diff --git a/.agents/skills/address-feedback/SKILL.md b/.agents/skills/address-feedback/SKILL.md index 92cc633df9..efefa868cc 100644 --- a/.agents/skills/address-feedback/SKILL.md +++ b/.agents/skills/address-feedback/SKILL.md @@ -55,7 +55,7 @@ Run what the change touches: - `bun typecheck`. - Affected package/app test suites (commands in each subdirectory's CLAUDE.md). - `bun run build:shared` when `packages/shared` changed, then re-run dependent suites. -- `bun run wire-contract:check` when shared endpoint types or the SDK surface changed; +- `bun run wire-contract:check` when shared endpoint types, the SDK surface, or API routes changed; if the change is intentional, `bun run wire-contract:update` and commit the snapshot diff with an explicit note on backward compatibility. diff --git a/.agents/skills/vortex-cleanup/SKILL.md b/.agents/skills/vortex-cleanup/SKILL.md new file mode 100644 index 0000000000..39595c2710 --- /dev/null +++ b/.agents/skills/vortex-cleanup/SKILL.md @@ -0,0 +1,83 @@ +--- +name: vortex-cleanup +description: Rules and procedure for over-engineering cleanups in this repository, such as ponytail audits and reviews (/ponytail:ponytail-audit, /ponytail:ponytail-review), dead-code and unused-dependency removal, and simplifying refactors. Use before auditing or applying any cleanup, so the run respects the Compatibility Contract and the scope decisions already made. +--- + +# Vortex cleanup rules + +Cleanup runs here follow the root `CLAUDE.md` **Compatibility Contract** first. The ponytail +plugin decides *what* looks over-engineered; this skill decides what may actually change +and how to prove nothing broke. When the two disagree, the contract wins. + +## Scope decisions (do not propose these again without new information) + +Excluded from cleanups by Marcel on 2026-10-01: + +- Duplicate ABI files (`apps/api/src/contracts`, `apps/frontend/src/contracts`, + `packages/shared/src/contracts`) and swaps to viem's `erc20Abi`. +- Storybook (`apps/frontend/.storybook`, `*.stories.tsx`), and any component a story + imports. +- The retired BRL↔AssetHub flows, the phases only they use, and their substrate branches. +- Generic `packages/shared` shrinking: endpoint placeholders, twin helpers, dead DTO types. +- The rebalancer's BlindPay shadow quotes, and gold demo mode. +- Hand-rolled sleeps and IP-range checks (stdlib/`node:net` swaps). + +Kept by the Compatibility Contract: mounted endpoints that nothing in this repo calls +(`/v1/siwe`, `/v1/storage`, `GET /v1/prices`), because external partners may call them. + +Deferred because the swap changes behaviour; each needs an explicit decision and a +migration plan, not a cleanup commit: + +- `body-parser` → `express.json()`: body-parser 2 leaves `req.body` undefined when nothing + was parsed. +- `joi` → zod: the email validation regexes differ. +- `method-override`: live, it honours `X-HTTP-Method-Override`. +- `dotenv`: the api also loads `../.env`; Bun only auto-loads from the working directory. +- ethers/siwe → viem: signed-transaction parsing and the SIWE nonce format. +- The swc build step: the Render start command depends on it. +- SDK ESLint → Biome: it enforces `.js` import extensions in the published ESM. +- node-forge → `node:crypto` for BRLA signing: node:crypto rejects PEM spellings forge + accepts, and the production key format is unverified. +- react-toastify → sonner, removing `input-otp`: UX changes. + +## Procedure + +1. **Base and isolation.** Work from `origin/staging` in a worktree. Re-check every finding + against the latest `staging` before integrating: staging can start using something you + deleted as dead. +2. **Audit.** Partition by workspace. Every delegated agent gets the Compatibility Contract + and this skill's scope table in its prompt: built-in Explore/Plan subagents do not load + `CLAUDE.md`. Finder-style passes can use the cheap tier (Claude Code: `sonnet`; Codex: + GPT-5.6-Luna); agents that edit code use the strong tier (Claude Code: session model; + Codex: GPT-5.6-Sol). +3. **Prove "dead" before deleting.** Zero references across `apps/`, `packages/`, + `contracts/`, `scripts/`, CI workflows, and package.json scripts. Include barrels, + string-keyed and dynamic use, lazy imports, TanStack file routes, dynamic i18n keys, + tsconfig `types`, side-effect imports, and stories. Usage only by tests means the + test usage goes too, never that the code is live. +4. **Dependencies.** Check imports, config files, CLI use in scripts, and peer requirements. + The frontend bundles `packages/shared` from source through its browser export, so + anything shared imports must stay resolvable from the frontend. The `bun.lock` diff may + only remove packages or move hoisting, never shift a consumer's resolved version. +5. **Refactors.** Characterization tests against the old code come first and stay. Keep + message strings, state keys, attempt classes, and log semantics that recovery or + operators rely on. If the result isn't smaller, or equivalence is uncertain, skip it + and say why. Skipping is fine; a silent behaviour change is not. +6. **Gates.** Before handing off: + - `bun run typecheck`, `bun run verify`, `bun run wire-contract:check`. + - The affected test suites. API tests need their own database: create + `vortex_test_` on `localhost:54329` and run with `TEST_DB_NAME`. The dashboard + uses `bun run test`, since plain `bun test` picks up its Playwright specs. + - `bun run build` for every affected app. + - Any change to the wire-contract snapshot needs a compatibility note in the PR. +7. **Commits and PR.** One conventional commit per concern, with tests in the same + commit, grouped by workspace. The PR targets `staging` and lists what was kept or + skipped and why. + +## Running parallel workstreams + +- Install with Bun's global cache (`bun install --frozen-lockfile`). Per-worktree caches + from `bun bootstrap:worktree` filled the disk when seven agents ran at once. +- Give each workstream exclusive ownership of the `package.json` files it edits. When + integrating by cherry-pick, resolve `bun.lock` conflicts by keeping the integration + side and re-running `bun install`. diff --git a/.agents/skills/vortex-integration/SKILL.md b/.agents/skills/vortex-integration/SKILL.md index ce4f0b9fe8..b2647b0902 100644 --- a/.agents/skills/vortex-integration/SKILL.md +++ b/.agents/skills/vortex-integration/SKILL.md @@ -19,12 +19,12 @@ A machine-loadable capability catalog for AI coding agents integrating Vortex in - `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 SDK accepts either a secret credential for its bound profile or an `accessTokenProvider` for that profile's renewable Supabase Bearer session; raw API clients may use the secret credential or that Supabase Bearer session directly. Provider identity (BRL tax ID, Alfredpay customer, or Monerium profile) is derived from the authenticated profile, never from request fields. Shared dummy/ownerless profiles are invalid. + - **Ramp registration requires an authenticated profile in every corridor.** The SDK accepts either a secret credential for its bound profile or an `accessTokenProvider` for that profile's renewable Supabase Bearer session; raw API clients may use the secret credential or that Supabase Bearer session directly. Provider identity (BRL tax ID, bank-transfer customer, or EUR provider profile) 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.) -- **Currently implemented SDK corridors**: BRL via PIX, USD via ACH, MXN via SPEI, COP via ACH, and ARS via CBU support BUY and SELL; EUR via SEPA (Monerium) supports BUY only. EUR BUY needs `walletAddress` (the user's Monerium-linked wallet, linked in the Dashboard or Widget) and the returned owner permit signed through `submitUserTransactions`. Supply `customerType` to select the same individual or business Monerium profile used at onboarding; it is required when both types are bound (`MONERIUM_CUSTOMER_TYPE_REQUIRED` otherwise). `MONERIUM_ONBOARDING_REQUIRED` / `MONERIUM_REAUTHENTICATION_REQUIRED` mean the user must (re)connect Monerium first. These corridors deliver to EVM networks only (no AssetHub). +- **Currently implemented SDK corridors**: BRL via PIX, USD via ACH, MXN via SPEI, COP via ACH, and ARS via CBU support BUY and SELL; EUR via SEPA supports BUY only, is available in sandbox with production activation pending, and needs the next `@vortexfi/sdk` release (0.9.0 has no EUR support; use the direct API until then). EUR BUY needs `walletAddress` (the wallet linked with the EUR provider in the Dashboard or Widget) and the returned owner permit signed through `submitUserTransactions`. Supply `customerType` to select the same individual or business EUR provider profile used at onboarding; it is required when both types are bound (`MONERIUM_CUSTOMER_TYPE_REQUIRED` otherwise). `MONERIUM_ONBOARDING_REQUIRED` / `MONERIUM_REAUTHENTICATION_REQUIRED` mean the user must (re)connect the EUR provider first. These corridors deliver to EVM networks only (no AssetHub). - **EUR currency value**: TypeScript uses the member `FiatToken.EURC`, which serializes to the wire value `"EUR"`. Raw JSON clients must send `"EUR"`, with `"sepa"` as the rail identifier. - **taxId is deprecated for BRL**: the user's tax ID is derived server-side from the authenticated profile. Sending a `taxId` that mismatches the derived one is rejected; stop sending it in new integrations. - **Deferred offramp funding**: the SDK checks the source wallet balance at `registerRamp` by default. Server integrations that register before funding a temporary wallet may configure `offrampFundingMode: "deferred"`. This skips only the SDK pre-flight; fund the exact `walletAddress` before signing/submitting user transactions, then update and start before the registration window expires. Backend execution-time balance checks remain authoritative. @@ -267,22 +267,23 @@ triggers: ## When to use The user wants to buy crypto with EUR and is already corridor-ready: an approved Vortex EUR provider binding, a live approved provider profile, exactly one existing Polygon EOA/IBAN destination, and access to that EOA for typed-data signing. Both individual and business legal entities may qualify. The active route delivers to supported EVM destinations, Polygon included. -Users become corridor-ready by completing Monerium OAuth onboarding in the Dashboard or Widget and linking the wallet they will pay in with (`POST /v1/monerium/wallet`); this flow does not cover onboarding, wallet linking, or IBAN provisioning. EUR SELL is unavailable. +EUR BUY is available in sandbox; production activation is pending. Users become corridor-ready by completing the EUR provider's OAuth onboarding in the Dashboard or Widget and linking the wallet they will pay in with (`POST /v1/monerium/wallet`); this flow does not cover onboarding, wallet linking, or IBAN provisioning. EUR SELL is unavailable. ## Prerequisites - Quote with TypeScript member `inputCurrency: FiatToken.EURC` (raw JSON value `"EUR"`), `from: "sepa"`, and a supported EVM destination. - A secret credential or Supabase session for the corridor-ready legal entity. -- `additionalData.destinationAddress`; do not submit profile, Monerium address, or IBAN identity. +- `additionalData.destinationAddress`; do not submit profile, provider address, or IBAN identity. - `additionalData.customerType` (`"individual"` or `"business"`) when the user owns both legal profiles; use the same type as onboarding and wallet linking. - A fresh EVM ephemeral key and a wallet-signing channel for the profile-linked Polygon owner. ## SDK recipe +Requires the next `@vortexfi/sdk` release; 0.9.0 has no EUR support. ```js -// walletAddress must be the wallet linked to the Monerium profile; a mismatch throws EurOnrampError. +// walletAddress must be the wallet linked to the EUR provider profile; a mismatch throws EurOnrampError. const { rampProcess, unsignedTransactions } = await vortex.registerRamp(quote, { customerType: "individual", destinationAddress: "0xDestinationWallet", - walletAddress: "0xMoneriumLinkedWallet" + walletAddress: "0xProviderLinkedWallet" }); // unsignedTransactions holds the owner's EIP-712 permit; the ephemeral txs are signed by the SDK. @@ -313,7 +314,7 @@ The permit expires one week after preparation. If SEPA settlement arrives after consumes its nonce, automatic execution stops for manual resolution. ## Common failures -- `400` approved-profile error: the effective legal entity has no approved local Monerium/EUR binding or the live provider profile is not approved. +- `400` approved-profile error: the effective legal entity has no approved local EUR provider binding or the live provider profile is not approved. - `409 MONERIUM_CUSTOMER_TYPE_REQUIRED`: both legal types are bound; repeat registration with the type used for wallet linking. - `409` expected-one-destination error: the profile does not have exactly one matching Polygon EOA/IBAN destination. Vortex does not create, select, or move one in this release. - Contract-wallet error: the linked mint destination must be an EOA for the ERC-2612 handoff. @@ -351,7 +352,7 @@ The user wants to ramp USD, MXN, COP, or ARS over their domestic banking rail. R ## Prerequisites - The user completed KYC for the corridor's country via the Vortex app or Widget, and the SDK is authenticated with that user's own `sk_*` key or Supabase session. - Buy: `destinationAddress` (required); `fiatAccountId`, `walletAddress` optional. -- Sell: `fiatAccountId` and `walletAddress` (both required). List saved accounts with `vortex.listAlfredpayFiatAccounts(country)`. +- Sell: `fiatAccountId` and `walletAddress` (both required). Verification does not create a pay-out account: the user adds one after verification (Dashboard **Add pay-out account**, the Widget, or `POST /v1/domestic/fiatAccounts`). List saved accounts with `vortex.listDomesticFiatAccounts(country)`. ## SDK recipe (onramp, MXN shown — substitute fiat + rail for USD/COP/ARS) ```js @@ -369,20 +370,35 @@ const { rampProcess } = await vortex.registerRamp(quote, { destinationAddress: "0xUserWalletAddress" }); -const started = await vortex.startRamp(rampProcess.id); +// Bank transfer instructions the user must pay arrive with the registered ramp +// (registerRamp performs the update that releases them); startRamp does not repeat them. +console.log(rampProcess.achPaymentData); -// Bank transfer instructions the user must pay are on the START response: -console.log(started.achPaymentData); +// After the user initiates the transfer, start before the ramp's expiresAt. +const started = await vortex.startRamp(rampProcess.id); ``` No user-signed on-chain transactions on buys. Unlike BRL there is no QR code — display the `achPaymentData` deposit instructions verbatim; the ramp continues automatically once the fiat deposit is confirmed. ## SDK recipe (offramp) ```js -const accounts = await vortex.listAlfredpayFiatAccounts("MEX"); +const sellQuote = await vortex.createQuote({ + rampType: RampDirection.SELL, + from: Networks.Polygon, + to: EPaymentMethod.SPEI, + network: Networks.Polygon, + inputAmount: "10", + inputCurrency: EvmToken.USDC, + outputCurrency: FiatToken.MXN +}); -const { rampProcess, unsignedTransactions } = await vortex.registerRamp(quote, { - fiatAccountId: accounts[0].id, +const accounts = await vortex.listDomesticFiatAccounts(DomesticCountry.MX); // DomesticCountry from @vortexfi/sdk +if (accounts.length === 0) { + throw new Error("Add a pay-out account for MX before selling"); +} + +const { rampProcess, unsignedTransactions } = await vortex.registerRamp(sellQuote, { + fiatAccountId: accounts[0].fiatAccountId, walletAddress: "0xUserWalletAddress" }); @@ -393,11 +409,11 @@ await vortex.submitUserTransactions(rampProcess.id, unsignedTransactions, { await vortex.startRamp(rampProcess.id); ``` -The SDK cannot **create** fiat accounts; they are created during onboarding in the Vortex app or Widget. `fiatAccountId` is opaque to the SDK. +The SDK cannot **create** fiat accounts, and verification does not create one either: the user adds it in the Dashboard (**Add pay-out account**) or Widget, or a server calls `POST /v1/domestic/fiatAccounts`. `fiatAccountId` is opaque to the SDK. ## Common failures -- `MissingAlfredpayOnrampParametersError` / `MissingAlfredpayOfframpParametersError` — `destinationAddress`, `fiatAccountId`, or `walletAddress` missing. -- `AlfredpayOnrampKycRequiredError` — the authenticated user has no approved KYC for the corridor's country. +- `MissingDomesticOnrampParametersError` / `MissingDomesticOfframpParametersError` — `destinationAddress`, `fiatAccountId`, or `walletAddress` missing. +- `DomesticOnrampKycRequiredError` — the authenticated user has no approved KYC for the corridor's country. - `400` "requires an API key linked to a user" on register — the supplied API credential or Bearer session is not bound to an eligible profile. Authenticate as the onboarded user or provision a managed profile and issue a credential for that explicit subject. - `InsufficientBalanceError` — in the default `"prefunded"` mode, the offramp pre-flight found the source wallet balance below the quote's input amount. A deliberate register-then-fund integration may use `offrampFundingMode: "deferred"`; it must fund before submitting user transactions and starting the ramp. @@ -695,7 +711,7 @@ try { ## Current corridor reality (August 2026) - **BRL via PIX**: onramp and offramp both live. `taxId` deprecated — derived from the user-linked key. -- **EUR via SEPA**: BUY is active (`FiatToken.EURC`, rail `"sepa"`) for an approved Monerium user with one Polygon EOA/IBAN destination, through the SDK (`walletAddress` + `submitUserTransactions` for the owner permit), the Widget, the Dashboard, and the direct API. Onboarding and wallet linking happen in the Dashboard or Widget. Destinations: any supported EVM network. SELL is unavailable. +- **EUR via SEPA**: BUY is available in sandbox, production activation pending (`FiatToken.EURC`, rail `"sepa"`), for an approved EUR provider user with one Polygon EOA/IBAN destination, through the SDK from its next release (`walletAddress` + `submitUserTransactions` for the owner permit), the Widget, the Dashboard, and the direct API. Onboarding and wallet linking happen in the Dashboard or Widget. Destinations: any supported EVM network. SELL is unavailable. - **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. - Live corridors deliver to EVM networks; AssetHub ramp execution is currently disabled. @@ -745,7 +761,7 @@ 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 | -| `AlfredpayOnrampKycRequiredError` | Bank-transfer-corridor KYC issue | Onboard or provision the credential's bound profile; do not retry programmatically | +| `DomesticOnrampKycRequiredError` | Bank-transfer-corridor KYC issue | Onboard or provision the credential's bound profile; do not retry programmatically | | Raw EUR registration `400` / `409` | Missing approved binding/profile or not exactly one Polygon EOA/IBAN match | Provision or reconcile the user out of band; do not submit caller-selected provider identity | | `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 | | `VortexSdkError` with `code === "provider_limit_exceeded"` | The provider account limit is exhausted | Stop retrying registration; wait for provider capacity to reset or contact Vortex support | diff --git a/.claude/skills/vortex-cleanup b/.claude/skills/vortex-cleanup new file mode 120000 index 0000000000..57ebae8b3b --- /dev/null +++ b/.claude/skills/vortex-cleanup @@ -0,0 +1 @@ +../../.agents/skills/vortex-cleanup \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31582ac4fe..8d3f629ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,6 @@ jobs: build: name: Running ci runs-on: ubuntu-latest - strategy: - matrix: - node-version: [ 18 ] env: CI: true @@ -103,3 +100,6 @@ jobs: - name: 🧪 Frontend tests (coverage-gated) run: cd apps/frontend && bun run test:coverage + + - name: 🧪 Gold tests + run: bun run test:gold diff --git a/CLAUDE.md b/CLAUDE.md index 4d4b618d41..ca60f3107d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,33 @@ Before finalizing, make a simplification pass. Remove speculative flexibility, d state, unnecessary branches, single-use helpers, and indirection that do not protect a demonstrated requirement. Keep the regression test that proves the leaner fix is safe. +## Compatibility Contract + +Every change, and especially every refactor or cleanup (including ponytail audits), must +keep existing integrators working. Simplification never justifies breaking them. + +- **HTTP API**: every route mounted from `apps/api/src/config/express.ts` keeps its path, + method, request validation, status codes, error codes/messages, and response bodies. + Never remove a mounted endpoint because nothing in this repo calls it; partners might. +- **SDK**: the exports of `packages/sdk/src/index.ts`, its emitted types, its runtime + behavior, and the HTTP requests it sends stay unchanged. Relaxing a requirement (for + example dropping an unused peer dependency) is fine. +- **`@vortexfi/shared`** is published to npm: never remove or change anything the SDK + source or its emitted types reference. Other removed exports need a version bump on the + next publish. +- **Persisted formats** stay readable: ramp state and its metadata, rebalancer state files, + browser storage keys, and webhook payloads. +- **Refactors** need tests that prove equivalence. Where existing tests don't, write + characterization tests against the old code first; if equivalence can't be shown, skip + the refactor. No exceptions in fund-moving code (`apps/api/src/api/services/phases/`, + `apps/rebalancer`). +- A **cleanup** must remove net code. Moving lines into a new abstraction is not cleanup. + +`bun run wire-contract:check` (CI) snapshots the shared endpoint types, the SDK surface, +and every mounted `METHOD /path`; a snapshot diff is a compatibility review, not a +formality. Cleanup and ponytail runs also follow +[`.agents/skills/vortex-cleanup/SKILL.md`](.agents/skills/vortex-cleanup/SKILL.md). + ## Testing These apply to every agent working in this repo: diff --git a/MAP.md b/MAP.md index cf40f66cbe..762b98f782 100644 --- a/MAP.md +++ b/MAP.md @@ -45,6 +45,6 @@ The full placement and lifecycle policy is in [`docs/README.md`](docs/README.md) |---|---| | `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). | +| `.agents/skills` | Purpose-built, repository-specific agent workflows (review, shipping, PR feedback, cleanup rules, 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/apps/api/.dockerignore b/apps/api/.dockerignore deleted file mode 100644 index 21945a5a6b..0000000000 --- a/apps/api/.dockerignore +++ /dev/null @@ -1,12 +0,0 @@ -node_modules/ -.git/ -.vscode/ -coverage/ - -.dockerignore -Dockerfile - -.gitignore -.eslintrc -.editorconfig -.gitlab-ci.yml diff --git a/apps/api/.env.example b/apps/api/.env.example index 2a73c31474..71fc7e818a 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -54,8 +54,6 @@ DB_SSL_REQUIRED=false DB_SSL_CA_CERT_PATH= # Blockchain -AMPLITUDE_WSS=wss://rpc-amplitude.pendulumchain.tech -PENDULUM_WSS=wss://rpc-pendulum.prd.pendulumchain.tech # Optional Substrate RPC overrides. Comma-separate multiple URLs for failover. ASSETHUB_WSS=wss://dot-rpc.stakeworld.io/assethub HYDRATION_WSS=wss://rpc.hydradx.cloud @@ -194,7 +192,6 @@ BRLA_API_KEY=your-brla-api-key BRLA_PRIVATE_KEY=your-brla-private-key # Integration test helpers (only required for phase-processor integration tests) -# BACKEND_TEST_STARTER_ACCOUNT= # TAX_ID= # External API contract tests (RUN_LIVE_TESTS=1, see docs/operations-testing.md). diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md index c7be262637..858821f671 100644 --- a/apps/api/CLAUDE.md +++ b/apps/api/CLAUDE.md @@ -15,8 +15,8 @@ architecture and commands. Run commands from `apps/api/` unless noted. ### Ramp state machine -The ramping process runs through defined phases; metadata and valid transitions live in -PostgreSQL, seeded via `bun seed:phase-metadata`. +The ramping process runs through defined phases; the phase flows and their valid transitions +live in code under `src/api/services/phases/`. - **Offramp**: prepareTransactions → squidRouter → pendulumFundEphemeral → subsidizePreSwap → nablaApprove → nablaSwap → subsidizePostSwap → performBrlaPayout → pendulumCleanup - **Onramp**: brlaTeleport → createMoonbeamEphemeral → executeMoonbeamToPendulumXCM → subsidizePreSwap → nablaApprove → nablaSwap → executePendulumToAssetHubXCM → pendulumCleanup @@ -31,7 +31,6 @@ bun test phase-processor.integration.test.ts --timeout X # integration test bun migrate # run migrations bun migrate:revert # revert ALL migrations (destructive — dev only) bun migrate:revert-last # revert last migration -bun seed:phase-metadata # seed phase configuration ``` Lint with the repo Biome config: from repo root `bun lint:fix`, or target a path with diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile deleted file mode 100644 index 8ce8ac6121..0000000000 --- a/apps/api/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:20.11.0 - -EXPOSE 3000 - -ARG NODE_ENV -ENV NODE_ENV $NODE_ENV - -RUN mkdir /app -WORKDIR /app -ADD package.json yarn.lock /app/ -RUN yarn --pure-lockfile - -# Copy .env file -COPY .env /app/.env - -ADD . /app - -CMD ["yarn", "start"] diff --git a/apps/api/README.md b/apps/api/README.md index 4ee0e9f029..7dc6333fa0 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -24,7 +24,6 @@ Run from `apps/api/`: ```bash bun migrate bun migrate:revert-last -bun seed:phase-metadata ``` Do not run bulk migration reverts against shared or production databases. The production diff --git a/apps/api/package.json b/apps/api/package.json index a7b090d573..79306574b1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,22 +1,15 @@ { "author": "Pendulum Chain", "dependencies": { - "@galacticcouncil/api-augment": "^0.8.1", - "@galacticcouncil/sdk": "^10.6.2", - "@paraspell/sdk-pjs": "^11.8.4", "@pendulum-chain/api-solang": "catalog:", "@polkadot/api": "catalog:", "@polkadot/api-contract": "catalog:", "@polkadot/keyring": "catalog:", "@polkadot/util": "catalog:", "@polkadot/util-crypto": "catalog:", - "@scure/bip39": "catalog:", "@supabase/supabase-js": "catalog:", "@types/multer": "^2.1.0", "@vortexfi/shared": "workspace:*", - "@wagmi/core": "catalog:", - - "bcrypt": "catalog:", "big.js": "catalog:", "body-parser": "^1.17.0", "compression": "^1.6.2", @@ -27,7 +20,6 @@ "ethers": "^6.13.4", "express": "^5.0.1", "express-rate-limit": "^6.7.0", - "express-validation": "^1.0.2", "google-auth-library": "^9.11.0", "google-spreadsheet": "^4.1.2", "helmet": "^4.6.0", @@ -41,13 +33,9 @@ "pg": "^8.14.1", "pg-hstore": "^2.3.4", "sequelize": "^6.37.6", - "sequelize-cli": "^6.6.2", "siwe": "^2.3.2", - "stellar-sdk": "catalog:", "umzug": "^3.8.2", - "uuid": "^11.1.0", "viem": "catalog:", - "web3": "^4.16.0", "winston": "^3.1.0", "zod": "catalog:" }, @@ -55,31 +43,19 @@ "devDependencies": { "@pendulum-chain/types": "catalog:", "@polkadot/types": "catalog:", - "@polkadot/types-augment": "catalog:", "@polkadot/types-codec": "catalog:", - "@polkadot/types-create": "catalog:", - "@polkadot/types-known": "catalog:", "@swc-node/register": "^1.10.10", "@swc/cli": "^0.5.2", "@swc/core": "^1.10.4", - "@types/bcrypt": "^5.0.2", "@types/big.js": "catalog:", "@types/body-parser": "^1.19.5", "@types/bun": "^1.3.1", "@types/compression": "^1.7.5", "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", - "@types/cron": "^2.4.3", "@types/express": "^5.0.0", "@types/method-override": "^3.0.0", "@types/morgan": "^1.9.9", - "@types/umzug": "^2.3.9", - "@types/uuid": "^10.0.0", - "ajv": "^8.17.1", - "concurrently": "catalog:", - "husky": "^9.1.7", - "nodemon": "^2.0.1", - "prettier": "catalog:", "typescript": "catalog:" }, "engines": { @@ -99,14 +75,12 @@ "preview:emails:watch": "bun --watch src/scripts/preview-emails.ts", "register:avenia-webhook": "bun src/scripts/register-avenia-webhook.ts", "seed:demo": "bun scripts/seed-demo-account.ts", - "seed:phase-metadata": "bun -r @swc-node/register src/database/seeders/phase-metadata.ts", "serve": "bun dist/index.js", "start": "bun run build && bun run serve", "test": "bun test --timeout 15000", "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/audit-avenia-entity-scope.sql b/apps/api/scripts/audit-avenia-entity-scope.sql deleted file mode 100644 index 6006c98b92..0000000000 --- a/apps/api/scripts/audit-avenia-entity-scope.sql +++ /dev/null @@ -1,144 +0,0 @@ --- Avenia entity-scope audit: sizes the population affected by single-entity ownership --- checks before deciding between profile-wide checks (code fix) and data re-homing. --- Read-only. Mirrors the type-less getOrCreateCustomerEntityForProfile resolution exactly: --- the profile's active entity when set (and owned), otherwise its oldest entity --- (created_at ASC, id ASC). A provider row on any other entity is invisible to the --- single-entity checks: its owner is denied (403 / "No completed Avenia profile found"). --- * Check 1 is the decision input: 0 means no CURRENT row is affected. --- * Check 2 must be 0 regardless (the resolver throws on it). --- * INFO checks size the populations where mismatches live or can re-appear. --- Execute with psql. - -\set ON_ERROR_STOP on -BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY; - --- Section 0: sanity — zeros here on a populated database mean RLS is filtering the role --- and every other result below is meaningless. -SELECT '0. sanity: provider_customers visible' AS check, count(*) AS rows FROM provider_customers -UNION ALL -SELECT '0. sanity: customer_entities visible', count(*) FROM customer_entities; - --- --------------------------------------------------------------------------- --- Section 1: the decision checks. --- --------------------------------------------------------------------------- - --- 1. Avenia rows the single-entity ownership check would deny their rightful owner. -SELECT '1. avenia rows invisible to single-entity checks (0 = no current victim)' AS check, count(*) AS rows -FROM provider_customers pc -JOIN customer_entities ce ON ce.id = pc.customer_entity_id -JOIN profiles p ON p.id = ce.profile_id -CROSS JOIN LATERAL ( - SELECT COALESCE( - (SELECT a.id FROM customer_entities a - WHERE a.id = p.active_customer_entity_id AND a.profile_id = p.id), - (SELECT o.id FROM customer_entities o - WHERE o.profile_id = p.id - ORDER BY o.created_at ASC, o.id ASC - LIMIT 1) - ) AS entity_id -) resolved -WHERE pc.provider = 'avenia' - AND pc.customer_entity_id <> resolved.entity_id - -UNION ALL - --- 2. Corrupt active-entity pointers (resolver throws ACTIVE_ENTITY_OWNERSHIP_MISMATCH). -SELECT '2. profiles whose active entity is not theirs (expect 0)', count(*) -FROM profiles p -WHERE p.active_customer_entity_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM customer_entities ce - WHERE ce.id = p.active_customer_entity_id AND ce.profile_id = p.id - ) - -UNION ALL - --- --------------------------------------------------------------------------- --- Section 2: INFO — populations where mismatches live or can re-appear. --- --------------------------------------------------------------------------- - --- 2a. Multi-entity profiles: the only population where a mismatch is possible. Any of --- these can later flip its resolved entity via active-entity selection (immutable once --- set), turning a today-visible row into a check-1 victim without any data change. -SELECT '2a. INFO profiles owning more than one entity', count(*) -FROM ( - SELECT ce.profile_id FROM customer_entities ce GROUP BY ce.profile_id HAVING count(*) > 1 -) multi - -UNION ALL - --- 2b. Migration-040 fold signature: avenia rows whose owning entity type differs from the --- row's customer_type (business rows folded onto the individual entity). -SELECT '2b. INFO avenia rows typed differently than their owning entity', count(*) -FROM provider_customers pc -JOIN customer_entities ce ON ce.id = pc.customer_entity_id -WHERE pc.provider = 'avenia' - AND pc.customer_type <> ce.type - -UNION ALL - --- 2c. Same as check 1 for the other providers (their services are entity-scoped too). -SELECT '2c. INFO non-avenia rows invisible to single-entity checks', count(*) -FROM provider_customers pc -JOIN customer_entities ce ON ce.id = pc.customer_entity_id -JOIN profiles p ON p.id = ce.profile_id -CROSS JOIN LATERAL ( - SELECT COALESCE( - (SELECT a.id FROM customer_entities a - WHERE a.id = p.active_customer_entity_id AND a.profile_id = p.id), - (SELECT o.id FROM customer_entities o - WHERE o.profile_id = p.id - ORDER BY o.created_at ASC, o.id ASC - LIMIT 1) - ) AS entity_id -) resolved -WHERE pc.provider <> 'avenia' - AND pc.customer_entity_id <> resolved.entity_id - -UNION ALL - --- 2d. Profiles owning several APPROVED avenia rows across entities with no active-entity --- selection to disambiguate: profile-wide resolution rejects these as ambiguous. -SELECT '2d. INFO profiles with >1 approved avenia row and no active-entity tiebreak', count(*) -FROM ( - SELECT ce.profile_id - FROM provider_customers pc - JOIN customer_entities ce ON ce.id = pc.customer_entity_id - JOIN profiles p ON p.id = ce.profile_id - WHERE pc.provider = 'avenia' AND pc.status = 'approved' - GROUP BY ce.profile_id, p.active_customer_entity_id - HAVING count(*) > 1 - AND count(*) FILTER (WHERE pc.customer_entity_id = p.active_customer_entity_id) <> 1 -) ambiguous; - --- --------------------------------------------------------------------------- --- Section 3: detail — every check-1 row by non-PII identifier. --- --------------------------------------------------------------------------- -SELECT - pc.id AS provider_customer_id, - pc.status, - pc.customer_type, - pc.provider_subaccount_id, - ce.id AS owning_entity_id, - ce.type AS owning_entity_type, - resolved.entity_id AS resolved_entity_id, - p.id AS profile_id, - p.active_customer_entity_id IS NOT NULL AS has_active_selection -FROM provider_customers pc -JOIN customer_entities ce ON ce.id = pc.customer_entity_id -JOIN profiles p ON p.id = ce.profile_id -CROSS JOIN LATERAL ( - SELECT COALESCE( - (SELECT a.id FROM customer_entities a - WHERE a.id = p.active_customer_entity_id AND a.profile_id = p.id), - (SELECT o.id FROM customer_entities o - WHERE o.profile_id = p.id - ORDER BY o.created_at ASC, o.id ASC - LIMIT 1) - ) AS entity_id -) resolved -WHERE pc.provider = 'avenia' - AND pc.customer_entity_id <> resolved.entity_id -ORDER BY pc.created_at; - -COMMIT; diff --git a/apps/api/scripts/export-unmigrated-avenia-customers.sql b/apps/api/scripts/export-unmigrated-avenia-customers.sql deleted file mode 100644 index d8df90599c..0000000000 --- a/apps/api/scripts/export-unmigrated-avenia-customers.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Export the result grid as CSV from the controlled database environment, then use it as --- input to reconcile-unmigrated-avenia-status.ts. This query does not expose raw CPF/CNPJ. --- The result is still restricted data because hashes, user IDs, and provider identifiers --- can be linked back to customers. - -BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY; - -WITH legacy_avenia AS ( - SELECT - encode( - sha256(convert_to(regexp_replace(t.tax_id, '[^0-9]', '', 'g'), 'UTF8')), - 'hex' - ) AS legacy_tax_hash, - t.user_id, - t.account_type::text AS account_type, - t.sub_account_id, - t.kyc_attempt, - t.internal_status::text AS legacy_status - FROM tax_ids t -) -SELECT - t.legacy_tax_hash, - CASE WHEN t.user_id IS NULL THEN 'OWNERLESS' ELSE 'USER_OWNED' END AS owner_status, - COALESCE(t.user_id::text, '') AS user_id, - t.account_type, - t.sub_account_id, - COALESCE(t.kyc_attempt, '') AS kyc_attempt, - COALESCE(t.legacy_status, '') AS legacy_status -FROM legacy_avenia t -WHERE COALESCE(t.sub_account_id, '') <> '' - AND NOT EXISTS ( - SELECT 1 - FROM provider_customers pc - WHERE pc.provider = 'avenia' - AND pc.tax_reference_hash = t.legacy_tax_hash - ) -ORDER BY t.user_id NULLS FIRST, t.legacy_tax_hash; - -ROLLBACK; diff --git a/apps/api/scripts/reconcile-unmigrated-avenia-status.ts b/apps/api/scripts/reconcile-unmigrated-avenia-status.ts deleted file mode 100644 index bd30436ddd..0000000000 --- a/apps/api/scripts/reconcile-unmigrated-avenia-status.ts +++ /dev/null @@ -1,430 +0,0 @@ -/** - * Compare legacy-only Avenia accounts exported by export-unmigrated-avenia-customers.sql - * with their current provider status. This script never opens a database connection. - * - * Usage: - * bun scripts/reconcile-unmigrated-avenia-status.ts \ - * --file /secure/path/unmigrated-avenia.csv \ - * --output /secure/path/unmigrated-avenia-status.csv - * - * BRLA_API_KEY, BRLA_PRIVATE_KEY, BRLA_BASE_URL, and SANDBOX_ENABLED are loaded from - * apps/api/.env in the same way as other backend scripts. The output contains provider - * identifiers and must be handled as restricted data. - */ -import { chmodSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import dotenv from "dotenv"; - -dotenv.config({ path: path.resolve(import.meta.dir, "../.env") }); - -const REQUIRED_HEADERS = [ - "legacy_tax_hash", - "owner_status", - "user_id", - "account_type", - "sub_account_id", - "kyc_attempt", - "legacy_status" -] as const; - -type InputRow = Record<(typeof REQUIRED_HEADERS)[number], string>; - -type Attempt = { - id: string; - status: string; - result?: string; - email?: string; - updatedAt?: string; - createdAt?: string; -}; - -type AveniaClient = { - subaccountInfo(subAccountId: string): Promise; - getKycAttempts(subAccountId: string): Promise; - getKybAttemptStatus(attemptId: string, subAccountId: string): Promise; -}; - -type Options = { - file: string; - output: string; - delayMs: number; - timeoutMs: number; - retries: number; -}; - -const OUTPUT_HEADERS = [ - ...REQUIRED_HEADERS, - "provider_account_type", - "identity_status", - "attempt_id", - "attempt_status", - "attempt_result", - "normalized_status", - "avenia_email", - "avenia_name", - "discrepancy", - "checked_at", - "error_status", - "error_code" -] as const; - -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 readPositiveInteger(name: string, fallback: number, allowZero = false): number { - const raw = readOption(name); - if (raw === undefined) return fallback; - - const value = Number(raw); - if (!Number.isInteger(value) || (allowZero ? value < 0 : value <= 0)) { - throw new Error(`${name} must be ${allowZero ? "a non-negative" : "a positive"} integer`); - } - return value; -} - -function parseOptions(): Options { - const file = readOption("--file"); - const output = readOption("--output"); - if (!file || !output) { - throw new Error("Usage: --file --output [--delay-ms 500] [--timeout-ms 20000] [--retries 2]"); - } - - const resolvedFile = path.resolve(file); - const resolvedOutput = path.resolve(output); - if (resolvedFile === resolvedOutput) throw new Error("Input and output paths must be different"); - - return { - delayMs: readPositiveInteger("--delay-ms", 500, true), - file: resolvedFile, - output: resolvedOutput, - retries: readPositiveInteger("--retries", 2, true), - timeoutMs: readPositiveInteger("--timeout-ms", 20_000) - }; -} - -export function parseCsv(input: string): string[][] { - const rows: string[][] = []; - let row: string[] = []; - let field = ""; - let quoted = false; - - for (let index = 0; index < input.length; index++) { - const character = input[index]; - if (quoted) { - if (character === '"' && input[index + 1] === '"') { - field += '"'; - index++; - } else if (character === '"') { - quoted = false; - } else { - field += character; - } - } else if (character === '"') { - if (field.length > 0) throw new Error("Invalid quote in CSV field"); - quoted = true; - } else if (character === ",") { - row.push(field); - field = ""; - } else if (character === "\n") { - row.push(field); - rows.push(row); - row = []; - field = ""; - } else if (character !== "\r") { - field += character; - } - } - - if (quoted) throw new Error("Unterminated quoted CSV field"); - if (field.length > 0 || row.length > 0) { - row.push(field); - rows.push(row); - } - return rows; -} - -export function readInputRows(input: string): InputRow[] { - const parsed = parseCsv(input.replace(/^\uFEFF/, "")); - const headers = parsed.shift(); - if (!headers) throw new Error("Input CSV is empty"); - - for (const required of REQUIRED_HEADERS) { - if (!headers.includes(required)) throw new Error(`Input CSV is missing required header: ${required}`); - } - - return parsed - .filter(values => values.some(value => value.length > 0)) - .map((values, rowIndex) => { - if (values.length !== headers.length) - throw new Error(`CSV row ${rowIndex + 2} has ${values.length} fields; expected ${headers.length}`); - const valuesByHeader = Object.fromEntries(headers.map((header, index) => [header, values[index]?.trim() ?? ""])); - const row = Object.fromEntries(REQUIRED_HEADERS.map(header => [header, valuesByHeader[header] ?? ""])) as InputRow; - validateInputRow(row, rowIndex + 2); - return row; - }); -} - -function validateInputRow(row: InputRow, rowNumber: number): void { - if (!/^[0-9a-f]{64}$/.test(row.legacy_tax_hash)) throw new Error(`CSV row ${rowNumber} has an invalid legacy_tax_hash`); - if (row.owner_status !== "OWNERLESS" && row.owner_status !== "USER_OWNED") { - throw new Error(`CSV row ${rowNumber} has an invalid owner_status`); - } - if (row.account_type !== "INDIVIDUAL" && row.account_type !== "COMPANY") { - throw new Error(`CSV row ${rowNumber} has an invalid account_type`); - } - if (!row.sub_account_id) throw new Error(`CSV row ${rowNumber} has no sub_account_id`); - if (row.kyc_attempt && !/^[A-Za-z0-9_-]+$/.test(row.kyc_attempt)) { - throw new Error(`CSV row ${rowNumber} has an unsafe kyc_attempt`); - } -} - -function csvCell(value: unknown): string { - const raw = value === undefined || value === null ? "" : String(value); - const text = /^[=+\-@]/.test(raw) ? `'${raw}` : raw; - return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; -} - -export function serializeCsv(rows: Record[]): string { - return `${OUTPUT_HEADERS.join(",")}\n${rows.map(row => OUTPUT_HEADERS.map(header => csvCell(row[header])).join(",")).join("\n")}\n`; -} - -function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null ? (value as Record) : undefined; -} - -function parseAccountInfo(value: unknown): { accountType: string; identityStatus: string; name: string } { - const accountInfo = asRecord(asRecord(value)?.accountInfo); - if ( - !accountInfo || - (accountInfo.accountType !== "INDIVIDUAL" && accountInfo.accountType !== "COMPANY") || - (accountInfo.identityStatus !== "NOT-IDENTIFIED" && accountInfo.identityStatus !== "CONFIRMED") - ) { - throw new Error("Avenia returned malformed account information"); - } - const individualName = typeof accountInfo.fullName === "string" ? accountInfo.fullName : ""; - const companyName = typeof accountInfo.name === "string" ? accountInfo.name : ""; - return { - accountType: accountInfo.accountType, - identityStatus: accountInfo.identityStatus, - name: (accountInfo.accountType === "COMPANY" ? companyName || individualName : individualName || companyName).trim() - }; -} - -function parseAttempt(value: unknown): Attempt { - const attempt = asRecord(value); - if ( - !attempt || - typeof attempt.id !== "string" || - !/^[A-Za-z0-9_-]+$/.test(attempt.id) || - !["PENDING", "PROCESSING", "COMPLETED", "EXPIRED"].includes(String(attempt.status)) || - (attempt.result !== undefined && attempt.result !== "APPROVED" && attempt.result !== "REJECTED") - ) { - throw new Error("Avenia returned a malformed verification attempt"); - } - const submissionData = asRecord(attempt.submissionData); - return { - createdAt: typeof attempt.createdAt === "string" ? attempt.createdAt : undefined, - email: typeof submissionData?.email === "string" ? submissionData.email.trim() : undefined, - id: attempt.id, - result: typeof attempt.result === "string" ? attempt.result : undefined, - status: String(attempt.status), - updatedAt: typeof attempt.updatedAt === "string" ? attempt.updatedAt : undefined - }; -} - -function parseIndividualAttempt(value: unknown): Attempt | undefined { - const attempts = asRecord(value)?.attempts; - if (!Array.isArray(attempts)) throw new Error("Avenia returned a malformed KYC attempt list"); - - return attempts.map(parseAttempt).sort((left, right) => { - const rightTime = Date.parse(right.updatedAt ?? right.createdAt ?? "") || 0; - const leftTime = Date.parse(left.updatedAt ?? left.createdAt ?? "") || 0; - return rightTime - leftTime; - })[0]; -} - -function parseCompanyAttempt(value: unknown, expectedId: string): Attempt { - const attempt = parseAttempt(asRecord(value)?.attempt); - if (attempt.id !== expectedId) throw new Error("Avenia returned a different KYB attempt ID"); - return attempt; -} - -export function normalizeStatus(identityStatus: string, attempt: Attempt | undefined, accountType: string): string { - if (identityStatus === "CONFIRMED") return "approved"; - if (!attempt) return "unknown_not_confirmed"; - if (attempt.status === "COMPLETED" && attempt.result === "APPROVED") return "approved"; - if (attempt.status === "COMPLETED" && attempt.result === "REJECTED") return "rejected"; - if (attempt.status === "PROCESSING") return "in_review"; - if (attempt.status === "PENDING") return "pending"; - if (attempt.status === "EXPIRED") return accountType === "COMPANY" ? "rejected" : "pending"; - return "unknown_not_confirmed"; -} - -function errorStatus(error: unknown): number | undefined { - const status = asRecord(error)?.status; - return typeof status === "number" ? status : undefined; -} - -function isAuthenticationError(error: unknown): boolean { - const status = errorStatus(error); - return status === 401 || status === 403 || (error instanceof Error && error.message === "Authorization error."); -} - -function isRetryable(error: unknown): boolean { - const status = errorStatus(error); - return status === 0 || status === 408 || status === 429 || (status !== undefined && status >= 500); -} - -function sleep(milliseconds: number): Promise { - return new Promise(resolve => setTimeout(resolve, milliseconds)); -} - -async function callWithRetry(operation: () => Promise, retries: number): Promise { - for (let attempt = 0; ; attempt++) { - try { - return await operation(); - } catch (error) { - if (isAuthenticationError(error) || !isRetryable(error) || attempt >= retries) throw error; - await sleep(500 * 2 ** attempt + Math.floor(Math.random() * 250)); - } - } -} - -export async function reconcileRow(row: InputRow, client: AveniaClient, retries: number): Promise> { - const checkedAt = new Date().toISOString(); - try { - const account = parseAccountInfo(await callWithRetry(() => client.subaccountInfo(row.sub_account_id), retries)); - let attempt: Attempt | undefined; - let discrepancy = account.accountType === row.account_type ? "" : "account_type_mismatch"; - let enrichmentErrorStatus: number | string = ""; - let enrichmentErrorCode = ""; - - if (account.identityStatus !== "CONFIRMED") { - if (row.account_type === "COMPANY") { - if (row.kyc_attempt) { - attempt = parseCompanyAttempt( - await callWithRetry(() => client.getKybAttemptStatus(row.kyc_attempt, row.sub_account_id), retries), - row.kyc_attempt - ); - } else { - discrepancy = [discrepancy, "missing_company_attempt"].filter(Boolean).join(";"); - } - } else { - attempt = parseIndividualAttempt(await callWithRetry(() => client.getKycAttempts(row.sub_account_id), retries)); - } - } else if (row.account_type === "INDIVIDUAL") { - try { - attempt = parseIndividualAttempt(await callWithRetry(() => client.getKycAttempts(row.sub_account_id), retries)); - } catch (error) { - if (isAuthenticationError(error)) throw error; - enrichmentErrorStatus = errorStatus(error) ?? ""; - enrichmentErrorCode = - error instanceof Error && error.message.startsWith("Avenia returned") - ? "basic_info_malformed_response" - : "basic_info_request_failed"; - } - } - - const normalizedStatus = normalizeStatus(account.identityStatus, attempt, row.account_type); - const includeBasicInfo = normalizedStatus === "approved" || normalizedStatus === "rejected"; - - return { - ...row, - attempt_id: attempt?.id ?? "", - attempt_result: attempt?.result ?? "", - attempt_status: attempt?.status ?? "", - avenia_email: includeBasicInfo ? (attempt?.email ?? "") : "", - avenia_name: includeBasicInfo ? account.name : "", - checked_at: checkedAt, - discrepancy, - error_code: enrichmentErrorCode, - error_status: enrichmentErrorStatus, - identity_status: account.identityStatus, - normalized_status: normalizedStatus, - provider_account_type: account.accountType - }; - } catch (error) { - if (isAuthenticationError(error)) throw error; - return { - ...row, - attempt_id: "", - attempt_result: "", - attempt_status: "", - avenia_email: "", - avenia_name: "", - checked_at: checkedAt, - discrepancy: "", - error_code: - error instanceof Error && error.message.startsWith("Avenia returned") - ? "malformed_response" - : "provider_request_failed", - error_status: errorStatus(error) ?? "", - identity_status: "", - normalized_status: "unresolved", - provider_account_type: "" - }; - } -} - -async function main(): Promise { - const options = parseOptions(); - if (!process.env.BRLA_API_KEY || !process.env.BRLA_PRIVATE_KEY) { - throw new Error("BRLA_API_KEY and BRLA_PRIVATE_KEY must be set in apps/api/.env"); - } - - const rows = readInputRows(readFileSync(options.file, "utf8")); - if (rows.length === 0) throw new Error("Input CSV contains no unmigrated Avenia rows"); - - const originalFetch = globalThis.fetch; - const nativeFetch = originalFetch.bind(globalThis); - globalThis.fetch = Object.assign( - (input: Parameters[0], init: Parameters[1] = {}) => - nativeFetch(input, { ...init, signal: AbortSignal.timeout(options.timeoutMs) }), - { preconnect: originalFetch.preconnect } - ); - - const { BRLA_BASE_URL, BrlaApiService, setLogger } = await import("@vortexfi/shared"); - const discardLog = (..._values: unknown[]) => undefined; - setLogger({ debug: discardLog, error: discardLog, info: discardLog, warn: discardLog }); - console.log(`Checking ${rows.length} legacy-only Avenia account(s) against ${new URL(BRLA_BASE_URL).origin}`); - - const client = BrlaApiService.getInstance(); - const results: Record[] = []; - for (const [index, row] of rows.entries()) { - results.push(await reconcileRow(row, client, options.retries)); - console.log(`Checked ${index + 1}/${rows.length}`); - if (options.delayMs > 0 && index < rows.length - 1) await sleep(options.delayMs); - } - - const temporaryOutput = `${options.output}.tmp-${process.pid}`; - try { - writeFileSync(temporaryOutput, serializeCsv(results), { encoding: "utf8", mode: 0o600 }); - renameSync(temporaryOutput, options.output); - chmodSync(options.output, 0o600); - } catch (error) { - try { - unlinkSync(temporaryOutput); - } catch { - // Preserve the original output-write failure. - } - throw error; - } - - const unresolved = results.filter(row => row.normalized_status === "unresolved").length; - const errors = results.filter(row => row.error_code).length; - console.log(`Wrote ${results.length} result(s) to ${options.output}; unresolved: ${unresolved}; errors: ${errors}`); - if (unresolved > 0 || errors > 0) process.exitCode = 2; -} - -if (import.meta.main) { - main().catch(error => { - console.error("Avenia reconciliation failed:", error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); -} diff --git a/apps/api/scripts/schema-parity-checks.sql b/apps/api/scripts/schema-parity-checks.sql deleted file mode 100644 index c47e91d64e..0000000000 --- a/apps/api/scripts/schema-parity-checks.sql +++ /dev/null @@ -1,508 +0,0 @@ --- Parity checks for the 038-049 schema migration and the migration 060 production gate. --- Run according to docs/operations-legacy-schema-cleanup.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. --- * The final MIGRATION GATE lists every blocking source row by non-PII identifier and --- raises an exception unless each eligible row has the exact canonical identity mapping. --- Execute with psql. ON_ERROR_STOP gives automation a non-zero exit on gate failure, while --- REPEATABLE READ guarantees that the report and final gate inspect one stable snapshot. - -\set ON_ERROR_STOP on -BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY; --- --- Section 0: sanity — if these return 0 on a database that has data, the readonly role --- is being filtered by row-level security (new tables have RLS enabled with no policies; --- a non-owner role needs BYPASSRLS) and every other result below is meaningless. - -SELECT '0. sanity: provider_customers visible' AS check, count(*) AS rows FROM provider_customers -UNION ALL -SELECT '0. sanity: customer_entities visible', count(*) FROM customer_entities -UNION ALL -SELECT '0. sanity: legacy mykobo_customers visible', count(*) FROM mykobo_customers -UNION ALL -SELECT '0. sanity: legacy tax_ids visible', count(*) FROM tax_ids; - --- --------------------------------------------------------------------------- --- Section 1: PARITY — every eligible legacy row must exist in the new schema. --- All counts must be 0. --- --------------------------------------------------------------------------- - --- 1a. Profiles without any customer entity (038 backfilled one per profile). -SELECT '1a. PARITY profiles missing customer_entity (expect 0)' AS check, count(*) AS rows -FROM profiles p -WHERE NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = p.id) - -UNION ALL - --- 1b. Mykobo: eligible = has an owning entity; verify the complete immutable mapping. -SELECT '1b. PARITY mykobo rows incorrectly mapped (expect 0)', count(*) -FROM mykobo_customers m -WHERE NOT EXISTS ( - SELECT 1 - FROM provider_customers pc - JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE pc.provider = 'mykobo' - AND pc.provider_customer_id = m.email - AND ce.profile_id = m.user_id - AND pc.rail = 'eur' - AND pc.country IS NULL - AND pc.customer_type = LOWER(m.type::text) -) - -UNION ALL - --- 1c. Alfredpay: eligible = latest row per (user, country, type); verify the complete --- immutable mapping. Older rows in each group remain in the approved-loss INFO bucket. -SELECT '1c. PARITY alfredpay rows incorrectly mapped (expect 0)', count(*) -FROM ( - SELECT DISTINCT ON (a.user_id, a.country, a.type) a.* - FROM alfredpay_customers a - ORDER BY a.user_id, a.country, a.type, a.updated_at DESC -) s -WHERE NOT EXISTS ( - SELECT 1 - FROM provider_customers pc - JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE pc.provider = 'alfredpay' - AND pc.provider_customer_id = s.alfred_pay_id - AND ce.profile_id = s.user_id - AND pc.rail IS NOT DISTINCT FROM CASE s.country::text - WHEN 'MX' THEN 'mxn' WHEN 'AR' THEN 'ars' WHEN 'CO' THEN 'cop' WHEN 'US' THEN 'usd' - WHEN 'BR' THEN 'brl' WHEN 'DO' THEN 'dop' WHEN 'CN' THEN 'cny' WHEN 'HK' THEN 'hkd' - WHEN 'CL' THEN 'clp' WHEN 'PE' THEN 'pen' WHEN 'BO' THEN 'bob' - END - AND pc.country = s.country::text - AND pc.customer_type = LOWER(s.type::text) -) - -UNION ALL - --- 1d. Avenia: eligible = owned tax_ids rows (user_id set); verify the complete immutable --- mapping. provider_subaccount_id and status are mutable after migration and are audited --- separately rather than treated as proof of a backfill defect. -SELECT '1d. PARITY owned tax_ids incorrectly mapped (expect 0)', count(*) -FROM tax_ids t -WHERE t.user_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM provider_customers pc - JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE pc.provider = 'avenia' - AND pc.tax_reference_hash = encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex') - AND pc.tax_reference = t.tax_id - AND ce.profile_id = t.user_id - AND pc.rail = 'brl' - AND pc.country = 'BR' - AND pc.customer_type = CASE t.account_type::text WHEN 'COMPANY' THEN 'business' ELSE 'individual' END - ) - -UNION ALL - --- 1e. Every migrated/created provider account has a KYC case (040 created one per row). -SELECT '1e. PARITY provider_customers without any kyc_case (expect 0)', count(*) -FROM provider_customers pc -WHERE NOT EXISTS (SELECT 1 FROM kyc_cases k WHERE k.provider_customer_id = pc.id) - -UNION ALL - --- 1f. Partner split: every legacy (name, ramp_type) pricing row survives as a pricing config --- on the canonical (folded-by-name) partner. -SELECT '1f. PARITY partners_legacy pricing rows missing a pricing config (expect 0)', count(*) -FROM (SELECT DISTINCT name, ramp_type FROM partners_legacy) pl -WHERE NOT EXISTS ( - SELECT 1 - FROM partners p - JOIN partner_pricing_configs cfg ON cfg.partner_id = p.id - WHERE p.name = pl.name AND cfg.ramp_type::text = pl.ramp_type::text -) - -UNION ALL - --- 1g. Duplicate customer entities per (profile, type) — migration 049's unique index --- should make this structurally impossible; 0 confirms the index is doing its job. -SELECT '1g. PARITY duplicate customer_entities per (profile,type) (expect 0)', count(*) -FROM ( - SELECT profile_id, type - FROM customer_entities - WHERE profile_id IS NOT NULL - GROUP BY profile_id, type - HAVING count(*) > 1 -) d; - --- --------------------------------------------------------------------------- --- Section 2: INFO — deliberately skipped / operationally interesting buckets. --- Non-zero is expected; the numbers tell you whether the skipped rows matter. --- --------------------------------------------------------------------------- - --- 2a. Quarantined unowned Avenia rows (never migrated by design). If any of these have a --- real subaccount, that user's KYC status is invisible to the new schema. -SELECT '2a. INFO tax_ids quarantined (user_id IS NULL)' AS check, count(*) AS rows -FROM tax_ids WHERE user_id IS NULL - -UNION ALL - -SELECT '2b. INFO quarantined rows that have a real subaccount', count(*) -FROM tax_ids WHERE user_id IS NULL AND COALESCE(sub_account_id, '') <> '' - -UNION ALL - --- 2c/2d. Legacy rows whose owner has no customer entity — the backfill JOIN silently --- dropped these. Should be 0 given 038 covered every profile; non-zero means the --- legacy user_id points at a deleted/foreign profile. -SELECT '2c. INFO mykobo rows whose owner has no entity', count(*) -FROM mykobo_customers m -WHERE NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = m.user_id) - -UNION ALL - -SELECT '2d. INFO alfredpay rows whose owner has no entity', count(*) -FROM alfredpay_customers a -WHERE NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = a.user_id) - -UNION ALL - -SELECT '2e. INFO owned tax_ids whose owner has no entity', count(*) -FROM tax_ids t -WHERE t.user_id IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = t.user_id) - -UNION ALL - --- 2f. Alfredpay duplicate folds: older rows per (user, country, type) superseded by the --- latest one. Informational — mirrors the runtime updatedAt-DESC semantics. -SELECT '2f. INFO alfredpay historical duplicates folded', count(*) -FROM alfredpay_customers a -WHERE EXISTS ( - SELECT 1 FROM alfredpay_customers b - WHERE b.user_id = a.user_id AND b.country = a.country AND b.type = a.type - AND b.updated_at > a.updated_at -) - -UNION ALL - --- 2g. Orphaned partner API keys (partner deleted/renamed before 041's backfill). These are --- revoked under the new validators; confirm nothing you rely on is in here. -SELECT '2g. INFO api_keys orphaned (partner_name set, partner_id NULL, active)', count(*) -FROM api_keys -WHERE partner_name IS NOT NULL AND partner_id IS NULL AND is_active - -UNION ALL - --- 2h. Explicitly approved for deletion with no conversion. -SELECT '2h. INFO kyc_level_2 rows intentionally not converted', count(*) -FROM kyc_level_2; - --- --------------------------------------------------------------------------- --- Section 3: STATUS DRIFT -- same account, different status between legacy and new. --- Legacy statuses are pushed through migration 045's canonicalization first, so a row --- listed here means the account genuinely changed state after the migration (the new --- schema is authoritative) -- or a mapping bug. Expected empty right after deploy. --- --------------------------------------------------------------------------- - -SELECT '3a. mykobo status drift' AS check, m.email AS key, m.status::text AS legacy_status, pc.status AS new_status -FROM mykobo_customers m -JOIN provider_customers pc ON pc.provider = 'mykobo' AND pc.provider_customer_id = m.email -WHERE CASE - WHEN m.status::text IN ('APPROVED', 'SUCCESS', 'Accepted') THEN 'approved' - WHEN m.status::text IN ('REJECTED', 'FAILED', 'Rejected') THEN 'rejected' - WHEN m.status::text IN ('USER_COMPLETED', 'VERIFYING', 'Requested', 'PENDING') THEN 'in_review' - ELSE 'pending' - END IS DISTINCT FROM pc.status; - -SELECT '3b. alfredpay status drift' AS check, s.alfred_pay_id AS key, s.status::text AS legacy_status, pc.status AS new_status -FROM ( - SELECT DISTINCT ON (a.user_id, a.country, a.type) a.* - FROM alfredpay_customers a - ORDER BY a.user_id, a.country, a.type, a.updated_at DESC -) s -JOIN provider_customers pc ON pc.provider = 'alfredpay' AND pc.provider_customer_id = s.alfred_pay_id -WHERE CASE - WHEN s.status::text IN ('APPROVED', 'SUCCESS') THEN 'approved' - WHEN s.status::text IN ('REJECTED', 'FAILED') THEN 'rejected' - WHEN s.status::text IN ('USER_COMPLETED', 'VERIFYING') THEN 'in_review' - WHEN s.status::text IN ('CONSULTED', 'LINK_OPENED', 'UPDATE_REQUIRED') THEN 'started' - WHEN s.status::text = 'PENDING' THEN 'in_review' - ELSE 'pending' - END IS DISTINCT FROM pc.status; - -SELECT '3c. avenia status drift' AS check, - repeat('*', GREATEST(length(pc.tax_reference) - 4, 0)) || right(pc.tax_reference, 4) AS key, - t.internal_status::text AS legacy_status, pc.status AS new_status -FROM tax_ids t -JOIN provider_customers pc - ON pc.provider = 'avenia' - AND pc.tax_reference_hash = encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex') -WHERE t.user_id IS NOT NULL - AND CASE - WHEN COALESCE(t.internal_status::text, 'Consulted') = 'Accepted' THEN 'approved' - WHEN COALESCE(t.internal_status::text, 'Consulted') = 'Rejected' THEN 'rejected' - WHEN COALESCE(t.internal_status::text, 'Consulted') = 'Requested' THEN 'in_review' - WHEN COALESCE(t.internal_status::text, 'Consulted') = 'Consulted' THEN 'started' - ELSE 'pending' - END IS DISTINCT FROM pc.status; - --- --------------------------------------------------------------------------- --- Section 4: MIGRATION GATE — exact legacy-to-canonical identity mapping. --- --- Run this section immediately before migration 060. It deliberately excludes approved-loss --- buckets (ownerless tax_ids, folded Alfredpay history, and all kyc_level_2 rows), which remain --- visible in Section 2. Any row returned below is an unapproved migration defect. Source IDs are --- UUIDs or one-way tax hashes; email addresses and raw tax IDs are not emitted. --- --------------------------------------------------------------------------- - -WITH alfredpay_source AS ( - SELECT DISTINCT ON (a.user_id, a.country, a.type) a.* - FROM alfredpay_customers a - ORDER BY a.user_id, a.country, a.type, a.updated_at DESC -), findings AS ( - SELECT - 'profile'::text AS source, - p.id::text AS source_id, - p.id AS expected_profile_id, - NULL::uuid AS canonical_provider_customer_id, - 'profile has no customer entity'::text AS issue - FROM profiles p - WHERE NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = p.id) - - UNION ALL - - SELECT - 'mykobo_customers', - m.id::text, - m.user_id, - pc.id, - CASE - WHEN NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = m.user_id) - THEN 'legacy owner has no customer entity' - WHEN pc.id IS NULL THEN 'canonical provider customer is missing' - WHEN ce.profile_id IS DISTINCT FROM m.user_id THEN 'canonical provider customer belongs to the wrong profile' - ELSE 'canonical rail, country, or customer type does not match migration 040' - END - FROM mykobo_customers m - LEFT JOIN provider_customers pc - ON pc.provider = 'mykobo' AND pc.provider_customer_id = m.email - LEFT JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE pc.id IS NULL - OR ce.profile_id IS DISTINCT FROM m.user_id - OR pc.rail IS DISTINCT FROM 'eur' - OR pc.country IS NOT NULL - OR pc.customer_type IS DISTINCT FROM LOWER(m.type::text) - - UNION ALL - - SELECT - 'alfredpay_customers', - STRING_AGG(a.id::text, ',' ORDER BY a.id), - a.user_id, - NULL::uuid, - 'multiple rows tie for latest updated_at; migration 040 selection cannot be proven deterministically' - FROM alfredpay_customers a - WHERE a.updated_at = ( - SELECT MAX(candidate.updated_at) - FROM alfredpay_customers candidate - WHERE candidate.user_id = a.user_id - AND candidate.country = a.country - AND candidate.type = a.type - ) - GROUP BY a.user_id, a.country, a.type, a.updated_at - HAVING COUNT(*) > 1 - - UNION ALL - - SELECT - 'alfredpay_customers', - s.id::text, - s.user_id, - pc.id, - CASE - WHEN NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = s.user_id) - THEN 'legacy owner has no customer entity' - WHEN pc.id IS NULL THEN 'canonical provider customer is missing' - WHEN ce.profile_id IS DISTINCT FROM s.user_id THEN 'canonical provider customer belongs to the wrong profile' - ELSE 'canonical rail, country, or customer type does not match migration 040' - END - FROM alfredpay_source s - LEFT JOIN provider_customers pc - ON pc.provider = 'alfredpay' AND pc.provider_customer_id = s.alfred_pay_id - LEFT JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE pc.id IS NULL - OR ce.profile_id IS DISTINCT FROM s.user_id - OR pc.rail IS DISTINCT FROM CASE s.country::text - WHEN 'MX' THEN 'mxn' WHEN 'AR' THEN 'ars' WHEN 'CO' THEN 'cop' WHEN 'US' THEN 'usd' - WHEN 'BR' THEN 'brl' WHEN 'DO' THEN 'dop' WHEN 'CN' THEN 'cny' WHEN 'HK' THEN 'hkd' - WHEN 'CL' THEN 'clp' WHEN 'PE' THEN 'pen' WHEN 'BO' THEN 'bob' - END - OR pc.country IS DISTINCT FROM s.country::text - OR pc.customer_type IS DISTINCT FROM LOWER(s.type::text) - - UNION ALL - - SELECT - 'tax_ids', - encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex'), - t.user_id, - pc.id, - CASE - WHEN NOT EXISTS (SELECT 1 FROM customer_entities owner_ce WHERE owner_ce.profile_id = t.user_id) - THEN 'legacy owner has no customer entity' - WHEN pc.id IS NULL THEN 'canonical provider customer is missing' - WHEN ce.profile_id IS DISTINCT FROM t.user_id THEN 'canonical provider customer belongs to the wrong profile' - ELSE 'canonical tax reference, rail, country, or customer type does not match migration 040' - END - FROM tax_ids t - LEFT JOIN provider_customers pc - ON pc.provider = 'avenia' - AND pc.tax_reference_hash = encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex') - LEFT JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE t.user_id IS NOT NULL - AND ( - pc.id IS NULL - OR pc.tax_reference IS DISTINCT FROM t.tax_id - OR ce.profile_id IS DISTINCT FROM t.user_id - OR pc.rail IS DISTINCT FROM 'brl' - OR pc.country IS DISTINCT FROM 'BR' - OR pc.customer_type IS DISTINCT FROM CASE t.account_type::text - WHEN 'COMPANY' THEN 'business' ELSE 'individual' - END - ) - - UNION ALL - - SELECT - 'provider_customers', - pc.id::text, - ce.profile_id, - pc.id, - 'canonical provider customer has no matching KYC case' - FROM provider_customers pc - JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE pc.provider IN ('mykobo', 'alfredpay', 'avenia') - AND NOT EXISTS ( - SELECT 1 - FROM kyc_cases k - WHERE k.provider_customer_id = pc.id - AND k.customer_entity_id = pc.customer_entity_id - AND k.provider = pc.provider - ) -) -SELECT source, source_id, expected_profile_id, canonical_provider_customer_id, issue -FROM findings -ORDER BY source, source_id; - --- Fail closed so unattended psql execution cannot mistake a result set for success. This is --- intentionally repeated instead of relying on client-side variables: the gate works in any --- PostgreSQL client that executes the complete file. -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM profiles p - WHERE NOT EXISTS (SELECT 1 FROM customer_entities ce WHERE ce.profile_id = p.id) - ) THEN - RAISE EXCEPTION 'MIGRATION GATE FAILED: profiles without customer entities exist; inspect Section 4'; - END IF; - - IF EXISTS ( - SELECT 1 - FROM mykobo_customers m - WHERE NOT EXISTS ( - SELECT 1 - FROM provider_customers pc - JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE pc.provider = 'mykobo' - AND pc.provider_customer_id = m.email - AND ce.profile_id = m.user_id - AND pc.rail = 'eur' - AND pc.country IS NULL - AND pc.customer_type = LOWER(m.type::text) - AND EXISTS ( - SELECT 1 FROM kyc_cases k - WHERE k.provider_customer_id = pc.id - AND k.customer_entity_id = pc.customer_entity_id - AND k.provider = pc.provider - ) - ) - ) THEN - RAISE EXCEPTION 'MIGRATION GATE FAILED: Mykobo migration defects exist; inspect Section 4'; - END IF; - - IF EXISTS ( - SELECT 1 - FROM alfredpay_customers a - WHERE a.updated_at = ( - SELECT MAX(candidate.updated_at) - FROM alfredpay_customers candidate - WHERE candidate.user_id = a.user_id - AND candidate.country = a.country - AND candidate.type = a.type - ) - GROUP BY a.user_id, a.country, a.type, a.updated_at - HAVING COUNT(*) > 1 - ) THEN - RAISE EXCEPTION 'MIGRATION GATE FAILED: ambiguous Alfredpay latest-row ties exist; inspect Section 4'; - END IF; - - IF EXISTS ( - WITH source AS ( - SELECT DISTINCT ON (a.user_id, a.country, a.type) a.* - FROM alfredpay_customers a - ORDER BY a.user_id, a.country, a.type, a.updated_at DESC - ) - SELECT 1 - FROM source s - WHERE NOT EXISTS ( - SELECT 1 - FROM provider_customers pc - JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE pc.provider = 'alfredpay' - AND pc.provider_customer_id = s.alfred_pay_id - AND ce.profile_id = s.user_id - AND pc.rail IS NOT DISTINCT FROM CASE s.country::text - WHEN 'MX' THEN 'mxn' WHEN 'AR' THEN 'ars' WHEN 'CO' THEN 'cop' WHEN 'US' THEN 'usd' - WHEN 'BR' THEN 'brl' WHEN 'DO' THEN 'dop' WHEN 'CN' THEN 'cny' WHEN 'HK' THEN 'hkd' - WHEN 'CL' THEN 'clp' WHEN 'PE' THEN 'pen' WHEN 'BO' THEN 'bob' - END - AND pc.country = s.country::text - AND pc.customer_type = LOWER(s.type::text) - AND EXISTS ( - SELECT 1 FROM kyc_cases k - WHERE k.provider_customer_id = pc.id - AND k.customer_entity_id = pc.customer_entity_id - AND k.provider = pc.provider - ) - ) - ) THEN - RAISE EXCEPTION 'MIGRATION GATE FAILED: Alfredpay migration defects exist; inspect Section 4'; - END IF; - - IF EXISTS ( - SELECT 1 - FROM tax_ids t - WHERE t.user_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM provider_customers pc - JOIN customer_entities ce ON ce.id = pc.customer_entity_id - WHERE pc.provider = 'avenia' - AND pc.tax_reference_hash = encode(sha256(convert_to(t.tax_id, 'UTF8')), 'hex') - AND pc.tax_reference = t.tax_id - AND ce.profile_id = t.user_id - AND pc.rail = 'brl' - AND pc.country = 'BR' - AND pc.customer_type = CASE t.account_type::text WHEN 'COMPANY' THEN 'business' ELSE 'individual' END - AND EXISTS ( - SELECT 1 FROM kyc_cases k - WHERE k.provider_customer_id = pc.id - AND k.customer_entity_id = pc.customer_entity_id - AND k.provider = pc.provider - ) - ) - ) THEN - RAISE EXCEPTION 'MIGRATION GATE FAILED: Avenia migration defects exist; inspect Section 4'; - END IF; - - RAISE NOTICE 'MIGRATION GATE PASSED: every eligible legacy provider row has the exact canonical identity mapping and KYC case'; -END -$$; - -COMMIT; diff --git a/apps/api/scripts/timeout-initial-ramps.ts b/apps/api/scripts/timeout-initial-ramps.ts deleted file mode 100644 index e286260ba5..0000000000 --- a/apps/api/scripts/timeout-initial-ramps.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * 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-console/accounts.controller.ts b/apps/api/src/api/controllers/admin-console/accounts.controller.ts index b985dc0dee..67f90b43f1 100644 --- a/apps/api/src/api/controllers/admin-console/accounts.controller.ts +++ b/apps/api/src/api/controllers/admin-console/accounts.controller.ts @@ -11,11 +11,12 @@ import ManagedProfileManager from "../../../models/managedProfileManager.model"; import ProfilePartnerAssignment from "../../../models/profilePartnerAssignment.model"; import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; import User from "../../../models/user.model"; +import { sendError } from "../../helpers/sendError"; +import { UUID_PATTERN } from "../../helpers/uuid"; import { isSessionActive } from "../../services/impersonation.service"; const DEFAULT_LIMIT = 25; const MAX_LIMIT = 100; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function clampLimit(value: unknown): number { const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN; @@ -154,9 +155,7 @@ export async function listAccounts(req: Request, res: Response): Promise { }); } catch (error) { logger.error("Error listing admin-console accounts:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to list accounts", status: httpStatus.INTERNAL_SERVER_ERROR } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to list accounts"); } } @@ -170,17 +169,13 @@ export async function getAccount(req: Request<{ profileId: string }>, res: Respo try { const { profileId } = req.params; if (!UUID_PATTERN.test(profileId)) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { code: "INVALID_PROFILE_ID", message: "profileId must be a valid UUID", status: httpStatus.BAD_REQUEST } - }); + sendError(res, httpStatus.BAD_REQUEST, "INVALID_PROFILE_ID", "profileId must be a valid UUID"); return; } const profile = await User.findByPk(profileId); if (!profile) { - res.status(httpStatus.NOT_FOUND).json({ - error: { code: "USER_NOT_FOUND", message: "Profile was not found", status: httpStatus.NOT_FOUND } - }); + sendError(res, httpStatus.NOT_FOUND, "USER_NOT_FOUND", "Profile was not found"); return; } @@ -283,8 +278,6 @@ export async function getAccount(req: Request<{ profileId: string }>, res: Respo }); } catch (error) { logger.error("Error reading admin-console account detail:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { code: "INTERNAL_SERVER_ERROR", message: "Failed to read account", status: httpStatus.INTERNAL_SERVER_ERROR } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to read account"); } } diff --git a/apps/api/src/api/controllers/admin-console/impersonation.controller.ts b/apps/api/src/api/controllers/admin-console/impersonation.controller.ts index 6d9aa3ab02..8e655f0eb2 100644 --- a/apps/api/src/api/controllers/admin-console/impersonation.controller.ts +++ b/apps/api/src/api/controllers/admin-console/impersonation.controller.ts @@ -3,6 +3,8 @@ import httpStatus from "http-status"; import logger from "../../../config/logger"; import AdminImpersonationSession from "../../../models/adminImpersonationSession.model"; import User from "../../../models/user.model"; +import { sendError } from "../../helpers/sendError"; +import { UUID_PATTERN } from "../../helpers/uuid"; import { impersonationNotAllowedResponse } from "../../middlewares/bearerPrincipal"; import { hasVortexAdminRole, vortexAdminRequiredResponse } from "../../middlewares/vortexAdminAuth"; import { buildApiClientRequestMetadata, observeApiClientEvent } from "../../observability/apiClientEvent.service"; @@ -17,8 +19,6 @@ import { revokeSession } from "../../services/impersonation.service"; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - /** * POST /v1/admin-console/impersonation * Mints an impersonation session for the calling vortex_admin. `req.userId` is that operator: @@ -30,13 +30,7 @@ export async function createImpersonationSession(req: Request, res: Response): P const { targetProfileId } = req.body ?? {}; if (typeof targetProfileId !== "string" || !UUID_PATTERN.test(targetProfileId)) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_IMPERSONATION_INPUT", - message: "targetProfileId must be a valid UUID", - status: httpStatus.BAD_REQUEST - } - }); + sendError(res, httpStatus.BAD_REQUEST, "INVALID_IMPERSONATION_INPUT", "targetProfileId must be a valid UUID"); return; } @@ -78,26 +72,16 @@ export async function createImpersonationSession(req: Request, res: Response): P status: "failure", userId: actorProfileId }); - res.status(httpStatus.SERVICE_UNAVAILABLE).json({ - error: { code: "IMPERSONATION_DISABLED", message: error.message, status: httpStatus.SERVICE_UNAVAILABLE } - }); + sendError(res, httpStatus.SERVICE_UNAVAILABLE, "IMPERSONATION_DISABLED", error.message); return; } if (error instanceof ImpersonationTargetError) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { code: "IMPERSONATION_TARGET_INVALID", message: error.message, status: httpStatus.BAD_REQUEST } - }); + sendError(res, httpStatus.BAD_REQUEST, "IMPERSONATION_TARGET_INVALID", error.message); return; } logger.error("Error creating impersonation session:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to create impersonation session", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to create impersonation session"); } } @@ -132,13 +116,7 @@ export async function listImpersonationSessions(req: Request, res: Response): Pr }); } catch (error) { logger.error("Error listing impersonation sessions:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to list impersonation sessions", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to list impersonation sessions"); } } @@ -152,13 +130,7 @@ export async function deleteImpersonationSession(req: Request<{ sessionId: strin try { const { sessionId } = req.params; if (!UUID_PATTERN.test(sessionId)) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_IMPERSONATION_SESSION_ID", - message: "sessionId must be a valid UUID", - status: httpStatus.BAD_REQUEST - } - }); + sendError(res, httpStatus.BAD_REQUEST, "INVALID_IMPERSONATION_SESSION_ID", "sessionId must be a valid UUID"); return; } @@ -179,13 +151,12 @@ export async function deleteImpersonationSession(req: Request<{ sessionId: strin const revoked = session ? await revokeSession(sessionId, isSelfRevoke ? "ended_by_target" : "revoked_by_admin") : false; if (!revoked || !session) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "IMPERSONATION_SESSION_NOT_FOUND", - message: "Impersonation session was not found or already ended", - status: httpStatus.NOT_FOUND - } - }); + sendError( + res, + httpStatus.NOT_FOUND, + "IMPERSONATION_SESSION_NOT_FOUND", + "Impersonation session was not found or already ended" + ); return; } @@ -206,12 +177,6 @@ export async function deleteImpersonationSession(req: Request<{ sessionId: strin res.status(httpStatus.NO_CONTENT).send(); } catch (error) { logger.error("Error ending impersonation session:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to end impersonation session", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to end impersonation session"); } } diff --git a/apps/api/src/api/controllers/admin/apiClientEvents.controller.ts b/apps/api/src/api/controllers/admin/apiClientEvents.controller.ts index 9da8c1f2e7..5149a3904a 100644 --- a/apps/api/src/api/controllers/admin/apiClientEvents.controller.ts +++ b/apps/api/src/api/controllers/admin/apiClientEvents.controller.ts @@ -3,6 +3,7 @@ import httpStatus from "http-status"; import { Op, WhereOptions } from "sequelize"; import logger from "../../../config/logger"; import ApiClientEvent, { ApiClientEventAttributes } from "../../../models/apiClientEvent.model"; +import { sendError } from "../../helpers/sendError"; import { ApiClientErrorType, ApiClientEventStatus, ApiClientOperation } from "../../observability/types"; type ApiClientEventsQuery = { @@ -146,12 +147,6 @@ export async function listApiClientEvents( }); } catch (error) { logger.error("Error listing API client events:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to list API client events", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to list API client events"); } } diff --git a/apps/api/src/api/controllers/admin/managedProfileManagers.controller.ts b/apps/api/src/api/controllers/admin/managedProfileManagers.controller.ts index f8d526d664..6e9fcfb605 100644 --- a/apps/api/src/api/controllers/admin/managedProfileManagers.controller.ts +++ b/apps/api/src/api/controllers/admin/managedProfileManagers.controller.ts @@ -3,6 +3,8 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../../config/logger"; import { CUSTOMER_ENTITY_TYPES } from "../../../models/customerEntity.model"; +import { sendError } from "../../helpers/sendError"; +import { UUID_PATTERN } from "../../helpers/uuid"; import { createManagedProfile, ManagedProfileLifecycleError } from "../../services/managed-profile-lifecycle.service"; import { configureManagedProfileManager, @@ -12,7 +14,6 @@ import { import { ManagedProfileProvisioningError } from "../../services/managed-profile-provisioning.service"; const SUPPORTED_CORRIDORS = Object.keys(CORRIDOR_CAPABILITIES) as CorridorCountry[]; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function isCorridorCountry(value: unknown): value is CorridorCountry { return typeof value === "string" && SUPPORTED_CORRIDORS.includes(value as CorridorCountry); @@ -37,13 +38,12 @@ export async function putManagedProfileManager(req: Request<{ profileId: string !hasValidCustomerTypes || typeof isActive !== "boolean" ) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_MANAGED_PROFILE_MANAGER_INPUT", - message: `profileId must be a UUID, isActive must be a boolean, allowedCorridors must be a non-empty duplicate-free array containing ${SUPPORTED_CORRIDORS.join(", ")}, and allowedCustomerTypes must be null or a non-empty duplicate-free array containing individual and/or business`, - status: httpStatus.BAD_REQUEST - } - }); + sendError( + res, + httpStatus.BAD_REQUEST, + "INVALID_MANAGED_PROFILE_MANAGER_INPUT", + `profileId must be a UUID, isActive must be a boolean, allowedCorridors must be a non-empty duplicate-free array containing ${SUPPORTED_CORRIDORS.join(", ")}, and allowedCustomerTypes must be null or a non-empty duplicate-free array containing individual and/or business` + ); return; } @@ -57,50 +57,30 @@ export async function putManagedProfileManager(req: Request<{ profileId: string } catch (error) { if (error instanceof ManagedProfileManagerError) { const status = error.code === "PROFILE_NOT_FOUND" ? httpStatus.NOT_FOUND : httpStatus.CONFLICT; - res.status(status).json({ error: { code: error.code, message: error.message, status } }); + sendError(res, status, error.code, error.message); return; } logger.error("Error configuring managed profile manager:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to configure managed profile manager", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to configure managed profile manager"); } } export async function readManagedProfileManager(req: Request<{ profileId: string }>, res: Response): Promise { try { if (!UUID_PATTERN.test(req.params.profileId)) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_MANAGED_PROFILE_MANAGER_INPUT", - message: "profileId must be a UUID", - status: httpStatus.BAD_REQUEST - } - }); + sendError(res, httpStatus.BAD_REQUEST, "INVALID_MANAGED_PROFILE_MANAGER_INPUT", "profileId must be a UUID"); return; } res.status(httpStatus.OK).json({ manager: await getManagedProfileManager(req.params.profileId) }); } catch (error) { if (error instanceof ManagedProfileManagerError) { - res.status(httpStatus.NOT_FOUND).json({ - error: { code: error.code, message: error.message, status: httpStatus.NOT_FOUND } - }); + sendError(res, httpStatus.NOT_FOUND, error.code, error.message); return; } logger.error("Error reading managed profile manager:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to read managed profile manager", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to read managed profile manager"); } } @@ -115,14 +95,12 @@ export async function postManagedProfileForManager(req: Request<{ profileId: str typeof contactEmail !== "string" || !CUSTOMER_ENTITY_TYPES.includes(customerType) ) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "MANAGED_PROFILE_INVALID_INPUT", - message: - "profileId must be a UUID, and contactEmail, externalSubjectId (1-255 characters), and customerType (individual|business) are required", - status: httpStatus.BAD_REQUEST - } - }); + sendError( + res, + httpStatus.BAD_REQUEST, + "MANAGED_PROFILE_INVALID_INPUT", + "profileId must be a UUID, and contactEmail, externalSubjectId (1-255 characters), and customerType (individual|business) are required" + ); return; } @@ -145,17 +123,11 @@ export async function postManagedProfileForManager(req: Request<{ profileId: str : error.code === "MANAGED_PROFILE_MANAGER_NOT_FOUND" || error.code === "MANAGED_PROFILE_NOT_FOUND" ? httpStatus.NOT_FOUND : httpStatus.CONFLICT; - res.status(status).json({ error: { code: error.code, message: error.message, status } }); + sendError(res, status, error.code, error.message); return; } logger.error("Error provisioning headless managed profile:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to provision managed profile", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to provision managed profile"); } } diff --git a/apps/api/src/api/controllers/admin/managedProfiles.controller.ts b/apps/api/src/api/controllers/admin/managedProfiles.controller.ts index 06a5a723a7..48a80cc9f5 100644 --- a/apps/api/src/api/controllers/admin/managedProfiles.controller.ts +++ b/apps/api/src/api/controllers/admin/managedProfiles.controller.ts @@ -2,6 +2,7 @@ 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 { sendError } from "../../helpers/sendError"; import { createManagedProfile, ManagedProfileServiceError } from "../../services/managed-profile.service"; function isSubjectType(value: unknown): value is ManagedProfileSubjectType { @@ -16,13 +17,12 @@ export async function postManagedProfile(req: Request, res: Response): Promise { try { const { @@ -32,14 +32,12 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi typeof fallbackAddress !== "string" || (feeBps !== undefined && typeof feeBps !== "number") ) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "MONERIUM_B2B_INVALID_INPUT", - message: - "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; feeBps must be a number when present", - status: httpStatus.BAD_REQUEST - } - }); + sendError( + res, + httpStatus.BAD_REQUEST, + "MONERIUM_B2B_INVALID_INPUT", + "managerProfileId (UUID), moneriumProfileId, externalSubjectId (1-255 characters), contactEmail, forwarderAddress, destination, and fallbackAddress are required; feeBps must be a number when present" + ); return; } @@ -57,7 +55,7 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi } catch (error) { if (error instanceof MoneriumB2bProvisioningError) { const status = error.code === "MONERIUM_B2B_INVALID_INPUT" ? httpStatus.BAD_REQUEST : httpStatus.CONFLICT; - res.status(status).json({ error: { code: error.code, message: error.message, status } }); + sendError(res, status, error.code, error.message); return; } if (error instanceof ManagedProfileProvisioningError) { @@ -67,18 +65,12 @@ export async function postMoneriumB2bAccount(req: Request, res: Response): Promi : error.code === "MANAGED_PROFILE_MANAGER_NOT_FOUND" ? httpStatus.NOT_FOUND : httpStatus.BAD_REQUEST; - res.status(status).json({ error: { code: error.code, message: error.message, status } }); + sendError(res, status, error.code, error.message); return; } logger.error("Error provisioning Monerium B2B account:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to provision Monerium B2B account", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to provision Monerium B2B account"); } } @@ -94,44 +86,39 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st try { const { status } = req.body ?? {}; if (!UUID_PATTERN.test(req.params.accountId) || typeof status !== "string" || !STATUS_VALUES.includes(status)) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "MONERIUM_B2B_INVALID_INPUT", - message: `accountId must be a UUID and status must be one of ${STATUS_VALUES.join(", ")}`, - status: httpStatus.BAD_REQUEST - } - }); + sendError( + res, + httpStatus.BAD_REQUEST, + "MONERIUM_B2B_INVALID_INPUT", + `accountId must be a UUID and status must be one of ${STATUS_VALUES.join(", ")}` + ); return; } const account = await MoneriumAccount.findByPk(req.params.accountId); if (!account) { - res.status(httpStatus.NOT_FOUND).json({ - error: { code: "MONERIUM_B2B_ACCOUNT_NOT_FOUND", message: "Monerium account not found", status: httpStatus.NOT_FOUND } - }); + sendError(res, httpStatus.NOT_FOUND, "MONERIUM_B2B_ACCOUNT_NOT_FOUND", "Monerium account not found"); return; } const targetStatus = status as MoneriumAccountStatus; if (targetStatus !== account.status && !STATUS_TRANSITIONS[account.status].includes(targetStatus)) { - res.status(httpStatus.CONFLICT).json({ - error: { - code: "MONERIUM_B2B_INVALID_STATUS_TRANSITION", - message: `Monerium account cannot transition from ${account.status} to ${targetStatus}`, - status: httpStatus.CONFLICT - } - }); + sendError( + res, + httpStatus.CONFLICT, + "MONERIUM_B2B_INVALID_STATUS_TRANSITION", + `Monerium account cannot transition from ${account.status} to ${targetStatus}` + ); return; } // Activation requires the issued IBAN: the penny test (runbook §7) cannot have // happened without it, and the association monitor needs the reference state. if (status === MoneriumAccountStatus.Active && account.iban === null) { - res.status(httpStatus.CONFLICT).json({ - error: { - code: "MONERIUM_B2B_ACCOUNT_NOT_READY", - message: "The account has no issued IBAN yet and cannot be activated", - status: httpStatus.CONFLICT - } - }); + sendError( + res, + httpStatus.CONFLICT, + "MONERIUM_B2B_ACCOUNT_NOT_READY", + "The account has no issued IBAN yet and cannot be activated" + ); return; } @@ -141,12 +128,6 @@ export async function patchMoneriumB2bAccountStatus(req: Request<{ accountId: st res.status(httpStatus.OK).json({ account: { accountId: account.id, accountStatus: account.status } }); } catch (error) { logger.error("Error updating Monerium B2B account status:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to update Monerium B2B account status", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to update Monerium B2B account status"); } } diff --git a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts index fc3d6f2f79..dae70c46f2 100644 --- a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts @@ -4,6 +4,7 @@ import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import Partner from "../../../models/partner.model"; import User from "../../../models/user.model"; +import { sendError } from "../../helpers/sendError"; import { ApiCredentialServiceError, createCredential, @@ -14,27 +15,25 @@ import { 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 } }); + sendError(res, 404, "PARTNER_NOT_FOUND", "Partner was not found"); 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 } - }); + sendError(res, 400, "CREDENTIAL_SUBJECT_REQUIRED", "userId profile subject is required"); 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 } }); + sendError(res, 404, "CREDENTIAL_SUBJECT_REQUIRED", "Profile was not found"); return null; } return { partner, profileId: userId }; } -function sendError(res: Response, error: unknown): boolean { +function sendCredentialError(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 } }); + sendError(res, status, error.code, error.message); return true; } @@ -51,9 +50,9 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R }); res.status(httpStatus.CREATED).json(credential); } catch (error) { - if (sendError(res, error)) return; + if (sendCredentialError(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 } }); + sendError(res, 500, "INTERNAL_SERVER_ERROR", "Failed to create API credential"); } } @@ -69,7 +68,7 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re }); } catch (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 } }); + sendError(res, 500, "INTERNAL_SERVER_ERROR", "Failed to list API credentials"); } } @@ -80,8 +79,8 @@ export async function revokeApiKey(req: Request<{ credentialId: string; partnerN await revokeCredential(req.params.credentialId, { partnerId: subject.partner.id, profileId: subject.profileId }); res.status(httpStatus.NO_CONTENT).send(); } catch (error) { - if (sendError(res, error)) return; + if (sendCredentialError(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 } }); + sendError(res, 500, "INTERNAL_SERVER_ERROR", "Failed to revoke API credential"); } } diff --git a/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.ts b/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.ts index 63eb1a4f0a..e0d05fe52f 100644 --- a/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerPricingConfigs.controller.ts @@ -6,6 +6,7 @@ import logger from "../../../config/logger"; import Partner from "../../../models/partner.model"; import PartnerPricingConfig from "../../../models/partnerPricingConfig.model"; import QuoteTicket from "../../../models/quoteTicket.model"; +import { sendError } from "../../helpers/sendError"; const FEE_TYPES = new Set(["absolute", "relative", "none"]); const FIAT_CURRENCIES = new Set(Object.values(FiatToken)); @@ -19,13 +20,7 @@ const NUMERIC_FIELDS = [ ] as const; function invalidInput(res: Response, message: string): void { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_PRICING_CONFIG_INPUT", - message, - status: httpStatus.BAD_REQUEST - } - }); + sendError(res, httpStatus.BAD_REQUEST, "INVALID_PRICING_CONFIG_INPUT", message); } function serializePricingConfig(config: PartnerPricingConfig, partnerName: string) { @@ -119,13 +114,7 @@ export async function createPartnerPricingConfig(req: Request, res: Response): P }); 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 - } - }); + sendError(res, httpStatus.NOT_FOUND, "PARTNER_NOT_FOUND", `No active partners found with name: ${partnerName}`); return; } @@ -172,24 +161,17 @@ export async function createPartnerPricingConfig(req: Request, res: Response): P }); } catch (error) { if (error instanceof UniqueConstraintError) { - res.status(httpStatus.CONFLICT).json({ - error: { - code: "PRICING_CONFIG_CONFLICT", - message: "A pricing config already exists for this partner, ramp type and fiat-currency scope. Delete it first.", - status: httpStatus.CONFLICT - } - }); + sendError( + res, + httpStatus.CONFLICT, + "PRICING_CONFIG_CONFLICT", + "A pricing config already exists for this partner, ramp type and fiat-currency scope. Delete it first." + ); return; } logger.error("Error creating partner pricing config:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to create partner pricing config", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to create partner pricing config"); } } @@ -199,13 +181,7 @@ export async function deletePartnerPricingConfig(req: Request<{ configId: string const config = await PartnerPricingConfig.findByPk(configId); if (!config) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "PRICING_CONFIG_NOT_FOUND", - message: "Partner pricing config was not found", - status: httpStatus.NOT_FOUND - } - }); + sendError(res, httpStatus.NOT_FOUND, "PRICING_CONFIG_NOT_FOUND", "Partner pricing config was not found"); return; } @@ -213,13 +189,12 @@ export async function deletePartnerPricingConfig(req: Request<{ configId: string // platform-wide fallback for fees and discounts, and deleting it breaks every quote. const partner = await Partner.findByPk(config.partnerId); if (partner?.name === "vortex" && config.fiatCurrency === null) { - res.status(httpStatus.CONFLICT).json({ - error: { - code: "VORTEX_CONFIG_PROTECTED", - message: "The default vortex wildcard pricing config cannot be deleted.", - status: httpStatus.CONFLICT - } - }); + sendError( + res, + httpStatus.CONFLICT, + "VORTEX_CONFIG_PROTECTED", + "The default vortex wildcard pricing config cannot be deleted." + ); return; } @@ -236,13 +211,12 @@ export async function deletePartnerPricingConfig(req: Request<{ configId: string } }); if (pendingQuotes > 0) { - res.status(httpStatus.CONFLICT).json({ - error: { - code: "PRICING_CONFIG_IN_USE", - message: `${pendingQuotes} pending quote(s) still reference this partner's pricing; retry after they expire`, - status: httpStatus.CONFLICT - } - }); + sendError( + res, + httpStatus.CONFLICT, + "PRICING_CONFIG_IN_USE", + `${pendingQuotes} pending quote(s) still reference this partner's pricing; retry after they expire` + ); return; } @@ -252,12 +226,6 @@ export async function deletePartnerPricingConfig(req: Request<{ configId: string res.status(httpStatus.NO_CONTENT).send(); } catch (error) { logger.error("Error deleting partner pricing config:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to delete partner pricing config", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to delete partner pricing config"); } } diff --git a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts index 16280a2343..36faae2f88 100644 --- a/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts +++ b/apps/api/src/api/controllers/admin/profilePartnerAssignments.controller.ts @@ -6,6 +6,7 @@ import logger from "../../../config/logger"; import Partner from "../../../models/partner.model"; import ProfilePartnerAssignment, { ProfilePartnerAssignmentAttributes } from "../../../models/profilePartnerAssignment.model"; import User from "../../../models/user.model"; +import { sendError } from "../../helpers/sendError"; const PROFILE_NOT_FOUND_AFTER_LOCK = "PROFILE_NOT_FOUND_AFTER_LOCK"; @@ -44,25 +45,13 @@ export async function createProfilePartnerAssignment(req: Request, res: Response const { userId, partnerName, expiresAt } = req.body; if (!userId || typeof userId !== "string" || !partnerName || typeof partnerName !== "string") { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_ASSIGNMENT_INPUT", - message: "userId and partnerName are required string fields", - status: httpStatus.BAD_REQUEST - } - }); + sendError(res, httpStatus.BAD_REQUEST, "INVALID_ASSIGNMENT_INPUT", "userId and partnerName are required string fields"); 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 - } - }); + sendError(res, httpStatus.NOT_FOUND, "USER_NOT_FOUND", "Profile was not found"); return; } @@ -74,13 +63,7 @@ export async function createProfilePartnerAssignment(req: Request, res: Response }); 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 - } - }); + sendError(res, httpStatus.NOT_FOUND, "PARTNER_NOT_FOUND", `No active partners found with name: ${partnerName}`); return; } @@ -124,46 +107,27 @@ export async function createProfilePartnerAssignment(req: Request, res: Response }); } catch (error) { if (error instanceof Error && error.message.startsWith("expiresAt")) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_EXPIRES_AT", - message: error.message, - status: httpStatus.BAD_REQUEST - } - }); + sendError(res, httpStatus.BAD_REQUEST, "INVALID_EXPIRES_AT", error.message); return; } if (error instanceof Error && error.message === PROFILE_NOT_FOUND_AFTER_LOCK) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "USER_NOT_FOUND", - message: "Profile was not found", - status: httpStatus.NOT_FOUND - } - }); + sendError(res, httpStatus.NOT_FOUND, "USER_NOT_FOUND", "Profile was not found"); return; } if (error instanceof UniqueConstraintError) { - res.status(httpStatus.CONFLICT).json({ - error: { - code: "ASSIGNMENT_CONFLICT", - message: "An active assignment already exists for this profile. Please retry the request.", - status: httpStatus.CONFLICT - } - }); + sendError( + res, + httpStatus.CONFLICT, + "ASSIGNMENT_CONFLICT", + "An active assignment already exists for this profile. Please retry the request." + ); return; } logger.error("Error creating profile partner assignment:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to create profile partner assignment", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to create profile partner assignment"); } } @@ -194,13 +158,7 @@ export async function listProfilePartnerAssignments( }); } catch (error) { logger.error("Error listing profile partner assignments:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to list profile partner assignments", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to list profile partner assignments"); } } @@ -210,13 +168,7 @@ export async function revokeProfilePartnerAssignment(req: Request<{ assignmentId const assignment = await ProfilePartnerAssignment.findByPk(assignmentId); if (!assignment) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "ASSIGNMENT_NOT_FOUND", - message: "Profile partner assignment was not found", - status: httpStatus.NOT_FOUND - } - }); + sendError(res, httpStatus.NOT_FOUND, "ASSIGNMENT_NOT_FOUND", "Profile partner assignment was not found"); return; } @@ -224,12 +176,6 @@ export async function revokeProfilePartnerAssignment(req: Request<{ assignmentId res.status(httpStatus.NO_CONTENT).send(); } catch (error) { logger.error("Error revoking profile partner assignment:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to revoke profile partner assignment", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to revoke profile partner assignment"); } } diff --git a/apps/api/src/api/controllers/admin/profileRoles.controller.ts b/apps/api/src/api/controllers/admin/profileRoles.controller.ts index 9fbc82447d..77fe765eb8 100644 --- a/apps/api/src/api/controllers/admin/profileRoles.controller.ts +++ b/apps/api/src/api/controllers/admin/profileRoles.controller.ts @@ -9,13 +9,13 @@ import ProfileRole, { type ProfileRoleName } from "../../../models/profileRole.model"; import User from "../../../models/user.model"; +import { sendError } from "../../helpers/sendError"; +import { UUID_PATTERN } from "../../helpers/uuid"; function isProfileRoleName(role: unknown): role is ProfileRoleName { return typeof role === "string" && (PROFILE_ROLE_NAMES as string[]).includes(role); } -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - /** Admins address profiles by id or by email (unique on profiles) interchangeably. */ async function findProfile(identifier: string): Promise { return UUID_PATTERN.test(identifier) ? User.findByPk(identifier) : User.findOne({ where: { email: identifier } }); @@ -27,36 +27,28 @@ export async function addProfileRole(req: Request, res: Response): Promise const identifier = userId ?? email; if (!identifier || typeof identifier !== "string" || !isProfileRoleName(role)) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_ROLE_INPUT", - message: `userId or email is required and role must be one of: ${PROFILE_ROLE_NAMES.join(", ")}`, - status: httpStatus.BAD_REQUEST - } - }); + sendError( + res, + httpStatus.BAD_REQUEST, + "INVALID_ROLE_INPUT", + `userId or email is required and role must be one of: ${PROFILE_ROLE_NAMES.join(", ")}` + ); return; } if (!HTTP_GRANTABLE_PROFILE_ROLES.includes(role)) { - res.status(httpStatus.FORBIDDEN).json({ - error: { - code: "ROLE_NOT_HTTP_GRANTABLE", - message: `${role} must be granted out-of-band (see scripts/grant-vortex-admin.ts), not via this endpoint`, - status: httpStatus.FORBIDDEN - } - }); + sendError( + res, + httpStatus.FORBIDDEN, + "ROLE_NOT_HTTP_GRANTABLE", + `${role} must be granted out-of-band (see scripts/grant-vortex-admin.ts), not via this endpoint` + ); return; } const user = await findProfile(identifier); if (!user) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "USER_NOT_FOUND", - message: "Profile was not found", - status: httpStatus.NOT_FOUND - } - }); + sendError(res, httpStatus.NOT_FOUND, "USER_NOT_FOUND", "Profile was not found"); return; } @@ -76,13 +68,7 @@ export async function addProfileRole(req: Request, res: Response): Promise }); } catch (error) { logger.error("Error adding profile role:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to add profile role", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to add profile role"); } } @@ -91,13 +77,7 @@ export async function removeProfileRole(req: Request<{ userIdOrEmail: string; ro const { userIdOrEmail, role } = req.params; if (!isProfileRoleName(role)) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_ROLE_INPUT", - message: `role must be one of: ${PROFILE_ROLE_NAMES.join(", ")}`, - status: httpStatus.BAD_REQUEST - } - }); + sendError(res, httpStatus.BAD_REQUEST, "INVALID_ROLE_INPUT", `role must be one of: ${PROFILE_ROLE_NAMES.join(", ")}`); return; } @@ -118,25 +98,13 @@ export async function removeProfileRole(req: Request<{ userIdOrEmail: string; ro }) : 0; if (!deleted) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "ROLE_NOT_FOUND", - message: "The profile does not have this role", - status: httpStatus.NOT_FOUND - } - }); + sendError(res, httpStatus.NOT_FOUND, "ROLE_NOT_FOUND", "The profile does not have this role"); return; } res.status(httpStatus.NO_CONTENT).send(); } catch (error) { logger.error("Error removing profile role:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to remove profile role", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to remove profile role"); } } diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 7fa04159d7..264b6adeef 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -85,9 +85,6 @@ import { resolveAveniaAccountForUser } from "../services/avenia-account"; import { findCustomerEntityIdsForProfile, getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; import { runFinancialOperation } from "../services/phases/blocks/core/financial-operation"; -// map from subaccountId → last interaction timestamp. Used for fetching the last relevant kyc event. -const _lastInteractionMap = new Map(); - // Helper function to use in the catch block of the controller functions. function handleApiError(error: unknown, res: Response, apiMethod: string): void { logger.error(`Error while performing ${apiMethod}: `, error); diff --git a/apps/api/src/api/controllers/managedProfiles.controller.ts b/apps/api/src/api/controllers/managedProfiles.controller.ts index 9a2081fdcf..8f702492bd 100644 --- a/apps/api/src/api/controllers/managedProfiles.controller.ts +++ b/apps/api/src/api/controllers/managedProfiles.controller.ts @@ -5,6 +5,8 @@ import { config } from "../../config/vars"; import { CUSTOMER_ENTITY_TYPES } from "../../models/customerEntity.model"; import type { ManagedProfileStatus } from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; +import { sendError } from "../helpers/sendError"; +import { UUID_PATTERN } from "../helpers/uuid"; import { getAuthenticatedProfileId } from "../middlewares/effectiveUser"; import { ApiCredentialServiceError, @@ -21,14 +23,13 @@ import { } from "../services/managed-profile-lifecycle.service"; import { ManagedProfileProvisioningError } from "../services/managed-profile-provisioning.service"; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function managerProfileId(req: Request): string { const profileId = getAuthenticatedProfileId(req); if (!profileId) throw new ManagedProfileLifecycleError("MANAGED_PROFILE_ACCESS_DENIED", "Authentication is required"); return profileId; } -function sendError(res: Response, error: unknown): void { +function sendLifecycleError(res: Response, error: unknown): void { if (error instanceof ApiCredentialServiceError) { const status = error.code === "CREDENTIAL_ACCESS_DENIED" @@ -38,7 +39,7 @@ function sendError(res: Response, error: unknown): void { : error.code === "CREDENTIAL_LIMIT_REACHED" ? httpStatus.CONFLICT : httpStatus.BAD_REQUEST; - res.status(status).json({ error: { code: error.code, message: error.message, status } }); + sendError(res, status, error.code, error.message); return; } if (error instanceof ManagedProfileLifecycleError || error instanceof ManagedProfileProvisioningError) { @@ -50,17 +51,16 @@ function sendError(res: Response, error: unknown): void { : error.code === "MANAGED_PROFILE_CONFLICT" ? httpStatus.CONFLICT : httpStatus.FORBIDDEN; - res.status(status).json({ error: { code: error.code, message: error.message, status } }); + sendError(res, status, error.code, error.message); return; } logger.error("Error handling managed profile lifecycle request", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to process managed profile lifecycle request", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError( + res, + httpStatus.INTERNAL_SERVER_ERROR, + "INTERNAL_SERVER_ERROR", + "Failed to process managed profile lifecycle request" + ); } function requireProfileId(profileId: string): void { @@ -94,7 +94,7 @@ export async function postManagedProfile(req: Request, res: Response): Promise, re const managedProfile = await getManagedProfile(managerProfileId(req), req.params.profileId); res.status(httpStatus.OK).json({ managedProfile }); } catch (error) { - sendError(res, error); + sendLifecycleError(res, error); } } @@ -156,7 +156,7 @@ export async function removeManagedProfile(req: Request<{ profileId: string }>, await deleteManagedProfile(managerProfileId(req), req.params.profileId); res.status(httpStatus.NO_CONTENT).send(); } catch (error) { - sendError(res, error); + sendLifecycleError(res, error); } } @@ -172,7 +172,7 @@ export async function postManagedProfileApiCredential(req: Request<{ profileId: }); res.status(httpStatus.CREATED).json(credential); } catch (error) { - sendError(res, error); + sendLifecycleError(res, error); } } @@ -182,7 +182,7 @@ export async function readManagedProfileApiCredentials(req: Request<{ profileId: const credentials = await listManagedProfileCredentials(managerProfileId(req), req.params.profileId); res.status(httpStatus.OK).json({ credentials }); } catch (error) { - sendError(res, error); + sendLifecycleError(res, error); } } @@ -198,6 +198,6 @@ export async function removeManagedProfileApiCredential( await revokeManagedProfileCredential(managerProfileId(req), req.params.profileId, req.params.credentialId); res.status(httpStatus.NO_CONTENT).send(); } catch (error) { - sendError(res, error); + sendLifecycleError(res, error); } } diff --git a/apps/api/src/api/controllers/monerium-b2b.controller.ts b/apps/api/src/api/controllers/monerium-b2b.controller.ts index 7edfe47378..13a48f67fb 100644 --- a/apps/api/src/api/controllers/monerium-b2b.controller.ts +++ b/apps/api/src/api/controllers/monerium-b2b.controller.ts @@ -8,6 +8,7 @@ import MoneriumConversionExecution from "../../models/moneriumConversionExecutio import MoneriumDepositAllocation from "../../models/moneriumDepositAllocation.model"; import MoneriumFiatDeposit from "../../models/moneriumFiatDeposit.model"; import { APIError } from "../errors/api-error"; +import { sendError } from "../helpers/sendError"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { processMoneriumWebhookInbox } from "../services/monerium-b2b/deposit-processor"; import { UNATTRIBUTED_ORDER_PREFIX } from "../services/monerium-b2b/mint-watcher"; @@ -70,13 +71,7 @@ async function findAccountForEffectiveUser(req: Request): Promise) => { - const evmClientManager = EvmClientManager.getInstance(); - const moonbeamClient = evmClientManager.getClient(Networks.Moonbeam); - const walletClient = evmClientManager.getWalletClient(Networks.Moonbeam, executorAccount); - - return { moonbeamClient, walletClient }; -}; - -export const executeXcmController = async ( - req: Request, - res: Response -): Promise => { - const { id, payload } = req.body; - - try { - const moonbeamExecutorAccount = privateKeyToAccount(config.secrets.moonbeamExecutorPrivateKey as `0x${string}`); - const { moonbeamClient } = createClients(moonbeamExecutorAccount); - const evmClientManager = EvmClientManager.getInstance(); - - const data = encodeFunctionData({ - abi: splitReceiverABI, - args: [id, payload], - functionName: "executeXCM" - }); - - try { - const { maxFeePerGas, maxPriorityFeePerGas } = await moonbeamClient.estimateFeesPerGas(); - // Safe to send multiple times. Idempotent. - const hash = (await evmClientManager.sendTransactionWithBlindRetry(Networks.Moonbeam, moonbeamExecutorAccount, { - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: MOONBEAM_RECEIVER_CONTRACT_ADDRESS, - value: 0n - })) as `0x${string}`; - res.json({ hash }); - return; - } catch (error) { - logger.error("Error executing XCM:", error); - res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid transaction" }); - return; - } - } catch (error) { - logger.error("Error executing XCM:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: "Internal Server Error" }); - } -}; - export const sendStatusWithPk = async (): Promise => { let moonbeamExecutorAccount; diff --git a/apps/api/src/api/controllers/notifications.controller.ts b/apps/api/src/api/controllers/notifications.controller.ts index 8be520a6fc..97a0293a78 100644 --- a/apps/api/src/api/controllers/notifications.controller.ts +++ b/apps/api/src/api/controllers/notifications.controller.ts @@ -3,12 +3,9 @@ import httpStatus from "http-status"; import { Op } from "sequelize"; import logger from "../../config/logger"; import Notification from "../../models/notification.model"; +import { sendError } from "../helpers/sendError"; import { getOrCreateNotificationPreferences } from "../services/notifications/notification.service"; -function sendError(res: Response, status: number, code: string, message: string): void { - res.status(status).json({ error: { code, message, status } }); -} - function requireUserId(req: Request, res: Response): string | null { if (!req.userId) { sendError(res, httpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "Authentication required"); diff --git a/apps/api/src/api/controllers/onboarding.controller.ts b/apps/api/src/api/controllers/onboarding.controller.ts index f3445da826..9567f94532 100644 --- a/apps/api/src/api/controllers/onboarding.controller.ts +++ b/apps/api/src/api/controllers/onboarding.controller.ts @@ -15,6 +15,7 @@ import ProfileRole from "../../models/profileRole.model"; import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; import User from "../../models/user.model"; import { APIError } from "../errors/api-error"; +import { sendError } from "../helpers/sendError"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { refreshAlfredpayCustomerStatus } from "../services/alfredpay/alfredpay-customer.service"; import { @@ -43,36 +44,33 @@ export function getOnboardingRequirements(req: Request, res: Response): void { const customerType = typeof req.query.customerType === "string" ? req.query.customerType.toLowerCase() : ""; if (!country || (customerType !== "individual" && customerType !== "business")) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_ONBOARDING_REQUIREMENTS_QUERY", - message: "country and customerType (individual or business) are required", - status: httpStatus.BAD_REQUEST - } - }); + sendError( + res, + httpStatus.BAD_REQUEST, + "INVALID_ONBOARDING_REQUIREMENTS_QUERY", + "country and customerType (individual or business) are required" + ); return; } if (!(country in ONBOARDING_REQUIREMENTS)) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "ONBOARDING_REQUIREMENTS_NOT_FOUND", - message: `No API-driven onboarding requirements are published for ${country} ${customerType}`, - status: httpStatus.NOT_FOUND - } - }); + sendError( + res, + httpStatus.NOT_FOUND, + "ONBOARDING_REQUIREMENTS_NOT_FOUND", + `No API-driven onboarding requirements are published for ${country} ${customerType}` + ); return; } const requirements = findOnboardingRequirements(country as OnboardingRequirementsCountry, customerType); if (!requirements) { - res.status(httpStatus.NOT_FOUND).json({ - error: { - code: "ONBOARDING_REQUIREMENTS_NOT_FOUND", - message: `No API-driven onboarding requirements are published for ${country} ${customerType}`, - status: httpStatus.NOT_FOUND - } - }); + sendError( + res, + httpStatus.NOT_FOUND, + "ONBOARDING_REQUIREMENTS_NOT_FOUND", + `No API-driven onboarding requirements are published for ${country} ${customerType}` + ); return; } @@ -101,9 +99,7 @@ function shouldRefreshProviderStatus(customerId: string): boolean { export async function getOnboardingStatus(req: Request, res: Response): Promise { const userId = getEffectiveUserId(req); if (!userId) { - res.status(httpStatus.UNAUTHORIZED).json({ - error: { code: "AUTHENTICATION_REQUIRED", message: "Authentication required", status: httpStatus.UNAUTHORIZED } - }); + sendError(res, httpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "Authentication required"); return; } @@ -397,33 +393,19 @@ export async function getOnboardingStatus(req: Request, res: Response): Promise< }); } catch (error) { logger.error("Error aggregating onboarding status:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "INTERNAL_SERVER_ERROR", - message: "Failed to read onboarding status", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to read onboarding status"); } } export async function putActiveEntity(req: Request, res: Response): Promise { if (!req.userId) { - res.status(httpStatus.UNAUTHORIZED).json({ - error: { code: "AUTHENTICATION_REQUIRED", message: "Authentication required", status: httpStatus.UNAUTHORIZED } - }); + sendError(res, httpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "Authentication required"); return; } const type = req.body?.type; if (type !== "individual" && type !== "business") { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_ACTIVE_ENTITY_TYPE", - message: "type must be individual or business", - status: httpStatus.BAD_REQUEST - } - }); + sendError(res, httpStatus.BAD_REQUEST, "INVALID_ACTIVE_ENTITY_TYPE", "type must be individual or business"); return; } @@ -433,18 +415,10 @@ export async function putActiveEntity(req: Request, res: Response): Promise, - res: Response -) => { - const { ephemeralAddress, requiresGlmr } = req.body; - const networkName = "pendulum"; - - if (!ephemeralAddress) { - res.status(httpStatus.BAD_REQUEST).send({ error: "Invalid request parameters" }); - return; - } - - try { - const result = await fundEphemeralAccount(networkName, ephemeralAddress, Boolean(requiresGlmr)); - if (result) { - res.json({ data: undefined, status: "success" }); - return; - } - res.status(httpStatus.INTERNAL_SERVER_ERROR).send({ error: "Funding error" }); - return; - } catch (error) { - logger.error("Error funding ephemeral account:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).send({ error: "Internal Server Error" }); - } -}; - interface StatusResponse { status: boolean; public: string; diff --git a/apps/api/src/api/controllers/rampInfo.controller.ts b/apps/api/src/api/controllers/rampInfo.controller.ts index 9b4ea19cfe..63e0f815ff 100644 --- a/apps/api/src/api/controllers/rampInfo.controller.ts +++ b/apps/api/src/api/controllers/rampInfo.controller.ts @@ -1,19 +1,14 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; +import { sendError } from "../helpers/sendError"; import { getEffectiveUserId } from "../middlewares/effectiveUser"; import { getRampInfo as resolveRampInfo } from "../services/rampInfo.service"; export async function getRampInfo(req: Request, res: Response): Promise { const profileId = getEffectiveUserId(req); if (!req.credential || !profileId) { - res.status(httpStatus.UNAUTHORIZED).json({ - error: { - code: "CREDENTIAL_REQUIRED", - message: "A public or secret API credential is required", - status: httpStatus.UNAUTHORIZED - } - }); + sendError(res, httpStatus.UNAUTHORIZED, "CREDENTIAL_REQUIRED", "A public or secret API credential is required"); return; } @@ -21,12 +16,6 @@ export async function getRampInfo(req: Request, res: Response): Promise { res.status(httpStatus.OK).json(await resolveRampInfo(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 - } - }); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Failed to read ramp info"); } } diff --git a/apps/api/src/api/controllers/recipients.controller.ts b/apps/api/src/api/controllers/recipients.controller.ts index 0fe29a9a27..610a0baab5 100644 --- a/apps/api/src/api/controllers/recipients.controller.ts +++ b/apps/api/src/api/controllers/recipients.controller.ts @@ -18,6 +18,8 @@ import ProviderCustomer, { VerificationStatus } from "../../models/providerCusto import RecipientInvitation, { type RecipientInviteeType, type SeededDiscount } from "../../models/recipientInvitation.model"; import RecipientPayoutReference from "../../models/recipientPayoutReference.model"; import SenderRecipient, { type SenderRecipientStatus } from "../../models/senderRecipient.model"; +import { sendError } from "../helpers/sendError"; +import { UUID_PATTERN } from "../helpers/uuid"; import { getAuthenticatedProfileId, getEffectiveUserId } from "../middlewares/effectiveUser"; import { getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; import { emitNotification } from "../services/notifications/notification.service"; @@ -34,8 +36,6 @@ import { providerForRail } from "../services/recipients/transfer-eligibility.service"; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - function recipientCorridor( invitation: RecipientInvitation | null, relationship?: SenderRecipient @@ -84,10 +84,6 @@ export async function resolveRecipientAuthorizationTarget(req: Request, res: Res return recipientCorridor(relationship.get("invitation") as RecipientInvitation | null, relationship); } -function sendError(res: Response, status: number, code: string, message: string): void { - res.status(status).json({ error: { code, message, status } }); -} - function requireUserId(req: Request, res: Response): string | null { if (!req.userId) { sendError(res, httpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "Authentication required"); diff --git a/apps/api/src/api/controllers/subsidize.controller.ts b/apps/api/src/api/controllers/subsidize.controller.ts index f8d5db4ca1..f8f898481c 100644 --- a/apps/api/src/api/controllers/subsidize.controller.ts +++ b/apps/api/src/api/controllers/subsidize.controller.ts @@ -1,18 +1,4 @@ import { Keyring } from "@polkadot/api"; -import { - ApiManager, - SubsidizeErrorResponse, - SubsidizePostSwapRequest, - SubsidizePostSwapResponse, - SubsidizePreSwapRequest, - SubsidizePreSwapResponse, - TOKEN_CONFIG, - XCMTokenConfig -} from "@vortexfi/shared"; -import Big from "big.js"; -import { Request, Response } from "express"; -import httpStatus from "http-status"; -import logger from "../../config/logger"; import { config } from "../../config/vars"; export const getFundingAccount = () => { @@ -23,94 +9,3 @@ export const getFundingAccount = () => { const keyring = new Keyring({ type: "sr25519" }); return keyring.addFromUri(config.secrets.pendulumFundingSeed); }; - -const validateSubsidyAmount = (amount: string, maxAmount: string) => { - let amountBig: Big; - try { - amountBig = Big(amount); - } catch { - throw new Error("Invalid subsidy amount"); - } - - if (amountBig.lte(0)) { - throw new Error("Subsidy amount must be positive"); - } - - if (amountBig.gt(Big(maxAmount))) { - throw new Error("Amount exceeds maximum subsidy amount"); - } -}; - -const getPendulumCurrencyConfig = (token: string): XCMTokenConfig => { - const normalizedToken = token.toUpperCase() as keyof typeof TOKEN_CONFIG; - const config = TOKEN_CONFIG[normalizedToken]; - - if (!config) { - throw new Error(`Unsupported token: ${token}`); - } - - return config; -}; - -export const subsidizePreSwap = async ( - req: Request, - res: Response -): Promise => { - try { - const { address, amountRaw, tokenToSubsidize } = req.body; - logger.info("Subsidize pre swap", address, amountRaw, tokenToSubsidize); - - const config = getPendulumCurrencyConfig(tokenToSubsidize); - - validateSubsidyAmount(amountRaw, config.maximumSubsidyAmountRaw); - - const fundingAccountKeypair = getFundingAccount(); - - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - await apiManager.executeApiCall( - api => api.tx.tokens.transfer(address, config.pendulumCurrencyId, amountRaw), - fundingAccountKeypair, - networkName - ); - - res.json({ message: "Subsidy transferred successfully" }); - return; - } catch (error) { - logger.error("Error in subsidizePreSwap::", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - details: error instanceof Error ? error.message : "Unknown error", - error: "Server error" - }); - } -}; - -export const subsidizePostSwap = async ( - req: Request, - res: Response -): Promise => { - try { - const { address, amountRaw, token } = req.body; - logger.info("Subsidize post swap", address, amountRaw, token); - - const config = getPendulumCurrencyConfig(token); - - validateSubsidyAmount(amountRaw, config.maximumSubsidyAmountRaw); - - const fundingAccountKeypair = getFundingAccount(); - - const apiManager = ApiManager.getInstance(); - const networkName = "pendulum"; - const apiInstance = await apiManager.getApi(networkName); - await apiInstance.api.tx.tokens.transfer(address, config.pendulumCurrencyId, amountRaw).signAndSend(fundingAccountKeypair); - - res.json({ message: "Subsidy transferred successfully" }); - return; - } catch (error) { - logger.error("Error in subsidizePostSwap::", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - details: error instanceof Error ? error.message : "Unknown error", - error: "Server error" - }); - } -}; diff --git a/apps/api/src/api/controllers/userApiKeys.controller.ts b/apps/api/src/api/controllers/userApiKeys.controller.ts index 892bf1977e..470a632110 100644 --- a/apps/api/src/api/controllers/userApiKeys.controller.ts +++ b/apps/api/src/api/controllers/userApiKeys.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; import { config } from "../../config/vars"; +import { sendError } from "../helpers/sendError"; import { ApiCredentialServiceError, createCredential, @@ -11,9 +12,7 @@ import { 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 } - }); + sendError(res, httpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "Authentication required to manage API credentials"); return null; } @@ -25,7 +24,7 @@ function sendServiceError(res: Response, error: unknown): boolean { : 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 } }); + sendError(res, status, error.code, error.message); return true; } @@ -44,7 +43,7 @@ export async function createUserApiKey(req: Request, res: Response): Promise, r } catch (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 } }); + sendError(res, 500, "INTERNAL_SERVER_ERROR", "Failed to revoke API credential"); } } diff --git a/apps/api/src/api/errors/api-error.test.ts b/apps/api/src/api/errors/api-error.test.ts new file mode 100644 index 0000000000..5037f828a0 --- /dev/null +++ b/apps/api/src/api/errors/api-error.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; +import httpStatus from "http-status"; +import { APIError } from "./api-error"; + +class CustomAPIError extends APIError {} + +describe("APIError", () => { + it("defaults to a private 500 error", () => { + const error = new APIError({ message: "boom" }); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("boom"); + expect(error.name).toBe("APIError"); + expect(error.status).toBe(httpStatus.INTERNAL_SERVER_ERROR); + expect(error.isPublic).toBe(false); + expect(error.isOperational).toBe(true); + expect(error.errors).toBeUndefined(); + expect(error.type).toBeUndefined(); + }); + + it("carries the supplied fields and names subclasses after their own class", () => { + const error = new CustomAPIError({ + errors: ["a"], + isPublic: true, + message: "nope", + stack: "custom stack", + status: httpStatus.BAD_REQUEST, + type: "entity.parse.failed" + }); + + expect(error).toBeInstanceOf(APIError); + expect(error.name).toBe("CustomAPIError"); + expect(error.status).toBe(httpStatus.BAD_REQUEST); + expect(error.isPublic).toBe(true); + expect(error.errors).toEqual(["a"]); + expect(error.stack).toBe("custom stack"); + expect(error.type).toBe("entity.parse.failed"); + }); +}); diff --git a/apps/api/src/api/errors/api-error.ts b/apps/api/src/api/errors/api-error.ts index 6d49c80b58..ee66d86f28 100644 --- a/apps/api/src/api/errors/api-error.ts +++ b/apps/api/src/api/errors/api-error.ts @@ -1,5 +1,4 @@ import httpStatus from "http-status"; -import ExtendableError from "./extendable-error"; interface APIErrorParams { message: string; @@ -12,9 +11,19 @@ interface APIErrorParams { /** * Class representing an API error. - * @extends ExtendableError + * @extends Error */ -export class APIError extends ExtendableError { +export class APIError extends Error { + readonly errors?: unknown[]; + + readonly status?: number; + + readonly isPublic: boolean; + + readonly isOperational: boolean; + + readonly type?: string; + /** * Creates an API error. * @param {string} message - Error message. @@ -22,13 +31,14 @@ export class APIError extends ExtendableError { * @param {boolean} isPublic - Whether the message should be visible to user or not. */ constructor({ message, errors, stack, status = httpStatus.INTERNAL_SERVER_ERROR, isPublic = false, type }: APIErrorParams) { - super({ - errors, - isPublic, - message, - stack, - status, - type - }); + super(message); + this.name = this.constructor.name; + this.message = message; + this.errors = errors; + this.status = status; + this.isPublic = isPublic; + this.isOperational = true; + this.stack = stack; + this.type = type; } } diff --git a/apps/api/src/api/errors/extendable-error.ts b/apps/api/src/api/errors/extendable-error.ts deleted file mode 100644 index 1c525a542f..0000000000 --- a/apps/api/src/api/errors/extendable-error.ts +++ /dev/null @@ -1,39 +0,0 @@ -interface ExtendableErrorParams { - message: string; - errors?: unknown[]; - status?: number; - isPublic?: boolean; - stack?: string; - type?: string; -} - -/** - * Base error class that can be extended with additional properties - * @extends Error - */ -class ExtendableError extends Error { - readonly errors?: unknown[]; - - readonly status?: number; - - readonly isPublic: boolean; - - readonly isOperational: boolean; - - readonly type?: string; - - constructor({ message, errors, status, isPublic = false, stack, type }: ExtendableErrorParams) { - super(message); - this.name = this.constructor.name; - this.message = message; - this.errors = errors; - this.status = status; - this.isPublic = isPublic; - this.isOperational = true; - this.stack = stack; - this.type = type; - // Error.captureStackTrace(this, this.constructor.name); - } -} - -export default ExtendableError; diff --git a/apps/api/src/api/helpers/constantTimeEquals.test.ts b/apps/api/src/api/helpers/constantTimeEquals.test.ts new file mode 100644 index 0000000000..d486a693e6 --- /dev/null +++ b/apps/api/src/api/helpers/constantTimeEquals.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "bun:test"; +import { constantTimeEquals } from "./constantTimeEquals"; + +describe("constantTimeEquals", () => { + it("is true only for identical bytes", () => { + expect(constantTimeEquals(Buffer.from("secret"), Buffer.from("secret"))).toBe(true); + expect(constantTimeEquals(Buffer.from("secret"), Buffer.from("secreT"))).toBe(false); + expect(constantTimeEquals(Buffer.from([]), Buffer.from([]))).toBe(true); + }); + + it("returns false instead of throwing when the lengths differ", () => { + expect(constantTimeEquals(Buffer.from("secret"), Buffer.from("secret!"))).toBe(false); + expect(constantTimeEquals(Buffer.from("secret!"), Buffer.from("secret"))).toBe(false); + expect(constantTimeEquals(Buffer.from("secret"), Buffer.from([]))).toBe(false); + expect(constantTimeEquals(Buffer.from([]), Buffer.from("secret"))).toBe(false); + }); +}); diff --git a/apps/api/src/api/helpers/constantTimeEquals.ts b/apps/api/src/api/helpers/constantTimeEquals.ts new file mode 100644 index 0000000000..7056c8612f --- /dev/null +++ b/apps/api/src/api/helpers/constantTimeEquals.ts @@ -0,0 +1,14 @@ +import crypto from "node:crypto"; + +/** + * Constant-time buffer comparison. `crypto.timingSafeEqual` throws on a length mismatch, so differing + * lengths run a dummy same-length comparison and return false instead. + */ +export function constantTimeEquals(a: Buffer, b: Buffer): boolean { + if (a.length !== b.length) { + // Compare against self to keep timing independent of the mismatch position. + crypto.timingSafeEqual(a, a); + return false; + } + return crypto.timingSafeEqual(a, b); +} diff --git a/apps/api/src/api/helpers/fetchProviderJson.ts b/apps/api/src/api/helpers/fetchProviderJson.ts new file mode 100644 index 0000000000..b0c6fd31a8 --- /dev/null +++ b/apps/api/src/api/helpers/fetchProviderJson.ts @@ -0,0 +1,25 @@ +import logger from "../../config/logger"; +import { ProviderInternalError } from "../errors/providerErrors"; +import { fetchWithTimeout } from "./fetchWithTimeout"; + +/** + * Fetches a price provider's JSON endpoint. Network failures and unparsable bodies surface as a + * ProviderInternalError (HTTP status handling is left to the caller, which needs the `Response`). + * + * @param describeError builds the ProviderInternalError message from the underlying failure + */ +export async function fetchProviderJson( + provider: string, + url: string, + init?: RequestInit, + describeError: (error: unknown) => string = error => + `Network error fetching price from ${provider}: ${(error as Error).message}` +): Promise<{ response: Response; body: T }> { + try { + const response = await fetchWithTimeout(url, init); + return { body: (await response.json()) as T, response }; + } catch (error) { + logger.error(`${provider} fetch error:`, error); + throw new ProviderInternalError(describeError(error)); + } +} diff --git a/apps/api/src/api/helpers/sendError.test.ts b/apps/api/src/api/helpers/sendError.test.ts new file mode 100644 index 0000000000..6d3240c883 --- /dev/null +++ b/apps/api/src/api/helpers/sendError.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import express from "express"; +import { sendError } from "./sendError"; + +describe("sendError", () => { + it("sends the error envelope with the status mirrored in the body, keys in code/message/status order", async () => { + const app = express(); + app.get("/conflict", (_req, res) => { + sendError(res, 409, "SOME_CONFLICT", "Something conflicts"); + }); + + const server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not bind test server"); + } + + try { + const response = await fetch(`http://127.0.0.1:${address.port}/conflict`); + expect(response.status).toBe(409); + expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); + expect(await response.text()).toBe('{"error":{"code":"SOME_CONFLICT","message":"Something conflicts","status":409}}'); + } finally { + server.close(); + } + }); + + it("returns the response so middlewares can `return sendError(...)`", () => { + const res: Record = {}; + res.status = () => res; + res.json = () => res; + + expect(sendError(res as never, 400, "BAD", "bad")).toBe(res as never); + }); +}); diff --git a/apps/api/src/api/helpers/sendError.ts b/apps/api/src/api/helpers/sendError.ts new file mode 100644 index 0000000000..87b3c476c8 --- /dev/null +++ b/apps/api/src/api/helpers/sendError.ts @@ -0,0 +1,9 @@ +import type { Response } from "express"; + +/** + * Sends the API's standard JSON error envelope: `{ error: { code, message, status } }`. + * Returns the response so middlewares can `return sendError(...)`. + */ +export function sendError(res: Response, status: number, code: string, message: string): Response { + return res.status(status).json({ error: { code, message, status } }); +} diff --git a/apps/api/src/api/helpers/uuid.test.ts b/apps/api/src/api/helpers/uuid.test.ts new file mode 100644 index 0000000000..d79577a0fe --- /dev/null +++ b/apps/api/src/api/helpers/uuid.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "bun:test"; +import { UUID_PATTERN } from "./uuid"; + +describe("UUID_PATTERN", () => { + it("accepts UUIDs in either case", () => { + expect(UUID_PATTERN.test("d3ff0000-0000-4000-8000-000000000001")).toBe(true); + expect(UUID_PATTERN.test("D3FF0000-0000-4000-8000-00000000000A")).toBe(true); + }); + + it.each(["", "not-a-uuid", "d3ff0000-0000-4000-8000-00000000000", "d3ff0000-0000-4000-8000-0000000000012", " d3ff0000-0000-4000-8000-000000000001"])( + "rejects %p", + value => { + expect(UUID_PATTERN.test(value)).toBe(false); + } + ); +}); diff --git a/apps/api/src/api/helpers/uuid.ts b/apps/api/src/api/helpers/uuid.ts new file mode 100644 index 0000000000..e19bbf060b --- /dev/null +++ b/apps/api/src/api/helpers/uuid.ts @@ -0,0 +1 @@ +export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/apps/api/src/api/middlewares/adminAuth.ts b/apps/api/src/api/middlewares/adminAuth.ts index 06be64f847..b20627a587 100644 --- a/apps/api/src/api/middlewares/adminAuth.ts +++ b/apps/api/src/api/middlewares/adminAuth.ts @@ -1,8 +1,5 @@ -import crypto from "crypto"; -import { NextFunction, Request, Response } from "express"; -import httpStatus from "http-status"; -import logger from "../../config/logger"; import { config } from "../../config/vars"; +import { bearerSecretAuth } from "./bearerSecretAuth"; /** * Middleware to authenticate admin requests using Bearer token @@ -10,105 +7,15 @@ import { config } from "../../config/vars"; * Usage: * - Set ADMIN_SECRET in environment variables * - Include header: Authorization: Bearer - * - * @param req - Express request - * @param res - Express response - * @param next - Express next function - */ -export function adminAuth(req: Request, res: Response, next: NextFunction): void { - try { - // Get Authorization header - const authHeader = req.headers.authorization; - - if (!authHeader) { - logger.warn("Admin auth attempt without Authorization header", { - ip: req.ip, - path: req.path - }); - res.status(httpStatus.UNAUTHORIZED).json({ - error: { - code: "ADMIN_AUTH_REQUIRED", - message: "Admin authentication required. Provide Authorization header with Bearer token.", - status: httpStatus.UNAUTHORIZED - } - }); - return; - } - - // Check if it's a Bearer token - const parts = authHeader.split(" "); - if (parts.length !== 2 || parts[0] !== "Bearer") { - res.status(httpStatus.UNAUTHORIZED).json({ - error: { - code: "INVALID_AUTH_FORMAT", - message: "Invalid authorization format. Use: Authorization: Bearer ", - status: httpStatus.UNAUTHORIZED - } - }); - return; - } - - const token = parts[1]; - - // Check if admin secret is configured - if (!config.adminSecret) { - logger.error("ADMIN_SECRET not configured in environment variables"); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "ADMIN_AUTH_NOT_CONFIGURED", - message: "Admin authentication is not properly configured", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); - return; - } - - // Validate token against configured secret - // Using constant-time comparison to prevent timing attacks - const isValid = safeCompare(token, config.adminSecret); - - if (!isValid) { - logger.warn("Failed admin auth attempt", { - ip: req.ip, - path: req.path - }); - res.status(httpStatus.FORBIDDEN).json({ - error: { - code: "INVALID_ADMIN_TOKEN", - message: "Invalid admin token", - status: httpStatus.FORBIDDEN - } - }); - return; - } - - // Token is valid, proceed to next middleware - next(); - } catch (error) { - logger.error("Error in admin authentication:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "ADMIN_AUTH_ERROR", - message: "An error occurred during admin authentication", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); - } -} - -/** - * Constant-time string comparison to prevent timing attacks - * @param a - First string - * @param b - Second string - * @returns True if strings are equal */ -function safeCompare(a: string, b: string): boolean { - const bufA = Buffer.from(a); - const bufB = Buffer.from(b); - if (bufA.length !== bufB.length) { - const dummyBuf = Buffer.alloc(bufA.length); - crypto.timingSafeEqual(bufA, dummyBuf); - return false; - } - return crypto.timingSafeEqual(bufA, bufB); -} +export const adminAuth = bearerSecretAuth({ + codes: { + error: "ADMIN_AUTH_ERROR", + invalidToken: "INVALID_ADMIN_TOKEN", + notConfigured: "ADMIN_AUTH_NOT_CONFIGURED", + required: "ADMIN_AUTH_REQUIRED" + }, + envName: "ADMIN_SECRET", + getSecret: () => config.adminSecret, + label: "Admin" +}); diff --git a/apps/api/src/api/middlewares/apiKeyAuth.ts b/apps/api/src/api/middlewares/apiKeyAuth.ts index 23cbbb0e76..453736f5f9 100644 --- a/apps/api/src/api/middlewares/apiKeyAuth.ts +++ b/apps/api/src/api/middlewares/apiKeyAuth.ts @@ -1,6 +1,8 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; import Partner from "../../models/partner.model"; +import { sendError } from "../helpers/sendError"; +import { UUID_PATTERN } from "../helpers/uuid"; import { buildApiClientRequestMetadata, getSafeApiKeyPrefix, @@ -46,13 +48,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { if (!apiKey) { if (options.required) { recordAuthFailure(req, 401, "auth_missing_api_key"); - return res.status(401).json({ - error: { - code: "API_KEY_REQUIRED", - message: "API key is required for this endpoint", - status: 401 - } - }); + return sendError(res, 401, "API_KEY_REQUIRED", "API key is required for this endpoint"); } // Optional auth - continue without partner info return next(); @@ -62,25 +58,22 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { const keyType = getKeyType(apiKey); if (keyType !== "secret") { recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); - return res.status(401).json({ - error: { - code: "INVALID_SECRET_KEY", - message: - "X-API-Key header must contain a secret key (sk_live_* or sk_test_*). Use X-Public-Key for public credentials.", - status: 401 - } - }); + return sendError( + res, + 401, + "INVALID_SECRET_KEY", + "X-API-Key header must contain a secret key (sk_live_* or sk_test_*). Use X-Public-Key for public credentials." + ); } if (!isValidSecretKeyFormat(apiKey)) { recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); - return res.status(401).json({ - error: { - code: "INVALID_SECRET_KEY_FORMAT", - message: "Invalid secret key format. Expected sk_live_* or sk_test_* format.", - status: 401 - } - }); + return sendError( + res, + 401, + "INVALID_SECRET_KEY_FORMAT", + "Invalid secret key format. Expected sk_live_* or sk_test_* format." + ); } // Find and validate API key @@ -88,13 +81,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { if (!result) { recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); - return res.status(401).json({ - error: { - code: "INVALID_API_KEY", - message: "The provided API key is invalid or has expired", - status: 401 - } - }); + return sendError(res, 401, "INVALID_API_KEY", "The provided API key is invalid or has expired"); } const partner = result.partner; @@ -104,16 +91,12 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { 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 } - }); + return sendError(res, 401, "INVALID_PUBLIC_KEY", "The provided public API key is invalid or expired"); } 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 } - }); + return sendError(res, 403, "CREDENTIAL_MISMATCH", "Public and secret credentials do not match"); } req.credential = result.credential; @@ -128,24 +111,12 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { 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 - } - }); + return sendError(res, 404, "PARTNER_NOT_FOUND", "The requested partner was not found"); } 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", - message: "The authenticated partner does not match the requested partner", - status: 403 - } - }); + return sendError(res, 403, "PARTNER_MISMATCH", "The authenticated partner does not match the requested partner"); } } @@ -170,36 +141,18 @@ export function enforcePartnerAuth() { if (req.body?.partnerId) { if (!req.credential?.partnerId) { recordAuthFailure(req, 403, "auth_missing_api_key"); - return res.status(403).json({ - error: { - code: "AUTHENTICATION_REQUIRED", - message: "Authentication is required when partnerId is specified", - status: 403 - } - }); + return sendError(res, 403, "AUTHENTICATION_REQUIRED", "Authentication is required when partnerId is specified"); } 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 - } - }); + return sendError(res, 404, "PARTNER_NOT_FOUND", "The requested partner was not found"); } if (requestedPartner.id !== req.credential.partnerId) { recordAuthFailure(req, 403, "auth_partner_mismatch", null, req.authenticatedPartner); - return res.status(403).json({ - error: { - code: "PARTNER_MISMATCH", - message: "The authenticated partner does not match the requested partner", - status: 403 - } - }); + return sendError(res, 403, "PARTNER_MISMATCH", "The authenticated partner does not match the requested partner"); } } @@ -208,7 +161,7 @@ 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); + const isUUID = UUID_PATTERN.test(partnerIdOrName); return isUUID ? Partner.findByPk(partnerIdOrName) : Partner.findOne({ where: { name: partnerIdOrName } }); } diff --git a/apps/api/src/api/middlewares/bearerPrincipal.ts b/apps/api/src/api/middlewares/bearerPrincipal.ts index be80834f4a..96f37cb79b 100644 --- a/apps/api/src/api/middlewares/bearerPrincipal.ts +++ b/apps/api/src/api/middlewares/bearerPrincipal.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; +import { sendError } from "../helpers/sendError"; import { SupabaseAuthService } from "../services/auth"; import { type ImpersonationContext, isImpersonationToken, resolveSession } from "../services/impersonation.service"; @@ -54,11 +55,10 @@ export function rejectImpersonation(req: Request, res: Response, next: NextFunct /** Shared with the routes that gate on impersonation inline instead of via the middleware. */ export function impersonationNotAllowedResponse(res: Response): void { - res.status(httpStatus.FORBIDDEN).json({ - error: { - code: "IMPERSONATION_NOT_ALLOWED", - message: "This action is not available while acting as another account.", - status: httpStatus.FORBIDDEN - } - }); + sendError( + res, + httpStatus.FORBIDDEN, + "IMPERSONATION_NOT_ALLOWED", + "This action is not available while acting as another account." + ); } diff --git a/apps/api/src/api/middlewares/bearerSecretAuth.test.ts b/apps/api/src/api/middlewares/bearerSecretAuth.test.ts new file mode 100644 index 0000000000..1e9e3cf046 --- /dev/null +++ b/apps/api/src/api/middlewares/bearerSecretAuth.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import express from "express"; +import logger from "../../config/logger"; +import { config } from "../../config/vars"; +import { adminAuth } from "./adminAuth"; +import { metricsDashboardAuth } from "./metricsDashboardAuth"; + +/** + * Characterization of the two bearer-secret middlewares: every status, error body (including + * key order), header and log line is pinned so they can share one implementation. + */ +const cases = [ + { + codes: { + error: "ADMIN_AUTH_ERROR", + invalidToken: "INVALID_ADMIN_TOKEN", + notConfigured: "ADMIN_AUTH_NOT_CONFIGURED", + required: "ADMIN_AUTH_REQUIRED" + }, + configKey: "adminSecret", + envName: "ADMIN_SECRET", + logs: { + error: "Error in admin authentication:", + failed: "Failed admin auth attempt", + missingHeader: "Admin auth attempt without Authorization header" + }, + messages: { + error: "An error occurred during admin authentication", + invalidToken: "Invalid admin token", + notConfigured: "Admin authentication is not properly configured", + required: "Admin authentication required. Provide Authorization header with Bearer token." + }, + middleware: adminAuth, + name: "adminAuth" + }, + { + codes: { + error: "METRICS_DASHBOARD_AUTH_ERROR", + invalidToken: "INVALID_METRICS_DASHBOARD_TOKEN", + notConfigured: "METRICS_DASHBOARD_AUTH_NOT_CONFIGURED", + required: "METRICS_DASHBOARD_AUTH_REQUIRED" + }, + configKey: "metricsDashboardSecret", + envName: "METRICS_DASHBOARD_SECRET", + logs: { + error: "Error in metrics dashboard authentication:", + failed: "Failed metrics dashboard auth attempt", + missingHeader: "Metrics dashboard auth attempt without Authorization header" + }, + messages: { + error: "An error occurred during metrics dashboard authentication", + invalidToken: "Invalid metrics dashboard token", + notConfigured: "Metrics dashboard authentication is not properly configured", + required: "Metrics dashboard authentication required. Provide Authorization header with Bearer token." + }, + middleware: metricsDashboardAuth, + name: "metricsDashboardAuth" + } +] as const; + +const INVALID_FORMAT_BODY = + '{"error":{"code":"INVALID_AUTH_FORMAT","message":"Invalid authorization format. Use: Authorization: Bearer ","status":401}}'; + +const errorBody = (code: string, message: string, status: number) => JSON.stringify({ error: { code, message, status } }); + +for (const testCase of cases) { + describe(testCase.name, () => { + const originalSecret = config[testCase.configKey]; + let warn: ReturnType; + let error: ReturnType; + // The logger is process-global: fire-and-forget work from earlier test files can log while a + // request is in flight, so assertions only count the lines this middleware can emit. + const ownMessages = new Set([ + ...Object.values(testCase.logs), + `${testCase.envName} not configured in environment variables` + ]); + const own = (spy: ReturnType) => spy.mock.calls.filter((call: unknown[]) => ownMessages.has(call[0])); + + beforeEach(() => { + config[testCase.configKey] = "s3cret-value"; + warn = spyOn(logger, "warn").mockImplementation((() => logger) as never); + error = spyOn(logger, "error").mockImplementation((() => logger) as never); + }); + + afterEach(() => { + Object.defineProperty(config, testCase.configKey, { + configurable: true, + enumerable: true, + value: originalSecret, + writable: true + }); + warn.mockRestore(); + error.mockRestore(); + }); + + async function call(authorization?: string) { + const app = express(); + app.get("/guarded", testCase.middleware, (_req, res) => { + res.json({ reached: true }); + }); + + const server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not bind test server"); + } + + try { + const response = await fetch(`http://127.0.0.1:${address.port}/guarded`, { + ...(authorization === undefined ? {} : { headers: { Authorization: authorization } }) + }); + return { headers: response.headers, status: response.status, text: await response.text() }; + } finally { + server.close(); + } + } + + it("rejects a missing Authorization header with 401 and warns", async () => { + const result = await call(); + + expect(result.status).toBe(401); + expect(result.text).toBe(errorBody(testCase.codes.required, testCase.messages.required, 401)); + expect(result.headers.get("content-type")).toBe("application/json; charset=utf-8"); + expect(result.headers.get("www-authenticate")).toBeNull(); + expect(own(warn)).toEqual([[testCase.logs.missingHeader, { ip: expect.any(String), path: "/guarded" }]]); + expect(own(error)).toEqual([]); + }); + + it("answers a missing header with 401 even when the secret is not configured", async () => { + config[testCase.configKey] = ""; + const result = await call(); + + expect(result.status).toBe(401); + expect(JSON.parse(result.text).error.code).toBe(testCase.codes.required); + }); + + for (const header of ["Basic s3cret-value", "Bearer", "Bearer s3cret-value extra", "bearer s3cret-value", "s3cret-value"]) { + it(`rejects the malformed header ${JSON.stringify(header)} with 401 INVALID_AUTH_FORMAT and no log`, async () => { + const result = await call(header); + + expect(result.status).toBe(401); + expect(result.text).toBe(INVALID_FORMAT_BODY); + expect(result.headers.get("www-authenticate")).toBeNull(); + expect(own(warn)).toEqual([]); + expect(own(error)).toEqual([]); + }); + } + + it("returns 500 and logs when the secret is not configured", async () => { + config[testCase.configKey] = ""; + const result = await call("Bearer anything"); + + expect(result.status).toBe(500); + expect(result.text).toBe(errorBody(testCase.codes.notConfigured, testCase.messages.notConfigured, 500)); + expect(own(error)).toEqual([[`${testCase.envName} not configured in environment variables`]]); + expect(own(warn)).toEqual([]); + }); + + // Same-length, shorter and longer tokens + for (const token of ["s3cret-valuX", "s3cret", "s3cret-value-and-more"]) { + it(`rejects the wrong token ${JSON.stringify(token)} with 403 and warns`, async () => { + const result = await call(`Bearer ${token}`); + + expect(result.status).toBe(403); + expect(result.text).toBe(errorBody(testCase.codes.invalidToken, testCase.messages.invalidToken, 403)); + expect(result.headers.get("www-authenticate")).toBeNull(); + expect(own(warn)).toEqual([[testCase.logs.failed, { ip: expect.any(String), path: "/guarded" }]]); + expect(own(error)).toEqual([]); + }); + } + + it("passes the request through to the next handler on the right token without logging", async () => { + const result = await call("Bearer s3cret-value"); + + expect(result.status).toBe(200); + expect(result.text).toBe('{"reached":true}'); + expect(own(warn)).toEqual([]); + expect(own(error)).toEqual([]); + }); + + // Called directly: HTTP clients mangle non-ASCII header values before they reach the middleware. + it("compares tokens as UTF-8 bytes (multi-byte secret, same-length and different-length mismatches)", () => { + config[testCase.configKey] = "p\u00e4ss-\u00fcber"; + const outcome = (token: string) => { + let status: number | undefined; + let nextCalled = false; + const res = { + json: () => res, + status: (code: number) => { + status = code; + return res; + } + }; + testCase.middleware( + { headers: { authorization: `Bearer ${token}` }, ip: "127.0.0.1", path: "/guarded" } as never, + res as never, + (() => { + nextCalled = true; + }) as never + ); + return { nextCalled, status }; + }; + + expect(outcome("p\u00e4ss-\u00fcber")).toEqual({ nextCalled: true, status: undefined }); + expect(outcome("p\u00e4ss-\u00fcbes")).toEqual({ nextCalled: false, status: 403 }); // same byte length, last byte differs + // The secret is 11 bytes in UTF-8 but 9 characters: an 11-character ASCII token has the same byte length + expect(outcome("pass-uber!!")).toEqual({ nextCalled: false, status: 403 }); + expect(outcome("pass-uber")).toEqual({ nextCalled: false, status: 403 }); // 9 bytes, shorter than the secret + }); + + it("returns 500 and logs the thrown error when reading the secret throws", async () => { + const boom = new Error("config exploded"); + Object.defineProperty(config, testCase.configKey, { + configurable: true, + enumerable: true, + get() { + throw boom; + } + }); + + const result = await call("Bearer s3cret-value"); + + expect(result.status).toBe(500); + expect(result.text).toBe(errorBody(testCase.codes.error, testCase.messages.error, 500)); + expect(own(error)).toEqual([[testCase.logs.error, boom]]); + }); + }); +} + +describe("bearer secret middlewares are independent", () => { + const originalAdmin = config.adminSecret; + const originalMetrics = config.metricsDashboardSecret; + + afterEach(() => { + config.adminSecret = originalAdmin; + config.metricsDashboardSecret = originalMetrics; + }); + + it("each middleware reads its own secret at request time", async () => { + config.adminSecret = "admin-one"; + config.metricsDashboardSecret = "metrics-one"; + const app = express(); + app.get("/admin", adminAuth, (_req, res) => void res.json({ ok: "admin" })); + app.get("/metrics", metricsDashboardAuth, (_req, res) => void res.json({ ok: "metrics" })); + const server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not bind test server"); + } + const base = `http://127.0.0.1:${address.port}`; + + try { + const status = async (path: string, token: string) => + (await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } })).status; + + expect(await status("/admin", "admin-one")).toBe(200); + expect(await status("/admin", "metrics-one")).toBe(403); + expect(await status("/metrics", "metrics-one")).toBe(200); + expect(await status("/metrics", "admin-one")).toBe(403); + + config.adminSecret = "admin-two"; + expect(await status("/admin", "admin-one")).toBe(403); + expect(await status("/admin", "admin-two")).toBe(200); + } finally { + server.close(); + } + }); +}); diff --git a/apps/api/src/api/middlewares/bearerSecretAuth.ts b/apps/api/src/api/middlewares/bearerSecretAuth.ts new file mode 100644 index 0000000000..0a1afc24f8 --- /dev/null +++ b/apps/api/src/api/middlewares/bearerSecretAuth.ts @@ -0,0 +1,82 @@ +import { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import { constantTimeEquals } from "../helpers/constantTimeEquals"; +import { sendError } from "../helpers/sendError"; + +interface BearerSecretAuthOptions { + /** Capitalised subject used in log lines and error messages, e.g. "Admin" or "Metrics dashboard". */ + label: string; + /** Environment variable holding the secret, named in the "not configured" log line. */ + envName: string; + /** Read on every request, so the secret is never captured at module load. */ + getSecret: () => string; + /** Error codes returned for this middleware's own failures. */ + codes: { required: string; notConfigured: string; invalidToken: string; error: string }; +} + +/** + * Middleware factory: authenticates `Authorization: Bearer ` against a single shared secret using a + * constant-time comparison. Missing header -> 401, malformed header -> 401, secret not configured -> 500, + * wrong token -> 403. + */ +export function bearerSecretAuth({ label, envName, getSecret, codes }: BearerSecretAuthOptions) { + const name = label.toLowerCase(); + + return (req: Request, res: Response, next: NextFunction): void => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader) { + logger.warn(`${label} auth attempt without Authorization header`, { + ip: req.ip, + path: req.path + }); + sendError( + res, + httpStatus.UNAUTHORIZED, + codes.required, + `${label} authentication required. Provide Authorization header with Bearer token.` + ); + return; + } + + const parts = authHeader.split(" "); + if (parts.length !== 2 || parts[0] !== "Bearer") { + sendError( + res, + httpStatus.UNAUTHORIZED, + "INVALID_AUTH_FORMAT", + "Invalid authorization format. Use: Authorization: Bearer " + ); + return; + } + + const secret = getSecret(); + if (!secret) { + logger.error(`${envName} not configured in environment variables`); + sendError( + res, + httpStatus.INTERNAL_SERVER_ERROR, + codes.notConfigured, + `${label} authentication is not properly configured` + ); + return; + } + + if (!constantTimeEquals(Buffer.from(parts[1]), Buffer.from(secret))) { + logger.warn(`Failed ${name} auth attempt`, { + ip: req.ip, + path: req.path + }); + sendError(res, httpStatus.FORBIDDEN, codes.invalidToken, `Invalid ${name} token`); + return; + } + + next(); + } catch (error) { + logger.error(`Error in ${name} authentication:`, error); + sendError(res, httpStatus.INTERNAL_SERVER_ERROR, codes.error, `An error occurred during ${name} authentication`); + } + }; +} diff --git a/apps/api/src/api/middlewares/dualAuth.ts b/apps/api/src/api/middlewares/dualAuth.ts index 2a20a272ad..f42169c104 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; +import { sendError } from "../helpers/sendError"; import { buildApiClientRequestMetadata, getSafeApiKeyPrefix, @@ -41,13 +42,7 @@ export function requireProfileBoundPrincipal(req: Request, res: Response, next: return; } - res.status(401).json({ - error: { - code: "AUTHENTICATION_REQUIRED", - message: "A profile-bound secret key or Bearer token is required.", - status: 401 - } - }); + sendError(res, 401, "AUTHENTICATION_REQUIRED", "A profile-bound secret key or Bearer token is required."); } function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean }) { @@ -60,39 +55,28 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } const keyType = getKeyType(apiKey); if (keyType !== "secret" || !isValidSecretKeyFormat(apiKey)) { recordDualAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); - return res.status(401).json({ - error: { - code: "INVALID_SECRET_KEY", - message: "X-API-Key header must contain a valid secret key (sk_live_* or sk_test_*).", - status: 401 - } - }); + return sendError( + res, + 401, + "INVALID_SECRET_KEY", + "X-API-Key header must contain a valid secret key (sk_live_* or sk_test_*)." + ); } const result = await validateSecretApiKey(apiKey); if (!result) { recordDualAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); - return res.status(401).json({ - error: { - code: "INVALID_API_KEY", - message: "The provided API key is invalid or has expired.", - status: 401 - } - }); + return sendError(res, 401, "INVALID_API_KEY", "The provided API key is invalid or has expired."); } 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 } - }); + return sendError(res, 401, "INVALID_PUBLIC_KEY", "The provided public API key is invalid or expired."); } 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 } - }); + return sendError(res, 403, "CREDENTIAL_MISMATCH", "Public and secret credentials do not match"); } } @@ -126,23 +110,16 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } requestId: req.headers["x-request-id"] }); recordDualAuthFailure(req, unavailable ? 503 : 401, unavailable ? "service_unavailable" : "auth_invalid_api_key"); - return res.status(unavailable ? 503 : 401).json({ - error: { - code: unavailable ? "AUTH_SERVICE_UNAVAILABLE" : "INVALID_BEARER_TOKEN", - message: unavailable ? "Authentication service unavailable" : "Authentication failed", - status: unavailable ? 503 : 401 - } - }); + return sendError( + res, + unavailable ? 503 : 401, + unavailable ? "AUTH_SERVICE_UNAVAILABLE" : "INVALID_BEARER_TOKEN", + unavailable ? "Authentication service unavailable" : "Authentication failed" + ); } if (!result.valid) { recordDualAuthFailure(req, 401, "auth_invalid_api_key"); - return res.status(401).json({ - error: { - code: "INVALID_BEARER_TOKEN", - message: "Invalid or expired Bearer token.", - status: 401 - } - }); + return sendError(res, 401, "INVALID_BEARER_TOKEN", "Invalid or expired Bearer token."); } req.userId = result.userId; @@ -156,13 +133,12 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } } recordDualAuthFailure(req, 401, "auth_missing_api_key"); - return res.status(401).json({ - error: { - code: "AUTHENTICATION_REQUIRED", - message: "Authentication required: provide either an X-API-Key header (sk_*) or an Authorization: Bearer token.", - status: 401 - } - }); + return sendError( + res, + 401, + "AUTHENTICATION_REQUIRED", + "Authentication required: provide either an X-API-Key header (sk_*) or an Authorization: Bearer token." + ); } catch (error) { logger.error("Dual auth middleware error:", error); next(error); diff --git a/apps/api/src/api/middlewares/error.ts b/apps/api/src/api/middlewares/error.ts index 8c99a01439..4d295b6fdd 100644 --- a/apps/api/src/api/middlewares/error.ts +++ b/apps/api/src/api/middlewares/error.ts @@ -1,5 +1,4 @@ import { NextFunction, Request, Response } from "express"; -import { ValidationError } from "express-validation"; import httpStatus from "http-status"; import { config } from "../../config/vars"; @@ -57,18 +56,10 @@ export { handler }; * If error is not an instanceOf APIError, convert it. * @public */ -export const converter = (err: Error | ValidationError, req: Request, res: Response, next: NextFunction): void => { +export const converter = (err: Error, req: Request, res: Response, next: NextFunction): void => { let convertedError: APIError; - if (err instanceof ValidationError) { - convertedError = new APIError({ - errors: err.errors, - message: "Validation Error", - // @ts-ignore - stack: err.stack, - status: err.status - }); - } else if (isBodyParserError(err)) { + if (isBodyParserError(err)) { convertedError = new APIError({ isPublic: true, message: err.type === "entity.parse.failed" ? "Invalid JSON payload" : "Request body too large", @@ -89,7 +80,7 @@ export const converter = (err: Error | ValidationError, req: Request, res: Respo return handler(convertedError, req, res, next); }; -function isBodyParserError(err: Error | ValidationError): err is Error & { status: 400 | 413; type: string } { +function isBodyParserError(err: Error): err is Error & { status: 400 | 413; type: string } { const bodyParserError = err as Error & { status?: number; type?: string }; return ( (bodyParserError.type === "entity.parse.failed" && bodyParserError.status === httpStatus.BAD_REQUEST) || diff --git a/apps/api/src/api/middlewares/managedProfileAuth.ts b/apps/api/src/api/middlewares/managedProfileAuth.ts index 4530c8d37b..f573d59afb 100644 --- a/apps/api/src/api/middlewares/managedProfileAuth.ts +++ b/apps/api/src/api/middlewares/managedProfileAuth.ts @@ -5,10 +5,10 @@ import CustomerEntity, { type CustomerEntityType } from "../../models/customerEn import ManagedProfile from "../../models/managedProfile.model"; import ManagedProfileManager from "../../models/managedProfileManager.model"; import User from "../../models/user.model"; +import { sendError } from "../helpers/sendError"; +import { UUID_PATTERN } from "../helpers/uuid"; import { getAuthenticatedProfileId } from "./effectiveUser"; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - export interface ManagedProfileContext { actorProfileId: string; controllingManagerProfileId: string; @@ -99,25 +99,23 @@ export function authorizeManagedProfile(options: ManagedProfileAuthOptions = {}) } if (!UUID_PATTERN.test(subjectProfileId)) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "INVALID_MANAGED_PROFILE_ID", - message: "X-Managed-Profile-Id must contain a valid profile UUID", - status: httpStatus.BAD_REQUEST - } - }); + sendError( + res, + httpStatus.BAD_REQUEST, + "INVALID_MANAGED_PROFILE_ID", + "X-Managed-Profile-Id must contain a valid profile UUID" + ); return; } const actorProfileId = getAuthenticatedProfileId(req); if (!actorProfileId) { - res.status(httpStatus.UNAUTHORIZED).json({ - error: { - code: "AUTHENTICATION_REQUIRED", - message: "Authentication is required to act for a managed profile", - status: httpStatus.UNAUTHORIZED - } - }); + sendError( + res, + httpStatus.UNAUTHORIZED, + "AUTHENTICATION_REQUIRED", + "Authentication is required to act for a managed profile" + ); return; } @@ -204,13 +202,12 @@ async function authorizeCustomerType( const expectedCustomerType = typeof options.customerType === "function" ? await options.customerType(req) : options.customerType; if (options.customerType !== undefined && expectedCustomerType !== customerType) { - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH", - message: "The operation customer type does not match the managed profile customer type", - status: httpStatus.BAD_REQUEST - } - }); + sendError( + res, + httpStatus.BAD_REQUEST, + "MANAGED_PROFILE_CUSTOMER_TYPE_MISMATCH", + "The operation customer type does not match the managed profile customer type" + ); return false; } if ( @@ -232,13 +229,12 @@ export function rejectManagedProfileSelection(req: Request, res: Response, next: return; } - res.status(httpStatus.BAD_REQUEST).json({ - error: { - code: "MANAGED_PROFILE_UNSUPPORTED", - message: "Managed profile selection is not supported for this operation", - status: httpStatus.BAD_REQUEST - } - }); + sendError( + res, + httpStatus.BAD_REQUEST, + "MANAGED_PROFILE_UNSUPPORTED", + "Managed profile selection is not supported for this operation" + ); } export function rejectDirectManagedCredential(req: Request, res: Response, next: NextFunction): void { @@ -251,11 +247,10 @@ export function rejectDirectManagedCredential(req: Request, res: Response, next: } function sendAccessDenied(res: Response): void { - res.status(httpStatus.FORBIDDEN).json({ - error: { - code: "MANAGED_PROFILE_ACCESS_DENIED", - message: "The authenticated profile cannot perform this operation for the requested managed profile", - status: httpStatus.FORBIDDEN - } - }); + sendError( + res, + httpStatus.FORBIDDEN, + "MANAGED_PROFILE_ACCESS_DENIED", + "The authenticated profile cannot perform this operation for the requested managed profile" + ); } diff --git a/apps/api/src/api/middlewares/metricsDashboardAuth.ts b/apps/api/src/api/middlewares/metricsDashboardAuth.ts index 188e338226..02efd65768 100644 --- a/apps/api/src/api/middlewares/metricsDashboardAuth.ts +++ b/apps/api/src/api/middlewares/metricsDashboardAuth.ts @@ -1,90 +1,17 @@ -import crypto from "crypto"; -import { NextFunction, Request, Response } from "express"; -import httpStatus from "http-status"; -import logger from "../../config/logger"; import { config } from "../../config/vars"; +import { bearerSecretAuth } from "./bearerSecretAuth"; /** * Authenticates internal observability dashboard requests with a dedicated bearer token. */ -export function metricsDashboardAuth(req: Request, res: Response, next: NextFunction): void { - try { - const authHeader = req.headers.authorization; - - if (!authHeader) { - logger.warn("Metrics dashboard auth attempt without Authorization header", { - ip: req.ip, - path: req.path - }); - res.status(httpStatus.UNAUTHORIZED).json({ - error: { - code: "METRICS_DASHBOARD_AUTH_REQUIRED", - message: "Metrics dashboard authentication required. Provide Authorization header with Bearer token.", - status: httpStatus.UNAUTHORIZED - } - }); - return; - } - - const parts = authHeader.split(" "); - if (parts.length !== 2 || parts[0] !== "Bearer") { - res.status(httpStatus.UNAUTHORIZED).json({ - error: { - code: "INVALID_AUTH_FORMAT", - message: "Invalid authorization format. Use: Authorization: Bearer ", - status: httpStatus.UNAUTHORIZED - } - }); - return; - } - - if (!config.metricsDashboardSecret) { - logger.error("METRICS_DASHBOARD_SECRET not configured in environment variables"); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "METRICS_DASHBOARD_AUTH_NOT_CONFIGURED", - message: "Metrics dashboard authentication is not properly configured", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); - return; - } - - if (!safeCompare(parts[1], config.metricsDashboardSecret)) { - logger.warn("Failed metrics dashboard auth attempt", { - ip: req.ip, - path: req.path - }); - res.status(httpStatus.FORBIDDEN).json({ - error: { - code: "INVALID_METRICS_DASHBOARD_TOKEN", - message: "Invalid metrics dashboard token", - status: httpStatus.FORBIDDEN - } - }); - return; - } - - next(); - } catch (error) { - logger.error("Error in metrics dashboard authentication:", error); - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ - error: { - code: "METRICS_DASHBOARD_AUTH_ERROR", - message: "An error occurred during metrics dashboard authentication", - status: httpStatus.INTERNAL_SERVER_ERROR - } - }); - } -} - -function safeCompare(a: string, b: string): boolean { - const bufA = Buffer.from(a); - const bufB = Buffer.from(b); - if (bufA.length !== bufB.length) { - const dummyBuf = Buffer.alloc(bufA.length); - crypto.timingSafeEqual(bufA, dummyBuf); - return false; - } - return crypto.timingSafeEqual(bufA, bufB); -} +export const metricsDashboardAuth = bearerSecretAuth({ + codes: { + error: "METRICS_DASHBOARD_AUTH_ERROR", + invalidToken: "INVALID_METRICS_DASHBOARD_TOKEN", + notConfigured: "METRICS_DASHBOARD_AUTH_NOT_CONFIGURED", + required: "METRICS_DASHBOARD_AUTH_REQUIRED" + }, + envName: "METRICS_DASHBOARD_SECRET", + getSecret: () => config.metricsDashboardSecret, + label: "Metrics dashboard" +}); diff --git a/apps/api/src/api/middlewares/publicKeyAuth.ts b/apps/api/src/api/middlewares/publicKeyAuth.ts index d5b149d8bc..6709b5b76e 100644 --- a/apps/api/src/api/middlewares/publicKeyAuth.ts +++ b/apps/api/src/api/middlewares/publicKeyAuth.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; +import { sendError } from "../helpers/sendError"; import { buildApiClientRequestMetadata, getSafeApiKeyPrefix, @@ -34,9 +35,7 @@ export function validatePublicKey() { 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 } - }); + return sendError(res, 403, "CREDENTIAL_MISMATCH", "Public credential values do not match"); } const apiKey = headerKey || legacyKey; @@ -48,26 +47,19 @@ export function validatePublicKey() { // Validate API key format if (!isValidApiKeyFormat(apiKey)) { recordPublicKeyFailure(req, 400, getSafeApiKeyPrefix(apiKey)); - return res.status(400).json({ - error: { - code: "INVALID_API_KEY_FORMAT", - message: "Invalid API key format. Expected: pk_live_* or pk_test_*", - status: 400 - } - }); + return sendError(res, 400, "INVALID_API_KEY_FORMAT", "Invalid API key format. Expected: pk_live_* or pk_test_*"); } // Check if it's a public key const keyType = getKeyType(apiKey); if (keyType !== "public") { recordPublicKeyFailure(req, 400, getSafeApiKeyPrefix(apiKey)); - return res.status(400).json({ - error: { - code: "INVALID_KEY_TYPE", - message: "Expected a public API key (pk_*). Use X-API-Key header for secret keys.", - status: 400 - } - }); + return sendError( + res, + 400, + "INVALID_KEY_TYPE", + "Expected a public API key (pk_*). Use X-API-Key header for secret keys." + ); } // Validate the public key exists and is active @@ -75,13 +67,7 @@ export function validatePublicKey() { if (!result) { recordPublicKeyFailure(req, 401, getSafeApiKeyPrefix(apiKey)); - return res.status(401).json({ - error: { - code: "INVALID_PUBLIC_KEY", - message: "The provided public API key is invalid, expired, or inactive", - status: 401 - } - }); + return sendError(res, 401, "INVALID_PUBLIC_KEY", "The provided public API key is invalid, expired, or inactive"); } // Attach validated public key info to request diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 5d17411350..8f70a50625 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -23,7 +23,6 @@ import { RampDirection, SubmitKybInformationRequest, SubmitKycInformationRequest, - TokenConfig, VALID_CRYPTO_CURRENCIES, VALID_FIAT_CURRENCIES, VALID_PROVIDERS @@ -61,12 +60,6 @@ interface ChangeOpBody extends CreationBody { paymentData: unknown; } -interface SwapBody { - amountRaw: string; - address: string; - token?: keyof TokenConfig; -} - interface SiweCreateBody { walletAddress: string; } @@ -274,54 +267,6 @@ export const validateStorageInput = validateRequestBodyValuesForTransactionStore export const validateContactInput = validateRequestBodyValues(CONTACT_SHEET_HEADER_VALUES); export const validateEmailInput = validateRequestBodyValues(EMAIL_SHEET_HEADER_VALUES); export const validateRatingInput = validateRequestBodyValues(RATING_SHEET_HEADER_VALUES); -export const validateExecuteXCM = validateRequestBodyValues(["id", "payload"]); - -export const validatePreSwapSubsidizationInput: RequestHandler = (req, res, next) => { - const { amountRaw, address } = req.body as SwapBody; - - if (amountRaw === undefined) { - res.status(httpStatus.BAD_REQUEST).json({ error: 'Missing "amountRaw" parameter' }); - return; - } - - if (typeof amountRaw !== "string") { - res.status(httpStatus.BAD_REQUEST).json({ error: '"amountRaw" parameter must be a string' }); - return; - } - - if (address === undefined) { - res.status(httpStatus.BAD_REQUEST).json({ error: 'Missing "address" parameter' }); - return; - } - - next(); -}; - -export const validatePostSwapSubsidizationInput: RequestHandler = (req, res, next) => { - const { amountRaw, address, token } = req.body as Required; - - if (amountRaw === undefined) { - res.status(httpStatus.BAD_REQUEST).json({ error: 'Missing "amountRaw" parameter' }); - return; - } - - if (typeof amountRaw !== "string") { - res.status(httpStatus.BAD_REQUEST).json({ error: '"amountRaw" parameter must be a string' }); - return; - } - - if (address === undefined) { - res.status(httpStatus.BAD_REQUEST).json({ error: 'Missing "address" parameter' }); - return; - } - - if (token === undefined) { - res.status(httpStatus.BAD_REQUEST).json({ error: 'Missing "token" parameter' }); - return; - } - - next(); -}; export const validateSiweCreate: RequestHandler = (req, res, next) => { const { walletAddress } = req.body as SiweCreateBody; diff --git a/apps/api/src/api/middlewares/vortexAdminAuth.ts b/apps/api/src/api/middlewares/vortexAdminAuth.ts index e521d3d521..8e615150a4 100644 --- a/apps/api/src/api/middlewares/vortexAdminAuth.ts +++ b/apps/api/src/api/middlewares/vortexAdminAuth.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; import ProfileRole from "../../models/profileRole.model"; +import { sendError } from "../helpers/sendError"; import { rejectImpersonation } from "./bearerPrincipal"; import { requireAuth } from "./supabaseAuth"; @@ -11,13 +12,7 @@ export async function hasVortexAdminRole(userId: string): Promise { /** Shared with the routes that gate on the role inline instead of via `requireVortexAdmin`. */ export function vortexAdminRequiredResponse(res: Response): void { - res.status(httpStatus.FORBIDDEN).json({ - error: { - code: "VORTEX_ADMIN_REQUIRED", - message: "The vortex_admin role is required for this action.", - status: httpStatus.FORBIDDEN - } - }); + sendError(res, httpStatus.FORBIDDEN, "VORTEX_ADMIN_REQUIRED", "The vortex_admin role is required for this action."); } async function checkVortexAdminRole(req: Request, res: Response, next: NextFunction): Promise { diff --git a/apps/api/src/api/routes/v1/moonbeam.route.ts b/apps/api/src/api/routes/v1/moonbeam.route.ts deleted file mode 100644 index a41b1fd366..0000000000 --- a/apps/api/src/api/routes/v1/moonbeam.route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Router } from "express"; -import { executeXcmController } from "../../controllers/moonbeam.controller"; -import { validateExecuteXCM } from "../../middlewares/validators"; - -const router: Router = Router(); - -router.route("/execute-xcm").post(validateExecuteXCM, executeXcmController); - -export default router; diff --git a/apps/api/src/api/routes/v1/pendulum.route.ts b/apps/api/src/api/routes/v1/pendulum.route.ts deleted file mode 100644 index 1daf536f94..0000000000 --- a/apps/api/src/api/routes/v1/pendulum.route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Router } from "express"; -import { fundEphemeralAccountController } from "../../controllers/pendulum.controller"; - -const router: Router = Router(); - -router.post("/fundEphemeral", fundEphemeralAccountController); - -export default router; diff --git a/apps/api/src/api/routes/v1/subsidize.route.ts b/apps/api/src/api/routes/v1/subsidize.route.ts deleted file mode 100644 index 7c70d71884..0000000000 --- a/apps/api/src/api/routes/v1/subsidize.route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Router } from "express"; -import * as subsidizeController from "../../controllers/subsidize.controller"; -import { validatePostSwapSubsidizationInput, validatePreSwapSubsidizationInput } from "../../middlewares/validators"; - -const router: Router = Router({ mergeParams: true }); - -router.route("/preswap").post(validatePreSwapSubsidizationInput, subsidizeController.subsidizePreSwap); -router.route("/postswap").post(validatePostSwapSubsidizationInput, subsidizeController.subsidizePostSwap); - -export default router; diff --git a/apps/api/src/api/services/alchemypay/alchemypay.service.ts b/apps/api/src/api/services/alchemypay/alchemypay.service.ts index 30b0dd8170..0d992caf2c 100644 --- a/apps/api/src/api/services/alchemypay/alchemypay.service.ts +++ b/apps/api/src/api/services/alchemypay/alchemypay.service.ts @@ -1,54 +1,114 @@ +import { createHmac } from "node:crypto"; import { AlchemyPayPriceResponse, RampDirection } from "@vortexfi/shared"; import logger from "../../../config/logger"; -import { ProviderInternalError } from "../../errors/providerErrors"; -import { fetchWithTimeout } from "../../helpers/fetchWithTimeout"; -import { createQuoteRequest } from "./request-creator"; -import { AlchemyPayResponse, processAlchemyPayResponse } from "./response-handler"; -import { getAlchemyPayNetworkCode, getCryptoCurrencyCode, getFiatCode } from "./utils"; - -type FetchResult = { - response: Response; - body: AlchemyPayResponse; +import { config } from "../../../config/vars"; +import { + InvalidAmountError, + InvalidParameterError, + ProviderInternalError, + UnsupportedPairError +} from "../../errors/providerErrors"; +import { fetchProviderJson } from "../../helpers/fetchProviderJson"; + +interface AlchemyPayResponse { + success: boolean; + returnMsg?: string; + data?: { + cryptoPrice: string; + rampFee: string; + networkFee: string; + fiatQuantity: string; + cryptoQuantity: string; + }; +} + +const QUOTE_PATH = "/open/api/v4/merchant/order/quote"; +const DEBIT_CARD_PAY_WAY_CODE = "10001"; +const DEFAULT_NETWORK = "POLYGON"; + +const NETWORK_MAP: Record = { + ARBITRUM: "ARBITRUM", + AVALANCHE: "AVAX", + BSC: "BSC", + ETHEREUM: "ETH", + POLYGON: "MATIC" }; /** - * Fetch data from AlchemyPay API - * @param url The URL to fetch - * @param request The request options - * @returns The response and parsed body + * Builds the signed quote request. AlchemyPay's signature is + * base64(HMAC-SHA256(timestamp + METHOD + path + JSON body with empty values dropped and keys sorted)). */ -async function fetchAlchemyPayData(url: string, request: RequestInit): Promise { - try { - const response = await fetchWithTimeout(url, request); - const body = (await response.json()) as AlchemyPayResponse; - return { body, response }; - } catch (fetchError) { - logger.error("AlchemyPay fetch error:", fetchError); - throw new ProviderInternalError(`Network error fetching price from AlchemyPay: ${(fetchError as Error).message}`); - } +function createQuoteRequest(direction: RampDirection, crypto: string, fiat: string, amount: string, network: string) { + const { secretKey, baseUrl, appId } = config.priceProviders.alchemyPay; + if (!secretKey || !appId) throw new Error("AlchemyPay configuration missing"); + + const isBuy = direction === RampDirection.BUY; + const requestUrl = baseUrl + QUOTE_PATH; + const timestamp = String(Date.now()); + + const requestBody: Record = { + amount, + crypto, + fiat, + network, + ...(isBuy ? { payWayCode: DEBIT_CARD_PAY_WAY_CODE } : {}), + side: isBuy ? "BUY" : "SELL" + }; + const body = JSON.stringify( + Object.fromEntries( + Object.entries(requestBody) + .filter(([, value]) => value !== "") + .sort(([aKey], [bKey]) => aKey.localeCompare(bKey)) + ) + ); + + const sign = createHmac("sha256", secretKey.trim()) + .update(`${timestamp}POST${new URL(requestUrl).pathname}${body}`) + .digest("base64"); + + return { + request: { body, headers: { appId, "Content-Type": "application/json", sign, timestamp }, method: "POST" }, + requestUrl + }; } -/** - * Query the AlchemyPay API for price quotes - * @param cryptoCurrencyCode The cryptocurrency code - * @param fiatCurrencyCode The fiat currency code - * @param amount The amount to convert - * @param network The blockchain network - * @param direction The direction of the conversion - * @returns Standardized price response - */ -async function priceQuery( - cryptoCurrencyCode: string, - fiatCurrencyCode: string, - amount: string, - network: string, - direction: RampDirection -): Promise { - const { requestUrl, request } = createQuoteRequest(direction, cryptoCurrencyCode, fiatCurrencyCode, amount, network); +/** Always throws: classifies a non-2xx response. */ +function handleHttpError(response: Response, body: AlchemyPayResponse): never { + const errorMessage = body?.returnMsg || `HTTP error ${response.status}: ${response.statusText}`; + logger.error(`AlchemyPay API Error (${response.status}): ${errorMessage}`); - const { response, body } = await fetchAlchemyPayData(requestUrl, request); + if (response.status >= 500) { + throw new ProviderInternalError(`AlchemyPay server error: ${errorMessage}`); + } + if (response.status >= 400) { + const lowerErrorMessage = errorMessage.toLowerCase(); + if (/minimum|maximum/.test(lowerErrorMessage)) { + throw new InvalidAmountError(`AlchemyPay: ${errorMessage}`); + } + if (/unsupported|invalid currency/.test(lowerErrorMessage)) { + throw new UnsupportedPairError(`AlchemyPay: ${errorMessage}`); + } + throw new InvalidParameterError(`AlchemyPay API error: ${errorMessage}`); + } + throw new ProviderInternalError(`Unexpected HTTP status ${response.status} from AlchemyPay: ${errorMessage}`); +} + +/** Always throws: classifies a 2xx response with success=false. */ +function handleLogicError(body: AlchemyPayResponse): never { + const errorMessage = body.returnMsg || "AlchemyPay API returned success=false with no message"; + logger.error(`AlchemyPay API Logic Error: ${errorMessage}`); - return processAlchemyPayResponse(response, body, amount, direction); + const lowerErrorMessage = errorMessage.toLowerCase(); + if (/minimum|maximum/.test(lowerErrorMessage)) { + throw new InvalidAmountError(`AlchemyPay: ${errorMessage}`); + } + if (/unsupported|invalid currency/.test(lowerErrorMessage)) { + throw new UnsupportedPairError(`AlchemyPay: ${errorMessage}`); + } + if (lowerErrorMessage.includes("invalid parameter")) { + throw new InvalidParameterError(`AlchemyPay: ${errorMessage}`); + } + throw new ProviderInternalError(`AlchemyPay API logic error: ${errorMessage}`); } /** @@ -58,28 +118,48 @@ async function priceQuery( * @param amount The amount to convert * @param direction The direction of the conversion (onramp or offramp) * @param network Optional network name - * @returns AlchemyPay price information in standardized format */ -export const getPriceFor = ( +export async function getPriceFor( sourceCurrency: string, targetCurrency: string, amount: string | number, direction: RampDirection, network?: string -): Promise => { - const DEFAULT_NETWORK = "POLYGON"; - const networkCode = getAlchemyPayNetworkCode(network || DEFAULT_NETWORK); - - // For offramp: source is crypto, target is fiat - // For onramp: source is fiat, target is crypto - const cryptoCurrency = direction === RampDirection.BUY ? targetCurrency : sourceCurrency; - const fiatCurrency = direction === RampDirection.BUY ? sourceCurrency : targetCurrency; - - return priceQuery( - getCryptoCurrencyCode(cryptoCurrency), - getFiatCode(fiatCurrency), - amount.toString(), - networkCode, - direction +): Promise { + const requestedNetwork = network || DEFAULT_NETWORK; + const networkCode = NETWORK_MAP[requestedNetwork.toUpperCase()] ?? requestedNetwork; + + // For offramp: source is crypto, target is fiat. For onramp: source is fiat, target is crypto. + const isBuy = direction === RampDirection.BUY; + const requestedAmount = amount.toString(); + + const { requestUrl, request } = createQuoteRequest( + direction, + (isBuy ? targetCurrency : sourceCurrency).toUpperCase(), + (isBuy ? sourceCurrency : targetCurrency).toUpperCase(), + requestedAmount, + networkCode ); -}; + const { response, body } = await fetchProviderJson("AlchemyPay", requestUrl, request); + + if (!response.ok) { + return handleHttpError(response, body); + } + if (!body.success) { + return handleLogicError(body); + } + if (!body.data) { + throw new ProviderInternalError("AlchemyPay API returned success=true but no data field"); + } + + const { rampFee, networkFee, fiatQuantity, cryptoQuantity } = body.data; + const totalFee = (Number(rampFee) || 0) + (Number(networkFee) || 0); + return { + direction, + provider: "alchemypay", + // `fiatQuantity` does not include the fees (per the response sample), so they are subtracted for SELL. + quoteAmount: isBuy ? Number(cryptoQuantity) : Math.max(0, (Number(fiatQuantity) || 0) - totalFee), + requestedAmount: Number(requestedAmount), + totalFee + }; +} diff --git a/apps/api/src/api/services/alchemypay/helpers.ts b/apps/api/src/api/services/alchemypay/helpers.ts deleted file mode 100644 index 8ec6deb09f..0000000000 --- a/apps/api/src/api/services/alchemypay/helpers.ts +++ /dev/null @@ -1,108 +0,0 @@ -import logger from "../../../config/logger"; - -/** - * Remove empty keys from an object - * @param map The object to remove empty keys from - * @returns The object without empty keys - */ -export function removeEmptyKeys(map: Record): Record { - return Object.entries(map).reduce( - (acc, [key, value]) => { - if (value !== null && value !== "") { - acc[key] = value; - } - return acc; - }, - {} as Record - ); -} - -/** - * Sort an object recursively - * @param obj The object to sort - * @returns The sorted object - */ -export function sortObject(obj: unknown): Record | unknown[] | unknown { - if (typeof obj !== "object" || obj === null) { - return obj; - } - - if (Array.isArray(obj)) { - const intList: number[] = []; - const floatList: number[] = []; - const stringList: string[] = []; - const jsonArray: object[] = []; - - obj.forEach(item => { - if (typeof item === "object" && item !== null) { - jsonArray.push(item as object); - } else if (Number.isInteger(item)) { - intList.push(item as number); - } else if (typeof item === "number") { - floatList.push(item); - } else if (typeof item === "string") { - stringList.push(item); - } else { - intList.push(Number(item)); - } - }); - - intList.sort((a, b) => a - b); - floatList.sort((a, b) => a - b); - stringList.sort(); - - const newList = [...intList, ...floatList, ...stringList, ...jsonArray]; - return newList.map(item => (typeof item === "object" ? sortObject(item) : item)); - } - - const sortedMap = new Map( - Object.entries(removeEmptyKeys(obj as Record)).sort(([aKey], [bKey]) => aKey.localeCompare(bKey)) - ); - - return Object.fromEntries( - Array.from(sortedMap.entries()).map(([key, value]) => [key, typeof value === "object" ? sortObject(value) : value]) - ); -} - -/** - * Get the path part of a URL with sorted query parameters - * @param requestUrl The URL - * @returns The path with sorted query parameters - */ -export function getPath(requestUrl: string): string { - const uri = new URL(requestUrl); - const path = uri.pathname; - const params = Array.from(uri.searchParams.entries()); - - if (params.length === 0) { - return path; - } - const sortedParams = [...params].sort(([aKey], [bKey]) => aKey.localeCompare(bKey)); - const queryString = sortedParams.map(([key, value]) => `${key}=${value}`).join("&"); - return `${path}?${queryString}`; -} - -/** - * Get the JSON body for a request, sorted for API signing - * @param body The body string - * @returns The sorted JSON body - */ -export function getJsonBody(body: string): string { - let map: Record; - - try { - map = JSON.parse(body); - } catch (error) { - map = {}; - logger.error("Couldn't parse JSON body", error); - } - - if (Object.keys(map).length === 0) { - return ""; - } - - map = removeEmptyKeys(map); - map = sortObject(map) as Record; - - return JSON.stringify(map); -} diff --git a/apps/api/src/api/services/alchemypay/request-creator.ts b/apps/api/src/api/services/alchemypay/request-creator.ts deleted file mode 100644 index 850f9dcba3..0000000000 --- a/apps/api/src/api/services/alchemypay/request-creator.ts +++ /dev/null @@ -1,156 +0,0 @@ -import crypto from "node:crypto"; -import { RampDirection } from "@vortexfi/shared"; -import { config } from "../../../config/vars"; -import { getJsonBody, getPath } from "./helpers"; - -/** - * Generate API signature for AlchemyPay - * @param timestamp The timestamp - * @param method The HTTP method - * @param requestUrl The request URL - * @param body The request body - * @param secretKey The secret key - * @returns The API signature - */ -function apiSign(timestamp: string, method: string, requestUrl: string, body: string, secretKey: string): string { - const content = timestamp + method.toUpperCase() + getPath(requestUrl) + getJsonBody(body); - return crypto.createHmac("sha256", secretKey).update(content).digest("base64"); -} - -const PAYMENT_METHODS = { - DEBIT_CARD: "10001" -} as const; - -type RequestConfig = { - requestUrl: string; - request: RequestInit; -}; - -/** - * Create a buy (onramp) quote request - * @param cryptoCurrency The cryptocurrency code - * @param fiat The fiat currency code - * @param amount The amount to convert - * @param network The blockchain network - * @returns Request configuration - */ -function createBuyQuoteRequest( - cryptoCurrency: string, - fiat: string, - amount: string, - network: string, - appId: string, - secretKey: string, - baseUrl: string -): RequestConfig { - const httpMethod = "POST"; - const requestPath = "/open/api/v4/merchant/order/quote"; - const requestUrl = baseUrl + requestPath; - const timestamp = String(Date.now()); - - const requestBody: Record = { - amount, - crypto: cryptoCurrency, - fiat, - network, - payWayCode: PAYMENT_METHODS.DEBIT_CARD, - side: "BUY" - }; - - const bodyString = JSON.stringify(requestBody); - const sortedBody = getJsonBody(bodyString); - - const signature = apiSign(timestamp, httpMethod, requestUrl, sortedBody, secretKey.trim()); - - const headers = { - appId, - "Content-Type": "application/json", - sign: signature, - timestamp - } as const; - - return { - request: { - body: sortedBody, - headers, - method: httpMethod - }, - requestUrl - }; -} - -/** - * Create a sell (offramp) quote request - * @param cryptoCurrency The cryptocurrency code - * @param fiat The fiat currency code - * @param amount The amount to convert - * @param network The blockchain network - * @returns Request configuration - */ -function createSellQuoteRequest( - cryptoCurrency: string, - fiat: string, - amount: string, - network: string, - appId: string, - secretKey: string, - baseUrl: string -): RequestConfig { - const httpMethod = "POST"; - const requestPath = "/open/api/v4/merchant/order/quote"; - const requestUrl = baseUrl + requestPath; - const timestamp = String(Date.now()); - - const requestBody: Record = { - amount, - crypto: cryptoCurrency, - fiat, - network, - side: "SELL" - }; - - const bodyString = JSON.stringify(requestBody); - const sortedBody = getJsonBody(bodyString); - - const signature = apiSign(timestamp, httpMethod, requestUrl, sortedBody, secretKey.trim()); - - const headers = { - appId, - "Content-Type": "application/json", - sign: signature, - timestamp - } as const; - - return { - request: { - body: sortedBody, - headers, - method: httpMethod - }, - requestUrl - }; -} - -/** - * Create a quote request based on direction - * @param direction The direction of the conversion (onramp or offramp) - * @param cryptoCurrencyCode The cryptocurrency code - * @param fiatCurrencyCode The fiat currency code - * @param amount The amount to convert - * @param network The blockchain network - * @returns Request configuration - */ -export function createQuoteRequest( - direction: RampDirection, - cryptoCurrencyCode: string, - fiatCurrencyCode: string, - amount: string, - network: string -): RequestConfig { - const { secretKey, baseUrl, appId } = config.priceProviders.alchemyPay; - if (!secretKey || !appId) throw new Error("AlchemyPay configuration missing"); - - return direction === RampDirection.BUY - ? createBuyQuoteRequest(cryptoCurrencyCode, fiatCurrencyCode, amount, network, appId, secretKey, baseUrl) - : createSellQuoteRequest(cryptoCurrencyCode, fiatCurrencyCode, amount, network, appId, secretKey, baseUrl); -} diff --git a/apps/api/src/api/services/alchemypay/response-handler.ts b/apps/api/src/api/services/alchemypay/response-handler.ts deleted file mode 100644 index 330ce9c856..0000000000 --- a/apps/api/src/api/services/alchemypay/response-handler.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { AlchemyPayPriceResponse, RampDirection } from "@vortexfi/shared"; -import logger from "../../../config/logger"; -import { - InvalidAmountError, - InvalidParameterError, - ProviderInternalError, - UnsupportedPairError -} from "../../errors/providerErrors"; - -export interface AlchemyPayResponse { - success: boolean; - returnMsg?: string; - data?: { - cryptoPrice: string; - rampFee: string; - networkFee: string; - fiatQuantity: string; - cryptoQuantity: string; - }; -} - -/** - * Handle HTTP errors from AlchemyPay - * @param response The HTTP response - * @param body The response body - * @returns Never returns, always throws an appropriate error - */ -function handleHttpError(response: Response, body: AlchemyPayResponse): never { - const errorMessage = body?.returnMsg || `HTTP error ${response.status}: ${response.statusText}`; - logger.error(`AlchemyPay API Error (${response.status}): ${errorMessage}`); - - if (response.status >= 500) { - throw new ProviderInternalError(`AlchemyPay server error: ${errorMessage}`); - } else if (response.status >= 400) { - // Try to map 4xx errors based on message - const lowerErrorMessage = errorMessage.toLowerCase(); - if (lowerErrorMessage.includes("minimum") || lowerErrorMessage.includes("maximum")) { - throw new InvalidAmountError(`AlchemyPay: ${errorMessage}`); - } - if (lowerErrorMessage.includes("unsupported") || lowerErrorMessage.includes("invalid currency")) { - throw new UnsupportedPairError(`AlchemyPay: ${errorMessage}`); - } - // Default 4xx to InvalidParameterError - throw new InvalidParameterError(`AlchemyPay API error: ${errorMessage}`); - } else { - // Other non-2xx errors - throw new ProviderInternalError(`Unexpected HTTP status ${response.status} from AlchemyPay: ${errorMessage}`); - } -} - -/** - * Handle logic errors from AlchemyPay (success=false) - * @param body The response body - * @returns Never returns, always throws an appropriate error - */ -function handleLogicError(body: AlchemyPayResponse): never { - const errorMessage = body.returnMsg || "AlchemyPay API returned success=false with no message"; - logger.error(`AlchemyPay API Logic Error: ${errorMessage}`); - - // Analyze returnMsg for specific errors - const lowerErrorMessage = errorMessage.toLowerCase(); - if (lowerErrorMessage.includes("minimum") || lowerErrorMessage.includes("maximum")) { - throw new InvalidAmountError(`AlchemyPay: ${errorMessage}`); - } - if (lowerErrorMessage.includes("unsupported") || lowerErrorMessage.includes("invalid currency")) { - throw new UnsupportedPairError(`AlchemyPay: ${errorMessage}`); - } - if (lowerErrorMessage.includes("invalid parameter")) { - throw new InvalidParameterError(`AlchemyPay: ${errorMessage}`); - } - throw new ProviderInternalError(`AlchemyPay API logic error: ${errorMessage}`); -} - -/** - * Parse successful response from AlchemyPay - * @param data The response data - * @param requestedAmount The amount that was requested - * @param direction The direction of the conversion (onramp or offramp) - * @returns Standardized price response - */ -function parseSuccessResponse( - data: AlchemyPayResponse["data"], - requestedAmount: string, - direction: RampDirection -): AlchemyPayPriceResponse { - if (!data) { - throw new ProviderInternalError("AlchemyPay response data is undefined"); - } - - const { rampFee, networkFee, fiatQuantity, cryptoQuantity } = data; - - const totalFee = (Number(rampFee) || 0) + (Number(networkFee) || 0); - // According to a comment in the response sample, the `fiatQuantity` does not yet include the fees - // so we need to subtract them. - const fiatAmount = - direction === RampDirection.BUY ? Number(requestedAmount) : Math.max(0, (Number(fiatQuantity) || 0) - totalFee); - const cryptoAmount = direction === RampDirection.BUY ? Number(cryptoQuantity) : Number(requestedAmount); - - return { - direction, - provider: "alchemypay", - quoteAmount: direction === RampDirection.BUY ? cryptoAmount : fiatAmount, - requestedAmount: Number(requestedAmount), - totalFee - }; -} - -/** - * Process AlchemyPay API response - * @param response The HTTP response - * @param body The response body - * @param requestedAmount The amount that was requested - * @param direction The direction of the conversion (onramp or offramp) - * @returns Standardized price response - */ -export function processAlchemyPayResponse( - response: Response, - body: AlchemyPayResponse, - requestedAmount: string, - direction: RampDirection -): AlchemyPayPriceResponse { - if (!response.ok) { - // Handle HTTP errors (4xx, 5xx) - return handleHttpError(response, body); - } - - // Handle cases where response is ok (2xx) but success flag is false - if (!body.success) { - return handleLogicError(body); - } - - if (!body.data) { - throw new ProviderInternalError("AlchemyPay API returned success=true but no data field"); - } - - return parseSuccessResponse(body.data, requestedAmount, direction); -} diff --git a/apps/api/src/api/services/alchemypay/utils.ts b/apps/api/src/api/services/alchemypay/utils.ts deleted file mode 100644 index c3cd61b8af..0000000000 --- a/apps/api/src/api/services/alchemypay/utils.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { EvmToken } from "@vortexfi/shared"; - -const NETWORK_MAP: Record = { - ARBITRUM: "ARBITRUM", - AVALANCHE: "AVAX", - BSC: "BSC", - ETHEREUM: "ETH", - POLYGON: "MATIC" -}; - -const CRYPTO_MAP: Record = { - [EvmToken.ETH]: "ETH", - [EvmToken.USDC]: "USDC", - [EvmToken.USDCE]: "USDC.e", - [EvmToken.USDT]: "USDT" -}; - -/** - * Get the AlchemyPay network code - * @param network The network name - * @returns The AlchemyPay network code - */ -export function getAlchemyPayNetworkCode(network: string): string { - return NETWORK_MAP[network.toUpperCase()] ?? network; -} - -/** - * Get the cryptocurrency code for AlchemyPay - * @param fromCrypto The cryptocurrency code - * @returns The AlchemyPay cryptocurrency code - */ -export function getCryptoCurrencyCode(fromCrypto: string): string { - return CRYPTO_MAP[fromCrypto.toLowerCase()] ?? fromCrypto.toUpperCase(); -} - -/** - * Get the fiat currency code for AlchemyPay - * @param toFiat The fiat currency code - * @returns The AlchemyPay fiat currency code - */ -export function getFiatCode(toFiat: string): string { - // The currencies need to be in uppercase - return toFiat.toUpperCase(); -} diff --git a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts index f30c3f5b04..0821e7fbb9 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts @@ -7,6 +7,7 @@ import { FiatToken, getAnyFiatTokenDetails, isDomesticToken, + multiplyByPowerOfTen, RampCurrency, RampDirection } from "@vortexfi/shared"; @@ -17,7 +18,6 @@ import ProviderCustomer from "../../../models/providerCustomer.model"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState from "../../../models/rampState.model"; import { getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; -import { multiplyByPowerOfTen } from "../pendulum/helpers"; import { AlfredpayLimitsService } from "./alfredpay-limits.service"; const FIAT_TO_COUNTRY: Partial> = { diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index 25d6cf4072..c792418c24 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -1,4 +1,4 @@ -import { isAuthRetryableFetchError, type User } from "@supabase/supabase-js"; +import { isAuthRetryableFetchError } from "@supabase/supabase-js"; import logger from "../../../config/logger"; import { supabase, supabaseAdmin } from "../../../config/supabase"; @@ -239,21 +239,4 @@ export class SupabaseAuthService { refresh_token: data.session.refresh_token }; } - - /** - * Get user profile from Supabase - */ - static async getUserProfile(userId: string): Promise { - const { data, error } = await supabaseAdmin.auth.admin.getUserById(userId); - - if (error) { - throw error; - } - - if (!data.user) { - throw new Error(`Supabase user ${userId} not found`); - } - - return data.user; - } } diff --git a/apps/api/src/api/services/hydration/swap.ts b/apps/api/src/api/services/hydration/swap.ts deleted file mode 100644 index 337a27ede6..0000000000 --- a/apps/api/src/api/services/hydration/swap.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createSdkContext, PoolType, SdkCtx, SubstrateTransaction, Trade } from "@galacticcouncil/sdk"; -import { Builder } from "@paraspell/sdk-pjs"; -import { ApiManager, AssetHubToken, assetHubTokenConfig, multiplyByPowerOfTen, XcmFees } from "@vortexfi/shared"; -import logger from "../../../config/logger"; - -/// The IDs of the Hydration assets for which the XCM fees are cached -const CACHED_ASSET_IDS = [ - assetHubTokenConfig[AssetHubToken.USDC].hydrationId, - assetHubTokenConfig[AssetHubToken.DOT].hydrationId, - assetHubTokenConfig[AssetHubToken.USDT].hydrationId -]; - -export class HydrationRouter { - private sdk?: Promise; - private cachedXcmFees: Record; - private xcmFeeRefreshInterval?: ReturnType; - - constructor() { - this.cachedXcmFees = {}; - } - - private getSdk(): Promise { - if (!this.sdk) { - const apiManager = ApiManager.getInstance(); - this.sdk = apiManager - .getApi("hydration") - .then(async ({ api }) => { - return createSdkContext(api, { - router: { includeOnly: [PoolType.Omni, PoolType.Stable, PoolType.Aave] } - }); - }) - .catch(error => { - this.sdk = undefined; - throw error; - }); - } - - return this.sdk; - } - - async getBestSellPriceFor(assetIn: string, assetOut: string, amountIn: string): Promise { - const sdk = await this.getSdk(); - return sdk.api.router.getBestSell(assetIn, assetOut, amountIn); - } - - async createTransactionForTrade(trade: Trade, beneficiaryAddress: string, slippage = 0.1): Promise { - const sdk = await this.getSdk(); - const txBuilder = sdk.tx.trade(trade); - txBuilder.withBeneficiary(beneficiaryAddress); - txBuilder.withSlippage(slippage); - - return await txBuilder.build(); - } - - async getXcmTransactionFeeToAssethub(assetId: string) { - if (this.cachedXcmFees[assetId]) { - return this.cachedXcmFees[assetId]; - } else { - await this.refreshCachedXcmTransactionFeeToAssethub(); - this.startXcmFeeRefreshInterval(); - return this.cachedXcmFees[assetId]; - } - } - - private startXcmFeeRefreshInterval() { - if (!this.xcmFeeRefreshInterval) { - this.xcmFeeRefreshInterval = setInterval( - () => { - this.refreshCachedXcmTransactionFeeToAssethub().catch(error => { - const message = error instanceof Error ? error.message : String(error); - logger.error(`HydrationRouter: Error refreshing cached XCM transaction fees: ${message}`); - }); - }, - 60 * 60 * 1000 - ); - } - } - - private async refreshCachedXcmTransactionFeeToAssethub() { - logger.info("HydrationRouter: Refreshing cached XCM transaction fees.."); - const placeholderSenderAddress = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - const placeholderReceiverAddress = "5DqTNJsGp6UayR5iHAZvH4zquY6ni6j35ZXLtJA6bXwsfixg"; - - for (const assetId of CACHED_ASSET_IDS) { - const tx = Builder() - .from("Hydration") - .to("AssetHubPolkadot") - .address(placeholderReceiverAddress) - .senderAddress(placeholderSenderAddress) - .currency({ - amount: "100000000", - id: assetId - }); - - const info = await tx.getXcmFeeEstimate(); - - const destinationAmountRaw = info.destination.fee.toString(); - // The destination fee is always in AssetHub DOT which has 10 decimals - const destinationAmountDecimals = multiplyByPowerOfTen(destinationAmountRaw, -10).toString(); - - const originAmountRaw = info.origin.fee.toString(); - // The origin fee is always in HDX which has 12 decimals - const originAmountDecimals = multiplyByPowerOfTen(originAmountRaw, -12).toString(); - - this.cachedXcmFees[assetId] = { - destination: { - amount: destinationAmountDecimals, - amountRaw: destinationAmountRaw, - currency: info.destination.currency - }, - origin: { - amount: originAmountDecimals, - amountRaw: originAmountRaw, - currency: info.origin.currency - } - }; - } - logger.info("HydrationRouter: Done refreshing cached XCM transaction fees."); - } -} - -const hydrationRouter = new HydrationRouter(); -export default hydrationRouter; diff --git a/apps/api/src/api/services/maintenance.service.ts b/apps/api/src/api/services/maintenance.service.ts index 0ea787da2b..1490006409 100644 --- a/apps/api/src/api/services/maintenance.service.ts +++ b/apps/api/src/api/services/maintenance.service.ts @@ -55,27 +55,6 @@ export class MaintenanceService { } } - /** - * Add a new maintenance schedule to the database - */ - public async addSchedule(scheduleData: { - title: string; - startDatetime: Date; - endDatetime: Date; - messageToDisplay: string; - isActiveConfig: boolean; - notes?: string; - }): Promise { - try { - const newSchedule = await MaintenanceSchedule.create(scheduleData); - logger.info(`Added new maintenance schedule: ${newSchedule.title} (${newSchedule.id})`); - return newSchedule; - } catch (error) { - logger.error("Error adding maintenance schedule:", error); - throw new Error("Failed to add maintenance schedule"); - } - } - /** * Update the active status of a maintenance schedule */ @@ -157,75 +136,6 @@ export class MaintenanceService { throw new Error("Failed to retrieve maintenance status"); } } - - /** - * Get a specific maintenance schedule by ID - */ - public async getScheduleById(id: string): Promise { - try { - const schedule = await MaintenanceSchedule.findByPk(id); - return schedule; - } catch (error) { - logger.error("Error fetching maintenance schedule by ID:", error); - throw new Error("Failed to retrieve maintenance schedule"); - } - } - - /** - * Delete a maintenance schedule - */ - public async deleteSchedule(id: string): Promise { - try { - const deletedRowsCount = await MaintenanceSchedule.destroy({ - where: { id } - }); - - if (deletedRowsCount === 0) { - logger.warn(`Maintenance schedule not found for deletion: ${id}`); - return false; - } - - logger.info(`Deleted maintenance schedule: ${id}`); - return true; - } catch (error) { - logger.error("Error deleting maintenance schedule:", error); - throw new Error("Failed to delete maintenance schedule"); - } - } - - /** - * Update a maintenance schedule - */ - public async updateSchedule( - id: string, - updateData: Partial<{ - title: string; - startDatetime: Date; - endDatetime: Date; - messageToDisplay: string; - isActiveConfig: boolean; - notes: string; - }> - ): Promise { - try { - const [updatedRowsCount] = await MaintenanceSchedule.update(updateData, { - where: { id } - }); - - if (updatedRowsCount === 0) { - logger.warn(`Maintenance schedule not found for update: ${id}`); - return null; - } - - // Fetch and return the updated schedule - const updatedSchedule = await MaintenanceSchedule.findByPk(id); - logger.info(`Updated maintenance schedule: ${id}`); - return updatedSchedule; - } catch (error) { - logger.error("Error updating maintenance schedule:", error); - throw new Error("Failed to update maintenance schedule"); - } - } } // Export singleton instance diff --git a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts index 93d3d8a363..d97c9d8cc8 100644 --- a/apps/api/src/api/services/monerium-b2b/account-provisioning.ts +++ b/apps/api/src/api/services/monerium-b2b/account-provisioning.ts @@ -5,11 +5,11 @@ import { config } from "../../../config/vars"; import KycCase from "../../../models/kycCase.model"; import MoneriumAccount, { MoneriumAccountStatus } from "../../../models/moneriumAccount.model"; import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import { UUID_PATTERN } from "../../helpers/uuid"; import { type ProvisionManagedProfileResult, provisionManagedProfile } from "../managed-profile-provisioning.service"; import { getPublicClient } from "./chain"; const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/i; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export class MoneriumB2bProvisioningError extends Error { constructor( diff --git a/apps/api/src/api/services/monerium-b2b/webhook.ts b/apps/api/src/api/services/monerium-b2b/webhook.ts index 6480deb805..c413df7a07 100644 --- a/apps/api/src/api/services/monerium-b2b/webhook.ts +++ b/apps/api/src/api/services/monerium-b2b/webhook.ts @@ -1,5 +1,6 @@ import crypto from "crypto"; import MoneriumWebhookEvent from "../../../models/moneriumWebhookEvent.model"; +import { constantTimeEquals } from "../../helpers/constantTimeEquals"; /** * Monerium B2B webhook authentication + durable inbox (plan §3, R06). @@ -13,15 +14,6 @@ export const MONERIUM_ID_HEADER = "webhook-id"; export const MONERIUM_SIGNATURE_HEADER = "webhook-signature"; export const MONERIUM_TIMESTAMP_HEADER = "webhook-timestamp"; -function constantTimeEquals(a: Buffer, b: Buffer): boolean { - if (a.length !== b.length) { - // Compare against self to keep timing independent of the mismatch position. - crypto.timingSafeEqual(a, a); - return false; - } - return crypto.timingSafeEqual(a, b); -} - function decodeBase64(value: string, minBytes: number, maxBytes: number): Buffer | null { if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null; const decoded = Buffer.from(value, "base64"); diff --git a/apps/api/src/api/services/moonpay/moonpay.service.ts b/apps/api/src/api/services/moonpay/moonpay.service.ts index e55e27acdf..e3499fc176 100644 --- a/apps/api/src/api/services/moonpay/moonpay.service.ts +++ b/apps/api/src/api/services/moonpay/moonpay.service.ts @@ -1,13 +1,15 @@ -import { MoonpayPriceResponse, RampDirection } from "@vortexfi/shared"; +import { FiatToken, MoonpayPriceResponse, RampDirection } from "@vortexfi/shared"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; -import { ProviderInternalError } from "../../errors/providerErrors"; -import { fetchWithTimeout } from "../../helpers/fetchWithTimeout"; -import { createQuoteRequest } from "./request-creator"; -import { processMoonpayResponse } from "./response-handler"; -import { getCryptoCode, getFiatCode } from "./utils"; +import { + InvalidAmountError, + InvalidParameterError, + ProviderInternalError, + UnsupportedPairError +} from "../../errors/providerErrors"; +import { fetchProviderJson } from "../../helpers/fetchProviderJson"; -export interface MoonpayResponse { +interface MoonpayResponse { baseCurrencyAmount: number; baseCurrencyPrice: number; quoteCurrencyAmount: number; @@ -20,79 +22,113 @@ export interface MoonpayResponse { }; } -type FetchResult = { - response: Response; - body: MoonpayResponse; -}; +const PAYMENT_METHODS = { + CREDIT_CARD: "credit_debit_card", + PIX: "pix_instant_payment", + SEPA: "sepa_bank_transfer" +} as const; -type MoonpayError = { - type: "NETWORK" | "PARSE"; - error: Error; - response?: Response; -}; +/** Always throws: classifies a non-2xx response. */ +function handleMoonpayError(response: Response, body: Pick): never { + const errorMessage = body?.message || `HTTP error ${response.status}: ${response.statusText}`; + const errorType = body?.type; -async function fetchMoonpayData(url: string): Promise { - try { - const response = await fetchWithTimeout(url); - const body = (await response.json()) as MoonpayResponse; - return { body, response }; - } catch (error) { - const moonpayError: MoonpayError = { - error: error as Error, - response: error instanceof TypeError ? undefined : (error as { response: Response }).response, - type: error instanceof TypeError ? "NETWORK" : "PARSE" - }; + logger.error(`Moonpay API Error (${response.status}): Type: ${errorType}, Message: ${errorMessage}`); - logger.error("Moonpay error:", moonpayError); - - throw new ProviderInternalError( - moonpayError.type === "NETWORK" - ? `Network error fetching price from Moonpay: ${moonpayError.error.message}` - : `Failed to parse response from Moonpay (Status: ${moonpayError.response?.status}): ${moonpayError.response?.statusText}` - ); + const lowerErrorMessage = errorMessage.toLowerCase(); + if (errorType === "NotFoundError" || lowerErrorMessage.includes("unsupported")) { + throw new UnsupportedPairError(`Moonpay: ${errorMessage}`); + } + if (/minimum|maximum|limit/.test(lowerErrorMessage)) { + throw new InvalidAmountError(`Moonpay: ${errorMessage}`); } + if (errorType === "BadRequestError" || response.status === 400) { + throw new InvalidParameterError(`Moonpay: ${errorMessage}`); + } + if (response.status >= 500) { + throw new ProviderInternalError(`Moonpay server error: ${errorMessage}`); + } + throw new InvalidParameterError(`Moonpay API error: ${errorMessage}`); } /** + * Get price information from Moonpay * https://dev.moonpay.com/reference/getbuyquote * https://dev.moonpay.com/reference/getsellquote + * @param sourceCurrency The source currency (crypto for offramp, fiat for onramp) + * @param targetCurrency The target currency (fiat for offramp, crypto for onramp) + * @param amount The amount to convert + * @param direction The direction of the conversion (onramp or offramp) */ -async function priceQuery( - cryptoCurrencyCode: string, - fiatCurrencyCode: string, +export async function getPriceFor( + sourceCurrency: string, + targetCurrency: string, amount: string, - extraFeePercentage: number, direction: RampDirection ): Promise { + const isBuy = direction === RampDirection.BUY; + const cryptoCode = (isBuy ? targetCurrency : sourceCurrency).toLowerCase(); + const fiatCode = (isBuy ? sourceCurrency : targetCurrency).toLowerCase(); + // Moonpay lists USDC on Polygon as its own currency + const moonpayCrypto = ["usdc", "usdc.e", "usdce"].includes(cryptoCode) ? "usdc_polygon" : cryptoCode; + const { baseUrl, apiKey } = config.priceProviders.moonpay; if (!apiKey) throw new Error("Moonpay API key not configured"); - const { requestPath: quoteRequestPath, params: quoteRequestParams } = createQuoteRequest( - direction, - cryptoCurrencyCode, - fiatCurrencyCode, - amount, - extraFeePercentage + // We can specify a custom fee percentage on top of the Moonpay fee for SELL quotes but we don't + const params = isBuy + ? new URLSearchParams({ + apiKey, + baseCurrencyAmount: amount, + baseCurrencyCode: fiatCode, + paymentMethod: fiatCode === "brl" ? PAYMENT_METHODS.PIX : PAYMENT_METHODS.CREDIT_CARD + }) + : new URLSearchParams({ + apiKey, + baseCurrencyAmount: amount, + extraFeePercentage: "0", + payoutMethod: fiatCode.toUpperCase() === FiatToken.EURC ? PAYMENT_METHODS.SEPA : PAYMENT_METHODS.CREDIT_CARD, + quoteCurrencyCode: fiatCode + }); + const url = `${baseUrl}/v3/currencies/${moonpayCrypto}/${isBuy ? "buy" : "sell"}_quote?${params}`; + + const { response, body } = await fetchProviderJson("Moonpay", url, undefined, error => + error instanceof TypeError + ? `Network error fetching price from Moonpay: ${error.message}` + : `Failed to parse response from Moonpay (Status: ${(error as { response?: Response }).response?.status}): ${(error as { response?: Response }).response?.statusText}` ); - const url = `${baseUrl}${quoteRequestPath}?${quoteRequestParams.toString()}`; + if (!response.ok) { + return handleMoonpayError(response, body); + } - const { response, body } = await fetchMoonpayData(url); + if (body.baseCurrencyAmount === undefined || body.quoteCurrencyAmount === undefined || body.feeAmount === undefined) { + throw new ProviderInternalError("Moonpay response missing essential data fields"); + } - return processMoonpayResponse(response, body, amount, direction); -} + const { + baseCurrencyAmount: receivedBaseCurrencyAmount, + quoteCurrencyAmount, + feeAmount, + baseCurrency: { minAmount, code } + } = body; -export const getPriceFor = ( - sourceCurrency: string, - targetCurrency: string, - amount: string, - direction: RampDirection -): Promise => { - // We can specify a custom fee percentage here added on top of the Moonpay fee but we don't - const extraFeePercentage = 0; + if (minAmount > Number(amount)) { + throw new InvalidAmountError(`Moonpay: ${minAmount} ${code} is the minimum amount for this pair`); + } - const cryptoCurrency = direction === RampDirection.BUY ? targetCurrency : sourceCurrency; - const fiatCurrency = direction === RampDirection.BUY ? sourceCurrency : targetCurrency; + if (Number(amount) !== receivedBaseCurrencyAmount) { + logger.warn(`Moonpay Warning: Requested base amount ${amount} differs from received ${receivedBaseCurrencyAmount}`); + throw new ProviderInternalError( + `Moonpay response discrepancy: Requested base amount ${amount}, received ${receivedBaseCurrencyAmount}` + ); + } - return priceQuery(getCryptoCode(cryptoCurrency), getFiatCode(fiatCurrency), amount, extraFeePercentage, direction); -}; + return { + direction, + provider: "moonpay", + quoteAmount: quoteCurrencyAmount, + requestedAmount: Number(amount), + totalFee: feeAmount + }; +} diff --git a/apps/api/src/api/services/moonpay/request-creator.ts b/apps/api/src/api/services/moonpay/request-creator.ts deleted file mode 100644 index 60ead77e3f..0000000000 --- a/apps/api/src/api/services/moonpay/request-creator.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { FiatToken, RampDirection } from "@vortexfi/shared"; -import { config } from "../../../config/vars"; - -const PAYMENT_METHODS = { - ACH: "ach_bank_transfer", - CREDIT_CARD: "credit_debit_card", - PAYPAL: "paypal", - PIX: "pix_instant_payment", - SEPA: "sepa_bank_transfer" -} as const; - -function createBuyQuoteRequest( - cryptoCurrencyCode: string, - fiatCurrencyCode: string, - fiatAmount: string -): { requestPath: string; params: URLSearchParams } { - const requestPath = `/v3/currencies/${cryptoCurrencyCode}/buy_quote`; - - const paymentMethod = fiatCurrencyCode.toLowerCase() === "brl" ? PAYMENT_METHODS.PIX : PAYMENT_METHODS.CREDIT_CARD; - - return { - params: new URLSearchParams({ - apiKey: config.priceProviders.moonpay.apiKey || "", - baseCurrencyAmount: fiatAmount, - baseCurrencyCode: fiatCurrencyCode, - paymentMethod - }), - requestPath - }; -} - -function createSellQuoteRequest( - cryptoCurrencyCode: string, - fiatCurrencyCode: string, - cryptoAmount: string, - extraFeePercentage: number -): { requestPath: string; params: URLSearchParams } { - const requestPath = `/v3/currencies/${cryptoCurrencyCode}/sell_quote`; - - const payoutMethod = fiatCurrencyCode.toUpperCase() === FiatToken.EURC ? PAYMENT_METHODS.SEPA : PAYMENT_METHODS.CREDIT_CARD; - - return { - params: new URLSearchParams({ - apiKey: config.priceProviders.moonpay.apiKey || "", - baseCurrencyAmount: cryptoAmount, - extraFeePercentage: extraFeePercentage.toString(), - payoutMethod, - quoteCurrencyCode: fiatCurrencyCode - }), - requestPath - }; -} - -type RequestConfig = { - requestPath: string; - params: URLSearchParams; -}; - -export function createQuoteRequest( - direction: RampDirection, - cryptoCurrencyCode: string, - fiatCurrencyCode: string, - amount: string, - extraFeePercentage?: number -): RequestConfig { - return direction === RampDirection.BUY - ? createBuyQuoteRequest(cryptoCurrencyCode, fiatCurrencyCode, amount) - : createSellQuoteRequest(cryptoCurrencyCode, fiatCurrencyCode, amount, extraFeePercentage ?? 0); -} diff --git a/apps/api/src/api/services/moonpay/response-handler.ts b/apps/api/src/api/services/moonpay/response-handler.ts deleted file mode 100644 index 0704669791..0000000000 --- a/apps/api/src/api/services/moonpay/response-handler.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { MoonpayPriceResponse, RampDirection } from "@vortexfi/shared"; -import logger from "../../../config/logger"; -import { - InvalidAmountError, - InvalidParameterError, - ProviderInternalError, - UnsupportedPairError -} from "../../errors/providerErrors"; -import { MoonpayResponse } from "./moonpay.service"; - -type MoonpayErrorResponse = { - message?: string; - type?: string; -}; - -function handleMoonpayError(response: Response, body: MoonpayErrorResponse): never { - const errorMessage = body?.message || `HTTP error ${response.status}: ${response.statusText}`; - const errorType = body?.type; - - logger.error(`Moonpay API Error (${response.status}): Type: ${errorType}, Message: ${errorMessage}`); - - if (errorType === "NotFoundError" || errorMessage.toLowerCase().includes("unsupported")) { - throw new UnsupportedPairError(`Moonpay: ${errorMessage}`); - } - - if ( - errorMessage.toLowerCase().includes("minimum") || - errorMessage.toLowerCase().includes("maximum") || - errorMessage.toLowerCase().includes("limit") - ) { - throw new InvalidAmountError(`Moonpay: ${errorMessage}`); - } - - if (errorType === "BadRequestError" || response.status === 400) { - throw new InvalidParameterError(`Moonpay: ${errorMessage}`); - } - - if (response.status >= 500) { - throw new ProviderInternalError(`Moonpay server error: ${errorMessage}`); - } - - throw new InvalidParameterError(`Moonpay API error: ${errorMessage}`); -} - -function validateMoonpayResponse( - body: MoonpayResponse, - requestedAmount: string, - direction: RampDirection -): MoonpayPriceResponse { - if (body.baseCurrencyAmount === undefined || body.quoteCurrencyAmount === undefined || body.feeAmount === undefined) { - throw new ProviderInternalError("Moonpay response missing essential data fields"); - } - - const { - baseCurrencyAmount: receivedBaseCurrencyAmount, - quoteCurrencyAmount, - feeAmount, - baseCurrency: { minAmount, code } - } = body; - - if (minAmount > Number(requestedAmount)) { - throw new InvalidAmountError(`Moonpay: ${minAmount} ${code} is the minimum amount for this pair`); - } - - if (Number(requestedAmount) !== receivedBaseCurrencyAmount) { - logger.warn( - `Moonpay Warning: Requested base amount ${requestedAmount} differs from received ${receivedBaseCurrencyAmount}` - ); - throw new ProviderInternalError( - `Moonpay response discrepancy: Requested base amount ${requestedAmount}, received ${receivedBaseCurrencyAmount}` - ); - } - - return { - direction, - provider: "moonpay", - quoteAmount: quoteCurrencyAmount, - requestedAmount: Number(requestedAmount), - totalFee: feeAmount - }; -} - -export async function processMoonpayResponse( - response: Response, - body: MoonpayResponse, - requestedAmount: string, - direction: RampDirection -): Promise { - if (!response.ok) { - return handleMoonpayError(response, body); - } - - return validateMoonpayResponse(body, requestedAmount, direction); -} diff --git a/apps/api/src/api/services/moonpay/utils.ts b/apps/api/src/api/services/moonpay/utils.ts deleted file mode 100644 index ffa085b722..0000000000 --- a/apps/api/src/api/services/moonpay/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function getCryptoCode(fromCrypto: string): string { - // If fromCrypto is USDC, we need to convert it to USDC_Polygon - if (fromCrypto.toLowerCase() === "usdc" || fromCrypto.toLowerCase() === "usdc.e" || fromCrypto.toLowerCase() === "usdce") { - return "usdc_polygon"; - } - if (fromCrypto.toLowerCase() === "usdt") { - return "usdt"; - } - - return fromCrypto.toLowerCase(); -} - -export function getFiatCode(toFiat: string): string { - return toFiat.toLowerCase(); -} diff --git a/apps/api/src/api/services/pendulum/helpers.ts b/apps/api/src/api/services/pendulum/helpers.ts deleted file mode 100644 index 0b5bde22aa..0000000000 --- a/apps/api/src/api/services/pendulum/helpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import Big from "big.js"; - -export const ChainDecimals = 12; - -export const nativeToDecimal = (value: Big, decimals: number = ChainDecimals): Big => { - const divisor = new Big(10).pow(decimals); - return value.div(divisor); -}; - -export function multiplyByPowerOfTen(bigDecimal: Big.BigSource, power: number): Big { - const newBigDecimal = new Big(bigDecimal); - if (newBigDecimal.c[0] === 0) return newBigDecimal; - - newBigDecimal.e += power; - return newBigDecimal; -} - -export function divideByPowerOfTen(bigDecimal: Big, power: number): Big { - const newBigDecimal = new Big(bigDecimal); - if (newBigDecimal.c[0] === 0) return newBigDecimal; - - newBigDecimal.e -= power; - return newBigDecimal; -} diff --git a/apps/api/src/api/services/pendulum/pendulum.service.ts b/apps/api/src/api/services/pendulum/pendulum.service.ts index 421c7679a8..8f4183c9b3 100644 --- a/apps/api/src/api/services/pendulum/pendulum.service.ts +++ b/apps/api/src/api/services/pendulum/pendulum.service.ts @@ -1,11 +1,16 @@ import { Keyring } from "@polkadot/api"; import { KeyringPair } from "@polkadot/keyring/types"; -import { ApiManager, SubstrateApiNetwork, TOKEN_CONFIG, waitUntilTrueWithTimeout } from "@vortexfi/shared"; +import { + ApiManager, + multiplyByPowerOfTen, + SubstrateApiNetwork, + TOKEN_CONFIG, + waitUntilTrueWithTimeout +} from "@vortexfi/shared"; import Big from "big.js"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import { GLMR_FUNDING_AMOUNT_RAW, PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../constants/constants"; -import { multiplyByPowerOfTen } from "./helpers"; export function getFundingData( ss58Format: number, 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 b533f872a5..cd5df89e24 100644 --- a/apps/api/src/api/services/phases/base-phase-handler.ts +++ b/apps/api/src/api/services/phases/base-phase-handler.ts @@ -1,4 +1,3 @@ -import { ReadMessageResult } from "@pendulum-chain/api-solang"; import { PresignedTx, RampErrorLog, RampPhase } from "@vortexfi/shared"; import httpStatus from "http-status"; import logger from "../../../config/logger"; @@ -166,17 +165,6 @@ export abstract class BasePhaseHandler implements PhaseHandler { return state.presignedTxs?.find(tx => tx.phase === phase) as PresignedTx; } - protected parseContractMessageResultError(result: ReadMessageResult) { - if (result.type === "error") { - return result.error; - } else if (result.type === "panic") { - return `${result.errorCode}: ${result.explanation}`; - } else if (result.type === "reverted") { - return `${result.description}`; - } - return "Could not extract error message for ReadMessageResult."; - } - /** * Create a subsidy entry for the current ramp and phase * @param state The current ramp state diff --git a/apps/api/src/api/services/phases/blocks/README.md b/apps/api/src/api/services/phases/blocks/README.md index 9f5d1eb2d7..1711802ae6 100644 --- a/apps/api/src/api/services/phases/blocks/README.md +++ b/apps/api/src/api/services/phases/blocks/README.md @@ -96,13 +96,11 @@ apps/api/src/api/services/phases/blocks/ 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 @@ -373,15 +371,6 @@ 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 @@ -569,8 +558,6 @@ declared signature bridges them. | `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 diff --git a/apps/api/src/api/services/phases/blocks/__tests__/subsidize-evm-topup.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/subsidize-evm-topup.test.ts new file mode 100644 index 0000000000..565658bed8 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/__tests__/subsidize-evm-topup.test.ts @@ -0,0 +1,766 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import * as sharedNamespace from "@vortexfi/shared"; +import { type EvmTokenDetails, EvmToken, Networks, RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { decodeFunctionData, encodeFunctionData, erc20Abi, EstimateGasExecutionError, ExecutionRevertedError } from "viem"; +import logger from "../../../../../config/logger"; +import * as quoteTicketNamespace from "../../../../../models/quoteTicket.model"; +import { priceFeedService } from "../../../priceFeed.service"; +import * as evmFundingNamespace from "../core/evm-funding"; +import * as financialOperationNamespace from "../core/financial-operation"; + +// Characterization of the EVM top-up path shared by SubsidizePreSwapExecutor and +// SubsidizePostSwapExecutor: every message, attempt class, request key and call +// argument is pinned for both executors so the two stay behaviourally identical. + +const sharedReal = { ...sharedNamespace }; +const quoteTicketReal = { ...quoteTicketNamespace }; +const evmFundingReal = { ...evmFundingNamespace }; +const financialOperationReal = { ...financialOperationNamespace }; + +const EPHEMERAL = "0x2222222222222222222222222222222222222222" as const; +const TX_HASH = "0x1111111111111111111111111111111111111111111111111111111111111111" as const; +const fundingAccount = { address: "0x1111111111111111111111111111111111111111" as `0x${string}` }; +const usdcAddress = (sharedReal.getOnChainTokenDetails(Networks.Base, EvmToken.USDC) as EvmTokenDetails) + .erc20AddressSourceChain as `0x${string}`; + +const findQuote = mock(async () => undefined as unknown); +const checkBalance = mock(async () => new Big(0)); +// Faithful to the real poller: resolves only at or above the desired amount and throws a Timeout otherwise. +const checkEvmBalanceImpl = async ({ amountDesiredRaw }: { amountDesiredRaw: string }) => { + const balance = await checkBalance(); + if (balance.lt(amountDesiredRaw)) { + throw new sharedReal.BalanceCheckError( + sharedReal.BalanceCheckErrorType.Timeout, + "Balance did not meet the limit within 5000ms" + ); + } + return balance; +}; +const checkEvmBalanceForToken = mock(checkEvmBalanceImpl); +const getFundingBalance = mock(async () => new Big("1000000000")); +const getDestinationBalance = mock(async () => checkBalance()); +const getNativeFundingBalance = mock(async () => new Big("1000000000")); +const estimateFeesPerGas = mock( + async (): Promise<{ gasPrice?: bigint; maxFeePerGas?: bigint; maxPriorityFeePerGas?: bigint }> => ({ + maxFeePerGas: 10n, + maxPriorityFeePerGas: 1n + }) +); +const sendTransaction = mock( + async (_network?: unknown, _account?: unknown, _transaction?: unknown) => TX_HASH as `0x${string}` +); +const estimateGas = mock(async (_args?: unknown) => 21000n); +const getTransaction = mock( + async (_args?: unknown): Promise<{ from: `0x${string}`; input: `0x${string}`; to: `0x${string}` }> => { + throw new Error("Unexpected transaction lookup"); + } +); +const getTransactionCount = mock(async (_args?: unknown) => 7); +const waitForTransactionReceipt = mock(async (_args?: unknown) => ({ status: "success" as "reverted" | "success" })); + +const operationFailures: unknown[] = []; +let operationReplay: unknown; +let legacyOperationResponse: unknown; +let beforeSerializedFundingOperation: (() => Promise | void) | undefined; +const runSerializedEvmFundingOperation = mock(async (_network: unknown, operation: () => Promise) => { + await beforeSerializedFundingOperation?.(); + return operation(); +}); +const runFinancialOperation = mock( + async (args: { + beforePerform?(): Promise; + perform(key: string): Promise; + reconcile?(operation: { response: unknown }): Promise; + signal?: AbortSignal; + }) => { + if (args.signal?.aborted) throw args.signal.reason; + if (operationReplay !== undefined) return operationReplay; + if (legacyOperationResponse !== undefined) { + const reconciled = await args.reconcile?.({ response: legacyOperationResponse }); + if (reconciled === null || reconciled === undefined) throw new Error("Legacy operation requires reconciliation"); + return reconciled; + } + try { + await args.beforePerform?.(); + return await args.perform("test-operation-key"); + } catch (error) { + operationFailures.push(error); + throw error; + } + } +); + +mock.module("@vortexfi/shared", () => ({ + ...sharedReal, + checkEvmBalanceForToken, + EvmClientManager: { + getInstance: () => ({ + getClient: () => ({ + chain: { nativeCurrency: { decimals: 18 } }, + estimateFeesPerGas, + estimateGas, + getTransaction, + getTransactionCount, + readContract: async () => 10000n, + waitForTransactionReceipt + }), + sendTransactionWithBlindRetry: sendTransaction + }) + }, + getEvmBalance: ({ ownerAddress }: { ownerAddress: string }) => + ownerAddress.toLowerCase() === fundingAccount.address.toLowerCase() ? getFundingBalance() : getDestinationBalance(), + getEvmNativeBalance: getNativeFundingBalance +})); +mock.module("../../../../../models/quoteTicket.model", () => ({ + ...quoteTicketReal, + default: { findByPk: findQuote } +})); +mock.module("../core/evm-funding", () => ({ + ...evmFundingReal, + getEvmFundingAccount: () => fundingAccount, + runSerializedEvmFundingOperation +})); +mock.module("../core/financial-operation", () => ({ + ...financialOperationReal, + requireFinancialFlowIdentity: () => ({ id: "test-flow", version: 1 }), + runFinancialOperation +})); +const { SubsidizePreSwapExecutor } = await import("../phases/subsidize-pre/execution"); +const { SubsidizePostSwapExecutor } = await import("../phases/subsidize-post/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 })); +}); + +const originalConvertCurrency = priceFeedService.convertCurrency; +let conversions: string[] = []; +let loggerError: ReturnType>; + +beforeEach(() => { + // mockReset (not mockClear) so unconsumed mockResolvedValueOnce entries cannot leak into the next test. + for (const fn of [ + findQuote, + checkBalance, + checkEvmBalanceForToken, + getFundingBalance, + getDestinationBalance, + getNativeFundingBalance, + estimateFeesPerGas, + sendTransaction, + estimateGas, + getTransaction, + getTransactionCount, + waitForTransactionReceipt + ]) { + fn.mockReset(); + } + runSerializedEvmFundingOperation.mockClear(); + runFinancialOperation.mockClear(); + checkEvmBalanceForToken.mockImplementation(checkEvmBalanceImpl); + checkBalance.mockResolvedValue(new Big(0)); + getFundingBalance.mockResolvedValue(new Big("1000000000")); + getDestinationBalance.mockImplementation(async () => checkBalance()); + getNativeFundingBalance.mockResolvedValue(new Big("1000000000")); + estimateFeesPerGas.mockResolvedValue({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + sendTransaction.mockImplementation(async () => TX_HASH); + estimateGas.mockResolvedValue(21000n); + getTransaction.mockImplementation(async () => { + throw new Error("Unexpected transaction lookup"); + }); + getTransactionCount.mockResolvedValue(7); + waitForTransactionReceipt.mockResolvedValue({ status: "success" }); + beforeSerializedFundingOperation = undefined; + operationFailures.length = 0; + operationReplay = undefined; + legacyOperationResponse = undefined; + conversions = []; + priceFeedService.convertCurrency = mock(async amount => { + conversions.push(String(amount)); + return String(amount); + }) as typeof priceFeedService.convertCurrency; + loggerError = spyOn(logger, "error").mockImplementation((() => logger) as never); +}); + +afterEach(() => { + priceFeedService.convertCurrency = originalConvertCurrency; + loggerError.mockRestore(); +}); + +type Executor = { + createSubsidy: ReturnType; + executePhase(state: unknown, signal?: AbortSignal): Promise; + getPhaseName(): string; +}; + +interface Case { + /** Quote metadata whose top-up needs 5 USDC (current 95, target 100) and whose quote output is $100. */ + baseMetadata: Record; + /** Block metadata for a shortfall of `shortfallRaw` against a 100 USDC target, quote output $100. */ + capBreach: { balanceRaw: string; message: string; metadata: Record; quoteOutput: string }; + direction: RampDirection; + name: string; + executorName: string; + make(): Executor; + metadataKey: string; + phaseName: string; + stateLabel: string; +} + +const preMetadata = (overrides: Record = {}) => ({ + blocks: { + subsidizePreSwap: { + expectedOutputAmountDecimal: "100", + expectedOutputAmountRaw: "100000000", + inputCurrency: EvmToken.USDC, + inputDecimals: 6, + network: Networks.Base, + targetInputAmountRaw: "100000000", + ...overrides + } + } +}); + +const postMetadata = (overrides: Record = {}) => ({ + blocks: { + subsidizePostSwap: { + actualOutputAmountRaw: "95000000", + outputCurrency: EvmToken.USDC, + outputDecimals: 6, + subsidyAmountInOutputTokenRaw: "5000000", + targetOutputAmountRaw: "100000000", + ...overrides + } + } +}); + +const cases: Case[] = [ + { + baseMetadata: preMetadata(), + capBreach: { + balanceRaw: "50000000", + message: "SubsidizePreSwapExecutor: Required subsidy $50 exceeds cap $5.00 (max of $1.00 and 0.05 of quote output $100).", + metadata: preMetadata(), + quoteOutput: "100" + }, + direction: RampDirection.SELL, + executorName: "SubsidizePreSwapExecutor", + make: () => Object.create(SubsidizePreSwapExecutor.prototype) as Executor, + metadataKey: "subsidizePreSwap", + name: "SubsidizePreSwapExecutor", + phaseName: "subsidizePreSwap", + stateLabel: "pre swap" + }, + { + baseMetadata: postMetadata(), + capBreach: { + balanceRaw: "50000000", + // discrepancy baseline equals the target, so the whole shortfall is a swap discrepancy + message: + "SubsidizePostSwapExecutor: Required swap discrepancy subsidy $50 exceeds cap $5.00 (max of $1.00 and 0.05 of quote output $100).", + metadata: postMetadata({ actualOutputAmountRaw: "100000000", subsidyAmountInOutputTokenRaw: "0" }), + quoteOutput: "100" + }, + direction: RampDirection.BUY, + executorName: "SubsidizePostSwapExecutor", + make: () => Object.create(SubsidizePostSwapExecutor.prototype) as Executor, + metadataKey: "subsidizePostSwap", + name: "SubsidizePostSwapExecutor", + phaseName: "subsidizePostSwap", + stateLabel: "post swap" + } +]; + +function makeState(testCase: Case, state: Record = { evmEphemeralAddress: EPHEMERAL }) { + return { id: "ramp-1", quoteId: "quote-1", state, type: testCase.direction }; +} + +function arrange(testCase: Case, metadata: Record, quoteOutput = "100") { + findQuote.mockResolvedValue({ metadata, outputAmount: quoteOutput, outputCurrency: EvmToken.USDC }); + const executor = testCase.make(); + executor.createSubsidy = mock(async () => undefined); + return executor; +} + +/** Preflight sees `balanceRaw`; the confirmation poll afterwards sees the full 100 USDC target. */ +function balances(balanceRaw: string, finalRaw = "100000000") { + checkBalance.mockResolvedValue(new Big(finalRaw)); + checkBalance.mockResolvedValueOnce(new Big(balanceRaw)); + getDestinationBalance.mockResolvedValue(new Big(balanceRaw)); +} + +describe.each(cases)("$name EVM top-up", testCase => { + const { executorName } = testCase; + + it("sends the capped top-up from the funding wallet and records the subsidy", async () => { + balances("95000000"); + const executor = arrange(testCase, testCase.baseMetadata); + + await executor.executePhase(makeState(testCase)); + + expect(runSerializedEvmFundingOperation).toHaveBeenCalledTimes(1); + expect(runSerializedEvmFundingOperation.mock.calls[0][0]).toBe(Networks.Base); + expect(runFinancialOperation).toHaveBeenCalledTimes(1); + const operationArgs = runFinancialOperation.mock.calls[0][0] as Record; + expect(operationArgs).toMatchObject({ + adoptSafeRequestHash: true, + attemptClass: "evm-subsidy-transfer", + provider: Networks.Base, + reconcileRequestMismatch: true, + request: { + destination: EPHEMERAL, + network: Networks.Base, + source: fundingAccount.address, + targetBalanceRaw: "100000000", + token: usdcAddress + }, + retryFailed: true, + settleAfterAbort: true + }); + expect(Object.keys(operationArgs.request).sort()).toEqual([ + "destination", + "network", + "source", + "targetBalanceRaw", + "token" + ]); + expect(operationArgs.externalId({ amountRaw: "5000000", hash: TX_HASH })).toBe(TX_HASH); + expect(operationArgs.externalId({ amountRaw: "0", hash: null })).toBeUndefined(); + + // Preflight poll, then the same confirmation poll against the target. + expect(checkEvmBalanceForToken).toHaveBeenCalledTimes(2); + expect(checkEvmBalanceForToken.mock.calls[0][0]).toMatchObject({ + amountDesiredRaw: "1", + chain: Networks.Base, + intervalMs: 1000, + ownerAddress: EPHEMERAL, + timeoutMs: 5000 + }); + expect(checkEvmBalanceForToken.mock.calls[1][0]).toMatchObject({ + amountDesiredRaw: "100000000", + chain: Networks.Base, + intervalMs: 1000, + ownerAddress: EPHEMERAL, + timeoutMs: 5000 + }); + + const data = encodeFunctionData({ abi: erc20Abi, args: [EPHEMERAL, 5000000n], functionName: "transfer" }); + const gasEstimateArgs = { + account: fundingAccount, + data, + maxFeePerGas: 10n, + maxPriorityFeePerGas: 1n, + to: usdcAddress, + value: 0n + }; + expect(estimateGas).toHaveBeenCalledTimes(2); + expect(estimateGas.mock.calls[0][0]).toEqual(gasEstimateArgs); + expect(estimateGas.mock.calls[1][0]).toEqual(gasEstimateArgs); + expect(getTransactionCount).toHaveBeenCalledWith({ address: fundingAccount.address, blockTag: "pending" }); + expect(sendTransaction).toHaveBeenCalledTimes(1); + expect(sendTransaction.mock.calls[0]).toEqual([ + Networks.Base, + fundingAccount, + { data, gas: 21000n, maxFeePerGas: 10n, maxPriorityFeePerGas: 1n, nonce: 7, to: usdcAddress, value: 0n } + ]); + expect(waitForTransactionReceipt).toHaveBeenCalledWith({ hash: TX_HASH }); + expect(executor.createSubsidy).toHaveBeenCalledTimes(1); + expect(executor.createSubsidy).toHaveBeenCalledWith(expect.anything(), 5, EvmToken.USDC, fundingAccount.address, TX_HASH); + }); + + it("skips the transfer and the subsidy record when the destination already holds the target", async () => { + balances("100000000"); + const executor = arrange(testCase, testCase.baseMetadata); + + await executor.executePhase(makeState(testCase)); + + expect(sendTransaction).not.toHaveBeenCalled(); + expect(estimateGas).not.toHaveBeenCalled(); + expect(executor.createSubsidy).not.toHaveBeenCalled(); + expect(checkEvmBalanceForToken).toHaveBeenCalledTimes(2); + }); + + it("rejects a corrupted state without an EVM ephemeral before any funding work", async () => { + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase, {}))).rejects.toMatchObject({ + message: `${executorName}: State metadata corrupted. This is a bug.` + }); + + expect(runSerializedEvmFundingOperation).not.toHaveBeenCalled(); + }); + + it("treats a zero ephemeral balance as input not yet arrived", async () => { + checkEvmBalanceForToken.mockImplementationOnce(async () => new Big(0)); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Failed to subsidize ${testCase.stateLabel} on EVM.` + }); + + expect((operationFailures[0] as Error).message).toBe("Invalid phase: input token did not arrive yet on EVM"); + expect(loggerError).toHaveBeenCalledWith(`Error in ${testCase.phaseName} (EVM):`, operationFailures[0]); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("pauses without sending when the funding wallet token balance is too low", async () => { + balances("95000000"); + getFundingBalance.mockResolvedValue(new Big("10")); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Funding wallet token balance 10 is below required subsidy 5000000.` + }); + + expect(loggerError).toHaveBeenCalledWith("EVM_FUNDING_TOKEN_BALANCE_LOW", { + availableRaw: "10", + network: Networks.Base, + phase: testCase.phaseName, + rampId: "ramp-1", + requiredRaw: "5000000", + token: usdcAddress + }); + expect(getTransactionCount).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("pauses without sending when the funding wallet has no native gas token", async () => { + balances("95000000"); + getNativeFundingBalance.mockResolvedValue(new Big(0)); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Funding wallet has no native token for gas.` + }); + + expect(estimateGas).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("pauses without sending when the funding wallet native balance cannot cover the maximum gas cost", async () => { + balances("95000000"); + getNativeFundingBalance.mockResolvedValue(new Big("215000")); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Funding wallet native balance 215000 is below maximum gas cost 220000.` + }); + + expect(getTransactionCount).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("fails the preflight when no gas price can be estimated", async () => { + balances("95000000"); + estimateFeesPerGas.mockResolvedValue({}); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Failed to subsidize ${testCase.stateLabel} on EVM.` + }); + + expect((operationFailures[0] as Error).message).toBe(`${executorName}: Could not estimate the funding wallet gas price`); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("retries when the destination balance dropped between the two preflight reads", async () => { + checkBalance.mockResolvedValue(new Big("99000000")); + getDestinationBalance.mockResolvedValue(new Big("90000000")); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Destination balance decreased during preflight; retrying subsidy calculation.` + }); + + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("shrinks the transfer when more funds arrive between the two preflight reads", async () => { + checkBalance.mockResolvedValue(new Big("100000000")); + checkBalance.mockResolvedValueOnce(new Big("95000000")); + getDestinationBalance.mockResolvedValue(new Big("98000000")); + const executor = arrange(testCase, testCase.baseMetadata); + + await executor.executePhase(makeState(testCase)); + + const transaction = sendTransaction.mock.calls[0][2] as { data: `0x${string}` }; + expect(decodeFunctionData({ abi: erc20Abi, data: transaction.data }).args?.[1]).toBe(2000000n); + expect(executor.createSubsidy).toHaveBeenCalledWith(expect.anything(), 2, EvmToken.USDC, fundingAccount.address, TX_HASH); + }); + + it("refuses a shortfall beyond the subsidy cap before any funding read", async () => { + const { balanceRaw, message, metadata, quoteOutput } = testCase.capBreach; + checkBalance.mockResolvedValue(new Big(balanceRaw)); + const executor = arrange(testCase, metadata, quoteOutput); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ isRecoverable: true, message }); + + expect(getFundingBalance).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("aborts a top-up whose pre-broadcast gas estimate proves a deterministic revert", async () => { + balances("95000000"); + estimateGas.mockResolvedValueOnce(21000n); + estimateGas.mockRejectedValueOnce( + new EstimateGasExecutionError(new ExecutionRevertedError({ message: "transfer amount exceeds balance" }), {}) + ); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ isRecoverable: true }); + + expect(operationFailures[0]).toBeInstanceOf(financialOperationReal.FinancialOperationRejectedError); + expect((operationFailures[0] as Error).message).toBe( + `${executorName}: Funding transfer was rejected during pre-broadcast gas estimation` + ); + expect(getTransactionCount).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("rethrows a non-deterministic pre-broadcast gas estimate failure unchanged", async () => { + balances("95000000"); + const rpcError = new Error("rpc unavailable"); + estimateGas.mockResolvedValueOnce(21000n); + estimateGas.mockRejectedValueOnce(rpcError); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ isRecoverable: true }); + + expect(operationFailures[0]).toBe(rpcError); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("fails the operation when the top-up transaction reverts on chain", async () => { + balances("95000000"); + waitForTransactionReceipt.mockResolvedValue({ status: "reverted" }); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Failed to subsidize ${testCase.stateLabel} on EVM.` + }); + + expect((operationFailures[0] as Error).message).toBe(`${executorName}: Subsidy transaction ${TX_HASH} failed`); + expect(executor.createSubsidy).not.toHaveBeenCalled(); + }); + + it("records the subsidy but pauses when the destination never reaches the target afterwards", async () => { + balances("95000000", "95000000"); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Confirmed subsidy operation did not leave the destination at its target balance.` + }); + + expect(executor.createSubsidy).toHaveBeenCalledWith(expect.anything(), 5, EvmToken.USDC, fundingAccount.address, TX_HASH); + }); + + it("wraps an unexpected confirmation poll failure as a generic recoverable error", async () => { + balances("95000000"); + const rpcError = new Error("rpc unavailable"); + checkEvmBalanceForToken.mockImplementationOnce(async () => new Big("95000000")); + checkEvmBalanceForToken.mockImplementationOnce(async () => { + throw rpcError; + }); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Failed to subsidize ${testCase.stateLabel} on EVM.` + }); + + expect(loggerError).toHaveBeenCalledWith(`Error in ${testCase.phaseName} (EVM):`, rpcError); + }); + + it("does not broadcast when the phase is aborted while waiting for the funding slot", async () => { + balances("95000000"); + const controller = new AbortController(); + beforeSerializedFundingOperation = () => { + controller.abort(new Error("phase timed out")); + }; + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase), controller.signal)).rejects.toMatchObject({ isRecoverable: true }); + + expect(sendTransaction).not.toHaveBeenCalled(); + expect(executor.createSubsidy).not.toHaveBeenCalled(); + }); + + it("repairs the subsidy record from a legacy confirmed hash without sending", async () => { + balances("100000000"); + legacyOperationResponse = { hash: TX_HASH }; + getTransaction.mockResolvedValue({ + from: fundingAccount.address, + input: encodeFunctionData({ abi: erc20Abi, args: [EPHEMERAL, 5000000n], functionName: "transfer" }), + to: usdcAddress + }); + const executor = arrange(testCase, testCase.baseMetadata); + + await executor.executePhase(makeState(testCase)); + + expect(getTransaction).toHaveBeenCalledWith({ hash: TX_HASH }); + expect(sendTransaction).not.toHaveBeenCalled(); + expect(executor.createSubsidy).toHaveBeenCalledWith(expect.anything(), 5, EvmToken.USDC, fundingAccount.address, TX_HASH); + }); + + it("only reconciles a legacy transfer that stays within the target balance", async () => { + balances("100000000"); + legacyOperationResponse = { hash: TX_HASH }; + getTransaction.mockResolvedValue({ + from: fundingAccount.address, + input: encodeFunctionData({ abi: erc20Abi, args: [EPHEMERAL, 100000001n], functionName: "transfer" }), + to: usdcAddress + }); + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ isRecoverable: true }); + + expect(executor.createSubsidy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it.each([ + ["positive transfer", { amountRaw: "5000000", hash: TX_HASH }, "50000000"], + ["no-op", { amountRaw: "0", hash: null }, "95000000"] + ])("does not advance an underfunded phase after replaying a confirmed %s", async (_kind, replay, balanceRaw) => { + checkBalance.mockResolvedValue(new Big(balanceRaw)); + operationReplay = replay; + const executor = arrange(testCase, testCase.baseMetadata); + + await expect(executor.executePhase(makeState(testCase))).rejects.toMatchObject({ + isRecoverable: true, + message: `${executorName}: Confirmed subsidy operation did not leave the destination at its target balance.` + }); + + expect(sendTransaction).not.toHaveBeenCalled(); + expect(conversions).toEqual([]); + if (replay.hash) { + expect(executor.createSubsidy).toHaveBeenCalledWith( + expect.anything(), + 5, + EvmToken.USDC, + fundingAccount.address, + replay.hash + ); + } else { + expect(executor.createSubsidy).not.toHaveBeenCalled(); + } + }); +}); + +describe("SubsidizePreSwapExecutor EVM top-up specifics", () => { + const preCase = cases[0]; + + it("adds the fee reserve to the swap input target", async () => { + // target 100_000_005: swap amount plus the reserve that keeps later fee transfers funded + balances("100000000", "100000005"); + const executor = arrange(preCase, preMetadata({ feeReserveRaw: "5" })); + + await executor.executePhase(makeState(preCase)); + + const operationArgs = runFinancialOperation.mock.calls[0][0] as Record; + expect(operationArgs.request.targetBalanceRaw).toBe("100000005"); + expect(checkEvmBalanceForToken.mock.calls[1][0]).toMatchObject({ amountDesiredRaw: "100000005" }); + const transaction = sendTransaction.mock.calls[0][2] as { data: `0x${string}` }; + expect(decodeFunctionData({ abi: erc20Abi, data: transaction.data }).args?.[1]).toBe(5n); + expect(executor.createSubsidy).toHaveBeenCalledWith( + expect.anything(), + 0.000005, + EvmToken.USDC, + fundingAccount.address, + TX_HASH + ); + }); + + it("converts the subsidy before the quote output and floors the cap at $1", async () => { + checkBalance.mockResolvedValue(new Big("98500000")); + const executor = arrange(preCase, preMetadata(), "2"); + + await expect(executor.executePhase(makeState(preCase))).rejects.toMatchObject({ + isRecoverable: true, + message: "SubsidizePreSwapExecutor: Required subsidy $1.5 exceeds cap $1.00 (max of $1.00 and 0.05 of quote output $2)." + }); + + expect(conversions).toEqual(["1.5", "2"]); + }); + + it("allows a shortfall exactly at the percentage cap", async () => { + balances("95000000"); + const executor = arrange(preCase, preMetadata()); + + await executor.executePhase(makeState(preCase)); + + expect(conversions).toEqual(["5", "100"]); + expect(sendTransaction).toHaveBeenCalledTimes(1); + }); +}); + +describe("SubsidizePostSwapExecutor EVM top-up specifics", () => { + const postCase = cases[1]; + + it("converts the quote output, then the discrepancy, then the discount portion", async () => { + // quoted actual 98, current 95, expected 100 -> discrepancy 3, discount 2 + balances("95000000"); + const executor = arrange( + postCase, + postMetadata({ actualOutputAmountRaw: "98000000", subsidyAmountInOutputTokenRaw: "2000000" }) + ); + + await executor.executePhase(makeState(postCase)); + + expect(conversions).toEqual(["100", "3", "2"]); + expect(sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("refuses a discount of at least $1 above its own cap", async () => { + checkBalance.mockResolvedValue(new Big("10000000")); + const executor = arrange( + postCase, + postMetadata({ + actualOutputAmountRaw: "10000000", + subsidyAmountInOutputTokenRaw: "1000000", + targetOutputAmountRaw: "11000000" + }), + "10" + ); + + await expect(executor.executePhase(makeState(postCase))).rejects.toMatchObject({ + isRecoverable: true, + message: "SubsidizePostSwapExecutor: Required discount subsidy $1 exceeds cap $0.50 (0.05 of quote output $10)." + }); + + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("defaults the output network to Base", async () => { + balances("95000000"); + const executor = arrange(postCase, postMetadata()); + await executor.executePhase(makeState(postCase)); + expect((runFinancialOperation.mock.calls[0][0] as Record).provider).toBe(Networks.Base); + }); + + it("rejects a non-EVM output network before any funding work", async () => { + const executor = arrange(postCase, postMetadata({ network: Networks.AssetHub })); + + await expect(executor.executePhase(makeState(postCase))).rejects.toMatchObject({ + isRecoverable: true, + message: "SubsidizePostSwapExecutor: Failed to subsidize post swap on EVM." + }); + + expect(loggerError).toHaveBeenCalledWith( + "Error in subsidizePostSwap (EVM):", + expect.objectContaining({ message: "SubsidizePostSwapExecutor: Unsupported EVM network assethub" }) + ); + expect(runSerializedEvmFundingOperation).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/core/combinators.ts b/apps/api/src/api/services/phases/blocks/core/combinators.ts deleted file mode 100644 index 6733bb4e99..0000000000 --- a/apps/api/src/api/services/phases/blocks/core/combinators.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/destination-funding.ts b/apps/api/src/api/services/phases/blocks/core/destination-funding.ts index 8d6717356e..50b66f7598 100644 --- a/apps/api/src/api/services/phases/blocks/core/destination-funding.ts +++ b/apps/api/src/api/services/phases/blocks/core/destination-funding.ts @@ -5,20 +5,14 @@ import { EvmClientManager, EvmNetworks, isNetworkEVM, + multiplyByPowerOfTen, 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 { - BASE_EPHEMERAL_STARTING_BALANCE_UNITS, - GLMR_FUNDING_AMOUNT_RAW, - PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS, - POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS -} from "../../../../../constants/constants"; +import { PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../../constants/constants"; import { UnrecoverablePhaseError } from "../../../../errors/phase-error"; -import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; // Compatibility program for quotes created before dynamic destination funding // metadata existed. Keep these values and operation identities stable until all @@ -44,32 +38,6 @@ export async function isPendulumEphemeralFunded(pendulumEphemeralAddress: string return Big(balance.free.toString()).gte(fundingAmountRaw); } -export async function isMoonbeamEphemeralFunded(moonbeamEphemeralAddress: string, moonbeamNode: API): Promise { - // @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 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() - ); - - return Big(balance.toString()).gte(fundingAmountRaw); -} - -export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): Promise { - 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() - ); - - return Big(balance.toString()).gte(fundingAmountRaw); -} - export function calculateDestinationFundingShortfallRaw(requiredFundingRaw: bigint, currentBalanceRaw: bigint): bigint { return requiredFundingRaw > currentBalanceRaw ? requiredFundingRaw - currentBalanceRaw : 0n; } diff --git a/apps/api/src/api/services/phases/blocks/core/evm-subsidy-top-up.ts b/apps/api/src/api/services/phases/blocks/core/evm-subsidy-top-up.ts new file mode 100644 index 0000000000..8e4d1917f0 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/evm-subsidy-top-up.ts @@ -0,0 +1,273 @@ +import { + BalanceCheckError, + BalanceCheckErrorType, + checkEvmBalanceForToken, + EvmClientManager, + EvmNetworks, + EvmTokenDetails, + getEvmBalance, + getEvmNativeBalance, + isDeterministicPreBroadcastRevert, + nativeToDecimal, + sleep +} from "@vortexfi/shared"; +import Big from "big.js"; +import { encodeFunctionData, erc20Abi } from "viem"; +import logger from "../../../../../config/logger"; +import RampState from "../../../../../models/rampState.model"; +import { SubsidyToken } from "../../../../../models/subsidy.model"; +import { BasePhaseHandler } from "../../base-phase-handler"; +import { EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES, getBaseL1FeeUpperBoundRaw } from "./evm-destination-gas"; +import { getEvmFundingAccount, runSerializedEvmFundingOperation } from "./evm-funding"; +import { FinancialOperationRejectedError } from "./financial-operation"; +import { reconcileLegacyEvmSubsidy } from "./legacy-evm-subsidy"; + +const EVM_SETTLEMENT_DELAY_MS = parseInt(process.env.SUBSIDY_SETTLEMENT_DELAY_MS || "15000", 10); + +interface EvmSubsidyTopUp { + /** + * Called once the ephemeral's current balance is known. Returns the raw shortfall to the target and + * the executor-specific cap check, which runs only when a positive transfer is needed. + */ + assess(currentBalance: Big): { enforceCaps(maximumTransferAmount: Big): Promise; requiredAmountRaw: Big }; + decimals: number; + ephemeralAddress: string; + /** Class name used as the prefix of every error message, e.g. "SubsidizePreSwapExecutor". */ + executorName: string; + /** "pre-swap" or "post-swap"; only appears in the info log. */ + label: string; + signal?: AbortSignal; + state: RampState; + subsidyToken: SubsidyToken; + /** Raw token balance the ephemeral must hold afterwards. */ + targetRaw: Big; + tokenDetails: EvmTokenDetails; +} + +/** + * Shared EVM path of the pre- and post-swap subsidy phases: tops the ephemeral's token balance up to + * the target from the treasury funding wallet in one reconciled financial operation. + */ +export abstract class EvmSubsidyTopUpExecutor extends BasePhaseHandler { + protected async topUpEvmEphemeral({ + assess, + decimals, + ephemeralAddress, + executorName, + label, + signal, + state, + subsidyToken, + targetRaw, + tokenDetails + }: EvmSubsidyTopUp): Promise { + const targetBalanceRaw = targetRaw.toFixed(0); + const evmClientManager = EvmClientManager.getInstance(); + const destinationNetwork = tokenDetails.network as EvmNetworks; + const fundingAccount = getEvmFundingAccount(destinationNetwork); + + const publicClient = evmClientManager.getClient(destinationNetwork); + const tokenAddress = tokenDetails.erc20AddressSourceChain as `0x${string}`; + let maximumTransferAmount = Big(0); + let transferAmount = Big(0); + let data: `0x${string}` | undefined; + let gas: bigint | undefined; + let maxFeePerGas: bigint | undefined; + let maxPriorityFeePerGas: bigint | undefined; + + const operation = await runSerializedEvmFundingOperation( + destinationNetwork, + () => + this.runFinancialOperation(state, { + adoptSafeRequestHash: true, + attemptClass: "evm-subsidy-transfer", + beforePerform: async () => { + await sleep(EVM_SETTLEMENT_DELAY_MS, signal); + const currentBalance = await checkEvmBalanceForToken({ + amountDesiredRaw: "1", + chain: destinationNetwork, + intervalMs: 1000, + ownerAddress: ephemeralAddress, + signal, + timeoutMs: 5000, + tokenDetails + }); + if (currentBalance.eq(0)) { + throw new Error("Invalid phase: input token did not arrive yet on EVM"); + } + + const { enforceCaps, requiredAmountRaw } = assess(currentBalance); + logger.debug(`${executorName}: requiredAmount ${requiredAmountRaw.toString()}`); + maximumTransferAmount = requiredAmountRaw.gt(0) ? requiredAmountRaw : Big(0); + if (maximumTransferAmount.gt(0)) { + await enforceCaps(maximumTransferAmount); + logger.info( + `Subsidizing ${label} EVM with ${maximumTransferAmount.toFixed()} to reach target value of ${targetBalanceRaw}` + ); + } + + const refreshedDestinationBalance = await getEvmBalance({ + chain: destinationNetwork, + ownerAddress: ephemeralAddress as `0x${string}`, + tokenDetails + }); + const refreshedRequiredAmount = targetRaw.sub(refreshedDestinationBalance); + if (refreshedRequiredAmount.gt(maximumTransferAmount)) { + throw this.createRecoverableError( + `${executorName}: Destination balance decreased during preflight; retrying subsidy calculation.` + ); + } + transferAmount = refreshedRequiredAmount.gt(0) ? refreshedRequiredAmount : Big(0); + if (transferAmount.eq(0)) return; + + data = encodeFunctionData({ + abi: erc20Abi, + args: [ephemeralAddress as `0x${string}`, BigInt(transferAmount.toFixed(0))], + functionName: "transfer" + }); + const fundingTokenBalance = await getEvmBalance({ + chain: destinationNetwork, + ownerAddress: fundingAccount.address, + tokenDetails + }); + if (fundingTokenBalance.lt(transferAmount)) { + logger.error("EVM_FUNDING_TOKEN_BALANCE_LOW", { + availableRaw: fundingTokenBalance.toFixed(), + network: destinationNetwork, + phase: this.getPhaseName(), + rampId: state.id, + requiredRaw: transferAmount.toFixed(), + token: tokenAddress + }); + throw this.createRecoverableError( + `${executorName}: Funding wallet token balance ${fundingTokenBalance.toFixed()} is below required subsidy ${transferAmount.toFixed()}.` + ); + } + + const nativeBalance = await getEvmNativeBalance(fundingAccount.address, destinationNetwork); + if (nativeBalance.lte(0)) { + throw this.createRecoverableError(`${executorName}: Funding wallet has no native token for gas.`); + } + + const fees = await publicClient.estimateFeesPerGas(); + maxFeePerGas = fees.maxFeePerGas; + maxPriorityFeePerGas = fees.maxPriorityFeePerGas; + gas = await publicClient.estimateGas({ + account: fundingAccount, + data, + maxFeePerGas, + maxPriorityFeePerGas, + to: tokenAddress, + value: 0n + }); + const feePerGas = fees.maxFeePerGas ?? fees.gasPrice; + if (feePerGas === undefined) { + throw new Error(`${executorName}: Could not estimate the funding wallet gas price`); + } + const baseL1Fee = await getBaseL1FeeUpperBoundRaw(destinationNetwork, EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES); + const maximumGasCost = Big(gas.toString()).mul(feePerGas.toString()).plus(baseL1Fee.toString()); + if (nativeBalance.lt(maximumGasCost)) { + throw this.createRecoverableError( + `${executorName}: Funding wallet native balance ${nativeBalance.toFixed()} is below maximum gas cost ${maximumGasCost.toFixed()}.` + ); + } + }, + externalId: operation => operation.hash ?? undefined, + perform: async () => { + if (transferAmount.eq(0)) { + return { amountRaw: "0", hash: null }; + } + if (data === undefined) { + throw new Error(`${executorName}: Missing transaction data after preflight`); + } + // Re-estimate inside the claimed operation and immediately before nonce + // selection. The send carries this explicit gas limit, so a deterministic + // revert here proves that nothing was broadcast. + try { + gas = await publicClient.estimateGas({ + account: fundingAccount, + data, + maxFeePerGas, + maxPriorityFeePerGas, + to: tokenAddress, + value: 0n + }); + } catch (error) { + if (isDeterministicPreBroadcastRevert(error)) { + throw new FinancialOperationRejectedError( + `${executorName}: Funding transfer was rejected during pre-broadcast gas estimation` + ); + } + throw error; + } + const nonce = await publicClient.getTransactionCount({ + address: fundingAccount.address, + blockTag: "pending" + }); + const hash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { + data, + gas, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + to: tokenAddress, + value: 0n + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error(`${executorName}: Subsidy transaction ${hash} failed`); + } + return { amountRaw: transferAmount.toFixed(0), hash }; + }, + provider: destinationNetwork, + reconcile: legacyOperation => + reconcileLegacyEvmSubsidy({ + destination: ephemeralAddress as `0x${string}`, + getTransaction: hash => publicClient.getTransaction({ hash }), + operation: legacyOperation, + source: fundingAccount.address, + targetBalanceRaw, + token: tokenAddress + }), + reconcileRequestMismatch: true, + request: { + destination: ephemeralAddress, + network: destinationNetwork, + source: fundingAccount.address, + targetBalanceRaw, + token: tokenAddress + }, + retryFailed: true, + settleAfterAbort: true, + signal + }), + signal + ); + + if (operation.hash) { + const subsidyAmount = nativeToDecimal(operation.amountRaw, decimals).toNumber(); + await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, operation.hash); + } + + // The poller resolves only at or above the target and throws on timeout, so the + // shortfall signal is the Timeout error, not a low return value. + try { + await checkEvmBalanceForToken({ + amountDesiredRaw: targetBalanceRaw, + chain: destinationNetwork, + intervalMs: 1000, + ownerAddress: ephemeralAddress, + signal, + timeoutMs: 5000, + tokenDetails + }); + } catch (error) { + if (error instanceof BalanceCheckError && error.type === BalanceCheckErrorType.Timeout) { + throw this.createRecoverableError( + `${executorName}: Confirmed subsidy operation did not leave the destination at its target balance.` + ); + } + throw error; + } + } +} diff --git a/apps/api/src/api/services/phases/blocks/core/fee-distribution.ts b/apps/api/src/api/services/phases/blocks/core/fee-distribution.ts index 66fa05fb39..a32416500f 100644 --- a/apps/api/src/api/services/phases/blocks/core/fee-distribution.ts +++ b/apps/api/src/api/services/phases/blocks/core/fee-distribution.ts @@ -5,6 +5,7 @@ import { EvmTransactionData, encodeSubmittableExtrinsic, getNetworkFromDestination, + multiplyByPowerOfTen, Networks, PENDULUM_USDC_ASSETHUB, PENDULUM_USDC_AXL, @@ -17,7 +18,6 @@ import { config } from "../../../../../config/vars"; import erc20ABI from "../../../../../contracts/ERC20"; 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"; diff --git a/apps/api/src/api/services/phases/blocks/core/quote-fees.ts b/apps/api/src/api/services/phases/blocks/core/quote-fees.ts index 48b0d63330..ef86d62e79 100644 --- a/apps/api/src/api/services/phases/blocks/core/quote-fees.ts +++ b/apps/api/src/api/services/phases/blocks/core/quote-fees.ts @@ -26,11 +26,6 @@ export interface FeeComponentsResult { feeCurrency: RampCurrency; } -export interface PreNablaDeductibleFeesResult { - preNablaDeductibleFeeAmount: Big; - feeCurrency: RampCurrency; -} - /** * Helper function to calculate a fee component (absolute or relative) * @param feeValue - The fee value from the database @@ -236,73 +231,6 @@ async function calculateAnchorFee( return totalAnchorFee; } -/** - * Calculate fees that are deducted before the Nabla swap - * @param inputAmount - The original user input amount - * @param inputCurrency - The input currency - * @param outputCurrency - The output currency - * @param rampType - The type of ramp operation - * @param from - The source destination type - * @param to - The target destination type - * @param partnerId - Optional partner id for custom fees - * @returns Promise resolving to the pre-Nabla deductible fees - */ -export async function calculatePreNablaDeductibleFees( - inputAmount: string, - inputCurrency: RampCurrency, - outputCurrency: RampCurrency, - rampType: RampDirection, - from: DestinationType, - to: DestinationType, - partnerId?: string -): Promise { - try { - // Validate chain support - validateChainSupport(rampType, from, to); - - // Determine the target fiat currency for fees - const feeCurrency = getTargetFiatCurrency(rampType, inputCurrency, outputCurrency); - - let preNablaDeductibleFeeAmount = new Big(0); - - if (rampType === RampDirection.BUY) { - // For on-ramp: Only Anchor Fee is deducted before Nabla - const anchorFee = await calculateAnchorFee(rampType, from, to, inputAmount, inputAmount); - - // Convert anchor fee to fee currency if needed - if (feeCurrency !== inputCurrency) { - const anchorFeeInFeeCurrency = await priceFeedService.convertCurrency(anchorFee.toString(), inputCurrency, feeCurrency); - preNablaDeductibleFeeAmount = new Big(anchorFeeInFeeCurrency); - } else { - preNablaDeductibleFeeAmount = anchorFee; - } - } else { - // For off-ramp: Vortex Fee + Partner Markup Fee - const { partnerMarkupFee, vortexFee } = await calculatePartnerAndVortexFees( - inputAmount, - rampType, - partnerId, - inputCurrency, - feeCurrency - ); - - preNablaDeductibleFeeAmount = vortexFee.plus(partnerMarkupFee); - } - - return { - feeCurrency, - preNablaDeductibleFeeAmount - }; - } catch (error) { - logger.error("Error calculating pre-Nabla deductible fees:", error); - - throw new APIError({ - message: QuoteError.FailedToCalculatePreNablaDeductibleFees, - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } -} - /** * Main function to calculate all fee components for a quote * @param request - The fee calculation request parameters diff --git a/apps/api/src/api/services/phases/blocks/core/register.ts b/apps/api/src/api/services/phases/blocks/core/register.ts deleted file mode 100644 index 7a774d6db8..0000000000 --- a/apps/api/src/api/services/phases/blocks/core/register.ts +++ /dev/null @@ -1,37 +0,0 @@ -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/squidrouter.ts b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts index ec33762d47..fe84e233ae 100644 --- a/apps/api/src/api/services/phases/blocks/core/squidrouter.ts +++ b/apps/api/src/api/services/phases/blocks/core/squidrouter.ts @@ -8,6 +8,7 @@ import { getRoute, isEvmTokenDetails, isNetworkEVM, + multiplyByPowerOfTen, NATIVE_TOKEN_ADDRESS, Networks, OnChainToken, @@ -24,7 +25,6 @@ import { Big } from "big.js"; import httpStatus from "http-status"; 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"; diff --git a/apps/api/src/api/services/phases/blocks/core/validation.ts b/apps/api/src/api/services/phases/blocks/core/validation.ts index 06598a8ed2..2e6dc10857 100644 --- a/apps/api/src/api/services/phases/blocks/core/validation.ts +++ b/apps/api/src/api/services/phases/blocks/core/validation.ts @@ -1,4 +1,4 @@ -import { FiatToken, getAnyFiatTokenDetails, RampDirection } from "@vortexfi/shared"; +import { FiatToken, getAnyFiatTokenDetails, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; import logger from "../../../../../config/logger"; @@ -8,7 +8,6 @@ import { ResolvedAlfredpayLimits, resolveAlfredpayQuoteLimits } from "../../../alfredpay/alfredpay.helpers"; -import { multiplyByPowerOfTen } from "../../../pendulum/helpers"; import { QuoteContext } from "../../../quote/core/types"; import { requiresEvmPartnerPayout } from "./helpers"; 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 index 559baca6d3..4f90356171 100644 --- 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 @@ -1,26 +1,16 @@ import { ApiManager, - BalanceCheckError, - BalanceCheckErrorType, - checkEvmBalanceForToken, - EvmClientManager, - EvmNetworks, EvmToken, EvmTokenDetails, - getEvmBalance, - getEvmNativeBalance, getOnChainTokenDetails, - isDeterministicPreBroadcastRevert, isNetworkEVM, 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"; @@ -28,24 +18,18 @@ 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 { EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES, getBaseL1FeeUpperBoundRaw } from "../../core/evm-destination-gas"; -import { getEvmFundingAccount, runSerializedEvmFundingOperation } from "../../core/evm-funding"; -import { FinancialOperationRejectedError } from "../../core/financial-operation"; -import { reconcileLegacyEvmSubsidy } from "../../core/legacy-evm-subsidy"; +import { EvmSubsidyTopUpExecutor } from "../../core/evm-subsidy-top-up"; 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 { +export class SubsidizePostSwapExecutor extends EvmSubsidyTopUpExecutor { public getPhaseName(): RampPhase { return "subsidizePostSwap"; } @@ -148,255 +132,66 @@ export class SubsidizePostSwapExecutor extends BasePhaseHandler { // simulated Nabla output. const expectedSwapOutputAmountRaw = Big(metadata.targetOutputAmountRaw); - const evmClientManager = EvmClientManager.getInstance(); - const destinationNetwork = outputTokenDetails.network as EvmNetworks; - const fundingAccount = getEvmFundingAccount(destinationNetwork); - - const publicClient = evmClientManager.getClient(destinationNetwork); - const tokenAddress = outputTokenDetails.erc20AddressSourceChain as `0x${string}`; - let maximumTransferAmount = Big(0); - let transferAmount = Big(0); - let data: `0x${string}` | undefined; - let gas: bigint | undefined; - let maxFeePerGas: bigint | undefined; - let maxPriorityFeePerGas: bigint | undefined; - - const operation = await runSerializedEvmFundingOperation( - destinationNetwork, - () => - this.runFinancialOperation(state, { - adoptSafeRequestHash: true, - attemptClass: "evm-subsidy-transfer", - beforePerform: async () => { - await sleep(EVM_SETTLEMENT_DELAY_MS, signal); - const currentBalance = await checkEvmBalanceForToken({ - amountDesiredRaw: "1", - chain: destinationNetwork, - intervalMs: 1000, - ownerAddress: evmEphemeralAddress, - signal, - timeoutMs: 5000, - tokenDetails: outputTokenDetails - }); - if (currentBalance.eq(0)) { - throw new Error("Invalid phase: input token did not arrive yet on EVM"); - } - - 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()}`); - maximumTransferAmount = requiredAmount.gt(0) ? requiredAmount : Big(0); - if (maximumTransferAmount.gt(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)) { - 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 ${maximumTransferAmount.toFixed()} to reach target value of ${expectedSwapOutputAmountRaw}` - ); - } - - const refreshedDestinationBalance = await getEvmBalance({ - chain: destinationNetwork, - ownerAddress: evmEphemeralAddress as `0x${string}`, - tokenDetails: outputTokenDetails - }); - const refreshedRequiredAmount = expectedSwapOutputAmountRaw.sub(refreshedDestinationBalance); - if (refreshedRequiredAmount.gt(maximumTransferAmount)) { + await this.topUpEvmEphemeral({ + assess: currentBalance => { + const subsidyComponents = calculatePostSwapSubsidyComponents({ + currentBalanceRaw: currentBalance, + discountSubsidyAmountRaw: String(metadata.subsidyAmountInOutputTokenRaw), + expectedOutputAmountRaw: expectedSwapOutputAmountRaw, + quotedActualOutputAmountRaw: String(metadata.actualOutputAmountRaw) + }); + return { + enforceCaps: async () => { + 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)) { throw this.createRecoverableError( - "SubsidizePostSwapExecutor: Destination balance decreased during preflight; retrying subsidy calculation." + `SubsidizePostSwapExecutor: Required swap discrepancy subsidy $${discrepancyUsd} exceeds cap $${discrepancyCapUsd.toFixed(2)} (max of $1.00 and ${discrepancyCapFraction} of quote output $${quoteOutputUsd}).` ); } - transferAmount = refreshedRequiredAmount.gt(0) ? refreshedRequiredAmount : Big(0); - if (transferAmount.eq(0)) return; - - data = encodeFunctionData({ - abi: erc20Abi, - args: [evmEphemeralAddress as `0x${string}`, BigInt(transferAmount.toFixed(0))], - functionName: "transfer" - }); - const fundingTokenBalance = await getEvmBalance({ - chain: destinationNetwork, - ownerAddress: fundingAccount.address, - tokenDetails: outputTokenDetails - }); - if (fundingTokenBalance.lt(transferAmount)) { - logger.error("EVM_FUNDING_TOKEN_BALANCE_LOW", { - availableRaw: fundingTokenBalance.toFixed(), - network: destinationNetwork, - phase: this.getPhaseName(), - rampId: state.id, - requiredRaw: transferAmount.toFixed(), - token: tokenAddress - }); + const discountCapFraction = config.subsidy.evmPostSwapDiscountSubsidyQuoteFraction; + const discountCapUsd = Big(quoteOutputUsd).mul(discountCapFraction); + if (Big(discountUsd).gte(1) && Big(discountUsd).gt(discountCapUsd)) { throw this.createRecoverableError( - `SubsidizePostSwapExecutor: Funding wallet token balance ${fundingTokenBalance.toFixed()} is below required subsidy ${transferAmount.toFixed()}.` + `SubsidizePostSwapExecutor: Required discount subsidy $${discountUsd} exceeds cap $${discountCapUsd.toFixed(2)} (${discountCapFraction} of quote output $${quoteOutputUsd}).` ); } - - const nativeBalance = await getEvmNativeBalance(fundingAccount.address, destinationNetwork); - if (nativeBalance.lte(0)) { - throw this.createRecoverableError("SubsidizePostSwapExecutor: Funding wallet has no native token for gas."); - } - - const fees = await publicClient.estimateFeesPerGas(); - maxFeePerGas = fees.maxFeePerGas; - maxPriorityFeePerGas = fees.maxPriorityFeePerGas; - gas = await publicClient.estimateGas({ - account: fundingAccount, - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: tokenAddress, - value: 0n - }); - const feePerGas = fees.maxFeePerGas ?? fees.gasPrice; - if (feePerGas === undefined) { - throw new Error("SubsidizePostSwapExecutor: Could not estimate the funding wallet gas price"); - } - const baseL1Fee = await getBaseL1FeeUpperBoundRaw(destinationNetwork, EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES); - const maximumGasCost = Big(gas.toString()).mul(feePerGas.toString()).plus(baseL1Fee.toString()); - if (nativeBalance.lt(maximumGasCost)) { - throw this.createRecoverableError( - `SubsidizePostSwapExecutor: Funding wallet native balance ${nativeBalance.toFixed()} is below maximum gas cost ${maximumGasCost.toFixed()}.` - ); - } - }, - externalId: operation => operation.hash ?? undefined, - perform: async () => { - if (transferAmount.eq(0)) { - return { amountRaw: "0", hash: null }; - } - if (data === undefined) { - throw new Error("SubsidizePostSwapExecutor: Missing transaction data after preflight"); - } - // Re-estimate inside the claimed operation and immediately before nonce - // selection. The send carries this explicit gas limit, so a deterministic - // revert here proves that nothing was broadcast. - try { - gas = await publicClient.estimateGas({ - account: fundingAccount, - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: tokenAddress, - value: 0n - }); - } catch (error) { - if (isDeterministicPreBroadcastRevert(error)) { - throw new FinancialOperationRejectedError( - "SubsidizePostSwapExecutor: Funding transfer was rejected during pre-broadcast gas estimation" - ); - } - throw error; - } - const nonce = await publicClient.getTransactionCount({ - address: fundingAccount.address, - blockTag: "pending" - }); - const hash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data, - gas, - maxFeePerGas, - maxPriorityFeePerGas, - nonce, - to: tokenAddress, - value: 0n - }); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); - if (receipt.status !== "success") { - throw new Error(`SubsidizePostSwapExecutor: Subsidy transaction ${hash} failed`); - } - return { amountRaw: transferAmount.toFixed(0), hash }; - }, - provider: destinationNetwork, - reconcile: legacyOperation => - reconcileLegacyEvmSubsidy({ - destination: evmEphemeralAddress as `0x${string}`, - getTransaction: hash => publicClient.getTransaction({ hash }), - operation: legacyOperation, - source: fundingAccount.address, - targetBalanceRaw: expectedSwapOutputAmountRaw.toFixed(0), - token: tokenAddress - }), - reconcileRequestMismatch: true, - request: { - destination: evmEphemeralAddress, - network: destinationNetwork, - source: fundingAccount.address, - targetBalanceRaw: expectedSwapOutputAmountRaw.toFixed(0), - token: tokenAddress }, - retryFailed: true, - settleAfterAbort: true, - signal - }), - signal - ); - - if (operation.hash) { - const subsidyAmount = nativeToDecimal(operation.amountRaw, metadata.outputDecimals).toNumber(); - const subsidyToken = metadata.outputCurrency as unknown as SubsidyToken; - await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, operation.hash); - } - - // The poller resolves only at or above the target and throws on timeout, so the - // shortfall signal is the Timeout error, not a low return value. - try { - await checkEvmBalanceForToken({ - amountDesiredRaw: expectedSwapOutputAmountRaw.toFixed(0), - chain: destinationNetwork, - intervalMs: 1000, - ownerAddress: evmEphemeralAddress, - signal, - timeoutMs: 5000, - tokenDetails: outputTokenDetails - }); - } catch (error) { - if (error instanceof BalanceCheckError && error.type === BalanceCheckErrorType.Timeout) { - throw this.createRecoverableError( - "SubsidizePostSwapExecutor: Confirmed subsidy operation did not leave the destination at its target balance." - ); - } - throw error; - } + requiredAmountRaw: subsidyComponents.requiredAmountRaw + }; + }, + decimals: metadata.outputDecimals, + ephemeralAddress: evmEphemeralAddress, + executorName: "SubsidizePostSwapExecutor", + label: "post-swap", + signal, + state, + subsidyToken: metadata.outputCurrency as unknown as SubsidyToken, + targetRaw: expectedSwapOutputAmountRaw, + tokenDetails: outputTokenDetails + }); return state; } catch (e) { 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 index 774e3e6003..c8967597e8 100644 --- 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 @@ -1,27 +1,16 @@ import { ApiManager, - BalanceCheckError, - BalanceCheckErrorType, - checkEvmBalanceForToken, - EvmClientManager, - EvmNetworks, EvmToken, EvmTokenDetails, - getEvmBalance, - getEvmNativeBalance, getOnChainTokenDetails, getPendulumDetails, - isDeterministicPreBroadcastRevert, 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"; @@ -29,20 +18,14 @@ 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 { EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES, getBaseL1FeeUpperBoundRaw } from "../../core/evm-destination-gas"; -import { getEvmFundingAccount, runSerializedEvmFundingOperation } from "../../core/evm-funding"; -import { FinancialOperationRejectedError } from "../../core/financial-operation"; -import { reconcileLegacyEvmSubsidy } from "../../core/legacy-evm-subsidy"; +import { EvmSubsidyTopUpExecutor } from "../../core/evm-subsidy-top-up"; 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 { +export class SubsidizePreSwapExecutor extends EvmSubsidyTopUpExecutor { public getPhaseName(): RampPhase { return "subsidizePreSwap"; } @@ -138,236 +121,47 @@ export class SubsidizePreSwapExecutor extends BasePhaseHandler { } // The swap consumes targetInputAmountRaw; feeReserveRaw (Alfredpay corridors) // additionally keeps the later distributeFees transfers funded on the ephemeral. - const expectedInputAmountForSwapRaw = Big(metadata.targetInputAmountRaw) - .plus(metadata.feeReserveRaw ?? "0") - .toFixed(0); - - const evmClientManager = EvmClientManager.getInstance(); - const destinationNetwork = inputTokenDetails.network as EvmNetworks; - const fundingAccount = getEvmFundingAccount(destinationNetwork); - - const publicClient = evmClientManager.getClient(destinationNetwork); - const tokenAddress = inputTokenDetails.erc20AddressSourceChain as `0x${string}`; - let maximumTransferAmount = Big(0); - let transferAmount = Big(0); - let data: `0x${string}` | undefined; - let gas: bigint | undefined; - let maxFeePerGas: bigint | undefined; - let maxPriorityFeePerGas: bigint | undefined; - - const operation = await runSerializedEvmFundingOperation( - destinationNetwork, - () => - this.runFinancialOperation(state, { - adoptSafeRequestHash: true, - attemptClass: "evm-subsidy-transfer", - beforePerform: async () => { - await sleep(EVM_SETTLEMENT_DELAY_MS, signal); - const currentBalance = await checkEvmBalanceForToken({ - amountDesiredRaw: "1", - chain: destinationNetwork, - intervalMs: 1000, - ownerAddress: evmEphemeralAddress, - signal, - timeoutMs: 5000, - tokenDetails: inputTokenDetails - }); - if (currentBalance.eq(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()}`); - maximumTransferAmount = requiredAmount.gt(0) ? requiredAmount : Big(0); - if (maximumTransferAmount.gt(0)) { - const subsidyDecimal = nativeToDecimal(maximumTransferAmount, 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)) { - 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 ${maximumTransferAmount.toFixed()} to reach target value of ${expectedInputAmountForSwapRaw}` - ); - } - - const refreshedDestinationBalance = await getEvmBalance({ - chain: destinationNetwork, - ownerAddress: evmEphemeralAddress as `0x${string}`, - tokenDetails: inputTokenDetails - }); - const refreshedRequiredAmount = Big(expectedInputAmountForSwapRaw).sub(refreshedDestinationBalance); - if (refreshedRequiredAmount.gt(maximumTransferAmount)) { - throw this.createRecoverableError( - "SubsidizePreSwapExecutor: Destination balance decreased during preflight; retrying subsidy calculation." - ); - } - transferAmount = refreshedRequiredAmount.gt(0) ? refreshedRequiredAmount : Big(0); - if (transferAmount.eq(0)) return; - - data = encodeFunctionData({ - abi: erc20Abi, - args: [evmEphemeralAddress as `0x${string}`, BigInt(transferAmount.toFixed(0))], - functionName: "transfer" - }); - const fundingTokenBalance = await getEvmBalance({ - chain: destinationNetwork, - ownerAddress: fundingAccount.address, - tokenDetails: inputTokenDetails - }); - if (fundingTokenBalance.lt(transferAmount)) { - logger.error("EVM_FUNDING_TOKEN_BALANCE_LOW", { - availableRaw: fundingTokenBalance.toFixed(), - network: destinationNetwork, - phase: this.getPhaseName(), - rampId: state.id, - requiredRaw: transferAmount.toFixed(), - token: tokenAddress - }); - throw this.createRecoverableError( - `SubsidizePreSwapExecutor: Funding wallet token balance ${fundingTokenBalance.toFixed()} is below required subsidy ${transferAmount.toFixed()}.` - ); - } - - const nativeBalance = await getEvmNativeBalance(fundingAccount.address, destinationNetwork); - if (nativeBalance.lte(0)) { - throw this.createRecoverableError("SubsidizePreSwapExecutor: Funding wallet has no native token for gas."); - } - - const fees = await publicClient.estimateFeesPerGas(); - maxFeePerGas = fees.maxFeePerGas; - maxPriorityFeePerGas = fees.maxPriorityFeePerGas; - gas = await publicClient.estimateGas({ - account: fundingAccount, - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: tokenAddress, - value: 0n - }); - const feePerGas = fees.maxFeePerGas ?? fees.gasPrice; - if (feePerGas === undefined) { - throw new Error("SubsidizePreSwapExecutor: Could not estimate the funding wallet gas price"); - } - const baseL1Fee = await getBaseL1FeeUpperBoundRaw(destinationNetwork, EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES); - const maximumGasCost = Big(gas.toString()).mul(feePerGas.toString()).plus(baseL1Fee.toString()); - if (nativeBalance.lt(maximumGasCost)) { - throw this.createRecoverableError( - `SubsidizePreSwapExecutor: Funding wallet native balance ${nativeBalance.toFixed()} is below maximum gas cost ${maximumGasCost.toFixed()}.` - ); - } - }, - externalId: operation => operation.hash ?? undefined, - perform: async () => { - if (transferAmount.eq(0)) { - return { amountRaw: "0", hash: null }; - } - if (data === undefined) { - throw new Error("SubsidizePreSwapExecutor: Missing transaction data after preflight"); - } - // Re-estimate inside the claimed operation and immediately before nonce - // selection. The send carries this explicit gas limit, so a deterministic - // revert here proves that nothing was broadcast. - try { - gas = await publicClient.estimateGas({ - account: fundingAccount, - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: tokenAddress, - value: 0n - }); - } catch (error) { - if (isDeterministicPreBroadcastRevert(error)) { - throw new FinancialOperationRejectedError( - "SubsidizePreSwapExecutor: Funding transfer was rejected during pre-broadcast gas estimation" - ); - } - throw error; - } - const nonce = await publicClient.getTransactionCount({ - address: fundingAccount.address, - blockTag: "pending" - }); - const hash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data, - gas, - maxFeePerGas, - maxPriorityFeePerGas, - nonce, - to: tokenAddress, - value: 0n - }); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); - if (receipt.status !== "success") { - throw new Error(`SubsidizePreSwapExecutor: Subsidy transaction ${hash} failed`); - } - return { amountRaw: transferAmount.toFixed(0), hash }; - }, - provider: destinationNetwork, - reconcile: legacyOperation => - reconcileLegacyEvmSubsidy({ - destination: evmEphemeralAddress as `0x${string}`, - getTransaction: hash => publicClient.getTransaction({ hash }), - operation: legacyOperation, - source: fundingAccount.address, - targetBalanceRaw: expectedInputAmountForSwapRaw, - token: tokenAddress - }), - reconcileRequestMismatch: true, - request: { - destination: evmEphemeralAddress, - network: destinationNetwork, - source: fundingAccount.address, - targetBalanceRaw: expectedInputAmountForSwapRaw, - token: tokenAddress - }, - retryFailed: true, - settleAfterAbort: true, - signal - }), - signal + const targetRaw = Big( + Big(metadata.targetInputAmountRaw) + .plus(metadata.feeReserveRaw ?? "0") + .toFixed(0) ); - if (operation.hash) { - const subsidyAmount = nativeToDecimal(operation.amountRaw, metadata.inputDecimals).toNumber(); - const subsidyToken = metadata.inputCurrency as unknown as SubsidyToken; - await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, operation.hash); - } - - // The poller resolves only at or above the target and throws on timeout, so the - // shortfall signal is the Timeout error, not a low return value. - try { - await checkEvmBalanceForToken({ - amountDesiredRaw: expectedInputAmountForSwapRaw, - chain: destinationNetwork, - intervalMs: 1000, - ownerAddress: evmEphemeralAddress, - signal, - timeoutMs: 5000, - tokenDetails: inputTokenDetails - }); - } catch (error) { - if (error instanceof BalanceCheckError && error.type === BalanceCheckErrorType.Timeout) { - throw this.createRecoverableError( - "SubsidizePreSwapExecutor: Confirmed subsidy operation did not leave the destination at its target balance." - ); - } - throw error; - } + await this.topUpEvmEphemeral({ + assess: currentBalance => ({ + enforceCaps: async maximumTransferAmount => { + const subsidyDecimal = nativeToDecimal(maximumTransferAmount, 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)) { + throw this.createRecoverableError( + `SubsidizePreSwapExecutor: Required subsidy $${subsidyUsd} exceeds cap $${subsidyCapUsd.toFixed(2)} (max of $1.00 and ${subsidyCapFraction} of quote output $${quoteOutputUsd}).` + ); + } + }, + requiredAmountRaw: targetRaw.sub(currentBalance) + }), + decimals: metadata.inputDecimals, + ephemeralAddress: evmEphemeralAddress, + executorName: "SubsidizePreSwapExecutor", + label: "pre-swap", + signal, + state, + subsidyToken: metadata.inputCurrency as unknown as SubsidyToken, + targetRaw, + tokenDetails: inputTokenDetails + }); return state; } catch (e) { 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 d93566efd4..3c31b59801 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -47,8 +47,6 @@ export interface StateMetadata { receiverTaxId: string; evmEphemeralAddress: string; substrateEphemeralAddress: string; - moonbeamEphemeralAccount: { secret: string; address: string }; - finalUserAddress: string; nabla: { approveExtrinsicOptions: ExtrinsicOptions; swapExtrinsicOptions: ExtrinsicOptions; @@ -56,10 +54,8 @@ export interface StateMetadata { assethubToPendulumHash: string; hydrationToAssethubXcmHash?: string; pendulumToAssethubXcmHash?: string; - pendulumToHydrationXcmHash?: string; pendulumToMoonbeamXcmHash?: string; moonbeamXcmTransactionHash: `0x${string}`; - hydrationSwapHash?: string; squidRouterApproveHash: string; squidRouterSwapHash: string; squidRouterPayTxHash: string; @@ -86,14 +82,11 @@ export interface StateMetadata { payOutTicketId: string | undefined; brlaPayoutTxHash?: `0x${string}`; permitTxHash?: string; - moneriumOnrampSelfTransferHash?: string; ibanPaymentData: IbanPaymentData; // Used for webhook notifications sessionId?: string; squidRouterQuoteId: string; // Final transaction hash and explorer link (computed once when ramp is complete) - finalTransactionHash?: string; - finalTransactionExplorerLink?: string; finalTransactionHashV2?: string; finalTransactionExplorerLinkV2?: string; // Alfredpay @@ -109,8 +102,6 @@ export interface StateMetadata { squidRouterPermitExecutionValue?: string; nablaSwapTxHash?: string; isDirectTransfer?: boolean; - // 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 // reports back the resulting tx hashes via UpdateRampRequest.additionalData. diff --git a/apps/api/src/api/services/phases/phase-registry.ts b/apps/api/src/api/services/phases/phase-registry.ts index 2f8e0a42ad..7c3e2c5df5 100644 --- a/apps/api/src/api/services/phases/phase-registry.ts +++ b/apps/api/src/api/services/phases/phase-registry.ts @@ -59,14 +59,6 @@ export class PhaseRegistry { public getHandler(phaseName: string): PhaseHandler | undefined { return this.handlers.get(phaseName); } - - /** - * Get all registered phase handlers - * @returns All registered phase handlers - */ - public getAllHandlers(): PhaseHandler[] { - return Array.from(this.handlers.values()); - } } export default PhaseRegistry.getInstance(); diff --git a/apps/api/src/api/services/phases/post-process/assethub-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/assethub-post-process-handler.ts deleted file mode 100644 index 3ada0f0a27..0000000000 --- a/apps/api/src/api/services/phases/post-process/assethub-post-process-handler.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CleanupPhase } from "@vortexfi/shared"; -import RampState from "../../../../models/rampState.model"; -import { BasePostProcessHandler } from "./base-post-process-handler"; - -export class AssetHubPostProcessHandler extends BasePostProcessHandler { - public getCleanupName(): CleanupPhase { - return "assetHubCleanup"; - } - - public shouldProcess(_state: RampState): boolean { - return false; - } - - public async process(_state: RampState): Promise<[boolean, Error | null]> { - return [true, null]; - } -} - -export default new AssetHubPostProcessHandler(); diff --git a/apps/api/src/api/services/phases/post-process/hydration-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/hydration-post-process-handler.ts deleted file mode 100644 index a2906ad0db..0000000000 --- a/apps/api/src/api/services/phases/post-process/hydration-post-process-handler.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { submitExtrinsic } from "@pendulum-chain/api-solang"; -import { ApiManager, CleanupPhase, decodeSubmittableExtrinsic, RampDirection } from "@vortexfi/shared"; -import logger from "../../../../config/logger"; -import RampState from "../../../../models/rampState.model"; -import { BasePostProcessHandler } from "./base-post-process-handler"; - -export class HydrationPostProcessHandler extends BasePostProcessHandler { - public getCleanupName(): CleanupPhase { - return "hydrationCleanup"; - } - - public shouldProcess(state: RampState): boolean { - if (state.currentPhase !== "complete") { - return false; - } - - if (state.type !== RampDirection.BUY) { - return false; - } - - const presignedTx = this.getPresignedTransaction(state, "hydrationCleanup"); - return presignedTx !== undefined; - } - - public async process(state: RampState): Promise<[boolean, Error | null]> { - const apiManager = ApiManager.getInstance(); - const hydrationNode = await apiManager.getApi("hydration"); - - try { - const { txData: hydrationCleanupTransaction } = this.getPresignedTransaction(state, "hydrationCleanup"); - - const cleanupExtrinsic = decodeSubmittableExtrinsic(hydrationCleanupTransaction as string, hydrationNode.api); - const result = await submitExtrinsic(cleanupExtrinsic); - - if (result.status.type === "error") { - return [false, this.createErrorObject(`Could not perform hydration cleanup: ${result.status.error.toString()}`)]; - } - - logger.info(`Successfully processed Hydration cleanup for ramp state ${state.id}`); - return [true, null]; - } catch (e) { - return [false, this.createErrorObject(`Error in Hydration cleanup: ${e}`)]; - } - } -} - -export default new HydrationPostProcessHandler(); diff --git a/apps/api/src/api/services/phases/post-process/index.ts b/apps/api/src/api/services/phases/post-process/index.ts index f1d8067c3d..fe9c27a598 100644 --- a/apps/api/src/api/services/phases/post-process/index.ts +++ b/apps/api/src/api/services/phases/post-process/index.ts @@ -1,4 +1,3 @@ -import assetHubPostProcessHandler from "./assethub-post-process-handler"; import baseChainPostProcessHandler from "./base-chain-post-process-handler"; import { BasePostProcessHandler } from "./base-post-process-handler"; import moonbeamPostProcessHandler from "./moonbeam-post-process-handler"; @@ -12,15 +11,7 @@ const postProcessHandlers: BasePostProcessHandler[] = [ pendulumPostProcessHandler, moonbeamPostProcessHandler, polygonPostProcessHandler, - baseChainPostProcessHandler, - assetHubPostProcessHandler + baseChainPostProcessHandler ]; -export { AssetHubPostProcessHandler } from "./assethub-post-process-handler"; -export { BaseChainPostProcessHandler } from "./base-chain-post-process-handler"; -export { BasePostProcessHandler } from "./base-post-process-handler"; -export { HydrationPostProcessHandler } from "./hydration-post-process-handler"; -export { MoonbeamPostProcessHandler } from "./moonbeam-post-process-handler"; -export { PendulumPostProcessHandler } from "./pendulum-post-process-handler"; -export { PolygonPostProcessHandler } from "./polygon-post-process-handler"; export { postProcessHandlers }; diff --git a/apps/api/src/api/services/price-providers.test.ts b/apps/api/src/api/services/price-providers.test.ts new file mode 100644 index 0000000000..256f9d07bf --- /dev/null +++ b/apps/api/src/api/services/price-providers.test.ts @@ -0,0 +1,709 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import crypto from "node:crypto"; +import { RampDirection } from "@vortexfi/shared"; +import logger from "../../config/logger"; +import { config } from "../../config/vars"; +import { + InvalidAmountError, + InvalidParameterError, + ProviderApiError, + ProviderInternalError, + UnsupportedPairError +} from "../errors/providerErrors"; +import * as alchemyPay from "./alchemypay/alchemypay.service"; +import * as moonpay from "./moonpay/moonpay.service"; +import * as transak from "./transak/transak.service"; + +/** + * Characterization of the competitor price adapters behind /v1/prices and /v1/prices/all: the exact request + * each one sends, the exact value it returns, and the error class + message for every failure branch (the + * messages are surfaced verbatim in the API responses). + */ + +type FetchCall = { url: string; init: RequestInit | undefined }; + +const originalFetch = globalThis.fetch; +let calls: FetchCall[] = []; + +function mockFetch(handler: (call: FetchCall) => Response | Promise) { + calls = []; + globalThis.fetch = Object.assign( + (input: Parameters[0], init?: Parameters[1]) => { + const call = { init, url: String(input) }; + calls.push(call); + return Promise.resolve().then(() => handler(call)); + }, + originalFetch + ) as typeof fetch; +} + +function respondWith(body: unknown, init: ResponseInit = {}) { + mockFetch(() => new Response(typeof body === "string" ? body : JSON.stringify(body), init)); +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error as Error; + } + throw new Error("expected the promise to reject"); +} + +async function expectProviderError(promise: Promise, errorClass: typeof ProviderApiError, message: string) { + const error = await rejection(promise); + expect(error.constructor).toBe(errorClass); + expect(error.name).toBe(errorClass.name); + expect(error.message).toBe(message); +} + +let logErrors: ReturnType; +let logWarnings: ReturnType; + +beforeEach(() => { + logErrors = spyOn(logger, "error").mockImplementation((() => logger) as never); + logWarnings = spyOn(logger, "warn").mockImplementation((() => logger) as never); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + calls = []; + logErrors.mockRestore(); + logWarnings.mockRestore(); +}); + +describe("AlchemyPay price adapter", () => { + const BASE_URL = "https://alchemypay.test"; + const QUOTE_URL = `${BASE_URL}/open/api/v4/merchant/order/quote`; + const TIMESTAMP = 1_700_000_000_000; + const original = { ...config.priceProviders.alchemyPay }; + let now: ReturnType; + + beforeEach(() => { + Object.assign(config.priceProviders.alchemyPay, { appId: "app-1", baseUrl: BASE_URL, secretKey: " secret-key " }); + now = spyOn(Date, "now").mockReturnValue(TIMESTAMP); + }); + + afterEach(() => { + Object.assign(config.priceProviders.alchemyPay, original); + now.mockRestore(); + }); + + // The documented AlchemyPay signature: base64(HMAC-SHA256(timestamp + METHOD + path + sorted JSON body)). + const sign = (body: string) => + crypto + .createHmac("sha256", "secret-key") + .update(`${TIMESTAMP}POST/open/api/v4/merchant/order/quote${body}`) + .digest("base64"); + + const okBody = (data: Record) => ({ data, success: true }); + const data = { cryptoPrice: "1", cryptoQuantity: "98.25", fiatQuantity: "60", networkFee: "0.5", rampFee: "1.5" }; + + describe("request", () => { + it("signs and sends a BUY quote request", async () => { + respondWith(okBody(data)); + await alchemyPay.getPriceFor("eur", "usdc", "100", RampDirection.BUY, "polygon"); + + const body = '{"amount":"100","crypto":"USDC","fiat":"EUR","network":"MATIC","payWayCode":"10001","side":"BUY"}'; + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe(QUOTE_URL); + expect(calls[0].init?.method).toBe("POST"); + expect(calls[0].init?.body).toBe(body); + expect(calls[0].init?.headers).toEqual({ + appId: "app-1", + "Content-Type": "application/json", + sign: sign(body), + timestamp: String(TIMESTAMP) + }); + expect(calls[0].init?.signal).toBeInstanceOf(AbortSignal); + }); + + it("signs and sends a SELL quote request (no payment method, usdc.e keeps the upper-cased code)", async () => { + respondWith(okBody(data)); + await alchemyPay.getPriceFor("usdc.e", "brl", "50", RampDirection.SELL, "avalanche"); + + const body = '{"amount":"50","crypto":"USDC.E","fiat":"BRL","network":"AVAX","side":"SELL"}'; + expect(calls[0].init?.body).toBe(body); + expect((calls[0].init?.headers as Record).sign).toBe(sign(body)); + }); + + it("maps networks, defaults to POLYGON, passes unknown networks through and stringifies numeric amounts", async () => { + respondWith(okBody(data)); + const bodyFor = async (network: string | undefined, amount: string | number = "1") => { + await alchemyPay.getPriceFor("eur", "eth", amount, RampDirection.BUY, network); + return JSON.parse(calls[calls.length - 1].init?.body as string); + }; + + expect((await bodyFor(undefined)).network).toBe("MATIC"); + expect((await bodyFor("Ethereum")).network).toBe("ETH"); + expect((await bodyFor("bsc")).network).toBe("BSC"); + expect((await bodyFor("arbitrum")).network).toBe("ARBITRUM"); + expect((await bodyFor("base")).network).toBe("base"); + expect((await bodyFor("polygon", 100.5)).amount).toBe("100.5"); + }); + + it("drops empty values from the signed body", async () => { + respondWith(okBody(data)); + await alchemyPay.getPriceFor("eur", "", "100", RampDirection.BUY); + + const body = '{"amount":"100","fiat":"EUR","network":"MATIC","payWayCode":"10001","side":"BUY"}'; + expect(calls[0].init?.body).toBe(body); + expect((calls[0].init?.headers as Record).sign).toBe(sign(body)); + }); + + it("fails before any network call when the app id or secret key is missing", async () => { + respondWith(okBody(data)); + for (const missing of ["appId", "secretKey"] as const) { + config.priceProviders.alchemyPay[missing] = ""; + const error = await rejection(alchemyPay.getPriceFor("eur", "usdc", "100", RampDirection.BUY)); + expect(error.constructor).toBe(Error); + expect(error.message).toBe("AlchemyPay configuration missing"); + config.priceProviders.alchemyPay[missing] = original[missing]; + } + expect(calls).toHaveLength(0); + }); + }); + + describe("successful responses", () => { + it("returns the crypto quantity for BUY", async () => { + respondWith(okBody(data)); + const result = await alchemyPay.getPriceFor("eur", "usdc", "100", RampDirection.BUY); + + expect(JSON.stringify(result)).toBe( + '{"direction":"BUY","provider":"alchemypay","quoteAmount":98.25,"requestedAmount":100,"totalFee":2}' + ); + }); + + it("returns the fiat quantity minus fees for SELL", async () => { + respondWith(okBody(data)); + const result = await alchemyPay.getPriceFor("usdc", "eur", "50", RampDirection.SELL); + + expect(JSON.stringify(result)).toBe( + '{"direction":"SELL","provider":"alchemypay","quoteAmount":58,"requestedAmount":50,"totalFee":2}' + ); + }); + + it("never returns a negative SELL quote and treats non-numeric fees as zero", async () => { + respondWith(okBody({ ...data, fiatQuantity: "1", networkFee: "2", rampFee: "1.5" })); + expect(await alchemyPay.getPriceFor("usdc", "eur", "50", RampDirection.SELL)).toEqual({ + direction: RampDirection.SELL, + provider: "alchemypay", + quoteAmount: 0, + requestedAmount: 50, + totalFee: 3.5 + }); + + respondWith(okBody({ ...data, fiatQuantity: "10", networkFee: "n/a", rampFee: "" })); + expect(await alchemyPay.getPriceFor("usdc", "eur", "50", RampDirection.SELL)).toEqual({ + direction: RampDirection.SELL, + provider: "alchemypay", + quoteAmount: 10, + requestedAmount: 50, + totalFee: 0 + }); + }); + }); + + describe("non-OK HTTP responses", () => { + const run = (status: number, body: unknown, statusText = "") => { + respondWith(body, { status, statusText }); + return alchemyPay.getPriceFor("eur", "usdc", "100", RampDirection.BUY); + }; + + it("maps 5xx to ProviderInternalError", async () => { + await expectProviderError(run(500, { returnMsg: "boom" }), ProviderInternalError, "AlchemyPay server error: boom"); + await expectProviderError( + run(503, {}, "Service Unavailable"), + ProviderInternalError, + "AlchemyPay server error: HTTP error 503: Service Unavailable" + ); + }); + + it("classifies 4xx by message: amount limits first, then unsupported pairs, otherwise invalid parameter", async () => { + await expectProviderError(run(400, { returnMsg: "Minimum amount is 10" }), InvalidAmountError, "AlchemyPay: Minimum amount is 10"); + await expectProviderError(run(422, { returnMsg: "above MAXIMUM" }), InvalidAmountError, "AlchemyPay: above MAXIMUM"); + await expectProviderError(run(400, { returnMsg: "Unsupported pair" }), UnsupportedPairError, "AlchemyPay: Unsupported pair"); + await expectProviderError(run(400, { returnMsg: "INVALID CURRENCY" }), UnsupportedPairError, "AlchemyPay: INVALID CURRENCY"); + await expectProviderError( + run(400, { returnMsg: "unsupported, minimum not met" }), + InvalidAmountError, + "AlchemyPay: unsupported, minimum not met" + ); + await expectProviderError(run(400, { returnMsg: "bad request" }), InvalidParameterError, "AlchemyPay API error: bad request"); + await expectProviderError( + run(404, null, "Not Found"), + InvalidParameterError, + "AlchemyPay API error: HTTP error 404: Not Found" + ); + }); + + it("maps other non-2xx statuses to ProviderInternalError", async () => { + await expectProviderError( + run(301, { returnMsg: "moved" }), + ProviderInternalError, + "Unexpected HTTP status 301 from AlchemyPay: moved" + ); + }); + }); + + describe("2xx responses with success=false", () => { + const run = (body: unknown) => { + respondWith(body); + return alchemyPay.getPriceFor("eur", "usdc", "100", RampDirection.BUY); + }; + + it("classifies the message: amount limits, unsupported pair, invalid parameter, otherwise internal error", async () => { + await expectProviderError(run({ returnMsg: "Minimum is 5", success: false }), InvalidAmountError, "AlchemyPay: Minimum is 5"); + await expectProviderError(run({ returnMsg: "maximum exceeded", success: false }), InvalidAmountError, "AlchemyPay: maximum exceeded"); + await expectProviderError(run({ returnMsg: "Unsupported network", success: false }), UnsupportedPairError, "AlchemyPay: Unsupported network"); + await expectProviderError(run({ returnMsg: "Invalid Currency", success: false }), UnsupportedPairError, "AlchemyPay: Invalid Currency"); + await expectProviderError(run({ returnMsg: "Invalid parameter x", success: false }), InvalidParameterError, "AlchemyPay: Invalid parameter x"); + await expectProviderError( + run({ returnMsg: "something else", success: false }), + ProviderInternalError, + "AlchemyPay API logic error: something else" + ); + await expectProviderError( + run({ success: false }), + ProviderInternalError, + "AlchemyPay API logic error: AlchemyPay API returned success=false with no message" + ); + }); + + it("rejects success=true without a data field", async () => { + await expectProviderError(run({ success: true }), ProviderInternalError, "AlchemyPay API returned success=true but no data field"); + }); + }); + + describe("fetch failures", () => { + it("wraps a rejected fetch in ProviderInternalError", async () => { + mockFetch(() => { + throw new TypeError("fetch failed"); + }); + await expectProviderError( + alchemyPay.getPriceFor("eur", "usdc", "100", RampDirection.BUY), + ProviderInternalError, + "Network error fetching price from AlchemyPay: fetch failed" + ); + }); + + it("wraps any non-TypeError failure the same way", async () => { + mockFetch(() => { + throw new DOMException("The operation timed out.", "TimeoutError"); + }); + await expectProviderError( + alchemyPay.getPriceFor("eur", "usdc", "100", RampDirection.BUY), + ProviderInternalError, + "Network error fetching price from AlchemyPay: The operation timed out." + ); + }); + + it("wraps an unparsable body in ProviderInternalError", async () => { + respondWith("not json"); + const error = await rejection(alchemyPay.getPriceFor("eur", "usdc", "100", RampDirection.BUY)); + expect(error).toBeInstanceOf(ProviderInternalError); + expect(error.message.startsWith("Network error fetching price from AlchemyPay: ")).toBe(true); + }); + }); +}); + +describe("Moonpay price adapter", () => { + const BASE_URL = "https://moonpay.test"; + const original = { ...config.priceProviders.moonpay }; + + beforeEach(() => { + Object.assign(config.priceProviders.moonpay, { apiKey: "pk_test_moonpay", baseUrl: BASE_URL }); + }); + + afterEach(() => { + Object.assign(config.priceProviders.moonpay, original); + }); + + const quote = { baseCurrency: { code: "eur", minAmount: 20 }, baseCurrencyAmount: 100, feeAmount: 5, quoteCurrencyAmount: 95 }; + + describe("request", () => { + it("sends a BUY quote request with the card payment method", async () => { + respondWith(quote); + await moonpay.getPriceFor("EUR", "USDC", "100", RampDirection.BUY); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe( + `${BASE_URL}/v3/currencies/usdc_polygon/buy_quote?apiKey=pk_test_moonpay&baseCurrencyAmount=100&baseCurrencyCode=eur&paymentMethod=credit_debit_card` + ); + expect(Object.keys(calls[0].init ?? {})).toEqual(["signal"]); + }); + + it("uses pix for BRL purchases", async () => { + respondWith(quote); + await moonpay.getPriceFor("BRL", "USDT", "100", RampDirection.BUY); + + expect(calls[0].url).toBe( + `${BASE_URL}/v3/currencies/usdt/buy_quote?apiKey=pk_test_moonpay&baseCurrencyAmount=100&baseCurrencyCode=brl&paymentMethod=pix_instant_payment` + ); + }); + + it("sends a SELL quote request, using SEPA for EUR payouts", async () => { + respondWith({ ...quote, baseCurrencyAmount: 50 }); + await moonpay.getPriceFor("usdc.e", "EUR", "50", RampDirection.SELL); + await moonpay.getPriceFor("ETH", "USD", "50", RampDirection.SELL); + + expect(calls[0].url).toBe( + `${BASE_URL}/v3/currencies/usdc_polygon/sell_quote?apiKey=pk_test_moonpay&baseCurrencyAmount=50&extraFeePercentage=0&payoutMethod=sepa_bank_transfer"eCurrencyCode=eur` + ); + expect(calls[1].url).toBe( + `${BASE_URL}/v3/currencies/eth/sell_quote?apiKey=pk_test_moonpay&baseCurrencyAmount=50&extraFeePercentage=0&payoutMethod=credit_debit_card"eCurrencyCode=usd` + ); + }); + + it("maps usdce to the polygon USDC code and lower-cases everything else", async () => { + respondWith(quote); + await moonpay.getPriceFor("EUR", "usdce", "100", RampDirection.BUY); + await moonpay.getPriceFor("EUR", "Pol", "100", RampDirection.BUY); + + expect(calls[0].url).toContain("/v3/currencies/usdc_polygon/buy_quote"); + expect(calls[1].url).toContain("/v3/currencies/pol/buy_quote"); + }); + + it("fails before any network call when the API key is missing", async () => { + respondWith(quote); + config.priceProviders.moonpay.apiKey = ""; + const error = await rejection(moonpay.getPriceFor("EUR", "USDC", "100", RampDirection.BUY)); + + expect(error.constructor).toBe(Error); + expect(error.message).toBe("Moonpay API key not configured"); + expect(calls).toHaveLength(0); + }); + }); + + describe("successful responses", () => { + it("returns the quote amount and fee", async () => { + respondWith(quote); + const result = await moonpay.getPriceFor("EUR", "USDC", "100", RampDirection.BUY); + + expect(JSON.stringify(result)).toBe( + '{"direction":"BUY","provider":"moonpay","quoteAmount":95,"requestedAmount":100,"totalFee":5}' + ); + }); + + it("returns the same shape for SELL", async () => { + respondWith({ ...quote, baseCurrencyAmount: 50, feeAmount: 1, quoteCurrencyAmount: 47 }); + const result = await moonpay.getPriceFor("USDC", "EUR", "50", RampDirection.SELL); + + expect(JSON.stringify(result)).toBe( + '{"direction":"SELL","provider":"moonpay","quoteAmount":47,"requestedAmount":50,"totalFee":1}' + ); + }); + + it("accepts a requested amount equal to the provider minimum", async () => { + respondWith({ ...quote, baseCurrency: { code: "eur", minAmount: 100 } }); + expect((await moonpay.getPriceFor("EUR", "USDC", "100", RampDirection.BUY)).quoteAmount).toBe(95); + }); + }); + + describe("validation of 2xx responses", () => { + const run = (body: unknown, amount = "100") => { + respondWith(body); + return moonpay.getPriceFor("EUR", "USDC", amount, RampDirection.BUY); + }; + + it("rejects responses missing essential fields", async () => { + const message = "Moonpay response missing essential data fields"; + await expectProviderError(run({ ...quote, baseCurrencyAmount: undefined }), ProviderInternalError, message); + await expectProviderError(run({ ...quote, quoteCurrencyAmount: undefined }), ProviderInternalError, message); + await expectProviderError(run({ ...quote, feeAmount: undefined }), ProviderInternalError, message); + }); + + it("rejects amounts below the provider minimum", async () => { + await expectProviderError( + run({ ...quote, baseCurrency: { code: "eur", minAmount: 120 } }), + InvalidAmountError, + "Moonpay: 120 eur is the minimum amount for this pair" + ); + }); + + it("rejects a quote for a different base amount than requested, and warns", async () => { + await expectProviderError( + run({ ...quote, baseCurrencyAmount: 99 }), + ProviderInternalError, + "Moonpay response discrepancy: Requested base amount 100, received 99" + ); + expect(logWarnings).toHaveBeenCalledTimes(1); + }); + + it("lets a response without baseCurrency surface the raw TypeError (not a provider error)", async () => { + const error = await rejection(run({ ...quote, baseCurrency: undefined })); + expect(error).toBeInstanceOf(TypeError); + expect(error).not.toBeInstanceOf(ProviderApiError); + }); + }); + + describe("non-OK HTTP responses", () => { + const run = (status: number, body: unknown, statusText = "") => { + respondWith(body, { status, statusText }); + return moonpay.getPriceFor("EUR", "USDC", "100", RampDirection.BUY); + }; + + it("maps NotFoundError and 'unsupported' messages to UnsupportedPairError (checked first)", async () => { + await expectProviderError(run(404, { message: "no such currency", type: "NotFoundError" }), UnsupportedPairError, "Moonpay: no such currency"); + await expectProviderError(run(400, { message: "Unsupported currency" }), UnsupportedPairError, "Moonpay: Unsupported currency"); + await expectProviderError(run(404, { message: "over the limit", type: "NotFoundError" }), UnsupportedPairError, "Moonpay: over the limit"); + }); + + it("maps minimum/maximum/limit messages to InvalidAmountError", async () => { + await expectProviderError(run(400, { message: "Minimum is 20" }), InvalidAmountError, "Moonpay: Minimum is 20"); + await expectProviderError(run(422, { message: "MAXIMUM is 5000" }), InvalidAmountError, "Moonpay: MAXIMUM is 5000"); + await expectProviderError(run(429, { message: "Rate limit exceeded" }), InvalidAmountError, "Moonpay: Rate limit exceeded"); + }); + + it("maps BadRequestError and HTTP 400 to InvalidParameterError (before the 5xx check)", async () => { + await expectProviderError(run(400, { message: "bad" }), InvalidParameterError, "Moonpay: bad"); + await expectProviderError(run(422, { message: "bad", type: "BadRequestError" }), InvalidParameterError, "Moonpay: bad"); + await expectProviderError(run(500, { message: "bad", type: "BadRequestError" }), InvalidParameterError, "Moonpay: bad"); + }); + + it("maps 5xx to ProviderInternalError", async () => { + await expectProviderError(run(500, { message: "kaput" }), ProviderInternalError, "Moonpay server error: kaput"); + await expectProviderError( + run(502, {}, "Bad Gateway"), + ProviderInternalError, + "Moonpay server error: HTTP error 502: Bad Gateway" + ); + }); + + it("maps any other failure to InvalidParameterError with the API-error prefix", async () => { + await expectProviderError(run(401, { message: "denied" }), InvalidParameterError, "Moonpay API error: denied"); + await expectProviderError(run(404, null, "Not Found"), InvalidParameterError, "Moonpay API error: HTTP error 404: Not Found"); + }); + }); + + describe("fetch failures", () => { + it("reports a TypeError as a network error", async () => { + mockFetch(() => { + throw new TypeError("fetch failed"); + }); + await expectProviderError( + moonpay.getPriceFor("EUR", "USDC", "100", RampDirection.BUY), + ProviderInternalError, + "Network error fetching price from Moonpay: fetch failed" + ); + }); + + it("reports any other failure as a parse error carrying the (usually absent) response status", async () => { + mockFetch(() => { + throw new DOMException("The operation timed out.", "TimeoutError"); + }); + await expectProviderError( + moonpay.getPriceFor("EUR", "USDC", "100", RampDirection.BUY), + ProviderInternalError, + "Failed to parse response from Moonpay (Status: undefined): undefined" + ); + + mockFetch(() => { + throw Object.assign(new Error("odd"), { response: { status: 418, statusText: "I'm a teapot" } }); + }); + await expectProviderError( + moonpay.getPriceFor("EUR", "USDC", "100", RampDirection.BUY), + ProviderInternalError, + "Failed to parse response from Moonpay (Status: 418): I'm a teapot" + ); + }); + + it("reports an unparsable body as a parse error", async () => { + respondWith("not json"); + await expectProviderError( + moonpay.getPriceFor("EUR", "USDC", "100", RampDirection.BUY), + ProviderInternalError, + "Failed to parse response from Moonpay (Status: undefined): undefined" + ); + }); + }); +}); + +describe("Transak price adapter", () => { + const BASE_URL = "https://transak.test"; + const original = { ...config.priceProviders.transak }; + + beforeEach(() => { + Object.assign(config.priceProviders.transak, { baseUrl: BASE_URL, partnerApiKey: "transak-key" }); + }); + + afterEach(() => { + Object.assign(config.priceProviders.transak, original); + }); + + const quote = { response: { conversionPrice: 0.95, cryptoAmount: 95, fiatAmount: 100, totalFee: 4 } }; + + describe("request", () => { + it("sends a BUY quote request on the default network", async () => { + respondWith(quote); + await transak.getPriceFor("eur", "usdc", "100", RampDirection.BUY); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe( + `${BASE_URL}/api/v1/pricing/public/quotes?cryptoCurrency=USDC&fiatAmount=100&fiatCurrency=EUR&isBuyOrSell=BUY&network=polygon&partnerApiKey=transak-key&paymentMethod=credit_debit_card` + ); + expect(Object.keys(calls[0].init ?? {})).toEqual(["signal"]); + }); + + it("sends a SELL quote request on the requested network", async () => { + respondWith(quote); + await transak.getPriceFor("usdt", "brl", 50, RampDirection.SELL, "Ethereum" as never); + + expect(calls[0].url).toBe( + `${BASE_URL}/api/v1/pricing/public/quotes?cryptoAmount=50&cryptoCurrency=USDT&fiatCurrency=BRL&isBuyOrSell=SELL&network=ethereum&partnerApiKey=transak-key` + ); + }); + + it("maps usdc.e and usdce to USDC and upper-cases everything else", async () => { + respondWith(quote); + await transak.getPriceFor("eur", "usdc.e", "100", RampDirection.BUY); + await transak.getPriceFor("eur", "usdce", "100", RampDirection.BUY); + await transak.getPriceFor("eur", "pol", "100", RampDirection.BUY); + + expect(calls[0].url).toContain("cryptoCurrency=USDC&"); + expect(calls[1].url).toContain("cryptoCurrency=USDC&"); + expect(calls[2].url).toContain("cryptoCurrency=POL&"); + }); + + it("fails before any network call when the partner API key is missing", async () => { + respondWith(quote); + config.priceProviders.transak.partnerApiKey = ""; + const error = await rejection(transak.getPriceFor("eur", "usdc", "100", RampDirection.BUY)); + + expect(error.constructor).toBe(Error); + expect(error.message).toBe("Transak partner API key is not defined"); + expect(calls).toHaveLength(0); + }); + }); + + describe("successful responses", () => { + it("returns the crypto amount for BUY", async () => { + respondWith(quote); + const result = await transak.getPriceFor("eur", "usdc", "100", RampDirection.BUY); + + expect(JSON.stringify(result)).toBe( + '{"direction":"BUY","provider":"transak","quoteAmount":95,"requestedAmount":100,"totalFee":4}' + ); + }); + + it("returns the fiat amount for SELL", async () => { + respondWith(quote); + const result = await transak.getPriceFor("usdc", "eur", "95", RampDirection.SELL); + + expect(JSON.stringify(result)).toBe( + '{"direction":"SELL","provider":"transak","quoteAmount":100,"requestedAmount":95,"totalFee":4}' + ); + }); + + it("accepts zero amounts and fees", async () => { + respondWith({ response: { conversionPrice: 0, cryptoAmount: 0, fiatAmount: 0, totalFee: 0 } }); + expect(await transak.getPriceFor("eur", "usdc", "100", RampDirection.BUY)).toEqual({ + direction: RampDirection.BUY, + provider: "transak", + quoteAmount: 0, + requestedAmount: 100, + totalFee: 0 + }); + }); + }); + + describe("validation of 2xx responses", () => { + const run = (body: unknown) => { + respondWith(body); + return transak.getPriceFor("eur", "usdc", "100", RampDirection.BUY); + }; + + it("rejects a missing response object or any missing essential field", async () => { + const message = "Transak response missing essential data fields"; + await expectProviderError(run({}), ProviderInternalError, message); + for (const field of ["conversionPrice", "cryptoAmount", "fiatAmount", "totalFee"] as const) { + await expectProviderError(run({ response: { ...quote.response, [field]: undefined } }), ProviderInternalError, message); + } + }); + }); + + describe("error responses (non-OK status or an error object)", () => { + const run = (status: number, body: unknown, statusText = "") => { + respondWith(body, { status, statusText }); + return transak.getPriceFor("eur", "usdc", "100", RampDirection.BUY); + }; + const error = (message: string) => ({ error: { message } }); + + it("maps unsupported-pair messages to UnsupportedPairError (checked first)", async () => { + for (const message of [ + "Invalid fiat currency", + "UNSUPPORTED token", + "Not available in your region", + "invalid crypto currency", + "Invalid network" + ]) { + await expectProviderError(run(400, error(message)), UnsupportedPairError, `Transak: ${message}`); + } + await expectProviderError(run(400, error("unsupported minimum")), UnsupportedPairError, "Transak: unsupported minimum"); + }); + + it("maps amount-limit messages to InvalidAmountError", async () => { + for (const message of ["Minimum is 20", "Maximum is 5000", "daily limit", "Amount exceeds balance"]) { + await expectProviderError(run(422, error(message)), InvalidAmountError, `Transak: ${message}`); + } + await expectProviderError(run(400, error("minimum")), InvalidAmountError, "Transak: minimum"); + }); + + it("maps HTTP 400 or an 'invalid parameter' message to InvalidParameterError (before the 5xx check)", async () => { + await expectProviderError(run(400, error("whatever")), InvalidParameterError, "Transak: whatever"); + await expectProviderError(run(422, error("Invalid parameter foo")), InvalidParameterError, "Transak: Invalid parameter foo"); + await expectProviderError(run(500, error("Invalid parameter foo")), InvalidParameterError, "Transak: Invalid parameter foo"); + }); + + it("maps 5xx to ProviderInternalError", async () => { + await expectProviderError(run(500, error("kaput")), ProviderInternalError, "Transak server error: kaput"); + await expectProviderError( + run(504, {}, "Gateway Timeout"), + ProviderInternalError, + "Transak server error: HTTP error 504: Gateway Timeout" + ); + }); + + it("maps any other failure to InvalidParameterError with the API-error prefix", async () => { + await expectProviderError(run(401, error("denied")), InvalidParameterError, "Transak API error: denied"); + await expectProviderError(run(404, null, "Not Found"), InvalidParameterError, "Transak API error: HTTP error 404: Not Found"); + }); + + it("treats an error object inside a 200 response as a failure", async () => { + await expectProviderError(run(200, error("Invalid network")), UnsupportedPairError, "Transak: Invalid network"); + await expectProviderError(run(200, error("odd"), "OK"), InvalidParameterError, "Transak API error: odd"); + await expectProviderError(run(200, { error: {} }, "OK"), InvalidParameterError, "Transak API error: HTTP error 200: OK"); + }); + }); + + describe("fetch failures", () => { + it("wraps a rejected fetch in ProviderInternalError", async () => { + mockFetch(() => { + throw new TypeError("fetch failed"); + }); + await expectProviderError( + transak.getPriceFor("eur", "usdc", "100", RampDirection.BUY), + ProviderInternalError, + "Network error fetching price from Transak: fetch failed" + ); + }); + + it("wraps any non-TypeError failure the same way", async () => { + mockFetch(() => { + throw new DOMException("The operation timed out.", "TimeoutError"); + }); + await expectProviderError( + transak.getPriceFor("eur", "usdc", "100", RampDirection.BUY), + ProviderInternalError, + "Network error fetching price from Transak: The operation timed out." + ); + }); + + it("wraps an unparsable body in ProviderInternalError", async () => { + respondWith("not json"); + const failure = await rejection(transak.getPriceFor("eur", "usdc", "100", RampDirection.BUY)); + expect(failure).toBeInstanceOf(ProviderInternalError); + expect(failure.message.startsWith("Network error fetching price from Transak: ")).toBe(true); + }); + }); +}); 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 b897b3246d..a037e27d80 100644 --- a/apps/api/src/api/services/quote/core/partner-resolution.ts +++ b/apps/api/src/api/services/quote/core/partner-resolution.ts @@ -2,6 +2,7 @@ import { CreateQuoteRequest, RampCurrency, RampDirection } from "@vortexfi/share import { Op } from "sequelize"; import logger from "../../../../config/logger"; import ProfilePartnerAssignment from "../../../../models/profilePartnerAssignment.model"; +import { UUID_PATTERN } from "../../../helpers/uuid"; import { findPartnerWithPricing, PartnerWithPricing } from "../../partners/partner-pricing.service"; import { getTargetFiatCurrency } from "../../phases/blocks/core/helpers"; import type { PartnerPricingSource } from "./types"; @@ -18,8 +19,6 @@ export interface ResolvedQuotePartner { source: PartnerPricingSource; } -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - async function findPartnerForRamp( partnerRef: string, rampType: RampDirection, diff --git a/apps/api/src/api/services/quote/utils.ts b/apps/api/src/api/services/quote/utils.ts deleted file mode 100644 index 7fa7acae02..0000000000 --- a/apps/api/src/api/services/quote/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { EvmToken, FiatToken, Networks } from "@vortexfi/shared"; - -export function isEurToEurcBaseDirect(inputCurrency: string, outputCurrency: string, toNetwork: string): boolean { - return inputCurrency === FiatToken.EURC && outputCurrency === EvmToken.EURC && toNetwork === Networks.Base; -} - -export function isBrlToBrlaBaseDirect(inputCurrency: string, outputCurrency: string, toNetwork: string): boolean { - return inputCurrency === FiatToken.BRL && outputCurrency === EvmToken.BRLA && toNetwork === Networks.Base; -} - -// Fiat -> own-stablecoin passthrough on Base (EUR->EURC, BRL->BRLA): the anchor already minted the -// requested stablecoin, so the swap/bridge/subsidy steps are skipped and funds transfer directly. -export function isFiatToOwnStablecoinBaseDirect(inputCurrency: string, outputCurrency: string, toNetwork: string): boolean { - return ( - isEurToEurcBaseDirect(inputCurrency, outputCurrency, toNetwork) || - isBrlToBrlaBaseDirect(inputCurrency, outputCurrency, toNetwork) - ); -} diff --git a/apps/api/src/api/services/ramp/base.service.ts b/apps/api/src/api/services/ramp/base.service.ts index d5ab2e8a57..a31e27802c 100644 --- a/apps/api/src/api/services/ramp/base.service.ts +++ b/apps/api/src/api/services/ramp/base.service.ts @@ -1,6 +1,5 @@ import { RampPhase } from "@vortexfi/shared"; import { Op, QueryTypes, Transaction } from "sequelize"; -import { v4 as uuidv4 } from "uuid"; import sequelize from "../../../config/database"; import logger from "../../../config/logger"; import QuoteTicket from "../../../models/quoteTicket.model"; @@ -99,7 +98,7 @@ export class BaseRampService { ): Promise { return RampState.create( { - id: uuidv4(), + id: crypto.randomUUID(), ...data, errorLogs: [], phaseHistory: [ @@ -122,16 +121,6 @@ export class BaseRampService { }); } - /** - * Update a ramp state - */ - protected async updateRampState(id: string, data: Partial): Promise<[number, RampState[]]> { - return RampState.update(data, { - returning: true, - where: { id } - }); - } - /** * Log a phase transition */ @@ -177,21 +166,6 @@ export class BaseRampService { ); } - /** - * Check if a quote is valid (pending and not expired) - */ - protected async isQuoteValid(id: string): Promise { - const quote = await QuoteTicket.findOne({ - where: { id } - }); - - if (!quote) { - return false; - } - - return quote.status === "pending" && new Date(quote.expiresAt) > new Date(); - } - /** * Execute a function within a transaction */ diff --git a/apps/api/src/api/services/ramp/helpers.test.ts b/apps/api/src/api/services/ramp/helpers.test.ts index 35e62b917a..b35f095759 100644 --- a/apps/api/src/api/services/ramp/helpers.test.ts +++ b/apps/api/src/api/services/ramp/helpers.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { Networks, RampDirection } from "@vortexfi/shared"; +import { Networks, RampDirection, TransactionStatus } from "@vortexfi/shared"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState from "../../../models/rampState.model"; -import { getFinalTransactionHashForRampV2 } from "./helpers"; +import { getFinalTransactionHashForRampV2, mapPhaseToTransactionStatus } from "./helpers"; type RampStateTestOverrides = { currentPhase?: string; @@ -109,3 +109,15 @@ describe("getFinalTransactionHashForRampV2", () => { }); }); }); + +describe("mapPhaseToTransactionStatus", () => { + it.each([ + ["complete", TransactionStatus.COMPLETE], + ["failed", TransactionStatus.FAILED], + ["timedOut", TransactionStatus.FAILED], + ["initial", TransactionStatus.PENDING], + ["nablaSwap", TransactionStatus.PENDING] + ])("maps %s to %s", (phase, expected) => { + expect(mapPhaseToTransactionStatus(phase)).toBe(expected); + }); +}); diff --git a/apps/api/src/api/services/ramp/helpers.ts b/apps/api/src/api/services/ramp/helpers.ts index 693fd51724..4bbd4866db 100644 --- a/apps/api/src/api/services/ramp/helpers.ts +++ b/apps/api/src/api/services/ramp/helpers.ts @@ -1,4 +1,4 @@ -import { RampDirection } from "@vortexfi/shared"; +import { RampDirection, TransactionStatus } from "@vortexfi/shared"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; @@ -229,3 +229,12 @@ export function getFinalTransactionHashForRampV2( return { transactionExplorerLink: undefined, transactionHash: undefined }; } + +/** + * Map a ramp phase to the user-facing transaction status (also used for webhook status changes). + */ +export function mapPhaseToTransactionStatus(phase: string): TransactionStatus { + if (phase === "complete") return TransactionStatus.COMPLETE; + if (phase === "failed" || phase === "timedOut") return TransactionStatus.FAILED; + return TransactionStatus.PENDING; +} 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 8348e830d5..155b9fa71a 100644 --- a/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts +++ b/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, it, mock } from "bun:test"; -import { EPaymentMethod, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import { EPaymentMethod, FiatToken, Networks, RampDirection, RampPhase, UnsignedTx } from "@vortexfi/shared"; import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState from "../../../models/rampState.model"; @@ -94,6 +94,25 @@ function makeRampState(onHold: boolean, currentPhase: RampPhase = "brlaOnrampMin }); } +const EVM_EPHEMERAL = "0x3333333333333333333333333333333333333333"; +const ephemeralTx: UnsignedTx = { meta: {}, network: Networks.Base, nonce: 0, phase: "distributeFees", signer: EVM_EPHEMERAL, txData: "0x" }; +const userWalletTx: UnsignedTx = { + meta: {}, + network: Networks.Base, + nonce: 0, + phase: "squidRouterPermitExecute", + signer: "0x4444444444444444444444444444444444444444", + txData: "0x" +}; + +function makeSellRampState() { + const rampState = makeRampState(false, "initial"); + rampState.type = RampDirection.SELL; + rampState.unsignedTxs = [ephemeralTx, userWalletTx]; + rampState.state = makeStateMetadata({ evmEphemeralAddress: EVM_EPHEMERAL, presignChecksPass: false }); + return rampState; +} + function makeStateMetadata(overrides: Partial): StateMetadata { return { assethubToPendulumHash: "", @@ -103,16 +122,11 @@ function makeStateMetadata(overrides: Partial): StateMetadata { destinationAddress: "0x2222222222222222222222222222222222222222", distributeFeeHash: "", evmEphemeralAddress: "", - finalUserAddress: "", ibanPaymentData: { bic: "", iban: "", receiverName: "" }, - moonbeamEphemeralAccount: { - address: "", - secret: "" - }, moonbeamXcmTransactionHash: "0x0000000000000000000000000000000000000000000000000000000000000000", nabla: { approveExtrinsicOptions: makeExtrinsicOptions(), @@ -189,4 +203,33 @@ describe("RampService.getRampStatus", () => { expect(status?.currentPhase).toBe("fundEphemeral"); }); + + it("withholds SELL user-wallet txs while ephemeral presigned txs are missing", async () => { + const service = new TestRampService(makeSellRampState()); + + const status = await service.getRampStatus("ramp-1", true); + + expect(status?.unsignedTxs).toEqual([ephemeralTx]); + }); + + it("releases SELL user-wallet txs once the ephemeral presigned txs validate", async () => { + const service = new TestRampService(makeSellRampState()); + Object.assign(service, { ephemeralPresignChecksPass: mock(async () => true) }); + + const status = await service.getRampStatus("ramp-1", true); + + expect(status?.unsignedTxs).toEqual([ephemeralTx, userWalletTx]); + }); + + it("skips presign validation for BUY ramps, which the gate does not filter", async () => { + const rampState = makeRampState(false); + rampState.state = makeStateMetadata({ presignChecksPass: false }); + const service = new TestRampService(rampState); + const ephemeralPresignChecksPass = mock(async () => false); + Object.assign(service, { ephemeralPresignChecksPass }); + + await service.getRampStatus("ramp-1", true); + + expect(ephemeralPresignChecksPass).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 9461077099..a1ca9991b3 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -54,7 +54,7 @@ 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 { getFinalTransactionHashForRampV2, mapPhaseToTransactionStatus } from "./helpers"; const CLIENT_WRITABLE_RAMP_STATE_FIELDS = new Set([ "assethubToPendulumHash", @@ -413,7 +413,7 @@ export class RampService extends BaseRampService { paymentMethod: rampState.paymentMethod, quoteId: rampState.quoteId, sessionId: rampState.state.sessionId, - status: this.mapPhaseToStatus(rampState.currentPhase), + status: mapPhaseToTransactionStatus(rampState.currentPhase), to: rampState.to, type: rampState.type, unsignedTxs: filterUnsignedTxsForResponse(rampState, false), @@ -547,7 +547,7 @@ export class RampService extends BaseRampService { paymentMethod: rampState.paymentMethod, quoteId: rampState.quoteId, sessionId: rampState.state.sessionId, - status: this.mapPhaseToStatus(rampState.currentPhase), + status: mapPhaseToTransactionStatus(rampState.currentPhase), to: rampState.to, type: rampState.type, unsignedTxs: filterUnsignedTxsForResponse(rampState, ephemeralPresignChecksPass), @@ -676,7 +676,7 @@ export class RampService extends BaseRampService { paymentMethod: rampState.paymentMethod, quoteId: rampState.quoteId, sessionId: rampState.state.sessionId, - status: this.mapPhaseToStatus(rampState.currentPhase), + status: mapPhaseToTransactionStatus(rampState.currentPhase), to: rampState.to, type: rampState.type, unsignedTxs: rampState.unsignedTxs, @@ -787,7 +787,7 @@ export class RampService extends BaseRampService { processingFeeUsd, quoteId: rampState.quoteId, sessionId: rampState.state.sessionId, - status: this.mapPhaseToStatus(rampState.currentPhase), + status: mapPhaseToTransactionStatus(rampState.currentPhase), ...(subsidyDisplay ? { discountCurrency: subsidyDisplay.currency, @@ -805,7 +805,14 @@ export class RampService extends BaseRampService { vortexFeeFiat: fiatFees.vortex, vortexFeeUsd: usdFees.vortex, walletAddress: rampState.state.destinationAddress || rampState.state.walletAddress, - ...(showUnsignedTxs && { unsignedTxs: rampState.unsignedTxs }) + ...(showUnsignedTxs && { + unsignedTxs: filterUnsignedTxsForResponse( + rampState, + rampState.type !== RampDirection.SELL || + rampState.state.presignChecksPass || + (await this.ephemeralPresignChecksPass(rampState)) + ) + }) }; return response; @@ -917,7 +924,7 @@ export class RampService extends BaseRampService { fromAmount: quote.inputAmount, fromCurrency: quote.inputCurrency, id: ramp.id, - status: this.mapPhaseToStatus(ramp.currentPhase), + status: mapPhaseToTransactionStatus(ramp.currentPhase), to: ramp.to, toAmount: quote.outputAmount, toCurrency: quote.outputCurrency, @@ -930,15 +937,6 @@ export class RampService extends BaseRampService { return { totalCount, transactions }; } - /** - * Map ramp phase to a user-friendly status - */ - private mapPhaseToStatus(phase: RampPhase): TransactionStatus { - if (phase === "complete") return TransactionStatus.COMPLETE; - if (phase === "failed" || phase === "timedOut") return TransactionStatus.FAILED; - return TransactionStatus.PENDING; - } - /** * Append an error log to a ramping process. * This function limits the number of error logs to 100 per ramping process. @@ -1114,15 +1112,9 @@ export class RampService extends BaseRampService { } } - private mapPhaseToWebhookStatus(phase: RampPhase): TransactionStatus { - if (phase === "complete") return TransactionStatus.COMPLETE; - if (phase === "failed" || phase === "timedOut") return TransactionStatus.FAILED; - return TransactionStatus.PENDING; - } - private async notifyStatusChangeIfNeeded(rampState: RampState, oldPhase: RampPhase, newPhase: RampPhase): Promise { - const oldStatus = this.mapPhaseToWebhookStatus(oldPhase); - const newStatus = this.mapPhaseToWebhookStatus(newPhase); + const oldStatus = mapPhaseToTransactionStatus(oldPhase); + const newStatus = mapPhaseToTransactionStatus(newPhase); // Only notify if status has changed and new status is not FAILED if (oldStatus !== newStatus && newStatus !== TransactionStatus.FAILED) { diff --git a/apps/api/src/api/services/spreadsheet.service.ts b/apps/api/src/api/services/spreadsheet.service.ts index 86798f8a3a..e068b54bd4 100644 --- a/apps/api/src/api/services/spreadsheet.service.ts +++ b/apps/api/src/api/services/spreadsheet.service.ts @@ -9,12 +9,6 @@ export interface GoogleCredentials { key?: string; } -interface SpreadsheetService { - initGoogleSpreadsheet: (sheetId: string, credentials: GoogleCredentials) => Promise; - getOrCreateSheet: (doc: GoogleSpreadsheet, headerValues: string[]) => Promise; - appendData: (sheet: GoogleSpreadsheetWorksheet, data: Record) => Promise; -} - export const initGoogleSpreadsheet = async (sheetId: string, credentials: GoogleCredentials): Promise => { if (!credentials.email || !credentials.key) { throw new Error("Missing required Google credentials"); @@ -69,9 +63,3 @@ export const appendData = async (sheet: GoogleSpreadsheetWorksheet, data: Record const doHeadersMatch = (existingHeaders: string[], newHeaders: string[]): boolean => existingHeaders.length === newHeaders.length && existingHeaders.every((header, index) => header === newHeaders[index]); - -export const spreadsheetService: SpreadsheetService = { - appendData, - getOrCreateSheet, - initGoogleSpreadsheet -}; diff --git a/apps/api/src/api/services/transactions/hydration/cleanup.ts b/apps/api/src/api/services/transactions/hydration/cleanup.ts deleted file mode 100644 index ba2ce8d958..0000000000 --- a/apps/api/src/api/services/transactions/hydration/cleanup.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SubmittableExtrinsic } from "@polkadot/api/types"; -import { ISubmittableResult } from "@polkadot/types/types"; -import { ApiManager, getAddressForFormat } from "@vortexfi/shared"; -import { getFundingAccount } from "../../../controllers/subsidize.controller"; - -export async function prepareHydrationCleanupTransaction( - inputAssetId: string | number, - outputAssetId: string | number -): Promise> { - const apiManager = ApiManager.getInstance(); - const { api, ss58Format } = await apiManager.getApi("hydration"); - - const fundingAccountKeypair = getFundingAccount(); - const fundingAddress = getAddressForFormat(fundingAccountKeypair.address, ss58Format); - - return api.tx.utility.batchAll([ - api.tx.tokens.transferAll(fundingAddress, inputAssetId, false), - api.tx.tokens.transferAll(fundingAddress, outputAssetId, false), - api.tx.balances.transferAll(fundingAddress, false) - ]); -} diff --git a/apps/api/src/api/services/transactions/hydration/index.ts b/apps/api/src/api/services/transactions/hydration/index.ts deleted file mode 100644 index 21410d93e5..0000000000 --- a/apps/api/src/api/services/transactions/hydration/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ApiManager } from "@vortexfi/shared"; -import hydrationRouter from "../../hydration/swap"; -import "@galacticcouncil/api-augment/hydradx"; -import { u8aToHex } from "@polkadot/util"; -import { decodeAddress } from "@polkadot/util-crypto"; // Import to augment types - -/// Builds the necessary transactions to swap on Hydration and do an XCM transfers to AssetHub -/// while paying with the assets that are available in transit. -export async function buildHydrationSwapTransaction( - assetIn: string, - assetOut: string, - amountIn: string, - beneficiaryAddress: string, - slippagePercent?: number -) { - const { api } = await ApiManager.getInstance().getApi("hydration"); - - const trade = await hydrationRouter.getBestSellPriceFor(assetIn, assetOut, amountIn); - const swapTx = await hydrationRouter.createTransactionForTrade(trade, beneficiaryAddress, slippagePercent); - - // Pay the tx fee in the output asset of the swap - const changeFeeCurrencyTx = api.tx.multiTransactionPayment.setCurrency(assetOut); - - return api.tx.utility.batchAll([swapTx.get(), changeFeeCurrencyTx]); -} - -/// Builds the necessary transaction to do an XCM transfer from Hydration to AssetHub -/// while paying the fee in the asset being transferred. -export async function buildHydrationToAssetHubTransfer( - receiverAddress: string, - rawAmount: string, - hydrationAssetId: string, - assethubAssetIndex: number | "native" -) { - const { api } = await ApiManager.getInstance().getApi("hydration"); - - const receiverId = u8aToHex(decodeAddress(receiverAddress)); - - const concreteAsset = - assethubAssetIndex === "native" - ? { interior: "Here", parents: 1 } - : { interior: { X3: [{ Parachain: 1000 }, { PalletInstance: 50 }, { GeneralIndex: assethubAssetIndex }] }, parents: 1 }; - - // Pay the tx fee in the asset - const changeFeeCurrencyTx = api.tx.multiTransactionPayment.setCurrency(hydrationAssetId); - - const dest = { V3: { interior: { X1: { Parachain: 1000 } }, parents: 1 } }; - const beneficiary = { V3: { interior: { X1: { AccountId32: { id: receiverId, network: null } } }, parents: 0 } }; - const assets = { - V3: [ - { - fun: { Fungible: rawAmount }, - id: { - Concrete: concreteAsset - } - } - ] - }; - - const xcmTransferTx = api.tx.polkadotXcm.transferAssets(dest, beneficiary, assets, 0, "Unlimited"); - - return api.tx.utility.batchAll([changeFeeCurrencyTx, xcmTransferTx]); -} diff --git a/apps/api/src/api/services/transactions/moonbeam/balance.ts b/apps/api/src/api/services/transactions/moonbeam/balance.ts deleted file mode 100644 index acaf57d5c8..0000000000 --- a/apps/api/src/api/services/transactions/moonbeam/balance.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/blocks/core/evm-funding"; - -export const fundMoonbeamEphemeralAccount = async (ephemeralAddress: string) => { - try { - const apiManager = ApiManager.getInstance(); - const apiData = await apiManager.getApi("moonbeam"); - - const fundingAmountRaw = multiplyByPowerOfTen(MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS, apiData.decimals).toFixed(); - - const moonbeamExecutorAccount = getEvmFundingAccount(Networks.Moonbeam); - const evmClientManager = EvmClientManager.getInstance(); - const publicClient = evmClientManager.getClient(Networks.Moonbeam); - const walletClient = evmClientManager.getWalletClient(Networks.Moonbeam, moonbeamExecutorAccount); - - const txHash = await walletClient.sendTransaction({ - to: ephemeralAddress as `0x${string}`, - value: BigInt(fundingAmountRaw) - }); - - const receipt = await publicClient.waitForTransactionReceipt({ - hash: txHash as `0x${string}` - }); - if (!receipt || receipt.status !== "success") { - throw new Error(`fundMoonbeamEphemeralAccount: Transaction ${txHash} failed or was not found`); - } - } catch (error) { - logger.error("Error during funding Moonbeam ephemeral:", error); - throw new Error("Error during funding Moonbeam ephemeral: " + error); - } -}; diff --git a/apps/api/src/api/services/transak/request-creator.ts b/apps/api/src/api/services/transak/request-creator.ts deleted file mode 100644 index e3d90bca00..0000000000 --- a/apps/api/src/api/services/transak/request-creator.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Networks, RampDirection } from "@vortexfi/shared"; -import { config } from "../../../config/vars"; - -/** - * Payment method constants for Transak API - */ -const PAYMENT_METHODS = { - CREDIT_CARD: "credit_debit_card" -} as const; - -/** - * Type definition for request configuration - */ -type RequestConfig = { - requestPath: string; - params: URLSearchParams; -}; - -/** - * Create a buy (onramp) quote request - * @param cryptoCurrencyCode The cryptocurrency code - * @param fiatCurrencyCode The fiat currency code - * @param fiatAmount The fiat amount to convert - * @param network The blockchain network - * @returns Request configuration - */ -function createBuyQuoteRequest( - cryptoCurrencyCode: string, - fiatCurrencyCode: string, - fiatAmount: string, - network: Networks -): RequestConfig { - const requestPath = "/api/v1/pricing/public/quotes"; - - const paramsObj: Record = { - cryptoCurrency: cryptoCurrencyCode, - fiatAmount, - fiatCurrency: fiatCurrencyCode, - isBuyOrSell: "BUY", - network: network.toLowerCase(), - partnerApiKey: config.priceProviders.transak.partnerApiKey || "", - paymentMethod: PAYMENT_METHODS.CREDIT_CARD - }; - - return { - params: new URLSearchParams(paramsObj), - requestPath - }; -} - -/** - * Create a sell (offramp) quote request - * @param cryptoCurrencyCode The cryptocurrency code - * @param fiatCurrencyCode The fiat currency code - * @param cryptoAmount The crypto amount to convert - * @param network The blockchain network - * @returns Request configuration - */ -function createSellQuoteRequest( - cryptoCurrencyCode: string, - fiatCurrencyCode: string, - cryptoAmount: string, - network: Networks -): RequestConfig { - const requestPath = "/api/v1/pricing/public/quotes"; - - const paramsObj: Record = { - cryptoAmount, - cryptoCurrency: cryptoCurrencyCode, - fiatCurrency: fiatCurrencyCode, - isBuyOrSell: "SELL", - network: network.toLowerCase(), - partnerApiKey: config.priceProviders.transak.partnerApiKey || "" - }; - - return { - params: new URLSearchParams(paramsObj), - requestPath - }; -} - -/** - * Create a quote request based on direction - * @param direction The direction of the conversion (onramp or offramp) - * @param cryptoCurrencyCode The cryptocurrency code - * @param fiatCurrencyCode The fiat currency code - * @param amount The amount to convert - * @param network The blockchain network - * @returns Request configuration - */ -export function createQuoteRequest( - direction: RampDirection, - cryptoCurrencyCode: string, - fiatCurrencyCode: string, - amount: string, - network: Networks -): RequestConfig { - return direction === RampDirection.BUY - ? createBuyQuoteRequest(cryptoCurrencyCode, fiatCurrencyCode, amount, network) - : createSellQuoteRequest(cryptoCurrencyCode, fiatCurrencyCode, amount, network); -} diff --git a/apps/api/src/api/services/transak/response-handler.ts b/apps/api/src/api/services/transak/response-handler.ts deleted file mode 100644 index 8043f6c07d..0000000000 --- a/apps/api/src/api/services/transak/response-handler.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { RampDirection, TransakPriceResponse } from "@vortexfi/shared"; -import logger from "../../../config/logger"; -import { - InvalidAmountError, - InvalidParameterError, - ProviderInternalError, - UnsupportedPairError -} from "../../errors/providerErrors"; - -/** - * Transak API response interface - */ -export interface TransakApiResponse { - response?: { - conversionPrice: number; - cryptoAmount: number; - fiatAmount: number; - totalFee: number; - fiatCurrency?: string; - cryptoCurrency?: string; - }; - error?: { - message: string; - }; -} - -/** - * Handle Transak API errors - * @param response The HTTP response - * @param body The response body - * @returns Never returns, always throws an appropriate error - */ -function handleTransakError(response: Response, body: TransakApiResponse): never { - const errorMessage = body?.error?.message || `HTTP error ${response.status}: ${response.statusText}`; - - logger.error(`Transak API Error (${response.status}): ${errorMessage}`); - - const lowerErrorMessage = errorMessage.toLowerCase(); - - // Classify errors based on message content - if ( - lowerErrorMessage.includes("invalid fiat currency") || - lowerErrorMessage.includes("unsupported") || - lowerErrorMessage.includes("not available") || - lowerErrorMessage.includes("invalid crypto currency") || - lowerErrorMessage.includes("invalid network") - ) { - throw new UnsupportedPairError(`Transak: ${errorMessage}`); - } - - if ( - lowerErrorMessage.includes("minimum") || - lowerErrorMessage.includes("maximum") || - lowerErrorMessage.includes("limit") || - lowerErrorMessage.includes("exceeds") - ) { - throw new InvalidAmountError(`Transak: ${errorMessage}`); - } - - if (response.status === 400 || lowerErrorMessage.includes("invalid parameter")) { - throw new InvalidParameterError(`Transak: ${errorMessage}`); - } - - if (response.status >= 500) { - throw new ProviderInternalError(`Transak server error: ${errorMessage}`); - } - - // Default to InvalidParameterError for other 4xx or unexpected errors - throw new InvalidParameterError(`Transak API error: ${errorMessage}`); -} - -/** - * Validate and transform Transak API response - * @param body The response body - * @param requestedAmount The amount that was requested - * @param direction The direction of the conversion (onramp or offramp) - * @returns Standardized price response - */ -function validateTransakResponse( - body: TransakApiResponse, - requestedAmount: string, - direction: RampDirection -): TransakPriceResponse { - if ( - !body.response || - body.response.conversionPrice === undefined || - body.response.cryptoAmount === undefined || - body.response.fiatAmount === undefined || - body.response.totalFee === undefined - ) { - throw new ProviderInternalError("Transak response missing essential data fields"); - } - - const { - response: { cryptoAmount, fiatAmount, totalFee } - } = body; - - const isBuy = direction === RampDirection.BUY; - - return { - direction, - provider: "transak", - quoteAmount: isBuy ? cryptoAmount : fiatAmount, - requestedAmount: Number(requestedAmount), - totalFee - }; -} - -/** - * Process Transak API response - * @param response The HTTP response - * @param body The response body - * @param requestedAmount The amount that was requested - * @param direction The direction of the conversion (onramp or offramp) - * @returns Standardized price response - */ -export function processTransakResponse( - response: Response, - body: TransakApiResponse, - requestedAmount: string, - direction: RampDirection -): TransakPriceResponse { - if (!response.ok || body.error) { - return handleTransakError(response, body); - } - - return validateTransakResponse(body, requestedAmount, direction); -} diff --git a/apps/api/src/api/services/transak/transak.service.ts b/apps/api/src/api/services/transak/transak.service.ts index f9547069b0..22a2762ff1 100644 --- a/apps/api/src/api/services/transak/transak.service.ts +++ b/apps/api/src/api/services/transak/transak.service.ts @@ -1,67 +1,53 @@ import { Networks, RampDirection, TransakPriceResponse } from "@vortexfi/shared"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; -import { ProviderInternalError } from "../../errors/providerErrors"; -import { fetchWithTimeout } from "../../helpers/fetchWithTimeout"; -import { createQuoteRequest } from "./request-creator"; -import { processTransakResponse, TransakApiResponse } from "./response-handler"; -import { getCryptoCode, getFiatCode } from "./utils"; +import { + InvalidAmountError, + InvalidParameterError, + ProviderInternalError, + UnsupportedPairError +} from "../../errors/providerErrors"; +import { fetchProviderJson } from "../../helpers/fetchProviderJson"; -const { priceProviders } = config; - -/** - * Type for fetch result - */ -type FetchResult = { - response: Response; - body: TransakApiResponse; -}; - -/** - * Fetch data from Transak API - * @param url The URL to fetch - * @returns The response and parsed body - */ -async function fetchTransakData(url: string): Promise { - try { - const response = await fetchWithTimeout(url); - const body = (await response.json()) as TransakApiResponse; - return { body, response }; - } catch (fetchError) { - logger.error("Transak fetch error:", fetchError); - throw new ProviderInternalError(`Network error fetching price from Transak: ${(fetchError as Error).message}`); - } +interface TransakApiResponse { + response?: { + conversionPrice: number; + cryptoAmount: number; + fiatAmount: number; + totalFee: number; + fiatCurrency?: string; + cryptoCurrency?: string; + }; + error?: { + message: string; + }; } -/** - * Query the Transak API for price quotes - * @param cryptoCurrencyCode The cryptocurrency code - * @param fiatCurrencyCode The fiat currency code - * @param amount The amount to convert - * @param network The blockchain network - * @param direction The direction of the conversion (onramp or offramp) - * @returns Standardized price response - */ -async function priceQuery( - cryptoCurrencyCode: string, - fiatCurrencyCode: string, - amount: string, - network: Networks, - direction: RampDirection -): Promise { - const { baseUrl, partnerApiKey } = priceProviders.transak; - - if (!partnerApiKey) { - throw new Error("Transak partner API key is not defined"); - } +const QUOTE_PATH = "/api/v1/pricing/public/quotes"; +const DEFAULT_NETWORK = "polygon"; - const { requestPath, params } = createQuoteRequest(direction, cryptoCurrencyCode, fiatCurrencyCode, amount, network); +/** Always throws: classifies a non-2xx response or an error object in the body. */ +function handleTransakError(response: Response, body: TransakApiResponse): never { + const errorMessage = body?.error?.message || `HTTP error ${response.status}: ${response.statusText}`; - const url = `${baseUrl}${requestPath}?${params.toString()}`; + logger.error(`Transak API Error (${response.status}): ${errorMessage}`); - const { response, body } = await fetchTransakData(url); + const lowerErrorMessage = errorMessage.toLowerCase(); - return processTransakResponse(response, body, amount, direction); + if (/invalid fiat currency|unsupported|not available|invalid crypto currency|invalid network/.test(lowerErrorMessage)) { + throw new UnsupportedPairError(`Transak: ${errorMessage}`); + } + if (/minimum|maximum|limit|exceeds/.test(lowerErrorMessage)) { + throw new InvalidAmountError(`Transak: ${errorMessage}`); + } + if (response.status === 400 || lowerErrorMessage.includes("invalid parameter")) { + throw new InvalidParameterError(`Transak: ${errorMessage}`); + } + if (response.status >= 500) { + throw new ProviderInternalError(`Transak server error: ${errorMessage}`); + } + // Default to InvalidParameterError for other 4xx or unexpected errors + throw new InvalidParameterError(`Transak API error: ${errorMessage}`); } /** @@ -71,28 +57,71 @@ async function priceQuery( * @param amount The amount to convert * @param direction The direction of the conversion (onramp or offramp) * @param network Optional network name - * @returns Transak price information in standardized format */ -export const getPriceFor = ( +export async function getPriceFor( sourceCurrency: string, targetCurrency: string, amount: string | number, direction: RampDirection, network?: Networks -): Promise => { - const DEFAULT_NETWORK = "polygon"; +): Promise { + const isBuy = direction === RampDirection.BUY; const networkCode = network?.toLowerCase() || DEFAULT_NETWORK; + // For offramp: source is crypto, target is fiat. For onramp: source is fiat, target is crypto. + const cryptoCode = (isBuy ? targetCurrency : sourceCurrency).toUpperCase(); + const fiatCode = (isBuy ? sourceCurrency : targetCurrency).toUpperCase(); + // Transak lists the bridged USDC.e as plain USDC + const transakCrypto = ["USDC.E", "USDCE"].includes(cryptoCode) ? "USDC" : cryptoCode; + const requestedAmount = amount.toString(); - // For offramp: source is crypto, target is fiat - // For onramp: source is fiat, target is crypto - const cryptoCurrency = direction === RampDirection.BUY ? targetCurrency : sourceCurrency; - const fiatCurrency = direction === RampDirection.BUY ? sourceCurrency : targetCurrency; + const { baseUrl, partnerApiKey } = config.priceProviders.transak; + if (!partnerApiKey) { + throw new Error("Transak partner API key is not defined"); + } - return priceQuery( - getCryptoCode(cryptoCurrency), - getFiatCode(fiatCurrency), - amount.toString(), - networkCode as Networks, - direction + const params = new URLSearchParams( + isBuy + ? { + cryptoCurrency: transakCrypto, + fiatAmount: requestedAmount, + fiatCurrency: fiatCode, + isBuyOrSell: "BUY", + network: networkCode, + partnerApiKey, + paymentMethod: "credit_debit_card" + } + : { + cryptoAmount: requestedAmount, + cryptoCurrency: transakCrypto, + fiatCurrency: fiatCode, + isBuyOrSell: "SELL", + network: networkCode, + partnerApiKey + } ); -}; + + const { response, body } = await fetchProviderJson("Transak", `${baseUrl}${QUOTE_PATH}?${params}`); + + if (!response.ok || body.error) { + return handleTransakError(response, body); + } + + if ( + !body.response || + body.response.conversionPrice === undefined || + body.response.cryptoAmount === undefined || + body.response.fiatAmount === undefined || + body.response.totalFee === undefined + ) { + throw new ProviderInternalError("Transak response missing essential data fields"); + } + + const { cryptoAmount, fiatAmount, totalFee } = body.response; + return { + direction, + provider: "transak", + quoteAmount: isBuy ? cryptoAmount : fiatAmount, + requestedAmount: Number(requestedAmount), + totalFee + }; +} diff --git a/apps/api/src/api/services/transak/utils.ts b/apps/api/src/api/services/transak/utils.ts deleted file mode 100644 index ccd1010561..0000000000 --- a/apps/api/src/api/services/transak/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Utility functions for Transak service - */ - -/** - * Normalize cryptocurrency code for Transak API - * @param fromCrypto The cryptocurrency code to normalize - * @returns Normalized cryptocurrency code - */ -export function getCryptoCode(fromCrypto: string): string { - const normalizedCrypto = fromCrypto.toLowerCase(); - if (["usdc", "usdc.e", "usdce"].includes(normalizedCrypto)) { - return "USDC"; - } - if (normalizedCrypto === "usdt") { - return "USDT"; - } - return fromCrypto.toUpperCase(); -} - -/** - * Normalize fiat currency code for Transak API - * @param toFiat The fiat currency code to normalize - * @returns Normalized fiat currency code - */ -export function getFiatCode(toFiat: string): string { - return toFiat.toUpperCase(); -} 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 69fb691f9d..95ccaac733 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 @@ -28,12 +28,10 @@ const fakeWebhook = (overrides: Record = {}) => ({ }); // Real timers, but backoff shrunk from 1s..16s to 1ms per attempt so the -// retry tests finish instantly. timeoutMs is shrunk so the per-attempt abort -// timer left dangling on rejected fetches fires (harmlessly) right away. +// retry tests finish instantly. const createService = () => { const service = new WebhookDeliveryService(); (service as unknown as { retryDelays: number[] }).retryDelays = [1, 1, 1, 1, 1]; - (service as unknown as { timeoutMs: number }).timeoutMs = 50; return service; }; 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 b097a9bf48..146d086fe3 100644 --- a/apps/api/src/api/services/webhook/webhook-delivery.service.ts +++ b/apps/api/src/api/services/webhook/webhook-delivery.service.ts @@ -4,12 +4,12 @@ import cryptoService from "../../../config/crypto"; import logger from "../../../config/logger"; import Webhook from "../../../models/webhook.model"; import { fetchWithTimeout } from "../../helpers/fetchWithTimeout"; +import { mapPhaseToTransactionStatus } from "../ramp/helpers"; 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]; // The signature covers the timestamp header, so a captured body+signature cannot be @@ -18,12 +18,6 @@ export class WebhookDeliveryService { return cryptoService.signPayload(`${timestamp}.${payload}`); } - private mapPhaseToStatus(phase: string): TransactionStatus { - if (phase === "complete") return TransactionStatus.COMPLETE; - if (phase === "failed" || phase === "timedOut") return TransactionStatus.FAILED; - return TransactionStatus.PENDING; - } - /** * One signed delivery attempt with the SSRF re-resolution guard. Shared by the * legacy in-process retry loop and the durable outbox dispatcher, which owns its @@ -39,9 +33,6 @@ export class WebhookDeliveryService { const timestamp = Math.floor(Date.now() / 1000); const signature = this.generateSignature(timestamp, payloadString); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); - const response = await fetchWithTimeout(webhook.url, { body: payloadString, headers: { @@ -57,12 +48,9 @@ export class WebhookDeliveryService { }, method: "POST", // A public host must not be able to bounce the request to a private one. - redirect: "error", - signal: controller.signal + redirect: "error" }); - clearTimeout(timeoutId); - if (response.ok) { return { error: null, ok: true }; } @@ -159,7 +147,7 @@ export class WebhookDeliveryService { quoteId, sessionId, transactionId, - transactionStatus: this.mapPhaseToStatus(newPhase), + transactionStatus: mapPhaseToTransactionStatus(newPhase), transactionType: transactionType }, timestamp: new Date().toISOString() diff --git a/apps/api/src/api/workers/alfredpay-status.worker.ts b/apps/api/src/api/workers/alfredpay-status.worker.ts index c4e880e595..deb9fcfed9 100644 --- a/apps/api/src/api/workers/alfredpay-status.worker.ts +++ b/apps/api/src/api/workers/alfredpay-status.worker.ts @@ -46,11 +46,6 @@ class AlfredpayStatusWorker { this.job.start(); } - public stop(): void { - logger.info("Stopping Alfredpay status worker"); - this.job.stop(); - } - private async poll(): Promise { try { const pending = await ProviderCustomer.findAll({ diff --git a/apps/api/src/api/workers/api-client-events-retention.worker.ts b/apps/api/src/api/workers/api-client-events-retention.worker.ts index 1a4378cd70..8acef0bc7f 100644 --- a/apps/api/src/api/workers/api-client-events-retention.worker.ts +++ b/apps/api/src/api/workers/api-client-events-retention.worker.ts @@ -17,11 +17,6 @@ class ApiClientEventsRetentionWorker { this.job.start(); } - public stop(): void { - logger.info("Stopping API client events retention worker"); - this.job.stop(); - } - private async cleanup(): Promise { logger.info("Running API client events retention worker cycle"); diff --git a/apps/api/src/api/workers/cleanup.worker.ts b/apps/api/src/api/workers/cleanup.worker.ts index a4afb4f87f..0dbd643b7e 100644 --- a/apps/api/src/api/workers/cleanup.worker.ts +++ b/apps/api/src/api/workers/cleanup.worker.ts @@ -38,14 +38,6 @@ class CleanupWorker { this.job.start(); } - /** - * Stop the cleanup worker - */ - public stop(): void { - logger.info("Stopping cleanup worker"); - this.job.stop(); - } - /** * Process a single state with appropriate cleanup handlers * @param state The state to process diff --git a/apps/api/src/api/workers/kyb-status.worker.ts b/apps/api/src/api/workers/kyb-status.worker.ts index faaa558262..7e9f902df3 100644 --- a/apps/api/src/api/workers/kyb-status.worker.ts +++ b/apps/api/src/api/workers/kyb-status.worker.ts @@ -49,11 +49,6 @@ class KybStatusWorker { this.job.start(); } - public stop(): void { - logger.info("Stopping KYB status worker"); - this.job.stop(); - } - private async poll(): Promise { try { const pending = await KycCase.findAll({ diff --git a/apps/api/src/api/workers/monerium-b2b.worker.ts b/apps/api/src/api/workers/monerium-b2b.worker.ts index 204ce6c614..0059ef0363 100644 --- a/apps/api/src/api/workers/monerium-b2b.worker.ts +++ b/apps/api/src/api/workers/monerium-b2b.worker.ts @@ -34,11 +34,6 @@ class MoneriumB2bWorker { this.job.start(); } - public stop(): void { - logger.info("Stopping Monerium B2B keeper worker"); - this.job.stop(); - } - private async cycle(): Promise { if (this.running) { return; // previous cycle (e.g. waiting on a receipt) still in progress diff --git a/apps/api/src/api/workers/notification-dispatch.worker.ts b/apps/api/src/api/workers/notification-dispatch.worker.ts index 34c231b64e..3768dacb1c 100644 --- a/apps/api/src/api/workers/notification-dispatch.worker.ts +++ b/apps/api/src/api/workers/notification-dispatch.worker.ts @@ -39,12 +39,6 @@ class NotificationDispatchWorker { this.reconcileJob.start(); } - public stop(): void { - logger.info("Stopping notification dispatch worker"); - this.dispatchJob.stop(); - this.reconcileJob.stop(); - } - // eslint-disable-next-line class-methods-use-this private async dispatch(): Promise { try { diff --git a/apps/api/src/api/workers/ramp-recovery.worker.ts b/apps/api/src/api/workers/ramp-recovery.worker.ts index f0b3f39928..8c38c46098 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.ts @@ -38,14 +38,6 @@ class RampRecoveryWorker { this.job.start(); } - /** - * Stop the worker - */ - public stop(): void { - logger.info("Stopping ramp recovery worker"); - this.job.stop(); - } - /** * Recover failed ramp states */ diff --git a/apps/api/src/api/workers/unhandled-payment.worker.ts b/apps/api/src/api/workers/unhandled-payment.worker.ts index 68dd527f67..9fd4ee097e 100644 --- a/apps/api/src/api/workers/unhandled-payment.worker.ts +++ b/apps/api/src/api/workers/unhandled-payment.worker.ts @@ -61,11 +61,6 @@ class UnhandledPaymentWorker { this.job.start(); } - public stop(): void { - logger.info("Stopping unhandled payment worker"); - this.job.stop(); - } - private async checkUnhandledPayments(): Promise { logger.info("Running unhandled payment worker cycle"); try { diff --git a/apps/api/src/api/workers/webhook-outbox.worker.ts b/apps/api/src/api/workers/webhook-outbox.worker.ts index 168029ec1d..d62ea61d34 100644 --- a/apps/api/src/api/workers/webhook-outbox.worker.ts +++ b/apps/api/src/api/workers/webhook-outbox.worker.ts @@ -26,12 +26,6 @@ class WebhookOutboxWorker { this.reconcileJob.start(); } - public stop(): void { - logger.info("Stopping webhook outbox worker"); - this.dispatchJob.stop(); - this.reconcileJob.stop(); - } - private async dispatchCycle(): Promise { if (this.running) return; this.running = true; diff --git a/apps/api/src/config/config-imports.test.ts b/apps/api/src/config/config-imports.test.ts deleted file mode 100644 index 31f535a676..0000000000 --- a/apps/api/src/config/config-imports.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {describe, expect, it} from "bun:test"; -import {readdirSync, readFileSync, statSync} from "node:fs"; -import path from "node:path"; - -const sourceRoot = path.resolve(import.meta.dir, ".."); -const configBarrelImportPattern = - /\bfrom\s+["'](?:\.\/config|(?:\.\.\/)+config)["']|\brequire\(["'](?:\.\/config|(?:\.\.\/)+config)["']\)/; - -function collectRuntimeTypeScriptFiles(directory: string): string[] { - return readdirSync(directory).flatMap(entry => { - const entryPath = path.join(directory, entry); - const stats = statSync(entryPath); - - if (stats.isDirectory()) { - if (entry === "config") { - return []; - } - return collectRuntimeTypeScriptFiles(entryPath); - } - - if (!entryPath.endsWith(".ts") || entryPath.endsWith(".test.ts")) { - return []; - } - - return [entryPath]; - }); -} - -describe("config imports", () => { - it("keeps runtime modules from importing the config barrel", () => { - const offenders = collectRuntimeTypeScriptFiles(sourceRoot) - .filter(filePath => configBarrelImportPattern.test(readFileSync(filePath, "utf8"))) - .map(filePath => path.relative(sourceRoot, filePath)); - - expect(offenders).toEqual([]); - }); -}); diff --git a/apps/api/src/config/evmTokens.test.ts b/apps/api/src/config/evmTokens.test.ts new file mode 100644 index 0000000000..571608683e --- /dev/null +++ b/apps/api/src/config/evmTokens.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, mock, test } from "bun:test"; +import { loadEvmTokens } from "./evmTokens"; + +const INTERVAL_MS = 5; + +const waitFor = async (condition: () => boolean) => { + const deadline = Date.now() + 2000; + while (!condition() && Date.now() < deadline) await Bun.sleep(1); +}; + +describe("loadEvmTokens", () => { + test("does not retry when the first load succeeds", async () => { + const load = mock(async () => true); + + await loadEvmTokens(load, INTERVAL_MS); + await Bun.sleep(INTERVAL_MS * 6); + + expect(load).toHaveBeenCalledTimes(1); + }); + + test("resolves after the first attempt, retries until the load succeeds, then stops", async () => { + const results = [false, false, true]; + const load = mock(async () => results.shift() ?? true); + + await loadEvmTokens(load, INTERVAL_MS); + expect(load).toHaveBeenCalledTimes(1); + + await waitFor(() => load.mock.calls.length >= 3); + await Bun.sleep(INTERVAL_MS * 6); + + expect(load).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/api/src/config/evmTokens.ts b/apps/api/src/config/evmTokens.ts new file mode 100644 index 0000000000..327046ce54 --- /dev/null +++ b/apps/api/src/config/evmTokens.ts @@ -0,0 +1,25 @@ +import { initializeEvmTokens } from "@vortexfi/shared"; +import logger from "./logger"; + +const RETRY_INTERVAL_MS = 60_000; + +/** + * Loads the Squid token list at boot. Tokens only Squid lists (PAXG, ...) are unavailable while just the + * static fallback is installed, so a failed first load is retried in the background until it succeeds. + * Boot waits for the first attempt only, which is bounded by the fetch timeout. + */ +export async function loadEvmTokens( + load: () => Promise = initializeEvmTokens, + retryIntervalMs = RETRY_INTERVAL_MS +): Promise { + if (await load()) return; + + logger.warn(`Squid token list unavailable, serving static tokens only; retrying every ${retryIntervalMs / 1000}s`); + const timer = setInterval(async () => { + if (await load()) { + clearInterval(timer); + logger.info("Squid token list loaded"); + } + }, retryIntervalMs); + timer.unref(); +} diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts deleted file mode 100644 index 3e42c68d7f..0000000000 --- a/apps/api/src/config/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Configuration index - */ - -export * from "./database"; -export * from "./express"; -export * from "./logger"; -// Re-export existing configuration -export * from "./vars"; diff --git a/apps/api/src/config/ramp-context.ts b/apps/api/src/config/ramp-context.ts index 6cea0c8d64..e583f4b1fe 100644 --- a/apps/api/src/config/ramp-context.ts +++ b/apps/api/src/config/ramp-context.ts @@ -36,13 +36,3 @@ export function runWithRampContext(rampId: string, fn: () => Promise): Pro export function getRampId(): string | undefined { return rampContextStorage.getStore()?.rampId; } - -/** - * Get the full ramp context from AsyncLocalStorage. - * Returns undefined if not running within a ramp context. - * - * @returns The current ramp context or undefined - */ -export function getRampContext(): RampProcessingContext | undefined { - return rampContextStorage.getStore(); -} diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index c7e531f135..e0938221e9 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -176,8 +176,6 @@ interface Config { demoProviderEnabled: boolean; flowVariant: FlowVariant; port: string | number; - amplitudeWss: string; - pendulumWss: string; rateLimitMaxRequests: string | number; rateLimitWindowMinutes: string | number; rateLimitNumberOfProxies: string | number; @@ -291,7 +289,6 @@ interface Config { sandboxEnabled: boolean; rampWidgetUrl: string; - backendTestStarterAccount: string | undefined; defaults: { vortexEvmPayoutAddress: string | undefined; }; @@ -304,8 +301,6 @@ interface Config { export const config: Config = { adminSecret: process.env.ADMIN_SECRET || "", - amplitudeWss: process.env.AMPLITUDE_WSS || "wss://rpc-amplitude.pendulumchain.tech", - backendTestStarterAccount: process.env.BACKEND_TEST_STARTER_ACCOUNT, database: { database: process.env.DB_NAME || "vortex", dialect: "postgres", @@ -385,7 +380,6 @@ export const config: Config = { mykobo: { feeFallback: readMykoboFeeFallback() }, - pendulumWss: process.env.PENDULUM_WSS || "wss://rpc-pendulum.prd.pendulumchain.tech", port: process.env.PORT || 3000, priceProviders: { alchemyPay: { diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index 91da296ceb..e53dfa899e 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -2,56 +2,28 @@ 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 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"; const PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS = "0.1"; // Amount to send to the new pendulum ephemeral account created -const MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS = "1"; // Amount to send to the new moonbeam ephemeral account created const MOONBEAM_EVM_SOURCE_STARTING_BALANCE_UNITS = "0.34"; // GLMR reserve for source-chain EVM transactions const POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS = "1.5"; // Amount to send to the new polygon ephemeral account created const BASE_EPHEMERAL_STARTING_BALANCE_UNITS = "0.00015"; // Amount to send to the new base ephemeral account created -const DEFAULT_POLLING_INTERVAL = 3000; const GLMR_FUNDING_AMOUNT_RAW = "50000000000000000"; -const ASSETHUB_XCM_FEE_USDC_UNITS = 0.013124; const MAX_FINAL_SETTLEMENT_SUBSIDY_USD = "10"; // 10 USD -const WEBHOOKS_CACHE_URL = "https://webhooks-cache.pendulumchain.tech"; // EXAMPLE URL - 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 -const THIRD_TX_TIME_WINDOW_IN_SECONDS = 7 * 24 * 60 * 60; // 7 days -const FOURTH_TX_TIME_WINDOW_IN_SECONDS = 30 * 24 * 60 * 60; // 30 days -const FIFTH_TX_TIME_WINDOW_IN_SECONDS = 90 * 24 * 60 * 60; // 90 days - -const SEQUENCE_TIME_WINDOWS = { - FIFTH_TX: FIFTH_TX_TIME_WINDOW_IN_SECONDS, - FIRST_TX: FIRST_TX_TIME_WINDOW_IN_SECONDS, - FOURTH_TX: FOURTH_TX_TIME_WINDOW_IN_SECONDS, - SECOND_TX: SECOND_TX_TIME_WINDOW_IN_SECONDS, - THIRD_TX: THIRD_TX_TIME_WINDOW_IN_SECONDS -}; - export { - ASSETHUB_XCM_FEE_USDC_UNITS, BASE_EPHEMERAL_STARTING_BALANCE_UNITS, DEFAULT_LOGIN_EXPIRATION_TIME_HOURS, - DEFAULT_POLLING_INTERVAL, GLMR_FUNDING_AMOUNT_RAW, MAX_FINAL_SETTLEMENT_SUBSIDY_USD, MOONBEAM_EVM_SOURCE_STARTING_BALANCE_UNITS, - MOONBEAM_EPHEMERAL_STARTING_BALANCE_UNITS, - MOONBEAM_FUNDING_AMOUNT_UNITS, - MOONBEAM_RECEIVER_CONTRACT_ADDRESS, PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS, PENDULUM_FUNDING_AMOUNT_UNITS, PENDULUM_GLMR_FUNDING_AMOUNT_UNITS, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, RAMP_START_EXPIRATION_TIME_SECONDS, - SEQUENCE_TIME_WINDOWS, - SUBSIDY_MINIMUM_RATIO_FUND_UNITS, - WEBHOOKS_CACHE_URL + SUBSIDY_MINIMUM_RATIO_FUND_UNITS }; diff --git a/apps/api/src/database/seeders/maintenance-schedules.ts b/apps/api/src/database/seeders/maintenance-schedules.ts deleted file mode 100644 index bff481fc8e..0000000000 --- a/apps/api/src/database/seeders/maintenance-schedules.ts +++ /dev/null @@ -1,78 +0,0 @@ -import sequelize from "../../config/database"; -import logger from "../../config/logger"; -import MaintenanceSchedule from "../../models/maintenanceSchedule.model"; - -/** - * Seed script for maintenance schedules - * This script adds sample maintenance schedules for testing purposes - */ -async function seedMaintenanceSchedules(): Promise { - try { - await sequelize.authenticate(); - logger.info("Database connection established for seeding maintenance schedules"); - - const now = new Date(); - - // Sample maintenance schedules - const schedules = [ - { - endDatetime: new Date(now.getTime() - 1 * 60 * 60 * 1000), - isActiveConfig: true, // 2 hours ago - messageToDisplay: "System was under maintenance for database upgrades.", // 1 hour ago - notes: "Completed successfully", - startDatetime: new Date(now.getTime() - 2 * 60 * 60 * 1000), - title: "Past Database Upgrade" - }, - { - endDatetime: new Date(now.getTime() + 3 * 60 * 60 * 1000), - isActiveConfig: false, // 1 hour from now - messageToDisplay: - "Pendulum Pay is undergoing scheduled maintenance. We expect to be back online soon. Thank you for your patience.", // 3 hours from now - notes: "Planned system updates and optimizations", - startDatetime: new Date(now.getTime() + 1 * 60 * 60 * 1000), // Set to true to activate - title: "Scheduled System Update" - }, - { - endDatetime: new Date(now.getTime() + 26 * 60 * 60 * 1000), - isActiveConfig: false, // 24 hours from now - messageToDisplay: "Emergency maintenance in progress. Service will be restored as soon as possible.", // 26 hours from now - notes: "Emergency maintenance window for critical fixes", - startDatetime: new Date(now.getTime() + 24 * 60 * 60 * 1000), - title: "Emergency Maintenance Window" - } - ]; - - // Clear existing schedules - await MaintenanceSchedule.destroy({ where: {} }); - logger.info("Cleared existing maintenance schedules"); - - // Insert new schedules - const createdSchedules = await MaintenanceSchedule.bulkCreate(schedules); - logger.info(`Created ${createdSchedules.length} maintenance schedules`); - - // Log the created schedules - createdSchedules.forEach(schedule => { - logger.info(`Created schedule: ${schedule.title} (${schedule.id})`); - }); - - logger.info("Maintenance schedules seeding completed successfully"); - } catch (error) { - logger.error("Error seeding maintenance schedules:", error); - throw error; - } -} - -// Run seeding if this file is executed directly -if (require.main === module) { - (async () => { - try { - await seedMaintenanceSchedules(); - process.exit(0); - } catch (error) { - console.error("Error seeding maintenance schedules:", error); - process.exit(1); - } - })(); -} - -export { seedMaintenanceSchedules }; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 15e69885d8..f04da55bd6 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,8 +1,9 @@ -import { EvmClientManager, initializeEvmTokens, setLogger } from "@vortexfi/shared"; +import { EvmClientManager, setLogger } from "@vortexfi/shared"; import dotenv from "dotenv"; import path from "path"; import cryptoService from "./config/crypto"; import { testDatabaseConnection } from "./config/database"; +import { loadEvmTokens } from "./config/evmTokens"; import app from "./config/express"; import logger from "./config/logger"; import { config } from "./config/vars"; @@ -63,8 +64,8 @@ const initializeApp = async () => { // Sandbox demo deployments only; a no-op everywhere else. installDemoProviders(); - // Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) - await initializeEvmTokens(); + // Initialize dynamic EVM tokens from SquidRouter API (static config on failure, retried in the background) + await loadEvmTokens(); // Test database connection await testDatabaseConnection(); diff --git a/apps/api/src/test-setup.ts b/apps/api/src/test-setup.ts deleted file mode 100644 index 099794c584..0000000000 --- a/apps/api/src/test-setup.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { mock } from "bun:test"; - -mock.module("../index", () => ({ - default: {}, - eventPoller: { - start: () => { - console.log("start"); - }, - stop: () => { - console.log("stop"); - } - }, - initializeApp: () => { - console.log("initializeApp"); - } -})); diff --git a/apps/api/src/test.json b/apps/api/src/test.json deleted file mode 100644 index 66717c23c9..0000000000 --- a/apps/api/src/test.json +++ /dev/null @@ -1,202 +0,0 @@ -[ - { - "meta": { - "additionalTxs": { - "nablaApprove1": { - "network": "pendulum", - "nonce": 1, - "phase": "nablaApprove", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0x71038400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e01fe968f312a1af841e4e7ecf4fc8a916175e759374be37fa070160c83a4ce661de496a8be0d9b21e3f057a8d31ed9c52fd9b9e91e5aa51854c2690390758edb810004000038060093dfde426795690be15b2071741d6538cd265eb673a9e9a1ae4e4389fda96a62000757318c4c02ce800200001101095ea7b3e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d8000069877b08fb3a000000000000000000000000000000000000000000000000" - }, - "nablaApprove2": { - "network": "pendulum", - "nonce": 2, - "phase": "nablaApprove", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0x71038400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e01dec8ef044d035fe5cc5a6d6ec95cce255d4756b95e990cec57fa325b00ec673037f416fb8359fff89621031968b0e3fb1a5e8e2a63088b56c469c8367337bb890008000038060093dfde426795690be15b2071741d6538cd265eb673a9e9a1ae4e4389fda96a62000757318c4c02ce800200001101095ea7b3e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d8000069877b08fb3a000000000000000000000000000000000000000000000000" - } - } - }, - "network": "pendulum", - "nonce": 0, - "phase": "nablaApprove", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0x71038400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e0130492b31ffb9ea743c5e506e7466be6629ca6da20c46997213f94be4d0c083575e5fcdde67fcbebe5b589b308187f7ada27588f6547fc389fde77fbfaa4b558e0000000038060093dfde426795690be15b2071741d6538cd265eb673a9e9a1ae4e4389fda96a62000757318c4c02ce800200001101095ea7b3e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d8000069877b08fb3a000000000000000000000000000000000000000000000000" - }, - { - "meta": { - "additionalTxs": { - "nablaSwap1": { - "network": "pendulum", - "nonce": 2, - "phase": "nablaSwap", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0x75058400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e01bec5e591a7c6357c02d31791879a9880f6a500aacae957f269c572dc66eca41dea1c6441c9a7bcc3de3ff8c30996366b86458f7f9da2a68bb862370ed2d5648800080000380600e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d80007003a9a535082584f0000150338ed1739000069877b08fb3a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000893dfde426795690be15b2071741d6538cd265eb673a9e9a1ae4e4389fda96a6290573e0b663336bc844ddd1293af95b0b1872f2677f93e11cc658fafddc58db9ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513ead9e076800000000000000000000000000000000000000000000000000000000" - }, - "nablaSwap2": { - "network": "pendulum", - "nonce": 3, - "phase": "nablaSwap", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0x75058400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e0134f0b11c34d4292b3786c7dd092f82b3844aa9eaff8fc47504fcc7294ef4bf063fc90c7747a63aff0c2f62c84d16c74c0367bacba55c9a67137a3d61bf605283000c0000380600e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d80007003a9a535082584f0000150338ed1739000069877b08fb3a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000893dfde426795690be15b2071741d6538cd265eb673a9e9a1ae4e4389fda96a6290573e0b663336bc844ddd1293af95b0b1872f2677f93e11cc658fafddc58db9ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513ead9e076800000000000000000000000000000000000000000000000000000000" - } - } - }, - "network": "pendulum", - "nonce": 1, - "phase": "nablaSwap", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0x75058400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e01e6d1f7cd8357a01b84a6e8c9711b94a0ffb7e51f903f18605bedad40534af1547ff556d82b03107989fd35a51e1f5ee703a5083c30856cbf66f62a3abe372f8900040000380600e0a5f34199e165cbd3f9b0eba1f5e15d5018a7ffe8b76a3693ba5b317efb09d80007003a9a535082584f0000150338ed1739000069877b08fb3a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000893dfde426795690be15b2071741d6538cd265eb673a9e9a1ae4e4389fda96a6290573e0b663336bc844ddd1293af95b0b1872f2677f93e11cc658fafddc58db9ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513ead9e076800000000000000000000000000000000000000000000000000000000" - }, - { - "meta": { - "additionalTxs": { - "pendulumCleanup1": { - "network": "pendulum", - "nonce": 4, - "phase": "pendulumCleanup", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0x69038400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e01f6c840b2af795b4eb4e5c4818f59f770ab5e202a33b667986d9ca1fc040a2e1868e010d2dd572eb7821e732d5f6a943ebce8cac719b4ac026c35adf45959ae800010000033020c35010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e647010d0035010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e647010c000a040056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e64700" - }, - "pendulumCleanup2": { - "network": "pendulum", - "nonce": 5, - "phase": "pendulumCleanup", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0x69038400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e015472581eea27114c3ce27e65139d642d5efc49c784d07b9806f28c41d0e7433fac7c896bab2aac97c5af82782cfb81a337470a9e4401d6444452f9d5df4c9c870014000033020c35010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e647010d0035010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e647010c000a040056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e64700" - } - } - }, - "network": "pendulum", - "nonce": 3, - "phase": "pendulumCleanup", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0x69038400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e01fc43e833d1eb40a7ad918993972b358e95d66bb484e91dc221cbc7abd9cd7c6227fc11c89ae231cde7954b6284cffbea8d5a354fb19bdd21149fb115c17ad387000c000033020c35010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e647010d0035010056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e647010c000a040056d9583bf0369fff4a35d997b2b5f5997843311823b1aa88fe9661874984e64700" - }, - { - "meta": { - "additionalTxs": { - "pendulumToMoonbeam1": { - "network": "pendulum", - "nonce": 3, - "phase": "pendulumToMoonbeam", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0xbd028400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e017c77d7a1c62252dd9b28840efd2f2d8babcb48f3991b8466d3c23bb8cfd7b174254eaff53cf89b083ae806c7d2147276c06a06d8c8b8f3e320722c546f1f9a85000c0000360408010cb50b0b0000000000000000000000000001060000c52ebca2b10000000000000000000100000001010200511f030044bf79d7146febe8f9a81f97f6687e03e843c2dd00" - }, - "pendulumToMoonbeam2": { - "network": "pendulum", - "nonce": 4, - "phase": "pendulumToMoonbeam", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0xbd028400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e01106b9e505834a9e86a2eb1866d98c72aa1bcd76a394431ea9db89d927c0ffa6416d9522c838485905f916f31927c44b1c500c124109ac5eb524bbd0ce6c7898200100000360408010cb50b0b0000000000000000000000000001060000c52ebca2b10000000000000000000100000001010200511f030044bf79d7146febe8f9a81f97f6687e03e843c2dd00" - } - } - }, - "network": "pendulum", - "nonce": 2, - "phase": "pendulumToMoonbeam", - "signer": "5GGtiSR1AR7todFch8YDFyL3vnMxnscmYa6gvPgdHmhaHRoF", - "txData": "0xbd028400ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e01101b3f94cec0bd4766407030674f3cfb1ff7585208436cfcddcb0bdfd0a9126d5775c4dcc77ddd5af71191d82cb3cba919677e070a765fc4d4549ad182ee4d8400080000360408010cb50b0b0000000000000000000000000001060000c52ebca2b10000000000000000000100000001010200511f030044bf79d7146febe8f9a81f97f6687e03e843c2dd00" - }, - { - "meta": { - "additionalTxs": { - "moonbeamToPendulumXcm1": { - "network": "moonbeam", - "nonce": 1, - "phase": "moonbeamToPendulumXcm", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0xcd028444bf79d7146febe8f9a81f97f6687e03e843c2dd4893d7dbfc2ad1deb51503ff873a0ccf6c344248f89fb06628c092f2c2989dd8347f4fce6fb52d20caf68600f330a9a6bf6053233924666caeb703df910b7f550100040000670b03010100b9200300010100ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e0304000002046e0300feb25f3fddad13f82c4d6dbc1481516f622364290013000069877b08fb3a0000000000" - }, - "moonbeamToPendulumXcm2": { - "network": "moonbeam", - "nonce": 2, - "phase": "moonbeamToPendulumXcm", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0xcd028444bf79d7146febe8f9a81f97f6687e03e843c2dd963366c237e20bdbda3d137f4bf567210580b61080b363de8cf24973262beee27e5507ab23c62352032a668014c1d820b76e870b939f2a3cf92914a80085b8610000080000670b03010100b9200300010100ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e0304000002046e0300feb25f3fddad13f82c4d6dbc1481516f622364290013000069877b08fb3a0000000000" - } - } - }, - "network": "moonbeam", - "nonce": 0, - "phase": "moonbeamToPendulumXcm", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0xcd028444bf79d7146febe8f9a81f97f6687e03e843c2dd26c4e7a250ff0632f5879f845440e2cea6d6899bad53f6567e7baf1896531d492fb238d1a08ff79ba2ff1f79a7f6c2a567676d4d8506d492924c51652a01e69c0000000000670b03010100b9200300010100ba3c6e28f2ee524b4c91b49c42c17b5d12ad0a3b6b2814caaf3311b26a8a513e0304000002046e0300feb25f3fddad13f82c4d6dbc1481516f622364290013000069877b08fb3a0000000000" - }, - { - "meta": { - "additionalTxs": { - "moonbeamCleanup1": { - "network": "moonbeam", - "nonce": 5, - "phase": "moonbeamCleanup", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0xc5018444bf79d7146febe8f9a81f97f6687e03e843c2ddd6f99b707e30efeed50ba422f36086dd42a003b2d80fccb4e100e3aedd8a47967eb9ff0abf971fc5a901595cb5702bb7ea2d20aa59384a6d81913b237f701ee901001400000a04ec733ccc573cbb46211876149e1830c58c6133e200" - }, - "moonbeamCleanup2": { - "network": "moonbeam", - "nonce": 6, - "phase": "moonbeamCleanup", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0xc5018444bf79d7146febe8f9a81f97f6687e03e843c2ddc013ac8d1c192812ff250b33c5c38ab4e43c01c3381d8fa64491a39675e9f0966055036bfdbd29f07d29785c62c17526911ac3c321d8e2491694b35f24742fb301001800000a04ec733ccc573cbb46211876149e1830c58c6133e200" - } - } - }, - "network": "moonbeam", - "nonce": 4, - "phase": "moonbeamCleanup", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0xc5018444bf79d7146febe8f9a81f97f6687e03e843c2dd7c7c3e6cf77569eedae3ab5cbf0513fe287d2ef31f0ff53686e5d70647071fdd2e564d7db1d948f7836c495e35cfa298d6876594955a4bf7f6f9ca59c1ce525c01001000000a04ec733ccc573cbb46211876149e1830c58c6133e200" - }, - { - "meta": { - "additionalTxs": { - "squidRouterApprove1": { - "network": "moonbeam", - "nonce": 3, - "phase": "squidRouterApprove", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0x02f8af8205040380852ba7def300830249f094ca01a1d0993565291051daff390892518acfad3a80b844095ea7b3000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d66600000000000000000000000000000000000000000000000000000000000b0bb5c001a0c9c496e7c42c04beebe9a11a8b06951b8348cf07d44f8358e1aec60d179d8d70a026fc37c6fecafb1cbc0b8681af559691d787fcfd8d2c8f58182c3f8ec8cc305f" - }, - "squidRouterApprove2": { - "network": "moonbeam", - "nonce": 4, - "phase": "squidRouterApprove", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0x02f8af8205040480852ba7def300830249f094ca01a1d0993565291051daff390892518acfad3a80b844095ea7b3000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d66600000000000000000000000000000000000000000000000000000000000b0bb5c001a0ef59e8c618a82519603aad2c0d4d1973416e062d61c7ac16b043858133345feba009adde44d04824e5620464ee2b2e49bb3448c52b47efeaad4924507b4d99f057" - } - } - }, - "network": "moonbeam", - "nonce": 2, - "phase": "squidRouterApprove", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0x02f8af8205040280852ba7def300830249f094ca01a1d0993565291051daff390892518acfad3a80b844095ea7b3000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d66600000000000000000000000000000000000000000000000000000000000b0bb5c001a0a851a1e0de3444226b1aca0f6f5e5ee5592071c331440aef6bd578cb15aed2e5a068cc2d42b64defb5b96c031bddfeb92ef5de4d947fe8de70851a087f342ae664" - }, - { - "meta": { - "additionalTxs": { - "squidRouterSwap1": { - "network": "moonbeam", - "nonce": 4, - "phase": "squidRouterSwap", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0x02f907e88205040480852ba7def3008311652094ce16f69375520ab01377ce7b88f5ba8c48f8d66688119c7e032f1033cfb907742147796000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000b0bb50000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000044bf79d7146febe8f9a81f97f6687e03e843c2dd0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007506f6c79676f6e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a30786365313646363933373535323061623031333737636537423838663542413843343846384436363600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000057000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007ba99e99bc669b3508aff9cc0a898e869459f87700000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000002e000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000f5b509bb0909a69b1c207e495f687a596c168e12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000f5b509bb0909a69b1c207e495f687a596c168e12000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e4bc651188000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000007ba99e99bc669b3508aff9cc0a898e869459f8770000000000000000000000000000000000000000000000000000019639c6912100000000000000000000000000000000000000000000000000000000000b0bb500000000000000000000000000000000000000000000000000000000000b065e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed000000000000000000000000000000000000000000000000000000000000000492d7b828eea103e5206246b8a9e258ce0000000000000000000000000000000092d7b828eea103e5206246b8a9e258cec001a0ac0b2f919f2fc14dee8d379fd7b4977455a94bf1caa5e38e482f9560e553caf1a022ef5ffb04448e1ed169e070fcff47ad4003a86dfe9670245c48eab62b560929" - }, - "squidRouterSwap2": { - "network": "moonbeam", - "nonce": 5, - "phase": "squidRouterSwap", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0x02f907e88205040580852ba7def3008311652094ce16f69375520ab01377ce7b88f5ba8c48f8d66688119c7e032f1033cfb907742147796000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000b0bb50000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000044bf79d7146febe8f9a81f97f6687e03e843c2dd0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007506f6c79676f6e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a30786365313646363933373535323061623031333737636537423838663542413843343846384436363600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000057000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007ba99e99bc669b3508aff9cc0a898e869459f87700000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000002e000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000f5b509bb0909a69b1c207e495f687a596c168e12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000f5b509bb0909a69b1c207e495f687a596c168e12000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e4bc651188000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000007ba99e99bc669b3508aff9cc0a898e869459f8770000000000000000000000000000000000000000000000000000019639c6912100000000000000000000000000000000000000000000000000000000000b0bb500000000000000000000000000000000000000000000000000000000000b065e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed000000000000000000000000000000000000000000000000000000000000000492d7b828eea103e5206246b8a9e258ce0000000000000000000000000000000092d7b828eea103e5206246b8a9e258cec080a02dce20a20c17623b906103564f62b110c4098403bf0bce77481ec3247230e50ca05ccecfa42032958c9772a0b313edb8a21e3adf8cf1eb70178a5baaf9ef6c1254" - } - } - }, - "network": "moonbeam", - "nonce": 3, - "phase": "squidRouterSwap", - "signer": "0x44BF79D7146FebE8F9A81f97F6687e03e843c2dD", - "txData": "0x02f907e88205040380852ba7def3008311652094ce16f69375520ab01377ce7b88f5ba8c48f8d66688119c7e032f1033cfb907742147796000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000b0bb50000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000044bf79d7146febe8f9a81f97f6687e03e843c2dd0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007506f6c79676f6e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a30786365313646363933373535323061623031333737636537423838663542413843343846384436363600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000057000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007ba99e99bc669b3508aff9cc0a898e869459f87700000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000002e000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000f5b509bb0909a69b1c207e495f687a596c168e12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000f5b509bb0909a69b1c207e495f687a596c168e12000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e4bc651188000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed0000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000007ba99e99bc669b3508aff9cc0a898e869459f8770000000000000000000000000000000000000000000000000000019639c6912100000000000000000000000000000000000000000000000000000000000b0bb500000000000000000000000000000000000000000000000000000000000b065e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000750e4c4984a9e0f12978ea6742bc1c5d248f40ed000000000000000000000000000000000000000000000000000000000000000492d7b828eea103e5206246b8a9e258ce0000000000000000000000000000000092d7b828eea103e5206246b8a9e258cec001a0efb3bd3f95bc53b7a5e13c94bc5721032d6b9df266d5c6ddc7e8937dfb4acba9a05391488f343dc36806e9f7a126725d5e9623642c82ddea9df4d37a21bb565530" - } -] diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 57f8691130..61265d1106 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -4,9 +4,6 @@ "module": "esnext", "moduleResolution": "bundler", "outDir": "dist", - "paths": { - "@packages/*": ["../../packages/*/src"] - }, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, diff --git a/apps/api/webhooks-cache/index.ts b/apps/api/webhooks-cache/index.ts deleted file mode 100644 index 5ea1fdcf6a..0000000000 --- a/apps/api/webhooks-cache/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -import bodyParser from "body-parser"; -import express, { type NextFunction, type Request, type Response } from "express"; -import helmet from "helmet"; -import httpStatus from "http-status"; -import logger from "../src/config/logger"; - -interface Event { - [key: string]: unknown; -} - -class EventStore { - private events: Event[] = []; - private readonly maxEvents: number; - - constructor(maxEvents: number) { - this.maxEvents = maxEvents; - } - - addEvent(event: Event): void { - this.events.push(event); - if (this.events.length > this.maxEvents) { - this.events.shift(); - } - } - - getEvents(): Event[] { - return this.events; - } - - clearEvents(): void { - this.events = []; - } -} - -const app = express(); -app.use(helmet()); -const PORT = process.env.PORT || 3000; -const PASSWORD = process.env.PASSWORD || "bananas"; -const MAX_EVENTS = 1000; - -const eventStore = new EventStore(MAX_EVENTS); - -app.use(bodyParser.json()); - -function authMiddleware(req: Request, res: Response, next: NextFunction): void { - const providedPassword = req.headers["Auth-password"]; - - if (providedPassword && providedPassword === PASSWORD) { - next(); - return; - } - - res.status(httpStatus.UNAUTHORIZED).json({ error: "Unauthorized" }); -} - -const checkDomain = (_: Request, res: Response, next: NextFunction): void => { - // TODO how to get the domain from the request? - if (true) { - next(); - return; - } - - res.status(httpStatus.FORBIDDEN).json({ error: "Access denied. Domain not allowed to post events" }); -}; - -app.post("*", checkDomain, (req: Request, res: Response) => { - eventStore.addEvent(req.body); - res.status(httpStatus.OK).send("Event recorded"); -}); - -app.get("/events", authMiddleware, (_req: Request, res: Response) => { - res.json(eventStore.getEvents()); -}); - -app.patch("/delete", authMiddleware, (_req: Request, _res: Response) => { - eventStore.clearEvents(); -}); - -app.listen(PORT, () => { - logger.info(`Server is running on port ${PORT}`); -}); diff --git a/apps/dashboard/e2e/maintenance.spec.ts b/apps/dashboard/e2e/maintenance.spec.ts new file mode 100644 index 0000000000..0494cde177 --- /dev/null +++ b/apps/dashboard/e2e/maintenance.spec.ts @@ -0,0 +1,124 @@ +import { expect, type Page, test } from "@playwright/test"; +import { MAINTENANCE_DETAILS, mockBackend } from "./support/mockBackend"; +import { injectMockWallet } from "./support/mockWallet"; +import { seedSession } from "./support/session"; + +const DESTINATION = "0x1111111111111111111111111111111111111111"; + +test("An active maintenance window shows the banner and blocks starting an onramp", async ({ page }) => { + const backend = await mockBackend(page, { fiatAccounts: [], maintenanceActive: true, onrampCurrency: "MXN" }); + await seedSession(page); + await page.goto("/transfer?mode=onramp"); + + await expect(page.getByText(MAINTENANCE_DETAILS.title)).toBeVisible({ timeout: 20_000 }); + await page.getByLabel("Destination wallet address").fill(DESTINATION); + await page.getByLabel("You pay (MXN)").fill("100"); + await expect(page.getByText("You receive", { exact: true })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("button", { name: "Continue to payment" })).toBeDisabled(); + + expect(backend.registerRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("Quote errors during an active maintenance window say quotes are paused", async ({ page }) => { + const backend = await mockBackend(page, { maintenanceActive: true }); + // The API's maintenance guard rejects quote creation for the whole window. + await page.route("http://localhost:3000/v1/quotes", route => + route.fulfill({ + json: { message: "Vortex services are temporarily unavailable during scheduled maintenance", statusCode: 503 }, + status: 503 + }) + ); + await seedSession(page); + const pausedMessage = page.getByText("Quotes are paused for scheduled maintenance. Try again once it ends."); + + await page.goto("/transfer?mode=onramp"); + await page.getByLabel("Destination wallet address").fill(DESTINATION); + await page.getByLabel("You pay (MXN)").fill("100"); + await expect(pausedMessage).toBeVisible({ timeout: 20_000 }); + + await page.goto("/transfer"); + await page.locator("#token-amount").fill("54.054054"); + await expect(pausedMessage).toBeVisible({ timeout: 20_000 }); + + await page.goto("/quote"); + await page.getByLabel("You pay").fill("100"); + await expect(pausedMessage).toBeVisible({ timeout: 20_000 }); + + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +async function openPaymentInstructions(page: Page) { + await page.goto("/transfer?mode=onramp"); + await page.getByLabel("Destination wallet address").fill(DESTINATION); + await page.getByLabel("You pay (MXN)").fill("100"); + const continueButton = page.getByRole("button", { name: "Continue to payment" }); + await expect(continueButton).toBeEnabled({ timeout: 20_000 }); + await continueButton.click(); + await expect(page.getByRole("button", { name: "I have made the payment" })).toBeEnabled({ timeout: 20_000 }); +} + +// React Query refetches on visibilitychange, as when the sender returns to the tab. Repeat it: the status request +// fired when the instructions mounted may still be in flight with the old answer. +async function refetchUntilBannerShows(page: Page) { + await expect(async () => { + await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange", { bubbles: true }))); + await expect(page.getByText(MAINTENANCE_DETAILS.title)).toBeVisible({ timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); +} + +test("A window that opens during payment setup and ends before the ramp expires pauses only the confirmation", async ({ + page +}) => { + const backend = await mockBackend(page, { fiatAccounts: [], onrampCurrency: "MXN" }); + await seedSession(page); + await openPaymentInstructions(page); + + backend.maintenance.active = true; + backend.maintenance.endsAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + await refetchUntilBannerShows(page); + + await expect(page.getByText("Confirming is paused until maintenance ends.", { exact: false })).toBeVisible(); + await expect(page.getByText("CLABE", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "I have made the payment" })).toBeDisabled(); + expect(backend.startRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("A window that outlasts the ramp's start deadline hides the payment details", async ({ page }) => { + // The mock's default window runs an hour; the registered ramp expires after 15 minutes. + const backend = await mockBackend(page, { fiatAccounts: [], onrampCurrency: "MXN" }); + await seedSession(page); + await openPaymentInstructions(page); + + backend.maintenance.active = true; + await refetchUntilBannerShows(page); + + await expect(page.getByRole("heading", { name: "Payment paused for maintenance" })).toBeVisible(); + await expect(page.getByText("CLABE", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "I have made the payment" })).toHaveCount(0); + expect(backend.startRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + +test("An active maintenance window blocks sending an offramp", async ({ page }) => { + const backend = await mockBackend(page, { maintenanceActive: true }); + await injectMockWallet(page, { chainIdHex: "0x89" }); + await seedSession(page); + await page.goto("/transfer?network=polygon"); + + const amountInput = page.locator("#token-amount"); + await expect(amountInput).toBeVisible({ timeout: 20_000 }); + await amountInput.fill("54.054054"); + await expect(page.getByText("Available: 1,000 USDC on Polygon", { exact: true })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(MAINTENANCE_DETAILS.title)).toBeVisible(); + await expect(page.getByRole("button", { name: /^Send/ })).toBeDisabled(); + + expect(backend.registerRequests).toEqual([]); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts index 21280da498..eee0939167 100644 --- a/apps/dashboard/e2e/support/mockBackend.ts +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -256,6 +256,8 @@ interface MockBackendOptions { apiCredentials?: Array>; approvedCorridors?: Array<"AR" | "BR" | "CO" | "MX" | "US">; limits?: Array>; + // Serve an active window on GET /v1/maintenance/status (default: none). Specs can change `maintenance` later. + maintenanceActive?: boolean; onboardingState?: OnboardingState; companyMode?: boolean; selectionRequired?: boolean; @@ -304,6 +306,13 @@ interface MockBackendOptions { tokenBalances?: TokenBalances | null | ((requestIndex: number, network: BalanceNetwork) => TokenBalances | null); } +export const MAINTENANCE_DETAILS = { + estimated_time_remaining_seconds: 3600, + message: "Ramps are paused while we upgrade.", + start_datetime: "2026-10-05T08:00:00.000Z", + title: "Scheduled maintenance" +}; + // AlfredPayStatus values the machine branches on (packages/shared AlfredPayStatus). const ALFREDPAY_SUCCESS = "SUCCESS"; const ALFREDPAY_VERIFYING = "VERIFYING"; @@ -435,6 +444,11 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) startRequests: [] as Array> }; const auth = { refreshes: 0 }; + const maintenance = { + active: options.maintenanceActive ?? false, + // An hour from now outlasts a freshly registered ramp's 15-minute start deadline. + endsAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() + }; let selectedCompany = options.companyMode ?? false; let hasActiveEntity = options.selectionRequired !== true; const fiatAccounts = [...(options.fiatAccounts ?? buildFiatAccounts())]; @@ -595,6 +609,14 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) return; } + if (path === "/v1/maintenance/status" && method === "GET") { + await fulfillJson({ + is_maintenance_active: maintenance.active, + maintenance_details: maintenance.active ? { ...MAINTENANCE_DETAILS, end_datetime: maintenance.endsAt } : null + }); + return; + } + if (path === "/v1/limits" && method === "POST") { const body = request.postDataJSON() as { corridors?: Array<"AR" | "BR" | "CO" | "MX" | "US"> }; limitsRequests.push(body); @@ -1173,6 +1195,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) kyc, kycFormSubmissions, limitsRequests, + maintenance, monerium, quoteRequests, registerRequests, diff --git a/apps/dashboard/src/components/layout/MaintenanceBanner.tsx b/apps/dashboard/src/components/layout/MaintenanceBanner.tsx new file mode 100644 index 0000000000..5ca95bc224 --- /dev/null +++ b/apps/dashboard/src/components/layout/MaintenanceBanner.tsx @@ -0,0 +1,28 @@ +import { TriangleAlert } from "lucide-react"; +import { useActiveMaintenance } from "@/hooks/useActiveMaintenance"; + +export function MaintenanceBanner() { + const maintenance = useActiveMaintenance(); + + if (!maintenance) return null; + + const endsAt = new Date(maintenance.end_datetime).toLocaleString(undefined, { + day: "numeric", + hour: "2-digit", + minute: "2-digit", + month: "short", + timeZoneName: "short" + }); + + return ( +
+ +
+

+ {maintenance.title} · {maintenance.message} +

+

New transfers and payment confirmations are paused until {endsAt}.

+
+
+ ); +} diff --git a/apps/dashboard/src/components/layout/NotificationsBell.tsx b/apps/dashboard/src/components/layout/NotificationsBell.tsx deleted file mode 100644 index 69ffd13439..0000000000 --- a/apps/dashboard/src/components/layout/NotificationsBell.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Bell, MailCheck } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Separator } from "@/components/ui/separator"; -import { unreadCount, useNotificationsStore } from "@/stores/notifications.store"; - -export function NotificationsBell() { - const items = useNotificationsStore(state => state.items); - const markAllRead = useNotificationsStore(state => state.markAllRead); - const unread = unreadCount(items); - - return ( - open && unread > 0 && markAllRead()}> - - - - -
-

Email updates

- {items.length > 0 && {items.length}} -
- - {items.length === 0 ? ( -
- -

No updates yet. Completion emails will show up here.

-
- ) : ( -
    - {items.map(item => ( -
  • -
    - -
    -

    {item.title}

    -

    {item.body}

    -

    - {new Date(item.createdAt).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} -

    -
    -
    -
  • - ))} -
- )} -
-
- ); -} diff --git a/apps/dashboard/src/components/layout/Topbar.tsx b/apps/dashboard/src/components/layout/Topbar.tsx index 34218bbf93..a623a42472 100644 --- a/apps/dashboard/src/components/layout/Topbar.tsx +++ b/apps/dashboard/src/components/layout/Topbar.tsx @@ -1,13 +1,10 @@ import { Separator } from "@/components/ui/separator"; import { SidebarTrigger } from "@/components/ui/sidebar"; -import { useManagedProfileSelection } from "@/stores/managed-profile.store"; import { AccountSwitcher } from "./AccountSwitcher"; import { ConnectWalletButton } from "./ConnectWalletButton"; -import { NotificationsBell } from "./NotificationsBell"; import { UserMenu } from "./UserMenu"; export function Topbar() { - const managedProfile = useManagedProfileSelection(); return (
@@ -15,7 +12,6 @@ export function Topbar() {
- {!managedProfile && }
diff --git a/apps/dashboard/src/components/onboarding/CorridorCard.tsx b/apps/dashboard/src/components/onboarding/CorridorCard.tsx index eff95f6629..956b41d6f4 100644 --- a/apps/dashboard/src/components/onboarding/CorridorCard.tsx +++ b/apps/dashboard/src/components/onboarding/CorridorCard.tsx @@ -1,14 +1,8 @@ -import { ArrowRight, ExternalLink, FileText, RotateCcw } from "lucide-react"; +import { ArrowRight, ExternalLink, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { - isCorridorOnboardingDisabled, - isOnboardingAvailable, - onboardingKindFor, - PROVIDER_LABEL, - routeFor -} from "@/domain/corridors"; +import { isOnboardingAvailable, onboardingKindFor, PROVIDER_LABEL, routeFor } from "@/domain/corridors"; import type { AlfredpayCorridorId } from "@/domain/fiatAccounts"; import { STATUS_META } from "@/domain/status"; import type { Corridor, OnboardingRoute, OnboardingStatus, SenderAccount } from "@/domain/types"; @@ -24,8 +18,7 @@ interface CorridorCardProps { verificationReadOnly?: boolean; } -const ROUTE_HINT: Record = { - google_form: { icon: FileText, label: "Completed via external Google Form" }, +const ROUTE_HINT: Record = { headless: null, redirect: { icon: ExternalLink, label: "Completed via partner redirect" } }; @@ -44,8 +37,8 @@ export function CorridorCard({ account, corridor, onStart, verificationReadOnly const kind = onboardingKindFor(corridor, account.type); const available = isOnboardingAvailable(corridor, kind); const onboarding = account.onboardings[corridor.id]; - // Suppress every actionable state (start, continue, retry, re-authenticate) while the - // corridor is disabled; purely informational buttons (awaiting review, complete) stay. + // Actionable states (start, continue, retry, re-authenticate) are the ones a read-only session + // blocks; purely informational buttons (awaiting review, complete) stay. // An approved Monerium profile still needs the pay-in wallet linked and the IBAN pointed at it. const walletLinkRequired = corridor.provider === "monerium" && moneriumWalletLinkRequired(onboarding); const actionable = @@ -56,7 +49,6 @@ export function CorridorCard({ account, corridor, onStart, verificationReadOnly onboarding.status === "rejected" || (onboarding.status === "in_review" && onboarding.reauthenticationRequired === true) || (onboarding.status === "approved" && (walletLinkRequired || onboarding.reauthenticationRequired === true)); - const disabled = isCorridorOnboardingDisabled(corridor) && actionable; const meta = onboarding ? STATUS_META[onboarding.status] : null; const hint = ROUTE_HINT[routeFor(corridor.id, kind)]; const managesPayoutAccounts = corridor.provider === "alfredpay" && onboarding?.status === "approved"; @@ -127,10 +119,6 @@ export function CorridorCard({ account, corridor, onStart, verificationReadOnly - ) : disabled ? ( - ) : !available && (!onboarding || onboarding.status === "not_started" || onboarding.status === "rejected") ? ( - {rejected &&

{rejected}

} - - ); -} - export function DocumentUploadScreen({ includeSelfie, error, onSubmit, onBack }: DocumentUploadScreenProps) { const [front, setFront] = useState(null); const [back, setBack] = useState(null); diff --git a/apps/dashboard/src/components/onboarding/alfredpay/FileDropZone.tsx b/apps/dashboard/src/components/onboarding/alfredpay/FileDropZone.tsx new file mode 100644 index 0000000000..192d95d3a8 --- /dev/null +++ b/apps/dashboard/src/components/onboarding/alfredpay/FileDropZone.tsx @@ -0,0 +1,71 @@ +import { KYC_FILE_ACCEPTED_TYPES, KYC_FILE_MAX_BYTES } from "@vortexfi/kyc"; +import { UploadCloud } from "lucide-react"; +import { useRef, useState } from "react"; +import { cn } from "@/lib/cn"; + +interface FileDropZoneProps { + /** Tighter box without the icon bubble, for the longer KYB document list. */ + compact?: boolean; + file: File | null; + label: string; + onChange: (file: File) => void; +} + +export function FileDropZone({ compact = false, label, file, onChange }: FileDropZoneProps) { + const inputRef = useRef(null); + const [rejected, setRejected] = useState(null); + + const handleFile = (candidate: File) => { + if (!KYC_FILE_ACCEPTED_TYPES.includes(candidate.type)) { + setRejected("Use a JPG, PNG or PDF file."); + return; + } + if (candidate.size > KYC_FILE_MAX_BYTES) { + setRejected("That file is over 5 MB."); + return; + } + setRejected(null); + onChange(candidate); + }; + + return ( +
+

{label}

+ + {rejected &&

{rejected}

} +
+ ); +} diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx index 3d1c8bbf43..0c897289e2 100644 --- a/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx +++ b/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx @@ -1,61 +1,8 @@ -import { KYC_FILE_ACCEPTED_TYPES, KYC_FILE_MAX_BYTES, type KybBusinessFiles } from "@vortexfi/kyc"; -import { UploadCloud } from "lucide-react"; -import { useRef, useState } from "react"; +import type { KybBusinessFiles } from "@vortexfi/kyc"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { DialogFooter } from "@/components/ui/dialog"; -import { cn } from "@/lib/cn"; - -function FileDropZone({ label, file, onChange }: { label: string; file: File | null; onChange: (file: File) => void }) { - const inputRef = useRef(null); - const [rejected, setRejected] = useState(null); - - const handleFile = (candidate: File) => { - if (!KYC_FILE_ACCEPTED_TYPES.includes(candidate.type)) { - setRejected("Use a JPG, PNG or PDF file."); - return; - } - if (candidate.size > KYC_FILE_MAX_BYTES) { - setRejected("That file is over 5 MB."); - return; - } - setRejected(null); - onChange(candidate); - }; - - return ( -
-

{label}

- - {rejected &&

{rejected}

} -
- ); -} +import { FileDropZone } from "./FileDropZone"; interface KybDocumentUploadScreenProps { error?: string; @@ -110,18 +57,23 @@ export function KybDocumentUploadScreen({ error, isRegulatedBusiness, onBack, on each.

- - - - + + + + {isRegulatedBusiness && ( <> - - + + )} - - + + {error &&

{error}

} diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx index 51f52e8987..07fe80ca96 100644 --- a/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx +++ b/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx @@ -4,8 +4,8 @@ import { useForm } from "react-hook-form"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { DialogFooter } from "@/components/ui/dialog"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; +import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"; +import { TextField } from "../TextField"; interface KybFormScreenProps { country: "MX" | "CO"; @@ -39,29 +39,6 @@ export function KybFormScreen({ country, defaults, onCancel, onSubmit, userEmail resolver: standardSchemaResolver(kybFormSchema) }); - const field = (name: keyof KybFormValues, label: string, type = "text", readOnly = false) => ( - ( - - {label} - - - - - - )} - /> - ); - return (
onSubmit(mapKybFormValues(values)))}> @@ -70,32 +47,32 @@ export function KybFormScreen({ country, defaults, onCancel, onSubmit, userEmail

Company details

Enter the legal details registered for this business.

- {field("businessName", "Legal business name")} +
- {field("taxId", "Tax ID")} - {field("website", "Website", "url")} + +
- {field("address", "Registered address")} +
- {field("city", "City")} - {field("state", "State")} + +
- {field("zipCode", "Postal code")} +

Authorized representative

This person's identity document is required on the next step.

- {field("repFirstName", "First name")} - {field("repLastName", "Last name")} + +
- {field("repEmail", "Email", "email", !!userEmail)} +
- {field("repDateOfBirth", "Date of birth", "date")} - {field("repDni", "Document number")} + +
- {field("repNationality", "Nationality (2-letter code)")} + ( - ( - - {label} - {description && {description}} - - - - - - )} - /> - ); - const numberField = ( name: "expectedMonthlyVolumeUsd" | "expectedMonthlyTransactions", label: string, @@ -120,25 +104,32 @@ export function KybQuestionnaireScreen({ defaults, onBack, onSubmit }: KybQuesti

Compliance questionnaire

Alfredpay requires these answers before it can review the business.

- {textField( - "sourceOfFunds", - "Source of funds", - "The primary source of the company's revenue or the funds used with Alfredpay.", - "Sale of goods/services, investments, venture capital" - )} - {textField( - "businessActivities", - "Business activities", - undefined, - "Money services, lending, FX, virtual currencies brokerage" - )} - {textField("accountPurpose", "Primary account purpose", undefined, "Treasury management, cross-border transfers")} - {textField( - "walletAddresses", - "Wallet addresses", - "List the wallets that will interact with Alfredpay and their chain. Enter N/A if the business will not transact on-chain.", - "ETH - 0x1234abcd…; TRX - TAbcd1234…" - )} + + + +
{numberField("expectedMonthlyVolumeUsd", "Expected monthly volume (USD)", "50000")} {numberField("expectedMonthlyTransactions", "Expected monthly transactions", "120")} @@ -151,14 +142,14 @@ export function KybQuestionnaireScreen({ defaults, onBack, onSubmit }: KybQuesti {checkboxField("transmitsCustomerFunds", "We transmit funds on behalf of our customers")} {transmitsCustomerFunds && checkboxField("conductsComplianceScreening", "We conduct compliance screening (KYC, KYB and AML)")} - {transmitsCustomerFunds && - conductsComplianceScreening && - textField( - "complianceScreeningDescription", - "Describe your compliance screening", - undefined, - "KYC, KYB and AML checks on every counterparty" - )} + {transmitsCustomerFunds && conductsComplianceScreening && ( + + )} {checkboxField("operatesInSanctionedCountries", "We operate in Cuba, Iran, Myanmar, North Korea or Syria")} {checkboxField("isRegulatedBusiness", "We perform regulated activities", "Adds two documents to the next step.")}
diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx index 3c641457df..f5baf46872 100644 --- a/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx +++ b/apps/dashboard/src/components/onboarding/alfredpay/KycFormScreen.tsx @@ -19,6 +19,7 @@ import { DialogFooter } from "@/components/ui/dialog"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { TextField, type TextFieldProps } from "../TextField"; export type AlfredpayKycCountry = "MX" | "CO" | "AR"; @@ -29,40 +30,6 @@ interface KycFormScreenProps { userEmail?: string; } -interface TextFieldProps { - control: Control; - name: FieldPath; - label: string; - placeholder?: string; - readOnly?: boolean; - type?: string; -} - -function TextField({ control, name, label, placeholder, readOnly, type = "text" }: TextFieldProps) { - return ( - ( - - {label} - - - - - - )} - /> - ); -} - /** * Phone numbers are stored in the `+` form Alfredpay validates against, so the * normaliser runs on every keystroke rather than at submit — the schema checks the stored value. diff --git a/apps/dashboard/src/components/onboarding/avenia/AveniaKycFormScreen.tsx b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFormScreen.tsx index 7b88d545e5..d926ca5f71 100644 --- a/apps/dashboard/src/components/onboarding/avenia/AveniaKycFormScreen.tsx +++ b/apps/dashboard/src/components/onboarding/avenia/AveniaKycFormScreen.tsx @@ -1,11 +1,11 @@ import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import type { AveniaKycFormData } from "@vortexfi/kyc"; -import { type Control, type FieldPath, type FieldValues, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { DialogFooter } from "@/components/ui/dialog"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; +import { Form } from "@/components/ui/form"; +import { TextField } from "../TextField"; interface AveniaKycFormScreenProps { initialData?: AveniaKycFormData; @@ -84,31 +84,3 @@ export function AveniaKycFormScreen({ initialData, onCancel, onSubmit }: AveniaK ); } - -function TextField({ - control, - label, - name, - type = "text" -}: { - control: Control; - label: string; - name: FieldPath; - type?: string; -}) { - return ( - ( - - {label} - - - - - - )} - /> - ); -} diff --git a/apps/dashboard/src/components/quote/QuoteExplorer.tsx b/apps/dashboard/src/components/quote/QuoteExplorer.tsx index 4567847a02..635bc8091e 100644 --- a/apps/dashboard/src/components/quote/QuoteExplorer.tsx +++ b/apps/dashboard/src/components/quote/QuoteExplorer.tsx @@ -21,6 +21,7 @@ import { CORRIDOR_LIST, CORRIDORS } from "@/domain/corridors"; import { getNetworkOptions, getRampTokenOptions, ONRAMP_CORRIDORS } from "@/domain/onramp"; import { PAYMENT_METHOD_LABEL } from "@/domain/transfer"; import type { CorridorId } from "@/domain/types"; +import { MAINTENANCE_QUOTE_ERROR, useActiveMaintenance } from "@/hooks/useActiveMaintenance"; import { useApprovedCorridors } from "@/hooks/useApprovedCorridors"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { @@ -164,6 +165,7 @@ export function QuoteExplorer() { } : null; const { data: quote, error, isFetching } = useQuote(quoteParams); + const maintenance = useActiveMaintenance(); return (
@@ -205,7 +207,11 @@ export function QuoteExplorer() { transition={springSnappy} > -

We couldn’t price that right now. Try another amount, token, or currency.

+

+ {maintenance + ? MAINTENANCE_QUOTE_ERROR + : "We couldn’t price that right now. Try another amount, token, or currency."} +

) : quote ? ( // The result is two distinct chunks, so it cascades in rather than landing as one block. The diff --git a/apps/dashboard/src/components/recipients/RecipientDialog.tsx b/apps/dashboard/src/components/recipients/RecipientDialog.tsx index 464f47a10f..51ff79a952 100644 --- a/apps/dashboard/src/components/recipients/RecipientDialog.tsx +++ b/apps/dashboard/src/components/recipients/RecipientDialog.tsx @@ -1,5 +1,6 @@ import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CORRIDOR_CAPABILITIES } from "@vortexfi/shared"; import { Building2, Check, Copy, Link2, Plus, User } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -24,8 +25,7 @@ import { dashboardInviteUrl, inviteUrl } from "@/domain/recipient"; import type { AccountType, Corridor, CorridorId, SenderAccount } from "@/domain/types"; import { useOnboardingStatusQuery } from "@/hooks/useApprovedCorridors"; import { RECIPIENTS_QUERY_KEY } from "@/hooks/useRecipients"; -import { notifyInviteCopied, notifyInviteLinkReady } from "@/lib/notify"; -import { CORRIDOR_RAIL } from "@/services/api/mappers"; +import { notifyInviteCopied } from "@/lib/notify"; import { RecipientsService } from "@/services/api/recipients.service"; // Mirrors the backend's MAX_DISCOUNT_BPS: larger discounts cannot execute under the @@ -95,8 +95,8 @@ export function RecipientDialog({ // not by ISO country — CORRIDOR_COUNTRY's quote-flow proxy ("DE") would 400 here. country: values.corridorId, inviteeType: values.recipientType === "company" ? "business" : "individual", - payoutCurrency: CORRIDOR_RAIL[values.corridorId], - rail: CORRIDOR_RAIL[values.corridorId], + payoutCurrency: CORRIDOR_CAPABILITIES[values.corridorId].rail, + rail: CORRIDOR_CAPABILITIES[values.corridorId].rail, ...(isDiscountManager && (values.buyBps > 0 || values.sellBps > 0) ? { discounts: { buyBps: values.buyBps, sellBps: values.sellBps } } : {}) @@ -106,7 +106,6 @@ export function RecipientDialog({ }, onSuccess: (invite, values) => { const selected = CORRIDORS[values.corridorId]; - notifyInviteLinkReady(selected.name); // Show the new invite as a pending recipient the moment it's created. queryClient.invalidateQueries({ queryKey: RECIPIENTS_QUERY_KEY }); const url = invite.seededDiscounts?.length diff --git a/apps/dashboard/src/components/transfer/OnrampForm.tsx b/apps/dashboard/src/components/transfer/OnrampForm.tsx index 793782e696..00194b5e63 100644 --- a/apps/dashboard/src/components/transfer/OnrampForm.tsx +++ b/apps/dashboard/src/components/transfer/OnrampForm.tsx @@ -17,6 +17,7 @@ import { CORRIDORS } from "@/domain/corridors"; import { eurOnrampBlocker, getNetworkOptions, getRampTokenOptions, ONRAMP_CORRIDORS } from "@/domain/onramp"; import { shortenAddress } from "@/domain/transfer"; import type { CorridorId, SenderAccount } from "@/domain/types"; +import { MAINTENANCE_QUOTE_ERROR, useActiveMaintenance } from "@/hooks/useActiveMaintenance"; import { useApprovedCorridors } from "@/hooks/useApprovedCorridors"; import { formatCurrencyAmount } from "@/lib/amount"; import { transferActor } from "@/machines/transferActor"; @@ -110,6 +111,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi } : null; const { data: quote, error, isFetching } = useQuote(quoteParams); + const maintenance = useActiveMaintenance(); const eurRamp = account.onboardings.EU?.ramp ?? null; const eurBlocker = corridorId === "EU" ? eurOnrampBlocker(eurRamp, address) : null; const transferState = useSelector(transferActor, snapshot => snapshot); @@ -327,7 +329,11 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi {error ? (
-

We couldn’t fetch a pay-in quote right now. Try another amount or token.

+

+ {maintenance + ? MAINTENANCE_QUOTE_ERROR + : "We couldn’t fetch a pay-in quote right now. Try another amount or token."} +

) : Number(amount) <= 0 ? (

@@ -342,7 +348,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi

- - ) : ( - - )} - - {livenessCheckOpened && ( -
-

- - Having trouble?{" "} - {" "} - to try again in a new session. - -

-
- )} - - ); -}; diff --git a/apps/frontend/src/components/Checkbox/index.tsx b/apps/frontend/src/components/Checkbox/index.tsx deleted file mode 100644 index df46443bd8..0000000000 --- a/apps/frontend/src/components/Checkbox/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { InputHTMLAttributes } from "react"; - -type CheckboxProps = InputHTMLAttributes; - -export function Checkbox({ className, ...props }: CheckboxProps) { - return ( - - ); -} diff --git a/apps/frontend/src/components/ComparisonSlider/index.tsx b/apps/frontend/src/components/ComparisonSlider/index.tsx deleted file mode 100644 index dfd5935eca..0000000000 --- a/apps/frontend/src/components/ComparisonSlider/index.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; - -interface ComparisonSliderProps { - beforeImage: string; - afterImage: string; - beforeAlt?: string; - afterAlt?: string; - className?: string; -} - -export const ComparisonSlider: React.FC = ({ - beforeImage, - afterImage, - beforeAlt = "Before", - afterAlt = "After", - className = "" -}) => { - const [sliderPosition, setSliderPosition] = useState(50); - const [isDragging, setIsDragging] = useState(false); - const containerRef = useRef(null); - - const handleMove = useCallback( - (event: MouseEvent | TouchEvent) => { - if (!isDragging || !containerRef.current) return; - - const containerRect = containerRef.current.getBoundingClientRect(); - const clientX = "touches" in event ? event.touches[0].clientX : event.clientX; - - const position = ((clientX - containerRect.left) / containerRect.width) * 100; - setSliderPosition(Math.min(Math.max(position, 0), 100)); - }, - [isDragging] - ); - - const handleMouseUp = useCallback(() => { - setIsDragging(false); - }, []); - - useEffect(() => { - if (isDragging) { - window.addEventListener("mousemove", handleMove); - window.addEventListener("touchmove", handleMove); - window.addEventListener("mouseup", handleMouseUp); - window.addEventListener("touchend", handleMouseUp); - } else { - window.removeEventListener("mousemove", handleMove); - window.removeEventListener("touchmove", handleMove); - window.removeEventListener("mouseup", handleMouseUp); - window.removeEventListener("touchend", handleMouseUp); - } - - return () => { - window.removeEventListener("mousemove", handleMove); - window.removeEventListener("touchmove", handleMove); - window.removeEventListener("mouseup", handleMouseUp); - window.removeEventListener("touchend", handleMouseUp); - }; - }, [isDragging, handleMove, handleMouseUp]); - - const handleMouseDown = () => setIsDragging(true); - const handleTouchStart = () => setIsDragging(true); - const updateSliderPosition = (delta: number) => { - setSliderPosition(position => Math.min(Math.max(position + delta, 0), 100)); - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case "ArrowLeft": - case "ArrowDown": - event.preventDefault(); - updateSliderPosition(-5); - break; - case "ArrowRight": - case "ArrowUp": - event.preventDefault(); - updateSliderPosition(5); - break; - case "Home": - event.preventDefault(); - setSliderPosition(0); - break; - case "End": - event.preventDefault(); - setSliderPosition(100); - break; - } - }; - - return ( -
- {beforeAlt} - {afterAlt} -
-
- - - - -
-
-
- ); -}; diff --git a/apps/frontend/src/components/MaintenanceBanner/index.tsx b/apps/frontend/src/components/MaintenanceBanner/index.tsx index 7e3d62e166..382b980e37 100644 --- a/apps/frontend/src/components/MaintenanceBanner/index.tsx +++ b/apps/frontend/src/components/MaintenanceBanner/index.tsx @@ -40,7 +40,7 @@ export const MaintenanceBanner: FC = () => { }; return ( -
+
diff --git a/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx b/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx deleted file mode 100644 index 5e265c4e31..0000000000 --- a/apps/frontend/src/components/Navbar/SolutionsDropdown.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { FocusEvent, KeyboardEvent } from "react"; -import { useTranslation } from "react-i18next"; -import { SubmenuItem } from "./types"; - -interface SolutionsDropdownProps { - isOpen: boolean; - onMouseEnter: () => void; - onMouseLeave: () => void; - submenuItems: SubmenuItem[]; -} - -const navLinkStyles = "text-white text-xl"; -const submenuButtonStyles = "block w-full cursor-pointer px-4 py-2 text-left text-gray-800 transition-colors hover:bg-gray-100"; - -export const SolutionsDropdown = ({ isOpen, onMouseEnter, onMouseLeave, submenuItems }: SolutionsDropdownProps) => { - const { t } = useTranslation(); - - const handleTriggerClick = () => { - if (isOpen) { - onMouseLeave(); - } else { - onMouseEnter(); - } - }; - - const handleTriggerKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - if (isOpen) { - onMouseLeave(); - } else { - onMouseEnter(); - } - } - if (event.key === "Escape") { - onMouseLeave(); - } - }; - - const handleBlur = (event: FocusEvent) => { - const nextFocusedElement = event.relatedTarget; - if (!nextFocusedElement || !event.currentTarget.contains(nextFocusedElement)) { - onMouseLeave(); - } - }; - - return ( -
- - {isOpen && ( -
-
- {submenuItems.map(item => ( - - ))} -
-
- )} -
- ); -}; diff --git a/apps/frontend/src/components/Navbar/types.ts b/apps/frontend/src/components/Navbar/types.ts deleted file mode 100644 index 001e3e8b3d..0000000000 --- a/apps/frontend/src/components/Navbar/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface SubmenuItem { - label: string; - onClick: () => void; -} diff --git a/apps/frontend/src/components/NetworkSelector/index.tsx b/apps/frontend/src/components/NetworkSelector/index.tsx deleted file mode 100644 index 7461e8fa88..0000000000 --- a/apps/frontend/src/components/NetworkSelector/index.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { ChevronDownIcon } from "@heroicons/react/20/solid"; -import { getNetworkDisplayName, getNetworkId, Networks } from "@vortexfi/shared"; -import { AnimatePresence, motion } from "motion/react"; -import { useRef, useState } from "react"; -import { isFrontendNetworkEnabled } from "../../config/networkAvailability"; -import { useNetwork } from "../../contexts/network"; -import { cn } from "../../helpers/cn"; -import { useClickOutside } from "../../hooks/useClickOutside"; -import { useNetworkTokenCompatibility } from "../../hooks/useNetworkTokenCompatibility"; -import { NetworkIcon } from "../NetworkIcon"; - -interface NetworkButtonProps { - selectedNetwork: Networks; - isOpen: boolean; - onClick: () => void; - disabled?: boolean; -} - -const supportedNetworks = Object.values(Networks).filter( - network => isFrontendNetworkEnabled(network) && network !== Networks.Pendulum && network !== Networks.Moonbeam -); - -const NetworkButton = ({ selectedNetwork, isOpen, onClick, disabled }: NetworkButtonProps) => ( - - - {getNetworkDisplayName(selectedNetwork)} - - - - -); - -interface NetworkDropdownProps { - isOpen: boolean; - onNetworkSelect: (network: Networks) => void; - disabled?: boolean; -} - -const NetworkDropdown = ({ isOpen, onNetworkSelect, disabled }: NetworkDropdownProps) => ( - - {isOpen && !disabled && ( - - {supportedNetworks.map(network => { - const networkId = getNetworkId(network); - return ( - - ); - })} - - )} - -); - -export const NetworkSelector = ({ disabled }: { disabled?: boolean }) => { - const { selectedNetwork } = useNetwork(); - const { handleNetworkSelect } = useNetworkTokenCompatibility(); - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - useClickOutside(dropdownRef, () => setIsOpen(false)); - - const handleNetworkChange = (network: Networks) => { - handleNetworkSelect(network, true); - setIsOpen(false); - }; - - const wrapperProps = disabled - ? { - className: "tooltip tooltip-primary tooltip-bottom before:whitespace-pre-wrap before:content-[attr(data-tip)]", - "data-tip": "The offramp is in progress. Cannot switch networks." - } - : {}; - - return ( -
-
- setIsOpen(!isOpen)} - selectedNetwork={selectedNetwork} - /> - -
-
- ); -}; diff --git a/apps/frontend/src/components/PoweredBySatoshipay/index.tsx b/apps/frontend/src/components/PoweredBySatoshipay/index.tsx deleted file mode 100644 index ee9b91474f..0000000000 --- a/apps/frontend/src/components/PoweredBySatoshipay/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useTranslation } from "react-i18next"; -import satoshipayLogo from "../../assets/logo/satoshipay.svg"; - -export const PoweredBySatoshipay = () => { - const { t } = useTranslation(); - - return ( -

- - {t("pages.swap.developedBy")} Satoshipay - -

- ); -}; diff --git a/apps/frontend/src/components/Ramp/Offramp/Offramp.test.tsx b/apps/frontend/src/components/Ramp/Offramp/Offramp.test.tsx index 84016614ae..dc6386afd6 100644 --- a/apps/frontend/src/components/Ramp/Offramp/Offramp.test.tsx +++ b/apps/frontend/src/components/Ramp/Offramp/Offramp.test.tsx @@ -86,7 +86,7 @@ describe("Offramp quote form (SELL)", () => { lastConstraintDirection: RampDirection.SELL, onChainToken: EvmToken.USDC }); - useQuoteStore.setState({ error: null, exchangeRate: 0, loading: false, outputAmount: undefined, quote: undefined }); + useQuoteStore.setState({ error: null, loading: false, outputAmount: undefined, quote: undefined }); usePartnerStore.setState({ apiKey: null, partnerId: null }); }); diff --git a/apps/frontend/src/components/Ramp/Onramp/Onramp.test.tsx b/apps/frontend/src/components/Ramp/Onramp/Onramp.test.tsx index 261183b9fe..5836aae58b 100644 --- a/apps/frontend/src/components/Ramp/Onramp/Onramp.test.tsx +++ b/apps/frontend/src/components/Ramp/Onramp/Onramp.test.tsx @@ -110,7 +110,7 @@ describe("Onramp quote form (BUY)", () => { inputAmount: "100", lastConstraintDirection: RampDirection.BUY }); - useQuoteStore.setState({ error: null, exchangeRate: 0, loading: false, outputAmount: undefined, quote: undefined }); + useQuoteStore.setState({ error: null, loading: false, outputAmount: undefined, quote: undefined }); // null (as opposed to undefined) means "resolved from the URL: no partner" — quotes may be fetched. usePartnerStore.setState({ apiKey: null, partnerId: null }); }); diff --git a/apps/frontend/src/components/Rating/Rating.test.tsx b/apps/frontend/src/components/Rating/Rating.test.tsx new file mode 100644 index 0000000000..a890a8791e --- /dev/null +++ b/apps/frontend/src/components/Rating/Rating.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import i18n from "../../test/i18n"; +import { Rating } from "./index"; + +const mocks = vi.hoisted(() => ({ address: undefined as string | undefined })); + +vi.mock("../../hooks/useVortexAccount", () => ({ + useVortexAccount: () => ({ address: mocks.address }) +})); + +const RATING_TITLE = () => i18n.t("components.rating.title"); + +async function renderRating(address: string | undefined) { + mocks.address = address; + await act(async () => { + render( + + + + ); + }); +} + +describe("Rating", () => { + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = '
'; + }); + + it.each([ + ["a lowercase EVM address", "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"], + ["an EIP-55 checksummed EVM address", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"] + ])("prompts for a rating for %s", async (_label, address) => { + await renderRating(address); + expect(screen.queryByText(RATING_TITLE())).not.toBeNull(); + }); + + it.each([ + ["no connected account", undefined], + ["a mixed-case EVM address with a bad checksum", "0xd8da6BF26964aF9D7eEd9e03E53415D37aA96045"], + ["a Substrate address", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"], + ["a truncated EVM address", "0xd8da6bf26964af9d7eed9e03e53415d37aa9604"] + ])("does not prompt for %s", async (_label, address) => { + await renderRating(address); + expect(screen.queryByText(RATING_TITLE())).toBeNull(); + }); +}); diff --git a/apps/frontend/src/components/Rating/index.tsx b/apps/frontend/src/components/Rating/index.tsx index 7261cc3c4d..40d2a1389e 100644 --- a/apps/frontend/src/components/Rating/index.tsx +++ b/apps/frontend/src/components/Rating/index.tsx @@ -3,7 +3,7 @@ import { AnimatePresence, motion } from "motion/react"; import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { validator } from "web3"; +import { isAddress } from "viem"; import { useVortexAccount } from "../../hooks/useVortexAccount"; import { RatingService } from "../../services/api"; import { CloseButton } from "../buttons/CloseButton"; @@ -16,7 +16,7 @@ export function Rating() { const { address: walletAddress } = useVortexAccount(); const [rating, setRating] = useState(0); - const isValidAddress = !!walletAddress && validator.isAddress(walletAddress); + const isValidAddress = !!walletAddress && isAddress(walletAddress); const { mutate: saveUserRatingMutation, diff --git a/apps/frontend/src/components/buttons/SwapSubmitButton/index.tsx b/apps/frontend/src/components/buttons/SwapSubmitButton/index.tsx deleted file mode 100644 index 754820934d..0000000000 --- a/apps/frontend/src/components/buttons/SwapSubmitButton/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useAppKitAccount } from "@reown/appkit/react"; -import { isNetworkEVM } from "@vortexfi/shared"; -import { FC } from "react"; -import { useNetwork } from "../../../contexts/network"; -import { usePolkadotWalletState } from "../../../contexts/polkadotWallet"; -import { useMaintenanceAwareButton } from "../../../hooks/useMaintenanceAware"; -import { Spinner } from "../../Spinner"; -import { ConnectWalletButton } from "../ConnectWalletButton"; - -interface SwapSubmitButtonProps { - text: string; - disabled: boolean; - pending: boolean; -} - -export const SwapSubmitButton: FC = ({ text, disabled, pending }) => { - const { buttonProps, isMaintenanceDisabled } = useMaintenanceAwareButton(disabled || pending); - - const { walletAccount } = usePolkadotWalletState(); - const { isConnected } = useAppKitAccount(); - const { selectedNetwork } = useNetwork(); - - if (!isNetworkEVM(selectedNetwork) && !walletAccount) { - return ( -
- -
- ); - } - - if (isNetworkEVM(selectedNetwork) && !isConnected) { - return ( -
- -
- ); - } - - return ( -
- -
- ); -}; diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx index f79cec5b91..44f1206d5e 100644 --- a/apps/frontend/src/components/ui/select.tsx +++ b/apps/frontend/src/components/ui/select.tsx @@ -1,4 +1,4 @@ -import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/16/solid"; import { Select as SelectPrimitive } from "radix-ui"; import * as React from "react"; diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx index 322f549d06..e11d5150fe 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx @@ -1,9 +1,7 @@ -import { InformationCircleIcon } from "@heroicons/react/24/outline"; import { FiatToken, isFiatToken } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; import { useEffect } from "react"; import { FormProvider } from "react-hook-form"; -import { useTranslation } from "react-i18next"; import { useRampActor } from "../../../contexts/rampState"; import { cn } from "../../../helpers/cn"; import { useRampForm } from "../../../hooks/ramp/useRampForm"; @@ -36,12 +34,10 @@ export interface FormData { } export const DetailsStep = ({ className }: DetailsStepProps) => { - const { t } = useTranslation(); const { shouldDisplay: signingBoxVisible, progress, signatureState, confirmations } = useSigningBoxState(); const rampActor = useRampActor(); - const { walletLockedFromState, isQuoteRedo } = useSelector(rampActor, state => ({ - isQuoteRedo: state.context.isQuoteRedo, + const { walletLockedFromState } = useSelector(rampActor, state => ({ walletLockedFromState: state.context.walletLocked })); @@ -107,14 +103,6 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { isWalletAddressDisabled={!!walletLockedFromState} signingState={signingState} /> - {isQuoteRedo && ( -
-
- -

{t("pages.widget.details.quoteChangedWarning")}

-
-
- )}
string): return t("components.errorStep.errors.quoteExpired"); } else if (error.includes("Insufficient funds")) { return t("components.errorStep.errors.insufficientFunds"); + } else if (error.includes("Ramp start window closed")) { + return t("components.errorStep.errors.startWindowClosed"); } else { return error; } diff --git a/apps/frontend/src/config/index.ts b/apps/frontend/src/config/index.ts index 89e63e62a4..ad7c485504 100644 --- a/apps/frontend/src/config/index.ts +++ b/apps/frontend/src/config/index.ts @@ -7,17 +7,11 @@ const env = (import.meta.env.VITE_ENVIRONMENT || nodeEnv) as Environment; export const config = { alchemyApiKey, - applicationClientDomain: "satoshipay.io", env, - isDev: env === "development", isProd: env === "production", isSandbox: sandboxEnabled, maybeSignerServiceUrl, - nodeEnv, supportUrl: "https://forms.gle/bgH4XTTbQ3YbwQ3t7", - swap: { - deadlineMinutes: 60 * 24 * 7 // 1 week - }, test: { overwriteMinimumTransferAmount: false }, diff --git a/apps/frontend/src/constants/animations.ts b/apps/frontend/src/constants/animations.ts index 13110157bf..c8affa959c 100644 --- a/apps/frontend/src/constants/animations.ts +++ b/apps/frontend/src/constants/animations.ts @@ -22,35 +22,6 @@ export const durations = { slow: 0.3 // 300ms - modals, drawers, complex transitions }; -/** - * Transform-based expand/collapse animation (GPU-accelerated, no layout thrashing) - * Use with overflow-hidden and transform-origin: top - */ -export const expandVariants: Variants = { - collapsed: { - opacity: 0, - scaleY: 0 - }, - expanded: { - opacity: 1, - scaleY: 1 - } -}; - -/** - * Slide-based expand/collapse (alternative to height animation) - */ -export const slideExpandVariants: Variants = { - collapsed: { - opacity: 0, - y: -10 - }, - expanded: { - opacity: 1, - y: 0 - } -}; - /** * Animation variants for word-by-word title animations * with 3D transform effects @@ -99,25 +70,6 @@ export const staggerContainer: Variants = { } }; -/** - * Feature card animations with alternating directions - */ -export const featureCardVariants = { - hidden: (index: number) => ({ - rotateZ: index % 2 === 0 ? -15 : 15, - scale: 0.9, - x: index % 2 === 0 ? -40 : 40, - y: 20 - }), - visible: { - rotateZ: 0, - scale: 1, - transition: { duration: 0.4 }, - x: 0, - y: 0 - } -}; - /** * Utility to check if user prefers reduced motion */ @@ -125,10 +77,3 @@ export const prefersReducedMotion = (): boolean => { if (typeof window === "undefined") return false; return window.matchMedia("(prefers-reduced-motion: reduce)").matches; }; - -/** - * Get transition with reduced motion support - */ -export const getTransition = (transition: object) => { - return prefersReducedMotion() ? { duration: 0 } : transition; -}; diff --git a/apps/frontend/src/constants/cache.ts b/apps/frontend/src/constants/cache.ts index 5d4c990c00..bf2e11bcbe 100644 --- a/apps/frontend/src/constants/cache.ts +++ b/apps/frontend/src/constants/cache.ts @@ -1,21 +1,8 @@ import { UseQueryOptions } from "@tanstack/react-query"; export const cacheKeys = { - accountBalance: "accountBalance", allPrices: "allPrices", - balance: "balance", - fiatAccounts: "fiatAccounts", - nablaInstance: "nablaInstance", - quoteBackstopPoolDrain: "quoteBackstopPoolDrain", - quoteBackstopPoolWithdraw: "quoteBackstopPoolWithdraw", - quoteSwapPoolRedeem: "quoteSwapPoolRedeem", - quoteSwapPoolWithdraw: "quoteSwapPoolWithdraw", - sharesTargetWorth: "sharesTargetWorth", - tokenAllowance: "tokenAllowance", - tokenOutAmount: "tokenOutAmount", - tokenPrice: "tokenPrice", - tokens: "tokens", - walletBalance: "walletBalance" + fiatAccounts: "fiatAccounts" }; type QueryOptions = Partial< @@ -31,27 +18,9 @@ const getOptions = staleTime: time }); -export const getActiveOptions = getOptions(true); export const activeOptions = { - "0": getActiveOptions(0), - "1h": getActiveOptions(3600000), - "1m": getActiveOptions(60000), - "3m": getActiveOptions(180000), - "3s": getActiveOptions(3000), - "5m": getActiveOptions(300000), - "15m": getActiveOptions(900000), - "15s": getActiveOptions(15000), - "30s": getActiveOptions(30000) + "1m": getOptions(true)(60000) }; -export const getInactiveOptions = getOptions(false); export const inactiveOptions = { - "0": getInactiveOptions(0), - "1h": getInactiveOptions(3600000), - "1m": getInactiveOptions(60000), - "3m": getInactiveOptions(180000), - "3s": getInactiveOptions(3000), - "5m": getInactiveOptions(300000), - "15m": getInactiveOptions(900000), - "15s": getInactiveOptions(15000), - "30s": getInactiveOptions(30000) + "5m": getOptions(false)(300000) }; diff --git a/apps/frontend/src/constants/localStorage.ts b/apps/frontend/src/constants/localStorage.ts deleted file mode 100644 index dff8171b65..0000000000 --- a/apps/frontend/src/constants/localStorage.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const storageKeys = { - ACCOUNT: "ACCOUNT", - BRLA_KYC_PIX_KEY: "BRLA_KYC_PIX_KEY", - BRLA_KYC_TAX_ID: "BRLA_KYC_TAX_ID", - - LAST_TRANSACTION_SUBMISSION_INDEX: "LAST_TRANSACTION_SUBMISSION_INDEX", - OFFRAMP_EXECUTION_INPUTS: "OFFRAMP_EXECUTION_INPUTS", - OFFRAMP_STATUS: "OFFRAMP_STATUS", - PENDULUM_SEED: "PENDULUM_SEED", - POOL_SETTINGS: "POOL_SETTINGS", - RAMP_SETTINGS: "RAMP_SETTINGS", - SIWE_SIGNATURE_KEY_PREFIX: "SIWE_SIGNATURE_", - - // Internal squidrouter recovery states - SQUIDROUTER_RECOVERY_STATE_APPROVAL: "SQUIDROUTER_TRANSACTION_STATE_APPROVAL", - SQUIDROUTER_RECOVERY_STATE_SWAP: "SQUIDROUTER_TRANSACTION_STATE_SWAP", - TOKEN_BRIDGED_AMOUNT: "TOKEN_BRIDGED_AMOUNT" -}; - -/// This enum is used to keep track of the last transaction submission index. This is used to determine if a transaction has been submitted before. -/// The indices are used to determine which transaction was last submitted. -export enum TransactionSubmissionIndices { - SQUIDROUTER_APPROVE = 0, - SQUIDROUTER_SWAP = 1, - - ASSETHUB_XCM = 0, - MOONBEAM_XCM = 1 -} diff --git a/apps/frontend/src/contexts/events.tsx b/apps/frontend/src/contexts/events.tsx index 1e73f27b5b..3fbd96c25d 100644 --- a/apps/frontend/src/contexts/events.tsx +++ b/apps/frontend/src/contexts/events.tsx @@ -4,7 +4,6 @@ import { LocalStorageKeys } from "../hooks/useLocalStorage"; import { useVortexAccount } from "../hooks/useVortexAccount"; import { storageService } from "../services/storage/local"; import { useInputAmount } from "../stores/quote/useQuoteFormStore"; -import { RampState } from "../types/phases"; import { useNetwork } from "./network"; declare global { @@ -13,28 +12,13 @@ declare global { } } -const UNIQUE_EVENT_TYPES: TrackableEvent["event"][] = [ - "click_details", - "click_support", - "transaction_confirmation", - "kyc_started", - "kyc_completed", - "signing_requested", - "transaction_signed", - "transaction_success", - "transaction_failure", - "email_submission" -]; +const UNIQUE_EVENT_TYPES: TrackableEvent["event"][] = ["transaction_failure", "email_submission"]; export interface AmountTypeEvent { event: "amount_type"; input_amount: string; } -export interface ClickDetailsEvent { - event: "click_details"; -} - export interface WalletConnectEvent { event: "wallet_connect"; wallet_action: "connect" | "disconnect" | "change"; @@ -51,7 +35,7 @@ export interface RampParameters { } export type TransactionEvent = RampParameters & { - event: "transaction_confirmation" | "kyc_started" | "kyc_completed" | "transaction_success" | "transaction_failure"; + event: "transaction_failure"; }; export type TransactionFailedEvent = RampParameters & { @@ -74,26 +58,11 @@ export interface ProgressEvent { phase_index: number; } -export interface SigningRequestedEvent { - event: "signing_requested"; - index: number; -} - -export interface TransactionSignedEvent { - event: "transaction_signed"; - index: number; -} - export interface EmailSubmissionEvent { event: "email_submission"; transaction_status: "success" | "failure"; } -export interface ClickSupportEvent { - event: "click_support"; - transaction_status: "success" | "failure"; -} - export interface NetworkChangeEvent { event: "network_change"; from_network: number; @@ -128,16 +97,12 @@ type InitializationErrorMessage = export type TrackableEvent = | AmountTypeEvent - | ClickDetailsEvent | WalletConnectEvent | TransactionEvent | TransactionFailedEvent | CompareQuoteEvent - | ClickSupportEvent | FormErrorEvent | EmailSubmissionEvent - | SigningRequestedEvent - | TransactionSignedEvent | ProgressEvent | NetworkChangeEvent | InitializationErrorEvent @@ -198,10 +163,6 @@ const useEvents = () => { window.dataLayer.push(event); }, []); - const resetUniqueEvents = useCallback(() => { - trackedEventTypes.current = new Set(); - }, []); - // Schedule a quote returned by a quote service. Once all quotes are ready, it emits a compare_quote event. // A quote of '-1' is emitted as undefined. const schedulePrice = useCallback( @@ -301,7 +262,6 @@ const useEvents = () => { }, [inputAmount, selectedNetwork, address, trackEvent]); return { - resetUniqueEvents, schedulePrice, trackEvent }; @@ -322,17 +282,3 @@ export function EventsProvider({ children }: PropsWithChildren) { return {children}; } - -export function createTransactionEvent(type: TransactionEvent["event"], state: RampState) { - return { - event: type, - from_amount: state.quote.inputAmount, - from_asset: state.quote.inputCurrency, - to_amount: state.quote.outputAmount, - to_asset: state.quote.outputCurrency - }; -} - -export function clearPersistentErrorEventStore() { - storageService.remove(LocalStorageKeys.FIRED_INITIALIZATION_EVENTS); -} diff --git a/apps/frontend/src/contexts/polkadotNode.tsx b/apps/frontend/src/contexts/polkadotNode.tsx index e1c7662a7e..2a4f69c6e7 100644 --- a/apps/frontend/src/contexts/polkadotNode.tsx +++ b/apps/frontend/src/contexts/polkadotNode.tsx @@ -2,7 +2,7 @@ import { ApiPromise, WsProvider } from "@polkadot/api"; import { useQuery } from "@tanstack/react-query"; import { createContext, type JSX, useContext, useEffect } from "react"; -import { ASSETHUB_WSS, MOONBEAM_WSS, PENDULUM_WSS } from "../constants/constants"; +import { ASSETHUB_WSS } from "../constants/constants"; import { useToastMessage } from "../helpers/notifications"; export interface ApiComponents { @@ -18,8 +18,6 @@ export interface ApiComponents { interface NetworkState { assethub?: ApiComponents; - pendulum?: ApiComponents; - moonbeam?: ApiComponents; } interface PolkadotNodeContextInterface { @@ -72,19 +70,13 @@ const usePolkadotNodes = () => { }; enum NodeName { - AssetHub = "assethub", - Pendulum = "pendulum", - Moonbeam = "moonbeam" + AssetHub = "assethub" } const getSocketUrl = (nodeName: NodeName): string => { switch (nodeName) { case NodeName.AssetHub: return ASSETHUB_WSS; - case NodeName.Pendulum: - return PENDULUM_WSS; - case NodeName.Moonbeam: - return MOONBEAM_WSS; } throw new Error(`Unsupported Polkadot node: ${nodeName}`); @@ -117,11 +109,9 @@ const usePolkadotNode = (nodeName: NodeName, enabled = false) => { }; const useAssetHubNode = (enabled = false) => usePolkadotNode(NodeName.AssetHub, enabled); -const usePendulumNode = (enabled = false) => usePolkadotNode(NodeName.Pendulum, enabled); -const useMoonbeamNode = (enabled = false) => usePolkadotNode(NodeName.Moonbeam, enabled); const PolkadotNodeProvider = ({ children }: { children: JSX.Element }) => { return {children}; }; -export { PolkadotNodeProvider, useAssetHubNode, usePendulumNode, useMoonbeamNode }; +export { PolkadotNodeProvider, useAssetHubNode }; diff --git a/apps/frontend/src/helpers/contracts.ts b/apps/frontend/src/helpers/contracts.ts index 16abdff050..481bc270ee 100644 --- a/apps/frontend/src/helpers/contracts.ts +++ b/apps/frontend/src/helpers/contracts.ts @@ -1,91 +1,8 @@ -import { Limits } from "@pendulum-chain/api-solang"; -import type { ApiPromise } from "@polkadot/api"; -import { ContractOptions } from "@polkadot/api-contract/types"; -import { INumber } from "@polkadot/types-codec/types"; -import type { QueryKey, UseQueryOptions } from "@tanstack/react-query"; import { roundDownToSignificantDecimals } from "@vortexfi/shared"; import BigNumber from "big.js"; const BIG_0 = new BigNumber("0"); -export type QueryOptions = Omit< - UseQueryOptions, - "queryKey" | "queryFn" ->; -export const emptyCacheKey = [""]; - -export const defaultReadLimits: Limits = { - gas: { - proofSize: "10000000000000000", - refTime: "10000000000000000" - }, - storageDeposit: undefined -}; - -export const defaultWriteLimits: Limits = { - gas: { - proofSize: "10000000000", - refTime: "10000000000000" - }, - storageDeposit: undefined -}; - -export const createWriteOptions = (_api: ApiPromise, opts?: ContractOptions) => ({ - gas: { - proofSize: "1300000", - refTime: "345000000000" - }, - storageDepositLimit: null, - ...opts -}); - -export interface ContractBalance { - rawBalance: BigNumber; - decimals: number; - preciseBigDecimal: BigNumber; - preciseString: string; - approximateStrings: { - atLeast2Decimals: string; - atLeast4Decimals: string; - }; - approximateNumber: number; -} - -export function parseContractBalanceResponse(decimals: number, balanceResponse: INumber | bigint): ContractBalance; - -export function parseContractBalanceResponse( - decimals: number | undefined, - balanceResponse: INumber | bigint | undefined -): ContractBalance | undefined; - -export function parseContractBalanceResponse( - decimals: number | undefined, - balanceResponse: INumber | bigint | undefined -): ContractBalance | undefined { - if (balanceResponse === undefined || decimals === undefined) return undefined; - - const rawBalanceBigInt = typeof balanceResponse === "bigint" ? balanceResponse : balanceResponse.toBigInt(); - - const rawBalanceString = rawBalanceBigInt.toString(); - const preciseBigDecimal = multiplyByPowerOfTen(new BigNumber(rawBalanceString), -decimals); - - const atLeast2Decimals = stringifyBigWithSignificantDecimals(preciseBigDecimal, 2); - const atLeast4Decimals = stringifyBigWithSignificantDecimals(preciseBigDecimal, 4); - const rawBalanceBigNumber = new BigNumber(rawBalanceBigInt.toString()); - - return { - approximateNumber: preciseBigDecimal.toNumber(), - approximateStrings: { - atLeast2Decimals, - atLeast4Decimals - }, - decimals, - preciseBigDecimal, - preciseString: preciseBigDecimal.toFixed(), - rawBalance: rawBalanceBigNumber - }; -} - export function stringifyBigWithSignificantDecimals(big: BigNumber, decimals: number) { const rounded = roundDownToSignificantDecimals(big, decimals); diff --git a/apps/frontend/src/helpers/crypto.ts b/apps/frontend/src/helpers/crypto.ts deleted file mode 100644 index f0d42e3ff5..0000000000 --- a/apps/frontend/src/helpers/crypto.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { multiplyByPowerOfTen } from "@vortexfi/shared"; -import { getAccount, readContract, signTypedData, switchChain } from "@wagmi/core"; -import { wagmiConfig } from "../wagmiConfig"; - -export async function signERC2612Permit( - owner: `0x${string}`, - spender: `0x${string}`, - valueUnits: string, - tokenAddress: `0x${string}`, - decimals: number, - chainId: number, - tokenName: string -): Promise<{ r: `0x${string}`; s: `0x${string}`; v: number; deadline: number }> { - const account = getAccount(wagmiConfig); - const originalChainId = account.chainId; - - const value = multiplyByPowerOfTen(valueUnits, decimals); - const deadline = BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 3600); // 1 week from now - - if (originalChainId && originalChainId !== chainId) { - try { - await switchChain(wagmiConfig, { chainId }); - } catch (error) { - console.error("Failed to switch chain for permit signing:", error); - throw new Error(`Failed to switch to chain ${chainId} for permit signing. Please switch manually and try again.`); - } - } - - try { - const nonce = (await readContract(wagmiConfig, { - abi: [ - { - inputs: [{ name: "owner", type: "address" }], - name: "nonces", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function" - } - ], - address: tokenAddress, - args: [owner], - chainId: chainId, - functionName: "nonces" - })) as bigint; - - const domain = { - chainId: BigInt(chainId), - name: tokenName, - verifyingContract: tokenAddress, - version: "1" - }; - - const types = { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - }; - - const message = { - deadline, - nonce, - owner, - spender, - value: value.toFixed(0, 0) - }; - - const signature = await signTypedData(wagmiConfig, { - account: owner, - domain, - message, - primaryType: "Permit", - types - }); - - const v = parseInt(signature.slice(130, 132), 16); - const r = `0x${signature.slice(2, 66)}` as `0x${string}`; - const s = `0x${signature.slice(66, 130)}` as `0x${string}`; - - return { deadline: Number(deadline), r, s, v }; - } catch (error) { - throw new Error("Failed to sign ERC2612 permit: " + error); - } finally { - if (originalChainId && originalChainId !== chainId) { - try { - await switchChain(wagmiConfig, { chainId: originalChainId }); - } catch (switchError) { - console.warn("Failed to switch back to original chain after permit signing:", switchError); - } - } - } -} diff --git a/apps/frontend/src/helpers/getTokenSymbol.ts b/apps/frontend/src/helpers/getTokenSymbol.ts deleted file mode 100644 index e382302cfa..0000000000 --- a/apps/frontend/src/helpers/getTokenSymbol.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - BaseFiatTokenDetails, - FiatTokenDetails, - isFiatTokenDetails, - isOnChainTokenDetails, - OnChainTokenDetails, - TokenDetails -} from "@vortexfi/shared"; - -export const getTokenSymbol = (token: BaseFiatTokenDetails | OnChainTokenDetails): string => { - if (isFiatTokenDetails(token as TokenDetails)) { - return (token as FiatTokenDetails).fiat.symbol; - } else if (isOnChainTokenDetails(token as TokenDetails)) { - return (token as OnChainTokenDetails).assetSymbol; - } - return ""; -}; diff --git a/apps/frontend/src/helpers/notifications.ts b/apps/frontend/src/helpers/notifications.ts index 5a6f3bb5d9..2df61e67b1 100644 --- a/apps/frontend/src/helpers/notifications.ts +++ b/apps/frontend/src/helpers/notifications.ts @@ -3,11 +3,7 @@ import { useTranslation } from "react-i18next"; import { ToastOptions, toast } from "react-toastify"; export enum ToastMessage { - AMOUNT_MISMATCH = "AMOUNT_MISMATCH", RAMP_LIMIT_EXCEEDED = "RAMP_LIMIT_EXCEEDED", - KYC_COMPLETED = "KYC_COMPLETED", - KYC_VERIFICATION_FAILED = "KYC_VERIFICATION_FAILED", - SIGNING_FAILED = "SIGNING_FAILED", POLKADOT_WALLET_ALREADY_OPEN_PENDING_CONNECTION = "POLKADOT_WALLET_ALREADY_OPEN_PENDING_CONNECTION", ERROR = "ERROR", NODE_CONNECTION_ERROR = "NODE_CONNECTION_ERROR", @@ -23,34 +19,6 @@ const toastConfig: Record l.trim()) - .filter(l => l.length > 0); - - const headerLine = lines.find(line => line.includes(SignInMessage.LOGIN_MESSAGE)) || ""; - const [domain, address] = headerLine.split(SignInMessage.LOGIN_MESSAGE).map(part => part.trim()); - - const nonceLine = lines.find(line => line.startsWith("Nonce:")) || ""; - const nonce = nonceLine.split("Nonce:")[1]?.trim() || ""; - - const issuedAtLine = lines.find(line => line.startsWith("Issued At:")) || ""; - const issuedAt = issuedAtLine.split("Issued At:")[1]?.trim(); // Can't really be empty. Constructor will default to current date if not defined. - const issuedAtMilis = new Date(issuedAt).getTime(); - - const expirationTimeLine = lines.find(line => line.startsWith("Expiration Time:")) || ""; - const expirationTime = expirationTimeLine.split("Expiration Time:")[1]?.trim(); - const expirationTimeMilis = new Date(expirationTime).getTime(); - - return new SignInMessage({ - address, - domain, - expirationTime: expirationTimeMilis, - issuedAt: issuedAtMilis, - nonce, - scheme: "https" - }); - } - - public toMessage(): string { - const header = `${this.domain}${SignInMessage.LOGIN_MESSAGE}${this.address}`; - - const body = `\nNonce: ${this.nonce}\nIssued At: ${this.issuedAt}\nExpiration Time: ${this.expirationTime}`; - - return `${header}\n\n${body}`; - } -} diff --git a/apps/frontend/src/hooks/brla/useBRLAKYCProcess/index.tsx b/apps/frontend/src/hooks/brla/useBRLAKYCProcess/index.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/frontend/src/hooks/offramp/useOfframpEvents.ts b/apps/frontend/src/hooks/offramp/useOfframpEvents.ts deleted file mode 100644 index eb8d50211d..0000000000 --- a/apps/frontend/src/hooks/offramp/useOfframpEvents.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useCallback } from "react"; -import { createTransactionEvent, useEventsContext } from "../../contexts/events"; - -import { RampState } from "../../types/phases"; - -export const useOfframpEvents = () => { - const { trackEvent, resetUniqueEvents } = useEventsContext(); - - const trackOfframpingEvent = useCallback( - (state: RampState | undefined) => { - if (!state) return; - - if (state.ramp?.currentPhase === "complete") { - trackEvent(createTransactionEvent("transaction_success", state)); - } else if (state.ramp?.currentPhase === "failed") { - // FIXME - // const inputTokenDetails = getPendulumDetails(state.inputTokenType, selectedNetwork); - // trackEvent({ - // ...createTransactionEvent('transaction_failure', state, selectedNetwork), - // event: 'transaction_failure', - // phase_name: state.phase, - // phase_index: Object.keys(RAMPING_PHASE_SECONDS).indexOf(state.phase), - // from_asset: inputTokenDetails.pendulumAssetSymbol, - // error_message: state.failure.message || 'Unknown error', - // }); - } - }, - [trackEvent] - ); - - return { resetUniqueEvents, trackEvent, trackOfframpingEvent }; -}; diff --git a/apps/frontend/src/hooks/ramp/useRampNavigation.test.tsx b/apps/frontend/src/hooks/ramp/useRampNavigation.test.tsx new file mode 100644 index 0000000000..0cb1b30942 --- /dev/null +++ b/apps/frontend/src/hooks/ramp/useRampNavigation.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import { TransactionStatus } from "@vortexfi/shared"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useRampNavigation } from "./useRampNavigation"; + +const mocks = vi.hoisted(() => ({ + isQuoteDisplayed: false, + rampMachineValue: "Idle" as string, + rampState: undefined as { ramp?: { currentPhase?: string; id?: string; status?: string } } | undefined +})); + +vi.mock("./useRampComponentState", () => ({ + useRampComponentState: () => ({ + rampMachineState: { value: mocks.rampMachineValue }, + rampState: mocks.rampState, + searchParams: {} + }) +})); + +vi.mock("./useIsQuoteComponentDisplayed", () => ({ + useIsQuoteComponentDisplayed: () => mocks.isQuoteDisplayed +})); + +const render = () => renderHook(() => useRampNavigation("success", "failure", "progress", "form", "quote")); +const current = () => render().result.current.getCurrentComponent(); + +describe("useRampNavigation", () => { + beforeEach(() => { + mocks.isQuoteDisplayed = false; + mocks.rampMachineValue = "Idle"; + mocks.rampState = undefined; + window.scrollTo = vi.fn() as unknown as typeof window.scrollTo; + }); + + it("shows the form by default", () => { + expect(current()).toBe("form"); + }); + + it("shows the quote when the quote component is displayed", () => { + mocks.isQuoteDisplayed = true; + expect(current()).toBe("quote"); + }); + + it("shows progress once a ramp exists and the machine is in RampFollowUp", () => { + mocks.rampState = { ramp: { currentPhase: "squidRouterSwap" } }; + mocks.rampMachineValue = "RampFollowUp"; + expect(current()).toBe("progress"); + }); + + it("does not show progress in RampFollowUp without a ramp state", () => { + mocks.rampMachineValue = "RampFollowUp"; + mocks.isQuoteDisplayed = true; + expect(current()).toBe("quote"); + }); + + it("shows success for a COMPLETE status or the complete phase, ahead of progress", () => { + mocks.rampMachineValue = "RampFollowUp"; + mocks.rampState = { ramp: { status: TransactionStatus.COMPLETE } }; + expect(current()).toBe("success"); + mocks.rampState = { ramp: { currentPhase: "complete" } }; + expect(current()).toBe("success"); + }); + + it("shows failure for a FAILED status or the failed phase, ahead of the quote", () => { + mocks.isQuoteDisplayed = true; + mocks.rampState = { ramp: { status: TransactionStatus.FAILED } }; + expect(current()).toBe("failure"); + mocks.rampState = { ramp: { currentPhase: "failed" } }; + expect(current()).toBe("failure"); + }); + + it("exposes the current phase and ramp id", () => { + mocks.rampState = { ramp: { currentPhase: "squidRouterSwap", id: "ramp-1" } }; + const { currentPhase, transactionId } = render().result.current; + expect(currentPhase).toBe("squidRouterSwap"); + expect(transactionId).toBe("ramp-1"); + }); + + it("scrolls to the top for progress, success and failure only", () => { + render(); + expect(window.scrollTo).not.toHaveBeenCalled(); + + mocks.isQuoteDisplayed = true; + render(); + expect(window.scrollTo).not.toHaveBeenCalled(); + + mocks.rampState = { ramp: { currentPhase: "complete" } }; + render(); + expect(window.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 0 }); + }); +}); diff --git a/apps/frontend/src/hooks/ramp/useRampNavigation.ts b/apps/frontend/src/hooks/ramp/useRampNavigation.ts index e5bed61d21..8c1aad46d2 100644 --- a/apps/frontend/src/hooks/ramp/useRampNavigation.ts +++ b/apps/frontend/src/hooks/ramp/useRampNavigation.ts @@ -3,13 +3,15 @@ import { ReactNode, useCallback, useLayoutEffect } from "react"; import { useIsQuoteComponentDisplayed } from "./useIsQuoteComponentDisplayed"; import { useRampComponentState } from "./useRampComponentState"; +type Screen = "success" | "failure" | "progress" | "quote" | "form"; + function getActiveScreen( currentPhase: string | undefined, status: TransactionStatus | undefined, rampStateDefined: boolean, machineValue: string, isQuoteDisplayed: boolean -): string { +): Screen { if (status === TransactionStatus.COMPLETE || currentPhase === "complete") return "success"; if (status === TransactionStatus.FAILED || currentPhase === "failed") return "failure"; if (rampStateDefined && machineValue === "RampFollowUp") return "progress"; @@ -41,34 +43,17 @@ export const useRampNavigation = ( } }, [activeScreen]); - const getCurrentComponent = useCallback(() => { - if (rampState?.ramp?.status === TransactionStatus.COMPLETE || rampState?.ramp?.currentPhase === "complete") { - return successComponent; - } - - if (rampState?.ramp?.status === TransactionStatus.FAILED || rampState?.ramp?.currentPhase === "failed") { - return failureComponent; - } - - if (rampState !== undefined && rampMachineState.value === "RampFollowUp") { - return progressComponent; - } - - if (isQuoteDisplayed) { - return quoteComponent; - } - - return formComponent; - }, [ - rampState, - rampMachineState.value, - successComponent, - failureComponent, - progressComponent, - formComponent, - quoteComponent, - isQuoteDisplayed - ]); + const getCurrentComponent = useCallback( + () => + ({ + failure: failureComponent, + form: formComponent, + progress: progressComponent, + quote: quoteComponent, + success: successComponent + })[activeScreen], + [activeScreen, successComponent, failureComponent, progressComponent, formComponent, quoteComponent] + ); return { currentPhase: rampState?.ramp?.currentPhase, diff --git a/apps/frontend/src/hooks/useDebouncedValue.ts b/apps/frontend/src/hooks/useDebouncedValue.ts deleted file mode 100644 index 82b431a09b..0000000000 --- a/apps/frontend/src/hooks/useDebouncedValue.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { debounce } from "../helpers/function"; - -export const useDebouncedValue = (value: T, delay = 1000) => { - const [debouncedValue, setDebouncedValue] = useState(value); - const debounceSet = useMemo(() => debounce(setDebouncedValue, delay), [delay]); - - useEffect(() => { - debounceSet(value); - }, [value, debounceSet]); - - return debouncedValue; -}; diff --git a/apps/frontend/src/hooks/useLocalStorage.test.tsx b/apps/frontend/src/hooks/useLocalStorage.test.tsx new file mode 100644 index 0000000000..fcb46c3e78 --- /dev/null +++ b/apps/frontend/src/hooks/useLocalStorage.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { LocalStorageKeys, useLocalStorage } from "./useLocalStorage"; + +const KEY = LocalStorageKeys.SELECTED_NETWORK; + +describe("useLocalStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("falls back to the default value when nothing is stored", () => { + const { result } = renderHook(() => useLocalStorage({ defaultValue: "base", key: KEY })); + expect(result.current.state).toBe("base"); + }); + + it("reads an already stored value on first render", () => { + localStorage.setItem(KEY, "polygon"); + const { result } = renderHook(() => useLocalStorage({ defaultValue: "base", key: KEY })); + expect(result.current.state).toBe("polygon"); + }); + + it("treats an empty stored value as missing", () => { + localStorage.setItem(KEY, ""); + const { result } = renderHook(() => useLocalStorage({ defaultValue: "base", key: KEY })); + expect(result.current.state).toBe("base"); + }); + + it("is undefined without a default value", () => { + const { result } = renderHook(() => useLocalStorage({ key: KEY })); + expect(result.current.state).toBeUndefined(); + }); + + it("set persists the value and updates the state", () => { + const { result } = renderHook(() => useLocalStorage({ defaultValue: "base", key: KEY })); + act(() => result.current.set("arbitrum")); + expect(result.current.state).toBe("arbitrum"); + expect(localStorage.getItem(KEY)).toBe("arbitrum"); + }); + + it("clear removes the stored value and restores the default", () => { + localStorage.setItem(KEY, "polygon"); + const { result } = renderHook(() => useLocalStorage({ defaultValue: "base", key: KEY })); + act(() => result.current.clear()); + expect(result.current.state).toBe("base"); + expect(localStorage.getItem(KEY)).toBeNull(); + }); + + it("keeps set and clear referentially stable across renders", () => { + const { result, rerender } = renderHook(() => useLocalStorage({ defaultValue: "base", key: KEY })); + const { set, clear } = result.current; + rerender(); + expect(result.current.set).toBe(set); + expect(result.current.clear).toBe(clear); + }); +}); diff --git a/apps/frontend/src/hooks/useLocalStorage.ts b/apps/frontend/src/hooks/useLocalStorage.ts index 35c7cf6659..23a6701e2b 100644 --- a/apps/frontend/src/hooks/useLocalStorage.ts +++ b/apps/frontend/src/hooks/useLocalStorage.ts @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { storageService } from "../services/storage/local"; -import { Storage } from "../services/storage/types"; export enum LocalStorageKeys { RATING = "RATING", @@ -16,25 +15,9 @@ export enum LocalStorageKeys { START_KEY_LOCAL_STORAGE = "rampStartKey" } -export const debounce = (func: (...args: T) => void, timeout = 300) => { - let timer: NodeJS.Timeout | undefined; - return (...args: T) => { - clearTimeout(timer); - timer = setTimeout(() => { - func(...args); - }, timeout); - }; -}; - -export type UseLocalStorageProps = { +type UseLocalStorageProps = { /** Storage key */ key: string; - /** Should the value be parsed (eg.: in case of objects) */ - parse?: boolean; - /** Should the value updating be debounced (eg.: for quickly changing values) */ - debounce?: number; - /** Expire time in seconds, or undefined for no expiry. */ - expire?: number; } & (T extends undefined ? { /** Default/fallback value */ @@ -45,83 +28,21 @@ export type UseLocalStorageProps = { defaultValue: T; }); -export interface UseLocalStorageResponse { - /** Storage state/value */ - state: T; - /** Set storage value */ - set: (data: T) => void; - /** Merge storage value with existing (eg.: updating part of an object) */ - merge: (data: Partial | ((data: T) => T)) => void; - /** Clear storage value */ - clear: () => void; -} - -const hasExpired = (timestamp: number, expiredMillis?: number) => { - if (expiredMillis === undefined) return false; - return Date.now() > timestamp + expiredMillis; -}; - -const getState = (key: string, defaultValue: T, parse: boolean, expire?: number): T => { - const date = expire !== undefined ? storageService.get(`${key}_`) : undefined; - if (date?.length && hasExpired(Date.parse(date), expire)) return defaultValue; - if (!parse) return (storageService.get(key) as T) ?? defaultValue; - const parsed = storageService.getParsed(key, defaultValue) as T; - return defaultValue !== undefined - ? ({ - ...defaultValue, - ...parsed - } as T) - : parsed; -}; +export const useLocalStorage = ({ key, defaultValue }: UseLocalStorageProps) => { + const [state, setState] = useState(() => (storageService.get(key) as T) ?? (defaultValue as T)); -export const useLocalStorage = ({ - key, - defaultValue, - debounce: debounceTime, - parse, - expire -}: UseLocalStorageProps): UseLocalStorageResponse => { - type TResponse = UseLocalStorageResponse; - const firstRef = useRef(false); - const storageSet = useMemo(() => { - const internalSet = (key: string, value: unknown) => { + const set = useCallback( + (value: T) => { storageService.set(key, value); - if (expire !== undefined) storageService.set(`${key}_`, Date.now()); - }; - return debounceTime ? debounce(internalSet, debounceTime) : internalSet; - }, [debounceTime, expire]); - - const [state, setState] = useState(() => getState(key, defaultValue as T, !!parse, expire)); - - const set = useCallback( - value => { - storageSet(key, value); setState(value); }, - [key, storageSet] + [key] ); - const clear = useCallback(() => { + + const clear = useCallback(() => { storageService.remove(key); - storageService.remove(`${key}_`); setState(defaultValue as T); }, [defaultValue, key]); - const merge = useCallback( - value => { - setState(prev => { - const newVal = typeof value === "function" ? value(prev) : ({ ...prev, ...value } as T); - storageSet(key, newVal); - return newVal; - }); - }, - [key, storageSet] - ); - - useEffect(() => { - if (firstRef.current) { - setState(getState(key, defaultValue as T, !!parse, expire)); - } - firstRef.current = true; - }, [defaultValue, key, expire, parse]); - return { clear, merge, set, state }; + return { clear, set, state }; }; diff --git a/apps/frontend/src/hooks/useLottieIntersectionAnimation.ts b/apps/frontend/src/hooks/useLottieIntersectionAnimation.ts deleted file mode 100644 index 60106f28b9..0000000000 --- a/apps/frontend/src/hooks/useLottieIntersectionAnimation.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { LottieRefCurrentProps } from "lottie-react"; -import { RefObject, useEffect, useRef } from "react"; - -interface UseLottieIntersectionAnimationOptions { - threshold?: number; - onComplete?: () => void; -} - -interface UseLottieIntersectionAnimationReturn { - lottieRef: RefObject; - cardRef: RefObject; - handleMouseEnter: () => void; - handleAnimationComplete: () => void; -} - -/** - * Custom hook to handle Lottie animations with Intersection Observer - * Plays animation on scroll into view (once) and on hover (repeatable) - * - * @param options - Configuration options - * @returns Refs and handlers for Lottie animation control - */ -export const useLottieIntersectionAnimation = ( - options: UseLottieIntersectionAnimationOptions = {} -): UseLottieIntersectionAnimationReturn => { - const { threshold = 0.7, onComplete } = options; - - const lottieRef = useRef(null); - const cardRef = useRef(null); - const hasPlayedOnce = useRef(false); - const isPlaying = useRef(false); - - useEffect(() => { - const card = cardRef.current; - if (!card) return; - - const observer = new IntersectionObserver( - entries => { - entries.forEach(entry => { - if (entry.isIntersecting && lottieRef.current && !hasPlayedOnce.current) { - lottieRef.current.play(); - hasPlayedOnce.current = true; - isPlaying.current = true; - } - }); - }, - { threshold } - ); - - observer.observe(card); - - return () => { - observer.disconnect(); - }; - }, [threshold]); - - const handleMouseEnter = () => { - if (lottieRef.current && !isPlaying.current) { - lottieRef.current.stop(); - lottieRef.current.goToAndStop(0, true); - lottieRef.current.setDirection(1); - lottieRef.current.play(); - isPlaying.current = true; - } - }; - - const handleAnimationComplete = () => { - isPlaying.current = false; - onComplete?.(); - }; - - return { - cardRef, - handleAnimationComplete, - handleMouseEnter, - lottieRef - }; -}; diff --git a/apps/frontend/src/hooks/useMaintenanceStatus.test.ts b/apps/frontend/src/hooks/useMaintenanceStatus.test.ts new file mode 100644 index 0000000000..d977d83d21 --- /dev/null +++ b/apps/frontend/src/hooks/useMaintenanceStatus.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { useMaintenanceStore } from "../stores/maintenanceStore"; +import { useMaintenanceStatus } from "./useMaintenanceStatus"; + +const { getMaintenanceStatus } = vi.hoisted(() => ({ getMaintenanceStatus: vi.fn() })); +vi.mock("../services/api/maintenance.service", () => ({ getMaintenanceStatus })); + +afterEach(() => { + vi.useRealTimers(); + useMaintenanceStore.setState({ error: null, isLoading: false, lastFetched: null, maintenanceStatus: null }); +}); + +it("refetches on every 5-minute poll and stops after unmount", async () => { + vi.useFakeTimers(); + // Real responses take time; the store only records lastFetched once they arrive. + getMaintenanceStatus.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ is_maintenance_active: false, maintenance_details: null }), 200)) + ); + + const { unmount } = renderHook(() => useMaintenanceStatus()); + expect(getMaintenanceStatus).toHaveBeenCalledTimes(1); + + await act(() => vi.advanceTimersByTimeAsync(5 * 60 * 1000)); + expect(getMaintenanceStatus).toHaveBeenCalledTimes(2); + + unmount(); + await act(() => vi.advanceTimersByTimeAsync(10 * 60 * 1000)); + expect(getMaintenanceStatus).toHaveBeenCalledTimes(2); +}); diff --git a/apps/frontend/src/hooks/useNetworkTokenCompatibility.ts b/apps/frontend/src/hooks/useNetworkTokenCompatibility.ts deleted file mode 100644 index 24dca97f85..0000000000 --- a/apps/frontend/src/hooks/useNetworkTokenCompatibility.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Networks } from "@vortexfi/shared"; -import { useNetwork } from "../contexts/network"; -import { useQuoteFormStore } from "../stores/quote/useQuoteFormStore"; - -export function useNetworkTokenCompatibility() { - const { - actions: { handleNetworkChange } - } = useQuoteFormStore(); - const { setSelectedNetwork } = useNetwork(); - - const handleNetworkSelect = async (network: Networks, resetState = true) => { - // First update the network in the context - await setSelectedNetwork(network, resetState); - - // Then check and update token compatibility - handleNetworkChange(network); - }; - - return { handleNetworkSelect }; -} diff --git a/apps/frontend/src/hooks/useOnchainTokenBalances.ts b/apps/frontend/src/hooks/useOnchainTokenBalances.ts deleted file mode 100644 index 730f3e2ce4..0000000000 --- a/apps/frontend/src/hooks/useOnchainTokenBalances.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { - ALCHEMY_API_KEY, - AssetHubTokenDetails, - AssetHubTokenDetailsWithBalance, - assetHubTokenConfig, - EvmTokenDetails, - EvmTokenDetailsWithBalance, - getAllEvmTokens, - getNetworkId, - isAssetHubTokenDetails, - isEvmTokenDetails, - isNetworkEVM, - Networks, - nativeToDecimal, - OnChainTokenDetails, - OnChainTokenDetailsWithBalance -} from "@vortexfi/shared"; -import Big from "big.js"; -import { useEffect, useMemo, useState } from "react"; -import { hexToBigInt } from "viem"; -import { useBalance } from "wagmi"; -import { useNetwork } from "../contexts/network"; -import { useAssetHubNode } from "../contexts/polkadotNode"; -import { multiplyByPowerOfTen } from "../helpers/contracts"; -import { getEvmTokensForNetwork } from "../services/tokens"; -import { useVortexAccount } from "./useVortexAccount"; - -// Global cache to persist balances across hook instances and app lifecycle -const globalBalanceCache = new Map>(); - -interface AlchemyTokenBalancesResponse { - data: { - tokens: { - network: string; - address: string; - tokenAddress: string | null; - tokenBalance: string; - }[]; - pageKey?: string; - }; -} - -const getAlchemyNetworkName = (network: Networks): string | null => { - if (!ALCHEMY_API_KEY) return null; - - const networkMap: Partial> = { - [Networks.Arbitrum]: "arb-mainnet", - [Networks.Avalanche]: "avax-mainnet", - [Networks.Base]: "base-mainnet", - [Networks.BSC]: "bsc-mainnet", - [Networks.Ethereum]: "eth-mainnet", - [Networks.Moonbeam]: "moonbeam-mainnet", - [Networks.Polygon]: "polygon-mainnet" - }; - - const networkName = networkMap[network]; - return networkName || null; -}; - -const fetchAlchemyTokenBalances = async (address: string, network: Networks): Promise> => { - const networkName = getAlchemyNetworkName(network); - if (!networkName) { - return new Map(); - } - - try { - const endpoint = `https://api.g.alchemy.com/data/v1/${ALCHEMY_API_KEY}/assets/tokens/balances/by-address`; - - const response = await fetch(endpoint, { - body: JSON.stringify({ - addresses: [ - { - address, - networks: [networkName] - } - ], - includeErc20Tokens: true, - includeNativeTokens: true - }), - headers: { - "Content-Type": "application/json" - }, - method: "POST" - }); - - const data: AlchemyTokenBalancesResponse = await response.json(); - - const balanceMap = new Map(); - if (data.data?.tokens) { - data.data.tokens.forEach(token => { - const tokenAddress = token.tokenAddress || "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; - const key = `${network}-${tokenAddress.toLowerCase()}`; - // Convert hex balance to decimal string using Big.js - const decimalBalance = hexToBigInt(token.tokenBalance as `0x${string}`).toString(); - balanceMap.set(key, decimalBalance); - }); - } - - return balanceMap; - } catch (error) { - console.error(`Error fetching balances for ${network}:`, error); - return new Map(); - } -}; - -export const useEvmNativeBalance = (): EvmTokenDetailsWithBalance | null => { - const { evmAddress: address } = useVortexAccount(); - const { selectedNetwork } = useNetwork(); - const chainId = getNetworkId(selectedNetwork); - - const tokensForNetwork: EvmTokenDetails[] = useMemo(() => { - if (isNetworkEVM(selectedNetwork)) { - return getEvmTokensForNetwork(selectedNetwork); - } else return []; - }, [selectedNetwork]); - - const nativeToken = useMemo(() => tokensForNetwork.find(t => t.isNative), [tokensForNetwork.find]); - - const { data: balance } = useBalance({ - address: address as `0x${string}`, - chainId: isNetworkEVM(selectedNetwork) ? chainId : undefined, - query: { - enabled: !!nativeToken && !!address && isNetworkEVM(selectedNetwork) - } - }); - - return useMemo(() => { - if (!nativeToken || !balance || !isNetworkEVM(selectedNetwork)) return null; - - const formattedBalance = multiplyByPowerOfTen(Big(balance.value.toString()), -balance.decimals).toFixed(4, 0); - - // Calculate balanceUsd by finding matching token in getAllEvmTokens by address and network - const allEvmTokens = getAllEvmTokens(); - const matchingToken = allEvmTokens.find( - token => - token.erc20AddressSourceChain?.toLowerCase() === nativeToken.erc20AddressSourceChain?.toLowerCase() && - token.network === nativeToken.network - ); - const usdPrice = matchingToken?.usdPrice ?? 0; - const balanceUsd = usdPrice > 0 ? Big(formattedBalance).times(usdPrice).toFixed(2, 0) : "0.00"; - - return { - ...nativeToken, - balance: formattedBalance, - balanceUsd - }; - }, [balance, selectedNetwork, nativeToken]); -}; - -export const useAssetHubNativeBalance = (): AssetHubTokenDetailsWithBalance | null => { - const [nativeBalance, setNativeBalance] = useState(null); - const { substrateAddress } = useVortexAccount(); - const { selectedNetwork } = useNetwork(); - const { apiComponents: assethubNode } = useAssetHubNode(selectedNetwork === Networks.AssetHub && !!substrateAddress); - - const nativeToken = useMemo(() => { - const assethubTokens = Object.values(assetHubTokenConfig); - return assethubTokens.find(token => token.isNative); - }, []); - - useEffect(() => { - if (!nativeToken || selectedNetwork !== Networks.AssetHub) { - setNativeBalance(null); - return; - } - - // If substrate wallet is not connected or node is not available, - // still show the token with zero balance - if (!substrateAddress || !assethubNode) { - setNativeBalance({ - ...nativeToken, - balance: "0.0000", - balanceUsd: "0.00" - }); - return; - } - - const getNativeBalance = async () => { - try { - const { api } = assethubNode; - const accountInfo = await api.query.system.account(substrateAddress); - const accountData = accountInfo.toJSON() as { - data: { - free: number; - reserved: number; - frozen: number; - }; - }; - - const freeBalance = accountData.data.free || 0; - const formattedBalance = nativeToDecimal(freeBalance, nativeToken.decimals).toFixed(4, 0).toString(); - - setNativeBalance({ - ...nativeToken, - balance: formattedBalance, - balanceUsd: "0.00" - }); - } catch (error) { - console.error("Error fetching AssetHub native balance:", error); - setNativeBalance(null); - } - }; - - getNativeBalance(); - }, [assethubNode, substrateAddress, selectedNetwork, nativeToken]); - - return nativeBalance; -}; - -const groupTokensByNetwork = (tokens: EvmTokenDetails[]): Record => { - return tokens.reduce>((acc, token) => { - if (!acc[token.network]) { - acc[token.network] = []; - } - acc[token.network].push(token); - return acc; - }, {}); -}; - -const getNumericProperty = (value: unknown, propertyName: string): number => { - if (!value || typeof value !== "object" || !(propertyName in value)) { - return 0; - } - - const propertyValue = (value as Record)[propertyName]; - return typeof propertyValue === "number" ? propertyValue : Number(propertyValue ?? 0); -}; - -const toJsonValue = (value: unknown): unknown => { - if (!value || typeof value !== "object" || !("toJSON" in value)) { - return undefined; - } - - const toJSON = (value as { toJSON?: unknown }).toJSON; - return typeof toJSON === "function" ? toJSON.call(value) : undefined; -}; - -export const useEvmBalances = (tokens: EvmTokenDetails[]): EvmTokenDetailsWithBalance[] => { - const { evmAddress: address } = useVortexAccount(); - const [balanceMap, setBalanceMap] = useState>(new Map()); - - const tokensByNetwork = useMemo(() => groupTokensByNetwork(tokens), [tokens]); - - // Fetch balances from Alchemy for all EVM networks once on component mount - useEffect(() => { - if (!address) return; - - const fetchAllBalances = async () => { - // Get all EVM networks from the tokens passed - const evmNetworks = Object.keys(tokensByNetwork).filter(network => isNetworkEVM(network as Networks)) as Networks[]; - - const allBalances = new Map(); - - for (const network of evmNetworks) { - const networkTokens = tokensByNetwork[network]; - if (!networkTokens?.length) continue; - - const cacheKey = `${address}-${network}`; - let balances: Map; - - const cachedBalances = globalBalanceCache.get(cacheKey); - if (cachedBalances) { - balances = cachedBalances; - } else { - try { - balances = await fetchAlchemyTokenBalances(address, network); - - globalBalanceCache.set(cacheKey, balances); - } catch (error) { - console.error(`Failed to fetch ${network} balances:`, error); - balances = new Map(); - } - } - - balances.forEach((value, key) => { - allBalances.set(key, value); - }); - } - - setBalanceMap(allBalances); - }; - - fetchAllBalances(); - }, [address, tokensByNetwork]); // - run when address or tokens change - - // Create a flat list of all tokens with their balances - const tokensWithBalances = tokens.reduce>((prev, curr, index) => { - const key = `${curr.network}-${curr.erc20AddressSourceChain?.toLowerCase()}`; - const tokenBalance = balanceMap.get(key); // If we are dealing with a stablecoin, we show 2 decimals, otherwise 6 - const showDecimals = curr.assetSymbol.toLowerCase().includes("usd") ? 2 : 6; - - const balance = tokenBalance ? multiplyByPowerOfTen(Big(tokenBalance), -curr.decimals).toFixed(showDecimals, 0) : "0.00"; - - // Calculate balanceUsd by finding matching token in getAllEvmTokens by address and network - const allEvmTokens = getAllEvmTokens(); - const matchingToken = allEvmTokens.find( - token => - token.erc20AddressSourceChain?.toLowerCase() === curr.erc20AddressSourceChain?.toLowerCase() && - token.network === curr.network - ); - const usdPrice = matchingToken?.usdPrice ?? 0; - const balanceUsd = usdPrice > 0 ? Big(balance).times(usdPrice).toFixed(2, 0) : "0.00"; - - prev.push({ - ...curr, - balance, - balanceUsd - }); - - return prev; - }, []); - - return tokensWithBalances; -}; - -export const useAssetHubBalances = (tokens: AssetHubTokenDetails[]): AssetHubTokenDetailsWithBalance[] => { - const [balances, setBalances] = useState>([]); - const { substrateAddress } = useVortexAccount(); - const assetTokens = useMemo(() => tokens.filter(t => !t.isNative), [tokens]); - const { apiComponents: assethubNode } = useAssetHubNode(assetTokens.length > 0 && !!substrateAddress); - - useEffect(() => { - // Only process non-native asset tokens here - native token handled by useAssetHubNativeBalance - if (tokens.length === 0) return; - - if (assetTokens.length === 0) { - setBalances([]); - return; - } - - // If substrate wallet is not connected or node is not available, - // still show the tokens with zero balances - if (!substrateAddress || !assethubNode) { - setBalances(assetTokens.map(token => ({ ...token, balance: "0.00", balanceUsd: "0.00" }))); - return; - } - - const getBalances = async () => { - const { api } = assethubNode; - - // Use unique list of assetIds and preserve mapping - const assetIds = Array.from(new Set(assetTokens.map(t => t.foreignAssetId).filter(id => id != null))); - const assetInfos = await api.query.assets.asset.multi(assetIds); - const accountQueries = assetIds.map(assetId => [assetId, substrateAddress]); - const accountInfos = await api.query.assets.account.multi(accountQueries); - - // Build maps by assetId - const assetInfoMap = new Map(); - assetIds.forEach((id, i) => assetInfoMap.set(id, assetInfos[i])); - - const accountInfoMap = new Map(); - assetIds.forEach((id, i) => accountInfoMap.set(id, accountInfos[i])); - - const tokensWithBalances = assetTokens.map(token => { - const assetId = token.foreignAssetId; - let balance: string; - - // assetId should exist for asset tokens; if missing, fallback to zero - if (assetId == null) { - balance = "0.00"; - } else { - const assetInfo = assetInfoMap.get(assetId); - const accountInfo = accountInfoMap.get(assetId); - - const rawMinBalance = getNumericProperty(toJsonValue(assetInfo), "minBalance"); - const rawBalance = getNumericProperty(toJsonValue(accountInfo), "balance"); - const offrampableBalance = rawBalance > 0 ? rawBalance - rawMinBalance : 0; - balance = nativeToDecimal(offrampableBalance, token.decimals).toFixed(2, 0).toString(); - } - - return { ...token, balance, balanceUsd: "0.00" }; - }); - - setBalances(tokensWithBalances); - }; - - getBalances(); - }, [assethubNode, assetTokens, tokens.length, substrateAddress]); - - return balances; -}; - -export const useOnchainTokenBalances = (tokens: OnChainTokenDetails[]): OnChainTokenDetailsWithBalance[] => { - const evmTokens = useMemo(() => tokens.filter(isEvmTokenDetails) as EvmTokenDetailsWithBalance[], [tokens]); - const substrateTokens = useMemo(() => tokens.filter(isAssetHubTokenDetails) as AssetHubTokenDetailsWithBalance[], [tokens]); - - const evmBalances = useEvmBalances(evmTokens); - const substrateBalances = useAssetHubBalances(substrateTokens); - const evmNativeBalance = useEvmNativeBalance(); - const assethubNativeBalance = useAssetHubNativeBalance(); - - return useMemo(() => { - // Combine all token balances - const allTokens = [...evmBalances, ...substrateBalances, assethubNativeBalance, evmNativeBalance].filter(Boolean); - - // Deduplicate tokens by network-symbol and type (native vs asset) - const uniqueTokens = new Map(); - allTokens.forEach(token => { - if (token) { - const isNative = "isNative" in token ? token.isNative : false; - const assetId = "foreignAssetId" in token ? token.foreignAssetId : null; - const key = `${token.network}-${token.assetSymbol}-${isNative}-${assetId}`; - if (!uniqueTokens.has(key)) { - uniqueTokens.set(key, token); - } - } - }); - - return Array.from(uniqueTokens.values()) as OnChainTokenDetailsWithBalance[]; - }, [assethubNativeBalance, evmBalances, substrateBalances, evmNativeBalance]); -}; diff --git a/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts b/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts deleted file mode 100644 index fe56047eb1..0000000000 --- a/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { OnChainTokenDetails, OnChainTokenDetailsWithBalance } from "@vortexfi/shared"; -import { useMemo } from "react"; -import { useOnchainTokenBalances } from "./useOnchainTokenBalances"; - -export const useOnchainTokenBalancesSorted = (tokens: OnChainTokenDetails[]): OnChainTokenDetailsWithBalance[] => { - const tokenBalances = useOnchainTokenBalances(tokens); - - return useMemo(() => { - // Sort by balance (highest to lowest), then by symbol (alphabetically) - return [...tokenBalances].sort((a, b) => { - const aBalance = parseFloat(a.balanceUsd ?? "0"); - const bBalance = parseFloat(b.balanceUsd ?? "0"); - - // Primary sort: balance descending (highest to lowest) - if (aBalance !== bBalance) { - return bBalance - aBalance; - } - - // Tie-breaker: sort by symbol alphabetically - return a.assetSymbol.localeCompare(b.assetSymbol); - }); - }, [tokenBalances]); -}; diff --git a/apps/frontend/src/hooks/useRampUrlParams.ts b/apps/frontend/src/hooks/useRampUrlParams.ts index 96bdb0e8b9..b3a4717cf9 100644 --- a/apps/frontend/src/hooks/useRampUrlParams.ts +++ b/apps/frontend/src/hooks/useRampUrlParams.ts @@ -2,14 +2,12 @@ import { useSearch } from "@tanstack/react-router"; import type { MoneriumOAuthCallback } from "@vortexfi/kyc"; import { AssetHubToken, - DestinationType, type EvmNetworks, EvmToken, FiatToken, getEvmTokenConfig, isNetworkEVM, logger, - mapFiatToDestination, Networks, OnChainToken, OnChainTokenSymbol, @@ -27,7 +25,7 @@ import { DEFAULT_RAMP_DIRECTION } from "../helpers/path"; import { QuoteService } from "../services/api"; import { useSetApiKey, useSetPartnerId } from "../stores/partnerStore"; import { useQuoteFormStoreActions } from "../stores/quote/useQuoteFormStore"; -import { useQuoteStore } from "../stores/quote/useQuoteStore"; +import { createQuotePayload, useQuoteStore } from "../stores/quote/useQuoteStore"; import { useRampDirection, useRampDirectionToggle } from "../stores/rampDirectionStore"; import { RampSearchParams } from "../types/searchParams"; import { useEvmTokensLoaded } from "./useEvmTokensLoaded"; @@ -117,50 +115,6 @@ function getNetworkFromParam(param?: string): Networks | undefined { return undefined; } -interface QuoteParams { - inputAmount?: Big; - onChainToken: OnChainTokenSymbol; - fiatToken: FiatToken; - selectedNetwork: DestinationType; - rampType: RampDirection; -} - -interface QuotePayload { - rampType: RampDirection; - fromDestination: DestinationType; - toDestination: DestinationType; - inputAmount: string; - inputCurrency: OnChainTokenSymbol | FiatToken; - outputCurrency: OnChainTokenSymbol | FiatToken; -} - -const createQuotePayload = (params: QuoteParams): QuotePayload => { - const { inputAmount, onChainToken, fiatToken, selectedNetwork, rampType } = params; - const fiatDestination = mapFiatToDestination(fiatToken); - const inputAmountStr = inputAmount?.toString() || "0"; - - const payloadMap: Record = { - [RampDirection.SELL]: { - fromDestination: selectedNetwork, - inputAmount: inputAmountStr, - inputCurrency: onChainToken, - outputCurrency: fiatToken, - rampType: RampDirection.SELL, - toDestination: fiatDestination - }, - [RampDirection.BUY]: { - fromDestination: fiatDestination, - inputAmount: inputAmountStr, - inputCurrency: fiatToken, - outputCurrency: onChainToken, - rampType: RampDirection.BUY, - toDestination: selectedNetwork - } - }; - - return payloadMap[rampType]; -}; - export enum RampUrlParamsKeys { RAMP_TYPE = "rampType", NETWORK = "network", diff --git a/apps/frontend/src/hooks/useTrackRampConfirmation.ts b/apps/frontend/src/hooks/useTrackRampConfirmation.ts deleted file mode 100644 index 8ec251d2a9..0000000000 --- a/apps/frontend/src/hooks/useTrackRampConfirmation.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { RampDirection } from "@vortexfi/shared"; -import { useCallback } from "react"; -import { useEventsContext } from "../contexts/events"; -import { useFiatToken, useInputAmount, useOnChainToken } from "../stores/quote/useQuoteFormStore"; -import { useQuoteStore } from "../stores/quote/useQuoteStore"; -import { useRampDirection } from "../stores/rampDirectionStore"; - -export const useTrackRampConfirmation = () => { - const rampDirection = useRampDirection(); - const { trackEvent } = useEventsContext(); - const fiatToken = useFiatToken(); - const onChainToken = useOnChainToken(); - const inputAmount = useInputAmount(); - const { quote } = useQuoteStore(); - - return useCallback(() => { - const fromAsset = rampDirection === RampDirection.BUY ? fiatToken : onChainToken; - const toAsset = rampDirection === RampDirection.BUY ? onChainToken : fiatToken; - trackEvent({ - event: "transaction_confirmation", - from_amount: inputAmount?.toString() || "0", - from_asset: fromAsset, - to_amount: quote?.outputAmount || "0", - to_asset: toAsset - }); - }, [fiatToken, onChainToken, inputAmount, quote, rampDirection, trackEvent]); -}; diff --git a/apps/frontend/src/layouts/index.test.tsx b/apps/frontend/src/layouts/index.test.tsx new file mode 100644 index 0000000000..003007160c --- /dev/null +++ b/apps/frontend/src/layouts/index.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "../test/i18n"; +import { useMaintenanceStore } from "../stores/maintenanceStore"; +import { API_BASE_URL, server } from "../test/msw-server"; + +const widgetMode = vi.hoisted(() => ({ value: true })); + +// Only the maintenance wiring is under test; stub the layout's unrelated chrome and hooks. +vi.mock("../components/Navbar", () => ({ Navbar: () => null })); +vi.mock("../components/Footer", () => ({ Footer: () => null })); +vi.mock("../components/Stepper", () => ({ default: () => null })); +vi.mock("../hooks/useInitTokenBalances", () => ({ useInitTokenBalances: () => undefined })); +vi.mock("../hooks/useStepper", () => ({ useStepper: () => ({ steps: [] }) })); +vi.mock("../hooks/useWidgetMode", () => ({ useWidgetMode: () => widgetMode.value })); +vi.mock("../hooks/ramp/useIsQuoteComponentDisplayed", () => ({ useIsQuoteComponentDisplayed: () => false })); + +import { BaseLayout } from "./index"; + +describe("BaseLayout", () => { + beforeEach(() => { + server.use( + http.get(`${API_BASE_URL}/maintenance/status`, () => + HttpResponse.json({ + is_maintenance_active: true, + maintenance_details: { + end_datetime: "2026-10-02T12:00:00Z", + message: "Ramps are paused while we upgrade.", + start_datetime: "2026-10-02T10:00:00Z", + title: "Scheduled maintenance" + } + }) + ) + ); + }); + + afterEach(() => { + useMaintenanceStore.setState({ error: null, isLoading: false, lastFetched: null, maintenanceStatus: null }); + }); + + it("fetches the maintenance status on mount and shows an active maintenance banner in the widget", async () => { + widgetMode.value = true; + + render(} />); + + expect(await screen.findByText("Scheduled maintenance")).toBeInTheDocument(); + }); + + it("keeps the maintenance banner off the marketing pages", async () => { + widgetMode.value = false; + + render(} />); + + await waitFor(() => expect(useMaintenanceStore.getState().maintenanceStatus?.is_maintenance_active).toBe(true)); + expect(screen.queryByText("Scheduled maintenance")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/layouts/index.tsx b/apps/frontend/src/layouts/index.tsx index 6deb7ae8e5..56217c4ad8 100644 --- a/apps/frontend/src/layouts/index.tsx +++ b/apps/frontend/src/layouts/index.tsx @@ -1,13 +1,13 @@ -import { FC, ReactNode, useEffect } from "react"; +import { FC, ReactNode } from "react"; import { Footer } from "../components/Footer"; import { MaintenanceBanner } from "../components/MaintenanceBanner"; import { Navbar } from "../components/Navbar"; import Stepper from "../components/Stepper"; import { useIsQuoteComponentDisplayed } from "../hooks/ramp/useIsQuoteComponentDisplayed"; import { useInitTokenBalances } from "../hooks/useInitTokenBalances"; +import { useMaintenanceStatus } from "../hooks/useMaintenanceStatus"; import { useStepper } from "../hooks/useStepper"; import { useWidgetMode } from "../hooks/useWidgetMode"; -import { useFetchMaintenanceStatus } from "../stores/maintenanceStore"; interface BaseLayoutProps { main: ReactNode; @@ -21,7 +21,7 @@ export const BaseLayout: FC = ({ main, modals }) => { const isQuoteComponentDisplayed = useIsQuoteComponentDisplayed(); useInitTokenBalances(); - useFetchMaintenanceStatus(); + useMaintenanceStatus(); const isStepperHidden = isWidgetMode && isQuoteComponentDisplayed; @@ -29,9 +29,9 @@ export const BaseLayout: FC = ({ main, modals }) => { <> {modals} - {isWidgetMode && ( <> +
{isStepperHidden ?
: }
diff --git a/apps/frontend/src/machines/actors/register.actor.test.ts b/apps/frontend/src/machines/actors/register.actor.test.ts index 77a3a60ee9..b8f2970f08 100644 --- a/apps/frontend/src/machines/actors/register.actor.test.ts +++ b/apps/frontend/src/machines/actors/register.actor.test.ts @@ -47,7 +47,6 @@ const baseContext = { }, sourceOrDestinationAddress: "0x2222222222222222222222222222222222222222" }, - paymentData: undefined, userEmail: "user@example.com" } as unknown as RampContext; diff --git a/apps/frontend/src/machines/actors/registerAdditionalData.ts b/apps/frontend/src/machines/actors/registerAdditionalData.ts index 8d7dde886e..4ff9837c88 100644 --- a/apps/frontend/src/machines/actors/registerAdditionalData.ts +++ b/apps/frontend/src/machines/actors/registerAdditionalData.ts @@ -17,7 +17,7 @@ export function buildRegisterRampAdditionalData( input: RampContext, connectedWalletAddress: string ): RegisterRampRequest["additionalData"] { - const { executionInput, paymentData } = input; + const { executionInput } = input; if (!executionInput) { throw new RegisterRampError("Execution input is required to register ramp.", RegisterRampErrorType.InvalidInput); @@ -85,7 +85,6 @@ export function buildRegisterRampAdditionalData( } return { - paymentData, sessionId: input.externalSessionId, walletAddress: connectedWalletAddress }; diff --git a/apps/frontend/src/machines/actors/sign.actor.test.ts b/apps/frontend/src/machines/actors/sign.actor.test.ts index e59cec4248..aac5e50000 100644 --- a/apps/frontend/src/machines/actors/sign.actor.test.ts +++ b/apps/frontend/src/machines/actors/sign.actor.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { http, HttpResponse } from "msw"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // The real module imports wagmi/walletconnect config at module load; the actor tests only // care about which signing helper is routed to, not the wallet plumbing itself. @@ -31,7 +31,7 @@ const ALICE_SUBSTRATE = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; function buildRampState(unsignedTxs: UnsignedTx[]): RampState { return { quote: buildQuoteResponse(), - ramp: { ...buildRampProcess("initial"), unsignedTxs }, + ramp: { ...buildRampProcess("initial"), expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(), unsignedTxs }, requiredUserActionsCompleted: false, signedTransactions: [], userSigningMeta: undefined @@ -221,6 +221,32 @@ describe("signTransactionsActor", () => { }); }); + describe("start deadline", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("refuses to broadcast the swap when the approve prompt ran into the start deadline margin", async () => { + const updateCalls = mockUpdateEndpoint(); + vi.mocked(signAndSubmitEvmTransaction).mockImplementationOnce(async () => { + // The user leaves the approve prompt open until only three minutes of the start window remain. + vi.setSystemTime(Date.now() + 12 * 60_000); + return "0xapprovehash"; + }); + const context = buildContext([ + buildUnsignedTx({ nonce: 1, phase: "squidRouterApprove" }), + buildUnsignedTx({ nonce: 2, phase: "squidRouterSwap" }) + ]); + const { parent } = buildParent(); + + await expect(signTransactionsActor({ input: { context, parent } })).rejects.toMatchObject({ + type: SignRampErrorType.StartWindowClosed + }); + expect(vi.mocked(signAndSubmitEvmTransaction).mock.calls.map(call => call[0].phase)).toEqual(["squidRouterApprove"]); + expect(updateCalls).toHaveLength(0); + }); + }); + describe("typed-data signing", () => { it("signs a single typed-data payload, replaces the txData and submits it as a presigned tx", async () => { const updateCalls = mockUpdateEndpoint(); diff --git a/apps/frontend/src/machines/actors/sign.actor.ts b/apps/frontend/src/machines/actors/sign.actor.ts index cf03a28829..ecbd25c849 100644 --- a/apps/frontend/src/machines/actors/sign.actor.ts +++ b/apps/frontend/src/machines/actors/sign.actor.ts @@ -18,6 +18,7 @@ import { RampContext, RampMachineActor, RampState } from "../types"; export enum SignRampErrorType { InvalidInput = "INVALID_INPUT", UserRejected = "USER_REJECTED", + StartWindowClosed = "START_WINDOW_CLOSED", UnknownError = "UNKNOWN_ERROR" } export class SignRampError extends Error implements DomainError { @@ -30,6 +31,10 @@ export class SignRampError extends Error implements DomainError { } } +// The API refuses to record or start a ramp after its start deadline, so funds broadcast later strand on the +// ephemeral. The margin covers the wallet confirmation, the receipt wait and the update/start calls. +const START_DEADLINE_MARGIN_MS = 4 * 60_000; + export const signTransactionsActor = async ({ input }: { @@ -88,6 +93,13 @@ export const signTransactionsActor = async ({ const tx = sortedTxs[idx]; const current = idx + 1; + // Typed-data permits move nothing until the backend executes them after a successful start. + // The negated comparison fails closed when expiresAt is missing (NaN). + const isBroadcast = !isSignedTypedData(tx.txData) && !isSignedTypedDataArray(tx.txData); + if (isBroadcast && !(Date.parse(rampState.ramp?.expiresAt ?? "") - Date.now() > START_DEADLINE_MARGIN_MS)) { + throw new SignRampError("Ramp start window closed before broadcast", SignRampErrorType.StartWindowClosed); + } + if (isSignedTypedData(tx.txData) || isSignedTypedDataArray(tx.txData)) { input.parent.send({ current, max: total, phase: "started", type: "SIGNING_UPDATE" }); if (isSignedTypedData(tx.txData)) { @@ -135,6 +147,9 @@ export const signTransactionsActor = async ({ } } catch (error) { console.log("Error during signing transactions: ", error); + if (error instanceof SignRampError) { + throw error; + } // We try to catch an error caused by user rejection of the signature request. if (error instanceof Error && error.message) { if (error.message.includes("User rejected the request")) { diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 52e1212b15..4a0f607144 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -227,9 +227,6 @@ export const kycStateNode = { }), reenter: true, target: "Monerium" - }, - MONERIUM_REFRESH: { - actions: sendTo("moneriumKyc", { type: "REFRESH" }) } } }, diff --git a/apps/frontend/src/machines/ramp.context.ts b/apps/frontend/src/machines/ramp.context.ts index 690b8bc040..29c3731c27 100644 --- a/apps/frontend/src/machines/ramp.context.ts +++ b/apps/frontend/src/machines/ramp.context.ts @@ -2,7 +2,6 @@ import { RampContext } from "./types"; export const initialRampContext: RampContext = { apiKey: undefined, - authToken: undefined, callbackUrl: undefined, chainId: undefined, connectedWalletAddress: undefined, @@ -14,11 +13,9 @@ export const initialRampContext: RampContext = { initializeFailedMessage: undefined, isAuthenticated: false, isQuoteExpired: false, - isQuoteRedo: false, kybLink: undefined, moneriumCallback: undefined, partnerId: undefined, - paymentData: undefined, postAuthTarget: undefined, quote: undefined, quoteId: undefined, diff --git a/apps/frontend/src/machines/ramp.machine.test.ts b/apps/frontend/src/machines/ramp.machine.test.ts index 5c174398cd..9a6789c2e8 100644 --- a/apps/frontend/src/machines/ramp.machine.test.ts +++ b/apps/frontend/src/machines/ramp.machine.test.ts @@ -583,6 +583,28 @@ describe("rampMachine", () => { await waitFor(actor, s => s.matches("KycComplete")); }); + it("UPDATE_QUOTE in KycComplete swaps in the refreshed quote, clears expiry and stays in KycComplete", async () => { + const actor = createRampActor({ + validateKyc: fromPromise(async (): Promise => ({ kycNeeded: false })) + }); + actor.start(); + await goToQuoteReady(actor); + await confirmRamp(actor, FiatToken.BRL); + await waitFor(actor, s => s.matches("KycComplete")); + actor.send({ type: "EXPIRE_QUOTE" }); + expect(actor.getSnapshot().context.isQuoteExpired).toBe(true); + + const refreshed = { ...quote, id: "quote-2", outputAmount: "90" } as unknown as QuoteResponse; + actor.send({ quote: refreshed, type: "UPDATE_QUOTE" }); + + const { context, value } = actor.getSnapshot(); + expect(value).toBe("KycComplete"); + expect(context.quote?.id).toBe("quote-2"); + expect(context.quoteId).toBe("quote-2"); + expect(context.executionInput?.quote.id).toBe("quote-2"); + expect(context.isQuoteExpired).toBe(false); + }); + it("PROCEED_TO_REGISTRATION from KycComplete stores the fiat account and registers directly when authenticated", async () => { const actor = createRampActor({ registerRamp: fromPromise(() => new Promise(() => {})), diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index 248c176bac..71e2192fd5 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -155,9 +155,6 @@ export const rampMachine = setup({ RESET_RAMP: { target: ".Resetting" }, - RESET_RAMP_CALLBACK: { - actions: [{ type: "resetRamp" }, { type: "urlCleanerWithCallbackAction" }] - }, SET_ADDRESS: { actions: assign({ connectedWalletAddress: ({ event }) => event.address @@ -355,13 +352,6 @@ export const rampMachine = setup({ }, EnterOTP: { on: { - CHANGE_EMAIL: { - actions: assign({ - errorMessage: undefined, - userEmail: undefined - }), - target: "EnterEmail" - }, ENTER_EMAIL: { actions: assign({ errorMessage: undefined, @@ -481,33 +471,17 @@ export const rampMachine = setup({ REFRESH_FAILED: { actions: [{ type: "refreshQuoteActionWithDelay" }] }, - UPDATE_QUOTE: [ - { - actions: assign({ - executionInput: ({ context, event }) => - context.executionInput ? { ...context.executionInput, quote: event.quote } : context.executionInput, - isQuoteRedo: () => true, - quote: ({ event }) => event.quote, - quoteId: ({ event }) => event.quote.id - }), - guard: ({ context, event }) => - context.paymentData !== undefined && event.quote.outputAmount !== context.quote?.outputAmount, - target: "QuoteReady" - }, - { - actions: [ - assign({ - executionInput: ({ context, event }) => - context.executionInput ? { ...context.executionInput, quote: event.quote } : context.executionInput, - isQuoteExpired: false, - quote: ({ event }) => event.quote, - quoteId: ({ event }) => event.quote.id - }) - ], - reenter: true, - target: "KycComplete" - } - ] + UPDATE_QUOTE: { + actions: assign({ + executionInput: ({ context, event }) => + context.executionInput ? { ...context.executionInput, quote: event.quote } : context.executionInput, + isQuoteExpired: false, + quote: ({ event }) => event.quote, + quoteId: ({ event }) => event.quote.id + }), + reenter: true, + target: "KycComplete" + } } }, KycFailure: { diff --git a/apps/frontend/src/machines/types.ts b/apps/frontend/src/machines/types.ts index e64bd72318..dd4ec94dd1 100644 --- a/apps/frontend/src/machines/types.ts +++ b/apps/frontend/src/machines/types.ts @@ -1,7 +1,7 @@ import { WalletAccount } from "@talismn/connect-wallets"; import type { MoneriumOAuthCallback } from "@vortexfi/kyc"; import { AlfredpayKycContext, AveniaKycContext } from "@vortexfi/kyc"; -import { FiatToken, PaymentData, QuoteResponse, RampDirection } from "@vortexfi/shared"; +import { FiatToken, QuoteResponse, RampDirection } from "@vortexfi/shared"; import { ActorRef, ActorRefFrom, Snapshot, SnapshotFrom } from "xstate"; import { ToastMessage } from "../helpers/notifications"; import { KYCFormData } from "../hooks/brla/useKYCForm"; @@ -19,13 +19,11 @@ export interface RampContext { /** Monerium OAuth callback (`?code&state` or `?error`) waiting for the restored KYC child. */ moneriumCallback?: MoneriumOAuthCallback; connectedWalletAddress: string | undefined; // The address of the connected wallet (EVM or Substrate) - authToken?: string; chainId: number | undefined; executionInput: RampExecutionInput | undefined; getMessageSignature: GetMessageSignatureCallback | undefined; initializeFailedMessage: string | undefined; isQuoteExpired: boolean; - paymentData?: PaymentData; apiKey?: string; partnerId?: string; quote: QuoteResponse | undefined; @@ -41,7 +39,6 @@ export interface RampContext { walletLocked?: string; callbackUrl?: string; externalSessionId?: string; - isQuoteRedo?: boolean; errorMessage?: string; kycFormData?: KYCFormData; enteredViaForm?: boolean; // True if user navigated from the Quote form, false if entered via direct URL @@ -49,8 +46,6 @@ export interface RampContext { userEmail?: string; userId?: string; isAuthenticated: boolean; - isAuthLoading?: boolean; - alfredpayCustomer?: unknown; postAuthTarget?: "QuoteReady" | "RegisterRamp" | "SelectRegion"; // Present only in the quote-less KYB deep-link flow — its presence enables the mode. kybLink?: { @@ -70,13 +65,11 @@ export type RampMachineEvents = | { type: "SET_ADDRESS"; address: string | undefined } | { type: "SET_SUBSTRATE_WALLET_ACCOUNT"; walletAccount: WalletAccount | undefined } | { type: "SET_GET_MESSAGE_SIGNATURE"; getMessageSignature: GetMessageSignatureCallback | undefined } - | { type: "SubmitLevel1"; formData: KYCFormData } // TODO: We should allow by default all child events | { type: "SummaryConfirm" } | { type: "SIGNING_UPDATE"; phase: RampSigningPhase | undefined; current?: number; max?: number } | { type: "PAYMENT_CONFIRMED" } | { type: "SET_RAMP_STATE"; rampState: RampState } | { type: "RESET_RAMP"; skipUrlCleaner?: boolean } - | { type: "RESET_RAMP_CALLBACK" } | { type: "FINISH_OFFRAMPING" } | { type: "SHOW_ERROR_TOAST"; message: ToastMessage } | { type: "PROCEED_TO_REGISTRATION"; selectedFiatAccountId?: string } @@ -90,17 +83,12 @@ export type RampMachineEvents = | { type: "REFRESH_FAILED" } // Auth events | { type: "ENTER_EMAIL"; email: string } - | { type: "CHANGE_EMAIL" } - | { type: "EMAIL_VERIFIED" } - | { type: "OTP_SENT" } | { type: "VERIFY_OTP"; code: string } | { type: "AUTH_SUCCESS"; tokens: { accessToken: string; refreshToken: string; userId: string; userEmail?: string } } - | { type: "AUTH_ERROR"; error: string } | { type: "LOGOUT" } | { type: "GO_BACK" } | { type: "START_KYB_LINK"; invite?: string; region?: string; locked?: boolean } | { type: "MONERIUM_CALLBACK"; callback: MoneriumOAuthCallback } - | { type: "MONERIUM_REFRESH" } | { type: "RETRY_INVITE" } | { type: "SELECT_REGION"; fiatToken: FiatToken }; diff --git a/apps/frontend/src/sections/individuals/FeeComparison/FeeComparisonTable/utils/assetUtils.ts b/apps/frontend/src/sections/individuals/FeeComparison/FeeComparisonTable/utils/assetUtils.ts deleted file mode 100644 index e2d293d505..0000000000 --- a/apps/frontend/src/sections/individuals/FeeComparison/FeeComparisonTable/utils/assetUtils.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - FiatToken, - getAnyFiatTokenDetails, - getOnChainTokenDetailsOrDefault, - Networks, - OnChainToken, - RampDirection -} from "@vortexfi/shared"; - -/** - * Determines source and target asset symbols based on ramp direction - * @param rampDirection Current ramp direction (onramp or offramp) - * @param selectedNetwork Selected network (must be a valid Networks enum value) - * @param onChainToken On-chain token (must be a valid OnChainToken value) - * @param fiatToken Fiat token (must be a valid FiatToken enum value) - * @returns Object containing source and target asset symbols - */ -export function getAssetSymbols( - rampDirection: RampDirection, - selectedNetwork: Networks, - onChainToken: OnChainToken, - fiatToken: FiatToken -) { - const isOnramp = rampDirection === RampDirection.BUY; - const onChainTokenDetails = getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken); - const fiatTokenDetails = getAnyFiatTokenDetails(fiatToken); - - return { - fiatTokenDetails, - onChainTokenDetails, - sourceAssetSymbol: isOnramp ? fiatTokenDetails.fiat.symbol : onChainTokenDetails.assetSymbol, - targetAssetSymbol: isOnramp ? onChainTokenDetails.assetSymbol : fiatTokenDetails.fiat.symbol - }; -} diff --git a/apps/frontend/src/services/api/brla.service.ts b/apps/frontend/src/services/api/brla.service.ts index 8b4568aa31..29e574613e 100644 --- a/apps/frontend/src/services/api/brla.service.ts +++ b/apps/frontend/src/services/api/brla.service.ts @@ -3,7 +3,6 @@ import { BrGetUserResponse, BrKYCDataUpload, BrKYCDataUploadRequest, - BrValidatePixKeyResponse, RampDirection } from "@vortexfi/shared"; import { apiRequest } from "./api-client"; @@ -34,17 +33,6 @@ export class BrlaService { static async recordInitialKycAttempt(taxId: string, quoteId: string, sessionId?: string): Promise> { return apiRequest>("post", `${this.BASE_PATH}/kyc/record-attempt`, { quoteId, sessionId, taxId }); } - /** - * Validate a PIX key - * @param pixKey The PIX key to validate - * @returns Whether the PIX key is valid - */ - static async validatePixKey(pixKey: string): Promise { - return apiRequest("get", `${this.BASE_PATH}/validatePixKey`, undefined, { - params: { pixKey } - }); - } - /** * Get the remaining limit for a user * @param taxId The user's tax ID diff --git a/apps/frontend/src/services/api/index.ts b/apps/frontend/src/services/api/index.ts index ef8784343c..384e17f66b 100644 --- a/apps/frontend/src/services/api/index.ts +++ b/apps/frontend/src/services/api/index.ts @@ -3,13 +3,8 @@ export * from "./brla.service"; export * from "./contact.service"; export * from "./email.service"; export * from "./maintenance.service"; -export * from "./moonbeam.service"; -export * from "./pendulum.service"; export * from "./price.service"; export * from "./quote.service"; export * from "./ramp.service"; export * from "./rating.service"; export * from "./recipients.service"; -export * from "./siwe.service"; -export * from "./storage.service"; -export * from "./subsidize.service"; diff --git a/apps/frontend/src/services/api/moonbeam.service.ts b/apps/frontend/src/services/api/moonbeam.service.ts deleted file mode 100644 index d9689874e4..0000000000 --- a/apps/frontend/src/services/api/moonbeam.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ApiPromise, WsProvider } from "@polkadot/api"; -import { MOONBEAM_WSS } from "../../constants/constants"; -import { ApiComponents } from "./polkadot.service"; - -async function createApiComponents(socketUrl: string, autoReconnect = true): Promise { - // Parameters from here https://github.com/galacticcouncil/sdk/blob/master/packages/sdk/TROUBLESHOOTING.md#websocket-ttl-cache - const provider = new WsProvider(socketUrl, autoReconnect ? 2_500 : undefined, {}, 60_000, 102400, 10 * 60_000); - const api = await ApiPromise.create({ provider }); - - await api.isReady; - - const chainProperties = api.registry.getChainProperties(); - const ss58Format = Number(chainProperties?.get("ss58Format")?.toString() ?? 42); - const decimals = Number(chainProperties?.get("tokenDecimals")?.toHuman()[0]) ?? 12; - - const [chain, nodeName, nodeVersion, bestNumberFinalize] = await Promise.all([ - api.rpc.system.chain(), - api.rpc.system.name(), - api.rpc.system.version(), - api.derive.chain.bestNumber() - ]); - - return { - api, - bestNumberFinalize: Number(bestNumberFinalize), - chain: chain.toString(), - decimals, - nodeName: nodeName.toString(), - nodeVersion: nodeVersion.toString(), - ss58Format, - tokenSymbol: chainProperties - ?.get("tokenSymbol") - ?.toString() - ?.replace(/[\\[\]]/g, "") - }; -} - -class MoonbeamApiService { - private static instance: MoonbeamApiService; - private apiComponents?: Promise; - - private constructor() {} - - public static getInstance(): MoonbeamApiService { - if (!MoonbeamApiService.instance) { - MoonbeamApiService.instance = new MoonbeamApiService(); - } - return MoonbeamApiService.instance; - } - - public getApi(): Promise { - if (!this.apiComponents) { - this.apiComponents = createApiComponents(MOONBEAM_WSS); - } - - return this.apiComponents; - } -} - -export const moonbeamApiService = MoonbeamApiService.getInstance(); diff --git a/apps/frontend/src/services/api/pendulum.service.ts b/apps/frontend/src/services/api/pendulum.service.ts deleted file mode 100644 index 886a7e1e29..0000000000 --- a/apps/frontend/src/services/api/pendulum.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ApiPromise, WsProvider } from "@polkadot/api"; -import { PENDULUM_WSS } from "../../constants/constants"; -import { ApiComponents } from "./polkadot.service"; - -async function createApiComponents(socketUrl: string, autoReconnect = true): Promise { - // Parameters from here https://github.com/galacticcouncil/sdk/blob/master/packages/sdk/TROUBLESHOOTING.md#websocket-ttl-cache - const provider = new WsProvider(socketUrl, autoReconnect ? 2_500 : false, {}, 60_000, 102400, 10 * 60_000); - const api = await ApiPromise.create({ provider }); - - await api.isReady; - - const chainProperties = api.registry.getChainProperties(); - const ss58Format = Number(chainProperties?.get("ss58Format")?.toString() ?? 42); - const decimals = Number(chainProperties?.get("tokenDecimals")?.toHuman()[0]) ?? 12; - - const [chain, nodeName, nodeVersion, bestNumberFinalize] = await Promise.all([ - api.rpc.system.chain(), - api.rpc.system.name(), - api.rpc.system.version(), - api.derive.chain.bestNumber() - ]); - - return { - api, - bestNumberFinalize: Number(bestNumberFinalize), - chain: chain.toString(), - decimals, - nodeName: nodeName.toString(), - nodeVersion: nodeVersion.toString(), - ss58Format, - tokenSymbol: chainProperties - ?.get("tokenSymbol") - ?.toString() - ?.replace(/[\\[\]]/g, "") - }; -} - -class PendulumApiService { - private static instance: PendulumApiService; - private apiComponents?: Promise; - - private constructor() {} - - public static getInstance(): PendulumApiService { - if (!PendulumApiService.instance) { - PendulumApiService.instance = new PendulumApiService(); - } - return PendulumApiService.instance; - } - - public getApi(): Promise { - if (!this.apiComponents) { - this.apiComponents = createApiComponents(PENDULUM_WSS); - } - - return this.apiComponents; - } -} - -export const pendulumApiService = PendulumApiService.getInstance(); diff --git a/apps/frontend/src/services/api/price.service.ts b/apps/frontend/src/services/api/price.service.ts index a4eab539d9..e574534475 100644 --- a/apps/frontend/src/services/api/price.service.ts +++ b/apps/frontend/src/services/api/price.service.ts @@ -1,4 +1,4 @@ -import { AllPricesResponse, BundledPriceResult, Currency, PriceProvider, RampDirection } from "@vortexfi/shared"; +import { AllPricesResponse, Currency, RampDirection } from "@vortexfi/shared"; import { apiRequest } from "./api-client"; /** @@ -7,103 +7,6 @@ import { apiRequest } from "./api-client"; export class PriceService { private static readonly BASE_PATH = "/prices"; - /** - * Get price information from a provider - * @param provider The provider name - * @param sourceCurrency The source currency (crypto for offramp, fiat for onramp) - * @param targetCurrency The target currency (fiat for offramp, crypto for onramp) - * @param amount The amount to convert - * @param direction The direction of the conversion (onramp or offramp) - * @param network Optional network name - * @returns Price information - */ - static async getPrice( - provider: PriceProvider, - sourceCurrency: Currency, - targetCurrency: Currency, - amount: string, - direction: RampDirection, - network?: string - ): Promise { - return apiRequest("get", this.BASE_PATH, undefined, { - params: { - amount, - direction, - network, - provider, - sourceCurrency, - targetCurrency - } - }); - } - - /** - * Get price information from AlchemyPay - * @param sourceCurrency The source currency (crypto for offramp, fiat for onramp) - * @param targetCurrency The target currency (fiat for offramp, crypto for onramp) - * @param amount The amount to convert - * @param direction The direction of the conversion (onramp or offramp) - * @param network Optional network name - * @returns AlchemyPay price information - */ - static async getAlchemyPayPrice( - sourceCurrency: Currency, - targetCurrency: Currency, - amount: string, - direction: RampDirection, - network?: string - ): Promise { - const response = await this.getPrice("alchemypay", sourceCurrency, targetCurrency, amount, direction, network); - return response; - } - - /** - * Get price information from Moonpay - * @param sourceCurrency The source currency (crypto for offramp, fiat for onramp) - * @param targetCurrency The target currency (fiat for offramp, crypto for onramp) - * @param amount The amount to convert - * @param direction The direction of the conversion (onramp or offramp) - * @param network Optional network name - * @returns Moonpay price information - */ - static async getMoonpayPrice( - sourceCurrency: Currency, - targetCurrency: Currency, - amount: string, - direction: RampDirection - ): Promise { - const response = await this.getPrice("moonpay", sourceCurrency, targetCurrency, amount, direction); - return response; - } - - /** - * Get price information from Transak - * @param sourceCurrency The source currency (crypto for offramp, fiat for onramp) - * @param targetCurrency The target currency (fiat for offramp, crypto for onramp) - * @param amount The amount to convert - * @param direction The direction of the conversion (onramp or offramp) - * @param network Optional network name - * @returns Transak price information - */ - static async getTransakPrice( - sourceCurrency: Currency, - targetCurrency: Currency, - amount: string, - direction: RampDirection, - network?: string - ): Promise { - const response = await this.getPrice("transak", sourceCurrency, targetCurrency, amount, direction, network); - return response; - } - - /** - * Get price information from all providers - * @param fromCrypto The source cryptocurrency - * @param toFiat The target fiat currency - * @param amount The amount to convert - * @param network Optional network name - * @returns Price information from all providers - */ /** * Get price information from all providers using the bundled endpoint * @param sourceCurrency The source currency (crypto for offramp, fiat for onramp) @@ -132,39 +35,4 @@ export class PriceService { signal }); } - - /** - * @deprecated Use getAllPricesBundled instead for better error handling and performance - * Get price information from all providers - * @param sourceCurrency The source currency (crypto for offramp, fiat for onramp) - * @param targetCurrency The target currency (fiat for offramp, crypto for onramp) - * @param amount The amount to convert - * @param direction The direction of the conversion (onramp or offramp) - * @param network Optional network name - * @returns Price information from all providers - */ - static async getAllPrices( - sourceCurrency: Currency, - targetCurrency: Currency, - amount: string, - direction: RampDirection, - network?: string - ): Promise> { - const providers: PriceProvider[] = ["alchemypay", "moonpay", "transak"]; - - const results = await Promise.allSettled( - providers.map(provider => this.getPrice(provider, sourceCurrency, targetCurrency, amount, direction, network)) - ); - - return results.reduce( - (acc, result, index) => { - const provider = providers[index]; - if (result.status === "fulfilled") { - acc[provider] = result.value; - } - return acc; - }, - {} as Record - ); - } } diff --git a/apps/frontend/src/services/api/ramp.service.ts b/apps/frontend/src/services/api/ramp.service.ts index 3eb372d988..487d7fa620 100644 --- a/apps/frontend/src/services/api/ramp.service.ts +++ b/apps/frontend/src/services/api/ramp.service.ts @@ -1,10 +1,8 @@ import { AccountMeta, - GetRampErrorLogsResponse, GetRampHistoryResponse, GetRampStatusResponse, PresignedTx, - RampProcess, RegisterRampRequest, RegisterRampResponse, StartRampRequest, @@ -82,60 +80,6 @@ export class RampService { return apiRequest("get", `${this.BASE_PATH}/${id}`); } - /** - * Get the error logs for a ramping process - * @param id The ramp ID - * @returns The error logs - */ - static async getRampErrorLogs(id: string): Promise { - return apiRequest("get", `${this.BASE_PATH}/${id}/errors`); - } - - /** - * Poll the status of a ramping process until it reaches a final state - * @param id The ramp ID - * @param onUpdate Callback function to handle status updates - * @param intervalMs Polling interval in milliseconds (default: 3000) - * @param maxAttempts Maximum number of polling attempts (default: 100) - * @returns The final status of the ramp process - */ - static async pollRampStatus( - id: string, - onUpdate?: (status: RampProcess) => void, - intervalMs = 3000, - maxAttempts = 100 - ): Promise { - let attempts = 0; - - const poll = async (): Promise => { - if (attempts >= maxAttempts) { - throw new Error("Maximum polling attempts reached"); - } - - attempts++; - const status = await this.getRampStatus(id); - - if (onUpdate) { - onUpdate(status); - } - - if ( - status.status === "COMPLETE" || - status.status === "FAILED" || - status.currentPhase === "complete" || - status.currentPhase === "failed" || - status.currentPhase === "timedOut" - ) { - return status; - } - - await new Promise(resolve => setTimeout(resolve, intervalMs)); - return poll(); - }; - - return poll(); - } - /** * Get transaction history for a wallet address * @param walletAddress The wallet address diff --git a/apps/frontend/src/services/api/siwe.service.ts b/apps/frontend/src/services/api/siwe.service.ts deleted file mode 100644 index d2679a1ca3..0000000000 --- a/apps/frontend/src/services/api/siwe.service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { CreateSiweRequest, CreateSiweResponse, ValidateSiweRequest, ValidateSiweResponse } from "@vortexfi/shared"; -import { apiRequest } from "./api-client"; - -/** - * Service for interacting with Sign-In with Ethereum (SIWE) API endpoints - */ -export class SiweService { - private static readonly BASE_PATH = "/siwe"; - - /** - * Create a SIWE nonce - * @param walletAddress The user's wallet address - * @returns The nonce - */ - static async createNonce(walletAddress: string): Promise { - const request: CreateSiweRequest = { walletAddress }; - return apiRequest("post", `${this.BASE_PATH}/create`, request); - } - - /** - * Validate a SIWE signature - * @param nonce The nonce - * @param signature The signature - * @param siweMessage The SIWE message - * @returns Validation result - */ - static async validateSignature(nonce: string, signature: string, siweMessage: string): Promise { - const request: ValidateSiweRequest = { - nonce, - signature, - siweMessage - }; - return apiRequest("post", `${this.BASE_PATH}/validate`, request); - } -} diff --git a/apps/frontend/src/services/api/storage.service.ts b/apps/frontend/src/services/api/storage.service.ts deleted file mode 100644 index 1894a704ae..0000000000 --- a/apps/frontend/src/services/api/storage.service.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - AssethubToBrlaStorageRequest, - BrlaToAssethubStorageRequest, - BrlaToEvmStorageRequest, - EvmToBrlaStorageRequest, - OfframpHandlerType, - OnrampHandlerType, - StoreDataRequest, - StoreDataResponse -} from "@vortexfi/shared"; -import { apiRequest } from "./api-client"; - -/** - * Service for interacting with Storage API endpoints - */ -export class StorageService { - private static readonly BASE_PATH = "/storage"; - - /** - * Store data for a specific flow type - * @param request The storage request data - * @returns Success message - */ - static async storeData(request: StoreDataRequest): Promise { - return apiRequest("post", `${this.BASE_PATH}/create`, request); - } - - /** - * Store data for EVM to BRLA flow - * @param data The EVM to BRLA flow data - * @returns Success message - */ - static async storeEvmToBrlaData(data: Omit): Promise { - const request: EvmToBrlaStorageRequest = { - ...data, - flowType: OfframpHandlerType.EVM_TO_BRLA, - timestamp: new Date().toISOString() - }; - return this.storeData(request); - } - - /** - * Store data for Assethub to BRLA flow - * @param data The Assethub to BRLA flow data - * @returns Success message - */ - static async storeAssethubToBrlaData( - data: Omit - ): Promise { - const request: AssethubToBrlaStorageRequest = { - ...data, - flowType: OfframpHandlerType.ASSETHUB_TO_BRLA, - timestamp: new Date().toISOString() - }; - return this.storeData(request); - } - - /** - * Store data for BRLA to EVM flow - * @param data The BRLA to EVM flow data - * @returns Success message - */ - static async storeBrlaToEvmData(data: Omit): Promise { - const request: BrlaToEvmStorageRequest = { - ...data, - flowType: OnrampHandlerType.BRLA_TO_EVM, - timestamp: new Date().toISOString() - }; - return this.storeData(request); - } - - /** - * Store data for BRLA to Assethub flow - * @param data The BRLA to Assethub flow data - * @returns Success message - */ - static async storeBrlaToAssethubData( - data: Omit - ): Promise { - const request: BrlaToAssethubStorageRequest = { - ...data, - flowType: OnrampHandlerType.BRLA_TO_ASSETHUB, - timestamp: new Date().toISOString() - }; - return this.storeData(request); - } -} diff --git a/apps/frontend/src/services/api/subsidize.service.ts b/apps/frontend/src/services/api/subsidize.service.ts deleted file mode 100644 index c0d568bbb8..0000000000 --- a/apps/frontend/src/services/api/subsidize.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - SubsidizePostSwapRequest, - SubsidizePostSwapResponse, - SubsidizePreSwapRequest, - SubsidizePreSwapResponse -} from "@vortexfi/shared"; -import { apiRequest } from "./api-client"; - -/** - * Service for interacting with Subsidize API endpoints - */ -export class SubsidizeService { - private static readonly BASE_PATH = "/subsidize"; - - /** - * Subsidize a pre-swap operation - * @param address The address to subsidize - * @param amountRaw The raw amount to subsidize - * @param tokenToSubsidize The token to subsidize - * @returns Success message - */ - static async subsidizePreSwap( - address: string, - amountRaw: string, - tokenToSubsidize: string - ): Promise { - const request: SubsidizePreSwapRequest = { - address, - amountRaw, - tokenToSubsidize - }; - return apiRequest("post", `${this.BASE_PATH}/preswap`, request); - } - - /** - * Subsidize a post-swap operation - * @param address The address to subsidize - * @param amountRaw The raw amount to subsidize - * @param token The token to subsidize - * @returns Success message - */ - static async subsidizePostSwap(address: string, amountRaw: string, token: string): Promise { - const request: SubsidizePostSwapRequest = { - address, - amountRaw, - token - }; - return apiRequest("post", `${this.BASE_PATH}/postswap`, request); - } -} diff --git a/apps/frontend/src/services/auth.ts b/apps/frontend/src/services/auth.ts index da0fba3d3c..d8644cd5a9 100644 --- a/apps/frontend/src/services/auth.ts +++ b/apps/frontend/src/services/auth.ts @@ -1,4 +1,5 @@ import * as Sentry from "@sentry/react"; +import { decodeJwtExpiryMs } from "@vortexfi/shared"; import { getSupabaseClient } from "../config/supabase"; import { SIGNING_SERVICE_URL } from "../constants/constants"; @@ -70,7 +71,7 @@ export class AuthService { if (!tokens) { return false; } - const expiryMs = this.decodeJwtExpiryMs(tokens.accessToken); + const expiryMs = decodeJwtExpiryMs(tokens.accessToken); return expiryMs === null || expiryMs > Date.now(); } @@ -82,23 +83,7 @@ export class AuthService { if (!tokens) { return null; } - return this.decodeJwtExpiryMs(tokens.accessToken); - } - - private static decodeJwtExpiryMs(token: string): number | null { - try { - const payload = token.split(".")[1]; - if (!payload) { - return null; - } - // JWT segments are base64url and usually unpadded; convert to base64 and re-pad before decoding. - const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); - const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); - const decoded = JSON.parse(atob(padded)) as { exp?: number }; - return typeof decoded.exp === "number" ? decoded.exp * 1000 : null; - } catch { - return null; - } + return decodeJwtExpiryMs(tokens.accessToken); } /** diff --git a/apps/frontend/src/setupTests.ts b/apps/frontend/src/setupTests.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/frontend/src/stores/maintenanceStore.ts b/apps/frontend/src/stores/maintenanceStore.ts index fb994a434b..bd872360aa 100644 --- a/apps/frontend/src/stores/maintenanceStore.ts +++ b/apps/frontend/src/stores/maintenanceStore.ts @@ -10,14 +10,12 @@ interface MaintenanceStore { // Actions fetchMaintenanceStatus: () => Promise; - clearError: () => void; - reset: () => void; } -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds +// Below the hook's 5-minute poll: lastFetched lands after the response, so an equal window skips every other poll. +const CACHE_DURATION = 60 * 1000; export const useMaintenanceStore = create((set, get) => ({ - clearError: () => set({ error: null }), error: null, // Actions @@ -50,22 +48,11 @@ export const useMaintenanceStore = create((set, get) => ({ isLoading: false, lastFetched: null, // Initial state - maintenanceStatus: null, - - reset: () => - set({ - error: null, - isLoading: false, - lastFetched: null, - maintenanceStatus: null - }) + maintenanceStatus: null })); // Selectors for easier access -export const useMaintenanceStatus = () => useMaintenanceStore(state => state.maintenanceStatus); export const useIsMaintenanceActive = () => useMaintenanceStore(state => state.maintenanceStatus?.is_maintenance_active ?? false); export const useMaintenanceDetails = () => useMaintenanceStore(state => state.maintenanceStatus?.maintenance_details ?? null); -export const useMaintenanceLoading = () => useMaintenanceStore(state => state.isLoading); -export const useMaintenanceError = () => useMaintenanceStore(state => state.error); export const useFetchMaintenanceStatus = () => useMaintenanceStore(state => state.fetchMaintenanceStatus); diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index f21eaddbec..0379a17b95 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -45,7 +45,6 @@ interface QuoteState { loading: boolean; error: string | null; // This is either the error message or the key of the translation outputAmount: Big | undefined; - exchangeRate: number; } const friendlyErrorMessages: Record = { @@ -94,7 +93,7 @@ function getFriendlyErrorMessage(error: unknown) { * @param params Quote parameters * @returns Quote payload for API request */ -const createQuotePayload = (params: QuoteParams): QuotePayload => { +export const createQuotePayload = (params: QuoteParams): QuotePayload => { const { inputAmount, onChainToken, fiatToken, selectedNetwork, rampType } = params; const fiatDestination = mapFiatToDestination(fiatToken); const inputAmountStr = inputAmount?.toString() || "0"; @@ -121,23 +120,8 @@ const createQuotePayload = (params: QuoteParams): QuotePayload => { return payloadMap[rampType]; }; -/** - * Calculates exchange rate and output amount from quote response - * @param quoteResponse The API response - * @returns Object containing output amount and exchange rate - */ -const processQuoteResponse = (quoteResponse: QuoteResponse) => { - const outputAmount = parseBig(quoteResponse.outputAmount); - // Calculate exchange rate safely using Big division, converting to Number at the end - const inputAmount = parseBig(quoteResponse.inputAmount); - const exchangeRate = inputAmount.eq(0) ? 0 : Number(outputAmount.div(inputAmount)); - - return { exchangeRate, outputAmount }; -}; - const DEFAULT_QUOTE_STORE_VALUES: QuoteState = { error: null, - exchangeRate: 0, loading: false, outputAmount: undefined, quote: undefined @@ -172,12 +156,9 @@ export const useQuoteStore = create()( partnerId ); - const { outputAmount, exchangeRate } = processQuoteResponse(quoteResponse); - set({ - exchangeRate, loading: false, - outputAmount, + outputAmount: parseBig(quoteResponse.outputAmount), quote: quoteResponse }); } catch (error) { @@ -190,13 +171,11 @@ export const useQuoteStore = create()( } }, forceSetQuote: (quote: QuoteResponse) => { - const { outputAmount, exchangeRate } = processQuoteResponse(quote); - set({ exchangeRate, loading: false, outputAmount, quote }); + set({ loading: false, outputAmount: parseBig(quote.outputAmount), quote }); }, reset: () => { set({ error: null, - exchangeRate: 0, loading: false, outputAmount: undefined, quote: undefined @@ -219,8 +198,6 @@ export const useQuoteStore = create()( ) ); -export const useQuoteOutputAmount = () => useQuoteStore(state => state.outputAmount); -export const useQuoteExchangeRate = () => useQuoteStore(state => state.exchangeRate); export const useQuoteLoading = () => useQuoteStore(state => state.loading); export const useQuoteError = () => useQuoteStore(state => state.error); export const useQuote = () => useQuoteStore(state => state.quote); diff --git a/apps/frontend/src/stores/termsStore.ts b/apps/frontend/src/stores/termsStore.ts index 19a3ca2063..060be02af4 100644 --- a/apps/frontend/src/stores/termsStore.ts +++ b/apps/frontend/src/stores/termsStore.ts @@ -59,11 +59,4 @@ export const useTermsStore = create()((set, get) => ({ termsError: false })); -export const useTermsChecked = () => useTermsStore(state => state.termsChecked); -export const useTermsAccepted = () => useTermsStore(state => state.termsAccepted); -export const useTermsError = () => useTermsStore(state => state.termsError); -export const useTermsAnimationKey = () => useTermsStore(state => state.termsAnimationKey); -export const useTermsIsValid = () => useTermsStore(state => state.isValid); - -export const useTermsActions = () => useTermsStore(state => state.actions); export const useValidateTerms = () => useTermsStore(state => state.actions.validateTerms); diff --git a/apps/frontend/src/test/fakeRampActor.ts b/apps/frontend/src/test/fakeRampActor.ts index a6ccdb875e..e5a54991e9 100644 --- a/apps/frontend/src/test/fakeRampActor.ts +++ b/apps/frontend/src/test/fakeRampActor.ts @@ -2,7 +2,6 @@ import { RampState } from "../types/phases"; interface FakeRampContext { initializeFailedMessage?: string; - isQuoteRedo?: boolean; rampState?: RampState; walletLocked?: string; } diff --git a/apps/frontend/src/tests/netlify-headers.test.ts b/apps/frontend/src/tests/netlify-headers.test.ts new file mode 100644 index 0000000000..99400d5367 --- /dev/null +++ b/apps/frontend/src/tests/netlify-headers.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +// Reads the [[headers]] blocks of netlify.toml: a `for = ""` line followed by `Name = "value"` lines. +const headerRules = readFileSync(new URL("../../netlify.toml", import.meta.url), "utf8") + .split("[[headers]]") + .slice(1) + .map(block => ({ + path: block.match(/^\s*for\s*=\s*"([^"]+)"/m)?.[1], + values: Object.fromEntries( + [...block.matchAll(/^\s*([A-Za-z-]+)\s*=\s*"([^"]*)"\s*$/gm)] + .filter(([, name]) => name !== "for") + .map(([, name, value]) => [name, value]) + ) + })); + +describe("Netlify headers", () => { + it("keeps the gold app out of third-party frames", () => { + expect(headerRules.find(rule => rule.path === "/pt-br/gold/*")?.values).toMatchObject({ + "Content-Security-Policy": "frame-ancestors 'none'", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY" + }); + }); + + it("does not apply the anti-framing headers beyond gold", () => { + expect(headerRules.filter(rule => rule.values["X-Frame-Options"]).map(rule => rule.path)).toEqual(["/pt-br/gold/*"]); + }); +}); diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 7829cc4bef..158e8e5d1b 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1,16 +1,5 @@ { "components": { - "airdropBanner": { - "button": "More info", - "description": "Buy or sell USDT/C from just R$5 or €10 - get 20 USDT for FREE", - "list": { - "1": "<1>All eligible users get 10 USDT - the first 100 receive a 10 USDT bonus, totaling 20 USDT.", - "2": "Earned rewards are sent directly to your wallet. <1>No need to claim!", - "3": "One reward per person, paid out every day - <1>check your status <2>here.", - "4": "Your trust matters - <1>Vortex is <2>open source for full transparency." - }, - "title": "Special offer until 27 May" - }, "alfredpayKycFlow": { "cancel": "Cancel", "completeInNewWindow": "Please complete the {{kycOrKyb}} process in the new window. Once you are done, click 'I have finished the verification'.", @@ -344,7 +333,8 @@ "invalidTransactionParameters": "The transaction parameters provided are invalid. Please verify your inputs.", "networkError": "A network error occurred. Please check your internet connection.", "quoteExpired": "Your quote has expired. Please refresh and request a new quote.", - "serverError": "A server error occurred while processing your request. Please try again later." + "serverError": "A server error occurred while processing your request. Please try again later.", + "startWindowClosed": "This sale expired before your funds were sent. Your funds are still in your wallet. Please start a new sale." }, "retryButton": "Try Again", "title": "Something Went Wrong" @@ -576,9 +566,6 @@ "continue": "Continue", "documentVerifiedAlt": "Document verified" }, - "kycLevel2Toggle": { - "temporarilyDisabled": "Temporarily disabled" - }, "maintenance": { "banner": { "ends": "Expected end", @@ -721,7 +708,6 @@ "openApp": "Open App", "payments": "Payments", "sellCrypto": "Sell Crypto", - "solutions": "Solutions", "toggleMobileMenu": "Toggle mobile menu", "vortexLogo": "Vortex Logo", "widget": "Widget" @@ -903,10 +889,6 @@ "confirmPayment": "I have made the payment", "processing": "Processing" }, - "termsAndConditions": { - "link": "Terms and Conditions", - "text": "I have read and accept the" - }, "transactionId": { "label": "Transaction ID" }, @@ -922,15 +904,6 @@ "useGetRampRegistrationErrorMessage": { "default": "Something went wrong", "quoteNotFound": "The quote is expired. Please try again." - }, - "useSubmitOfframp": { - "cnpjUserDoesntExist": "Please contact our support team at support@vortexfinance.co to get onboarded as a business user.", - "kycInvalid": "Your KYC level is invalid. Please contact support.", - "maintenance": "Temporary maintenance in progress — please check back later." - }, - "useTokenAmountOut": { - "default": "Something went wrong", - "insufficientLiquidity": "Insufficient liquidity for this exchange. Please try a smaller amount or try again later." } }, "menus": { @@ -1588,7 +1561,6 @@ } }, "swap": { - "developedBy": "Developed by", "error": { "ARS_tokenUnavailable": "Improving your ARS exit - back shortly! ", "amountOutOfRange": { @@ -1698,7 +1670,6 @@ "next": "Next" }, "eurRampUnavailable": "We are improving your EUR ramp. It will be back soon.", - "quoteChangedWarning": "Your quote has changed. Please confirm the new output amount before proceeding.", "title": "Enter Details" } } @@ -1763,10 +1734,6 @@ }, "title": "How to sell crypto <1> with Vortex " }, - "pitch": { - "description": "Complete everything in one simple step. With our trusted local partners, enjoy fast, secure transactions and the best rates in the market.", - "title": "Buy & Sell <1>Stablecoins - directly with your Bank Account" - }, "popularTokens": { "countries": { "description": "On and off-ramp in these popular fiat currencies", @@ -1781,12 +1748,6 @@ "title": "Supported Networks" } }, - "popularTokensPartners": { - "circlePartner": "We're an official partner of Circle.", - "joinNetwork": "Join our network of official partners", - "pendulumPowered": "Our infrastructure is powered by Pendulum.", - "title": "Vortex everywhere" - }, "trustedBy": { "comingSoon": "Coming soon", "title": "Trusted by" @@ -1822,14 +1783,10 @@ } }, "toasts": { - "amountMismatch": "Mismatching offramp amounts detected. Please restart the offramp process.", "copyText": "Copied to clipboard", "genericError": "An error occurred. Please try again.", - "kycCompleted": "Success! Get ready to off-ramp.", - "kycVerificationFailed": "Could not verify KYC status. Please try again.", "nodeConnectionError": "Error while connecting to the node. Refresh the page to re-connect.", "rampLimitExceeded": "This amount exceeds the remaining limit of {{remaining}} BRL for your account.", - "signingFailed": "Signing failed. Please try again.", "signingRejected": "Request cancelled", "walletAlreadyOpen": "Wallet already open pending connection. Please try again." } diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 7a817a73fc..cb7ee1e617 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1,16 +1,5 @@ { "components": { - "airdropBanner": { - "button": "Mais informações", - "description": "Compre ou venda USDT/C por apenas R$5 ou €10 - receba 20 USDT de graça!", - "list": { - "1": "<1>Todos os usuários elegíveis recebem 10 USDT - os primeiros 100 recebem um bônus de 10 USDT, totalizando 20 USDT.", - "2": "As recompensas ganhas são enviadas diretamente para sua carteira. <1>Não é necessário reivindicar!", - "3": "Uma recompensa por pessoa, paga diariamente – <1>confira seu status <2>aqui.", - "4": "Sua confiança é importante – <1>o Vortex é <2>open source para total transparência." - }, - "title": "Oferta especial até 27 Maio" - }, "alfredpayKycFlow": { "cancel": "Cancelar", "completeInNewWindow": "Por favor, complete o processo {{kycOrKyb}} na nova janela. Quando terminar, clique no botão abaixo.", @@ -347,7 +336,8 @@ "invalidTransactionParameters": "Os parâmetros da transação fornecidos são inválidos. Verifique suas entradas.", "networkError": "Ocorreu um erro de rede. Verifique sua conexão com a internet.", "quoteExpired": "Sua cotação expirou. Atualize e solicite uma nova cotação.", - "serverError": "Ocorreu um erro no servidor ao processar sua solicitação. Tente novamente mais tarde." + "serverError": "Ocorreu um erro no servidor ao processar sua solicitação. Tente novamente mais tarde.", + "startWindowClosed": "Esta venda expirou antes do envio dos seus fundos. Seus fundos continuam na sua carteira. Inicie uma nova venda." }, "retryButton": "Tentar Novamente", "title": "Algo deu errado" @@ -579,9 +569,6 @@ "continue": "Continuar", "documentVerifiedAlt": "Documento verificado" }, - "kycLevel2Toggle": { - "temporarilyDisabled": "Temporariamente desativado" - }, "maintenance": { "banner": { "ends": "Término previsto", @@ -724,7 +711,6 @@ "openApp": "Abrir App", "payments": "Pagamentos", "sellCrypto": "Vender Cripto", - "solutions": "Soluções", "toggleMobileMenu": "Alternar menu móvel", "vortexLogo": "Logo Vortex", "widget": "Widget" @@ -906,10 +892,6 @@ "confirmPayment": "Eu fiz o pagamento.", "processing": "Processando" }, - "termsAndConditions": { - "link": "Termos e Condições", - "text": "Eu li e aceito os" - }, "transactionId": { "label": "ID da Transação" }, @@ -925,15 +907,6 @@ "useGetRampRegistrationErrorMessage": { "default": "Algo deu errado", "quoteNotFound": "A cotação expirou. Por favor, tente novamente." - }, - "useSubmitOfframp": { - "cnpjUserDoesntExist": "Entre em contato com nossa equipe de suporte em support@vortexfinance.co para começar como usuário empresarial.", - "kycInvalid": "Seu nível de KYC é inválido. Por favor, entre em contato com o suporte.", - "maintenance": "Manutenção temporária em andamento - por favor, volte mais tarde." - }, - "useTokenAmountOut": { - "default": "Algo deu errado", - "insufficientLiquidity": "Insuficiente liquidez para esta troca. Por favor, tente com um valor menor ou tente novamente mais tarde." } }, "menus": { @@ -1592,7 +1565,6 @@ } }, "swap": { - "developedBy": "Desenvolvido por", "error": { "ARS_tokenUnavailable": "Ajustando sua saída ARS - em breve!", "amountOutOfRange": { @@ -1702,7 +1674,6 @@ "next": "Próximo" }, "eurRampUnavailable": "Estamos melhorando a sua rampa de EUR. Ela voltará em breve.", - "quoteChangedWarning": "Sua cotação foi alterada. Por favor, confirme o novo valor de saída antes de continuar.", "title": "Insira os detalhes" } } @@ -1767,10 +1738,6 @@ }, "title": "Como vender criptomoedas online com Vortex" }, - "pitch": { - "description": "Complete tudo em uma única etapa simples. Com nossos parceiros locais confiáveis, desfrute de transações rápidas, seguras e as melhores taxas do mercado.", - "title": "Compre e Venda <1>Stablecoins - diretamente com sua conta bancária" - }, "popularTokens": { "countries": { "description": "Faça on e off-ramp nestas moedas fiduciárias populares", @@ -1785,12 +1752,6 @@ "title": "Redes Suportadas" } }, - "popularTokensPartners": { - "circlePartner": "Somos parceiros oficiais da Circle.", - "joinNetwork": "Junte-se à nossa rede de parceiros oficiais", - "pendulumPowered": "Nossa infraestrutura é alimentada por Pendulum.", - "title": "Vortex em todos os lugares" - }, "trustedBy": { "comingSoon": "Em breve", "title": "Confiado por" @@ -1826,14 +1787,10 @@ } }, "toasts": { - "amountMismatch": "Valores de saque incompatíveis detectados. Por favor, reinicie o processo de saque.", "copyText": "Copiado para a área de transferência", "genericError": "Ocorreu um erro. Por favor, tente novamente.", - "kycCompleted": "Sucesso! Prepare-se para sacar.", - "kycVerificationFailed": "Não foi possível verificar o status do KYC. Por favor, tente novamente.", "nodeConnectionError": "Erro ao conectar ao nó. Atualize a página para reconectar.", "rampLimitExceeded": "Este valor excede o limite restante de {{remaining}} BRL para sua conta.", - "signingFailed": "Falha na assinatura. Por favor, tente novamente.", "signingRejected": "Assinatura rejeitada. Por favor, tente novamente.", "walletAlreadyOpen": "Carteira já aberta aguardando conexão. Por favor, tente novamente." } diff --git a/apps/frontend/src/wagmiConfig.ts b/apps/frontend/src/wagmiConfig.ts index d9e15c1f3d..a0c997bb48 100644 --- a/apps/frontend/src/wagmiConfig.ts +++ b/apps/frontend/src/wagmiConfig.ts @@ -46,7 +46,7 @@ const wagmiAdapter = new WagmiAdapter({ // AppKit registers custom elements and a browser-global modal singleton, so it must not run // during prerender: `__root.tsx` imports this module for `wagmiConfig`, which would otherwise // drag the whole modal into the server bundle for every marketing page. Every consumer of the -// AppKit hooks (EVMWalletButton, SwapSubmitButton, QuoteSubmitButtons) lives under the widget +// AppKit hooks (EVMWalletButton, QuoteSubmitButtons) lives under the widget // route, which is `ssr: false`, so they only ever render after this has run in the browser. if (typeof window !== "undefined") { createAppKit({ diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index cce22a3ede..3656f33d87 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -10,9 +10,6 @@ "module": "ESNext", "moduleResolution": "bundler", "noEmit": true, - "paths": { - "@packages/*": ["../../packages/*/src"] - }, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 74290be418..0651a9ac39 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -2,7 +2,6 @@ import { sentryVitePlugin } from "@sentry/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react"; -import * as path from "path"; import { defineConfig } from "vite"; // Marketing routes are static, so they are prerendered to HTML at build time and served by @@ -83,11 +82,6 @@ export default defineConfig({ project: "vortex" }) ], - resolve: { - alias: { - shared: path.resolve(__dirname, "../shared/dist/esm/index.js") - } - }, server: { host: true } diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts index 4671dde089..382977653e 100644 --- a/apps/frontend/vitest.config.ts +++ b/apps/frontend/vitest.config.ts @@ -14,7 +14,6 @@ export default defineConfig({ "src/stories/**", "src/contracts/**", "src/routeTree.gen.ts", - "src/setupTests.ts", "src/test/**" ], provider: "v8", diff --git a/apps/gold/public/assets/gold-bar-cutout.png b/apps/gold/public/assets/gold-bar-cutout.png deleted file mode 100644 index bf18eefeba..0000000000 Binary files a/apps/gold/public/assets/gold-bar-cutout.png and /dev/null differ diff --git a/apps/gold/public/assets/gold-bar-cutout.webp b/apps/gold/public/assets/gold-bar-cutout.webp new file mode 100644 index 0000000000..01ac87d033 Binary files /dev/null and b/apps/gold/public/assets/gold-bar-cutout.webp differ diff --git a/apps/gold/public/assets/gold-bar-ivory.png b/apps/gold/public/assets/gold-bar-ivory.png deleted file mode 100644 index 017bea03c9..0000000000 Binary files a/apps/gold/public/assets/gold-bar-ivory.png and /dev/null differ diff --git a/apps/gold/public/assets/gold-bar.png b/apps/gold/public/assets/gold-bar.png deleted file mode 100644 index 04fdd161af..0000000000 Binary files a/apps/gold/public/assets/gold-bar.png and /dev/null differ diff --git a/apps/gold/public/brand/ouro-wordmark.png b/apps/gold/public/brand/ouro-wordmark.png index cee7b209f4..0024a35f7f 100644 Binary files a/apps/gold/public/brand/ouro-wordmark.png and b/apps/gold/public/brand/ouro-wordmark.png differ diff --git a/apps/gold/src/App.jsx b/apps/gold/src/App.jsx index ea1a65f173..468a48e893 100644 --- a/apps/gold/src/App.jsx +++ b/apps/gold/src/App.jsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ArrowLeft, ArrowRight, Bank, CaretDown, Check, CheckCircle, Clock, Copy, EnvelopeSimple, Eye, EyeSlash, Fingerprint, Info, LockKey, SealCheck, ShieldCheck, SignOut, TrendUp, Wallet, WarningCircle, X } from "@phosphor-icons/react"; +import { ArrowLeft, ArrowRight, Bank, CaretDown, Check, CheckCircle, Clock, Copy, EnvelopeSimple, Eye, EyeSlash, Fingerprint, Info, LockKey, SealCheck, ShieldCheck, SignOut, TrendDown, TrendUp, Wallet, WarningCircle, X } from "@phosphor-icons/react"; import { SellFlow } from "./SellFlow.jsx"; import { MIN_BUY, QUICK_BUY_VALUES, DEFAULT_BUY, validBuyAmount, buyFeePercent } from "./lib/purchase-options.js"; import { QRCodeSVG } from "qrcode.react"; import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis } from "recharts"; -import { fetchPaxgMarket, getDemoMarket } from "./lib/market.js"; +import { CHART_PERIODS, chartWindow, fetchPaxgMarket, getDemoMarket } from "./lib/market.js"; +import { deleteEphemeralRampKeys } from "./lib/ephemeral-store.js"; import { readPaxgBalance } from "./lib/paxg.js"; -import { addRampHistory, clearActiveRamp, getActiveRamp, getRampHistory, saveActiveRamp } from "./lib/pilot-store.js"; +import { addRampHistory, clearActiveRamp, failActiveRamp, getActiveRamp, getRampHistory, saveActiveRamp } from "./lib/pilot-store.js"; import { classifyRamp, clearVortexSession, @@ -16,10 +17,15 @@ import { getBrazilBuyReadiness, getPaxgAvailability, getBrazilKycUploads, + hasVortexSession, + isValidCpf, + kycOutcome, pollBrazilKyc, pollRamp, + rampStartDeadline, registerPaxgBuy, requestVortexOtp, + secondsUntilExpiry, startRampSafely, submitBrazilKyc, uploadKycDocument, @@ -43,28 +49,32 @@ const IconButton = ({ label, children, className = "", ...props }) =>

COMPRE OURO COM PIX

Comprar ouro
ficou simples.

Escolha um valor. Pague com PIX.
{" "}Receba ouro com lastro físico, direto na sua carteira.

Barra de ouro fino de um quilograma
12,6 g

seu saldo em ouro

A partir de {formatBRL(MIN_BUY, 0)}