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.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 2f013b63d9..26d60a8c90 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -15,6 +15,7 @@ import QuoteTicket from "../../models/quoteTicket.model"; import User from "../../models/user.model"; import { hashTaxReference } from "../services/avenia/avenia-customer.service"; import { SupabaseAuthService } from "../services/auth"; +import { validateSubaccountCreation } from "../middlewares/validators"; import { createSubaccount, createKybDocument, @@ -1861,6 +1862,76 @@ describe("createSubaccount", () => { expect(subaccountInfoMock).toHaveBeenCalledWith("new-subaccount"); }); + // The route runs validateSubaccountCreation ahead of the controller; drive both in order so the + // assertions cover what a real request reaches. + async function submitThroughRoute(body: unknown, userId = "squatter-user") { + const req = { body, userId } as any; + const res = createResponse(); + let validated = false; + validateSubaccountCreation(req, res as any, () => { + validated = true; + }); + if (validated) await createSubaccount(req, res as any); + return res; + } + + it("never reaches the provider or the database for malformed input", async () => { + mockBrlaApi(); + const findOne = mock(async () => null); + ProviderCustomer.findOne = findOne as unknown as typeof ProviderCustomer.findOne; + + const malformed = [ + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "12345678901" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "abc" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: 8786985906 }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "11222333000181" }, + { accountType: AveniaAccountType.COMPANY, name: "Squatter Ltda", taxId: "08786985906" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: " ", taxId: "08786985906" }, + { accountType: AveniaAccountType.INDIVIDUAL, taxId: "08786985906" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "08786985907" } + ]; + for (const body of malformed) { + const res = await submitThroughRoute(body); + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + } + + expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); + expect(FinancialOperation.findOrCreate).not.toHaveBeenCalled(); + expect(sequelize.transaction).not.toHaveBeenCalled(); + expect(findOne).not.toHaveBeenCalled(); + }); + + it("stores the normalized tax id when the client sends a formatted valid CPF", async () => { + mockBrlaApi(); + const providerCreateMock = mock(async (values: Record) => ({ ...values })); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; + ProviderCustomer.create = providerCreateMock as unknown as typeof ProviderCustomer.create; + + const res = await submitThroughRoute({ ...validBody, taxId: "087.869.859-06" }, "new-user"); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(createAveniaSubaccountMock).toHaveBeenCalledTimes(1); + expect(providerCreateMock.mock.calls[0]?.[0]).toMatchObject({ + taxReference: "08786985906", + taxReferenceHash: hashTaxReference("08786985906") + }); + }); + + it("sends the provider the trimmed name the validator measured", async () => { + mockBrlaApi(); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; + ProviderCustomer.create = mock(async (values: Record) => ({ + ...values + })) as unknown as typeof ProviderCustomer.create; + const name = "a".repeat(255); + + const res = await submitThroughRoute({ ...validBody, name: ` ${name} ` }, "new-user"); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(createAveniaSubaccountMock).toHaveBeenCalledWith(AveniaAccountType.INDIVIDUAL, name); + }); + it("rejects overwrite when a started record belongs to another entity", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); @@ -2104,6 +2175,52 @@ describe("newKyc", () => { expect(getInstance).not.toHaveBeenCalled(); }); + describe("tax id binding", () => { + function mockOwnedIndividual() { + CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "individual", + id: "customer-1", + provider: "avenia", + providerSubaccountId: "subaccount-1", + taxReferenceHash: hashTaxReference("08786985906") + })) as unknown as typeof ProviderCustomer.findOne; + const getInstance = mock(() => ({}) as BrlaApiService); + BrlaApiService.getInstance = getInstance; + // Anything past the binding check opens the KYC claim transaction; fail loudly if reached. + const transaction = mock(async () => { + throw new Error("reached the KYC claim"); + }); + sequelize.transaction = transaction as unknown as typeof sequelize.transaction; + return { getInstance, transaction }; + } + + it("rejects a taxIdNumber that differs from the claimed CPF before any provider call", async () => { + const { getInstance, transaction } = mockOwnedIndividual(); + + for (const taxIdNumber of ["52998224725", "", undefined, 8786985906]) { + const res = createResponse(); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error: "taxIdNumber does not match the tax ID claimed for this subaccount." }); + } + expect(getInstance).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("lets a formatted equivalent of the claimed CPF through to the submission", async () => { + const { transaction } = mockOwnedIndividual(); + + const res = createResponse(); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "087.869.859-06" }, userId: "user-1" } as any, res as any); + + expect(transaction).toHaveBeenCalled(); + expect(res.statusCode).not.toBe(httpStatus.BAD_REQUEST); + }); + }); + it("rejects an imported-method case before provider document or submission calls", async () => { CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll; ProviderCustomer.findOne = mock(async () => ({ @@ -2111,7 +2228,8 @@ describe("newKyc", () => { customerType: "individual", id: "customer-1", provider: "avenia", - providerSubaccountId: "subaccount-1" + providerSubaccountId: "subaccount-1", + taxReferenceHash: hashTaxReference("08786985906") })) as unknown as typeof ProviderCustomer.findOne; KycCase.findAll = mock(async () => [{ id: "case-1", verificationMethod: "sumsub_share_token" }]) as unknown as typeof KycCase.findAll; sequelize.transaction = mock(async callback => @@ -2129,7 +2247,7 @@ describe("newKyc", () => { ); const res = createResponse(); - await newKyc({ body: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "08786985906" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.CONFLICT); expect(getUploadedDocuments).not.toHaveBeenCalled(); @@ -2146,6 +2264,7 @@ describe("newKyc", () => { provider: "avenia", providerSubaccountId: "subaccount-1", status: VerificationStatus.InReview, + taxReferenceHash: hashTaxReference("08786985906"), update: customerUpdate }; ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; @@ -2203,6 +2322,7 @@ describe("newKyc", () => { { body: { subAccountId: "subaccount-1", + taxIdNumber: "087.869.859-06", uploadedDocumentId: "document-1", uploadedSelfieId: "selfie-1" }, @@ -2297,6 +2417,7 @@ describe("Avenia API KYB", () => { providerSubaccountId: "subaccount-1", status: VerificationStatus.Pending, statusExternal: null, + taxReferenceHash: hashTaxReference(validSubmission.taxIdentificationNumberTin), update })) as unknown as typeof ProviderCustomer.findOne; return update; @@ -2430,6 +2551,45 @@ describe("Avenia API KYB", () => { expect(createUbo).not.toHaveBeenCalled(); }); + it("rejects a TIN that differs from the claimed CNPJ before any provider call", async () => { + const { customerUpdate, submit } = mockInitialSubmission(); + const getInstance = mock(() => ({ submitKybLevel1: submit }) as unknown as BrlaApiService); + BrlaApiService.getInstance = getInstance; + + const res = createResponse(); + await submitKybLevel1Api( + { + body: { ...validSubmission, taxIdentificationNumberTin: "11222333000181" }, + query: { subAccountId: "subaccount-1" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error: "taxIdentificationNumberTin does not match the tax ID claimed for this subaccount." }); + expect(getInstance).not.toHaveBeenCalled(); + expect(submit).not.toHaveBeenCalled(); + expect(customerUpdate).not.toHaveBeenCalled(); + }); + + it("accepts a formatted TIN equivalent to the claimed CNPJ", async () => { + const { submit } = mockInitialSubmission(); + + const res = createResponse(); + await submitKybLevel1Api( + { + body: { ...validSubmission, taxIdentificationNumberTin: "42.731.085/0001-67" }, + query: { subAccountId: "subaccount-1" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(submit).toHaveBeenCalledTimes(1); + }); + it("submits ready company documents and persists the pending attempt", async () => { const { caseUpdate, customerUpdate, submit } = mockInitialSubmission(); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 7fa04159d7..e6af6c20ba 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); @@ -371,7 +368,9 @@ export const createSubaccount = async ( res: Response ): Promise => { try { - const { name, taxId, accountType: requestAccountType } = req.body; + const { taxId, accountType: requestAccountType } = req.body; + // validateSubaccountCreation bounded the trimmed name, so every use below sends that same value. + const name = req.body.name.trim(); const effectiveUserId = getEffectiveUserId(req); // Reject callers that do not resolve to a user (anonymous requests @@ -449,7 +448,7 @@ export const createSubaccount = async ( provider: "avenia", request: { accountType, - name: name.trim(), + name, ownerProfileId: effectiveUserId, taxReferenceHash }, @@ -459,7 +458,7 @@ export const createSubaccount = async ( let companyName: string | null = null; if (accountType === AveniaAccountType.COMPANY) { - companyName = name.trim(); + companyName = name; try { const account = await brlaApiService.subaccountInfo(id); companyName = account?.accountInfo.name?.trim() || account?.accountInfo.fullName?.trim() || companyName; @@ -906,6 +905,12 @@ export const newKyc = async ( res.status(httpStatus.BAD_REQUEST).json({ error: "Individual KYC requires an individual customer account." }); return; } + // The provider approves whoever the submitted documents belong to; the CPF claimed at + // createSubaccount must be that same identity, or the approval would attach to the wrong tax id. + if (typeof req.body.taxIdNumber !== "string" || hashTaxReference(req.body.taxIdNumber) !== record.taxReferenceHash) { + res.status(httpStatus.BAD_REQUEST).json({ error: "taxIdNumber does not match the tax ID claimed for this subaccount." }); + return; + } const response = await submitStandardAveniaKyc({ actorProfileId, @@ -1068,6 +1073,13 @@ export const submitKybLevel1Api = async ( ): Promise => { try { const record = await resolveAveniaKybAccount(req, req.query.subAccountId); + // Same binding as newKyc: the submitted TIN must be the CNPJ claimed for this subaccount. + if (hashTaxReference(req.body.taxIdentificationNumberTin) !== record.taxReferenceHash) { + res + .status(httpStatus.BAD_REQUEST) + .json({ error: "taxIdentificationNumberTin does not match the tax ID claimed for this subaccount." }); + return; + } const subAccountId = record.providerSubaccountId as string; const brlaApiService = BrlaApiService.getInstance(); if (record.status === VerificationStatus.Approved) { 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.test.ts b/apps/api/src/api/middlewares/validators.test.ts index 97e9e5f485..30ff2426c6 100644 --- a/apps/api/src/api/middlewares/validators.test.ts +++ b/apps/api/src/api/middlewares/validators.test.ts @@ -1,4 +1,4 @@ -import { BrDocumentType, Networks, QuoteError, RampDirection } from "@vortexfi/shared"; +import { AveniaAccountType, BrDocumentType, Networks, QuoteError, RampDirection } from "@vortexfi/shared"; import { describe, expect, it, mock } from "bun:test"; import type { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; @@ -9,7 +9,8 @@ import { validateAveniaKybLevel1, validateAveniaKybUbo, validateCreateBestQuoteInput, - validateKycSubmission + validateKycSubmission, + validateSubaccountCreation } from "./validators"; function buildRes() { @@ -283,3 +284,84 @@ describe("validateKycSubmission", () => { expect(res.statusCode).toBeUndefined(); }); }); + +describe("validateSubaccountCreation", () => { + // Synthetic identifiers with valid check digits (never real people or companies). + const VALID_CPF = "52998224725"; + const VALID_CPF_FORMATTED = "529.982.247-25"; + const VALID_CNPJ = "11222333000181"; + const VALID_CNPJ_FORMATTED = "11.222.333/0001-81"; + + function validate(body: unknown) { + const req = { body } as unknown as Request; + const res = buildRes(); + const next = mock(() => undefined) as unknown as NextFunction; + + validateSubaccountCreation(req, res, next); + + return { next, res }; + } + + function expectRejected(body: unknown, error: string) { + const { next, res } = validate(body); + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error }); + expect(next).not.toHaveBeenCalled(); + } + + const individual = { accountType: AveniaAccountType.INDIVIDUAL, name: "Ana Maria Silva", taxId: VALID_CPF }; + const company = { accountType: AveniaAccountType.COMPANY, name: "Acme Ltda", taxId: VALID_CNPJ }; + + it("accepts a valid CPF, plain or formatted, for an individual", () => { + for (const taxId of [VALID_CPF, VALID_CPF_FORMATTED, ` ${VALID_CPF} `]) { + const { next, res } = validate({ ...individual, taxId }); + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBeUndefined(); + } + }); + + it("accepts a valid CNPJ, plain or formatted, for a company", () => { + for (const taxId of [VALID_CNPJ, VALID_CNPJ_FORMATTED]) { + const { next, res } = validate({ ...company, taxId }); + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBeUndefined(); + } + }); + + it("rejects checksum-invalid and trivial identifiers", () => { + for (const taxId of ["52998224724", "12345678901", "11111111111", "abc", "", "529.982.247-2"]) { + expectRejected({ ...individual, taxId }, "taxId must be a valid CPF for INDIVIDUAL accounts."); + } + expectRejected({ ...company, taxId: "11222333000182" }, "taxId must be a valid CNPJ for COMPANY accounts."); + }); + + it("rejects a CPF for a company and a CNPJ for an individual", () => { + expectRejected({ ...company, taxId: VALID_CPF }, "taxId must be a valid CNPJ for COMPANY accounts."); + expectRejected({ ...individual, taxId: VALID_CNPJ }, "taxId must be a valid CPF for INDIVIDUAL accounts."); + }); + + it("rejects a missing or non-string taxId", () => { + for (const taxId of [undefined, null, 52998224725, [VALID_CPF], { value: VALID_CPF }]) { + expectRejected({ ...individual, taxId }, "taxId must be a valid CPF for INDIVIDUAL accounts."); + } + }); + + it("rejects a missing, non-string, blank or oversized name", () => { + const error = "name must be a non-empty string of at most 255 characters."; + for (const name of [undefined, null, 42, ["Ana"], "", " ", "x".repeat(256)]) { + expectRejected({ ...individual, name }, error); + } + const { next } = validate({ ...individual, name: "x".repeat(255) }); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("rejects an unknown or missing accountType before looking at the other fields", () => { + for (const accountType of [undefined, "BUSINESS", 1]) { + expectRejected({ ...individual, accountType }, "Invalid accountType."); + } + }); + + it("rejects a missing body instead of throwing", () => { + expectRejected(undefined, "Invalid accountType."); + }); +}); diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 5d17411350..6b05bf3820 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -1,4 +1,5 @@ import { + AveniaAccountType, BrDocumentType, BrKYCDataUploadRequest, BrKybLevel1Payload, @@ -12,6 +13,7 @@ import { getCaseSensitiveNetwork, isSupportedFiatCurrency, isValidAveniaAccountType, + isValidCnpj, isValidCpf, isValidCurrencyForDirection, isValidDirection, @@ -23,7 +25,6 @@ import { RampDirection, SubmitKybInformationRequest, SubmitKycInformationRequest, - TokenConfig, VALID_CRYPTO_CURRENCIES, VALID_FIAT_CURRENCIES, VALID_PROVIDERS @@ -61,12 +62,6 @@ interface ChangeOpBody extends CreationBody { paymentData: unknown; } -interface SwapBody { - amountRaw: string; - address: string; - token?: keyof TokenConfig; -} - interface SiweCreateBody { walletAddress: string; } @@ -274,54 +269,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; @@ -354,16 +301,36 @@ export const validateSiweValidate: RequestHandler = (req, res, next) => { next(); }; +// provider_customers.company_name is VARCHAR(255); the controller stores the trimmed name there after the provider call. +const SUBACCOUNT_NAME_MAX_LENGTH = 255; + +// Runs before any provider call or DB write: a subaccount reserves its tax id exclusively, so only a +// well-formed CPF (INDIVIDUAL) or CNPJ (COMPANY) may reach the controller. export const validateSubaccountCreation: RequestHandler = (req, res, next) => { - const { accountType } = req.body as CreateAveniaSubaccountRequest; + const { accountType, name, taxId } = (req.body ?? {}) as Partial>; - if (!accountType || !isValidAveniaAccountType(accountType)) { + if (typeof accountType !== "string" || !isValidAveniaAccountType(accountType)) { res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid accountType." }); return; } + if (typeof name !== "string" || name.trim().length === 0 || name.trim().length > SUBACCOUNT_NAME_MAX_LENGTH) { + res.status(httpStatus.BAD_REQUEST).json({ + error: `name must be a non-empty string of at most ${SUBACCOUNT_NAME_MAX_LENGTH} characters.` + }); + return; + } + + const isCompany = accountType === AveniaAccountType.COMPANY; + if (typeof taxId !== "string" || !(isCompany ? isValidCnpj(taxId.trim()) : isValidCpf(taxId.trim()))) { + res.status(httpStatus.BAD_REQUEST).json({ + error: `taxId must be a valid ${isCompany ? "CNPJ" : "CPF"} for ${accountType} accounts.` + }); + return; + } + next(); }; 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__/squid-router-pay.executor.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts index 749ed76ce6..0eb48b308e 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/squid-router-pay.executor.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; import * as sharedNamespace from "@vortexfi/shared"; import { Networks } from "@vortexfi/shared"; import Big from "big.js"; +import { decodeFunctionData, keccak256, parseAbi } from "viem"; import type QuoteTicket from "../../../../../models/quoteTicket.model"; import type RampState from "../../../../../models/rampState.model"; import * as financialOperationNamespace from "../core/financial-operation"; @@ -22,6 +23,9 @@ const recoverAxelarStuckConfirm = mock(async (..._args: unknown[]) => "AXELAR_RE const checkEvmBalanceForToken = mock(async (..._args: unknown[]) => new Big("900100")); const estimateFeesPerGas = mock(async () => ({ maxFeePerGas: 10n, maxPriorityFeePerGas: 3n })); const sendTransaction = mock(async (_transaction: Record) => "0xgasfunding" as `0x${string}`); +const estimateGas = mock(async (..._args: unknown[]) => 318_000n); +const getBalance = mock(async (..._args: unknown[]) => 10n ** 18n); +const waitForTransactionReceipt = mock(async (..._args: unknown[]) => ({ status: "success" })); const fundingAccount = { address: "0x1111111111111111111111111111111111111111" as `0x${string}` }; mock.module("@vortexfi/shared", () => ({ @@ -31,8 +35,10 @@ mock.module("@vortexfi/shared", () => ({ getClient: () => ({ chain: {}, estimateFeesPerGas, + estimateGas, + getBalance, getTransactionCount: async () => 0, - waitForTransactionReceipt: async () => ({ status: "success" }) + waitForTransactionReceipt }), getWalletClient: () => ({ account: fundingAccount, sendTransaction }) }) @@ -62,6 +68,12 @@ beforeEach(() => { checkEvmBalanceForToken.mockClear(); estimateFeesPerGas.mockClear(); sendTransaction.mockClear(); + estimateGas.mockClear(); + getBalance.mockClear(); + waitForTransactionReceipt.mockClear(); + estimateGas.mockImplementation(async () => 318_000n); + sendTransaction.mockImplementation(async () => "0xgasfunding" as `0x${string}`); + waitForTransactionReceipt.mockImplementation(async () => ({ status: "success" })); getStatus.mockImplementation(async () => ({ id: "", isGMPTransaction: true, @@ -317,4 +329,299 @@ describe("SquidRouterPayExecutor reliability", () => { await handler.executeFundTransaction(makeState(), Networks.Base, "1", SWAP_HASH, 1, "initial-gas-payment"); expect(sendTransaction.mock.calls[1]?.[0]).toMatchObject({ maxFeePerGas: 20n, maxPriorityFeePerGas: 6n }); }); + + it("searches Axelarscan once per poll when the Squid status falls back to it", async () => { + getStatus + .mockImplementationOnce(async () => { + throw new Error("squid unavailable"); + }) + .mockImplementationOnce(async () => ({ + id: "", + isGMPTransaction: true, + routeStatus: [], + squidTransactionStatus: "", + status: "success" + })); + getStatusAxelarScan.mockImplementation(async () => ({ id: `${SWAP_HASH}_17_1`, status: "approved" }) as never); + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.initialDelayMs = 0; + handler.pollIntervalMs = 0; + handler.stuckAlertThresholdMs = Number.POSITIVE_INFINITY; + + await handler.checkBridgeStatus(makeState(), SWAP_HASH, makeQuote(Networks.Base), 1000); + + expect(getStatusAxelarScan).toHaveBeenCalledTimes(1); + }); +}); + +const SQUID_ROUTER = "0xce16F69375520ab01377ce7B88f5BA8C48F8D666"; +const PAYLOAD = "0x00000000000000000000000000000000000000000000000000000000000000400000" as const; +const COMMAND_ID = "0x2d130523637b387cd09fa4859e8a4b8210a08a5d7247b67d6054aabdfd25ee01"; + +// Shape of the 2026-10-05/06 incidents: approved on BSC, relayer never executed. +function approvedNotExecutedStatus(overrides: Record = {}) { + return { + approved: { + block_timestamp: Math.floor(Date.now() / 1000) - 30 * 60, + returnValues: { + contractAddress: SQUID_ROUTER, + payloadHash: keccak256(PAYLOAD), + sourceAddress: SQUID_ROUTER, + sourceChain: "base" + } + }, + call: { + chain: "base", + event: "ContractCallWithToken", + returnValues: { amount: "15702688", payload: PAYLOAD, symbol: "axlUSDC" } + }, + command_id: COMMAND_ID, + gas_status: null, + id: `${SWAP_HASH}_17_1`, + is_insufficient_fee: false, + no_gas_remain: true, + status: "executing", + ...overrides + } as never; +} + +function makeExecuteHandler(claimedRows = 1) { + const sendMessage = mock(async (_message: { text: string }) => undefined); + const handler = Object.create(SquidRouterPayExecutor.prototype) as any; + handler.stuckAlertThresholdMs = 0; + handler.slackNotifier = { sendMessage }; + handler.patchStateKey = mock(async (target: RampState, key: string, value: string) => { + if (claimedRows === 0) return 0; + target.state = { ...target.state, [key]: value }; + return 1; + }); + return { handler, sendMessage }; +} + +describe("SquidRouterPayExecutor approved-not-executed recovery", () => { + it("executes the approved call once on the destination chain and reports it", async () => { + sendTransaction.mockImplementation(async () => "0xexecute" as `0x${string}`); + const state = makeState(); + const { handler, sendMessage } = makeExecuteHandler(); + + await handler.monitorStuckGmp(state, SWAP_HASH, makeQuote(Networks.Base), approvedNotExecutedStatus()); + await handler.monitorStuckGmp(state, SWAP_HASH, makeQuote(Networks.Base), approvedNotExecutedStatus()); + + expect(sendTransaction).toHaveBeenCalledTimes(1); + const tx = sendTransaction.mock.calls[0]![0] as { data: `0x${string}`; to: string; gas: bigint; value?: bigint }; + expect(tx.to).toBe(SQUID_ROUTER); + expect(tx.value).toBeUndefined(); + expect(tx.gas).toBe((318_000n * 6n) / 5n); + const decoded = decodeFunctionData({ + abi: parseAbi([ + "function executeWithToken(bytes32 commandId, string sourceChain, string sourceAddress, bytes payload, string tokenSymbol, uint256 amount)" + ]), + data: tx.data + }); + expect(decoded.args).toEqual([COMMAND_ID, "base", SQUID_ROUTER, PAYLOAD, "axlUSDC", 15702688n]); + expect(handler.patchStateKey).toHaveBeenCalledWith( + state, + "squidRouterAxelarExecuteTxHash", + "pending", + `state->>'squidRouterAxelarExecuteTxHash' IS NULL` + ); + expect(state.state.squidRouterAxelarExecuteTxHash).toBe("0xexecute"); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage.mock.calls[0]![0].text).toContain("classification: approved_not_executed"); + expect(sendMessage.mock.calls[0]![0].text).toContain("executed the approved call on base (0xexecute)"); + }); + + it("uses execute() for a ContractCall without tokens", async () => { + const { handler } = makeExecuteHandler(); + const status = approvedNotExecutedStatus({ call: { chain: "base", event: "ContractCall", returnValues: { payload: PAYLOAD } } }); + + await handler.monitorStuckGmp(makeState(), SWAP_HASH, makeQuote(Networks.Base), status); + + const tx = sendTransaction.mock.calls[0]![0] as { data: `0x${string}` }; + const decoded = decodeFunctionData({ + abi: parseAbi(["function execute(bytes32 commandId, string sourceChain, string sourceAddress, bytes payload)"]), + data: tx.data + }); + expect(decoded.args).toEqual([COMMAND_ID, "base", SQUID_ROUTER, PAYLOAD]); + }); + + it("does not execute before the stuck threshold", async () => { + const { handler, sendMessage } = makeExecuteHandler(); + handler.stuckAlertThresholdMs = Number.POSITIVE_INFINITY; + + await handler.monitorStuckGmp(makeState(), SWAP_HASH, makeQuote(Networks.Base), approvedNotExecutedStatus()); + + expect(sendTransaction).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("does not execute when the call was already executed or the relayer is executing", async () => { + const { handler } = makeExecuteHandler(); + + await handler.monitorStuckGmp( + makeState(), + SWAP_HASH, + makeQuote(Networks.Base), + approvedNotExecutedStatus({ executed: { transactionHash: "0xrelayer" }, status: "executed" }) + ); + await handler.monitorStuckGmp( + makeState(), + SWAP_HASH, + makeQuote(Networks.Base), + approvedNotExecutedStatus({ executing: { transactionHash: "0xrelayer" } }) + ); + + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("does not execute when the processor aborted the execution", async () => { + const { handler } = makeExecuteHandler(); + const controller = new AbortController(); + controller.abort(new Error("phase timed out")); + + await handler.monitorStuckGmp(makeState(), SWAP_HASH, makeQuote(Networks.Base), approvedNotExecutedStatus(), controller.signal); + + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("does not execute when a concurrent execution holds the claim", async () => { + const { handler } = makeExecuteHandler(0); + + const outcome = await handler.maybeExecuteApprovedGmp(makeState(), makeQuote(Networks.Base), approvedNotExecutedStatus()); + + expect(outcome).toContain("claimed by a concurrent execution"); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("refuses to send and alerts when the payload does not match the approved hash", async () => { + const { handler, sendMessage } = makeExecuteHandler(); + const status = approvedNotExecutedStatus({ + approved: { block_timestamp: 0, returnValues: { contractAddress: SQUID_ROUTER, payloadHash: keccak256("0x1234"), sourceAddress: SQUID_ROUTER, sourceChain: "base" } } + }); + + await handler.monitorStuckGmp(makeState(), SWAP_HASH, makeQuote(Networks.Base), status); + + expect(sendTransaction).not.toHaveBeenCalled(); + expect(handler.patchStateKey).not.toHaveBeenCalledWith(expect.anything(), "squidRouterAxelarExecuteTxHash", "pending", expect.anything()); + expect(sendMessage.mock.calls[0]![0].text).toContain("refusing to execute: payload does not match"); + }); + + it("refuses to send and alerts when the simulation reverts", async () => { + estimateGas.mockImplementation(async () => { + throw new Error("execution reverted: NotApprovedByGateway"); + }); + const { handler, sendMessage } = makeExecuteHandler(); + + await handler.monitorStuckGmp(makeState(), SWAP_HASH, makeQuote(Networks.Base), approvedNotExecutedStatus()); + + expect(sendTransaction).not.toHaveBeenCalled(); + expect(sendMessage.mock.calls[0]![0].text).toContain("refusing to execute: simulation failed"); + }); + + it("does not claim when the funding wallet lacks native gas on the destination chain", async () => { + getBalance.mockImplementationOnce(async () => 0n); + const state = makeState(); + const { handler } = makeExecuteHandler(); + + const outcome = await handler.maybeExecuteApprovedGmp(state, makeQuote(Networks.Base), approvedNotExecutedStatus()); + + expect(outcome).toContain("lacks native gas on base"); + expect(state.state.squidRouterAxelarExecuteTxHash).toBeUndefined(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("uses the approved destination token when it differs from the source call", async () => { + const { handler } = makeExecuteHandler(); + const status = approvedNotExecutedStatus({ + approved: { + block_timestamp: 0, + returnValues: { + amount: "15702688", + contractAddress: SQUID_ROUTER, + payloadHash: keccak256(PAYLOAD), + sourceAddress: SQUID_ROUTER, + sourceChain: "base", + symbol: "USDC" + } + } + }); + + await handler.maybeExecuteApprovedGmp(makeState(), makeQuote(Networks.Base), status); + + const tx = sendTransaction.mock.calls[0]![0] as { data: `0x${string}` }; + const decoded = decodeFunctionData({ + abi: parseAbi([ + "function executeWithToken(bytes32 commandId, string sourceChain, string sourceAddress, bytes payload, string tokenSymbol, uint256 amount)" + ]), + data: tx.data + }); + expect(decoded.args[4]).toBe("USDC"); + }); + + it("refuses to send when the estimated gas exceeds the cap", async () => { + estimateGas.mockImplementation(async () => 1_600_001n); + const state = makeState(); + const { handler } = makeExecuteHandler(); + + const outcome = await handler.maybeExecuteApprovedGmp(state, makeQuote(Networks.Base), approvedNotExecutedStatus()); + + expect(outcome).toContain("exceeds 1600000"); + expect(state.state.squidRouterAxelarExecuteTxHash).toBeUndefined(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("keeps the claim and never resends when the send fails after claiming", async () => { + sendTransaction.mockImplementation(async () => { + throw new Error("rpc dropped the request"); + }); + const state = makeState(); + const { handler } = makeExecuteHandler(); + + const first = await handler.maybeExecuteApprovedGmp(state, makeQuote(Networks.Base), approvedNotExecutedStatus()); + const second = await handler.maybeExecuteApprovedGmp(state, makeQuote(Networks.Base), approvedNotExecutedStatus()); + + expect(first).toContain("failed after claim"); + expect(second).toContain("unknown outcome; not retrying"); + expect(state.state.squidRouterAxelarExecuteTxHash).toBe("pending"); + expect(sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("keeps the claim when the execute reverts on-chain", async () => { + waitForTransactionReceipt.mockImplementation(async () => ({ status: "reverted" })); + const state = makeState(); + const { handler } = makeExecuteHandler(); + + const outcome = await handler.maybeExecuteApprovedGmp(state, makeQuote(Networks.Base), approvedNotExecutedStatus()); + + expect(outcome).toContain("failed after claim"); + expect(outcome).toContain("reverted"); + expect(state.state.squidRouterAxelarExecuteTxHash).toBe("pending"); + }); + + it("does not send when an earlier execution left a pending claim", async () => { + const { handler } = makeExecuteHandler(); + + const outcome = await handler.maybeExecuteApprovedGmp( + makeState({ squidRouterAxelarExecuteTxHash: "pending" }), + makeQuote(Networks.Base), + approvedNotExecutedStatus() + ); + + expect(outcome).toContain("unknown outcome; not retrying"); + expect(handler.patchStateKey).not.toHaveBeenCalled(); + expect(estimateGas).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("does not execute for a non-EVM destination", async () => { + const { handler } = makeExecuteHandler(); + const quote = { ...makeQuote(Networks.Base), to: Networks.AssetHub } as unknown as QuoteTicket; + (quote.metadata as any).blocks.squidRouterSwap.toNetwork = Networks.AssetHub; + + const outcome = await handler.maybeExecuteApprovedGmp(makeState(), quote, approvedNotExecutedStatus()); + + expect(outcome).toContain("not an EVM chain"); + expect(estimateGas).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); }); 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/compatibility-scope.test.ts b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts index 004fe9ce5f..2f80142e17 100644 --- a/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "bun:test"; +import { RampDirection } from "@vortexfi/shared"; import { Op } from "sequelize"; import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../../../../constants/constants"; -import { getPersistedBlockFlowCompatibilityScope } from "./compatibility-scope"; +import { getFundedInitialSellRampWhere, getPersistedBlockFlowCompatibilityScope } from "./compatibility-scope"; + +const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; describe("persisted block-flow compatibility scope", () => { it("scopes pending quotes and resumable ramps to the current flow variant", () => { @@ -19,9 +22,28 @@ describe("persisted block-flow compatibility scope", () => { [Op.or]: [ { currentPhase: { [Op.notIn]: ["complete", "failed", "timedOut", "initial"] } }, { createdAt: { [Op.gte]: initialRampCutoff }, currentPhase: "initial" }, - { currentPhase: "initial", "state.aveniaTicketId": { [Op.ne]: null } } + { currentPhase: "initial", "state.aveniaTicketId": { [Op.ne]: null } }, + // Funded SELL ramps the recovery worker may start past the client window. + { + createdAt: { [Op.gt]: new Date(now.getTime() - THREE_DAYS_MS), [Op.lt]: now }, + currentPhase: "initial", + type: RampDirection.SELL, + [Op.or]: [ + { "state.squidRouterSwapHash": { [Op.ne]: null } }, + { "state.squidRouterNoPermitTransferHash": { [Op.ne]: null } } + ] + } ] } }); }); + + it("selects funded SELL ramps only between the minimum age and the three-day recovery window", () => { + const now = new Date("2026-07-31T12:00:00.000Z"); + + expect(getFundedInitialSellRampWhere(now, 16 * 60 * 1000).createdAt).toEqual({ + [Op.gt]: new Date(now.getTime() - THREE_DAYS_MS), + [Op.lt]: new Date(now.getTime() - 16 * 60 * 1000) + }); + }); }); diff --git a/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts index c3b18ddbf9..f4246fc8e4 100644 --- a/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts +++ b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts @@ -1,17 +1,43 @@ +import { RampDirection } from "@vortexfi/shared"; import { Op } from "sequelize"; import type { FlowVariant } from "../../../../../config/vars"; import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../../../../constants/constants"; const TERMINAL_RAMP_PHASES = ["complete", "failed", "timedOut"] as const; +const FUNDED_SELL_RECOVERY_WINDOW_MS = 3 * 24 * 60 * 60 * 1000; + +/** + * `initial` SELL ramps whose user-broadcast source transaction hash was already reported, created + * within the recovery window and at least `minAgeMs` ago. The user's funds are on the ephemeral + * once that transaction mines, so the recovery worker starts these past the client start window. + * The worker selects with this predicate and the startup check keeps their flow versions + * registered, so the two cannot drift apart. + */ +export function getFundedInitialSellRampWhere(now = new Date(), minAgeMs = 0) { + return { + createdAt: { + [Op.gt]: new Date(now.getTime() - FUNDED_SELL_RECOVERY_WINDOW_MS), + [Op.lt]: new Date(now.getTime() - minAgeMs) + }, + currentPhase: "initial" as const, + type: RampDirection.SELL, + [Op.or]: [ + { "state.squidRouterSwapHash": { [Op.ne]: null } }, + { "state.squidRouterNoPermitTransferHash": { [Op.ne]: null } } + ] + }; +} /** * Selects only persisted state that this backend could still execute. * * A registered ramp remains in `initial` until startRamp is called. Both updateRamp - * and the public startRamp reject it after the shared expiration window. Avenia ramps - * are the exception: registration creates a payable PIX ticket, and the recovery - * worker may start an expired initial ramp after the provider confirms payment. Those - * rows therefore remain deployment dependencies. Once a ramp has entered a financial + * and the public startRamp reject it after the shared expiration window. Two kinds of + * ramp are the exception: Avenia registration creates a payable PIX ticket, and the + * recovery worker may start an expired initial ramp after the provider confirms payment; + * and a SELL ramp whose user already reported its source transaction hash is started by + * the same worker (see getFundedInitialSellRampWhere). Those rows therefore remain + * deployment dependencies. Once a ramp has entered a financial * phase, age never makes it safe to ignore: every non-terminal phase owned by this flow * variant stays fail-closed. */ @@ -29,7 +55,8 @@ export function getPersistedBlockFlowCompatibilityScope(flowVariant: FlowVariant [Op.or]: [ { currentPhase: { [Op.notIn]: [...TERMINAL_RAMP_PHASES, "initial"] } }, { createdAt: { [Op.gte]: initialRampCutoff }, currentPhase: "initial" }, - { currentPhase: "initial", "state.aveniaTicketId": { [Op.ne]: null } } + { currentPhase: "initial", "state.aveniaTicketId": { [Op.ne]: null } }, + getFundedInitialSellRampWhere(now) ] } }; 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/squid-router-swap/execution.ts b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts index e1debfde21..ead4199599 100644 --- a/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/squid-router-swap/execution.ts @@ -28,7 +28,7 @@ import { } from "@vortexfi/shared"; import { Big } from "big.js"; import { QueryTypes } from "sequelize"; -import { encodeFunctionData, Hash } from "viem"; +import { encodeFunctionData, Hash, Hex, keccak256, parseAbi } from "viem"; import logger from "../../../../../../config/logger"; import { axelarGasServiceAbi } from "../../../../../../contracts/AxelarGasService"; import QuoteTicket from "../../../../../../models/quoteTicket.model"; @@ -54,6 +54,11 @@ const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; const AXELAR_CONFIRM_RECOVERY_COOLDOWN_MS = 10 * 60 * 1000; const STUCK_ALERT_REPEAT_MS = 6 * 60 * 60 * 1000; const EXTRA_GAS_PENDING_MARKER = "pending"; +const AXELAR_EXECUTE_PENDING_MARKER = "pending"; +const AXELAR_EXECUTABLE_ABI = parseAbi([ + "function execute(bytes32 commandId, string sourceChain, string sourceAddress, bytes payload)", + "function executeWithToken(bytes32 commandId, string sourceChain, string sourceAddress, bytes payload, string tokenSymbol, uint256 amount)" +]); const STATUS_REQUEST_TIMEOUT_MS = 30000; const DESTINATION_BALANCE_FALLBACK_MIN_RATIO_BPS = 9000; @@ -61,6 +66,8 @@ type TerminalBridgeEvidence = Pick { + const previous = state.state.squidRouterAxelarExecuteTxHash; + if (previous === AXELAR_EXECUTE_PENDING_MARKER) { + return "destination execute previously attempted with unknown outcome; not retrying; check the funding wallet's transactions manually"; + } + if (previous) { + return `destination execute already sent (${previous}); not sending again`; + } + + const commandId = axelarScanStatus?.command_id as Hex | undefined; + const approved = axelarScanStatus?.approved?.returnValues; + const call = axelarScanStatus?.call; + const payload = call?.returnValues?.payload as Hex | undefined; + const destinationChain = this.resolveBridgeToChain(quote); + if (!commandId || !call || !approved?.contractAddress || !approved.sourceChain || !approved.sourceAddress || !payload) { + return "cannot execute: Axelar status lacks the command id, approval or payload"; + } + if (!destinationChain || !isNetworkEVM(destinationChain)) { + return `cannot execute: destination ${destinationChain} is not an EVM chain`; + } + if (keccak256(payload).toLowerCase() !== approved.payloadHash?.toLowerCase()) { + return "refusing to execute: payload does not match the approved payload hash"; + } + + const { sourceChain, sourceAddress } = approved; + // The gateway validates the destination approval's token, which can differ from the source call's. + const symbol = approved.symbol ?? call.returnValues?.symbol; + const amount = approved.amount ?? call.returnValues?.amount; + let data: Hex; + if (call.event === "ContractCallWithToken" && symbol && amount) { + data = encodeFunctionData({ + abi: AXELAR_EXECUTABLE_ABI, + args: [commandId, sourceChain, sourceAddress, payload, symbol, BigInt(amount)], + functionName: "executeWithToken" + }); + } else if (call.event === "ContractCall") { + data = encodeFunctionData({ + abi: AXELAR_EXECUTABLE_ABI, + args: [commandId, sourceChain, sourceAddress, payload], + functionName: "execute" + }); + } else { + return `cannot execute: unsupported Axelar call event ${call.event} (or missing token symbol/amount)`; + } + + const network = destinationChain as EvmNetworks; + const to = approved.contractAddress as `0x${string}`; + const evmClientManager = EvmClientManager.getInstance(); + const publicClient = evmClientManager.getClient(network); + const fundingAccount = getEvmFundingAccount(network); + + let estimatedGas: bigint; + try { + estimatedGas = await abortableCall(signal, () => publicClient.estimateGas({ account: fundingAccount.address, data, to })); + } catch (error) { + return `refusing to execute: simulation failed: ${error instanceof Error ? error.message : String(error)}`; + } + // Caps the gas Axelarscan-supplied data can make the funding wallet burn. + if (estimatedGas > BigInt(DEFAULT_SQUIDROUTER_GAS_ESTIMATE)) { + return `refusing to execute: estimated gas ${estimatedGas} exceeds ${DEFAULT_SQUIDROUTER_GAS_ESTIMATE}`; + } + const gas = (estimatedGas * 6n) / 5n; + const { maxFeePerGas, maxPriorityFeePerGas } = await abortableCall(signal, () => publicClient.estimateFeesPerGas()); + const balance = await abortableCall(signal, () => publicClient.getBalance({ address: fundingAccount.address })); + if (balance < gas * maxFeePerGas) { + return `cannot execute: funding wallet ${fundingAccount.address} lacks native gas on ${network} (has ${balance} wei, needs ${gas * maxFeePerGas})`; + } + + if (signal?.aborted) { + return "execution aborted before destination execute; not sending"; + } + const claimedRows = await this.patchStateKey( + state, + "squidRouterAxelarExecuteTxHash", + AXELAR_EXECUTE_PENDING_MARKER, + `state->>'squidRouterAxelarExecuteTxHash' IS NULL` + ); + if (claimedRows === 0) { + return "destination execute already claimed by a concurrent execution; not sending"; + } + + try { + const walletClient = evmClientManager.getWalletClient(network, fundingAccount); + const { hash } = await runSerializedEvmFundingOperation( + network, + async () => { + const nonce = await publicClient.getTransactionCount({ address: fundingAccount.address, blockTag: "pending" }); + return this.runFinancialOperation(state, { + attemptClass: "axelar-destination-execute", + externalId: operation => operation.hash, + perform: async () => { + const hash = await walletClient.sendTransaction({ + account: fundingAccount, + chain: publicClient.chain, + data, + gas, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + to + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new FinancialOperationRejectedError(`Axelar destination execute ${hash} reverted`); + } + return { hash }; + }, + provider: network, + request: { commandId, network, to }, + settleAfterAbort: true, + signal + }); + }, + signal + ); + await this.patchStateKey(state, "squidRouterAxelarExecuteTxHash", hash); + logger.warn(`SQUIDROUTER_AXELAR_EXECUTED: sent approved GMP execute. ramp=${state.id} network=${network} tx=${hash}`); + return `executed the approved call on ${network} (${hash})`; + } catch (error) { + return `destination execute failed after claim, not retrying; check manually: ${error instanceof Error ? error.message : String(error)}`; + } + } + private async alertStuckGmp( state: RampState, swapHash: string, @@ -812,6 +953,7 @@ export class SquidRouterPayExecutor extends BasePhaseHandler { } const guidanceByClassification: Record = { + approved_not_executed: "approved but Axelar's relayer never executed; Vortex sends the destination execute itself", executed: "", execution_failed: "destination execution failed; external; retry the execution manually from the Axelarscan page", insufficient_gas: "Vortex-actionable: Axelar reports the paid gas as insufficient", @@ -985,6 +1127,7 @@ export class SquidRouterPayExecutor extends BasePhaseHandler { : axelarScanStatus.status; return { + axelarScanStatus, evidenceProvider: "axelar", id: "", isGMPTransaction: true, 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..96fc145512 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; @@ -77,6 +73,9 @@ export interface StateMetadata { // the tx hash after; any present value prevents further top-ups, so a crash or // send failure in between can never cause a second payment. squidRouterExtraGasTxHash?: string; + // Destination execute sent by the funding wallet for an approved GMP call the Axelar + // relayer never executed. Same "pending"-then-hash claim as squidRouterExtraGasTxHash. + squidRouterAxelarExecuteTxHash?: string; unhandledPaymentAlertSent: boolean; depositQrCode: string | undefined; // Set to true once update-time validation gate passes (all presigned txs valid + complete, @@ -86,14 +85,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 +105,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.moonbeam-retirement.test.ts b/apps/api/src/api/services/ramp/ramp.service.moonbeam-retirement.test.ts index 5f5ebf7044..a53e798a41 100644 --- a/apps/api/src/api/services/ramp/ramp.service.moonbeam-retirement.test.ts +++ b/apps/api/src/api/services/ramp/ramp.service.moonbeam-retirement.test.ts @@ -71,7 +71,7 @@ describe("RampService Moonbeam retirement", () => { expect(update).not.toHaveBeenCalled(); }); - it("rejects public and provider-paid starts before persisted flow execution", async () => { + it("rejects public, provider-paid, and funded-SELL starts before persisted flow execution", async () => { RampState.findByPk = mock(async () => ({ createdAt: new Date(), currentPhase: "initial", @@ -85,7 +85,11 @@ describe("RampService Moonbeam retirement", () => { })) as unknown as typeof RampState.findByPk; const service = new TestRampService(); - for (const start of [() => service.startRamp({ rampId: "ramp-1" }), () => service.recoverPaidAveniaRamp("ramp-1")]) { + for (const start of [ + () => service.startRamp({ rampId: "ramp-1" }), + () => service.recoverPaidAveniaRamp("ramp-1"), + () => service.recoverFundedSellRamp("ramp-1") + ]) { await expect(start()).rejects.toMatchObject({ status: httpStatus.SERVICE_UNAVAILABLE }); } }); diff --git a/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts b/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts new file mode 100644 index 0000000000..6f2561cd82 --- /dev/null +++ b/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import type { Transaction } from "sequelize"; +import { config } from "../../../config/vars"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.model"; +import { RampService } from "./ramp.service"; + +class TestRampService extends RampService { + protected async withTransaction(callback: (transaction: Transaction) => Promise): Promise { + return callback({} as Transaction); + } +} + +const originalQuoteFindByPk = QuoteTicket.findByPk; +const originalRampFindByPk = RampState.findByPk; + +afterEach(() => { + QuoteTicket.findByPk = originalQuoteFindByPk; + RampState.findByPk = originalRampFindByPk; +}); + +function stubRampAndQuote( + ramp: { from?: Networks; state: Record; to?: string; type: RampDirection }, + outputCurrency: string +) { + RampState.findByPk = mock(async () => ({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + currentPhase: "initial", + flowVariant: config.flowVariant, + from: Networks.Ethereum, + id: "ramp-1", + presignedTxs: [], + quoteId: "quote-1", + to: "pix", + unsignedTxs: [], + ...ramp + })) as unknown as typeof RampState.findByPk; + QuoteTicket.findByPk = mock(async () => ({ + id: "quote-1", + metadata: { blocks: {}, flow: { id: "BrlOfframpBase" }, globals: { fees: { usd: {} }, request: {} } }, + outputCurrency + })) as unknown as typeof QuoteTicket.findByPk; +} + +describe("RampService.recoverFundedSellRamp guards", () => { + const conflict = { message: "Ramp does not have a reported source transaction", status: httpStatus.CONFLICT }; + + it("refuses a SELL ramp whose source transaction hash was never reported", async () => { + stubRampAndQuote({ state: {}, type: RampDirection.SELL }, FiatToken.BRL); + + await expect(new TestRampService().recoverFundedSellRamp("ramp-1")).rejects.toMatchObject(conflict); + }); + + it("refuses a BUY ramp even when a hash-shaped field is present", async () => { + stubRampAndQuote({ state: { squidRouterSwapHash: "0xabc" }, type: RampDirection.BUY }, FiatToken.BRL); + + await expect(new TestRampService().recoverFundedSellRamp("ramp-1")).rejects.toMatchObject(conflict); + }); + + it("refuses a domestic (AlfredPay) SELL whose reported hash FundEphemeral does not verify", async () => { + stubRampAndQuote({ state: { squidRouterNoPermitTransferHash: "0xabc" }, type: RampDirection.SELL }, FiatToken.MXN); + + await expect(new TestRampService().recoverFundedSellRamp("ramp-1")).rejects.toMatchObject(conflict); + }); + + it("refuses an AssetHub SELL whose reported Squid hash FundEphemeral does not verify", async () => { + stubRampAndQuote( + { + from: Networks.AssetHub, + state: { assethubToPendulumHash: "0xdef", squidRouterSwapHash: "0xabc" }, + to: "sepa", + type: RampDirection.SELL + }, + FiatToken.EURC + ); + + await expect(new TestRampService().recoverFundedSellRamp("ramp-1")).rejects.toMatchObject(conflict); + }); +}); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 9461077099..c15050e5e3 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), @@ -576,9 +576,22 @@ export class RampService extends BaseRampService { return this.startRampWithOptions({ rampId }, { enforceDeadline: false, requirePaidAveniaTicket: true }); } + /** + * Start an EVM SELL ramp whose user already reported the hash of their source transaction but + * whose client never reached /ramp/start inside the window. That transaction delivers the funds + * to the ephemeral, so the deadline no longer protects anyone; FundEphemeral verifies the + * reported hash against the issued blueprint on-chain before any platform spend. + */ + public async recoverFundedSellRamp(rampId: string): Promise { + return this.startRampWithOptions( + { rampId }, + { enforceDeadline: false, requirePaidAveniaTicket: false, requireReportedSellSource: true } + ); + } + private async startRampWithOptions( request: StartRampRequest, - options: { enforceDeadline: boolean; requirePaidAveniaTicket: boolean } + options: { enforceDeadline: boolean; requirePaidAveniaTicket: boolean; requireReportedSellSource?: boolean } ): Promise { return this.withTransaction(async transaction => { const rampState = await RampState.findByPk(request.rampId, { lock: Transaction.LOCK.UPDATE, transaction }); @@ -622,6 +635,22 @@ export class RampService extends BaseRampService { status: httpStatus.CONFLICT }); } + if (options.requireReportedSellSource) { + // Domestic (AlfredPay) and AssetHub SELLs are excluded: FundEphemeral only verifies the + // reported hash for the other EVM SELLs, so this recovery has no pre-spend proof for them. + const { squidRouterNoPermitTransferHash, squidRouterSwapHash } = rampState.state; + if ( + rampState.type !== RampDirection.SELL || + rampState.from === Networks.AssetHub || + isDomesticToken(quote.outputCurrency as FiatToken) || + !(squidRouterSwapHash || squidRouterNoPermitTransferHash) + ) { + throw new APIError({ + message: "Ramp does not have a reported source transaction", + status: httpStatus.CONFLICT + }); + } + } if (options.enforceDeadline) { RampService.assertStartDeadlineNotExceeded(rampState); } @@ -676,7 +705,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 +816,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 +834,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 +953,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 +966,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 +1141,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.test.ts b/apps/api/src/api/workers/ramp-recovery.worker.test.ts index a8abb6f2aa..bc0db34f82 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.test.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.test.ts @@ -1,7 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import { EPaymentMethod, Networks } from "@vortexfi/shared"; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { EPaymentMethod, Networks, RampDirection } from "@vortexfi/shared"; +import { Op } from "sequelize"; +import logger from "../../config/logger"; +import { config } from "../../config/vars"; import RampState from "../../models/rampState.model"; import phaseProcessor from "../services/phases/phase-processor"; +import rampService from "../services/ramp/ramp.service"; import RampRecoveryWorker from "./ramp-recovery.worker"; const originalFindAll = RampState.findAll; @@ -37,3 +41,89 @@ describe("RampRecoveryWorker Moonbeam retirement", () => { expect(processRamp).not.toHaveBeenCalled(); }); }); + +describe("RampRecoveryWorker funded SELL start", () => { + const originalRecoverFundedSellRamp = rampService.recoverFundedSellRamp; + const originalAppendErrorLog = rampService.appendErrorLog; + const recoverFundedSellRamp = mock(async (_rampId: string): Promise => undefined); + const appendErrorLog = mock(async (_id: string, _entry: unknown) => undefined); + const fundedSell = { + currentPhase: "initial", + from: Networks.Ethereum, + id: "funded-sell-ramp", + state: { flow: { id: "BrlOfframpBase" }, squidRouterSwapHash: "0xabc" }, + to: EPaymentMethod.PIX, + unsignedTxs: [] + }; + let queries: Array<{ where: Record }>; + + beforeEach(() => { + queries = []; + RampState.findAll = mock(async (options: { where: Record }) => { + queries.push(options); + return options.where.currentPhase === "initial" ? [fundedSell] : []; + }) as unknown as typeof RampState.findAll; + rampService.recoverFundedSellRamp = recoverFundedSellRamp as unknown as typeof rampService.recoverFundedSellRamp; + rampService.appendErrorLog = appendErrorLog as unknown as typeof rampService.appendErrorLog; + recoverFundedSellRamp.mockReset(); + recoverFundedSellRamp.mockImplementation(async () => undefined); + appendErrorLog.mockClear(); + }); + + afterEach(() => { + rampService.recoverFundedSellRamp = originalRecoverFundedSellRamp; + rampService.appendErrorLog = originalAppendErrorLog; + }); + + async function runWorker() { + const worker = new RampRecoveryWorker("*/5 * * * *", false) as unknown as { recover: () => Promise }; + await worker.recover(); + } + + it("selects initial SELL ramps with a reported source hash between 16 minutes and 3 days old", async () => { + const before = Date.now(); + await runWorker(); + const after = Date.now(); + + const where = queries.find(query => query.where.currentPhase === "initial")?.where as Record; + expect(where.type).toBe(RampDirection.SELL); + expect(where.flowVariant).toBe(config.flowVariant); + expect(where[Op.or]).toEqual([ + { "state.squidRouterSwapHash": { [Op.ne]: null } }, + { "state.squidRouterNoPermitTransferHash": { [Op.ne]: null } } + ]); + const createdAt = where.createdAt as Record; + const minute = 60 * 1000; + // The worker reads the clock between `before` and `after`, so each cutoff lies in that window. + expect(createdAt[Op.lt].getTime()).toBeGreaterThanOrEqual(before - 16 * minute); + expect(createdAt[Op.lt].getTime()).toBeLessThanOrEqual(after - 16 * minute); + expect(createdAt[Op.gt].getTime()).toBeGreaterThanOrEqual(before - 3 * 24 * 60 * minute); + expect(createdAt[Op.gt].getTime()).toBeLessThanOrEqual(after - 3 * 24 * 60 * minute); + }); + + it("starts each selected ramp through the funded SELL path, not the phase processor", async () => { + await runWorker(); + + expect(recoverFundedSellRamp).toHaveBeenCalledTimes(1); + expect(recoverFundedSellRamp).toHaveBeenCalledWith("funded-sell-ramp"); + expect(processRamp).not.toHaveBeenCalled(); + expect(appendErrorLog).not.toHaveBeenCalled(); + }); + + it("logs a failed start on the ramp and selects it again on the next cycle", async () => { + recoverFundedSellRamp.mockImplementation(async () => { + throw new Error("database unavailable"); + }); + + const info = spyOn(logger, "info"); + await runWorker(); + await runWorker(); + + expect(info).toHaveBeenCalledWith("Ramp recovery attempt completed. Successful: 0, Failed: 1"); + info.mockRestore(); + expect(appendErrorLog).toHaveBeenCalledTimes(2); + expect(appendErrorLog.mock.calls[0]?.[0]).toBe("funded-sell-ramp"); + expect(appendErrorLog.mock.calls[0]?.[1]).toMatchObject({ error: "database unavailable", phase: "initial" }); + expect(recoverFundedSellRamp).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/api/src/api/workers/ramp-recovery.worker.ts b/apps/api/src/api/workers/ramp-recovery.worker.ts index f0b3f39928..c7979eae36 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.ts @@ -3,12 +3,16 @@ import { CronJob } from "cron"; import { Op } from "sequelize"; import logger from "../../config/logger"; import { config } from "../../config/vars"; +import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../constants/constants"; import RampState from "../../models/rampState.model"; +import { getFundedInitialSellRampWhere } from "../services/phases/blocks/core/compatibility-scope"; import { isMoonbeamRuntimeDisabledForState } from "../services/phases/moonbeam-runtime"; import phaseProcessor from "../services/phases/phase-processor"; import rampService from "../services/ramp/ramp.service"; const TEN_MINUTES_IN_MS = 10 * 60 * 1000; +// Funded SELL ramps are started only once the public start window has certainly closed. +const FUNDED_SELL_MIN_AGE_MS = (RAMP_START_EXPIRATION_TIME_SECONDS + 60) * 1000; const DISABLED_HYDRATION_PHASES = ["pendulumToHydrationXcm", "hydrationSwap", "hydrationToAssethubXcm"]; /** @@ -38,14 +42,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 */ @@ -69,8 +65,17 @@ class RampRecoveryWorker { } }); - const statesToRecover = staleStates.filter(state => !isMoonbeamRuntimeDisabledForState(state)); - const retiredStateCount = staleStates.length - statesToRecover.length; + // SELL ramps whose user reported the source transaction hash but whose client never started + // them before the public start window closed. Their funds are already on the ephemeral. + const fundedSellStates = await RampState.findAll({ + where: { + ...getFundedInitialSellRampWhere(new Date(), FUNDED_SELL_MIN_AGE_MS), + flowVariant: config.flowVariant + } + }); + + const statesToRecover = [...staleStates, ...fundedSellStates].filter(state => !isMoonbeamRuntimeDisabledForState(state)); + const retiredStateCount = staleStates.length + fundedSellStates.length - statesToRecover.length; if (retiredStateCount > 0) { logger.warn(`Skipped ${retiredStateCount} Moonbeam-dependent ramp states during automatic recovery.`); } @@ -82,12 +87,17 @@ class RampRecoveryWorker { logger.info(`Found ${statesToRecover.length} stale ramp states to process.`); - // Process each stale state concurrently + // Process each state concurrently. A funded initial SELL ramp is started (past the public + // deadline); every other state resumes its current phase. const recoveryPromises = statesToRecover.map(async state => { try { logger.info(`Attempting recovery in phase ${state.currentPhase} for ramp ${state.id}`); // Process the state (processRamp already wraps execution with runWithRampContext) - await phaseProcessor.processRamp(state.id); + if (state.currentPhase === "initial") { + await rampService.recoverFundedSellRamp(state.id); + } else { + await phaseProcessor.processRamp(state.id); + } logger.info(`Successfully processed ramp state ${state.id}`); return { stateId: state.id, status: "fulfilled" }; } catch (e: unknown) { @@ -122,7 +132,8 @@ class RampRecoveryWorker { const results = await Promise.allSettled(recoveryPromises); // Log summary of results - const successfulRecoveries = results.filter(r => r.status === "fulfilled").length; + // Each attempt catches its own error and resolves with its outcome in `value.status`. + const successfulRecoveries = results.filter(r => r.status === "fulfilled" && r.value.status === "fulfilled").length; const failedRecoveries = results.length - successfulRecoveries; logger.info(`Ramp recovery attempt completed. Successful: ${successfulRecoveries}, Failed: ${failedRecoveries}`); } catch (error) { 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/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts index 7f65870441..ec9f3fae93 100644 --- a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import * as shared from "@vortexfi/shared"; import { AveniaTicketStatus, @@ -18,6 +18,8 @@ import { import { parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import rampService from "../../api/services/ramp/ramp.service"; +import RampRecoveryWorker from "../../api/workers/ramp-recovery.worker"; import { getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; import { resolvePersistedBlockFlow } from "../../api/services/phases/blocks/flows/catalog"; import { assertPersistedBlockFlowVersionsSupported } from "../../api/services/phases/blocks/register-handlers"; @@ -78,6 +80,7 @@ interface CorridorSetup { approveHash: `0x${string}`; /** Hash of the user's broadcast squidRouterSwap on Polygon. */ swapHash: `0x${string}`; + userId: string; } /** @@ -201,6 +204,28 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via }); } + /** + * Signs a blueprint plus the four required same-call backups at the following + * nonces, shaped for /v1/ramp/update. + */ + async function signBlueprintWithBackups(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx) { + const additionalTxs: Record = {}; + for (let i = 1; i <= 4; i++) { + additionalTxs[`${blueprint.phase}${i}`] = { + nonce: blueprint.nonce + i, + txData: await signBlueprint(ephemeral, { ...blueprint, nonce: blueprint.nonce + i }) + }; + } + return { + meta: { additionalTxs }, + network: blueprint.network, + nonce: blueprint.nonce, + phase: blueprint.phase, + signer: ephemeral.address, + txData: await signBlueprint(ephemeral, blueprint) + }; + } + /** Broadcasts a user-wallet blueprint on its source chain exactly as issued. */ function broadcastUserBlueprint(userWallet: PrivateKeyAccount, blueprint: UnsignedTx): `0x${string}` { const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; @@ -217,7 +242,7 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via * plus the ephemeral's presigned Base-side transactions the way the * frontend/SDK would via /v1/ramp/update. */ - async function setUpRegisteredRamp(options: { reportHashes?: boolean } = {}): Promise { + async function setUpRegisteredRamp(options: { reportHashes?: boolean; viaApi?: boolean } = {}): Promise { const reportHashes = options.reportHashes ?? true; const ephemeral = privateKeyToAccount(generatePrivateKey()); const userWallet = privateKeyToAccount(generatePrivateKey()); @@ -266,16 +291,39 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via const approveHash = broadcastUserBlueprint(userWallet, approveBlueprint); const swapHash = broadcastUserBlueprint(userWallet, swapBlueprint); - await rampState.update({ - presignedTxs: [ - presign(nablaApproveBlueprint, signedNablaApprove), - presign(nablaSwapBlueprint, signedNablaSwap), - presign(payoutBlueprint, signedPayout) - ], - state: reportHashes - ? { ...rampState.state, squidRouterApproveHash: approveHash, squidRouterSwapHash: swapHash } - : rampState.state - }); + let effectiveSignedNablaSwap = signedNablaSwap; + let effectiveSignedPayout = signedPayout; + if (options.viaApi) { + // Full API flow: sign EVERY ephemeral blueprint (with the required backups) and submit + // through /v1/ramp/update, so the later /v1/ramp/start validation sees a complete set. + const apiPresignedTxs = []; + for (const blueprint of unsignedTxs.filter(tx => tx.signer.toLowerCase() === ephemeral.address.toLowerCase())) { + apiPresignedTxs.push(await signBlueprintWithBackups(ephemeral, blueprint)); + } + effectiveSignedNablaSwap = apiPresignedTxs.find(tx => tx.phase === "nablaSwap")?.txData as `0x${string}`; + effectiveSignedPayout = apiPresignedTxs.find(tx => tx.phase === "brlaPayoutOnBase")?.txData as `0x${string}`; + const updateResponse = await app.request("/v1/ramp/update", { + body: JSON.stringify({ + additionalData: reportHashes ? { squidRouterApproveHash: approveHash, squidRouterSwapHash: swapHash } : {}, + presignedTxs: apiPresignedTxs, + rampId: ramp.id + }), + headers: { Authorization: `Bearer ${testUserToken(user.id)}`, "Content-Type": "application/json" }, + method: "POST" + }); + expect(updateResponse.status).toBe(200); + } else { + await rampState.update({ + presignedTxs: [ + presign(nablaApproveBlueprint, signedNablaApprove), + presign(nablaSwapBlueprint, signedNablaSwap), + presign(payoutBlueprint, signedPayout) + ], + state: reportHashes + ? { ...rampState.state, squidRouterApproveHash: approveHash, squidRouterSwapHash: swapHash } + : rampState.state + }); + } return { approveBlueprint, @@ -283,12 +331,13 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via ephemeral, quoteId: quote.id, rampId: ramp.id, - signedNablaSwap, - signedPayout, + signedNablaSwap: effectiveSignedNablaSwap, + signedPayout: effectiveSignedPayout, swapBlueprint, swapHash, swapInputRaw, swapOutputRaw, + userId: user.id, userWallet }; } @@ -486,6 +535,164 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via 30000 ); + describe("recovery worker starts funded SELL ramps the client never started", () => { + const MINUTE = 60 * 1000; + let startSpy: ReturnType>; + + beforeEach(() => { + startSpy = spyOn(rampService, "recoverFundedSellRamp"); + }); + + afterEach(() => { + startSpy.mockRestore(); + }); + + async function backdate(rampId: string, ageMs: number): Promise { + await RampState.update({ createdAt: new Date(Date.now() - ageMs) }, { where: { id: rampId } }); + } + + async function runRecoveryWorker(): Promise { + const worker = new RampRecoveryWorker("*/5 * * * *", false) as unknown as { recover: () => Promise }; + await worker.recover(); + } + + async function waitForPhase(rampId: string, phase: RampPhase): Promise { + const deadline = Date.now() + 20000; + for (;;) { + const ramp = await RampState.findByPk(rampId); + if (ramp?.currentPhase === phase) { + return ramp; + } + if (Date.now() > deadline) { + throw new Error(`Ramp ${rampId} did not reach ${phase}; stuck in ${ramp?.currentPhase}`); + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + + // The worker's only way to start a ramp is recoverFundedSellRamp, and a started ramp advances + // asynchronously, so the spy (not the persisted phase alone) proves nothing was started. + async function expectStillInitialAndUntouched(setup: CorridorSetup): Promise { + expect(startSpy).not.toHaveBeenCalled(); + const ramp = await RampState.findByPk(setup.rampId); + expect(ramp?.currentPhase).toBe("initial"); + expect(ramp?.errorLogs).toEqual([]); + expect(ramp?.phaseHistory.map(entry => entry.phase)).toEqual(["initial"]); + expect(submissionsOf(setup.signedNablaSwap)).toBe(0); + expect(submissionsOf(setup.signedPayout)).toBe(0); + } + + it( + "starts and completes a ramp whose hash was reported inside the window but was never started", + async () => { + const setup = await setUpRegisteredRamp({ viaApi: true }); + scriptHappyWorld(setup); + const pixOutBefore = world.brla.pixOutputTickets.length; + await backdate(setup.rampId, 17 * MINUTE); + + await runRecoveryWorker(); + + expect(startSpy).toHaveBeenCalledTimes(1); + expect(startSpy).toHaveBeenCalledWith(setup.rampId); + const final = await waitForPhase(setup.rampId, "complete"); + expect(final.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(submissionsOf(setup.signedNablaSwap)).toBe(1); + expect(submissionsOf(setup.signedPayout)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(setup.swapOutputRaw); + expect(world.brla.pixOutputTickets.length).toBe(pixOutBefore + 1); + }, + 60000 + ); + + it("keeps the public start and update strict: both still reject the same ramp with 400 after the deadline", async () => { + const setup = await setUpRegisteredRamp({ viaApi: true }); + await backdate(setup.rampId, 17 * MINUTE); + const headers = { Authorization: `Bearer ${testUserToken(setup.userId)}`, "Content-Type": "application/json" }; + + const start = await app.request("/v1/ramp/start", { + body: JSON.stringify({ rampId: setup.rampId }), + headers, + method: "POST" + }); + const update = await app.request("/v1/ramp/update", { + body: JSON.stringify({ + additionalData: { squidRouterSwapHash: setup.swapHash }, + presignedTxs: [], + rampId: setup.rampId + }), + headers, + method: "POST" + }); + + expect(start.status).toBe(400); + expect(await start.text()).toContain("Maximum time window to start process exceeded"); + expect(update.status).toBe(400); + expect(await update.text()).toContain("Maximum time window to start process exceeded"); + await expectStillInitialAndUntouched(setup); + }); + + it("leaves a ramp whose source hash was never reported initial", async () => { + const setup = await setUpRegisteredRamp({ reportHashes: false, viaApi: true }); + scriptHappyWorld(setup); + await backdate(setup.rampId, 17 * MINUTE); + + await runRecoveryWorker(); + + await expectStillInitialAndUntouched(setup); + }); + + for (const ageMinutes of [14, 15.5]) { + it(`leaves a ramp untouched while the public start window or its one-minute grace is open (${ageMinutes} min)`, async () => { + const setup = await setUpRegisteredRamp({ viaApi: true }); + scriptHappyWorld(setup); + await backdate(setup.rampId, ageMinutes * MINUTE); + + await runRecoveryWorker(); + + await expectStillInitialAndUntouched(setup); + }); + } + + it("leaves a ramp older than the three-day recovery window untouched", async () => { + const setup = await setUpRegisteredRamp({ viaApi: true }); + scriptHappyWorld(setup); + await backdate(setup.rampId, 3 * 24 * 60 * MINUTE + 60 * MINUTE); + + await runRecoveryWorker(); + + await expectStillInitialAndUntouched(setup); + }); + + it( + "security: a reported hash whose calldata differs from the blueprint fails the started ramp before any spend", + async () => { + const setup = await setUpRegisteredRamp({ reportHashes: false, viaApi: true }); + scriptHappyWorld(setup); + const swapTxData = setup.swapBlueprint.txData as unknown as { to: `0x${string}`; value?: string }; + const tamperedHash = world.evm.broadcastUserTransaction(Networks.Polygon, setup.userWallet.address, { + data: "0xdeadbeef", + to: swapTxData.to, + value: BigInt(swapTxData.value ?? "0") + }); + const rampState = await RampState.findByPk(setup.rampId); + await rampState?.update({ + state: { ...rampState.state, squidRouterApproveHash: setup.approveHash, squidRouterSwapHash: tamperedHash } + }); + await backdate(setup.rampId, 17 * MINUTE); + + await runRecoveryWorker(); + + const final = await waitForPhase(setup.rampId, "failed"); + expect(final.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final.errorLogs.some(log => log.error.includes("calldata does not match"))).toBe(true); + expect(submissionsOf(setup.signedNablaSwap)).toBe(0); + expect(submissionsOf(setup.signedPayout)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(0n); + }, + 60000 + ); + }); + async function requestSellQuote(inputCurrency: string, inputAmount: string, network: Networks = Networks.Ethereum) { return app.request("/v1/quotes", { body: JSON.stringify({ diff --git a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts index 6f9892885d..31a53a566c 100644 --- a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts @@ -15,6 +15,7 @@ import { import { decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import RampRecoveryWorker from "../../api/workers/ramp-recovery.worker"; import { getEvmFundingAccount } from "../../api/services/phases/blocks/core/evm-funding"; import { getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; import FinancialOperation from "../../models/financialOperation.model"; @@ -698,4 +699,31 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { }, 30000 ); + + it( + "recovery worker: starts a direct-transfer ramp whose transfer hash was reported but never started", + async () => { + const setup = await setUpRegisteredRamp({ submitViaApi: true }); + scriptHappyWorld(setup); + // The client reported the transfer hash inside the window, then never called /v1/ramp/start. + await RampState.update({ createdAt: new Date(Date.now() - 17 * 60 * 1000) }, { where: { id: setup.rampId } }); + const pixOutBefore = world.brla.pixOutputTickets.length; + + const worker = new RampRecoveryWorker("*/5 * * * *", false) as unknown as { recover: () => Promise }; + await worker.recover(); + + const deadline = Date.now() + 20000; + let final = await RampState.findByPk(setup.rampId); + while (final?.currentPhase !== "complete" && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 50)); + final = await RampState.findByPk(setup.rampId); + } + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(submissionsOf(setup.signedNablaSwap)).toBe(1); + expect(submissionsOf(setup.signedPayout)).toBe(1); + expect(world.brla.pixOutputTickets.length).toBe(pixOutBefore + 1); + }, + 60000 + ); }); 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..6333d86dec 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; @@ -299,11 +301,21 @@ interface MockBackendOptions { rampRegisterError?: string; // Fail this many POST /v1/ramp/start calls with a 500 before succeeding. rampStartFailures?: number; + // Fail this many POST /v1/ramp/update calls that report wallet hashes (the offramp's final + // update) with the maintenance guard's 503 before succeeding. + rampHashUpdateFailures?: number; onrampCurrency?: "ARS" | "BRL" | "COP" | "MXN" | "USD"; quoteOverrides?: (requestIndex: number, requestBody: Record) => Record; 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 +447,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 +612,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); @@ -1000,6 +1025,14 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) } if (path === "/v1/ramp/update" && method === "POST") { updateRequests.push(request.postDataJSON() as Record); + const hashUpdates = updateRequests.filter(body => body.additionalData).length; + if (updateRequests.at(-1)?.additionalData && hashUpdates <= (options.rampHashUpdateFailures ?? 0)) { + await fulfillJson( + { message: "Vortex services are temporarily unavailable during scheduled maintenance: Upgrade - Back soon." }, + 503 + ); + return; + } const isOnramp = quoteRequests.at(-1)?.rampType === "BUY"; const paymentData = isOnramp ? options.onrampCurrency === "BRL" @@ -1173,6 +1206,7 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) kyc, kycFormSubmissions, limitsRequests, + maintenance, monerium, quoteRequests, registerRequests, diff --git a/apps/dashboard/e2e/support/mockWallet.ts b/apps/dashboard/e2e/support/mockWallet.ts index 7006365e92..c31dff1576 100644 --- a/apps/dashboard/e2e/support/mockWallet.ts +++ b/apps/dashboard/e2e/support/mockWallet.ts @@ -4,6 +4,8 @@ export const MOCK_WALLET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; export const MOCK_WALLET_NAME = "E2E Mock Wallet"; /** The hash the stub returns for every eth_sendTransaction. */ export const MOCK_WALLET_TX_HASH = `0x${"cd".repeat(32)}`; +/** sessionStorage key counting eth_sendTransaction calls in the tab; it survives reloads. */ +export const MOCK_WALLET_SEND_COUNT_KEY = "e2e-mock-wallet-sends"; // Copied from apps/frontend/e2e/support/mockWallet.ts. Injects a minimal EIP-1193 provider // announced via EIP-6963 before the app loads. wagmi discovers announced providers by default @@ -14,7 +16,7 @@ export const MOCK_WALLET_TX_HASH = `0x${"cd".repeat(32)}`; // a mid-flow chain switch in signAndSubmitEvmTransaction. export async function injectMockWallet(page: Page, options: { chainIdHex?: string } = {}) { await page.addInitScript( - ({ address, name, chainIdHex, txHash }) => { + ({ address, name, chainIdHex, sendCountKey, txHash }) => { // biome-ignore lint/suspicious/noExplicitAny: minimal EIP-1193 stub const listeners: Record void>> = {}; const provider = { @@ -46,6 +48,7 @@ export async function injectMockWallet(page: Page, options: { chainIdHex?: strin return `0x${"ab".repeat(65)}`; // The offramp's user-owned squidRouterNoPermitTransfer is broadcast through here. case "eth_sendTransaction": + sessionStorage.setItem(sendCountKey, String(Number(sessionStorage.getItem(sendCountKey) ?? 0) + 1)); return txHash; case "eth_getTransactionReceipt": return { @@ -89,6 +92,7 @@ export async function injectMockWallet(page: Page, options: { chainIdHex?: strin address: MOCK_WALLET_ADDRESS, chainIdHex: options.chainIdHex ?? "0x2105", name: MOCK_WALLET_NAME, + sendCountKey: MOCK_WALLET_SEND_COUNT_KEY, txHash: MOCK_WALLET_TX_HASH } ); diff --git a/apps/dashboard/e2e/transfer-mxn-journey.spec.ts b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts index b49721f9f4..0f67e444ae 100644 --- a/apps/dashboard/e2e/transfer-mxn-journey.spec.ts +++ b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; import { E2E_FIAT_ACCOUNT_ID, E2E_FIAT_ACCOUNT_ID_2, E2E_QUOTE_ID, E2E_RAMP_ID, mockBackend } from "./support/mockBackend"; -import { injectMockWallet, MOCK_WALLET_ADDRESS, MOCK_WALLET_TX_HASH } from "./support/mockWallet"; +import { injectMockWallet, MOCK_WALLET_ADDRESS, MOCK_WALLET_SEND_COUNT_KEY, MOCK_WALLET_TX_HASH } from "./support/mockWallet"; import { seedSession } from "./support/session"; const EXPECTED_PAYIN_USDC = "54.054054"; @@ -124,6 +124,47 @@ test("SELL MXN transfer: quote, register, ephemeral presigning, wallet broadcast expect(backend.unexpectedExternalRequests).toEqual([]); }); +// Once the wallet has broadcast, the tokens sit on the ramp's ephemeral account. A failed final +// update (here the maintenance guard's 503) must keep the ramp and its hash, survive a reload, and +// resend that same update on retry, never the wallet transaction. +test("SELL retries a failed post-broadcast update after a reload without re-broadcasting", async ({ page }) => { + const backend = await mockBackend(page, { rampHashUpdateFailures: 1 }); + 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(EXPECTED_PAYIN_USDC); + const sendButton = page.getByRole("button", { name: /Send/ }); + await expect(sendButton).toBeEnabled({ timeout: 20_000 }); + await sendButton.click(); + + const notStarted = page.getByRole("alert").filter({ hasText: "Your transfer hasn’t started yet" }); + await expect(notStarted).toBeVisible({ timeout: 30_000 }); + await expect(notStarted).toContainText("scheduled maintenance"); + await expect(page.getByText(/Try again within \d+ min/)).toBeVisible(); + expect(backend.updateRequests).toHaveLength(2); + expect(backend.startRequests).toHaveLength(0); + + await page.reload(); + await expect(page.getByText("The page was reloaded before the transfer started.")).toBeVisible({ timeout: 20_000 }); + await page.getByRole("button", { name: "Try again" }).click(); + await expect(page.getByText("Transfer initiated")).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/transactions/); + + expect(backend.registerRequests).toHaveLength(1); + expect(backend.updateRequests).toHaveLength(3); + expect(backend.updateRequests[2]).toEqual(backend.updateRequests[1]); + expect((backend.updateRequests[2] as { additionalData?: Record }).additionalData).toEqual({ + squidRouterNoPermitTransferHash: MOCK_WALLET_TX_HASH + }); + expect(backend.startRequests).toHaveLength(1); + expect(await page.evaluate(key => sessionStorage.getItem(key), MOCK_WALLET_SEND_COUNT_KEY)).toBe("1"); + expect(backend.unmatchedRequests).toEqual([]); + expect(backend.unexpectedExternalRequests).toEqual([]); +}); + test("SELL refreshes a near-expiry quote before registration", async ({ page }) => { const backend = await mockBackend(page, { quoteOverrides: requestIndex => { 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..d0bada43dc 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); @@ -123,8 +125,10 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi transferState.matches("CheckingBalance") || transferState.matches("Registering") || transferState.matches("SigningUserTxs") || + transferState.matches("SubmittingUserTxs") || transferState.matches("AwaitingPayment") || transferState.matches("Starting") || + transferState.matches("AwaitingRetry") || transferState.matches("Tracking"); if (transferState.matches("AwaitingPayment") && transferState.context.ramp && belongsToActiveOwner) { @@ -327,7 +331,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,8 +350,8 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi

- + + ); + } + + return ( +
+
+ +
+

Your transfer hasn’t started yet

+

{startError ?? "The page was reloaded before the transfer started."}

+

Your tokens already left your wallet. Trying again finishes starting this transfer and never sends them again.

+
+
+ {Number.isFinite(deadline) && ( +

+ Try again within {Math.ceil((deadline - now) / 60_000)} min (before{" "} + {new Date(deadline).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}). After that the transfer can no + longer be started. +

+ )} + +
+ ); +} + function Row({ label, children }: { label: string; children: React.ReactNode }) { return (
diff --git a/apps/dashboard/src/components/ui/avatar.tsx b/apps/dashboard/src/components/ui/avatar.tsx index f8164b0214..8d1f945dec 100644 --- a/apps/dashboard/src/components/ui/avatar.tsx +++ b/apps/dashboard/src/components/ui/avatar.tsx @@ -12,10 +12,6 @@ function Avatar({ className, ...props }: React.ComponentProps) { - return ; -} - function AvatarFallback({ className, ...props }: React.ComponentProps) { return ( ) { return
; } -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - function CardContent({ className, ...props }: React.ComponentProps<"div">) { return
; } @@ -50,4 +40,4 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return
; } -export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx index 6c12de316b..a23b32b737 100644 --- a/apps/dashboard/src/components/ui/dialog.tsx +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -15,10 +15,6 @@ function DialogPortal({ ...props }: React.ComponentProps; } -function DialogClose({ ...props }: React.ComponentProps) { - return ; -} - function DialogOverlay({ className, ...props }: React.ComponentProps) { return ( ) { - return ; -} - function DropdownMenuItem({ className, inset, @@ -55,60 +50,6 @@ function DropdownMenuItem({ ); } -function DropdownMenuCheckboxItem({ - className, - children, - checked, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ); -} - -function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { - return ; -} - -function DropdownMenuRadioItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ); -} - function DropdownMenuLabel({ className, inset, @@ -134,68 +75,4 @@ function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - -function DropdownMenuSub({ ...props }: React.ComponentProps) { - return ; -} - -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { inset?: boolean }) { - return ( - - {children} - - - ); -} - -function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent -}; +export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator }; diff --git a/apps/dashboard/src/components/ui/input-otp.tsx b/apps/dashboard/src/components/ui/input-otp.tsx index 62b84d50ba..465132b001 100644 --- a/apps/dashboard/src/components/ui/input-otp.tsx +++ b/apps/dashboard/src/components/ui/input-otp.tsx @@ -1,5 +1,4 @@ import { OTPInput, OTPInputContext } from "input-otp"; -import { MinusIcon } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/cn"; @@ -46,12 +45,4 @@ function InputOTPSlot({ index, className, ...props }: React.ComponentProps<"div" ); } -function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { - return ( - - ); -} - -export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; +export { InputOTP, InputOTPGroup, InputOTPSlot }; diff --git a/apps/dashboard/src/components/ui/popover.tsx b/apps/dashboard/src/components/ui/popover.tsx index bfe33e424b..001f8dbfab 100644 --- a/apps/dashboard/src/components/ui/popover.tsx +++ b/apps/dashboard/src/components/ui/popover.tsx @@ -32,8 +32,4 @@ function PopoverContent({ ); } -function PopoverAnchor({ ...props }: React.ComponentProps) { - return ; -} - -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/dashboard/src/components/ui/select.tsx b/apps/dashboard/src/components/ui/select.tsx index 8b55e2eef1..2a925933ba 100644 --- a/apps/dashboard/src/components/ui/select.tsx +++ b/apps/dashboard/src/components/ui/select.tsx @@ -7,10 +7,6 @@ function Select({ ...props }: React.ComponentProps) return ; } -function SelectGroup({ ...props }: React.ComponentProps) { - return ; -} - function SelectValue({ ...props }: React.ComponentProps) { return ; } @@ -74,16 +70,6 @@ function SelectContent({ ); } -function SelectLabel({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - function SelectItem({ className, children, ...props }: React.ComponentProps) { return ( ) { - return ( - - ); -} - function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { return ( ) { return ; } -function SheetTrigger({ ...props }: React.ComponentProps) { - return ; -} - -function SheetClose({ ...props }: React.ComponentProps) { - return ; -} - function SheetPortal({ ...props }: React.ComponentProps) { return ; } @@ -71,10 +63,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { return
; } -function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { - return
; -} - function SheetTitle({ className, ...props }: React.ComponentProps) { return ; } @@ -89,4 +77,4 @@ function SheetDescription({ className, ...props }: React.ComponentProps) { ); } -function SidebarInput({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } -function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function SidebarSeparator({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { return (
) { ); } -function SidebarGroupLabel({ className, asChild = false, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? SlotPrimitive.Root : "div"; - return ( - svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className - )} - data-sidebar="group-label" - data-slot="sidebar-group-label" - {...props} - /> - ); -} - -function SidebarGroupAction({ className, asChild = false, ...props }: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? SlotPrimitive.Root : "button"; - return ( - svg]:size-4 [&>svg]:shrink-0", - "after:-inset-2 after:absolute md:after:hidden", - "group-data-[collapsible=icon]:hidden", - className - )} - data-sidebar="group-action" - data-slot="sidebar-group-action" - {...props} - /> - ); -} - function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) { return (
& { asChild?: boolean; showOnHover?: boolean }) { - const Comp = asChild ? SlotPrimitive.Root : "button"; - return ( - svg]:size-4 [&>svg]:shrink-0", - "after:-inset-2 after:absolute md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", - className - )} - data-sidebar="menu-action" - data-slot="sidebar-menu-action" - {...props} - /> - ); -} - -function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function SidebarMenuSkeleton({ className, showIcon = false, ...props }: React.ComponentProps<"div"> & { showIcon?: boolean }) { - const width = React.useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, []); - return ( -
- {showIcon && } - -
- ); -} - -function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { - return ( -
    - ); -} - -function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) { - return ( -
  • - ); -} - -function SidebarMenuSubButton({ - asChild = false, - size = "md", - isActive = false, - className, - ...props -}: React.ComponentProps<"a"> & { asChild?: boolean; size?: "sm" | "md"; isActive?: boolean }) { - const Comp = asChild ? SlotPrimitive.Root : "a"; - return ( - svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", - className - )} - data-active={isActive} - data-sidebar="menu-sub-button" - data-size={size} - data-slot="sidebar-menu-sub-button" - {...props} - /> - ); -} - export { Sidebar, SidebarContent, - SidebarFooter, SidebarGroup, - SidebarGroupAction, SidebarGroupContent, - SidebarGroupLabel, SidebarHeader, - SidebarInput, SidebarInset, SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, SidebarProvider, SidebarRail, - SidebarSeparator, SidebarTrigger, useSidebar }; diff --git a/apps/dashboard/src/components/ui/table.tsx b/apps/dashboard/src/components/ui/table.tsx index 948977416d..232fd4156d 100644 --- a/apps/dashboard/src/components/ui/table.tsx +++ b/apps/dashboard/src/components/ui/table.tsx @@ -17,16 +17,6 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { return ; } -function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { - return ( - tr]:last:border-b-0", className)} - data-slot="table-footer" - {...props} - /> - ); -} - function TableRow({ className, ...props }: React.ComponentProps<"tr">) { return ( ) { ); } -function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { - return ; -} - -export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; +export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell }; diff --git a/apps/dashboard/src/domain/corridors.test.ts b/apps/dashboard/src/domain/corridors.test.ts index c5e5576e6f..daefb1c148 100644 --- a/apps/dashboard/src/domain/corridors.test.ts +++ b/apps/dashboard/src/domain/corridors.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { CORRIDORS, isCorridorAvailableForAccountType, isCorridorOnboardingDisabled } from "./corridors"; +import { isCorridorAvailableForAccountType } from "./corridors"; describe("isCorridorAvailableForAccountType", () => { it("disallows Argentina for company accounts", () => { @@ -14,12 +14,3 @@ describe("isCorridorAvailableForAccountType", () => { assert.equal(isCorridorAvailableForAccountType("US", "company"), true); }); }); - -describe("isCorridorOnboardingDisabled", () => { - it("disables no corridor now that EU onboarding runs through Monerium again", () => { - assert.equal(isCorridorOnboardingDisabled(CORRIDORS.EU), false); - for (const corridor of [CORRIDORS.AR, CORRIDORS.BR, CORRIDORS.CO, CORRIDORS.MX, CORRIDORS.US]) { - assert.equal(isCorridorOnboardingDisabled(corridor), false); - } - }); -}); diff --git a/apps/dashboard/src/domain/corridors.ts b/apps/dashboard/src/domain/corridors.ts index 80c8e5c5bd..c4dc0a42d0 100644 --- a/apps/dashboard/src/domain/corridors.ts +++ b/apps/dashboard/src/domain/corridors.ts @@ -3,7 +3,6 @@ import type { AccountType, Corridor, CorridorId, OnboardingKind, OnboardingRoute export const CORRIDORS: Record = { AR: { - availability: "live", currency: "ARS", flag: "🇦🇷", id: "AR", @@ -13,7 +12,6 @@ export const CORRIDORS: Record = { recipientMethod: "ach" }, BR: { - availability: "live", currency: "BRL", flag: "🇧🇷", id: "BR", @@ -23,7 +21,6 @@ export const CORRIDORS: Record = { recipientMethod: "pix" }, CO: { - availability: "live", currency: "COP", flag: "🇨🇴", id: "CO", @@ -33,7 +30,6 @@ export const CORRIDORS: Record = { recipientMethod: "ach" }, EU: { - availability: "live", currency: "EURC", flag: "🇪🇺", id: "EU", @@ -43,7 +39,6 @@ export const CORRIDORS: Record = { recipientMethod: "iban" }, MX: { - availability: "live", currency: "MXN", flag: "🇲🇽", id: "MX", @@ -53,7 +48,6 @@ export const CORRIDORS: Record = { recipientMethod: "spei" }, US: { - availability: "live", currency: "USD", flag: "🇺🇸", id: "US", @@ -106,13 +100,3 @@ export function isOnboardingAvailable(corridor: Corridor, kind: OnboardingKind): } return isCorridorAvailableForAccountType(corridor.id, kind === "kyb" ? "company" : "individual"); } - -/** - * Corridors whose onboarding is switched off. When one is, the corridor card replaces every - * actionable button (start, continue, retry, re-authenticate) with a disabled, explanatory one - * and the wizard refuses the corridor even via the `?onboarding=` deep link. None today: - * EU was off while the Monerium onramp was rebuilt and runs through Monerium OAuth again. - */ -export function isCorridorOnboardingDisabled(_corridor: Corridor): boolean { - return false; -} diff --git a/apps/dashboard/src/domain/transfer.test.ts b/apps/dashboard/src/domain/transfer.test.ts new file mode 100644 index 0000000000..ccac07bf32 --- /dev/null +++ b/apps/dashboard/src/domain/transfer.test.ts @@ -0,0 +1,20 @@ +import { FiatToken } from "@vortexfi/shared"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { offrampStartsAfterDeadline } from "./transfer"; + +describe("offrampStartsAfterDeadline", () => { + it("is true for a non-domestic SELL whose source hash the API accepted", () => { + assert.equal(offrampStartsAfterDeadline(FiatToken.BRL, true), true); + }); + + it("is false while the source hash is still unsubmitted", () => { + assert.equal(offrampStartsAfterDeadline(FiatToken.BRL, false), false); + }); + + it("is false for AlfredPay pay-outs, which the recovery worker never starts", () => { + for (const currency of [FiatToken.USD, FiatToken.MXN, FiatToken.COP, FiatToken.ARS]) { + assert.equal(offrampStartsAfterDeadline(currency, true), false); + } + }); +}); diff --git a/apps/dashboard/src/domain/transfer.ts b/apps/dashboard/src/domain/transfer.ts index c11b7ea7fc..e8261673ff 100644 --- a/apps/dashboard/src/domain/transfer.ts +++ b/apps/dashboard/src/domain/transfer.ts @@ -1,4 +1,4 @@ -import { Networks } from "@vortexfi/shared"; +import { isDomesticToken, Networks, type RampCurrency } from "@vortexfi/shared"; import type { RecipientMethod } from "./types"; /** Display label for the fiat rail each corridor settles on. */ @@ -29,3 +29,12 @@ export function shortenAddress(address: string): string { } return `${address.slice(0, 6)}…${address.slice(-4)}`; } + +/** + * Whether the API starts a broadcast offramp on its own once its 15-minute start deadline has + * passed. The API's recovery worker does so for a non-domestic (non-AlfredPay) SELL whose source + * hash /ramp/update has already accepted. + */ +export function offrampStartsAfterDeadline(outputCurrency: RampCurrency, sourceHashAccepted: boolean): boolean { + return sourceHashAccepted && !isDomesticToken(outputCurrency); +} diff --git a/apps/dashboard/src/domain/types.ts b/apps/dashboard/src/domain/types.ts index 56ec97c3b3..f45a29e5f8 100644 --- a/apps/dashboard/src/domain/types.ts +++ b/apps/dashboard/src/domain/types.ts @@ -20,8 +20,8 @@ export type CorridorId = z.infer; export type OnboardingKind = "kyb" | "kyc"; -/** How a corridor's onboarding is collected: in-dashboard wizard, external form, or partner redirect. */ -export type OnboardingRoute = "headless" | "google_form" | "redirect"; +/** How a corridor's onboarding is collected: in-dashboard wizard or partner redirect. */ +export type OnboardingRoute = "headless" | "redirect"; export type AccountType = "company" | "individual"; @@ -31,16 +31,12 @@ export type RecipientMethod = "pix" | "iban" | "spei" | "ach"; export type KycProvider = "alfredpay" | "avenia" | "monerium" | "mykobo"; -/** Brazil & Europe are live; Alfredpay corridors are selectable but not yet verifiable. */ -export type CorridorAvailability = "live" | "coming_soon"; - export interface Corridor { id: CorridorId; name: string; flag: string; currency: string; provider: KycProvider; - availability: CorridorAvailability; recipientMethod: RecipientMethod; recipientLabel: string; } @@ -131,13 +127,3 @@ export interface Recipient { fiatAccountId?: string; createdAt: string; } - -export interface AppNotification { - id: string; - title: string; - body: string; - /** The address the notification relates to. */ - email: string; - createdAt: string; - read: boolean; -} diff --git a/apps/dashboard/src/hooks/useActiveMaintenance.test.ts b/apps/dashboard/src/hooks/useActiveMaintenance.test.ts new file mode 100644 index 0000000000..893da9d4f9 --- /dev/null +++ b/apps/dashboard/src/hooks/useActiveMaintenance.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { MaintenanceStatusResponse } from "@/services/api/maintenance.service"; +import { maintenanceRefetchInterval } from "./useActiveMaintenance"; + +const NOW = Date.parse("2026-10-05T10:00:00.000Z"); +const MINUTE = 60 * 1000; + +function activeUntil(endDatetime: string): MaintenanceStatusResponse { + return { + is_maintenance_active: true, + maintenance_details: { + end_datetime: endDatetime, + message: "Ramps are paused while we upgrade.", + start_datetime: "2026-10-05T09:00:00.000Z", + title: "Scheduled maintenance" + } + }; +} + +describe("maintenanceRefetchInterval", () => { + it("polls every 5 minutes without an active window", () => { + assert.equal(maintenanceRefetchInterval(undefined, NOW), 5 * MINUTE); + assert.equal(maintenanceRefetchInterval({ is_maintenance_active: false, maintenance_details: null }, NOW), 5 * MINUTE); + }); + + it("refetches just after a window that ends before the next poll", () => { + assert.equal(maintenanceRefetchInterval(activeUntil("2026-10-05T10:02:00.000Z"), NOW), 2 * MINUTE + 15_000); + }); + + it("keeps the 5-minute poll for a window that ends later", () => { + assert.equal(maintenanceRefetchInterval(activeUntil("2026-10-05T11:00:00.000Z"), NOW), 5 * MINUTE); + }); + + it("rechecks shortly when the API still reports a window past its end", () => { + assert.equal(maintenanceRefetchInterval(activeUntil("2026-10-05T09:59:00.000Z"), NOW), 15_000); + }); + + it("falls back to the 5-minute poll for an unparseable end", () => { + assert.equal(maintenanceRefetchInterval(activeUntil("not a date"), NOW), 5 * MINUTE); + assert.equal(maintenanceRefetchInterval({ is_maintenance_active: true, maintenance_details: null }, NOW), 5 * MINUTE); + }); +}); diff --git a/apps/dashboard/src/hooks/useActiveMaintenance.ts b/apps/dashboard/src/hooks/useActiveMaintenance.ts new file mode 100644 index 0000000000..234b7721e4 --- /dev/null +++ b/apps/dashboard/src/hooks/useActiveMaintenance.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { + type MaintenanceDetails, + MaintenanceService, + type MaintenanceStatusResponse +} from "@/services/api/maintenance.service"; + +export const MAINTENANCE_QUOTE_ERROR = "Quotes are paused for scheduled maintenance. Try again once it ends."; + +const POLL_INTERVAL_MS = 5 * 60 * 1000; +// Slack after end_datetime so the refetch lands once the API has stopped rejecting. +const END_BUFFER_MS = 15_000; + +/** Poll every 5 minutes, but refetch just after an active window ends so actions unlock promptly. */ +export function maintenanceRefetchInterval(status: MaintenanceStatusResponse | undefined, now: number): number { + const endsAt = status?.is_maintenance_active ? Date.parse(status.maintenance_details?.end_datetime ?? "") : Number.NaN; + return Number.isNaN(endsAt) ? POLL_INTERVAL_MS : Math.min(POLL_INTERVAL_MS, Math.max(endsAt - now, 0) + END_BUFFER_MS); +} + +/** The active maintenance window, or null. The API rejects quotes and ramp mutations while one is active. */ +export function useActiveMaintenance(): MaintenanceDetails | null { + const { data } = useQuery({ + queryFn: MaintenanceService.getStatus, + queryKey: ["maintenance-status"], + refetchInterval: query => maintenanceRefetchInterval(query.state.data, Date.now()), + refetchOnWindowFocus: true + }); + + return data?.is_maintenance_active ? data.maintenance_details : null; +} diff --git a/apps/dashboard/src/lib/notify.ts b/apps/dashboard/src/lib/notify.ts index bc42c69ba0..d8642a4506 100644 --- a/apps/dashboard/src/lib/notify.ts +++ b/apps/dashboard/src/lib/notify.ts @@ -1,57 +1,33 @@ import { toast } from "sonner"; import type { OnboardingKind, OnboardingStatus } from "@/domain/types"; import { useAuthStore } from "@/stores/auth.store"; -import { useNotificationsStore } from "@/stores/notifications.store"; function currentEmail() { return useAuthStore.getState().user?.email ?? "you@vortex.fi"; } -/** - * Simulates the "email completion update": pushes an entry into the in-app - * notifications panel and fires a toast. Mirrors the real KYC_COMPLETED signal. - */ +/** Fires the "email completion update" toast. Mirrors the real KYC_COMPLETED signal. */ export function notifyOnboardingStatus(corridorName: string, kind: OnboardingKind, status: OnboardingStatus) { const email = currentEmail(); const label = kind.toUpperCase(); if (status === "in_review") { - const title = `${corridorName} ${label} submitted`; - const body = `Your ${corridorName} ${label} is now in review. We'll email ${email} when it's complete.`; - useNotificationsStore.getState().add({ body, email, title }); - toast.info(title, { description: `Confirmation sent to ${email}` }); + toast.info(`${corridorName} ${label} submitted`, { description: `Confirmation sent to ${email}` }); return; } if (status === "approved") { - const title = `${corridorName} ${label} approved`; - const body = `Your ${corridorName} ${label} was approved. You can now register recipients and transfer.`; - useNotificationsStore.getState().add({ body, email, title }); - toast.success(title, { description: `Completion email sent to ${email}` }); + toast.success(`${corridorName} ${label} approved`, { description: `Completion email sent to ${email}` }); return; } if (status === "rejected") { - const title = `${corridorName} ${label} needs attention`; - const body = `Your ${corridorName} ${label} could not be approved. Details were emailed to ${email}.`; - useNotificationsStore.getState().add({ body, email, title }); - toast.error(title, { description: `Details sent to ${email}` }); + toast.error(`${corridorName} ${label} needs attention`, { description: `Details sent to ${email}` }); } } export function notifyTransferCompleted(summary: string) { - const email = currentEmail(); - const title = "Transfer completed"; - const body = `${summary} settled successfully. A confirmation was sent to ${email}.`; - useNotificationsStore.getState().add({ body, email, title }); - toast.success(title, { description: summary }); -} - -export function notifyInviteLinkReady(corridorName: string) { - const email = currentEmail(); - const title = "Invite link ready"; - const body = `Share this ${corridorName} invite link with your recipient — they can receive transfers once they complete KYC/KYB.`; - useNotificationsStore.getState().add({ body, email, title }); + toast.success("Transfer completed", { description: summary }); } export function notifyInviteCopied() { diff --git a/apps/dashboard/src/lib/widget.ts b/apps/dashboard/src/lib/widget.ts index 835d227048..c000f6e8f9 100644 --- a/apps/dashboard/src/lib/widget.ts +++ b/apps/dashboard/src/lib/widget.ts @@ -1,5 +1,4 @@ import type { CorridorId } from "@/domain/types"; -import { CORRIDOR_KYB_REGION } from "@/services/api/mappers"; /** * The Vortex widget origin. The dashboard is hosted on its own domain, so the widget is @@ -12,13 +11,15 @@ const WIDGET_URL: string = /** * Widget onboarding entry point for a corridor — the **recipient** hand-off (plan §6.2). - * Senders onboard in the dashboard (§6.1). Deep-linkable corridors pin the region - * (`?kybLocked=`). The accepted invitation remains authoritative if the URL is edited. + * Senders onboard in the dashboard (§6.1). The corridor id doubles as the widget's KYB region + * code (`?kybLocked=`, see `KYB_REGIONS` in `apps/frontend/src/constants/kybRegions.ts`). That + * list excludes EU (EU recipients onboard via Monerium), so an EU link's `?kybLocked=EU` is not + * recognized and the corridor locks from the accepted invitation response instead, which stays + * authoritative if the URL is edited. */ export function onboardingUrl(corridorId: CorridorId, inviteToken?: string): string { - const region = CORRIDOR_KYB_REGION[corridorId]; const url = new URL(`${WIDGET_URL}/widget`); - if (region) url.searchParams.set("kybLocked", region); + url.searchParams.set("kybLocked", corridorId); if (inviteToken) url.searchParams.set("invite", inviteToken); return url.toString(); } diff --git a/apps/dashboard/src/machines/transfer.actors.test.ts b/apps/dashboard/src/machines/transfer.actors.test.ts new file mode 100644 index 0000000000..9af86eec4a --- /dev/null +++ b/apps/dashboard/src/machines/transfer.actors.test.ts @@ -0,0 +1,69 @@ +import type { RampProcess, UnsignedTx } from "@vortexfi/shared"; +import { mock } from "bun:test"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { MaintenanceService } from "@/services/api/maintenance.service"; + +mock.module("@/services/transactions/userSigning", () => ({ + signAndSubmitEvmTransaction: () => { + throw new Error("Wallet reached in transfer actors test"); + }, + signMultipleTypedData: () => { + throw new Error("Wallet reached in transfer actors test"); + } +})); + +const { signUserTransactions } = await import("./transfer.actors"); + +const input = { + ramp: { id: "ramp-sell" } as RampProcess, + userTxs: [ + { + network: "polygon", + nonce: 0, + phase: "squidRouterApprove", + signer: "0x1111111111111111111111111111111111111111", + txData: { data: "0x", to: "0x2222222222222222222222222222222222222222", value: "0" } + } + ] as unknown as UnsignedTx[] +}; + +async function withStatus(getStatus: typeof MaintenanceService.getStatus, run: () => Promise) { + const original = MaintenanceService.getStatus; + MaintenanceService.getStatus = getStatus; + try { + await run(); + } finally { + MaintenanceService.getStatus = original; + } +} + +describe("signUserTransactions maintenance check", () => { + it("stops before the wallet signs when a window has opened", () => + withStatus( + async () => ({ + is_maintenance_active: true, + maintenance_details: { + end_datetime: "2026-10-05T12:00:00.000Z", + message: "Ramps are paused while we upgrade.", + start_datetime: "2026-10-05T10:00:00.000Z", + title: "Scheduled maintenance" + } + }), + () => assert.rejects(signUserTransactions(input), /paused for scheduled maintenance\. Nothing was sent/) + )); + + it("signs when no window is active", () => + withStatus( + async () => ({ is_maintenance_active: false, maintenance_details: null }), + () => assert.rejects(signUserTransactions(input), /Wallet reached/) + )); + + it("signs when the status check fails, leaving enforcement to the API guard", () => + withStatus( + async () => { + throw new Error("network down"); + }, + () => assert.rejects(signUserTransactions(input), /Wallet reached/) + )); +}); diff --git a/apps/dashboard/src/machines/transfer.actors.ts b/apps/dashboard/src/machines/transfer.actors.ts index d006524fc1..8462324287 100644 --- a/apps/dashboard/src/machines/transfer.actors.ts +++ b/apps/dashboard/src/machines/transfer.actors.ts @@ -19,8 +19,10 @@ import { type RampProcess, type RegisterRampRequest, signUnsignedTransactions, - type UnsignedTx + type UnsignedTx, + type UpdateRampRequest } from "@vortexfi/shared"; +import { MaintenanceService } from "@/services/api/maintenance.service"; import { fetchQuote, type QuoteParams } from "@/services/api/quote.service"; import { shouldRefreshQuote } from "@/services/api/quote-expiry"; import { isTerminalPhase, RampService } from "@/services/api/ramp.service"; @@ -168,20 +170,30 @@ export async function registerTransfer(input: RegisterTransferInput): Promise; +} + /** * Ported from the widget's sign.actor (EVM paths): walks the user-owned transactions in * nonce order, signing typed data (offramp permits) and broadcasting squidRouter - * transactions with the connected wallet, then submits signatures + hashes via - * /ramp/update. + * transactions with the connected wallet. Returns the signatures + hashes for /ramp/update, + * which the machine submits as its own step so a failed update never re-broadcasts. */ -export async function signUserTransactions(input: SignUserTransactionsInput): Promise { - const { ramp, userTxs } = input; - if (userTxs.length === 0) { - return ramp; +export async function signUserTransactions(input: SignUserTransactionsInput): Promise { + const { userTxs } = input; + + // The banner's status can be minutes old. Once the wallet broadcasts, a window that has opened meanwhile 503s the + // final /ramp/update with funds already moved, so re-check right before signing. A failed check falls back to the + // API guard, like the rest of the UI. + const status = await MaintenanceService.getStatus().catch(() => null); + if (status?.is_maintenance_active && status.maintenance_details) { + throw new Error("Transfers are paused for scheduled maintenance. Nothing was sent from your wallet."); } const sortedTxs = [...userTxs].sort((a, b) => a.nonce - b.nonce); @@ -220,20 +232,23 @@ export async function signUserTransactions(input: SignUserTransactionsInput): Pr throw error; } - return RampService.updateRamp(ramp.id, signedTxs, { - squidRouterApproveHash, - squidRouterNoPermitApproveHash, - squidRouterNoPermitSwapHash, - squidRouterNoPermitTransferHash, - squidRouterSwapHash - }); + return { + additionalData: { + squidRouterApproveHash, + squidRouterNoPermitApproveHash, + squidRouterNoPermitSwapHash, + squidRouterNoPermitTransferHash, + squidRouterSwapHash + }, + signedTxs + }; } const POLL_INTERVAL_MS = 3000; /** * Polls /ramp/:id until a terminal phase, invoking onStatus on every tick. - * Returns a stop() function; mirrors the widget's RampService.pollRampStatus. + * Returns a stop() function. */ export function pollRampUntilTerminal( rampId: string, diff --git a/apps/dashboard/src/machines/transfer.machine.test.ts b/apps/dashboard/src/machines/transfer.machine.test.ts index 8080ff24ea..40a618c7f3 100644 --- a/apps/dashboard/src/machines/transfer.machine.test.ts +++ b/apps/dashboard/src/machines/transfer.machine.test.ts @@ -10,11 +10,14 @@ import { mock } from "bun:test"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createActor, fromPromise, waitFor } from "xstate"; -import type { TransferQuoteRequest } from "./transfer.actors"; +import type { TransferQuoteRequest, UserTxSubmission } from "./transfer.actors"; +const WALLET_TX_HASH = `0x${"cd".repeat(32)}`; +let broadcasts = 0; mock.module("@/services/transactions/userSigning", () => ({ - signAndSubmitEvmTransaction: () => { - throw new Error("Unexpected wallet signing in transfer machine test"); + signAndSubmitEvmTransaction: async () => { + broadcasts += 1; + return WALLET_TX_HASH; }, signMultipleTypedData: () => { throw new Error("Unexpected wallet signing in transfer machine test"); @@ -42,11 +45,12 @@ const ramp = { } as RampProcess; describe("transferMachine", () => { - it("blocks owner activation only during quote, balance, registration, and user signing", async () => { + it("blocks owner activation only during quote, balance, registration, user signing, and its submission", async () => { let releaseQuote: (() => void) | undefined; let releaseBalance: (() => void) | undefined; let releaseRegistration: (() => void) | undefined; let releaseSigning: (() => void) | undefined; + let releaseSubmission: (() => void) | undefined; let releaseStart: (() => void) | undefined; const sellQuote = { ...quote, rampType: RampDirection.SELL } as QuoteResponse; const sellRamp = { ...ramp, type: RampDirection.SELL } as RampProcess; @@ -65,9 +69,11 @@ describe("transferMachine", () => { ) ), signUserTransactions: fromPromise( - () => new Promise(resolve => (releaseSigning = () => resolve(sellRamp))) + () => + new Promise(resolve => (releaseSigning = () => resolve({ additionalData: {}, signedTxs: [] }))) ), startRamp: fromPromise(() => new Promise(resolve => (releaseStart = () => resolve(sellRamp)))), + submitUserTxs: fromPromise(() => new Promise(resolve => (releaseSubmission = () => resolve(sellRamp)))), trackRamp: fromPromise(async () => undefined) as never } }); @@ -99,7 +105,8 @@ describe("transferMachine", () => { ["CheckingQuote", () => releaseQuote?.()], ["CheckingBalance", () => releaseBalance?.()], ["Registering", () => releaseRegistration?.()], - ["SigningUserTxs", () => releaseSigning?.()] + ["SigningUserTxs", () => releaseSigning?.()], + ["SubmittingUserTxs", () => releaseSubmission?.()] ] as const) { await waitFor(actor, snapshot => snapshot.matches(state)); actor.send({ ownerProfileId: "profile-2", recovery: null, type: "ACTIVATE_OWNER" }); @@ -181,12 +188,16 @@ describe("transferMachine", () => { registerTransfer: fromPromise(async () => ({ ramp: eurRamp, userTxs: [permit] })), signUserTransactions: fromPromise(async ({ input }) => { signed += input.userTxs.length; - return { ...eurRamp, ibanPaymentData: { bic: "MONEEE00", iban: "EE52", receiverName: "Monerium" } } as RampProcess; + return { additionalData: {}, signedTxs: [] }; }), startRamp: fromPromise(async () => { startCalls += 1; return eurRamp; - }) + }), + submitUserTxs: fromPromise( + async () => + ({ ...eurRamp, ibanPaymentData: { bic: "MONEEE00", iban: "EE52", receiverName: "Monerium" } }) as RampProcess + ) } }); const actor = createActor(machine).start(); @@ -541,4 +552,147 @@ describe("transferMachine", () => { assert.equal(registerCalls, 0); actor.stop(); }); + + describe("offramp after the wallet broadcast", () => { + const wallet = "0x1111111111111111111111111111111111111111"; + const sellQuote = { id: "quote-sell", rampType: RampDirection.SELL } as QuoteResponse; + const sellRamp = { expiresAt: "2026-10-05T12:15:00.000Z", id: "ramp-sell", type: RampDirection.SELL } as RampProcess; + const walletTx = { + network: Networks.Polygon, + nonce: 0, + phase: "squidRouterNoPermitTransfer", + signer: wallet, + txData: { data: "0x", gas: "21000", to: "0x2222222222222222222222222222222222222222", value: "0" } + } as unknown as UnsignedTx; + const sellMeta = { + accountId: "account-1", + amountIn: "100", + amountInToken: "USDC", + corridorId: "MX" as const, + direction: RampDirection.SELL, + fiatPayoutAmount: "1850", + ownerProfileId: "profile-1", + payinNetwork: "polygon", + payoutCurrency: "MXN", + recipientEmail: "recipient@example.com", + recipientId: "recipient-1", + summary: "1850 MXN to recipient@example.com" + }; + const maintenance = "Vortex services are temporarily unavailable during scheduled maintenance"; + + function sellMachine(calls: { submissions: UserTxSubmission[]; starts: number }, fail: { submit?: number; start?: number }) { + return transferMachine.provide({ + actors: { + checkTransferBalance: fromPromise(async (): Promise => undefined), + refreshTransferQuote: fromPromise(async ({ input }) => ({ quote: input.quote })), + registerTransfer: fromPromise(async () => ({ ramp: sellRamp, userTxs: [walletTx] })), + startRamp: fromPromise(async () => { + calls.starts += 1; + if (calls.starts <= (fail.start ?? 0)) throw new Error(maintenance); + return sellRamp; + }), + submitUserTxs: fromPromise(async ({ input }) => { + calls.submissions.push(input.submission); + if (calls.submissions.length <= (fail.submit ?? 0)) throw new Error(maintenance); + return sellRamp; + }), + trackRamp: fromPromise(async () => undefined) as never + } + }); + } + + function startSell(actor: { send: (event: never) => void }) { + actor.send({ ownerProfileId: "profile-1", recovery: null, type: "ACTIVATE_OWNER" } as never); + actor.send({ + additionalData: { walletAddress: wallet }, + meta: sellMeta, + ownerProfileId: "profile-1", + quote: sellQuote, + quoteRequest: { ...quoteRequest, params: { ...quoteRequest.params, direction: RampDirection.SELL } }, + type: "START" + } as never); + } + + it("keeps the ramp and broadcast hash when the final update fails, and resends it without re-broadcasting", async () => { + broadcasts = 0; + const calls = { starts: 0, submissions: [] as UserTxSubmission[] }; + const actor = createActor(sellMachine(calls, { submit: 1 })).start(); + startSell(actor); + + await waitFor(actor, snapshot => snapshot.matches("AwaitingRetry")); + const failed = actor.getSnapshot().context; + assert.equal(broadcasts, 1); + assert.equal(calls.starts, 0); + assert.equal(failed.errorMessage, maintenance); + assert.equal(failed.ramp?.id, "ramp-sell"); + assert.equal(failed.ramp?.expiresAt, "2026-10-05T12:15:00.000Z"); + assert.equal(failed.userTxSubmission?.additionalData.squidRouterNoPermitTransferHash, WALLET_TX_HASH); + + actor.send({ ownerProfileId: "profile-2", type: "RETRY" }); + assert.equal(actor.getSnapshot().value, "AwaitingRetry"); + + actor.send({ ownerProfileId: "profile-1", type: "RETRY" }); + await waitFor(actor, snapshot => snapshot.matches("Tracking")); + assert.equal(broadcasts, 1); + assert.equal(calls.submissions.length, 2); + assert.deepEqual(calls.submissions[1], calls.submissions[0]); + assert.equal(calls.starts, 1); + assert.equal(actor.getSnapshot().context.userTxSubmission, null); + assert.equal(actor.getSnapshot().context.errorMessage, null); + actor.stop(); + }); + + it("retries only the start once the update went through", async () => { + broadcasts = 0; + const calls = { starts: 0, submissions: [] as UserTxSubmission[] }; + const actor = createActor(sellMachine(calls, { start: 1 })).start(); + startSell(actor); + + await waitFor(actor, snapshot => snapshot.matches("AwaitingRetry")); + assert.equal(actor.getSnapshot().context.errorMessage, maintenance); + assert.equal(actor.getSnapshot().context.userTxSubmission, null); + assert.equal(actor.getSnapshot().context.ramp?.id, "ramp-sell"); + + actor.send({ ownerProfileId: "profile-1", type: "RETRY" }); + await waitFor(actor, snapshot => snapshot.matches("Tracking")); + assert.equal(broadcasts, 1); + assert.equal(calls.submissions.length, 1); + assert.equal(calls.starts, 2); + actor.stop(); + }); + + it("resumes a restored offramp at the update it still owes, without the wallet", async () => { + broadcasts = 0; + const calls = { starts: 0, submissions: [] as UserTxSubmission[] }; + const submission: UserTxSubmission = { + additionalData: { squidRouterNoPermitTransferHash: WALLET_TX_HASH }, + signedTxs: [] + }; + const actor = createActor(sellMachine(calls, {})).start(); + actor.send({ + ownerProfileId: "profile-1", + recovery: { + activeOwnerProfileId: "profile-1", + additionalData: null, + errorMessage: null, + lastStatus: null, + meta: sellMeta, + quote: sellQuote, + quoteRequest: null, + ramp: sellRamp, + userTxSubmission: submission, + userTxs: [] + }, + type: "ACTIVATE_OWNER" + }); + assert.equal(actor.getSnapshot().value, "AwaitingRetry"); + + actor.send({ ownerProfileId: "profile-1", type: "RETRY" }); + await waitFor(actor, snapshot => snapshot.matches("Tracking")); + assert.equal(broadcasts, 0); + assert.deepEqual(calls.submissions, [submission]); + assert.equal(calls.starts, 1); + actor.stop(); + }); + }); }); diff --git a/apps/dashboard/src/machines/transfer.machine.ts b/apps/dashboard/src/machines/transfer.machine.ts index 0648e46afd..44cb546224 100644 --- a/apps/dashboard/src/machines/transfer.machine.ts +++ b/apps/dashboard/src/machines/transfer.machine.ts @@ -5,7 +5,7 @@ import { type RampProcess, type UnsignedTx } from "@vortexfi/shared"; -import { assign, emit, fromCallback, fromPromise, setup } from "xstate"; +import { and, assign, emit, fromCallback, fromPromise, setup } from "xstate"; import type { Transaction } from "@/domain/types"; import { type CheckTransferBalanceInput, @@ -18,7 +18,8 @@ import { registerTransfer, signUserTransactions, type TransferQuoteRequest, - UserRejectedError + UserRejectedError, + type UserTxSubmission } from "./transfer.actors"; /** Everything the transactions table needs, captured at submit time. */ @@ -37,6 +38,8 @@ export interface TransferContext { meta: TransferMeta | null; ramp: RampProcess | null; userTxs: UnsignedTx[]; + /** Wallet output not yet accepted by /ramp/update. Kept so a retry resends it instead of re-signing. */ + userTxSubmission: UserTxSubmission | null; lastStatus: GetRampStatusResponse | null; errorMessage: string | null; } @@ -53,6 +56,7 @@ export type TransferEvent = | { type: "STATUS_UPDATE"; status: GetRampStatusResponse } | { type: "TERMINAL"; status: GetRampStatusResponse } | { type: "PAYMENT_CONFIRMED"; ownerProfileId: string } + | { type: "RETRY"; ownerProfileId: string } | { type: "ACTIVATE_OWNER"; ownerProfileId: string; recovery: TransferContext | null } | { type: "RESET" }; @@ -70,6 +74,7 @@ const initialContext: TransferContext = { quote: null, quoteRequest: null, ramp: null, + userTxSubmission: null, userTxs: [] }; @@ -107,13 +112,15 @@ export const transferMachine = setup({ checkTransferBalance: fromPromise(({ input }: { input: CheckTransferBalanceInput }) => checkTransferBalance(input)), refreshTransferQuote: fromPromise(({ input }: { input: RefreshTransferQuoteInput }) => refreshTransferQuote(input)), registerTransfer: fromPromise(({ input }: { input: RegisterTransferInput }) => registerTransfer(input)), - signUserTransactions: fromPromise(({ input }: { input: { ramp: RampProcess; userTxs: UnsignedTx[] } }) => - signUserTransactions(input) - ), + signUserTransactions: fromPromise(({ input }: { input: { userTxs: UnsignedTx[] } }) => signUserTransactions(input)), startRamp: fromPromise(async ({ input }: { input: { rampId: string } }) => { const { RampService } = await import("@/services/api/ramp.service"); return RampService.startRamp(input.rampId); }), + submitUserTxs: fromPromise(async ({ input }: { input: { rampId: string; submission: UserTxSubmission } }) => { + const { RampService } = await import("@/services/api/ramp.service"); + return RampService.updateRamp(input.rampId, input.submission.signedTxs, input.submission.additionalData); + }), trackRamp: fromCallback(({ sendBack, input }) => pollRampUntilTerminal( input.rampId, @@ -123,6 +130,8 @@ export const transferMachine = setup({ ) }, guards: { + hasPendingUserTxs: ({ context }) => context.userTxSubmission !== null, + isOfframpRecovery: ({ event }) => event.type === "ACTIVATE_OWNER" && event.recovery?.quote?.rampType === RampDirection.SELL, isOnramp: ({ context }) => context.quote?.rampType === RampDirection.BUY, isOnrampWithoutUserTxs: ({ context, event }) => { const output = (event as unknown as { output?: RegisterTransferOutput }).output; @@ -146,6 +155,11 @@ export const transferMachine = setup({ initial: "Idle", on: { ACTIVATE_OWNER: [ + { + actions: assign(({ event }) => ({ ...event.recovery, activeOwnerProfileId: event.ownerProfileId })), + guard: "isOfframpRecovery", + target: ".AwaitingRetry" + }, { actions: assign(({ event }) => ({ ...event.recovery, activeOwnerProfileId: event.ownerProfileId })), guard: "isRecoveryActivation", @@ -171,6 +185,25 @@ export const transferMachine = setup({ } } }, + // An offramp whose wallet transactions are already broadcast, but whose /ramp/update or + // /ramp/start failed. The funds sit on the ephemeral account, so the ramp and the wallet + // output must survive: RETRY resends the failed call, never the wallet transactions. + AwaitingRetry: { + on: { + RETRY: [ + { + actions: assign(() => ({ errorMessage: null })), + guard: and(["isOwnerEvent", "hasPendingUserTxs"]), + target: "SubmittingUserTxs" + }, + { + actions: assign(() => ({ errorMessage: null })), + guard: "isOwnerEvent", + target: "Starting" + } + ] + } + }, CheckingBalance: { invoke: { input: ({ context }) => { @@ -297,32 +330,11 @@ export const transferMachine = setup({ }, SigningUserTxs: { invoke: { - input: ({ context }) => { - if (!context.ramp) { - throw new Error("Ramp is missing"); - } - return { ramp: context.ramp, userTxs: context.userTxs }; + input: ({ context }) => ({ userTxs: context.userTxs }), + onDone: { + actions: assign(({ event }) => ({ userTxSubmission: event.output })), + target: "SubmittingUserTxs" }, - onDone: [ - { - // An onramp releases its payment instructions only once the owner-signed - // transactions are in, so keep whatever the update returned. - actions: assign(({ context, event }) => ({ - ramp: { - ...event.output, - achPaymentData: event.output.achPaymentData ?? context.ramp?.achPaymentData, - depositQrCode: event.output.depositQrCode ?? context.ramp?.depositQrCode, - ibanPaymentData: event.output.ibanPaymentData ?? context.ramp?.ibanPaymentData - } - })), - guard: "isOnramp", - target: "AwaitingPayment" - }, - { - actions: assign(({ event }) => ({ ramp: event.output })), - target: "Starting" - } - ], onError: { actions: [ assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), @@ -353,8 +365,9 @@ export const transferMachine = setup({ })), target: "Tracking" }, - // A BUY user may already have paid, so the ramp and its instructions must survive a - // failed start: back to AwaitingPayment, where PAYMENT_CONFIRMED retries the same ramp. + // A BUY user may already have paid, and a SELL user's tokens have already left the wallet, + // so the ramp must survive a failed start: back to AwaitingPayment (PAYMENT_CONFIRMED) or + // AwaitingRetry (RETRY), both of which retry the same ramp. onError: [ { actions: [ @@ -369,12 +382,58 @@ export const transferMachine = setup({ assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), emit(({ event }) => ({ message: errorMessage(event.error), type: "TRANSFER_FAILED" as const })) ], - target: "Failed" + target: "AwaitingRetry" } ], src: "startRamp" } }, + SubmittingUserTxs: { + invoke: { + input: ({ context }) => { + if (!context.ramp || !context.userTxSubmission) { + throw new Error("Signed wallet transactions are missing"); + } + return { rampId: context.ramp.id, submission: context.userTxSubmission }; + }, + onDone: [ + { + // An onramp releases its payment instructions only once the owner-signed + // transactions are in, so keep whatever the update returned. + actions: assign(({ context, event }) => ({ + ramp: { + ...event.output, + achPaymentData: event.output.achPaymentData ?? context.ramp?.achPaymentData, + depositQrCode: event.output.depositQrCode ?? context.ramp?.depositQrCode, + ibanPaymentData: event.output.ibanPaymentData ?? context.ramp?.ibanPaymentData + }, + userTxSubmission: null + })), + guard: "isOnramp", + target: "AwaitingPayment" + }, + { + actions: assign(({ event }) => ({ ramp: event.output, userTxSubmission: null })), + target: "Starting" + } + ], + // An onramp's wallet only signed a permit, so nothing has moved yet. An offramp's + // wallet has already broadcast: keep everything so the same update can be resent. + onError: [ + { + actions: assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), + guard: "isOnramp", + target: "Failed" + }, + { + actions: assign(({ event }) => ({ errorMessage: errorMessage(event.error) })), + target: "AwaitingRetry" + } + ], + src: "submitUserTxs" + }, + on: { ACTIVATE_OWNER: {} } + }, Tracking: { entry: emit(({ context }) => { if (!context.ramp || !context.meta) { diff --git a/apps/dashboard/src/machines/transferActor.test.ts b/apps/dashboard/src/machines/transferActor.test.ts index f6e4534c3a..f7c2a97670 100644 --- a/apps/dashboard/src/machines/transferActor.test.ts +++ b/apps/dashboard/src/machines/transferActor.test.ts @@ -85,7 +85,6 @@ async function recoverySnapshot(ownerProfileId: string, accountId: string): Prom return persisted; } -values.set("vortex-dashboard-transfer-state", "unowned legacy state"); const { activateTransferOwner, canChangeEffectiveIdentity, clearAllTransferRecovery, resetTransferState, transferActor } = await import("./transferActor"); @@ -99,8 +98,7 @@ after(() => { }); describe("transferActor owner recovery", () => { - it("rejects legacy state and restores only the selected owner's snapshot", async () => { - assert.equal(values.has("vortex-dashboard-transfer-state"), false); + it("restores only the selected owner's snapshot", async () => { const ownerOneKey = "vortex-dashboard-transfer-state:owner:profile-1"; const ownerTwoKey = "vortex-dashboard-transfer-state:owner:profile-2"; values.set(ownerOneKey, await recoverySnapshot("profile-1", "account-1")); @@ -139,7 +137,9 @@ describe("transferActor owner recovery", () => { const corruptions = [ { ...complete, version: undefined }, { ...complete, ramp: undefined }, - { ...complete, meta: { ...complete.meta, ownerProfileId: "profile-else" } } + { ...complete, meta: { ...complete.meta, ownerProfileId: "profile-else" } }, + // Only an offramp owes /ramp/update after its wallet step; a BUY snapshot never carries one. + { ...complete, userTxSubmission: { additionalData: {}, signedTxs: [] } } ]; for (const corruption of corruptions) { @@ -173,6 +173,49 @@ describe("transferActor owner recovery", () => { assert.equal(transferActor.getSnapshot().context.activeOwnerProfileId, "profile-5"); }); + it("restores an offramp awaiting retry with the wallet output it still owes the API", () => { + const key = "vortex-dashboard-transfer-state:owner:profile-6"; + const hash = `0x${"cd".repeat(32)}`; + const snapshot = { + meta: { + accountId: "account-6", + amountIn: "100", + amountInToken: "USDC", + corridorId: "MX", + direction: RampDirection.SELL, + fiatPayoutAmount: "1850", + ownerProfileId: "profile-6", + payinNetwork: "polygon", + payoutCurrency: "MXN", + recipientEmail: "recipient@example.com", + recipientId: "recipient-6", + summary: "1850 MXN to recipient@example.com" + }, + ownerProfileId: "profile-6", + quote: { id: "quote-sell", rampType: RampDirection.SELL }, + ramp: { expiresAt: "2026-10-05T12:15:00.000Z", id: "ramp-sell", type: RampDirection.SELL }, + userTxSubmission: { additionalData: { squidRouterNoPermitTransferHash: hash }, signedTxs: [] }, + version: 1 + }; + + values.set(key, JSON.stringify({ ...snapshot, userTxSubmission: { additionalData: {}, signedTxs: "corrupt" } })); + assert.equal(activateTransferOwner("profile-6"), true); + assert.equal(values.has(key), false); + assert.equal(transferActor.getSnapshot().value, "Idle"); + + assert.equal(activateTransferOwner("profile-existing"), true); + values.set(key, JSON.stringify(snapshot)); + assert.equal(activateTransferOwner("profile-6"), true); + const restored = transferActor.getSnapshot(); + assert.equal(restored.value, "AwaitingRetry"); + assert.equal(restored.context.ramp?.id, "ramp-sell"); + assert.equal(restored.context.userTxSubmission?.additionalData.squidRouterNoPermitTransferHash, hash); + assert.deepEqual(JSON.parse(values.get(key) ?? "null"), snapshot); + + resetTransferState(); + assert.equal(values.has(key), false); + }); + it("allows identity changes while idle", () => { assert.equal(canChangeEffectiveIdentity(), true); }); diff --git a/apps/dashboard/src/machines/transferActor.ts b/apps/dashboard/src/machines/transferActor.ts index 5e2f4ad2f9..33b29c24c9 100644 --- a/apps/dashboard/src/machines/transferActor.ts +++ b/apps/dashboard/src/machines/transferActor.ts @@ -1,8 +1,9 @@ import { type QuoteResponse, RampDirection, type RampProcess } from "@vortexfi/shared"; -import { type Actor, createActor } from "xstate"; +import { createActor } from "xstate"; import { TRANSACTIONS_QUERY_KEY } from "@/hooks/useTransactions"; import { notifyTransferCompleted } from "@/lib/notify"; import { queryClient } from "@/lib/queryClient"; +import type { UserTxSubmission } from "./transfer.actors"; import { type TransferContext, type TransferMeta, transferMachine } from "./transfer.machine"; /** @@ -10,8 +11,7 @@ import { type TransferContext, type TransferMeta, transferMachine } from "./tran * keeps running here after the form unmounts. Transaction rows come from the backend ramp * history, so each status change just invalidates that query to pull the latest. */ -const LEGACY_TRANSFER_STATE_STORAGE_KEY = "vortex-dashboard-transfer-state"; -const TRANSFER_STATE_STORAGE_PREFIX = `${LEGACY_TRANSFER_STATE_STORAGE_KEY}:owner:`; +const TRANSFER_STATE_STORAGE_PREFIX = "vortex-dashboard-transfer-state:owner:"; const TRANSFER_RECOVERY_VERSION = 1; interface PersistedTransferRecovery { @@ -19,6 +19,8 @@ interface PersistedTransferRecovery { ownerProfileId: string; quote: QuoteResponse; ramp: RampProcess; + /** Offramp only: wallet output still owed to /ramp/update. Absent in BUY snapshots. */ + userTxSubmission?: UserTxSubmission | null; version: typeof TRANSFER_RECOVERY_VERSION; } @@ -34,14 +36,20 @@ function recoveryContext(value: Record, ownerProfileId: string) const quote = value.quote; const meta = value.meta; const ramp = value.ramp; - return isRecord(quote) && - quote.rampType === RampDirection.BUY && + const submission = value.userTxSubmission ?? null; + const direction = isRecord(quote) ? quote.rampType : undefined; + return (direction === RampDirection.BUY || direction === RampDirection.SELL) && isRecord(meta) && meta.ownerProfileId === ownerProfileId && - meta.direction === RampDirection.BUY && + meta.direction === direction && isRecord(ramp) && - ramp.type === RampDirection.BUY && - typeof ramp.id === "string" + ramp.type === direction && + typeof ramp.id === "string" && + (submission === null || + (direction === RampDirection.SELL && + isRecord(submission) && + Array.isArray(submission.signedTxs) && + isRecord(submission.additionalData))) ? { activeOwnerProfileId: ownerProfileId, additionalData: null, @@ -51,6 +59,7 @@ function recoveryContext(value: Record, ownerProfileId: string) quote: quote as unknown as QuoteResponse, quoteRequest: null, ramp: ramp as unknown as RampProcess, + userTxSubmission: submission as UserTxSubmission | null, userTxs: [] } : undefined; @@ -78,13 +87,7 @@ function readPersistedTransferState(ownerProfileId: string): TransferContext | u } } -function startTransferActor(): Actor { - // Ownerless legacy state cannot be attributed safely and must never be adopted. - localStorage.removeItem(LEGACY_TRANSFER_STATE_STORAGE_KEY); - return createActor(transferMachine).start(); -} - -export const transferActor = startTransferActor(); +export const transferActor = createActor(transferMachine).start(); const notifiedRampIds = new Set(); @@ -94,7 +97,8 @@ export function canChangeEffectiveIdentity(): boolean { snapshot.matches("CheckingQuote") || snapshot.matches("CheckingBalance") || snapshot.matches("Registering") || - snapshot.matches("SigningUserTxs") + snapshot.matches("SigningUserTxs") || + snapshot.matches("SubmittingUserTxs") ); } @@ -119,7 +123,6 @@ export function activateTransferOwner(ownerProfileId: string): boolean { export function clearAllTransferRecovery(): void { notifiedRampIds.clear(); - localStorage.removeItem(LEGACY_TRANSFER_STATE_STORAGE_KEY); for (let index = localStorage.length - 1; index >= 0; index -= 1) { const key = localStorage.key(index); if (key?.startsWith(TRANSFER_STATE_STORAGE_PREFIX)) { @@ -156,9 +159,17 @@ transferActor.on("STATUS_CHANGED", event => { transferActor.subscribe(snapshot => { try { - if (snapshot.matches("AwaitingPayment")) { + // A BUY user may already have paid, and a SELL user's wallet has already broadcast, so from + // here until tracking a reload must bring the ramp back (and an offramp's unsubmitted wallet + // output) so update/start can be retried. + if ( + snapshot.matches("AwaitingPayment") || + snapshot.matches("SubmittingUserTxs") || + snapshot.matches("Starting") || + snapshot.matches("AwaitingRetry") + ) { const ownerProfileId = snapshot.context.activeOwnerProfileId; - const { meta, quote, ramp } = snapshot.context; + const { meta, quote, ramp, userTxSubmission } = snapshot.context; if (!ownerProfileId || meta?.ownerProfileId !== ownerProfileId || !quote || !ramp) { return; } @@ -167,13 +178,12 @@ transferActor.subscribe(snapshot => { ownerProfileId, quote, ramp, + userTxSubmission, version: TRANSFER_RECOVERY_VERSION }; localStorage.setItem(storageKey(ownerProfileId), JSON.stringify(recovery)); refreshTransactions(); - } else if (!snapshot.matches("Starting")) { - // Keep the AwaitingPayment snapshot through Starting: the user may already have - // paid, and a reload must bring the instructions back so start can be retried. + } else { const ownerProfileId = snapshot.context.activeOwnerProfileId; if (ownerProfileId) { localStorage.removeItem(storageKey(ownerProfileId)); diff --git a/apps/dashboard/src/routes/_app.tsx b/apps/dashboard/src/routes/_app.tsx index ee5ab0ba35..478064698d 100644 --- a/apps/dashboard/src/routes/_app.tsx +++ b/apps/dashboard/src/routes/_app.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Navigate, Outlet, useRouterState } from "@tanstack/rea import { motion } from "motion/react"; import { AppSidebar } from "@/components/layout/AppSidebar"; import { ImpersonationBanner } from "@/components/layout/ImpersonationBanner"; +import { MaintenanceBanner } from "@/components/layout/MaintenanceBanner"; import { ManagedProfileBanner } from "@/components/layout/ManagedProfileBanner"; import { Topbar } from "@/components/layout/Topbar"; import { isChildModePathForbidden } from "@/components/managed-profiles/managed-profile-ui"; @@ -59,6 +60,7 @@ function AppLayout() {
    + diff --git a/apps/dashboard/src/routes/_app/recipients.tsx b/apps/dashboard/src/routes/_app/recipients.tsx index 810c600ce3..8ddf670b01 100644 --- a/apps/dashboard/src/routes/_app/recipients.tsx +++ b/apps/dashboard/src/routes/_app/recipients.tsx @@ -6,7 +6,7 @@ import { RecipientDialog } from "@/components/recipients/RecipientDialog"; import { RecipientsTable } from "@/components/recipients/RecipientsTable"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { CORRIDOR_LIST, CORRIDORS } from "@/domain/corridors"; +import { CORRIDOR_LIST } from "@/domain/corridors"; import { useActiveAccount } from "@/hooks/useActiveAccount"; import { useRecipients } from "@/hooks/useRecipients"; import { popIn } from "@/lib/motion"; @@ -23,10 +23,10 @@ function RecipientsPage() { return null; } - // Any approved corridor unlocks inviting to every live corridor; default to the first approved one. + // Any approved corridor unlocks inviting to every corridor; default to the first approved one. const hasApprovedCorridor = approvedIds.size > 0; - const corridors = hasApprovedCorridor ? CORRIDOR_LIST.filter(corridor => corridor.availability === "live") : []; - const defaultCorridorId = [...approvedIds].map(id => CORRIDORS[id]).find(corridor => corridor.availability === "live")?.id; + const corridors = hasApprovedCorridor ? CORRIDOR_LIST : []; + const [defaultCorridorId] = approvedIds; return ( diff --git a/apps/dashboard/src/routes/_app/transactions.tsx b/apps/dashboard/src/routes/_app/transactions.tsx index 7ac7433ae3..16ce7ef600 100644 --- a/apps/dashboard/src/routes/_app/transactions.tsx +++ b/apps/dashboard/src/routes/_app/transactions.tsx @@ -20,11 +20,14 @@ function TransactionsPage() { const account = useActiveAccount(); const { transactions } = useTransactions(account); const { recipients } = useRecipients(account); - const resumableRamp = useSelector(transferActor, snapshot => - snapshot.matches("AwaitingPayment") && + const resumable = useSelector(transferActor, snapshot => snapshot.context.meta?.ownerProfileId === snapshot.context.activeOwnerProfileId && snapshot.context.meta.accountId === account?.id - ? snapshot.context.ramp + ? snapshot.matches("AwaitingPayment") + ? "payment" + : snapshot.matches("AwaitingRetry") + ? "start" + : null : null ); @@ -42,19 +45,23 @@ function TransactionsPage() {

    Pay-in and pay-out history for {account.name}.

    - {resumableRamp && ( + {resumable && (
    -

    Payment awaiting confirmation

    +

    + {resumable === "payment" ? "Payment awaiting confirmation" : "Transfer not started yet"} +

    - Your payment instructions are saved until the payment window expires. + {resumable === "payment" + ? "Your payment instructions are saved until the payment window expires." + : "Your tokens left your wallet. Start the transfer before its start window closes."}

    diff --git a/apps/dashboard/src/services/api/maintenance.service.ts b/apps/dashboard/src/services/api/maintenance.service.ts new file mode 100644 index 0000000000..b338c18d9b --- /dev/null +++ b/apps/dashboard/src/services/api/maintenance.service.ts @@ -0,0 +1,18 @@ +import { apiClient } from "./api-client"; + +// Mirrors GET /v1/maintenance/status (apps/api/src/api/services/maintenance.service.ts). +export interface MaintenanceDetails { + title: string; + message: string; + start_datetime: string; + end_datetime: string; +} + +export interface MaintenanceStatusResponse { + is_maintenance_active: boolean; + maintenance_details: MaintenanceDetails | null; +} + +export const MaintenanceService = { + getStatus: () => apiClient.get("/maintenance/status") +}; diff --git a/apps/dashboard/src/services/api/mappers.ts b/apps/dashboard/src/services/api/mappers.ts index f50b1c7c24..d3837a4707 100644 --- a/apps/dashboard/src/services/api/mappers.ts +++ b/apps/dashboard/src/services/api/mappers.ts @@ -1,9 +1,6 @@ -import { CORRIDOR_FIAT_TOKEN, EPaymentMethod, FiatToken, Networks, type PaymentMethod } from "@vortexfi/shared"; +import { CORRIDOR_CAPABILITIES, EPaymentMethod, type PaymentMethod } from "@vortexfi/shared"; import type { CorridorId } from "@/domain/types"; -/** Dashboard corridor → wire FiatToken (note EURC → "EUR"). */ -export const CORRIDOR_FIAT: Record = CORRIDOR_FIAT_TOKEN; - /** Dashboard corridor → wire PaymentMethod. */ export const CORRIDOR_PAYMENT_METHOD: Record = { AR: EPaymentMethod.CBU, @@ -25,57 +22,12 @@ export const CORRIDOR_COUNTRY: Record = { }; /** - * Dashboard corridor → payout rail as the recipient backend expects it (the lowercased - * currency code). `providerForRail` routes eur→monerium, brl→avenia, everything else→alfredpay. + * Maps a fetched recipient's payout rail (the lowercased currency code the recipient backend uses; + * `providerForRail` routes eur→monerium, brl→avenia, everything else→alfredpay) back to its corridor. */ -export const CORRIDOR_RAIL: Record = { - AR: "ars", - BR: "brl", - CO: "cop", - EU: "eur", - MX: "mxn", - US: "usd" -}; - -/** Inverse of CORRIDOR_RAIL — maps a fetched recipient's rail back to its corridor. */ export const CORRIDOR_BY_RAIL: Record = Object.fromEntries( - Object.entries(CORRIDOR_RAIL).map(([corridorId, rail]) => [rail, corridorId as CorridorId]) + Object.entries(CORRIDOR_CAPABILITIES).map(([corridorId, { rail }]) => [rail, corridorId as CorridorId]) ) as Record; /** AlfredPay corridors expose a fetchable list of saved fiat (payout) accounts. */ export const ALFREDPAY_CORRIDORS: CorridorId[] = ["US", "MX", "CO", "AR"]; - -/** Inverse of CORRIDOR_FIAT — maps a ramp-history payout currency back to its corridor. */ -export const CORRIDOR_BY_FIAT: Partial> = Object.fromEntries( - Object.entries(CORRIDOR_FIAT).map(([corridorId, fiat]) => [fiat, corridorId as CorridorId]) -) as Partial>; - -/** - * Region code for the widget's KYB deep link (`?kybLocked=`). Mirrors `KYB_REGIONS` in - * `apps/frontend/src/constants/kybRegions.ts`, plus EU, which the widget's region list - * excludes (EU recipients onboard via Monerium): an EU link's `?kybLocked=EU` is not - * recognized, and the corridor locks from the accepted invitation response instead. - */ -export const CORRIDOR_KYB_REGION: Partial> = { - AR: "AR", - BR: "BR", - CO: "CO", - EU: "EU", - MX: "MX", - US: "US" -}; - -/** Min/max payout limits per corridor, in units of the corridor's fiat currency. */ -export const CORRIDOR_LIMITS: Record = { - AR: { max: 9_000_000, min: 9_500 }, - BR: { max: 50_000, min: 55 }, - CO: { max: 40_000_000, min: 40_000 }, - EU: { max: 10_000, min: 10 }, - MX: { max: 180_000, min: 185 }, - US: { max: 10_000, min: 10 } -}; - -/** Dashboard transfer-network id → wire Networks (values already match). */ -export function toWireNetwork(networkId: string): Networks { - return networkId as Networks; -} diff --git a/apps/dashboard/src/services/api/quote.service.ts b/apps/dashboard/src/services/api/quote.service.ts index 5a8ee67bf1..71387d36b1 100644 --- a/apps/dashboard/src/services/api/quote.service.ts +++ b/apps/dashboard/src/services/api/quote.service.ts @@ -1,4 +1,5 @@ import { + CORRIDOR_FIAT_TOKEN, type CreateQuoteRequest, type EvmNetworks, type OnChainToken, @@ -7,7 +8,7 @@ import { } from "@vortexfi/shared"; import type { CorridorId } from "@/domain/types"; import { apiClient } from "./api-client"; -import { CORRIDOR_COUNTRY, CORRIDOR_FIAT, CORRIDOR_PAYMENT_METHOD } from "./mappers"; +import { CORRIDOR_COUNTRY, CORRIDOR_PAYMENT_METHOD } from "./mappers"; export interface QuoteParams { corridorId: CorridorId; @@ -24,7 +25,7 @@ export interface QuoteParams { */ export function buildQuoteRequest(params: QuoteParams): CreateQuoteRequest { const { corridorId, direction, inputAmount, network, token } = params; - const fiat = CORRIDOR_FIAT[corridorId]; + const fiat = CORRIDOR_FIAT_TOKEN[corridorId]; const paymentMethod = CORRIDOR_PAYMENT_METHOD[corridorId]; const isBuy = direction === RampDirection.BUY; diff --git a/apps/dashboard/src/services/api/transaction.mappers.ts b/apps/dashboard/src/services/api/transaction.mappers.ts index 4ec80ee044..8ed2e6a5cc 100644 --- a/apps/dashboard/src/services/api/transaction.mappers.ts +++ b/apps/dashboard/src/services/api/transaction.mappers.ts @@ -1,11 +1,11 @@ import { + FIAT_TOKEN_CORRIDOR, type FiatToken, type GetRampHistoryTransaction, RampDirection, TransactionStatus as WireTransactionStatus } from "@vortexfi/shared"; import type { Transaction, TransactionStatus } from "@/domain/types"; -import { CORRIDOR_BY_FIAT } from "./mappers"; export function mapTransactionStatus(tx: Pick): TransactionStatus { if (tx.currentPhase === "timedOut") { @@ -28,7 +28,7 @@ export function mapRampHistoryTransaction(tx: GetRampHistoryTransaction, account return null; } const isOnramp = tx.type === RampDirection.BUY; - const corridorId = CORRIDOR_BY_FIAT[(isOnramp ? tx.fromCurrency : tx.toCurrency) as FiatToken]; + const corridorId = FIAT_TOKEN_CORRIDOR[(isOnramp ? tx.fromCurrency : tx.toCurrency) as FiatToken]; if (!corridorId) { return null; } diff --git a/apps/dashboard/src/services/auth.test.ts b/apps/dashboard/src/services/auth.test.ts index 74f56bbda1..ae1ca176d4 100644 --- a/apps/dashboard/src/services/auth.test.ts +++ b/apps/dashboard/src/services/auth.test.ts @@ -260,42 +260,6 @@ describe("AuthService impersonation session", () => { assert.equal(AuthService.getImpersonationSession(), null); }); - it("rejects a legacy session without a bearer profile and removes its keys on the next write", () => { - values.set("vortex_dashboard_impersonation_token", "vtx_imp_legacy"); - values.set("vortex_dashboard_impersonation_session_id", "legacy-session"); - values.set( - "vortex_dashboard_impersonation_expires_at", - "2026-01-01T00:00:00.000Z", - ); - values.set( - "vortex_dashboard_impersonation_target_email", - "legacy@example.com", - ); - - assert.equal(AuthService.getImpersonationSession(), null); - - AuthService.storeImpersonationSession({ - expiresAt: "2026-02-01T00:00:00.000Z", - sessionId: "session-2", - targetEmail: "current@example.com", - targetProfileId: "customer-2", - token: "vtx_imp_current", - }); - assert.equal(values.has("vortex_dashboard_impersonation_token"), false); - assert.equal( - values.has("vortex_dashboard_impersonation_session_id"), - false, - ); - assert.equal( - values.has("vortex_dashboard_impersonation_expires_at"), - false, - ); - assert.equal( - values.has("vortex_dashboard_impersonation_target_email"), - false, - ); - }); - it("prefers the impersonation token over the operator's own access token", () => { assert.equal(AuthService.getEffectiveAccessToken(), "expired-access-token"); diff --git a/apps/dashboard/src/services/auth.ts b/apps/dashboard/src/services/auth.ts index ba14caea2c..eff5035a27 100644 --- a/apps/dashboard/src/services/auth.ts +++ b/apps/dashboard/src/services/auth.ts @@ -1,3 +1,4 @@ +import { decodeJwtExpiryMs } from "@vortexfi/shared"; import { API_BASE_URL } from "./api/base-url"; export interface AuthTokens { @@ -46,10 +47,6 @@ export class AuthService { // One atomic record prevents readers from combining fields from different cross-tab writes. static readonly IMPERSONATION_STORAGE_KEY = "vortex_dashboard_impersonation_session"; static readonly MANAGED_PROFILE_STORAGE_KEY = "vortex_dashboard_managed_profile_selection"; - private static readonly LEGACY_IMPERSONATION_TOKEN_KEY = "vortex_dashboard_impersonation_token"; - private static readonly LEGACY_IMPERSONATION_SESSION_ID_KEY = "vortex_dashboard_impersonation_session_id"; - private static readonly LEGACY_IMPERSONATION_EXPIRES_AT_KEY = "vortex_dashboard_impersonation_expires_at"; - private static readonly LEGACY_IMPERSONATION_TARGET_EMAIL_KEY = "vortex_dashboard_impersonation_target_email"; private static readonly impersonationListeners = new Set<() => void>(); private static readonly managedProfileListeners = new Set<() => void>(); private static acceptedImpersonationSnapshot: string | null | undefined; @@ -128,7 +125,6 @@ export class AuthService { }) ); this.acceptedImpersonationSnapshot = this.getImpersonationSessionSnapshot(); - this.clearLegacyImpersonationKeys(); this.notifyImpersonationListeners(previousSnapshot); } @@ -158,18 +154,9 @@ export class AuthService { else localStorage.setItem(this.IMPERSONATION_STORAGE_KEY, snapshot); } - /** Stable serialized snapshot for `useSyncExternalStore`. Also reads complete legacy data. */ + /** Stable serialized snapshot for `useSyncExternalStore`. */ static getImpersonationSessionSnapshot(): string | null { - const current = localStorage.getItem(this.IMPERSONATION_STORAGE_KEY); - if (current !== null) { - return current; - } - - const token = localStorage.getItem(this.LEGACY_IMPERSONATION_TOKEN_KEY); - const sessionId = localStorage.getItem(this.LEGACY_IMPERSONATION_SESSION_ID_KEY); - const expiresAt = localStorage.getItem(this.LEGACY_IMPERSONATION_EXPIRES_AT_KEY); - const targetEmail = localStorage.getItem(this.LEGACY_IMPERSONATION_TARGET_EMAIL_KEY); - return token && sessionId && expiresAt && targetEmail ? JSON.stringify({ expiresAt, sessionId, targetEmail, token }) : null; + return localStorage.getItem(this.IMPERSONATION_STORAGE_KEY); } static parseImpersonationSessionSnapshot(snapshot: string | null): ImpersonationSession | null { @@ -202,7 +189,7 @@ export class AuthService { static subscribeImpersonationSession(listener: () => void): () => void { this.impersonationListeners.add(listener); const handleStorage = (event: StorageEvent) => { - if (event.key === null || this.isImpersonationStorageKey(event.key)) { + if (event.key === null || event.key === this.IMPERSONATION_STORAGE_KEY) { listener(); } }; @@ -222,7 +209,6 @@ export class AuthService { if (expectedSnapshot !== undefined && storedSnapshot !== expectedSnapshot) return false; const previousSnapshot = this.getAcceptedImpersonationSessionSnapshot(); localStorage.removeItem(this.IMPERSONATION_STORAGE_KEY); - this.clearLegacyImpersonationKeys(); this.acceptedImpersonationSnapshot = null; this.notifyImpersonationListeners(previousSnapshot); return true; @@ -332,29 +318,13 @@ export class AuthService { if (!tokens) { return false; } - const expiryMs = this.decodeJwtExpiryMs(tokens.accessToken); + const expiryMs = decodeJwtExpiryMs(tokens.accessToken); return expiryMs === null || expiryMs > Date.now(); } static getAccessTokenExpiryMs(): number | null { const tokens = this.getTokens(); - return tokens ? this.decodeJwtExpiryMs(tokens.accessToken) : null; - } - - 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 tokens ? decodeJwtExpiryMs(tokens.accessToken) : null; } /** @@ -428,23 +398,6 @@ export class AuthService { this.clearTokens(); } - private static clearLegacyImpersonationKeys(): void { - localStorage.removeItem(this.LEGACY_IMPERSONATION_TOKEN_KEY); - localStorage.removeItem(this.LEGACY_IMPERSONATION_SESSION_ID_KEY); - localStorage.removeItem(this.LEGACY_IMPERSONATION_EXPIRES_AT_KEY); - localStorage.removeItem(this.LEGACY_IMPERSONATION_TARGET_EMAIL_KEY); - } - - private static isImpersonationStorageKey(key: string): boolean { - return [ - this.IMPERSONATION_STORAGE_KEY, - this.LEGACY_IMPERSONATION_TOKEN_KEY, - this.LEGACY_IMPERSONATION_SESSION_ID_KEY, - this.LEGACY_IMPERSONATION_EXPIRES_AT_KEY, - this.LEGACY_IMPERSONATION_TARGET_EMAIL_KEY - ].includes(key); - } - private static notifyImpersonationListeners(previousSnapshot: string | null): void { if (this.getAcceptedImpersonationSessionSnapshot() === previousSnapshot) return; for (const listener of this.impersonationListeners) { diff --git a/apps/dashboard/src/stores/auth.store.ts b/apps/dashboard/src/stores/auth.store.ts index f4c44bd38a..51c7d7b4da 100644 --- a/apps/dashboard/src/stores/auth.store.ts +++ b/apps/dashboard/src/stores/auth.store.ts @@ -7,7 +7,6 @@ import { AdminConsoleService } from "@/services/api/admin-console.service"; import { AuthAPI } from "@/services/api/auth.api"; import { AuthService, type AuthTokens } from "@/services/auth"; import { restoreAuthSession } from "@/services/sessionRestore"; -import { useNotificationsStore } from "@/stores/notifications.store"; interface AuthUser { name: string; @@ -48,7 +47,6 @@ function userFromTokens(tokens: AuthTokens): AuthUser { export function clearAccountState(): void { queryClient.clear(); - useNotificationsStore.getState().clear(); if (typeof document !== "undefined") void disconnect(wagmiConfig); } diff --git a/apps/dashboard/src/stores/notifications.store.ts b/apps/dashboard/src/stores/notifications.store.ts deleted file mode 100644 index 8a1da08771..0000000000 --- a/apps/dashboard/src/stores/notifications.store.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { create } from "zustand"; -import type { AppNotification } from "@/domain/types"; - -interface NotificationsState { - items: AppNotification[]; - add: (notification: Omit) => void; - markAllRead: () => void; - clear: () => void; -} - -export const useNotificationsStore = create(set => ({ - add: notification => - set(state => ({ - items: [ - { - ...notification, - createdAt: new Date().toISOString(), - id: crypto.randomUUID(), - read: false - }, - ...state.items - ] - })), - clear: () => set({ items: [] }), - items: [], - markAllRead: () => set(state => ({ items: state.items.map(item => ({ ...item, read: true })) })) -})); - -export const unreadCount = (items: AppNotification[]) => items.filter(item => !item.read).length; diff --git a/apps/demo/package.json b/apps/demo/package.json index 35b7bd242d..d71f392f40 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -8,7 +8,6 @@ "qrcode.react": "^4.2.0", "react": "19.2.0", "react-dom": "19.2.0", - "stellar-sdk": "catalog:", "viem": "catalog:", "wagmi": "catalog:" }, diff --git a/apps/demo/src/index.ts b/apps/demo/src/index.ts deleted file mode 100644 index ca1eb5785a..0000000000 --- a/apps/demo/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { CanvasShell } from "./components/CanvasShell"; -export { WalletButton } from "./components/WalletButton"; -export { useBscWallet } from "./hooks/useBscWallet"; -export { bscNetwork, wagmiConfig } from "./wagmi"; diff --git a/apps/demo/src/wagmi.ts b/apps/demo/src/wagmi.ts index 339b6f37cc..de8d1ab4ce 100644 --- a/apps/demo/src/wagmi.ts +++ b/apps/demo/src/wagmi.ts @@ -3,8 +3,6 @@ import { createAppKit } from "@reown/appkit/react"; import { WagmiAdapter } from "@reown/appkit-adapter-wagmi"; import { http } from "wagmi"; -export const bscNetwork = bsc; - const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID?.trim(); if (!projectId) { diff --git a/apps/frontend/e2e/support/mockBackend.ts b/apps/frontend/e2e/support/mockBackend.ts index b7693383bd..337d7e8785 100644 --- a/apps/frontend/e2e/support/mockBackend.ts +++ b/apps/frontend/e2e/support/mockBackend.ts @@ -68,6 +68,9 @@ export function buildRampProcess(overrides: Record = {}) { createdAt: new Date().toISOString(), currentPhase: "initial", depositQrCode: E2E_DEPOSIT_QR_CODE, + // The API sets createdAt + RAMP_START_EXPIRATION_TIME_SECONDS (15 min); the SELL start-deadline + // guard in sign.actor.ts fails closed without it. + expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(), from: "pix", id: E2E_RAMP_ID, inputAmount: "100", diff --git a/apps/frontend/netlify.toml b/apps/frontend/netlify.toml index fd989cce82..0a4fd73193 100644 --- a/apps/frontend/netlify.toml +++ b/apps/frontend/netlify.toml @@ -25,3 +25,15 @@ # apps/gold or the shared/sdk packages would be skipped as "no content change". Build when # any input of this site changed (paths are relative to the base directory). ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- . ../gold ../../packages/shared ../../packages/sdk" + +# Gold signs Ethereum transactions and collects PIX keys and KYC data, so it must not be framed +# by other sites (clickjacking) and keeps the baseline headers of its standalone release +# (gold.satoshipay.io). Scoped to Gold so the rest of the site, e.g. the widget, is unchanged. +[[headers]] + for = "/pt-br/gold/*" + [headers.values] + Content-Security-Policy = "frame-ancestors 'none'" + X-Frame-Options = "DENY" + X-Content-Type-Options = "nosniff" + Referrer-Policy = "strict-origin-when-cross-origin" + Permissions-Policy = "camera=(self), microphone=(), geolocation=()" diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f971523e8d..2317edd142 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -3,18 +3,11 @@ "@fontsource/roboto": "^5.0.8", "@heroicons/react": "^2.1.3", "@hookform/resolvers": "^4.1.3", - "@pendulum-chain/api": "catalog:", "@pendulum-chain/api-solang": "catalog:", "@polkadot/api": "catalog:", - "@polkadot/api-base": "catalog:", "@polkadot/api-contract": "catalog:", - "@polkadot/api-derive": "catalog:", - "@polkadot/extension-dapp": "^0.53.1", "@polkadot/extension-inject": "^0.53.1", "@polkadot/keyring": "catalog:", - "@polkadot/rpc-augment": "catalog:", - "@polkadot/rpc-core": "catalog:", - "@polkadot/rpc-provider": "catalog:", "@polkadot/types": "catalog:", "@polkadot/util": "catalog:", "@polkadot/util-crypto": "catalog:", @@ -26,7 +19,6 @@ "@storybook/react": "catalog:", "@supabase/supabase-js": "catalog:", "@tailwindcss/vite": "^4.0.3", - "@talismn/connect-components": "^1.1.9", "@talismn/connect-wallets": "^1.2.8", "@tanstack/react-query": "^5.64.2", "@tanstack/react-router": "^1.170.25", @@ -34,7 +26,6 @@ "@tanstack/react-start": "^1.168.42", "@tanstack/react-virtual": "^3.13.18", "@tanstack/zod-adapter": "^1.167.0", - "@types/crypto-js": "^4.2.2", "@vitejs/plugin-react": "^5.2.0", "@vortexfi/kyc": "workspace:*", "@vortexfi/shared": "workspace:*", @@ -45,16 +36,11 @@ "@walletconnect/utils": "catalog:", "@xstate/react": "^6.0.0", "big.js": "catalog:", - "bn.js": "^5.2.1", "buffer": "^6.0.3", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cobe": "catalog:", - "crypto-js": "^4.2.0", "i18next": "^24.2.3", "input-otp": "^1.4.2", - "lottie-react": "^2.4.1", - "lucide-react": "^0.562.0", "motion": "^12.0.3", "numora": "^4.0.0", "numora-react": "^4.0.0", @@ -65,28 +51,19 @@ "react-hook-form": "^7.65.0", "react-i18next": "^15.4.1", "react-toastify": "^11.0.5", - "stellar-sdk": "catalog:", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.0.3", "torph": "^0.0.9", "viem": "catalog:", "wagmi": "catalog:", - "web3": "^4.16.0", "xstate": "^5.20.1", "zod": "catalog:", "zustand": "^5.0.2" }, "devDependencies": { - "@babel/core": "^7.20.12", - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/preset-env": "^7.20.2", - "@babel/preset-typescript": "^7.18.6", "@pendulum-chain/types": "catalog:", "@playwright/test": "^1.61.1", - "@polkadot/types-augment": "catalog:", "@polkadot/types-codec": "catalog:", - "@polkadot/types-create": "catalog:", - "@polkadot/types-known": "catalog:", "@storybook/react-vite": "^9.1.4", "@tanstack/react-query-devtools": "^5.91.1", "@tanstack/router-plugin": "^1.168.29", @@ -95,39 +72,20 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/big.js": "catalog:", - "@types/bn.js": "^5", "@types/node": "catalog:", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", - "@typescript-eslint/eslint-plugin": "^5.53.0", - "@typescript-eslint/parser": "^5.53.0", "@vitest/coverage-v8": "3.2.4", - "babel-preset-vite": "^1.1.3", "daisyui": "^5.5.5", - "esbuild": "^0.25.9", - "eslint": "^8.34.0", - "eslint-plugin-react": "^7.32.2", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-storybook": "^9.1.4", - "husky": ">=6", "jsdom": "26", "lightningcss": "^1.32.0", - "lint-staged": ">=10", "msw": "^2.14.6", - "prettier": "catalog:", "storybook": "^9.1.4", - "ts-node": "^10.9.1", "tw-animate-css": "^1.4.0", "typescript": "catalog:", "vite": "^7.3.5", - "vite-plugin-node-polyfills": "^0.23.0", "vitest": "^3.1.1" }, - "eslintConfig": { - "extends": [ - "plugin:storybook/recommended" - ] - }, "name": "vortex-frontend", "private": true, "scripts": { diff --git a/apps/frontend/src/assets/dollar.svg b/apps/frontend/src/assets/dollar.svg deleted file mode 100644 index 49603ce404..0000000000 --- a/apps/frontend/src/assets/dollar.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - diff --git a/apps/frontend/src/assets/exclamation_mark_error.svg b/apps/frontend/src/assets/exclamation_mark_error.svg deleted file mode 100644 index 4cfb13b037..0000000000 --- a/apps/frontend/src/assets/exclamation_mark_error.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/frontend/src/assets/logo/circle.png b/apps/frontend/src/assets/logo/circle.png deleted file mode 100644 index 6a792907db..0000000000 Binary files a/apps/frontend/src/assets/logo/circle.png and /dev/null differ diff --git a/apps/frontend/src/assets/logo/vortex_x.svg b/apps/frontend/src/assets/logo/vortex_x.svg deleted file mode 100644 index b0c74711bc..0000000000 --- a/apps/frontend/src/assets/logo/vortex_x.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/frontend/src/assets/socials/socials-discord.tsx b/apps/frontend/src/assets/socials/socials-discord.tsx deleted file mode 100644 index 2ff54496eb..0000000000 --- a/apps/frontend/src/assets/socials/socials-discord.tsx +++ /dev/null @@ -1,16 +0,0 @@ -const DiscordLogo = () => ( - - - - - - - - - - - - -); - -export default DiscordLogo; diff --git a/apps/frontend/src/assets/socials/socials-github.tsx b/apps/frontend/src/assets/socials/socials-github.tsx deleted file mode 100644 index 899a5716f8..0000000000 --- a/apps/frontend/src/assets/socials/socials-github.tsx +++ /dev/null @@ -1,14 +0,0 @@ -const GithubLogo = () => ( - - - - - - - - - - -); - -export default GithubLogo; diff --git a/apps/frontend/src/assets/socials/socials-linkedin.tsx b/apps/frontend/src/assets/socials/socials-linkedin.tsx deleted file mode 100644 index 6e246b36f7..0000000000 --- a/apps/frontend/src/assets/socials/socials-linkedin.tsx +++ /dev/null @@ -1,14 +0,0 @@ -const LinkedinLogo = () => ( - - - - - - - - - - -); - -export default LinkedinLogo; diff --git a/apps/frontend/src/assets/socials/socials-medium.tsx b/apps/frontend/src/assets/socials/socials-medium.tsx deleted file mode 100644 index ceb68ee2ff..0000000000 --- a/apps/frontend/src/assets/socials/socials-medium.tsx +++ /dev/null @@ -1,14 +0,0 @@ -const MediumLogo = () => ( - - - - - - - - - - -); - -export default MediumLogo; diff --git a/apps/frontend/src/assets/socials/socials-reddit.tsx b/apps/frontend/src/assets/socials/socials-reddit.tsx deleted file mode 100644 index ae3e943fb7..0000000000 --- a/apps/frontend/src/assets/socials/socials-reddit.tsx +++ /dev/null @@ -1,17 +0,0 @@ -const RedditLogo = () => ( - - - - - - - - - - - - - -); - -export default RedditLogo; diff --git a/apps/frontend/src/assets/socials/socials-telegram.tsx b/apps/frontend/src/assets/socials/socials-telegram.tsx deleted file mode 100644 index 875f501798..0000000000 --- a/apps/frontend/src/assets/socials/socials-telegram.tsx +++ /dev/null @@ -1,14 +0,0 @@ -const TelegramLogo = () => ( - - - - - - - - - - -); - -export default TelegramLogo; diff --git a/apps/frontend/src/assets/socials/socials-twitter.tsx b/apps/frontend/src/assets/socials/socials-twitter.tsx deleted file mode 100644 index 190405a58f..0000000000 --- a/apps/frontend/src/assets/socials/socials-twitter.tsx +++ /dev/null @@ -1,18 +0,0 @@ -const TwitterLogo = () => ( - - - - - - - - - - -); - -export default TwitterLogo; diff --git a/apps/frontend/src/assets/socials/telegram.svg b/apps/frontend/src/assets/socials/telegram.svg deleted file mode 100644 index 118e4c6e08..0000000000 --- a/apps/frontend/src/assets/socials/telegram.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/trusted-by/circle.svg b/apps/frontend/src/assets/trusted-by/circle.svg deleted file mode 100644 index 7740b2aee9..0000000000 --- a/apps/frontend/src/assets/trusted-by/circle.svg +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/frontend/src/assets/trusted-by/nabla.svg b/apps/frontend/src/assets/trusted-by/nabla.svg deleted file mode 100644 index ac0c9a8386..0000000000 --- a/apps/frontend/src/assets/trusted-by/nabla.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/apps/frontend/src/assets/trusted-by/pendulum-icon.svg b/apps/frontend/src/assets/trusted-by/pendulum-icon.svg deleted file mode 100755 index fa87046af2..0000000000 --- a/apps/frontend/src/assets/trusted-by/pendulum-icon.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - diff --git a/apps/frontend/src/assets/trusted-by/stellar.svg b/apps/frontend/src/assets/trusted-by/stellar.svg deleted file mode 100644 index b26f68c1be..0000000000 --- a/apps/frontend/src/assets/trusted-by/stellar.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/apps/frontend/src/assets/videos/vortex-background.webm b/apps/frontend/src/assets/videos/vortex-background.webm deleted file mode 100644 index 7c3dc39aaf..0000000000 Binary files a/apps/frontend/src/assets/videos/vortex-background.webm and /dev/null differ diff --git a/apps/frontend/src/assets/wallets/metamask-wallet.png b/apps/frontend/src/assets/wallets/metamask-wallet.png deleted file mode 100644 index 72584fe4f1..0000000000 Binary files a/apps/frontend/src/assets/wallets/metamask-wallet.png and /dev/null differ diff --git a/apps/frontend/src/assets/widget-full-snippet-brl.png b/apps/frontend/src/assets/widget-full-snippet-brl.png deleted file mode 100644 index 880b955b85..0000000000 Binary files a/apps/frontend/src/assets/widget-full-snippet-brl.png and /dev/null differ diff --git a/apps/frontend/src/assets/widget-full-snippet-eur.png b/apps/frontend/src/assets/widget-full-snippet-eur.png deleted file mode 100644 index 3ce3b00f72..0000000000 Binary files a/apps/frontend/src/assets/widget-full-snippet-eur.png and /dev/null differ diff --git a/apps/frontend/src/assets/widget-full-snippet-sell.png b/apps/frontend/src/assets/widget-full-snippet-sell.png deleted file mode 100644 index 048cdc8656..0000000000 Binary files a/apps/frontend/src/assets/widget-full-snippet-sell.png and /dev/null differ diff --git a/apps/frontend/src/assets/widget-snippet-sell.png b/apps/frontend/src/assets/widget-snippet-sell.png deleted file mode 100644 index e5ef057e11..0000000000 Binary files a/apps/frontend/src/assets/widget-snippet-sell.png and /dev/null differ diff --git a/apps/frontend/src/assets/widget-snippet.png b/apps/frontend/src/assets/widget-snippet.png deleted file mode 100644 index 5fab4c7b15..0000000000 Binary files a/apps/frontend/src/assets/widget-snippet.png and /dev/null differ diff --git a/apps/frontend/src/components/AnimatedFeatureCard/index.tsx b/apps/frontend/src/components/AnimatedFeatureCard/index.tsx deleted file mode 100644 index be555f59a1..0000000000 --- a/apps/frontend/src/components/AnimatedFeatureCard/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Lottie, { LottieOptions } from "lottie-react"; -import { motion } from "motion/react"; -import { featureCardVariants, prefersReducedMotion } from "../../constants/animations"; -import { useLottieIntersectionAnimation } from "../../hooks/useLottieIntersectionAnimation"; - -interface AnimatedFeatureCardProps { - icon: LottieOptions["animationData"]; - title: string; - description: string; - index: number; - iconAriaLabel?: string; -} - -/** - * AnimatedFeatureCard - A card component with Lottie animation - * Features: - * - Animates on scroll into view - * - Plays animation on hover - * - Alternating entrance direction based on index - * - Full WCAG accessibility support - * - Respects prefers-reduced-motion - */ -export const AnimatedFeatureCard = ({ icon, title, description, index, iconAriaLabel }: AnimatedFeatureCardProps) => { - const { lottieRef, cardRef, handleMouseEnter, handleAnimationComplete } = useLottieIntersectionAnimation(); - - const reducedMotion = prefersReducedMotion(); - - return ( - } - role="article" - transition={{ duration: 0.4 }} - variants={featureCardVariants} - viewport={{ margin: "0px 0px -20px 0px" }} - whileInView="visible" - > -
    -
    -

    {title}

    - -
    -
    -
    - - ); -}; diff --git a/apps/frontend/src/components/Avenia/AveniaVerificationForm/useKYCFormLocalStorage.ts b/apps/frontend/src/components/Avenia/AveniaVerificationForm/useKYCFormLocalStorage.ts deleted file mode 100644 index 047dc235fb..0000000000 --- a/apps/frontend/src/components/Avenia/AveniaVerificationForm/useKYCFormLocalStorage.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useEffect } from "react"; -import { Path, PathValue, UseFormReturn } from "react-hook-form"; -import { debounce } from "../../../hooks/useLocalStorage"; - -export const BRLA_KYC_FORM_STORAGE_KEY = "brla_kyc_form_data"; - -export const useKYCFormLocalStorage = (form: UseFormReturn) => { - const { watch, setValue } = form; - - const saveToStorage = debounce((data: T) => { - localStorage.setItem(BRLA_KYC_FORM_STORAGE_KEY, JSON.stringify(data)); - }, 500); - - useEffect(() => { - const savedData = localStorage.getItem(BRLA_KYC_FORM_STORAGE_KEY); - - if (!savedData) { - return; - } - - try { - const parsedData = JSON.parse(savedData); - Object.entries(parsedData).forEach(([key, value]) => { - setValue(key as Path, value as PathValue>, { shouldValidate: true }); - }); - } catch (error) { - console.error("Error loading form data from localStorage:", error); - } - }, [setValue]); - - useEffect(() => { - const subscription = watch(data => { - saveToStorage(data as T); - }); - return () => subscription.unsubscribe(); - }, [watch, saveToStorage]); - - const clearStorage = () => { - localStorage.removeItem(BRLA_KYC_FORM_STORAGE_KEY); - }; - - return { - clearStorage - }; -}; diff --git a/apps/frontend/src/components/Avenia/LivenessComponent/index.tsx b/apps/frontend/src/components/Avenia/LivenessComponent/index.tsx deleted file mode 100644 index ea5da9f260..0000000000 --- a/apps/frontend/src/components/Avenia/LivenessComponent/index.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React, { useEffect, useRef } from "react"; -import { Trans, useTranslation } from "react-i18next"; -import { cn } from "../../../helpers/cn"; -import { AveniaKycActorRef, SelectedAveniaData } from "../../../machines/types"; - -interface AveniaLivenessProps { - aveniaKycActor: AveniaKycActorRef; - aveniaState: SelectedAveniaData; -} -export const LivenessComponent: React.FC = ({ aveniaState, aveniaKycActor }) => { - const { t } = useTranslation(); - const { livenessUrl } = aveniaState.context.documentUploadIds || {}; - const { livenessCheckOpened } = aveniaState.context; - const refreshClicked = useRef(false); - - useEffect(() => { - if (livenessUrl && refreshClicked.current) { - window.open(livenessUrl, "_blank"); - refreshClicked.current = false; // Reset the flag - } - }, [livenessUrl]); - - const handleOpenLivenessUrl = () => { - if (livenessUrl) { - window.open(livenessUrl, "_blank"); - aveniaKycActor.send({ type: "LIVENESS_OPENED" }); - } - }; - - const handleLivenessDone = () => { - aveniaKycActor.send({ type: "LIVENESS_DONE" }); - }; - - const handleRefreshUrl = () => { - refreshClicked.current = true; - aveniaKycActor.send({ type: "REFRESH_LIVENESS_URL" }); - }; - - return ( -
    -
    -

    {t("components.brlaLiveness.description")}

    -
    -
    - {livenessCheckOpened ? ( - - ) : ( - - )} -
    - {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)}