diff --git a/.agents/skills/address-feedback/SKILL.md b/.agents/skills/address-feedback/SKILL.md new file mode 100644 index 000000000..92cc633df --- /dev/null +++ b/.agents/skills/address-feedback/SKILL.md @@ -0,0 +1,69 @@ +--- +name: address-feedback +description: Fetch a GitHub PR review (Copilot, human, or another agent), verify each finding against the code, fix what is real with regression tests, run the repo gates, and push. Use when the user shares PR review feedback, a pullrequestreview URL, or asks to address review comments. +--- + +# Address PR review feedback + +Turns one review round into one command: fetch → verify → fix → gate → push → report. + +## 1. Fetch the feedback + +- `.../pull/#pullrequestreview-` URL → + `gh api repos/{owner}/{repo}/pulls//reviews/` for the body and + `gh api repos/{owner}/{repo}/pulls//reviews//comments --paginate` for the + inline comments (always paginate — large reviews span pages). +- Bare PR number → `gh pr view --json reviews,commits` for the review summaries, + plus `gh api repos/{owner}/{repo}/pulls//comments --paginate` for the inline + comments (`gh pr view` does not return them); keep the ones whose + `pull_request_review_id` belongs to a review submitted since the last push. +- Pasted findings text → use as-is. +- Make sure the PR branch is checked out and current (`gh pr checkout ` — never mix + with uncommitted local work; stop and say so if the tree is dirty with unrelated + changes). + +## 2. Verify before trusting — triage every finding + +Reviews (Copilot especially) contain false positives. For each finding, read the actual +code — whole functions and their callers, not diff hunks — then classify: + +- **Agree**: real defect, fix it. +- **Disagree**: false positive or intended behavior. Keep the code evidence; it goes in + the report verbatim. +- **Business decision**: a behavior/product choice (defaults, risk acceptance, who gets + notified, what partners may see). Never guess these — collect them and ask the user, + with a recommendation each (use AskUserQuestion when interactive). + +## 3. Fix agreed findings + +- Surgical changes only; match surrounding style; no drive-by refactors. +- Per repo rules: every bug that slipped past existing tests gets a regression test that + fails without the fix, in the same commit. +- If the fix changes behavior documented in `docs/security-spec`, sync the owning spec + file in the same change (grep `docs/security-spec/README.md` for the owner). + +## 4. Gates before pushing + +Trust guard: gates execute branch-local code. Only run them when the PR head lives in +`pendulum-chain/vortex` itself and the author is a known collaborator or team agent; +for fork or external-author PRs, stop after triage and report instead of executing. + +Run what the change touches: + +- `bun lint:fix` (Biome — except `packages/sdk`, which uses `bun lint` / ESLint inside + the package). +- `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; + if the change is intentional, `bun run wire-contract:update` and commit the snapshot + diff with an explicit note on backward compatibility. + +## 5. Push and report + +- Commit in logical groups using Conventional Commits (`(): `). +- Push to the PR branch; watch CI in a background task and fix failures it surfaces. +- Final message: a disposition table — every finding → **Fixed** (commit), **Disagreed** + (evidence), or **Needs your decision** (options + recommendation). Nothing silently + dropped. +- Reply on the PR threads only if the user asks. diff --git a/.agents/skills/babysit-pr/SKILL.md b/.agents/skills/babysit-pr/SKILL.md new file mode 100644 index 000000000..f8e708fd3 --- /dev/null +++ b/.agents/skills/babysit-pr/SKILL.md @@ -0,0 +1,52 @@ +--- +name: babysit-pr +description: Watch an open PR and keep it moving without manual ferrying — react to new reviews (Copilot, human, agent) and CI failures, push mechanical fixes, and surface business decisions. Use after opening a PR, e.g. "babysit PR 1234" or "watch this PR until it's mergeable". +--- + +# Babysit a PR + +Long-running watch loop for one PR. Autonomous for mechanical work; it never merges the +PR and never resolves business decisions on its own. + +## Setup + +- Resolve the PR: argument, or the current branch's PR via `gh pr view`. +- Trust guard: this loop runs branch-local code (gates, fixes). Only babysit PRs whose + head lives in `pendulum-chain/vortex` itself with a known collaborator or team agent + as author; for fork or external-author PRs, watch and report only — never execute + branch code. +- Make sure the PR branch is checked out and the tree is clean; stop and report if there + is unrelated uncommitted work. +- Record a baseline to diff against on every wake-up (persist it in a scratch state + file): head SHA, review ids, issue-comment ids, and CI runs from + `gh pr view --json headRefOid,reviews,comments,statusCheckRollup` (its `comments` + field is issue comments only), plus the inline review-comment ids from + `gh api repos/{owner}/{repo}/pulls//comments --paginate`. +- If no Copilot review exists yet, request one: + `gh api repos/{owner}/{repo}/pulls//requested_reviewers -f "reviewers[]=copilot-pull-request-reviewer[bot]"`. + If the API rejects the bot reviewer, note it in the report and continue — the user can + request it in the GitHub UI. + +## Each iteration + +1. Fetch current PR state and diff against the baseline. +2. New CI failure on the current head → read the failing logs + (`gh run view --log-failed`), fix, run the repo gates, push. +3. New review or new inline review comments → run the `/address-feedback` flow on them: + verify each finding against the code, fix agreed items with regression tests, run + gates, push. Keep the disagree evidence and any business decisions for the report. +4. New human questions or discussion → draft replies but do not post them unless the + user explicitly enabled posting; include the drafts in the report instead. +5. After pushing fixes prompted by a Copilot review, re-request the Copilot review so + the next round starts without the user ferrying anything. +6. Update the state file. + +## Pacing, notifying, stopping + +- Pace with the harness loop mechanism (dynamic `/loop` / scheduled wake-ups): check + every 5–15 minutes; after pushing, watch CI to completion in a background task and act + on the result instead of waiting for the next tick. +- Actively notify the user (not just a log line) when: a business decision is pending, a + reviewer pushes back on a disagree disposition, or all feedback is addressed and CI is + green (mergeable — the user's call). +- Stop when the PR is merged or closed, or when the user says stop. Never merge. diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md new file mode 100644 index 000000000..706299991 --- /dev/null +++ b/.agents/skills/ship/SKILL.md @@ -0,0 +1,50 @@ +--- +name: ship +description: End-to-end Vortex feature pipeline — plan with decision gates, implement with tests, self-review until dry, open the PR, and hand off to babysit-pr. Use for "ship this feature", "one-shot this", or when starting substantial feature work from a problem statement or handoff document. +--- + +# Ship a feature + +Pipeline with exactly two human gates: plan approval and merge. Everything in between +runs autonomously. + +## Phase 0 — Understand and plan (human gate 1) + +- Ingest the problem statement, handoff doc, linked PRs/proposals/ADRs. +- Fan out parallel read-only mapping subagents over the affected subsystems (quote + pipeline, phases/blocks, providers, dashboard, SDK — whatever the change touches). +- Draft the implementation plan: approach and alternatives considered, files to touch, + DB/migration impact (including what is already deployed on staging/prod), + `docs/security-spec` impact, wire-contract impact (is a partner-facing change + intended?), and the test plan (which suites, which new tests). +- Collect every open product/behavior decision — defaults, who is notified, what + partners may see, risk acceptances — and ask the user concretely with a + recommendation per question (AskUserQuestion). Never resolve these silently. +- Present the plan and wait for approval before writing any code. + +## Phase 1 — Implement + +- Work in a fresh worktree branched off the latest `origin/staging`. +- Implement in logical Conventional Commits; ship tests alongside per the repo rules + (features get tests; bug fixes get regression tests that fail without the fix). +- Gates before every push: `bun lint:fix` (ESLint inside `packages/sdk`), + `bun typecheck`, affected test suites, `bun run build:shared` when shared changed, + `bun run wire-contract:check` (update + compatibility note only when the surface + change is intentional), security-spec sync when documented behavior changed. + +## Phase 2 — Self-review until dry + +- Run `/vortex-review` on the branch. +- Fix the confirmed findings here (this session has implementer context), rerun the + gates, then rerun `/vortex-review`. Repeat until a run reports no new confirmed + findings. +- Findings deliberately not addressed go into the PR description with the reasoning — + never silently dropped. + +## Phase 3 — PR and handoff (human gate 2) + +- Push and open the PR against `staging`: human-friendly sentence-case title, and a + description covering what/why, the decisions taken in phase 0, test evidence, and the + wire-contract note when the snapshot changed. +- Request a Copilot review and start `/babysit-pr` for the follow-through. +- Merging is always the user's decision. diff --git a/.agents/skills/vortex-review/SKILL.md b/.agents/skills/vortex-review/SKILL.md new file mode 100644 index 000000000..6003e0145 --- /dev/null +++ b/.agents/skills/vortex-review/SKILL.md @@ -0,0 +1,84 @@ +--- +name: vortex-review +description: Deep multi-lens review of a Vortex PR or branch. Runs parallel Vortex-specific finder lenses, loops until no new findings, adversarially verifies every finding, and reports severity-ranked results. Use when asked for an in-depth code review, a pre-merge confidence check, or to review a PR/branch/diff. +--- + +# Vortex deep review + +Review-only skill: it produces a findings report and NEVER modifies code. Fixes happen in +the implementing session or via `/address-feedback`. + +## 1. Resolve scope + +- Argument is a PR number/URL → `gh pr view` for title/description/discussion, `gh pr diff` + for the diff. If the branch isn't checked out locally, check it out in a worktree. +- No argument → diff the current branch against `$(git merge-base HEAD origin/staging)`. +- Build a change inventory: files by workspace, plus flags that route lens emphasis: + migrations touched, `packages/shared` touched, SDK public surface touched, + `docs/security-spec` relevant paths touched, frontend machines/widget touched. +- Trust guard: before running ANY branch-local command (installs, builds, tests, + `wire-contract:check`), confirm the PR head lives in `pendulum-chain/vortex` itself + and the author is a known collaborator or team agent. For fork or external-author + PRs, review by inspection only — read the diff and files, execute nothing from the + branch — and say so in the report. +- Run `bun run wire-contract:check` (after `bun run build:shared` if shared changed). + A stale snapshot on the branch = automatic P1 finding. +- Read the PR description and any linked proposal/ADR: findings include "the change + doesn't achieve its stated intent", not just "the code is wrong". + +## 2. Round 1 — finder fleet + +Spawn parallel read-only subagents, one per lens (use the Workflow tool if available, +otherwise parallel Agent tasks). Every finder gets: the diff, the change inventory, its +lens brief below, and these standing rules: + +- Verify every claim against the actual code — read whole functions/files, never judge + from diff hunks alone. Check whether a suspected issue is already guarded elsewhere. +- Report findings as: severity (P0 blocker / P1 must-fix / P2 should-fix / P3 nit), + `file:line`, one-sentence claim, concrete failure scenario (inputs/state → wrong + outcome), suggested fix, suggested regression test. +- Also report what you checked and found clean. + +Lens briefs (these encode the classes that historically slipped through review): + +| Lens | Hunt for | +|---|---| +| correctness | Logic errors, wrong conditionals, off-by-one, unhandled branches, race conditions, dead code introduced by the change | +| financial-integrity | Fee math and rounding (Big.js modes), raw (18-dp) vs decimal confusion, subsidy caps, net-rate promise, the same amount derived differently at quote vs settlement vs distribution time | +| phase-recovery | Phase handlers/blocks: idempotency under retry and crash-between-steps; preconditions that can never pass after partial success (e.g. balance checks for funds that already moved); presigned-tx identity must include nonce (phase+network+signer alone collides); retries vs lock expiry; `unknown`-status operations conflated with "not started" | +| silent-failures | Swallowed errors, catch-and-continue, fallbacks that mask wrong data, logs asserting success on unverified paths, error branches that can never fire | +| partner-surface | Anything a live integrator depends on: shared endpoint types, SDK public API and `parseAPIError` message matching vs actual backend error strings, webhook payloads/signing, auth semantics, `docs/api/openapi/vortex.openapi.json` sync, wire-contract snapshot diff review | +| db-migrations | Migrations vs the already-deployed staging/prod schema (edits to already-run migrations never re-run; backfills must handle existing rows); irreversible steps without a runbook; enum exhaustiveness (`FiatToken` has 6 values: EURC, ARS, BRL, USD, MXN, COP — check every `Record`; `SubsidyToken`); Sequelize model ↔ schema drift | +| consistency | Corridor/capability matrix agreement across widget (frontend), API, dashboard, SDK (`tokenAvailability`, `mapFiatToDestination`, `ARRIVAL_TEXT_BY_TOKEN`); i18n in both en and pt-BR; `docs/security-spec` staleness per the CLAUDE.md sync rule | +| test-adequacy | Would the new tests fail without the fix; edge cases vs happy path only; `mock.module` leak rules (snapshot values before mocking, restore in `afterAll`); no production data/PII in fixtures; coverage gates still passing | + +For small diffs (< ~150 changed lines) merge to 4 finders: correctness+silent-failures, +financial+phase-recovery, partner-surface+db-migrations, consistency+test-adequacy. + +## 3. Loop until dry + +- Deduplicate new candidates against everything already seen (file + line + claim), not + only against confirmed findings — otherwise refuted findings resurface forever. +- After any round that produces new candidates, run another fleet round where finders get + the list of everything found so far and the brief "hunt for what these reviewers + missed — different files, different failure classes". +- Stop after two consecutive rounds with zero new candidates (typically 2–3 rounds). + +## 4. Adversarial verification + +Every candidate goes to a verifier subagent prompted to REFUTE it with code evidence: is +the path reachable, is the behavior actually wrong, is it guarded elsewhere, does the +failure scenario hold? P0/P1 candidates get two independent verifiers; the finding +survives only if the refutation fails. Refuted candidates go to a one-line appendix so +the same false positive isn't re-litigated next run. + +## 5. Report + +Deliver in the final message (and post via `gh pr review --comment` only if asked): + +1. One-paragraph verdict: mergeable or not, and the dominant risk. +2. Findings ranked by severity: `file:line`, claim, failure scenario, fix, regression + test to add. +3. "Verified clean" — per lens, what was checked and held up. Absence of findings must + be evidence of checking, not of not looking. +4. Appendix: refuted candidates (one line each). diff --git a/.claude/skills/address-feedback b/.claude/skills/address-feedback new file mode 120000 index 000000000..fef436c94 --- /dev/null +++ b/.claude/skills/address-feedback @@ -0,0 +1 @@ +../../.agents/skills/address-feedback \ No newline at end of file diff --git a/.claude/skills/babysit-pr b/.claude/skills/babysit-pr new file mode 120000 index 000000000..d5bb2ed71 --- /dev/null +++ b/.claude/skills/babysit-pr @@ -0,0 +1 @@ +../../.agents/skills/babysit-pr \ No newline at end of file diff --git a/.claude/skills/ship b/.claude/skills/ship new file mode 120000 index 000000000..084c1ffef --- /dev/null +++ b/.claude/skills/ship @@ -0,0 +1 @@ +../../.agents/skills/ship \ No newline at end of file diff --git a/.claude/skills/vortex-review b/.claude/skills/vortex-review new file mode 120000 index 000000000..ea57c2744 --- /dev/null +++ b/.claude/skills/vortex-review @@ -0,0 +1 @@ +../../.agents/skills/vortex-review \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c7784d0a..31582ac4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,11 @@ jobs: - name: ✏️ Typecheck run: bun run typecheck + - name: 🔒 Wire-contract gate + run: | + cd scripts/wire-contract && bun test && cd ../.. + bun run wire-contract:check + test: name: Running tests runs-on: ubuntu-latest diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 27e7ca3f8..33122f063 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -13,6 +13,9 @@ jobs: contracts: name: External API contracts (live) runs-on: ubuntu-latest + concurrency: + group: external-api-contracts-avenia-sandbox + cancel-in-progress: false env: CI: true RUN_LIVE_TESTS: "1" @@ -50,6 +53,7 @@ jobs: BRLA_API_KEY: ${{ secrets.CONTRACT_BRLA_API_KEY }} BRLA_PRIVATE_KEY: ${{ secrets.CONTRACT_BRLA_PRIVATE_KEY }} AVENIA_CONTRACT_SUBACCOUNT_ID: ${{ secrets.CONTRACT_AVENIA_SUBACCOUNT_ID }} + AVENIA_CONTRACT_WEBHOOK_URL: ${{ secrets.CONTRACT_AVENIA_WEBHOOK_URL }} COINGECKO_API_KEY: ${{ secrets.COINGECKO_API_KEY }} run: bun test src/tests/contracts/ diff --git a/.gitignore b/.gitignore index 143705ae9..2c678ad9d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,9 +55,10 @@ storybook-static CLAUDE.local.md -# Ignore everything under .claude except the team-shared settings.json +# Ignore everything under .claude except the team-shared settings.json and skills .claude/* !.claude/settings.json +!.claude/skills/ /.roo/* # hardhat generated files in workspace contract projects diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4054f133d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Repository agent instructions + +- Read and follow the root `CLAUDE.md` before doing any work in this repository. +- When work touches an app or package, also read and follow the closest scoped `CLAUDE.md`. +- Commit messages use Conventional Commits: `(): `. +- Pull request titles use plain, human-friendly sentence case. Never prefix a PR title with `():`. +- Before creating or renaming a pull request, reread the `Commit Messages & PR Titles` section in `CLAUDE.md`. diff --git a/CLAUDE.md b/CLAUDE.md index e44d49221..a613a19f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,10 +84,14 @@ update the existing canonical document when one owns the topic. document. - Keep local `README.md` files only when they explain a non-obvious subsystem contract. - Repair indexes and relative links in the same change as a move or deletion. +- Agent skills live in `.agents/skills//SKILL.md` so every coding agent can use + them; `.claude/skills/` holds only symlinks to those directories (Claude Code follows + them for discovery). When adding a skill, create the directory there and add the + matching symlink. ## Commit Messages & PR Titles -Every commit message and PR title follows [Conventional Commits](https://www.conventionalcommits.org/): +Every commit message follows [Conventional Commits](https://www.conventionalcommits.org/): ``` (): diff --git a/apps/api/.env.example b/apps/api/.env.example index b0afbd769..e6f56f676 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -88,12 +88,40 @@ SLACK_USER_ID= # the transfer, attempts recovery, and alerts. Optional; defaults to 20 minutes. SQUID_ROUTER_PAY_STUCK_ALERT_MS=1200000 +# Email notifications via Resend. +# Without RESEND_API_KEY, notifications stay queued and nothing is sent. +RESEND_API_KEY= +EMAIL_FROM_ADDRESS=Vortex Finance +EMAIL_REPLY_TO_ADDRESS= +# Required outside DEPLOYMENT_ENV=production: comma-separated recipients allowed +# to receive mail. Anyone else is recorded as skipped and never emailed. +EMAIL_RECIPIENT_ALLOWLIST= + +# Public https URL of this backend's Avenia webhook receiver, e.g. +# https://api.vortexfinance.co/v1/webhooks/avenia. Used only by +# `bun register:avenia-webhook`, which registers the subscription with Avenia. +AVENIA_WEBHOOK_URL= + +# Live Avenia contract tests only. The suite adds a unique query parameter, +# registers this URL temporarily, verifies it via the list endpoint, and deletes it. +AVENIA_CONTRACT_WEBHOOK_URL= + # Widget URL (optional, has default) RAMP_WIDGET_URL=https://www.vortexfinance.co/widget # Vortex fee config VORTEX_FEE_PEN_PERCENTAGE=0.0 +# Fee-collecting EVM BUY destination execution pricing. Exact provider-token +# direct payouts remain covered by their existing source reserve. Other quotes +# are unavailable when the expected funding plus payout fee exceeds the USD +# ceiling, and registration is rejected when fees move beyond the quote margin +# (12000 = 20%). Keep dynamic funding disabled for the first deployment, then +# enable it only after every API and worker replica runs the new executor. +EVM_DYNAMIC_DESTINATION_FUNDING_ENABLED=false +EVM_DESTINATION_MAX_EXECUTION_FEE_USD=5 +EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS=12000 + # Rate Limiting RATE_LIMIT_MAX_REQUESTS=100 RATE_LIMIT_WINDOW_MINUTES=1 diff --git a/apps/api/.gitignore b/apps/api/.gitignore index b1fa2d19f..e378c9c60 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -45,6 +45,9 @@ jspm_packages # Optional REPL history .node_repl_history +# Rendered email template previews +.email-previews + # testing artifacts */failedRampStateRecovery.json */lastRampState.json diff --git a/apps/api/package.json b/apps/api/package.json index c3888e4d4..8b25e24ac 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -89,10 +89,14 @@ "name": "vortex-backend", "scripts": { "build": "bun run swc src -d dist --strip-leading-paths", + "build:auth-emails": "bun src/scripts/auth-email-templates.ts", "dev": "NODE_ENV=development bun --watch src/index.ts", "migrate": "bun -r @swc-node/register src/database/migrator.ts", "migrate:revert": "bun -r @swc-node/register src/database/migrator.ts revert-all", "migrate:revert-last": "bun -r @swc-node/register src/database/migrator.ts revert", + "preview:emails": "bun src/scripts/preview-emails.ts", + "preview:emails:watch": "bun --watch src/scripts/preview-emails.ts", + "register:avenia-webhook": "bun src/scripts/register-avenia-webhook.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", diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index c4c4b97b0..95c3b36df 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -33,8 +33,39 @@ import { normalizeAlfredpayProviderStatus, resolveAlfredpayKybSubmissionId } from "../services/alfredpay/alfredpay-customer.service"; +import { enqueueAlfredpayVerificationNotification } from "../services/alfredpay/verification-notifications"; import { ALFREDPAY_EFFECTIVE_USER_REQUIRED_MESSAGE } from "../services/quote/alfredpay-customer"; +async function enqueueObservedAlfredpayOutcome({ + failureReason, + isBusiness, + status, + submissionId, + updatedAt, + userId +}: { + failureReason?: string; + isBusiness: boolean; + status: string; + submissionId: string; + updatedAt: string; + userId: string; +}): Promise { + const providerStatus = normalizeAlfredpayProviderStatus(status); + if (providerStatus !== AlfredpayKycStatus.COMPLETED && providerStatus !== AlfredpayKycStatus.FAILED) { + return; + } + + await enqueueAlfredpayVerificationNotification({ + reason: failureReason ?? null, + status: providerStatus, + subject: isBusiness ? "business" : "individual", + submissionId, + updatedAt, + userId + }); +} + /** * Maps an Alfredpay 4xx rejection on the fiat-account routes to a sanitized caller-facing * 400 body, or returns null for anything that should stay an opaque 500 (5xx, transport @@ -195,8 +226,31 @@ export class AlfredpayController { updateData.lastFailureReasons = [statusResponse.metadata.failureReason]; } - if (Object.keys(updateData).length > 0) { - await alfredPayCustomer.update(updateData); + // Queue before persisting a terminal status. Once the row becomes terminal both + // status pollers exclude it, so doing this afterwards could lose the email forever. + // Own catch: these are local DB writes, and the upstream-404 staleness heuristic + // below must never fire on their errors — an enqueue failure whose message happens + // to contain "not found" would otherwise wipe the observed status. + try { + await enqueueObservedAlfredpayOutcome({ + failureReason: statusResponse.metadata?.failureReason, + isBusiness, + status: statusResponse.status, + submissionId, + updatedAt: statusResponse.updatedAt, + userId + }); + + if (Object.keys(updateData).length > 0) { + await alfredPayCustomer.update(updateData); + } + } catch (error) { + // Skipping the update keeps enqueue-before-persist: the next refresh + // re-observes the outcome and the enqueue dedupes on the submission id. + logger.error( + `Error queuing/persisting observed Alfredpay outcome for customer ${alfredPayCustomer.alfredPayId}:`, + error + ); } } } catch (error) { @@ -425,6 +479,17 @@ export class AlfredpayController { updateData.lastFailureReasons = [statusResponse.metadata.failureReason]; } + // See alfredpayStatus above: terminal persistence must never get ahead of + // the durable, idempotent notification enqueue. + await enqueueObservedAlfredpayOutcome({ + failureReason: statusResponse.metadata?.failureReason, + isBusiness, + status: statusResponse.status, + submissionId, + updatedAt: statusResponse.updatedAt, + userId + }); + if (Object.keys(updateData).length > 0) { await alfredPayCustomer.update(updateData); } diff --git a/apps/api/src/api/controllers/avenia-webhook.controller.test.ts b/apps/api/src/api/controllers/avenia-webhook.controller.test.ts new file mode 100644 index 000000000..e737d79ab --- /dev/null +++ b/apps/api/src/api/controllers/avenia-webhook.controller.test.ts @@ -0,0 +1,213 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import bodyParser from "body-parser"; +import express from "express"; +import { + keyServer, + loggerModuleMock, + loggerModuleReal, + primaryKeys, + rotatedKeys, + sharedModuleMock, + sharedModuleReal, + sign +} from "../services/avenia/__tests__/fixtures"; + +interface AveniaOwner { + accountType: string; + profileId: string; +} + +const enqueueVerificationNotification = mock(async (_attempt: { id: string }, _userId: string): Promise => true); +const findAveniaOwnerBySubaccountId = mock( + async (): Promise => ({ accountType: "COMPANY", profileId: "user-1" }) +); + +// Signature verification is exercised for real here; only its key source is stubbed. +mock.module("@vortexfi/shared", sharedModuleMock); +mock.module("../../config/logger", loggerModuleMock); +// Plain-object snapshots taken before mocking: mock.module mutates the imported +// namespaces in place, so a spread at restore time would copy the stubs back. +const verificationNotificationsReal = { ...(await import("../services/avenia/verification-notifications")) }; +mock.module("../services/avenia/verification-notifications", () => ({ enqueueVerificationNotification })); +// mock.module is process-global, so the rest of the service is spread back in: stubbing the +// lookup alone would strip upsertAveniaKycCase from every test file loaded after this one. +const aveniaCustomerService = { ...(await import("../services/avenia/avenia-customer.service")) }; +mock.module("../services/avenia/avenia-customer.service", () => ({ + ...aveniaCustomerService, + findAveniaOwnerBySubaccountId +})); + +const { handleAveniaWebhook } = await import("./avenia-webhook.controller"); + +// Mirrors the production mount in config/express.ts: raw body, ahead of any JSON parser. +const app = express(); +app.post("/v1/webhooks/avenia", bodyParser.raw({ type: "*/*" }), handleAveniaWebhook); +const server = app.listen(0); +const port = (server.address() as { port: number }).port; + +const EVENT = JSON.stringify({ + data: { attempt: { id: "attempt-1", result: "APPROVED", status: "COMPLETED", updatedAt: "2026-07-29T10:00:00Z" } }, + subAccountId: "sub-1", + subscription: "KYC" +}); + +function post(body: string, signature?: string): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (signature !== undefined) { + headers.signature = signature; + } + + return fetch(`http://127.0.0.1:${port}/v1/webhooks/avenia`, { body, headers, method: "POST" }); +} + +function signed(body: string): Promise { + return post(body, sign(Buffer.from(body), primaryKeys.privateKey)); +} + +describe("handleAveniaWebhook", () => { + beforeEach(() => { + keyServer.servedKey = primaryKeys.publicKey; + enqueueVerificationNotification.mockClear(); + findAveniaOwnerBySubaccountId.mockClear(); + findAveniaOwnerBySubaccountId.mockImplementation(async () => ({ accountType: "COMPANY", profileId: "user-1" })); + }); + + it("enqueues a verification email for a known subaccount", async () => { + const response = await signed(EVENT); + + expect(response.status).toBe(200); + expect(enqueueVerificationNotification).toHaveBeenCalledTimes(1); + expect(enqueueVerificationNotification.mock.calls[0]?.[1]).toBe("user-1"); + }); + + it("accepts Avenia's documented nested event envelope", async () => { + const body = JSON.stringify({ + event: { + accountId: "sub-nested", + data: { attempt: { id: "attempt-nested", result: "APPROVED", status: "COMPLETED", updatedAt: "2026-08-06" } }, + subscription: "KYC" + } + }); + + const response = await signed(body); + + expect(response.status).toBe(200); + expect(findAveniaOwnerBySubaccountId).toHaveBeenCalledWith("sub-nested"); + expect(enqueueVerificationNotification.mock.calls[0]?.[0].id).toBe("attempt-nested"); + }); + + it("verifies the exact bytes received rather than a reparsed body", async () => { + // Whitespace a JSON round-trip would drop still has to satisfy the signature. + const body = `{ "subAccountId":"sub-1", "subscription":"KYC",\n"data":{"attempt":{"id":"attempt-2","status":"EXPIRED","updatedAt":"2026-07-29T10:00:00Z"}} }`; + + const response = await signed(body); + + expect(response.status).toBe(200); + expect(enqueueVerificationNotification.mock.calls[0]?.[0].id).toBe("attempt-2"); + }); + + it("rejects a body signed with the wrong key without touching the database", async () => { + const response = await post(EVENT, sign(Buffer.from(EVENT), rotatedKeys.privateKey)); + + expect(response.status).toBe(401); + expect(findAveniaOwnerBySubaccountId).not.toHaveBeenCalled(); + expect(enqueueVerificationNotification).not.toHaveBeenCalled(); + }); + + it("rejects a body altered after signing", async () => { + const signature = sign(Buffer.from(EVENT), primaryKeys.privateKey); + + const response = await post(EVENT.replace("sub-1", "sub-9"), signature); + + expect(response.status).toBe(401); + expect(enqueueVerificationNotification).not.toHaveBeenCalled(); + }); + + it("rejects a request carrying no signature header", async () => { + const response = await post(EVENT); + + expect(response.status).toBe(401); + }); + + it("acknowledges events that carry no verification attempt", async () => { + const response = await signed(JSON.stringify({ data: { ticket: {} }, subAccountId: "sub-1", subscription: "TICKET" })); + + expect(response.status).toBe(200); + expect(enqueueVerificationNotification).not.toHaveBeenCalled(); + }); + + it("acknowledges an unknown or partner-owned subaccount so Avenia stops retrying", async () => { + findAveniaOwnerBySubaccountId.mockImplementation(async () => null); + + const response = await signed(EVENT); + + expect(response.status).toBe(200); + expect(enqueueVerificationNotification).not.toHaveBeenCalled(); + }); + + it("rejects a malformed body", async () => { + const response = await signed("not json"); + + expect(response.status).toBe(400); + }); + + // A signature only proves Avenia sent the bytes. Everything below parses as JSON and + // would previously have been read as an event, either throwing on a property access or + // persisting a payload an email is later rendered from. + it.each([ + ["a signed null", "null"], + ["a signed array", "[]"], + ["a signed string", '"event"'], + ["an event with no subaccount", JSON.stringify({ data: {}, subscription: "KYC" })], + ["an event whose subaccount is not a string", JSON.stringify({ data: {}, subAccountId: 7, subscription: "KYC" })], + [ + "an attempt with no id", + JSON.stringify({ data: { attempt: { status: "COMPLETED", updatedAt: "x" } }, subAccountId: "sub-1", subscription: "KYC" }) + ], + [ + "an attempt with no status", + JSON.stringify({ data: { attempt: { id: "a-1", updatedAt: "x" } }, subAccountId: "sub-1", subscription: "KYC" }) + ], + [ + "an attempt with no updatedAt", + JSON.stringify({ data: { attempt: { id: "a-1", status: "COMPLETED" } }, subAccountId: "sub-1", subscription: "KYC" }) + ], + [ + "an attempt whose reason is not a string", + JSON.stringify({ + data: { attempt: { id: "a-1", result: "REJECTED", resultMessage: { text: "no" }, status: "COMPLETED", updatedAt: "x" } }, + subAccountId: "sub-1", + subscription: "KYC" + }) + ] + ])("rejects %s without enqueuing anything", async (_case, body) => { + const response = await signed(body); + + expect(response.status).toBe(400); + expect(enqueueVerificationNotification).not.toHaveBeenCalled(); + }); + + // An unknown status is not a malformed payload: rejecting it would make Avenia retry a + // value we simply have no email for. + it("acknowledges an attempt carrying a status it has no email for", async () => { + const response = await signed( + JSON.stringify({ + data: { attempt: { id: "a-1", status: "SOMETHING-NEW", updatedAt: "2026-08-06T10:00:00Z" } }, + subAccountId: "sub-1", + subscription: "KYC" + }) + ); + + expect(response.status).toBe(200); + expect(enqueueVerificationNotification).toHaveBeenCalledTimes(1); + }); +}); + +afterAll(() => { + server.close(); + // Restore the real modules so this file's stubs don't leak into later files. + mock.module("@vortexfi/shared", sharedModuleReal); + mock.module("../../config/logger", loggerModuleReal); + mock.module("../services/avenia/verification-notifications", () => verificationNotificationsReal); + mock.module("../services/avenia/avenia-customer.service", () => aveniaCustomerService); +}); diff --git a/apps/api/src/api/controllers/avenia-webhook.controller.ts b/apps/api/src/api/controllers/avenia-webhook.controller.ts new file mode 100644 index 000000000..07ead7b55 --- /dev/null +++ b/apps/api/src/api/controllers/avenia-webhook.controller.ts @@ -0,0 +1,168 @@ +import { KycAttemptResult, KycAttemptStatus } from "@vortexfi/shared"; +import { Request, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import { accountTypeToCustomerType, findAveniaOwnerBySubaccountId } from "../services/avenia/avenia-customer.service"; +import { enqueueVerificationNotification, NotifiableAttempt } from "../services/avenia/verification-notifications"; +import { verifyAveniaSignature } from "../services/avenia/webhook-signature"; + +interface ParsedWebhookEvent { + subAccountId: string; + subscription: string; + // Null for an event that carries no attempt at all (a ticket or limit-update event), + // which is a legitimate no-op rather than a bad request. + attempt: NotifiableAttempt | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === "string"; +} + +/** + * Validates the envelope and, when one is present, the attempt — before any property is + * read. A signature only proves Avenia sent the bytes, not that they describe an event we + * can act on: `JSON.parse` alone would let `null`, an array, or an attempt missing its + * status through to a database write and, eventually, to a user's inbox. + * + * Only the fields an email is built from are required. Demanding the rest of Avenia's + * documented attempt shape would reject payloads over fields we never read. + */ +function parseWebhookEvent(rawBody: Buffer): ParsedWebhookEvent | null { + let body: unknown; + try { + body = JSON.parse(rawBody.toString("utf8")); + } catch { + return null; + } + + if (!isRecord(body)) { + return null; + } + + // Avenia's management guide shows the fields at the top level, while its + // event-specific guide wraps them in `event` and calls the account `accountId`. + // Accept both documented envelopes and normalize them before doing any work. + const event = isRecord(body.event) ? body.event : body; + const subAccountId = event.subAccountId ?? event.accountId; + if (!isNonEmptyString(subAccountId) || !isNonEmptyString(event.subscription)) { + return null; + } + + const envelope = { subAccountId, subscription: event.subscription }; + const data = isRecord(event.data) ? event.data : {}; + + if (data.attempt === undefined || data.attempt === null) { + return { ...envelope, attempt: null }; + } + + const attempt = data.attempt; + + if ( + !isRecord(attempt) || + !isNonEmptyString(attempt.id) || + !isNonEmptyString(attempt.status) || + !isNonEmptyString(attempt.updatedAt) || + !isOptionalString(attempt.result) || + !isOptionalString(attempt.resultMessage) + ) { + return null; + } + + return { + ...envelope, + attempt: { + id: attempt.id, + // Unrecognised enum values are not rejected here: terminalNotificationType treats + // anything it does not know as non-terminal, so a new Avenia status is a no-op + // rather than a 400 Avenia would retry forever. + result: attempt.result as KycAttemptResult | undefined, + resultMessage: attempt.resultMessage, + status: attempt.status as KycAttemptStatus, + updatedAt: attempt.updatedAt + } + }; +} + +/** + * Receives Avenia verification webhooks for both individual (KYC) and company (KYB) + * subaccounts. + * + * Avenia documents no KYB subscription, so company events are only expected to arrive + * because both kinds share the attempts resource and we subscribe with "*". Which kind + * an event belongs to is read from our own ProviderCustomer.customerType rather than the + * payload, so this keeps working whatever Avenia labels the event. + * + * Everything past signature verification answers 200: Avenia must not retry an event + * we have deliberately ignored (a ticket event, an unknown subaccount, a non-terminal + * attempt). Only an unverified or malformed body is rejected. + */ +export const handleAveniaWebhook = async (req: Request, res: Response): Promise => { + const signature = req.get("signature"); + const rawBody = req.body; + + if (!signature || !Buffer.isBuffer(rawBody)) { + res.status(httpStatus.UNAUTHORIZED).json({ error: "Missing signature or body" }); + return; + } + + if (!(await verifyAveniaSignature(rawBody, signature))) { + logger.warn("Rejected Avenia webhook with an invalid signature"); + res.status(httpStatus.UNAUTHORIZED).json({ error: "Invalid signature" }); + return; + } + + const event = parseWebhookEvent(rawBody); + + if (!event) { + logger.warn("Rejected Avenia webhook whose body is not a readable verification event"); + res.status(httpStatus.BAD_REQUEST).json({ error: "Malformed webhook body" }); + return; + } + + const { attempt } = event; + + try { + if (!attempt) { + logger.debug(`Ignoring Avenia ${event.subscription} webhook carrying no verification attempt`); + res.status(httpStatus.OK).json({ received: true }); + return; + } + + const owner = await findAveniaOwnerBySubaccountId(event.subAccountId); + + if (!owner) { + logger.warn(`Avenia webhook for unknown or partner-owned subaccount ${event.subAccountId}; no email will be sent`); + res.status(httpStatus.OK).json({ received: true }); + return; + } + + // The event does not say whether it settled a KYC or a KYB, so the copy follows our + // own customer record; without it every individual would be told "business verification". + const enqueued = await enqueueVerificationNotification( + attempt, + owner.profileId, + accountTypeToCustomerType(owner.accountType) + ); + + // accountType is logged so we can confirm empirically whether company attempts + // reach us this way; the reconciliation poller is retired once they demonstrably do. + logger.info( + `Avenia ${owner.accountType} verification webhook: attempt ${attempt.id} ` + + `status ${attempt.status}${attempt.result ? `/${attempt.result}` : ""}, ` + + `email ${enqueued ? "enqueued" : "not applicable"}` + ); + + res.status(httpStatus.OK).json({ received: true }); + } catch (error) { + logger.error(`Error handling Avenia webhook for subaccount ${event.subAccountId}: ${error}`); + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ error: "Failed to handle webhook" }); + } +}; diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index f6255fbcd..feaa9f3b4 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -3,10 +3,12 @@ import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; import httpStatus from "http-status"; import logger from "../../config/logger"; import CustomerEntity from "../../models/customerEntity.model"; +import EmailNotification, { NotificationProvider, NotificationType } from "../../models/emailNotification.model"; import KycCase from "../../models/kycCase.model"; import PartnerManagedProfile from "../../models/partnerManagedProfile.model"; import ProviderCustomer, {VerificationStatus} from "../../models/providerCustomer.model"; import User from "../../models/user.model"; +import { SupabaseAuthService } from "../services/auth"; import { createSubaccount, fetchSubaccountKycStatus, @@ -725,20 +727,7 @@ describe("Avenia company KYB", () => { expect(strayCreate).not.toHaveBeenCalled(); }); - it("persists an approved provider result and returns only normalized browser fields", async () => { - mockEntityPerProfile(); - const caseUpdate = mock(async () => undefined); - KycCase.findOne = mock(async () => ({ - customerEntityId: "entity-user-1", - providerCustomerId: "customer-1", - update: caseUpdate - })) as unknown as typeof KycCase.findOne; - const customerUpdate = mock(async () => undefined); - ProviderCustomer.findByPk = mock(async () => ({ - customerEntityId: "entity-user-1", - provider: "avenia", - update: customerUpdate - })) as unknown as typeof ProviderCustomer.findByPk; + function mockApprovedAttempt() { BrlaApiService.getInstance = mock( () => ({ @@ -757,17 +746,99 @@ describe("Avenia company KYB", () => { })) }) as unknown as BrlaApiService ); + } - const res = createResponse(); - await getKybAttemptStatus({ query: { attemptId: "attempt-1" }, userId: "user-1" } as any, res as any); + it("persists an approved provider result and returns only normalized browser fields", async () => { + mockEntityPerProfile(); + const events: string[] = []; + const caseUpdate = mock(async () => { + events.push("caseUpdate"); + }); + KycCase.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + providerCustomerId: "customer-1", + update: caseUpdate + })) as unknown as typeof KycCase.findOne; + const customerUpdate = mock(async () => { + events.push("customerUpdate"); + }); + ProviderCustomer.findByPk = mock(async () => ({ + customerEntityId: "entity-user-1", + provider: "avenia", + update: customerUpdate + })) as unknown as typeof ProviderCustomer.findByPk; + mockApprovedAttempt(); + + const realNotificationFindOne = EmailNotification.findOne; + const realNotificationFindOrCreate = EmailNotification.findOrCreate; + const realGetUserLocale = SupabaseAuthService.getUserLocale; + const queuedKeys: Record[] = []; + EmailNotification.findOne = mock(async () => null) as unknown as typeof EmailNotification.findOne; + SupabaseAuthService.getUserLocale = mock(async () => "en-US") as typeof SupabaseAuthService.getUserLocale; + EmailNotification.findOrCreate = mock(async ({ defaults, where }: { defaults: unknown; where: Record }) => { + events.push("enqueue"); + queuedKeys.push(where); + return [defaults as EmailNotification, true]; + }) as unknown as typeof EmailNotification.findOrCreate; + + try { + const res = createResponse(); + await getKybAttemptStatus({ query: { attemptId: "attempt-1" }, userId: "user-1" } as any, res as any); + + expect(res.body).toEqual({ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }); + expect(customerUpdate).toHaveBeenCalledWith( + expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }) + ); + expect(caseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }) + ); + // Enqueue-before-persist: a terminal case is invisible to this route's short-circuit + // and to the KYB worker, so the outcome must be queued before either write. + expect(events).toEqual(["enqueue", "customerUpdate", "caseUpdate"]); + expect(queuedKeys[0]).toEqual({ + provider: NotificationProvider.Avenia, + resourceId: "attempt-1", + type: NotificationType.VerificationApproved + }); + } finally { + EmailNotification.findOne = realNotificationFindOne; + EmailNotification.findOrCreate = realNotificationFindOrCreate; + SupabaseAuthService.getUserLocale = realGetUserLocale; + } + }); - expect(res.body).toEqual({ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }); - expect(customerUpdate).toHaveBeenCalledWith( - expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }) - ); - expect(caseUpdate).toHaveBeenCalledWith( - expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }) - ); + it("fails the request and skips the terminal writes when the outcome cannot be queued", async () => { + mockEntityPerProfile(); + const caseUpdate = mock(async () => undefined); + KycCase.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + providerCustomerId: "customer-1", + update: caseUpdate + })) as unknown as typeof KycCase.findOne; + const customerUpdate = mock(async () => undefined); + ProviderCustomer.findByPk = mock(async () => ({ + customerEntityId: "entity-user-1", + provider: "avenia", + update: customerUpdate + })) as unknown as typeof ProviderCustomer.findByPk; + mockApprovedAttempt(); + + const realNotificationFindOne = EmailNotification.findOne; + EmailNotification.findOne = mock(async () => { + throw new Error("queue unavailable"); + }) as unknown as typeof EmailNotification.findOne; + + try { + const res = createResponse(); + await getKybAttemptStatus({ query: { attemptId: "attempt-1" }, userId: "user-1" } as any, res as any); + + // The case stays non-terminal, so the next poll re-observes the outcome and retries. + expect(res.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); + expect(customerUpdate).not.toHaveBeenCalled(); + expect(caseUpdate).not.toHaveBeenCalled(); + } finally { + EmailNotification.findOne = realNotificationFindOne; + } }); }); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 5f409af2c..d854b0270 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -48,6 +48,7 @@ import { updateAveniaKycOutcome, upsertAveniaKycCase } from "../services/avenia/avenia-customer.service"; +import { enqueueVerificationNotification } from "../services/avenia/verification-notifications"; import { resolveAveniaAccountForUser } from "../services/avenia-account"; import { findCustomerEntityIdsForProfile, getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; @@ -800,6 +801,8 @@ export const initiateKybLevel1 = async ( // steps — so our status stays pending (dashboard keeps offering Continue). in_review is set only // once Avenia reports PROCESSING. await record.update({ status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }); + // The attempt id persisted here is what the KYB status worker polls; without it the + // outcome is never observed and no verification email is sent. await upsertAveniaKycCase(record, VerificationStatus.Pending, KycAttemptStatus.PENDING, response.attemptId); res.status(httpStatus.OK).json(response); @@ -886,6 +889,13 @@ export const getKybAttemptStatus = async ( ...(rejected ? { approvedAt: null, rejectedAt: new Date() } : {}) }; + // Queue before persisting a terminal status: once the case is Approved/Rejected the + // short-circuit above and the KYB worker's filters both stop observing the attempt, + // so enqueuing afterwards could lose the email forever if the webhook never fired. + // Keyed on the attempt id, so the webhook or worker racing this poll cannot + // double-send; a failed enqueue fails the request and leaves the case pollable. + await enqueueVerificationNotification(attempt, effectiveUserId, "business"); + await record.update({ lastFailureReasons: failureReason ? [failureReason] : [], status: normalizedStatus, diff --git a/apps/api/src/api/routes/v1/avenia-webhook.route.ts b/apps/api/src/api/routes/v1/avenia-webhook.route.ts new file mode 100644 index 000000000..77a2cbdb8 --- /dev/null +++ b/apps/api/src/api/routes/v1/avenia-webhook.route.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { handleAveniaWebhook } from "../../controllers/avenia-webhook.controller"; + +const router = Router(); + +/** + * POST /v1/webhooks/avenia + * Inbound KYC/KYB verification events from Avenia. Authenticated by RSA signature, + * not by API key, so it is mounted without the partner auth middleware. + */ +router.post("/", handleAveniaWebhook); + +export default router; diff --git a/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts index f09944d6d..ac4fa382b 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts @@ -6,10 +6,12 @@ import { AlfredpayKycStatus } from "@vortexfi/shared"; import logger from "../../../config/logger"; +import CustomerEntity from "../../../models/customerEntity.model"; import KycCase from "../../../models/kycCase.model"; import ProviderCustomer, { ProviderCustomerType, VerificationStatus } from "../../../models/providerCustomer.model"; import User from "../../../models/user.model"; import { findCustomerEntityIdsForProfile, getOrCreateCustomerEntityForProfile } from "../customer-entity.service"; +import { enqueueAlfredpayVerificationNotification } from "./verification-notifications"; export function alfredpayTypeToCustomerType(type: AlfredpayCustomerType): ProviderCustomerType { return type === AlfredpayCustomerType.BUSINESS ? "business" : "individual"; @@ -258,6 +260,36 @@ export async function resolveAlfredpayKybSubmissionId(alfredPayId: string): Prom return providerSubmissionIds[0] ?? persistedSubmissionId ?? undefined; } +/** + * Alfredpay publishes no verification webhook, so a terminal outcome is only ever seen by a + * status poll. Enqueuing here — rather than in either poller — makes both routes produce the + * mail from the one place the transition is observed: the dashboard refresh and the + * background sweep each stop looking at an account once it is stored terminal, so whichever + * of them got there first has to be the one that queues the email. + */ +async function enqueueTerminalVerificationEmail( + record: ProviderCustomer, + providerStatus: AlfredpayKycStatus, + submissionId: string, + updatedAt: string, + failureReason?: string +): Promise { + // Partner-owned entities have no profile to email. + const entity = await CustomerEntity.findByPk(record.customerEntityId); + if (!entity?.profileId) { + return; + } + + await enqueueAlfredpayVerificationNotification({ + reason: failureReason ?? null, + status: providerStatus, + subject: record.customerType === "business" ? "business" : "individual", + submissionId, + updatedAt, + userId: entity.profileId + }); +} + /** * Refreshes a stored Alfredpay account against the provider so an outcome that lands after the KYC * wizard was closed (e.g. the provider approving an already-submitted customer) is reflected by the @@ -294,6 +326,20 @@ export async function refreshAlfredpayCustomerStatus(record: ProviderCustomer): } return; } + // Ordered before the write, not after it: both callers select on a non-terminal stored + // status, so an account persisted terminal while the enqueue failed drops out of every + // future poll and its mail is lost for good. Throwing here leaves the account + // non-terminal and the next poll retries the outcome and the email together. + if (mapped === AlfredPayStatus.Success || mapped === AlfredPayStatus.Failed) { + await enqueueTerminalVerificationEmail( + record, + providerStatus, + submissionId, + statusResponse.updatedAt, + statusResponse.metadata?.failureReason + ); + } + await view.update({ providerCaseId: submissionId, status: mapped, @@ -303,8 +349,15 @@ export async function refreshAlfredpayCustomerStatus(record: ProviderCustomer): : {}) }); } catch (error) { - // Keep the stored status if the provider is unavailable or has no submission yet. - logger.info(`Skipping Alfredpay status refresh for customer ${record.id}: ${error}`); + // Keep the stored status if the provider is unavailable or has no submission yet; + // provider read, enqueue, and persistence retry together on the next observation. + // warn, not info: this branch also swallows enqueue/persistence failures, and a + // persistent one silently blocks the terminal status (and its email) forever. + logger.warn( + `Skipping Alfredpay status refresh for customer ${record.id}: ${ + error instanceof Error ? (error.stack ?? error.message) : error + }` + ); } } diff --git a/apps/api/src/api/services/alfredpay/verification-notifications.test.ts b/apps/api/src/api/services/alfredpay/verification-notifications.test.ts new file mode 100644 index 000000000..d039022cb --- /dev/null +++ b/apps/api/src/api/services/alfredpay/verification-notifications.test.ts @@ -0,0 +1,100 @@ +import { AlfredpayKycStatus } from "@vortexfi/shared"; +import { afterAll, beforeEach, describe, expect, it } from "bun:test"; +import EmailNotification, { NotificationProvider, NotificationType } from "../../../models/emailNotification.model"; +import { SupabaseAuthService } from "../auth"; +import { enqueueAlfredpayVerificationNotification } from "./verification-notifications"; + +const realFindOrCreate = EmailNotification.findOrCreate; +const realFindOne = EmailNotification.findOne; +const realGetUserLocale = SupabaseAuthService.getUserLocale; + +afterAll(() => { + EmailNotification.findOrCreate = realFindOrCreate; + EmailNotification.findOne = realFindOne; + SupabaseAuthService.getUserLocale = realGetUserLocale; +}); + +let queued: Record[] = []; + +beforeEach(() => { + queued = []; + SupabaseAuthService.getUserLocale = (async () => "en-US") as typeof SupabaseAuthService.getUserLocale; + EmailNotification.findOne = (async () => null) as unknown as typeof EmailNotification.findOne; + EmailNotification.findOrCreate = (async ({ defaults }: { defaults: Record }) => { + queued.push(defaults); + return [defaults, true]; + }) as unknown as typeof EmailNotification.findOrCreate; +}); + +function enqueue(status: AlfredpayKycStatus, overrides: Record = {}) { + return enqueueAlfredpayVerificationNotification({ + status, + subject: "individual", + submissionId: "submission-1", + updatedAt: "2026-08-05T10:00:00.000Z", + userId: "user-1", + ...overrides + } as Parameters[0]); +} + +describe("enqueueAlfredpayVerificationNotification", () => { + it("queues an approval for a completed submission", async () => { + expect(await enqueue(AlfredpayKycStatus.COMPLETED)).toBe(true); + + expect(queued).toHaveLength(1); + expect(queued[0].type).toBe(NotificationType.VerificationApproved); + expect(queued[0].provider).toBe(NotificationProvider.Alfredpay); + }); + + it("queues a rejection carrying the provider's failure reason", async () => { + expect(await enqueue(AlfredpayKycStatus.FAILED, { reason: "Document unreadable" })).toBe(true); + + expect(queued[0].type).toBe(NotificationType.VerificationRejected); + expect((queued[0].payload as { reason: string }).reason).toBe("Document unreadable"); + }); + + // The reason row is the one place vendor copy reaches the reader, so it is only ever + // rendered on a rejection — an approval must not leak a stale failure string. + it("drops the reason on anything but a rejection", async () => { + await enqueue(AlfredpayKycStatus.COMPLETED, { reason: "Document unreadable" }); + + expect((queued[0].payload as { reason: string | null }).reason).toBeNull(); + }); + + it("caps the reason so vendor copy cannot run away with the template", async () => { + await enqueue(AlfredpayKycStatus.FAILED, { reason: "x".repeat(500) }); + + expect((queued[0].payload as { reason: string }).reason).toHaveLength(200); + }); + + // Alfredpay reports the same vocabulary for KYC and KYB, so the noun in the copy can + // only come from our own customer record. + it("carries the subject through so KYB copy does not read as identity verification", async () => { + await enqueue(AlfredpayKycStatus.COMPLETED, { subject: "business" }); + + expect((queued[0].payload as { subject: string }).subject).toBe("business"); + }); + + // The unique key is (provider, type, resource_id): keying on the submission id is what + // makes a sweep racing or repeating a dashboard refresh a no-op. + it("keys the row on the Alfredpay submission id", async () => { + await enqueue(AlfredpayKycStatus.COMPLETED, { submissionId: "submission-xyz" }); + + expect(queued[0].resourceId).toBe("submission-xyz"); + }); + + // Alfredpay has no expiry state, and these are all still resolvable in the wizard — + // mailing any of them would tell the user a decision was reached when none was. + it("stays silent for every non-terminal status", async () => { + for (const status of [ + AlfredpayKycStatus.CREATED, + AlfredpayKycStatus.PENDING, + AlfredpayKycStatus.IN_REVIEW, + AlfredpayKycStatus.UPDATE_REQUIRED + ]) { + expect(await enqueue(status)).toBe(false); + } + + expect(queued).toHaveLength(0); + }); +}); diff --git a/apps/api/src/api/services/alfredpay/verification-notifications.ts b/apps/api/src/api/services/alfredpay/verification-notifications.ts new file mode 100644 index 000000000..d8985cb68 --- /dev/null +++ b/apps/api/src/api/services/alfredpay/verification-notifications.ts @@ -0,0 +1,68 @@ +import { AlfredpayKycStatus } from "@vortexfi/shared"; +import { NotificationProvider, NotificationType } from "../../../models/emailNotification.model"; +import { enqueueNotification } from "../email"; +import { VerificationSubject } from "../email/types"; + +const MAX_REASON_LENGTH = 200; + +/** + * Terminal outcomes only. Alfredpay has no expiry state, so `verification_expired` never + * fires for this provider — CREATED, PENDING and IN_REVIEW are still in flight, and + * UPDATE_REQUIRED is resumable in the wizard rather than a decision. + */ +function terminalNotificationType(status: AlfredpayKycStatus): NotificationType | null { + if (status === AlfredpayKycStatus.COMPLETED) { + return NotificationType.VerificationApproved; + } + + return status === AlfredpayKycStatus.FAILED ? NotificationType.VerificationRejected : null; +} + +/** + * Single enqueue path for both Alfredpay verification kinds and both poll routes (the + * dashboard's on-demand refresh and the background sweep). Keyed on the submission id, so + * the same outcome observed twice — a sweep racing a dashboard refresh, or either one + * repeating — cannot send two emails. A resubmission after a rejection normally carries a + * fresh submission id (the retry endpoints re-read it from the redirect link), which is a + * genuinely new outcome and correctly mails again. Caveat: if Alfredpay ever retains the + * id across an in-place retry, a second rejection of the same submission dedupes away — + * there is no per-outcome id to key on. + * + * `subject` decides whether the email says identity or business verification. Alfredpay + * reports the same status vocabulary for KYC and KYB, so only our own customer record + * tells them apart. + */ +export async function enqueueAlfredpayVerificationNotification({ + status, + submissionId, + userId, + subject, + updatedAt, + reason +}: { + status: AlfredpayKycStatus; + submissionId: string; + userId: string; + subject: VerificationSubject; + updatedAt: string; + reason?: string | null; +}): Promise { + const type = terminalNotificationType(status); + if (!type) { + return false; + } + + await enqueueNotification({ + payload: { + reason: type === NotificationType.VerificationRejected ? (reason?.slice(0, MAX_REASON_LENGTH) ?? null) : null, + subject, + updatedAt + }, + provider: NotificationProvider.Alfredpay, + resourceId: submissionId, + type, + userId + }); + + return true; +} diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index 82056be07..25d6cf407 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -75,6 +75,23 @@ export class SupabaseAuthService { } } + /** + * Reads the user's preferred email locale from Supabase Auth metadata. + * Falls back to the default locale when unset or unreadable. + */ + static async getUserLocale(userId: string): Promise { + try { + const { data, error } = await supabaseAdmin.auth.admin.getUserById(userId); + if (error) { + throw error; + } + return resolveLocale(data.user?.user_metadata?.locale as string | undefined).resolved; + } catch (error) { + logger.warn(`Could not read locale for user ${userId}, falling back to ${DEFAULT_LOCALE}: ${error}`); + return DEFAULT_LOCALE; + } + } + /** * Send OTP to email */ diff --git a/apps/api/src/api/services/avenia/__tests__/fixtures.ts b/apps/api/src/api/services/avenia/__tests__/fixtures.ts new file mode 100644 index 000000000..f42962c63 --- /dev/null +++ b/apps/api/src/api/services/avenia/__tests__/fixtures.ts @@ -0,0 +1,70 @@ +import * as shared from "@vortexfi/shared"; +import crypto from "crypto"; +import * as loggerModule from "../../../../config/logger"; + +function generate() { + return crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" } + }); +} + +export const primaryKeys = generate(); +export const rotatedKeys = generate(); + +// Avenia's live endpoint serves PKCS#1 ("BEGIN RSA PUBLIC KEY"), not the SPKI the other +// fixtures use, so the verifier has to accept both encodings. +export const pkcs1Keys = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "pkcs1" } +}); + +// Bun's mock.module is process-global, so every test file that stubs @vortexfi/shared +// must stub it to the same thing or whichever file loads last wins. Both Avenia test +// files therefore share this one key server and flip `servedKey` per test instead. +// `calls` counts outbound key fetches, which is what the refresh bounding is about. +export const keyServer = { calls: 0, servedKey: primaryKeys.publicKey }; + +export const getAveniaPublicKey = async (): Promise => { + keyServer.calls += 1; + return keyServer.servedKey; +}; + +// mock.module replaces the module for the whole process, so the real exports are spread +// back in: a bare stub would strip every other @vortexfi/shared export from any test file +// that happens to load after this one. +export const sharedModuleMock = () => ({ + ...shared, + BrlaApiService: { getInstance: () => ({ getAveniaPublicKey }) } +}); + +const silence = () => undefined; + +export const loggerModuleMock = () => ({ + default: { debug: silence, error: silence, info: silence, warn: silence } +}); + +// afterAll restore targets: mock.module is process-global, so each Avenia test file must +// put the real modules back when it finishes or its stubs poison every later file. +// Snapshotted HERE, at fixture load — before any mock.module call. mock.module mutates +// already-imported namespaces in place, so spreading `shared` at restore time would copy +// the stubs back instead of the real exports. +const sharedSnapshot = { ...shared }; +const loggerSnapshot = { ...loggerModule }; + +export const sharedModuleReal = () => sharedSnapshot; + +export const loggerModuleReal = () => loggerSnapshot; + +/** Mirrors Avenia's documented signing: RSA-PSS over the raw body, SHA-256, max salt. */ +export function sign(body: Buffer, key: string): string { + return crypto + .sign("sha256", body, { + key, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_MAX_SIGN + }) + .toString("base64"); +} diff --git a/apps/api/src/api/services/avenia/avenia-customer.service.ts b/apps/api/src/api/services/avenia/avenia-customer.service.ts index f8b79744b..6f6f7e6d7 100644 --- a/apps/api/src/api/services/avenia/avenia-customer.service.ts +++ b/apps/api/src/api/services/avenia/avenia-customer.service.ts @@ -1,6 +1,7 @@ import { AveniaAccountType, BrlaApiService, normalizeTaxId } from "@vortexfi/shared"; import crypto from "crypto"; import logger from "../../../config/logger"; +import CustomerEntity from "../../../models/customerEntity.model"; import KycCase from "../../../models/kycCase.model"; import ProviderCustomer, { ProviderCustomerType, VerificationStatus } from "../../../models/providerCustomer.model"; @@ -34,6 +35,26 @@ export async function findAveniaCustomerBySubaccountId(subAccountId: string): Pr }); } +/** + * The profile owning a subaccount, or null when the subaccount is unknown or is + * partner-owned — the latter has no profile behind it and so nobody to notify. + */ +export async function findAveniaOwnerBySubaccountId( + subAccountId: string +): Promise<{ accountType: AveniaAccountType; profileId: string } | null> { + const customer = await findAveniaCustomerBySubaccountId(subAccountId); + if (!customer) { + return null; + } + + const entity = await CustomerEntity.findByPk(customer.customerEntityId); + if (!entity?.profileId) { + return null; + } + + return { accountType: customerTypeToAccountType(customer.customerType), profileId: entity.profileId }; +} + /** * Keeps the single kyc_case per Avenia account in sync with the account status (the * migration backfilled exactly one case per provider account; runtime transitions update diff --git a/apps/api/src/api/services/avenia/verification-notifications.test.ts b/apps/api/src/api/services/avenia/verification-notifications.test.ts new file mode 100644 index 000000000..903249563 --- /dev/null +++ b/apps/api/src/api/services/avenia/verification-notifications.test.ts @@ -0,0 +1,106 @@ +import { KycAttemptResult, KycAttemptStatus } from "@vortexfi/shared"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import EmailNotification, { NotificationProvider, NotificationType } from "../../../models/emailNotification.model"; +import { SupabaseAuthService } from "../auth"; +import type { NotifiableAttempt } from "./verification-notifications"; + +// Resolved in beforeAll, not imported: the webhook controller test mock.modules this +// exact path, and a top-level import binds to whatever is registered at file-evaluation +// time. Resolving after that file's afterAll restore guarantees the real implementation. +let enqueueVerificationNotification: typeof import("./verification-notifications").enqueueVerificationNotification; + +beforeAll(async () => { + ({ enqueueVerificationNotification } = await import("./verification-notifications")); +}); + +const realFindOrCreate = EmailNotification.findOrCreate; +const realFindOne = EmailNotification.findOne; +const realGetUserLocale = SupabaseAuthService.getUserLocale; + +afterAll(() => { + EmailNotification.findOrCreate = realFindOrCreate; + EmailNotification.findOne = realFindOne; + SupabaseAuthService.getUserLocale = realGetUserLocale; +}); + +let queued: Record[] = []; +let keys: Record[] = []; + +beforeEach(() => { + queued = []; + keys = []; + SupabaseAuthService.getUserLocale = (async () => "en-US") as typeof SupabaseAuthService.getUserLocale; + EmailNotification.findOne = (async () => null) as unknown as typeof EmailNotification.findOne; + EmailNotification.findOrCreate = (async ({ defaults, where }: { defaults: Record; where: Record }) => { + queued.push(defaults); + keys.push(where); + return [defaults, true]; + }) as unknown as typeof EmailNotification.findOrCreate; +}); + +function attempt(overrides: Partial = {}): NotifiableAttempt { + return { + id: "attempt-1", + status: KycAttemptStatus.COMPLETED, + updatedAt: "2026-08-05T10:00:00.000Z", + ...overrides + }; +} + +describe("enqueueVerificationNotification", () => { + it("queues an approval keyed on the Avenia attempt id", async () => { + const enqueued = await enqueueVerificationNotification(attempt({ result: KycAttemptResult.APPROVED }), "user-1", "business"); + + expect(enqueued).toBe(true); + expect(queued).toHaveLength(1); + expect(queued[0].type).toBe(NotificationType.VerificationApproved); + expect((queued[0].payload as { subject: string }).subject).toBe("business"); + // The attempt id is the dedupe key that makes a replayed webhook or a racing poll a no-op. + expect(keys[0]).toEqual({ + provider: NotificationProvider.Avenia, + resourceId: "attempt-1", + type: NotificationType.VerificationApproved + }); + }); + + it("queues a rejection carrying the provider's reason, capped at 200 characters", async () => { + const enqueued = await enqueueVerificationNotification( + attempt({ result: KycAttemptResult.REJECTED, resultMessage: "y".repeat(500) }), + "user-1", + "individual" + ); + + expect(enqueued).toBe(true); + expect(queued[0].type).toBe(NotificationType.VerificationRejected); + expect((queued[0].payload as { reason: string }).reason).toBe("y".repeat(200)); + }); + + it("omits the reason on non-rejections", async () => { + await enqueueVerificationNotification( + attempt({ result: KycAttemptResult.APPROVED, resultMessage: "internal note" }), + "user-1", + "individual" + ); + + expect((queued[0].payload as { reason: string | null }).reason).toBeNull(); + }); + + it("queues an expiry regardless of result", async () => { + const enqueued = await enqueueVerificationNotification(attempt({ status: KycAttemptStatus.EXPIRED }), "user-1", "individual"); + + expect(enqueued).toBe(true); + expect(queued[0].type).toBe(NotificationType.VerificationExpired); + }); + + it("ignores non-terminal and unrecognised outcomes", async () => { + expect(await enqueueVerificationNotification(attempt({ status: KycAttemptStatus.PENDING }), "user-1", "individual")).toBe( + false + ); + expect(await enqueueVerificationNotification(attempt(), "user-1", "individual")).toBe(false); + expect( + await enqueueVerificationNotification(attempt({ result: "SOMETHING_NEW" as KycAttemptResult }), "user-1", "individual") + ).toBe(false); + + expect(queued).toHaveLength(0); + }); +}); diff --git a/apps/api/src/api/services/avenia/verification-notifications.ts b/apps/api/src/api/services/avenia/verification-notifications.ts new file mode 100644 index 000000000..d1a41c6fa --- /dev/null +++ b/apps/api/src/api/services/avenia/verification-notifications.ts @@ -0,0 +1,69 @@ +import { AveniaVerificationAttempt, KycAttemptResult, KycAttemptStatus } from "@vortexfi/shared"; +import { NotificationProvider, NotificationType } from "../../../models/emailNotification.model"; +import { enqueueNotification } from "../email"; +import { VerificationSubject } from "../email/types"; + +const MAX_REASON_LENGTH = 200; + +/** + * The fields an outcome email is built from. Narrower than the full attempt so the webhook + * receiver can validate exactly what it passes on, rather than asserting a shape Avenia + * never guaranteed; the pollers pass whole attempts, which satisfy this. + */ +export type NotifiableAttempt = Pick; + +/** + * Terminal outcomes only. An attempt that is still PENDING or PROCESSING, or that + * completed without a result we recognise, produces no email — the webhook for the + * settled state arrives later, and the reconciliation poller is a second chance at it. + */ +function terminalNotificationType(attempt: NotifiableAttempt): NotificationType | null { + if (attempt.status === KycAttemptStatus.EXPIRED) { + return NotificationType.VerificationExpired; + } + + if (attempt.status !== KycAttemptStatus.COMPLETED) { + return null; + } + + if (attempt.result === KycAttemptResult.APPROVED) { + return NotificationType.VerificationApproved; + } + + return attempt.result === KycAttemptResult.REJECTED ? NotificationType.VerificationRejected : null; +} + +/** + * Single enqueue path for both verification kinds and both delivery routes (webhook + * and reconciliation poll). Keyed on the attempt id, so the same outcome arriving + * twice — replayed webhook, or a poll racing the webhook — cannot send two emails. + * + * `subject` decides whether the email says identity or business verification: the Avenia + * attempt itself does not distinguish KYC from KYB, so the caller passes what our own + * customer record says. + */ +export async function enqueueVerificationNotification( + attempt: NotifiableAttempt, + userId: string, + subject: VerificationSubject +): Promise { + const type = terminalNotificationType(attempt); + if (!type) { + return false; + } + + await enqueueNotification({ + payload: { + reason: + type === NotificationType.VerificationRejected ? (attempt.resultMessage?.slice(0, MAX_REASON_LENGTH) ?? null) : null, + subject, + updatedAt: attempt.updatedAt + }, + provider: NotificationProvider.Avenia, + resourceId: attempt.id, + type, + userId + }); + + return true; +} diff --git a/apps/api/src/api/services/avenia/webhook-signature.test.ts b/apps/api/src/api/services/avenia/webhook-signature.test.ts new file mode 100644 index 000000000..259af195d --- /dev/null +++ b/apps/api/src/api/services/avenia/webhook-signature.test.ts @@ -0,0 +1,116 @@ +import { afterAll, beforeEach, describe, expect, it, mock, setSystemTime } from "bun:test"; +import { + keyServer, + loggerModuleMock, + loggerModuleReal, + pkcs1Keys, + primaryKeys, + rotatedKeys, + sharedModuleMock, + sharedModuleReal, + sign +} from "./__tests__/fixtures"; + +mock.module("@vortexfi/shared", sharedModuleMock); +mock.module("../../../config/logger", loggerModuleMock); + +const { REFRESH_COOLDOWN_MS, verifyAveniaSignature } = await import("./webhook-signature"); + +// The verifier keeps a cached key, a refresh cooldown and a TTL in module state, all read +// off the clock. Tests drive that clock rather than sleeping, and each one starts past the +// cooldown so a previous test's refresh cannot suppress this one's. +// +// It starts at the real current time on purpose: the other Avenia test file shares this +// module state, and a fixed past date would leave its refresh timestamps in the future, +// making the cooldown look permanently active. +let clock = new Date(); + +function advance(ms: number): void { + clock = new Date(clock.getTime() + ms); + setSystemTime(clock); +} + +afterAll(() => { + setSystemTime(); + // Restore the real modules so this file's stubs don't leak into later files. + mock.module("@vortexfi/shared", sharedModuleReal); + mock.module("../../../config/logger", loggerModuleReal); +}); + +describe("verifyAveniaSignature", () => { + beforeEach(() => { + keyServer.servedKey = primaryKeys.publicKey; + keyServer.calls = 0; + advance(REFRESH_COOLDOWN_MS + 1); + }); + + it("accepts a body signed with Avenia's key", async () => { + const body = Buffer.from(JSON.stringify({ data: { attempt: { id: "a-1" } }, subAccountId: "sub-1" })); + + expect(await verifyAveniaSignature(body, sign(body, primaryKeys.privateKey))).toBe(true); + }); + + it("accepts a key served in Avenia's PKCS#1 encoding", async () => { + keyServer.servedKey = pkcs1Keys.publicKey; + const body = Buffer.from(JSON.stringify({ data: { attempt: { id: "a-1" } }, subAccountId: "sub-1" })); + + expect(await verifyAveniaSignature(body, sign(body, pkcs1Keys.privateKey))).toBe(true); + }); + + it("rejects a signature from a foreign key", async () => { + const body = Buffer.from(JSON.stringify({ subAccountId: "sub-1" })); + + expect(await verifyAveniaSignature(body, sign(body, rotatedKeys.privateKey))).toBe(false); + }); + + it("rejects a body altered after signing", async () => { + const signature = sign(Buffer.from(JSON.stringify({ subAccountId: "sub-1" })), primaryKeys.privateKey); + + expect(await verifyAveniaSignature(Buffer.from(JSON.stringify({ subAccountId: "attacker" })), signature)).toBe(false); + }); + + it("rejects a malformed signature header", async () => { + expect(await verifyAveniaSignature(Buffer.from("{}"), "not-base64-at-all!!")).toBe(false); + }); + + it("refetches the key so a rotation does not reject genuine events", async () => { + const body = Buffer.from(JSON.stringify({ subAccountId: "sub-1" })); + // Warm the cache with the pre-rotation key. + await verifyAveniaSignature(body, sign(body, primaryKeys.privateKey)); + + keyServer.servedKey = rotatedKeys.publicKey; + advance(REFRESH_COOLDOWN_MS + 1); + + expect(await verifyAveniaSignature(body, sign(body, rotatedKeys.privateKey))).toBe(true); + }); + + it("does not fetch the key again for every forged body", async () => { + const body = Buffer.from(JSON.stringify({ subAccountId: "sub-1" })); + // Warm the cache so the forgeries below are misses against a fresh key, and clear the + // cooldown that warm-up may have started so the burst begins with a refresh available. + await verifyAveniaSignature(body, sign(body, primaryKeys.privateKey)); + advance(REFRESH_COOLDOWN_MS + 1); + keyServer.calls = 0; + + const forged = sign(body, rotatedKeys.privateKey); + for (let i = 0; i < 20; i += 1) { + expect(await verifyAveniaSignature(body, forged)).toBe(false); + } + + // One refetch for the whole burst: the rest are rejected inside the cooldown. + expect(keyServer.calls).toBe(1); + }); + + it("coalesces concurrent refreshes into a single fetch", async () => { + const body = Buffer.from(JSON.stringify({ subAccountId: "sub-1" })); + // Expire the cached key so every one of these needs a key fetched. + advance(2 * 60 * 60 * 1000); + keyServer.calls = 0; + + const signature = sign(body, primaryKeys.privateKey); + const results = await Promise.all(Array.from({ length: 5 }, () => verifyAveniaSignature(body, signature))); + + expect(results).toEqual([true, true, true, true, true]); + expect(keyServer.calls).toBe(1); + }); +}); diff --git a/apps/api/src/api/services/avenia/webhook-signature.ts b/apps/api/src/api/services/avenia/webhook-signature.ts new file mode 100644 index 000000000..cc5fd6b11 --- /dev/null +++ b/apps/api/src/api/services/avenia/webhook-signature.ts @@ -0,0 +1,94 @@ +import { BrlaApiService } from "@vortexfi/shared"; +import crypto from "crypto"; +import logger from "../../../config/logger"; + +const KEY_TTL_MS = 60 * 60 * 1000; +// Shortest gap between two outbound key fetches. The route is public and anyone can +// make a signature miss, so without this every forged body would cost Avenia a request. +export const REFRESH_COOLDOWN_MS = 30 * 1000; + +let cachedKey: { pem: string; fetchedAt: number } | null = null; +let inFlightRefresh: Promise | null = null; +let lastRefreshStartedAt = 0; + +function refreshKey(): Promise { + lastRefreshStartedAt = Date.now(); + inFlightRefresh = BrlaApiService.getInstance() + .getAveniaPublicKey() + .then(pem => { + cachedKey = { fetchedAt: Date.now(), pem }; + return pem; + }) + .finally(() => { + inFlightRefresh = null; + }); + return inFlightRefresh; +} + +/** + * The key to re-check a miss against, or null when a refresh is not allowed right now. + * Concurrent misses share one fetch, and a burst of them costs at most one fetch per + * cooldown — so a rotation is still picked up within seconds, but a flood of forgeries + * cannot be amplified into a flood of Avenia requests. + */ +function refreshedKey(): Promise | null { + if (inFlightRefresh) { + return inFlightRefresh; + } + if (Date.now() - lastRefreshStartedAt < REFRESH_COOLDOWN_MS) { + return null; + } + return refreshKey(); +} + +function verifyWith(pem: string, body: Buffer, signature: Buffer): boolean { + return crypto.verify( + "sha256", + body, + { + key: pem, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + // Avenia signs with the maximum salt length; AUTO reads the actual length back + // out of the signature, so this stays correct if they ever change it. + saltLength: crypto.constants.RSA_PSS_SALTLEN_AUTO + }, + signature + ); +} + +/** + * Verifies an Avenia webhook against their published RSA key (RSA-PSS, SHA-256). + * + * The signature covers the raw request body, so the caller must pass the unparsed + * bytes: re-serialising the parsed JSON does not reproduce them byte for byte. + * + * Avenia's guide states the key rotates and must never be pinned, so a body that + * fails against the cached key is retried once against a freshly fetched one before + * being rejected. That distinguishes a rotation from a forgery, at the cost of one + * Avenia request per cooldown window rather than one per bad signature. + */ +export async function verifyAveniaSignature(body: Buffer, signatureBase64: string): Promise { + let signature: Buffer; + try { + signature = Buffer.from(signatureBase64, "base64"); + } catch { + return false; + } + + try { + const cached = cachedKey && Date.now() - cachedKey.fetchedAt < KEY_TTL_MS ? cachedKey.pem : null; + if (cached && verifyWith(cached, body, signature)) { + return true; + } + + const refreshed = refreshedKey(); + if (!refreshed) { + return false; + } + + return verifyWith(await refreshed, body, signature); + } catch (error) { + logger.error(`Failed to verify Avenia webhook signature: ${error}`); + return false; + } +} diff --git a/apps/api/src/api/services/email/dispatch.test.ts b/apps/api/src/api/services/email/dispatch.test.ts new file mode 100644 index 000000000..4e6f62ced --- /dev/null +++ b/apps/api/src/api/services/email/dispatch.test.ts @@ -0,0 +1,366 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { Op } from "sequelize"; +import sequelize from "../../../config/database"; +import { config } from "../../../config/vars"; +import EmailNotification, { + NotificationProvider, + NotificationStatus, + NotificationType +} from "../../../models/emailNotification.model"; +import NotificationPreference from "../../../models/notificationPreference.model"; +import User from "../../../models/user.model"; +import { OutboundEmail } from "./resend.transport"; + +const RECIPIENT = "dispatch-test@vortexfinance.co"; + +const sends: OutboundEmail[] = []; +const slackAlerts: string[] = []; + +// Only the outbound edges are replaced: the transport, the Slack alert, and template +// rendering. The claim/retry/abandon logic under test runs for real against the in-memory +// table below. mock.module is process-global, so each real module is spread back in. +let sendFailure: Error | null = null; + +// Plain-object snapshots taken before mocking: mock.module mutates the imported +// namespaces in place, so a spread at restore time would copy the stubs back. +const realTransport = { ...(await import("./resend.transport")) }; +mock.module("./resend.transport", () => ({ + ...realTransport, + sendEmail: async (email: OutboundEmail) => { + if (sendFailure) { + throw sendFailure; + } + sends.push(email); + return "resend-message-id"; + } +})); + +const realSlack = { ...(await import("../slack.service")) }; +mock.module("../slack.service", () => ({ + ...realSlack, + SlackNotifier: class { + async sendMessage({ text }: { text: string }): Promise { + slackAlerts.push(text); + } + } +})); + +const realTemplates = { ...(await import("./templates")) }; +mock.module("./templates", () => ({ + ...realTemplates, + renderNotification: () => ({ html: "

body

", subject: "subject", text: "body" }) +})); + +const { dispatchPendingNotifications } = await import("./notification.service"); + +interface FakeRow { + id: string; + attempts: number; + status: NotificationStatus; + type: NotificationType; + provider: NotificationProvider; + resourceId: string; + userId: string; + lastError: string | null; + nextAttemptAt: Date; + updatedAt: Date; + update(values: Partial): Promise; +} + +let table: FakeRow[] = []; +let preferences: { emailEnabled: boolean; prefs: Record } | null = null; + +const HOUR_AGO = new Date(Date.now() - 60 * 60 * 1000); + +function row(overrides: Partial = {}): FakeRow { + const record: FakeRow = { + attempts: 0, + id: "notification-1", + lastError: null, + nextAttemptAt: HOUR_AGO, + provider: NotificationProvider.Vortex, + resourceId: "ramp-1", + status: NotificationStatus.Pending, + type: NotificationType.RampCompleted, + updatedAt: HOUR_AGO, + userId: "user-1", + async update(values: Partial) { + Object.assign(record, values); + }, + ...overrides + }; + table.push(record); + return record; +} + +/** Supports only the operators the dispatcher actually issues. */ +function satisfies(value: unknown, condition: unknown): boolean { + if (condition === null || typeof condition !== "object" || condition instanceof Date) { + return value === condition; + } + + const ops = condition as Record; + + if (Op.lt in ops) return (value as number) < (ops[Op.lt] as number); + if (Op.lte in ops) return (value as number) <= (ops[Op.lte] as number); + if (Op.gte in ops) return (value as number) >= (ops[Op.gte] as number); + if (Op.in in ops) return (ops[Op.in] as unknown[]).includes(value); + + throw new Error(`Unsupported operator in the email dispatch test double: ${String(condition)}`); +} + +function findMatching(where: Record): FakeRow[] { + return table.filter(record => + Object.keys(where).every(field => satisfies((record as unknown as Record)[field], where[field])) + ); +} + +const realFindAll = EmailNotification.findAll; +const realUpdate = EmailNotification.update; +const realTransaction = sequelize.transaction; +const realUserFindByPk = User.findByPk; +const realPreferenceFindOne = NotificationPreference.findOne; +const realApiKey = config.integrations.resend.apiKey; +const realAllowlist = config.integrations.resend.recipientAllowlist; +const realDeploymentEnv = config.deploymentEnv; + +afterAll(() => { + EmailNotification.findAll = realFindAll; + EmailNotification.update = realUpdate; + sequelize.transaction = realTransaction; + User.findByPk = realUserFindByPk; + NotificationPreference.findOne = realPreferenceFindOne; + config.integrations.resend.apiKey = realApiKey; + config.integrations.resend.recipientAllowlist = realAllowlist; + config.deploymentEnv = realDeploymentEnv; + // Restore the real modules so this file's stubs don't leak into later files. + mock.module("./resend.transport", () => realTransport); + mock.module("../slack.service", () => realSlack); + mock.module("./templates", () => realTemplates); +}); + +beforeEach(() => { + table = []; + sends.length = 0; + slackAlerts.length = 0; + preferences = null; + sendFailure = null; + + config.deploymentEnv = "test"; + config.integrations.resend.apiKey = "re_test_key"; + config.integrations.resend.recipientAllowlist = [RECIPIENT]; + + EmailNotification.findAll = (async ({ where }: { where: Record }) => + findMatching(where)) as unknown as typeof EmailNotification.findAll; + + EmailNotification.update = (async (values: Record, { where }: { where: Record }) => { + for (const record of findMatching(where)) { + for (const [field, value] of Object.entries(values)) { + // `attempts` is only ever written as literal("attempts + 1") at claim time, and + // the claim applies that increment to the instances it returns — which are these + // same objects — so applying it here too would count it twice. + if (field === "attempts") continue; + (record as unknown as Record)[field] = value; + } + record.updatedAt = new Date(); + } + return [findMatching(where).length]; + }) as unknown as typeof EmailNotification.update; + + sequelize.transaction = (async (callback: (t: unknown) => Promise) => + callback({ LOCK: { UPDATE: "UPDATE" } })) as unknown as typeof sequelize.transaction; + + User.findByPk = (async () => ({ email: RECIPIENT })) as unknown as typeof User.findByPk; + NotificationPreference.findOne = (async () => preferences) as unknown as typeof NotificationPreference.findOne; +}); + +describe("dispatchPendingNotifications", () => { + it("sends a due notification and records it as sent", async () => { + const pending = row(); + + await dispatchPendingNotifications(); + + expect(sends).toHaveLength(1); + expect(sends[0].to).toBe(RECIPIENT); + expect(pending.status).toBe(NotificationStatus.Sent); + }); + + // A crash after Resend accepts but before `sent` is persisted returns the row to the + // queue. Without a stable key the retry is a second email, not a replay of the first. + it("keys the send on the queue row so an uncertain retry cannot double-send", async () => { + row({ id: "notification-abc" }); + + await dispatchPendingNotifications(); + + expect(sends[0].idempotencyKey).toBe("notification-abc"); + }); +}); + +describe("recipient preferences", () => { + it("skips a recipient who has disabled email entirely", async () => { + preferences = { emailEnabled: false, prefs: {} }; + const pending = row(); + + await dispatchPendingNotifications(); + + expect(sends).toHaveLength(0); + expect(pending.status).toBe(NotificationStatus.Skipped); + }); + + it("skips only the notification type the recipient turned off", async () => { + preferences = { emailEnabled: true, prefs: { [NotificationType.RampCompleted]: false } }; + const muted = row({ id: "muted", type: NotificationType.RampCompleted }); + const allowed = row({ id: "allowed", resourceId: "attempt-1", type: NotificationType.VerificationApproved }); + + await dispatchPendingNotifications(); + + expect(muted.status).toBe(NotificationStatus.Skipped); + expect(allowed.status).toBe(NotificationStatus.Sent); + expect(sends).toHaveLength(1); + }); + + it("treats a recipient with no preferences row as opted in", async () => { + preferences = null; + row(); + + await dispatchPendingNotifications(); + + expect(sends).toHaveLength(1); + }); +}); + +describe("stale claim recovery", () => { + const staleSending = (attempts: number) => + row({ + attempts, + status: NotificationStatus.Sending, + updatedAt: new Date(Date.now() - 20 * 60 * 1000) + }); + + it("requeues a stale claim that still has attempts left", async () => { + const stalled = staleSending(2); + + await dispatchPendingNotifications(); + + expect(stalled.status).toBe(NotificationStatus.Sent); + expect(stalled.attempts).toBe(3); + expect(slackAlerts).toHaveLength(0); + }); + + // A process dying between claim and resolution records no failure, so the cap + // handleDeliveryFailure applies never runs. Requeuing unconditionally let a crash loop + // resend forever instead of abandoning at the cap. + it("abandons a stale claim that has spent its attempts instead of requeuing it", async () => { + const exhausted = staleSending(6); + + await dispatchPendingNotifications(); + + expect(exhausted.status).toBe(NotificationStatus.Abandoned); + expect(sends).toHaveLength(0); + expect(slackAlerts).toHaveLength(1); + expect(slackAlerts[0]).toContain("after 6 attempts"); + }); + + it("never claims a row that is already at the attempt cap", async () => { + const spent = row({ attempts: 6, status: NotificationStatus.Failed }); + + await dispatchPendingNotifications(); + + expect(spent.attempts).toBe(6); + expect(sends).toHaveLength(0); + }); +}); + +describe("claiming", () => { + // Both flow-variant backends dispatch against one table; dropping the transactional + // SKIP LOCKED claim would double-send every email whenever their cycles overlap. + it("claims inside a transaction with a row lock, SKIP LOCKED, and a bounded batch", async () => { + const captured: Record[] = []; + const previous = EmailNotification.findAll; + EmailNotification.findAll = (async (options: Record) => { + captured.push(options); + return (previous as unknown as (options: Record) => Promise)(options); + }) as unknown as typeof EmailNotification.findAll; + row(); + + await dispatchPendingNotifications(); + + const claim = captured.find(options => options.lock !== undefined); + expect(claim).toBeDefined(); + expect(claim?.skipLocked).toBe(true); + expect(claim?.transaction).toBeDefined(); + expect(claim?.limit).toBe(25); + }); +}); + +describe("non-production recipient allowlist", () => { + it("skips a recipient absent from the allowlist without calling Resend", async () => { + config.integrations.resend.recipientAllowlist = ["someone-else@vortexfinance.co"]; + const pending = row(); + + await dispatchPendingNotifications(); + + expect(sends).toHaveLength(0); + expect(pending.status).toBe(NotificationStatus.Skipped); + expect(pending.lastError).toContain("EMAIL_RECIPIENT_ALLOWLIST"); + }); + + it("skips everyone when the allowlist is empty", async () => { + config.integrations.resend.recipientAllowlist = []; + const pending = row(); + + await dispatchPendingNotifications(); + + expect(sends).toHaveLength(0); + expect(pending.status).toBe(NotificationStatus.Skipped); + }); + + it("does not gate production sends on the allowlist", async () => { + config.deploymentEnv = "production"; + config.integrations.resend.recipientAllowlist = []; + const pending = row(); + + await dispatchPendingNotifications(); + + expect(sends).toHaveLength(1); + expect(pending.status).toBe(NotificationStatus.Sent); + }); +}); + +describe("delivery failure", () => { + it("records a failed send and schedules the first retry from the backoff table", async () => { + sendFailure = new Error("Resend responded 500: internal error"); + const pending = row(); + + const before = Date.now(); + await dispatchPendingNotifications(); + + expect(pending.status).toBe(NotificationStatus.Failed); + expect(pending.lastError).toContain("Resend responded 500"); + // First failure (attempts = 1) → next attempt one minute out. + const delay = pending.nextAttemptAt.getTime() - before; + expect(delay).toBeGreaterThanOrEqual(55_000); + expect(delay).toBeLessThanOrEqual(65_000); + expect(slackAlerts).toHaveLength(0); + }); + + it("abandons on the final failed attempt and alerts Slack", async () => { + sendFailure = new Error("Resend responded 500: internal error"); + const last = row({ attempts: 5 }); + + await dispatchPendingNotifications(); + + expect(last.status).toBe(NotificationStatus.Abandoned); + expect(slackAlerts).toHaveLength(1); + expect(slackAlerts[0]).toContain("after 6 attempts"); + }); + + it("caps the recorded error text", async () => { + sendFailure = new Error("x".repeat(5000)); + const pending = row(); + + await dispatchPendingNotifications(); + + expect(pending.lastError?.length).toBeLessThanOrEqual(2000); + }); +}); diff --git a/apps/api/src/api/services/email/index.ts b/apps/api/src/api/services/email/index.ts new file mode 100644 index 000000000..b708e2d73 --- /dev/null +++ b/apps/api/src/api/services/email/index.ts @@ -0,0 +1,2 @@ +export { dispatchPendingNotifications, enqueueNotification } from "./notification.service"; +export { enqueueRampCompletedEmail, reconcileMissedRampCompletedEmails } from "./ramp-completion"; diff --git a/apps/api/src/api/services/email/notification.service.test.ts b/apps/api/src/api/services/email/notification.service.test.ts new file mode 100644 index 000000000..0b99ea557 --- /dev/null +++ b/apps/api/src/api/services/email/notification.service.test.ts @@ -0,0 +1,68 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import EmailNotification, { NotificationProvider, NotificationType } from "../../../models/emailNotification.model"; +import { SupabaseAuthService } from "../auth"; +import { enqueueNotification, nextRetryAt } from "./notification.service"; + +// The schedule the queue documents is 1/5/15/60/180 minutes. It is expressed here in +// minutes-from-now because that is what the caller stores as `next_attempt_at`. +function delayMinutes(attempts: number): number | null { + const at = nextRetryAt(attempts); + return at === null ? null : Math.round((at.getTime() - Date.now()) / 60_000); +} + +describe("nextRetryAt", () => { + it("walks the full documented backoff schedule", () => { + expect([1, 2, 3, 4, 5].map(delayMinutes)).toEqual([1, 5, 15, 60, 180]); + }); + + // A cap of 5 abandoned the row on the attempt that should have waited 180 minutes, + // so the last backoff step was never actually used. + it("reaches the 180-minute step before abandoning", () => { + expect(delayMinutes(5)).toBe(180); + expect(nextRetryAt(6)).toBeNull(); + }); + + it("abandons past the cap rather than indexing off the end of the schedule", () => { + expect(nextRetryAt(7)).toBeNull(); + expect(nextRetryAt(99)).toBeNull(); + }); +}); + +describe("enqueueNotification", () => { + const realFindOne = EmailNotification.findOne; + const realFindOrCreate = EmailNotification.findOrCreate; + const realGetUserLocale = SupabaseAuthService.getUserLocale; + + afterAll(() => { + EmailNotification.findOne = realFindOne; + EmailNotification.findOrCreate = realFindOrCreate; + SupabaseAuthService.getUserLocale = realGetUserLocale; + }); + + // Duplicates are the common case (webhook replays, re-polled attempts); they must not + // each cost a Supabase admin API call just to resolve a locale that is never used. + it("short-circuits an already-queued key before resolving the locale", async () => { + let localeLookups = 0; + let writes = 0; + EmailNotification.findOne = (async () => ({}) as EmailNotification) as unknown as typeof EmailNotification.findOne; + EmailNotification.findOrCreate = (async () => { + writes += 1; + return [{} as EmailNotification, false]; + }) as unknown as typeof EmailNotification.findOrCreate; + SupabaseAuthService.getUserLocale = (async () => { + localeLookups += 1; + return "en-US"; + }) as typeof SupabaseAuthService.getUserLocale; + + await enqueueNotification({ + payload: {}, + provider: NotificationProvider.Vortex, + resourceId: "ramp-dup", + type: NotificationType.RampCompleted, + userId: "user-1" + }); + + expect(localeLookups).toBe(0); + expect(writes).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/email/notification.service.ts b/apps/api/src/api/services/email/notification.service.ts new file mode 100644 index 000000000..069dec178 --- /dev/null +++ b/apps/api/src/api/services/email/notification.service.ts @@ -0,0 +1,277 @@ +import { literal, Op } from "sequelize"; +import sequelize from "../../../config/database"; +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import EmailNotification, { NotificationKey, NotificationStatus } from "../../../models/emailNotification.model"; +import NotificationPreference from "../../../models/notificationPreference.model"; +import User from "../../../models/user.model"; +import { SupabaseAuthService } from "../auth"; +import { SlackNotifier } from "../slack.service"; +import { EmailNotConfiguredError, sendEmail } from "./resend.transport"; +import { renderNotification } from "./templates"; + +const BACKOFF_MINUTES = [1, 5, 15, 60, 180]; +// One initial send plus one retry per backoff step. Deriving it keeps the last step +// reachable: a flat 5 abandoned the row on the attempt that should have waited 180 minutes. +const MAX_ATTEMPTS = BACKOFF_MINUTES.length + 1; +const BATCH_SIZE = 25; +const STALE_CLAIM_MS = 15 * 60 * 1000; +const STALE_ABANDON_REASON = "Abandoned after a claimed send repeatedly failed to complete"; + +interface EnqueueParams extends NotificationKey { + userId: string; + payload: Record; +} + +function describeKey({ provider, type, resourceId }: NotificationKey): string { + return `${provider}/${type} notification for resource ${resourceId}`; +} + +/** + * Records a notification to be emailed. Idempotent on the notification key: + * enqueuing the same event twice is a no-op, so callers can fire without guarding. + */ +export async function enqueueNotification({ userId, payload, ...key }: EnqueueParams): Promise { + // Duplicates are the common case (webhook replays, re-polled attempts), so check the + // key before resolving the locale — that resolution is a Supabase admin API call. + if (await EmailNotification.findOne({ where: { ...key } })) { + return; + } + + const locale = await SupabaseAuthService.getUserLocale(userId); + + const [, created] = await EmailNotification.findOrCreate({ + defaults: { ...key, locale, payload, userId }, + where: { ...key } + }); + + if (created) { + logger.info(`Enqueued ${describeKey(key)}`); + } +} + +/** + * Records a notification key as deliberately not-to-be-mailed. The row exists so sweeps + * that re-enqueue anything without a row (reconciliation) stop re-surfacing the resource; + * it is never due for dispatch. Idempotent on the key, like enqueueNotification. + */ +export async function recordSkippedNotification(key: NotificationKey, userId: string, reason: string): Promise { + const [, created] = await EmailNotification.findOrCreate({ + defaults: { ...key, lastError: reason, locale: "en-US", status: NotificationStatus.Skipped, userId }, + where: { ...key } + }); + + if (created) { + logger.info(`Recorded ${describeKey(key)} as skipped: ${reason}`); + } +} + +/** + * When a notification that has already failed `attempts` times should next be tried, + * or null once every backoff step has been spent and the row must be abandoned. + */ +export function nextRetryAt(attempts: number): Date | null { + if (attempts >= MAX_ATTEMPTS) { + return null; + } + return new Date(Date.now() + BACKOFF_MINUTES[attempts - 1] * 60 * 1000); +} + +async function alertAbandoned(notification: EmailNotification, reason: string | null): Promise { + try { + await new SlackNotifier().sendMessage({ + text: `Abandoned ${describeKey(notification)} after ${notification.attempts} attempts: ${reason}` + }); + } catch (error) { + logger.error(`Failed to send Slack alert for abandoned notification ${notification.id}: ${error}`); + } +} + +/** + * Atomically claims a batch of due notifications so a concurrent backend cannot + * pick up the same rows. Attempts are incremented at claim time, which also caps + * retries if the process dies mid-send. + */ +async function claimDueNotifications(): Promise { + return sequelize.transaction(async transaction => { + const due = await EmailNotification.findAll({ + limit: BATCH_SIZE, + lock: transaction.LOCK.UPDATE, + order: [["nextAttemptAt", "ASC"]], + skipLocked: true, + transaction, + where: { + // The cap is enforced here as well as in handleDeliveryFailure: a row that was + // requeued without a failure ever being recorded (a crash between claim and + // resolution) would otherwise be picked up forever. + attempts: { [Op.lt]: MAX_ATTEMPTS }, + nextAttemptAt: { [Op.lte]: new Date() }, + status: { [Op.in]: [NotificationStatus.Pending, NotificationStatus.Failed] } + } + }); + + if (due.length === 0) { + return []; + } + + await EmailNotification.update( + { attempts: literal("attempts + 1") as unknown as number, status: NotificationStatus.Sending }, + { transaction, where: { id: { [Op.in]: due.map(notification => notification.id) } } } + ); + + for (const notification of due) { + notification.attempts += 1; + notification.status = NotificationStatus.Sending; + } + + return due; + }); +} + +/** + * Email is opt-out: a profile with no preferences row has never disabled anything, and + * `getOrCreateNotificationPreferences` defaults `email_enabled` to true, so a missing row + * and a default row must behave alike. `email_enabled` is the master switch; + * `prefs[]` — keyed by the stored `type` value, e.g. `ramp_completed` — + * silences one type when set to false and is ignored otherwise. + * + * Resolved at delivery rather than at enqueue so an opt-out registered while a row sits in + * the queue is still honoured. + */ +async function emailIsAllowed(notification: EmailNotification): Promise { + const preferences = await NotificationPreference.findOne({ where: { profileId: notification.userId } }); + + if (!preferences) { + return true; + } + + return preferences.emailEnabled && preferences.prefs[notification.type] !== false; +} + +async function deliver(notification: EmailNotification): Promise { + if (!(await emailIsAllowed(notification))) { + logger.info(`Skipping notification ${notification.id}: the recipient has disabled email for this notification`); + await notification.update({ + lastError: "Recipient has disabled email notifications", + status: NotificationStatus.Skipped + }); + return; + } + + const user = await User.findByPk(notification.userId); + + if (!user?.email) { + await notification.update({ + lastError: "No email address on the recipient profile", + status: NotificationStatus.Skipped + }); + return; + } + + const { deploymentEnv } = config; + const { recipientAllowlist } = config.integrations.resend; + + if (deploymentEnv !== "production" && !recipientAllowlist.includes(user.email.toLowerCase())) { + logger.info(`Skipping notification ${notification.id}: ${deploymentEnv} allowlist does not include the recipient`); + await notification.update({ + lastError: `Recipient not in EMAIL_RECIPIENT_ALLOWLIST (${deploymentEnv})`, + status: NotificationStatus.Skipped + }); + return; + } + + const rendered = renderNotification(notification); + // The row id is the idempotency key: a crash after Resend accepts but before `sent` is + // persisted leaves the row to be reclaimed, and the retry must collapse into the original + // send rather than mail the user twice. + const messageId = await sendEmail({ ...rendered, idempotencyKey: notification.id, to: user.email }); + + await notification.update({ + lastError: null, + providerMessageId: messageId || null, + sentAt: new Date(), + status: NotificationStatus.Sent + }); +} + +async function handleDeliveryFailure(notification: EmailNotification, error: unknown): Promise { + const message = error instanceof Error ? error.message : String(error); + const retryAt = nextRetryAt(notification.attempts); + + await notification.update({ + lastError: message.slice(0, 2000), + nextAttemptAt: retryAt ?? notification.nextAttemptAt, + status: retryAt ? NotificationStatus.Failed : NotificationStatus.Abandoned + }); + + if (!retryAt) { + logger.error(`Abandoning notification ${notification.id} after ${notification.attempts} attempts: ${message}`); + await alertAbandoned(notification, message); + } else { + logger.warn(`Notification ${notification.id} attempt ${notification.attempts} failed: ${message}`); + } +} + +/** + * Releases rows a previous cycle claimed but never resolved (e.g. the process was + * killed mid-send), so they become eligible again instead of stalling forever. + * + * A row that has spent its attempts is abandoned here rather than requeued. Nothing + * else can retire it: a process dying between claim and resolution records no failure, + * so handleDeliveryFailure — which owns the cap on the normal path — never runs. + */ +async function releaseStaleClaims(): Promise { + const staleClaim = { + status: NotificationStatus.Sending, + updatedAt: { [Op.lt]: new Date(Date.now() - STALE_CLAIM_MS) } + }; + + const exhausted = await EmailNotification.findAll({ + where: { ...staleClaim, attempts: { [Op.gte]: MAX_ATTEMPTS } } + }); + + await EmailNotification.update( + { lastError: STALE_ABANDON_REASON, status: NotificationStatus.Abandoned }, + { where: { ...staleClaim, attempts: { [Op.gte]: MAX_ATTEMPTS } } } + ); + + await EmailNotification.update( + { nextAttemptAt: new Date(), status: NotificationStatus.Failed }, + { where: { ...staleClaim, attempts: { [Op.lt]: MAX_ATTEMPTS } } } + ); + + for (const notification of exhausted) { + logger.error(`Abandoning notification ${notification.id} stuck in sending after ${notification.attempts} attempts`); + await alertAbandoned(notification, STALE_ABANDON_REASON); + } +} + +export async function dispatchPendingNotifications(): Promise { + if (!config.integrations.resend.apiKey) { + logger.warn("RESEND_API_KEY is not set; leaving pending notifications queued"); + return; + } + + await releaseStaleClaims(); + + const claimed = await claimDueNotifications(); + if (claimed.length === 0) { + return; + } + + logger.info(`Dispatching ${claimed.length} notification(s)`); + + for (const notification of claimed) { + try { + await deliver(notification); + } catch (error) { + if (error instanceof EmailNotConfiguredError) { + // No send was attempted, so give the attempt consumed at claim time back — + // a Pending row at the cap would be invisible to claim and stale sweep alike. + await notification.update({ attempts: notification.attempts - 1, status: NotificationStatus.Pending }); + return; + } + await handleDeliveryFailure(notification, error); + } + } +} diff --git a/apps/api/src/api/services/email/ramp-completion.test.ts b/apps/api/src/api/services/email/ramp-completion.test.ts new file mode 100644 index 000000000..437a73c90 --- /dev/null +++ b/apps/api/src/api/services/email/ramp-completion.test.ts @@ -0,0 +1,168 @@ +import { RampDirection } from "@vortexfi/shared"; +import { afterAll, beforeEach, describe, expect, it } from "bun:test"; +import { FindOptions, Op } from "sequelize"; +import { SupabaseAuthService } from "../auth"; +import EmailNotification, { NotificationStatus } from "../../../models/emailNotification.model"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.model"; +import { enqueueRampCompletedEmail, reconcileMissedRampCompletedEmails } from "./ramp-completion"; + +const realRampFindAll = RampState.findAll; +const realNotificationFindOrCreate = EmailNotification.findOrCreate; +const realNotificationFindOne = EmailNotification.findOne; +const realQuoteFindByPk = QuoteTicket.findByPk; +const realGetUserLocale = SupabaseAuthService.getUserLocale; + +afterAll(() => { + RampState.findAll = realRampFindAll; + EmailNotification.findOrCreate = realNotificationFindOrCreate; + EmailNotification.findOne = realNotificationFindOne; + QuoteTicket.findByPk = realQuoteFindByPk; + SupabaseAuthService.getUserLocale = realGetUserLocale; +}); + +const completedRamp = (id: string) => + ({ + id, + phaseHistory: [{ phase: "complete", timestamp: "2026-08-01T12:30:00.000Z" }], + quoteId: `quote-for-${id}`, + type: RampDirection.BUY, + updatedAt: new Date("2026-08-01T12:31:00.000Z"), + userId: "user-1" + }) as unknown as RampState; + +// Every ramp this sweep decides is missing a notification is looked up by quote, so the +// quote lookups are exactly the set it chose to re-enqueue. Returning null stops each one +// there, keeping the test off the Supabase/locale path enqueuing would otherwise take. +let quoteLookups: string[] = []; +let rampQuery: FindOptions | undefined; + +beforeEach(() => { + quoteLookups = []; + rampQuery = undefined; + EmailNotification.findOrCreate = realNotificationFindOrCreate; + EmailNotification.findOne = (async () => null) as unknown as typeof EmailNotification.findOne; + SupabaseAuthService.getUserLocale = realGetUserLocale; + QuoteTicket.findByPk = (async (quoteId: string) => { + quoteLookups.push(quoteId); + return null; + }) as unknown as typeof QuoteTicket.findByPk; +}); + +function withMissingRamps(ids: string[]): void { + RampState.findAll = (async (options: FindOptions) => { + rampQuery = options; + return ids.map(completedRamp); + }) as unknown as typeof RampState.findAll; +} + +describe("reconcileMissedRampCompletedEmails", () => { + // The enqueue at ramp completion is not atomic with the terminal phase write, and + // `complete` is never revisited, so a crash in between would otherwise lose the email. + it("re-enqueues a completed ramp that never got a queue row", async () => { + withMissingRamps(["ramp-lost"]); + + await reconcileMissedRampCompletedEmails(); + + expect(quoteLookups).toEqual(["quote-for-ramp-lost"]); + }); + + it("asks the database only for ramps without a matching queue row and has no age cutoff", async () => { + withMissingRamps([]); + + await reconcileMissedRampCompletedEmails(); + + const where = rampQuery?.where as Record; + expect(where.updatedAt).toBeUndefined(); + expect((rampQuery?.where as Record)[Op.and]).toBeDefined(); + }); + + // No age cutoff means a prolonged outage can accumulate an arbitrary backlog; the cap + // keeps one cycle bounded, and processed ramps leave the anti-join so the rest drains. + it("processes a bounded, stable batch per cycle", async () => { + withMissingRamps([]); + + await reconcileMissedRampCompletedEmails(); + + expect(rampQuery?.limit).toBe(250); + expect(rampQuery?.order).toEqual([["updatedAt", "ASC"]]); + }); + + it("keeps going after one ramp fails to reconcile", async () => { + withMissingRamps(["ramp-broken", "ramp-lost"]); + QuoteTicket.findByPk = (async (quoteId: string) => { + quoteLookups.push(quoteId); + if (quoteId === "quote-for-ramp-broken") { + throw new Error("quote read failed"); + } + return null; + }) as unknown as typeof QuoteTicket.findByPk; + + await reconcileMissedRampCompletedEmails(); + + expect(quoteLookups).toEqual(["quote-for-ramp-broken", "quote-for-ramp-lost"]); + }); +}); + +describe("enqueueRampCompletedEmail", () => { + it("records when the ramp actually completed, not when a delayed enqueue runs", async () => { + let defaults: { payload?: Record } | undefined; + SupabaseAuthService.getUserLocale = (async () => "en") as typeof SupabaseAuthService.getUserLocale; + EmailNotification.findOrCreate = (async options => { + defaults = options.defaults; + return [{} as EmailNotification, true]; + }) as typeof EmailNotification.findOrCreate; + QuoteTicket.findByPk = (async () => ({ + inputAmount: "100", + inputCurrency: "eur", + network: "polygon", + outputAmount: "99", + outputCurrency: "usdc" + })) as unknown as typeof QuoteTicket.findByPk; + + await enqueueRampCompletedEmail(completedRamp("ramp-delayed")); + + expect(defaults?.payload?.completedAt).toBe("2026-08-01T12:30:00.000Z"); + }); + + it("trims the DECIMAL scale padding off amounts before they reach the email", async () => { + let defaults: { payload?: Record } | undefined; + SupabaseAuthService.getUserLocale = (async () => "en-US") as typeof SupabaseAuthService.getUserLocale; + EmailNotification.findOrCreate = (async options => { + defaults = options.defaults; + return [{} as EmailNotification, true]; + }) as typeof EmailNotification.findOrCreate; + QuoteTicket.findByPk = (async () => ({ + inputAmount: "1250.000000000000000000", + inputCurrency: "brl", + network: "polygon", + outputAmount: "230.450000000000000000", + outputCurrency: "usdc" + })) as unknown as typeof QuoteTicket.findByPk; + + await enqueueRampCompletedEmail(completedRamp("ramp-padded")); + + expect(defaults?.payload?.fiatAmount).toBe("1250.00"); + expect(defaults?.payload?.tokenAmount).toBe("230.45"); + }); + + it("tombstones an API-credential ramp as skipped instead of mailing the partner profile", async () => { + let defaults: { status?: string; userId?: string } | undefined; + let localeLookups = 0; + SupabaseAuthService.getUserLocale = (async () => { + localeLookups += 1; + return "en-US"; + }) as typeof SupabaseAuthService.getUserLocale; + EmailNotification.findOrCreate = (async options => { + defaults = options.defaults as typeof defaults; + return [{} as EmailNotification, true]; + }) as typeof EmailNotification.findOrCreate; + QuoteTicket.findByPk = (async () => ({ apiCredentialId: "cred-1" })) as unknown as typeof QuoteTicket.findByPk; + + await enqueueRampCompletedEmail(completedRamp("ramp-partner")); + + expect(defaults?.status).toBe(NotificationStatus.Skipped); + expect(defaults?.userId).toBe("user-1"); + expect(localeLookups).toBe(0); + }); +}); diff --git a/apps/api/src/api/services/email/ramp-completion.ts b/apps/api/src/api/services/email/ramp-completion.ts new file mode 100644 index 000000000..9a3e11ff2 --- /dev/null +++ b/apps/api/src/api/services/email/ramp-completion.ts @@ -0,0 +1,129 @@ +import { RampDirection } from "@vortexfi/shared"; +import { literal, Op } from "sequelize"; +import logger from "../../../config/logger"; +import { NotificationProvider, NotificationType } from "../../../models/emailNotification.model"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.model"; +import { trimTrailingZeros } from "../phases/blocks/core/helpers"; +import { enqueueNotification, recordSkippedNotification } from "./notification.service"; + +function getCompletedAt(rampState: RampState): string { + const completion = [...rampState.phaseHistory].reverse().find(entry => entry.phase === "complete"); + const rawTimestamp: unknown = completion?.timestamp; + const completedAt = + rawTimestamp instanceof Date + ? rawTimestamp + : typeof rawTimestamp === "string" || typeof rawTimestamp === "number" + ? new Date(rawTimestamp) + : rampState.updatedAt; + + return Number.isNaN(completedAt.getTime()) ? rampState.updatedAt.toISOString() : completedAt.toISOString(); +} + +/** + * Queues the ramp completion email. Only ramps a signed-in user runs for themselves get + * one. A partner-API ramp is excluded even though it carries a userId — the credential + * middleware fills it with the credential's linked profile, which would flood the partner + * with one email per end-customer ramp — and the email on a ramp's additionalData belongs + * to the partner's customer, not to us. Exclusion writes a skipped tombstone so the + * reconcile sweep does not re-surface the ramp every hour. + * + * Lives here rather than on RampService because the phase processor is the only + * place a ramp actually reaches the complete phase, and it cannot import + * RampService without a cycle. + */ +export async function enqueueRampCompletedEmail(rampState: RampState): Promise { + if (!rampState.userId) { + return; + } + + const quote = await QuoteTicket.findByPk(rampState.quoteId); + if (!quote) { + logger.warn(`Skipping completion email for ${rampState.id}: quote ${rampState.quoteId} not found`); + return; + } + + if (quote.apiCredentialId) { + await recordSkippedNotification( + { provider: NotificationProvider.Vortex, resourceId: rampState.id, type: NotificationType.RampCompleted }, + rampState.userId, + "Partner-API ramp: the userId is the credential's profile, not a subscribed end user" + ); + return; + } + + // On a buy the user pays fiat and receives the token; on a sell it is the other way + // round. The email always reports the fiat leg as Amount and the on-chain leg as + // Token, so which side of the quote each one reads from swaps with the direction. + const isBuy = rampState.type === RampDirection.BUY; + + await enqueueNotification({ + payload: { + completedAt: getCompletedAt(rampState), + // DECIMAL(38,18) columns come back scale-padded ("1250.000000000000000000"); + // trim them the same way the quote API response does before anyone reads them. + fiatAmount: trimTrailingZeros(isBuy ? quote.inputAmount : quote.outputAmount), + fiatCurrency: (isBuy ? quote.inputCurrency : quote.outputCurrency).toUpperCase(), + network: quote.network, + rampId: rampState.id, + rampType: isBuy ? "buy" : "sell", + tokenAmount: trimTrailingZeros(isBuy ? quote.outputAmount : quote.inputAmount), + tokenSymbol: (isBuy ? quote.outputCurrency : quote.inputCurrency).toUpperCase() + }, + provider: NotificationProvider.Vortex, + resourceId: rampState.id, + type: NotificationType.RampCompleted, + userId: rampState.userId + }); +} + +/** + * Second chance for completion emails the inline enqueue never wrote. + * + * That enqueue runs after the terminal phase is already persisted and deliberately does + * not fail the ramp, so a backend that dies — or an enqueue that throws — between the two + * leaves a completed ramp with no queue row. `complete` is terminal and never revisited, + * so nothing else would ever notice. This sweep re-enqueues those; enqueuing is keyed on + * the ramp id, so a row the inline path did write is a no-op here. + */ +// Bounds one reconcile cycle after a prolonged outage; anything beyond it drains on the +// following cycles, because every processed ramp gains a queue row and leaves the anti-join. +const RECONCILE_BATCH_SIZE = 250; + +export async function reconcileMissedRampCompletedEmails(): Promise { + const completed = await RampState.findAll({ + attributes: ["id", "phaseHistory", "quoteId", "type", "updatedAt", "userId"], + limit: RECONCILE_BATCH_SIZE, + order: [["updatedAt", "ASC"]], + where: { + [Op.and]: literal(`NOT EXISTS ( + SELECT 1 + FROM email_notifications + WHERE provider = '${NotificationProvider.Vortex}' + AND type = '${NotificationType.RampCompleted}' + AND resource_id = "RampState"."id"::text + )`), + currentPhase: "complete", + // Query only anomalies, using the notification key's index. A fixed lookback can + // turn one transient outage into a permanent gap once the completed ramp ages out. + userId: { [Op.not]: null } + } + }); + + if (completed.length === 0) { + return; + } + + logger.warn(`Reconciling ${completed.length} completed ramp(s) whose completion email was never enqueued`); + if (completed.length === RECONCILE_BATCH_SIZE) { + logger.warn(`Completion email reconcile hit its ${RECONCILE_BATCH_SIZE}-ramp cap; the remainder drains next cycle`); + } + + for (const state of completed) { + try { + await enqueueRampCompletedEmail(state); + } catch (error) { + logger.error(`Error reconciling completion email for ${state.id}: ${error}`); + } + } +} diff --git a/apps/api/src/api/services/email/resend.transport.ts b/apps/api/src/api/services/email/resend.transport.ts new file mode 100644 index 000000000..f671a203b --- /dev/null +++ b/apps/api/src/api/services/email/resend.transport.ts @@ -0,0 +1,70 @@ +import logger from "../../../config/logger"; +import { config } from "../../../config/vars"; +import { fetchWithTimeout } from "../../helpers/fetchWithTimeout"; + +const RESEND_EMAILS_URL = "https://api.resend.com/emails"; + +export interface OutboundEmail { + to: string; + subject: string; + html: string; + text: string; + /** + * Sent as `Idempotency-Key`. Resend replays the original response for a repeated key + * within 24 hours, which is what makes retrying an uncertain send safe. + */ + idempotencyKey: string; +} + +export class EmailNotConfiguredError extends Error { + constructor() { + super("RESEND_API_KEY is not set; refusing to send email"); + this.name = "EmailNotConfiguredError"; + } +} + +/** + * Sends one email through Resend and returns the provider message id. + * Throws on any non-2xx response so the caller can schedule a retry. + */ +export async function sendEmail(email: OutboundEmail): Promise { + const { apiKey, fromAddress, replyToAddress } = config.integrations.resend; + + if (!apiKey) { + throw new EmailNotConfiguredError(); + } + + const response = await fetchWithTimeout(RESEND_EMAILS_URL, { + body: JSON.stringify({ + from: fromAddress, + html: email.html, + ...(replyToAddress ? { reply_to: replyToAddress } : {}), + subject: email.subject, + text: email.text, + to: [email.to] + }), + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "Idempotency-Key": email.idempotencyKey + }, + method: "POST" + }); + + const body = await response.text(); + + if (!response.ok) { + throw new Error(`Resend responded ${response.status}: ${body.slice(0, 500)}`); + } + + try { + const parsed = JSON.parse(body) as { id?: string }; + if (!parsed.id) { + logger.warn("Resend accepted the email but returned no message id"); + } + return parsed.id ?? ""; + } catch { + logger.warn("Resend returned a non-JSON success body"); + return ""; + } +} diff --git a/apps/api/src/api/services/email/templates/index.ts b/apps/api/src/api/services/email/templates/index.ts new file mode 100644 index 000000000..a590fa3b8 --- /dev/null +++ b/apps/api/src/api/services/email/templates/index.ts @@ -0,0 +1,32 @@ +import EmailNotification, { NotificationType } from "../../../../models/emailNotification.model"; +import { + EmailLocale, + RampCompletedPayload, + RenderedEmail, + toEmailLocale, + VerificationKind, + VerificationPayload +} from "../types"; +import { renderRampCompleted } from "./ramp-completed"; +import { renderVerificationStatus } from "./verification-status"; + +const VERIFICATION_KINDS: Partial> = { + [NotificationType.VerificationApproved]: "approved", + [NotificationType.VerificationExpired]: "expired", + [NotificationType.VerificationRejected]: "rejected" +}; + +export function renderNotification(notification: EmailNotification): RenderedEmail { + const locale: EmailLocale = toEmailLocale(notification.locale); + + if (notification.type === NotificationType.RampCompleted) { + return renderRampCompleted(locale, notification.payload as unknown as RampCompletedPayload); + } + + const verificationKind = VERIFICATION_KINDS[notification.type]; + if (verificationKind) { + return renderVerificationStatus(verificationKind, locale, notification.payload as unknown as VerificationPayload); + } + + throw new Error(`No email template registered for notification type '${notification.type}'`); +} diff --git a/apps/api/src/api/services/email/templates/layout.ts b/apps/api/src/api/services/email/templates/layout.ts new file mode 100644 index 000000000..785475a46 --- /dev/null +++ b/apps/api/src/api/services/email/templates/layout.ts @@ -0,0 +1,196 @@ +import { EmailLocale } from "../types"; + +const SUPPORT_EMAIL = "support@vortexfinance.co"; + +// Served from the frontend's public/ directory. Email clients cannot render SVG and block +// data: URIs, so the mark has to be a hosted raster asset on an absolute HTTPS URL. +const MARK_URL = "https://www.vortexfinance.co/vortex-mark-email.png"; + +// Vortex design tokens from apps/frontend/App.css, converted to hex: email clients do not +// support oklch(), CSS custom properties, or + + + + + + + +
+ + + + + + + + + + +
+ Vortex +
+ + + + ${renderHighlight(body.highlight)} + + + +
+ ${renderStatus(body.status)} +

${escapeHtml(body.heading)}

+

${escapeHtml(body.intro)}

+
+ + + + +
+ ${renderDetails(body.details)} +
+
+
+
+

${escapeHtml(body.outro)}

+

+ ${SUPPORT_EMAIL} +

${renderLinks(body.links)} +
+
+ +`; +} + +export function renderText(body: EmailBody): string { + const rows = body.highlight ? [body.highlight, ...body.details] : body.details; + const details = rows.map(row => `- ${row.label}: ${row.value}`).join("\n"); + return `${body.intro}\n\n${details}\n\n${body.outro}\n${SUPPORT_EMAIL}\n`; +} diff --git a/apps/api/src/api/services/email/templates/ramp-completed.ts b/apps/api/src/api/services/email/templates/ramp-completed.ts new file mode 100644 index 000000000..c7c5b8d97 --- /dev/null +++ b/apps/api/src/api/services/email/templates/ramp-completed.ts @@ -0,0 +1,66 @@ +import { EmailLocale, RampCompletedPayload, RenderedEmail } from "../types"; +import { EmailBody, formatDate, renderHtml, renderText } from "./layout"; + +interface Copy { + subject: string; + heading: string; + intro: (rampType: "buy" | "sell") => string; + status: string; + labels: { amount: string; token: string; network: string; rampId: string; date: string }; + outro: string; +} + +const COPY: Record = { + "en-US": { + heading: "Your transaction is complete", + intro: rampType => (rampType === "buy" ? "Your purchase has been completed." : "Your sale has been completed."), + labels: { + amount: "Amount", + date: "Date", + network: "Network", + rampId: "Ramp ID", + token: "Token" + }, + outro: "If you have any questions, reach out to us at", + status: "Completed", + subject: "Your Vortex transaction is complete" + }, + "pt-BR": { + heading: "Sua transação foi concluída", + intro: rampType => (rampType === "buy" ? "Sua compra foi concluída." : "Sua venda foi concluída."), + labels: { + amount: "Valor", + date: "Data", + network: "Rede", + rampId: "ID da transação", + token: "Token" + }, + outro: "Se tiver alguma dúvida, entre em contato conosco em", + status: "Concluída", + subject: "Sua transação na Vortex foi concluída" + } +}; + +export function renderRampCompleted(locale: EmailLocale, payload: RampCompletedPayload): RenderedEmail { + const copy = COPY[locale]; + + const body: EmailBody = { + details: [ + { label: copy.labels.token, value: `${payload.tokenAmount} ${payload.tokenSymbol}` }, + { label: copy.labels.network, value: payload.network }, + { label: copy.labels.rampId, value: payload.rampId }, + { label: copy.labels.date, value: formatDate(payload.completedAt, locale) } + ], + heading: copy.heading, + highlight: { label: copy.labels.amount, value: `${payload.fiatAmount} ${payload.fiatCurrency}` }, + intro: copy.intro(payload.rampType), + outro: copy.outro, + status: { label: copy.status, tone: "success" } + }; + + return { + html: renderHtml(body), + subject: copy.subject, + text: renderText(body) + }; +} diff --git a/apps/api/src/api/services/email/templates/verification-status.test.ts b/apps/api/src/api/services/email/templates/verification-status.test.ts new file mode 100644 index 000000000..9b20986d1 --- /dev/null +++ b/apps/api/src/api/services/email/templates/verification-status.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test"; +import { VerificationPayload } from "../types"; +import { renderVerificationStatus } from "./verification-status"; + +const payload = (extra: Partial = {}): VerificationPayload => ({ + reason: null, + updatedAt: "2026-01-15T10:00:00Z", + ...extra +}); + +describe("renderVerificationStatus", () => { + it("names identity verification for an individual", () => { + const email = renderVerificationStatus("approved", "en-US", payload({ subject: "individual" })); + + expect(email.subject).toBe("Your Vortex identity verification was approved"); + expect(email.text).toContain("Your identity verification has been approved"); + expect(email.text).not.toContain("business"); + }); + + it("names business verification for a company", () => { + const email = renderVerificationStatus("approved", "en-US", payload({ subject: "business" })); + + expect(email.subject).toBe("Your Vortex business verification was approved"); + expect(email.text).toContain("Your business verification has been approved"); + }); + + it("keeps the individual/business split in pt-BR", () => { + const individual = renderVerificationStatus("rejected", "pt-BR", payload({ subject: "individual" })); + const business = renderVerificationStatus("rejected", "pt-BR", payload({ subject: "business" })); + + expect(individual.subject).toBe("Sua verificação de identidade na Vortex não foi aprovada"); + expect(business.subject).toBe("Sua verificação empresarial na Vortex não foi aprovada"); + }); + + // Rows queued before the split carry no subject; individual is the reading that was wrong + // before, so it is the one an unlabelled row must fall back to. + it("falls back to individual copy when the payload predates the subject field", () => { + const email = renderVerificationStatus("expired", "en-US", payload()); + + expect(email.subject).toBe("Your Vortex identity verification expired"); + }); + + it("still carries the rejection reason and status tone", () => { + const email = renderVerificationStatus("rejected", "en-US", payload({ reason: "Document unreadable", subject: "business" })); + + expect(email.text).toContain("Document unreadable"); + expect(email.html).toContain("Not approved"); + }); + + // Spec invariant 9 (docs/security-spec/05-integrations/resend.md): the reason is + // provider-supplied text and must never reach the HTML body as markup. + it("escapes a hostile provider reason instead of rendering it as markup", () => { + const hostile = ` & "quotes"`; + const email = renderVerificationStatus("rejected", "en-US", payload({ reason: hostile, subject: "individual" })); + + expect(email.html).not.toContain(" = { + approved: "success", + expired: "warning", + rejected: "error" +}; + +const REASON_LABEL: Record = { + "en-US": "Reason", + "pt-BR": "Motivo" +}; + +const DATE_LABEL: Record = { + "en-US": "Date", + "pt-BR": "Data" +}; + +// The only wording that differs between an individual's KYC and a company's KYB. Both +// nouns are feminine in pt-BR, so the surrounding agreement holds for either. +const NOUN: Record> = { + business: { "en-US": "business verification", "pt-BR": "verificação empresarial" }, + individual: { "en-US": "identity verification", "pt-BR": "verificação de identidade" } +}; + +const COPY: Record Copy>> = { + approved: { + "en-US": noun => ({ + heading: `Your ${noun} was approved`, + intro: `Your ${noun} has been approved. You can now continue using Vortex.`, + outro: "If you have any questions, reach out to us at", + status: "Approved", + subject: `Your Vortex ${noun} was approved` + }), + "pt-BR": noun => ({ + heading: `Sua ${noun} foi aprovada`, + intro: `Sua ${noun} foi aprovada. Você já pode continuar usando a Vortex.`, + outro: "Se tiver alguma dúvida, entre em contato conosco em", + status: "Aprovada", + subject: `Sua ${noun} na Vortex foi aprovada` + }) + }, + expired: { + "en-US": noun => ({ + heading: `Your ${noun} expired`, + intro: `Your ${noun} expired before it could be completed. You can start a new verification at any time.`, + outro: "If you have any questions, reach out to us at", + status: "Expired", + subject: `Your Vortex ${noun} expired` + }), + "pt-BR": noun => ({ + heading: `Sua ${noun} expirou`, + intro: `Sua ${noun} expirou antes de ser concluída. Você pode iniciar uma nova verificação quando quiser.`, + outro: "Se tiver alguma dúvida, entre em contato conosco em", + status: "Expirada", + subject: `Sua ${noun} na Vortex expirou` + }) + }, + rejected: { + "en-US": noun => ({ + heading: `Your ${noun} was not approved`, + intro: `Your ${noun} could not be approved.`, + outro: "If you have any questions, reach out to us at", + status: "Not approved", + subject: `Your Vortex ${noun} was not approved` + }), + "pt-BR": noun => ({ + heading: `Sua ${noun} não foi aprovada`, + intro: `Não foi possível aprovar sua ${noun}.`, + outro: "Se tiver alguma dúvida, entre em contato conosco em", + status: "Não aprovada", + subject: `Sua ${noun} na Vortex não foi aprovada` + }) + } +}; + +export function renderVerificationStatus( + kind: VerificationKind, + locale: EmailLocale, + payload: VerificationPayload +): RenderedEmail { + const subject: VerificationSubject = payload.subject === "business" ? "business" : "individual"; + const copy = COPY[kind][locale](NOUN[subject][locale]); + + const body: EmailBody = { + details: [ + ...(payload.reason ? [{ label: REASON_LABEL[locale], value: payload.reason }] : []), + { label: DATE_LABEL[locale], value: formatDate(payload.updatedAt, locale) } + ], + heading: copy.heading, + intro: copy.intro, + outro: copy.outro, + status: { label: copy.status, tone: STATUS_TONES[kind] } + }; + + return { + html: renderHtml(body), + subject: copy.subject, + text: renderText(body) + }; +} diff --git a/apps/api/src/api/services/email/types.ts b/apps/api/src/api/services/email/types.ts new file mode 100644 index 000000000..174038994 --- /dev/null +++ b/apps/api/src/api/services/email/types.ts @@ -0,0 +1,44 @@ +export const SUPPORTED_LOCALES = ["en-US", "pt-BR"] as const; + +export type EmailLocale = (typeof SUPPORTED_LOCALES)[number]; + +export const DEFAULT_EMAIL_LOCALE: EmailLocale = "en-US"; + +export function toEmailLocale(locale: string | null | undefined): EmailLocale { + return SUPPORTED_LOCALES.includes(locale as EmailLocale) ? (locale as EmailLocale) : DEFAULT_EMAIL_LOCALE; +} + +export interface RenderedEmail { + subject: string; + html: string; + text: string; +} + +export interface RampCompletedPayload { + rampId: string; + rampType: "buy" | "sell"; + // The fiat leg and the on-chain leg, already resolved to the user's perspective: + // which side of the quote each one comes from depends on the ramp direction. + fiatAmount: string; + fiatCurrency: string; + tokenAmount: string; + tokenSymbol: string; + network: string; + completedAt: string; +} + +export interface VerificationPayload { + reason: string | null; + updatedAt: string; + // Absent on rows queued before the individual/business split; those read as individual, + // which is the common case and the one the business-only copy was wrong for. + subject?: VerificationSubject; +} + +// Templates stay decoupled from the Notification model so they can be rendered +// (and previewed) without pulling in the database layer. +export type VerificationKind = "approved" | "expired" | "rejected"; + +// Whose verification it was: an individual's KYC or a company's KYB. Both arrive on the +// same Avenia attempts resource, so only our own customer record can tell them apart. +export type VerificationSubject = "business" | "individual"; diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts index 416366c27..424065ace 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.flow.test.ts @@ -30,13 +30,11 @@ const CORE_PHASES: RampPhase[] = [ ]; describe("Alfredpay offramp flow", () => { - it("fails closed for persisted v1 identities (drain-then-deploy contract)", () => { - // Version 2 added the fee-collection phase. v1 is deliberately NOT kept - // dispatchable: deploys are gated on draining v1 quotes and in-flight ramps, - // and anything that slips through must fail closed for manual recovery. - expect(alfredpayOfframpFlow.identity.version).toBe(2); - expect(() => getBlockFlowByIdentity({ ...alfredpayOfframpFlow.identity, version: 1 })).toThrow( - /Unsupported persisted flow AlfredpayOfframp@1/ + it("fails closed for persisted pre-v3 identities (drain-then-deploy contract)", () => { + expect(alfredpayOfframpFlow.identity.version).toBe(3); + expect(alfredpayOfframpFlow.identity.blockSchemaVersions.alfredpayOfframp).toBe(2); + expect(() => getBlockFlowByIdentity({ ...alfredpayOfframpFlow.identity, version: 2 })).toThrow( + /Unsupported persisted flow AlfredpayOfframp@2/ ); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts index 04be6c2ae..d38c0351a 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-offramp.registration.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, mock } from "bun:test"; -import { type EvmNetworks, EvmToken, FiatToken, Networks } from "@vortexfi/shared"; +import { + AlfredpayFeeType, + AlfredpayOnChainCurrency, + type EvmNetworks, + EvmToken, + FiatToken, + Networks +} from "@vortexfi/shared"; import { registerAlfredpayOfframp } from "../phases/alfredpay-offramp/registration"; import type { AlfredpayOfframpMetadata } from "../phases/alfredpay-offramp/simulation"; @@ -19,6 +26,28 @@ const metadata: AlfredpayOfframpMetadata = { network: Networks.Polygon, outputAmountDecimal: "1980", outputAmountRaw: "198000", + pricing: { + customer: { allInRate: "19.8", inputAmountUsd: "100", referenceDifferenceBps: "-100" }, + provider: { + baseCurrency: AlfredpayOnChainCurrency.USDT, + feeAmount: "1", + fees: [{ amount: "1", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }], + grossRate: "20", + grossReferenceDifferenceBps: "0", + netRate: "20", + netReferenceDifferenceBps: "0", + quoteCurrency: FiatToken.MXN, + quotedAt: new Date("2026-01-01T00:00:00Z"), + source: "alfredpay" + }, + reference: { + baseCurrency: "USD", + observedAt: new Date("2026-01-01T00:00:00Z"), + quoteCurrency: FiatToken.MXN, + rate: "20", + source: "fastforex" + } + }, quoteId: "quote-old", subsidyAmountDecimal: "0", subsidyAmountRaw: "0", diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts index f292e3cdf..c114a5e41 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-cross-chain.flow.test.ts @@ -98,6 +98,15 @@ const CORE_PHASES: RampPhase[] = [ function buildCtx(): PhaseCtx { return { addNote: () => undefined, + evmDestinationGas: { + executionFeeUsd: "0.01", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1", + network: Networks.Arbitrum, + programVersion: 2, + transferGasLimit: "100000" + }, notes: [], now: new Date(), partner: { id: null }, @@ -150,12 +159,12 @@ describe("Alfredpay cross-chain onramp flow", () => { expect(output.token).toBe(EvmToken.USDC); expect(output.chain).toBe(Networks.Arbitrum); - expect(output.amount.toFixed()).toBe("95"); + expect(output.amount.toFixed()).toBe("94.99"); expect(metadata.globals.fees.usd).toEqual({ anchor: "2", - network: "0", + network: "0.01", partnerMarkup: "1", - total: "4.000000", + total: "4.010000", vortex: "1" }); expect(Object.keys(metadata.blocks)).toEqual([ @@ -168,7 +177,7 @@ describe("Alfredpay cross-chain onramp flow", () => { "distributeFees" ]); expect(getBlockMetadata(metadata, AlfredpayMintContext).outputAmountRaw).toBe("98000000"); - expect(getBlockMetadata(metadata, SubsidizePreContext).targetInputAmountRaw).toBe("96000000"); - expect(getBlockMetadata(metadata, SquidRouterSwapContext).inputAmountRaw).toBe("96000000"); + expect(getBlockMetadata(metadata, SubsidizePreContext).targetInputAmountRaw).toBe("95990000"); + expect(getBlockMetadata(metadata, SquidRouterSwapContext).inputAmountRaw).toBe("95990000"); }); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts index c67552e1d..59d2174bd 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/alfredpay-onramp-direct.flow.test.ts @@ -85,6 +85,15 @@ const ALFREDPAY_ONRAMP_DIRECT: RampPhase[] = ["initial", ...CORE_PHASES, "comple function buildCtx(outputCurrency: EvmToken): PhaseCtx { return { addNote: () => undefined, + evmDestinationGas: { + executionFeeUsd: "0.01", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1", + network: Networks.Polygon, + programVersion: 2, + transferGasLimit: "100000" + }, notes: [], now: new Date(), partner: { id: null }, @@ -135,12 +144,12 @@ describe("Alfredpay direct onramp flow", () => { expect(squidCalculations).toBe(0); expect(capturedProviderRequests[0]?.metadata.customerId).toBe("anonymous"); - expect(output).toMatchObject({ amountRaw: "96000000", chain: Networks.Polygon, token: ALFREDPAY_EVM_TOKEN }); + expect(output).toMatchObject({ amountRaw: "95990000", chain: Networks.Polygon, token: ALFREDPAY_EVM_TOKEN }); expect(metadata.blocks.squidRouterSwap).toMatchObject({ effectiveExchangeRate: "1", - inputAmountRaw: "96000000", + inputAmountRaw: "95990000", networkFeeUSD: "0", - outputAmountRaw: "96000000" + outputAmountRaw: "95990000" }); }); @@ -149,10 +158,10 @@ describe("Alfredpay direct onramp flow", () => { const { metadata, output } = await makeAlfredpayOnrampDirectFlow(EvmToken.USDC).simulate(buildCtx(EvmToken.USDC)); expect(squidCalculations).toBe(1); - expect(output).toMatchObject({ amountRaw: "95000000", chain: Networks.Polygon, token: EvmToken.USDC }); + expect(output).toMatchObject({ amountRaw: "94990000", chain: Networks.Polygon, token: EvmToken.USDC }); expect(metadata.blocks.squidRouterSwap).toMatchObject({ - inputAmountRaw: "96000000", - outputAmountRaw: "95000000" + inputAmountRaw: "95990000", + outputAmountRaw: "94990000" }); }); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts index e630b0a3e..650973ded 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.flow.test.ts @@ -1,6 +1,7 @@ import { afterAll, describe, expect, it, mock } from "bun:test"; import Big from "big.js"; import { BrlaApiService, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import { config } from "../../../../../config/vars"; import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; const partnerPricingReal = { ...partnerPricingNamespace }; @@ -166,7 +167,7 @@ describe("BRL cross-chain onramp flow compile-time adjacency", () => { }); }); -function buildCtx(): PhaseCtx { +function buildCtx(includeDynamicFunding = true): PhaseCtx { const notes: string[] = []; return { addNote: (note: string) => { @@ -176,6 +177,19 @@ function buildCtx(): PhaseCtx { displayFiat: { anchor: "0.1", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" }, usd: { anchor: "0.1", network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" } }, + ...(includeDynamicFunding + ? { + evmDestinationGas: { + executionFeeUsd: "0.01", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1", + network: Networks.Arbitrum, + programVersion: 2 as const, + transferGasLimit: "100000" + } + } + : {}), notes, now: new Date(), partner: null, @@ -191,7 +205,7 @@ function buildCtx(): PhaseCtx { }; } -async function runFlow(flow: typeof brlOnrampBaseCrossChainFlow) { +async function runFlow(flow: typeof brlOnrampBaseCrossChainFlow, includeDynamicFunding = true) { BrlaApiService.getInstance = mock(() => ({ createPayInQuote: mock(async (request: { inputCurrency: string }) => ({ appliedFees: [{ amount: "0.2", type: "Gas Fee" }], @@ -200,7 +214,7 @@ async function runFlow(flow: typeof brlOnrampBaseCrossChainFlow) { })) })) as unknown as typeof BrlaApiService.getInstance; - return flow.simulate(buildCtx()); + return flow.simulate(buildCtx(includeDynamicFunding)); } describe("BRL cross-chain onramp flow simulation", () => { @@ -210,6 +224,17 @@ describe("BRL cross-chain onramp flow simulation", () => { expect(output.token).toBe(EvmToken.USDC); expect(output.chain).toBe(Networks.Arbitrum); }); + + it("keeps producing legacy-compatible metadata until the rollout flag is enabled", async () => { + const originalEnabled = config.evmDestinationGas.dynamicFundingEnabled; + config.evmDestinationGas.dynamicFundingEnabled = false; + try { + const { metadata } = await runFlow(brlOnrampBaseCrossChainFlow, false); + expect(metadata.globals.evmDestinationGas).toBeUndefined(); + } finally { + config.evmDestinationGas.dynamicFundingEnabled = originalEnabled; + } + }); }); describe("BRL cross-chain onramp flow metadata ownership", () => { @@ -217,7 +242,7 @@ describe("BRL cross-chain onramp flow metadata ownership", () => { const { metadata } = await runFlow(brlOnrampBaseCrossChainFlow); const { blocks, globals } = metadata; - expect(globals.fees.usd).toMatchObject({ anchor: "1.5", network: "0.1", total: "1.700000", vortex: "0.1" }); + expect(globals.fees.usd).toMatchObject({ anchor: "1.5", network: "0.11", total: "1.710000", vortex: "0.1" }); expect(Object.keys(blocks)).toEqual([ "aveniaMint", "fundEphemeral", @@ -233,7 +258,7 @@ describe("BRL cross-chain onramp flow metadata ownership", () => { const aveniaMint = getBlockMetadata(metadata, AveniaMintContext).mint; expect(aveniaMint).toBeDefined(); expect(aveniaMint.currency).toBe(FiatToken.BRL); - // 100 BRL in, 99 BRLA quoted -> 1 BRL mint fee, 0.2 gas fee deducted from delivery + // 100 BRL in, 99 BRLA quoted -> 1 BRL mint fee, 0.2 provider gas fee deducted from delivery expect(Big(aveniaMint.fee).toFixed()).toBe("1"); expect(Big(aveniaMint.inputAmountDecimal).toFixed()).toBe("100"); expect(Big(aveniaMint.outputAmountDecimal).toFixed()).toBe("98.8"); @@ -260,8 +285,8 @@ describe("BRL cross-chain onramp flow metadata ownership", () => { expect(evmToEvm.networkFeeUSD).toBe("0.1"); const distributeFees = getBlockMetadata(metadata, DistributeFeesContext); - expect(distributeFees.networkFeeUsd).toBe("0.1"); - expect(distributeFees.totalFeesUsd).toBe("0.2"); + expect(distributeFees.networkFeeUsd).toBe("0.11"); + expect(distributeFees.totalFeesUsd).toBe("0.21"); const subsidy = getBlockMetadata(metadata, FinalSettlementSubsidyContext); expect(subsidy).toBeDefined(); @@ -271,7 +296,7 @@ describe("BRL cross-chain onramp flow metadata ownership", () => { expect(getBlockMetadata(metadata, SubsidizePreContext).inputCurrency).toBe(EvmToken.BRLA); const subsidizePost = getBlockMetadata(metadata, SubsidizePostContext); expect(subsidizePost.outputCurrency).toBe(EvmToken.USDC); - expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe("17.8"); + expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe("17.79"); expect(getBlockMetadata(metadata, DestinationTransferContext).amountRaw).toBe("17500000"); }); }); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts index ee69dff6d..abb8c1b6a 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-cross-chain.transactions.test.ts @@ -16,7 +16,7 @@ import * as evmFundingNamespace from "../core/evm-funding"; import * as partnerPricingNamespace from "../../../partners/partner-pricing.service"; import type { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; import Big from "big.js"; -import { decodeFunctionData, erc20Abi } from "viem"; +import { decodeFunctionData, erc20Abi, parseTransaction } from "viem"; import type { FlowMetadata } from "../core/metadata"; import type { SubsidyMetadata } from "../phases/subsidize-pre/simulation"; @@ -203,6 +203,15 @@ function buildMetadata(): FlowMetadata { } }, globals: { + evmDestinationGas: { + executionFeeUsd: "0.363", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1000000000", + network: Networks.Arbitrum, + programVersion: 2, + transferGasLimit: "100000" + }, fees: { usd: { anchor: "0.1", network: "0.1", partnerMarkup: "0", total: "0.3", vortex: "0.1" } }, partner: null, request: REQUEST @@ -263,6 +272,10 @@ describe("BRL onramp Base cross-chain transactions", () => { expect(blocks.unsignedTxs.find(tx => tx.phase === "squidRouterSwap")?.txData).toMatchObject({ data: "0xa2" }); expect(blocks.unsignedTxs.find(tx => tx.phase === "backupSquidRouterApprove")?.txData).toMatchObject({ data: "0xb1" }); expect(blocks.unsignedTxs.find(tx => tx.phase === "backupSquidRouterSwap")?.txData).toMatchObject({ data: "0xb2" }); + expect(blocks.unsignedTxs.find(tx => tx.phase === "destinationTransfer")?.txData).toMatchObject({ + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "1000000" + }); }); it("allocates the production nonce lanes per network", async () => { @@ -300,6 +313,9 @@ describe("BRL onramp Base cross-chain transactions", () => { const presignedTxs = await signUnsignedTransactions(blocks.unsignedTxs, { evmEphemeral }); expect(presignedTxs.length).toBeGreaterThanOrEqual(blocks.unsignedTxs.length); expect(presignedTxs.every(tx => typeof tx.txData === "string" && tx.txData.startsWith("0x"))).toBe(true); + const destinationTransfer = presignedTxs.find(tx => tx.phase === "destinationTransfer"); + expect(destinationTransfer).toBeDefined(); + expect(parseTransaction(destinationTransfer?.txData as `0x${string}`).maxFeePerGas).toBe(3_000_000_000n); }, 60_000); it("preserves 18-decimal BSC USDT precision in the destination transfer", async () => { diff --git a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts index 1a5c7826f..8a8f3dea7 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/brl-onramp-base-same-chain.flow.test.ts @@ -100,6 +100,17 @@ function buildCtx(outputCurrency: EvmToken): PhaseCtx { displayFiat: { anchor: "0.1", currency: FiatToken.BRL, network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" }, usd: { anchor: "0.1", network: "0", partnerMarkup: "0", total: "0.2", vortex: "0.1" } }, + evmDestinationGas: { + executionFeeUsd: "0.01", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1", + maximumFundingL1FeeRaw: "1", + maximumPayoutL1FeeRaw: "1", + network: Networks.Base, + programVersion: 2, + transferGasLimit: "100000" + }, notes: [], now: new Date(), partner: null, @@ -166,15 +177,17 @@ describe("BRL Base same-chain block flows", () => { expect(destinationTransfer.network).toBe(Networks.Base); expect(destinationTransfer.token).toBe(outputCurrency); expect(metadata.globals.fees.usd.anchor).toBe("1.5"); - expect(metadata.globals.fees.usd.network).toBe(outputCurrency === EvmToken.USDC ? "0" : "0.1"); - expect(metadata.globals.fees.usd.total).toBe(outputCurrency === EvmToken.USDC ? "1.600000" : "1.700000"); - expect(metadata.globals.fees.displayFiat?.network).toBe(outputCurrency === EvmToken.USDC ? "0" : "0.1"); - expect(metadata.globals.fees.displayFiat?.total).toBe(outputCurrency === EvmToken.USDC ? "1.60" : "1.70"); + expect(metadata.globals.fees.usd.network).toBe(outputCurrency === EvmToken.USDC ? "0.01" : "0.11"); + expect(metadata.globals.fees.usd.total).toBe(outputCurrency === EvmToken.USDC ? "1.610000" : "1.710000"); + expect(metadata.globals.fees.displayFiat?.network).toBe(outputCurrency === EvmToken.USDC ? "0.01" : "0.11"); + expect(metadata.globals.fees.displayFiat?.total).toBe(outputCurrency === EvmToken.USDC ? "1.61" : "1.71"); expect(getBlockMetadata(metadata, DistributeFeesContext).networkFeeUsd).toBe( - outputCurrency === EvmToken.USDC ? "0" : "0.1" + outputCurrency === EvmToken.USDC ? "0.01" : "0.11" ); const subsidizePost = getBlockMetadata(metadata, SubsidizePostContext); - expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe(outputCurrency === EvmToken.USDC ? "17.9" : "17.8"); + expect(Big(subsidizePost.actualOutputAmountDecimal).toFixed()).toBe( + outputCurrency === EvmToken.USDC ? "17.89" : "17.79" + ); expect(subsidizePost.applied).toBe(false); }); } diff --git a/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts index 46537f65c..425732660 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/fund-ephemeral-user-hashes.test.ts @@ -3,6 +3,11 @@ import { EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; import type QuoteTicket from "../../../../../models/quoteTicket.model"; import type RampState from "../../../../../models/rampState.model"; import * as userTxVerifier from "../../../phases/helpers/user-tx-verifier"; +import { privateKeyToAccount } from "viem/accounts"; + +// Snapshot before mocking: mock.module mutates the imported namespace in place, so +// spreading `userTxVerifier` at restore time would copy the stub back. +const userTxVerifierReal = { ...userTxVerifier }; const verifyUserSubmittedTxByHash = mock(async () => undefined); mock.module("../../../phases/helpers/user-tx-verifier", () => ({ @@ -12,7 +17,7 @@ mock.module("../../../phases/helpers/user-tx-verifier", () => ({ const { FundEphemeralExecutor } = await import("../phases/fund-ephemeral/execution"); afterAll(() => { - mock.module("../../../phases/helpers/user-tx-verifier", () => ({ ...userTxVerifier })); + mock.module("../../../phases/helpers/user-tx-verifier", () => userTxVerifierReal); }); function makeQuote(outputCurrency: FiatToken = FiatToken.BRL) { @@ -104,3 +109,113 @@ describe("FundEphemeralExecutor user hash verification", () => { expect(verifyUserSubmittedTxByHash).not.toHaveBeenCalled(); }); }); + +describe("FundEphemeralExecutor destination gas funding", () => { + it("uses the signed payout liability for non-Ethereum EVM destinations", async () => { + const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); + const rawTx = await account.signTransaction({ + chainId: 137, + gas: 100_000n, + maxFeePerGas: 30_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: "0x0000000000000000000000000000000000000001", + type: "eip1559", + value: 0n + }); + const handler = Object.create(FundEphemeralExecutor.prototype) as any; + handler.getPresignedTransaction = () => ({ + meta: {}, + network: Networks.Polygon, + nonce: 0, + phase: "destinationTransfer", + signer: account.address, + txData: rawTx + }); + const state = { + unsignedTxs: [ + { + network: Networks.Polygon, + nonce: 0, + phase: "destinationTransfer", + signer: account.address, + txData: { + data: "0x", + gas: "100000", + maxFeePerGas: "10000000000", + maxPriorityFeePerGas: "1000000000", + to: "0x0000000000000000000000000000000000000001", + value: "0" + } + } + ] + } as unknown as RampState; + + expect( + await handler.getDestinationEvmFundingRequirementRaw(state, Networks.Polygon, { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "12000000000", + network: Networks.Polygon, + programVersion: 2, + transferGasLimit: "100000" + }) + ).toBe(3_000_000_000_000_000n); + }); + + it("reserves the persisted Base payout L1 envelope instead of a live early fee", async () => { + const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); + const rawTx = await account.signTransaction({ + chainId: 8453, + gas: 100_000n, + maxFeePerGas: 3_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: "0x0000000000000000000000000000000000000001", + type: "eip1559", + value: 0n + }); + const handler = Object.create(FundEphemeralExecutor.prototype) as any; + handler.getPresignedTransaction = () => ({ + meta: {}, + network: Networks.Base, + nonce: 0, + phase: "destinationTransfer", + signer: account.address, + txData: rawTx + }); + const state = { + unsignedTxs: [ + { + network: Networks.Base, + nonce: 0, + phase: "destinationTransfer", + signer: account.address, + txData: { + data: "0x", + gas: "100000", + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "1000000000", + to: "0x0000000000000000000000000000000000000001", + value: "0" + } + } + ] + } as unknown as RampState; + + expect( + await handler.getDestinationEvmFundingRequirementRaw(state, Networks.Base, { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "15000000000000", + network: Networks.Base, + programVersion: 2, + transferGasLimit: "100000" + }) + ).toBe(315_000_000_000_000n); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/__tests__/offramp-subsidy-usd-valuation.test.ts b/apps/api/src/api/services/phases/blocks/__tests__/offramp-subsidy-usd-valuation.test.ts index b44129424..87ba290c7 100644 --- a/apps/api/src/api/services/phases/blocks/__tests__/offramp-subsidy-usd-valuation.test.ts +++ b/apps/api/src/api/services/phases/blocks/__tests__/offramp-subsidy-usd-valuation.test.ts @@ -4,6 +4,10 @@ import Big from "big.js"; import * as partnerPricingService from "../../../partners/partner-pricing.service"; import { priceFeedService } from "../../../priceFeed.service"; +// Snapshot before mocking: mock.module mutates the imported namespace in place, so +// spreading `partnerPricingService` at restore time would copy the stub back. +const partnerPricingReal = { ...partnerPricingService }; + const findPartnerWithPricing = mock(async () => null); mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingService, @@ -13,7 +17,7 @@ mock.module("../../../partners/partner-pricing.service", () => ({ const { simulateOfframpSubsidizePost } = await import("../phases/subsidize-post/simulation"); afterAll(() => { - mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingService })); + mock.module("../../../partners/partner-pricing.service", () => partnerPricingReal); }); describe("block offramp subsidy USD valuation", () => { diff --git a/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts b/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts index 7ca1e8e2b..c68358c93 100644 --- a/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/destination-funding.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from "bun:test"; import { Networks } from "@vortexfi/shared"; import { privateKeyToAccount } from "viem/accounts"; import { UnrecoverablePhaseError } from "../../../../errors/phase-error"; -import { ensurePresignedTransferFunded } from "./destination-funding"; +import { + calculateDestinationFundingShortfallRaw, + calculateSourceEvmFundingRequirementRaw, + ensurePresignedTransferFunded, + getDynamicDestinationEvmFundingNetwork +} from "./destination-funding"; +import { calculatePresignedGasBudgetRaw } from "./evm-destination-gas"; const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); const recipient = "0x0000000000000000000000000000000000000001"; @@ -29,3 +35,35 @@ describe("ensurePresignedTransferFunded", () => { ); }); }); + +describe("EVM destination gas funding", () => { + it("derives the funding requirement from the signed transaction fee cap", async () => { + const rawTx = await account.signTransaction({ + chainId: 1, + gas: 100_000n, + maxFeePerGas: 3_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: recipient, + type: "eip1559", + value: 0n + }); + + expect(calculatePresignedGasBudgetRaw(rawTx)).toBe(300_000_000_000_000n); + }); + + it("funds only the shortfall below the signed gas requirement", () => { + expect(calculateDestinationFundingShortfallRaw(300n, 125n)).toBe(175n); + expect(calculateDestinationFundingShortfallRaw(300n, 300n)).toBe(0n); + expect(calculateDestinationFundingShortfallRaw(300n, 400n)).toBe(0n); + }); + + it("keeps a same-network payout liability additive to source reserves", () => { + expect(calculateSourceEvmFundingRequirementRaw(100n, 25n, 300n)).toBe(425n); + }); + + it("does not dynamically fund destination gas for direct-transfer flows", () => { + expect(getDynamicDestinationEvmFundingNetwork(Networks.Base, true, true)).toBeUndefined(); + expect(getDynamicDestinationEvmFundingNetwork(Networks.Base, true, false)).toBe(Networks.Base); + }); +}); 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 4189a751f..8d6717356 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 @@ -4,6 +4,7 @@ import { checkEvmNativeBalancePeriodically, EvmClientManager, EvmNetworks, + isNetworkEVM, Networks } from "@vortexfi/shared"; import Big from "big.js"; @@ -19,6 +20,21 @@ import { 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 +// such quotes/ramps have expired or completed. +export const LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS: Record = { + [Networks.Arbitrum]: "0.0002", + [Networks.Avalanche]: "0.0034", + [Networks.Base]: "0.000034", + [Networks.BaseSepolia]: "0.000034", + [Networks.BSC]: "0.000115", + [Networks.Ethereum]: "0.005", + [Networks.Moonbeam]: "0.34", + [Networks.Polygon]: "0.6", + [Networks.PolygonAmoy]: "0.2" +}; + export async function isPendulumEphemeralFunded(pendulumEphemeralAddress: string, pendulumNode: API): Promise { const fundingAmountUnits = Big(PENDULUM_EPHEMERAL_STARTING_BALANCE_UNITS); const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, pendulumNode.decimals).toFixed(); @@ -54,21 +70,39 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): return Big(balance.toString()).gte(fundingAmountRaw); } -export const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { - [Networks.Ethereum]: "0.005", - [Networks.Arbitrum]: "0.0002", - [Networks.Base]: "0.000034", - [Networks.Polygon]: "0.6", - [Networks.BSC]: "0.000115", - [Networks.Avalanche]: "0.0034", - [Networks.Moonbeam]: "0.34", - [Networks.PolygonAmoy]: "0.2", - [Networks.BaseSepolia]: "0.000034" -}; +export function calculateDestinationFundingShortfallRaw(requiredFundingRaw: bigint, currentBalanceRaw: bigint): bigint { + return requiredFundingRaw > currentBalanceRaw ? requiredFundingRaw - currentBalanceRaw : 0n; +} + +export function calculateSourceEvmFundingRequirementRaw( + fixedFundingRaw: bigint, + plannedNativeValueRaw: bigint, + sameNetworkDestinationLiabilityRaw = 0n +): bigint { + // A same-chain payout spends from the same balance as the source phases, so its signed + // fee liability must remain additive instead of reusing the fixed source reserve. + return fixedFundingRaw + plannedNativeValueRaw + sameNetworkDestinationLiabilityRaw; +} + +export function getDynamicDestinationEvmFundingNetwork( + destinationNetwork: Networks | undefined, + isBuy: boolean, + isDirectTransfer: boolean | undefined +): EvmNetworks | undefined { + // Direct provider-mint flows intentionally have no destination fee envelope. Their + // execution therefore keeps the existing fixed source reserve rather than adding an + // unquoted dynamic treasury transfer. + if (!isBuy || isDirectTransfer === true || !destinationNetwork || !isNetworkEVM(destinationNetwork)) { + return undefined; + } + + return destinationNetwork; +} export async function isDestinationEvmEphemeralFunded( evmEphemeralAddress: string, - destinationNetwork: EvmNetworks + destinationNetwork: EvmNetworks, + requiredFundingRaw: bigint ): Promise { const destinationClient = EvmClientManager.getInstance().getClient(destinationNetwork); const chain = destinationClient.chain; @@ -77,11 +111,7 @@ export async function isDestinationEvmEphemeralFunded( } const balance = await destinationClient.getBalance({ address: evmEphemeralAddress as `0x${string}` }); - const fundingAmountRaw = new Big( - multiplyByPowerOfTen(DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork], chain.nativeCurrency.decimals).toFixed() - ); - - return Big(balance.toString()).gte(fundingAmountRaw); + return Big(balance.toString()).gte(requiredFundingRaw.toString()); } const PRESIGNED_TRANSFER_BALANCE_POLL_MS = 5000; diff --git a/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.test.ts b/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.test.ts new file mode 100644 index 000000000..4bd167746 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from "bun:test"; +import { + type EvmNetworks, + type EvmTransactionData, + Networks, + type PresignedTx, + QuoteError, + RampDirection +} from "@vortexfi/shared"; +import { APIError } from "../../../../errors/api-error"; +import { config } from "../../../../../config/vars"; +import { + assertPreparedEvmDestinationFeeWithinQuote, + assertEvmTreasuryFundingFeeWithinQuote, + calculateBoundedPresignedGasBudgetRaw, + calculateExpectedExecutionFeeRaw, + calculateQuotedPresignedExecutionBudgetRaw, + EVM_ERC20_TRANSFER_GAS_LIMIT, + EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES, + EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES, + getBaseL1FeeUpperBoundRaw, + getEvmDestinationExecutionFeeUsd, + getEvmNativeFeeCurrency +} from "./evm-destination-gas"; +import type { PhaseCtx } from "./types"; +import { privateKeyToAccount } from "viem/accounts"; +import { installFakeEvm } from "../../../../../test-utils/fake-world/fake-evm"; + +describe("EVM destination gas policy", () => { + it("prices the persisted funding and ERC-20 payout gas envelopes", () => { + expect( + calculateExpectedExecutionFeeRaw( + 1_200_000_000n, + 21_000n, + EVM_ERC20_TRANSFER_GAS_LIMIT + ) + ).toBe(145_200_000_000_000n); + }); + + it("adds the persisted Base L1 security-fee envelopes", () => { + expect( + calculateExpectedExecutionFeeRaw( + 1_200_000_000n, + 21_000n, + EVM_ERC20_TRANSFER_GAS_LIMIT, + 24_000n + ) + ).toBe(145_200_000_024_000n); + }); + + it("re-binds every signed payout field before deriving treasury liability", async () => { + const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); + const unsignedTransaction = destinationTransfer( + transaction("1000000000", "1000000000"), + account.address, + Networks.Polygon + ); + const overrides = [ + { expected: "'to'", gas: 100_000n, to: "0x0000000000000000000000000000000000000002" }, + { data: "0x12345678" as const, expected: "data", gas: 100_000n }, + { expected: "value", gas: 100_000n, value: 1n }, + { expected: "gas limit", gas: 100_001n }, + { + expected: "below expected minimum", + gas: 100_000n, + maxFeePerGas: 999_999_999n, + maxPriorityFeePerGas: 999_999_999n + }, + { expected: "nonce", gas: 100_000n, nonce: 1 } + ]; + + for (const override of overrides) { + const rawTransaction = await account.signTransaction({ + chainId: 137, + gas: override.gas, + maxFeePerGas: override.maxFeePerGas ?? 3_000_000_000n, + maxPriorityFeePerGas: override.maxPriorityFeePerGas ?? 1_000_000_000n, + nonce: override.nonce ?? 0, + to: (override.to ?? "0x0000000000000000000000000000000000000001") as `0x${string}`, + type: "eip1559", + value: override.value ?? 0n, + ...(override.data ? { data: override.data } : {}) + }); + + await expect( + calculateBoundedPresignedGasBudgetRaw( + destinationTransfer(rawTransaction, account.address, Networks.Polygon), + unsignedTransaction + ) + ).rejects.toThrow(override.expected); + } + + const foreignAccount = privateKeyToAccount( + "0x8b3a350cf5c34c9194ca3a545d44d3b6739b0eeb2e0ec143f7fb926a4f9f9f0d" + ); + const foreignRawTransaction = await foreignAccount.signTransaction({ + chainId: 137, + gas: 100_000n, + maxFeePerGas: 3_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: "0x0000000000000000000000000000000000000001", + type: "eip1559", + value: 0n + }); + await expect( + calculateBoundedPresignedGasBudgetRaw( + destinationTransfer(foreignRawTransaction, account.address, Networks.Polygon), + unsignedTransaction + ) + ).rejects.toThrow("Recovered signer"); + }); + + it("adds the persisted Base L1 payout envelope to the presigned liability", async () => { + const { fakeEvm, restore } = installFakeEvm(); + try { + const account = privateKeyToAccount("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"); + const rawTransaction = await account.signTransaction({ + chainId: 8453, + gas: 100_000n, + maxFeePerGas: 3_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonce: 0, + to: "0x0000000000000000000000000000000000000001", + type: "eip1559", + value: 0n + }); + const presignedTransfer = destinationTransfer(rawTransaction, account.address, Networks.Base); + const unsignedTransfer = destinationTransfer( + transaction("1000000000", "1000000000"), + account.address, + Networks.Base + ); + + expect( + await calculateQuotedPresignedExecutionBudgetRaw(presignedTransfer, unsignedTransfer, { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "13000000000000", + network: Networks.Base, + programVersion: 2, + transferGasLimit: "100000" + }) + ).toBe(300_000_000_000_000n + 13_000_000_000_000n); + fakeEvm.onReadContract = () => { + throw new Error("late Base oracle unavailable"); + }; + expect( + await calculateQuotedPresignedExecutionBudgetRaw(presignedTransfer, unsignedTransfer, { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "13000000000000", + network: Networks.Base, + programVersion: 2, + transferGasLimit: "100000" + }) + ).toBe(313_000_000_000_000n); + fakeEvm.onReadContract = undefined; + expect(await getBaseL1FeeUpperBoundRaw(Networks.Base, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES)).toBe( + fakeEvm.baseL1FeeUpperBoundRaw + ); + expect(await getBaseL1FeeUpperBoundRaw(Networks.BaseSepolia, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES)).toBe( + fakeEvm.baseL1FeeUpperBoundRaw + ); + expect(await getBaseL1FeeUpperBoundRaw(Networks.Arbitrum, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES)).toBe(0n); + } finally { + restore(); + } + }); + + it("rejects a Base treasury transfer when its current L1 fee exceeds the quote envelope", async () => { + const { fakeEvm, restore } = installFakeEvm(); + try { + const quote = { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "12000000000000", + network: Networks.Base as EvmNetworks, + programVersion: 2 as const, + transferGasLimit: "100000" + }; + fakeEvm.baseL1FeeUpperBoundRaw = 12_000_000_000_001n; + + await expect(assertEvmTreasuryFundingFeeWithinQuote(quote, Networks.Base, 1_000_000_000n)).rejects.toThrow( + QuoteError.NetworkFeesTooHigh + ); + } finally { + restore(); + } + }); + + it("rejects a Base treasury transfer when the payout L1 fee exceeds the quote envelope", async () => { + const { fakeEvm, restore } = installFakeEvm(); + try { + const quote = { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "12000000000000", + network: Networks.Base as EvmNetworks, + programVersion: 2 as const, + transferGasLimit: EVM_ERC20_TRANSFER_GAS_LIMIT.toString() + }; + fakeEvm.onReadContract = (_network, params) => { + if (params.functionName !== "getL1FeeUpperBound") return undefined; + return params.args?.[0] === EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES + ? 12_000_000_000_001n + : fakeEvm.baseL1FeeUpperBoundRaw; + }; + + await expect(assertEvmTreasuryFundingFeeWithinQuote(quote, Networks.Base, 1_000_000_000n)).rejects.toThrow( + QuoteError.NetworkFeesTooHigh + ); + } finally { + restore(); + } + }); + + it("requires Arbitrum funding gas to include the parent-chain poster component", async () => { + const { fakeEvm, restore } = installFakeEvm(); + try { + fakeEvm.arbitrumL1GasComponent = 520n; + const quote = { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + network: Networks.Arbitrum as EvmNetworks, + programVersion: 2 as const, + transferGasLimit: "100000" + }; + + await expect(assertEvmTreasuryFundingFeeWithinQuote(quote, Networks.Arbitrum, 1_000_000_000n)).rejects.toThrow( + QuoteError.NetworkFeesTooHigh + ); + + quote.fundingGasLimit = "21520"; + quote.transferGasLimit = "100520"; + await expect( + assertEvmTreasuryFundingFeeWithinQuote(quote, Networks.Arbitrum, 1_000_000_000n) + ).resolves.toBeUndefined(); + } finally { + restore(); + } + }); + + it("maps every EVM network to the native currency used to price its gas", () => { + const expected: Record = { + [Networks.Arbitrum]: "ETH", + [Networks.Avalanche]: "AVAX", + [Networks.Base]: "ETH", + [Networks.BaseSepolia]: "ETH", + [Networks.BSC]: "BNB", + [Networks.Ethereum]: "ETH", + [Networks.Moonbeam]: "GLMR", + [Networks.Polygon]: "MATIC", + [Networks.PolygonAmoy]: "MATIC" + }; + + for (const [network, currency] of Object.entries(expected)) { + expect(String(getEvmNativeFeeCurrency(network as EvmNetworks))).toBe(currency); + } + }); + + it("does not price destination gas for exact provider-direct payouts", async () => { + const ctx = { + priceEvmDestinationGas: false, + request: { rampType: RampDirection.BUY, to: Networks.Base } + } as PhaseCtx; + + expect(await getEvmDestinationExecutionFeeUsd(ctx)).toBe("0"); + expect(ctx.evmDestinationGas).toBeUndefined(); + }); + + it("allows registration-time fee movement inside the quote margin", () => { + expect(() => + assertPreparedEvmDestinationFeeWithinQuote( + { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "120", + network: Networks.Arbitrum, + programVersion: 2, + transferGasLimit: "100000" + }, + Networks.Arbitrum, + transaction("120") + ) + ).not.toThrow(); + }); + + it("uses the persisted absolute ceiling after deployment margin configuration changes", () => { + const originalMargin = config.evmDestinationGas.networkFeeMarginBps; + config.evmDestinationGas.networkFeeMarginBps = 30_000; + try { + expect(() => + assertPreparedEvmDestinationFeeWithinQuote( + { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "120", + network: Networks.BSC, + programVersion: 2, + transferGasLimit: "100000" + }, + Networks.BSC, + transaction("121") + ) + ).toThrow(QuoteError.NetworkFeesTooHigh); + } finally { + config.evmDestinationGas.networkFeeMarginBps = originalMargin; + } + }); + + it("rejects registration when the destination fee moved beyond the quote margin", () => { + let thrown: unknown; + try { + assertPreparedEvmDestinationFeeWithinQuote( + { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "120", + network: Networks.BSC, + programVersion: 2, + transferGasLimit: "100000" + }, + Networks.BSC, + transaction("121") + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(APIError); + expect((thrown as APIError).message).toBe(QuoteError.NetworkFeesTooHigh); + }); +}); + +function transaction(maxFeePerGas: string, maxPriorityFeePerGas = "1"): EvmTransactionData { + return { + data: "0x", + gas: "100000", + maxFeePerGas, + maxPriorityFeePerGas, + to: "0x0000000000000000000000000000000000000001", + value: "0" + }; +} + +function destinationTransfer( + txData: PresignedTx["txData"], + signer: string, + network: EvmNetworks +): PresignedTx { + return { meta: {}, network, nonce: 0, phase: "destinationTransfer", signer, txData }; +} diff --git a/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.ts b/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.ts new file mode 100644 index 000000000..f2741a432 --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/evm-destination-gas.ts @@ -0,0 +1,315 @@ +import { + EvmClientManager, + type EvmNetworks, + EvmToken, + type EvmTransactionData, + getNetworkFromDestination, + getOnChainTokenDetails, + isEvmTokenDetails, + isNativeEvmToken, + isNetworkEVM, + Networks, + type OnChainToken, + type PresignedTx, + QuoteError, + type RampCurrency, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { encodeFunctionData, erc20Abi, formatUnits, parseAbi, parseTransaction, type TransactionSerialized } from "viem"; +import { config } from "../../../../../config/vars"; +import { APIError } from "../../../../errors/api-error"; +import { priceFeedService } from "../../../priceFeed.service"; +import { validatePresignedEvmTransactionAgainstUnsigned } from "../../../transactions/validation"; +import type { EvmDestinationGasQuote } from "./metadata"; +import type { PhaseCtx } from "./types"; + +export const EVM_NATIVE_TRANSFER_GAS_LIMIT = 21_000n; +export const EVM_ERC20_TRANSFER_GAS_LIMIT = 100_000n; +export const EVM_DESTINATION_FUNDING_PROGRAM_VERSION = 2 as const; + +// Base's GasPriceOracle adds the signed fields itself. These conservative unsigned +// EIP-1559 sizes cover a native transfer and an ERC-20 transfer respectively. +export const EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES = 128n; +export const EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES = 256n; + +const BASE_GAS_PRICE_ORACLE_ADDRESS = "0x420000000000000000000000000000000000000F"; +const BASE_GAS_PRICE_ORACLE_ABI = parseAbi(["function getL1FeeUpperBound(uint256 unsignedTxSize) view returns (uint256)"]); + +const ARBITRUM_NODE_INTERFACE_ADDRESS = "0x00000000000000000000000000000000000000C8"; +const ARBITRUM_NODE_INTERFACE_ABI = parseAbi([ + "function gasEstimateL1Component(address to, bool contractCreation, bytes data) view returns (uint64 gasEstimateForL1, uint256 baseFee, uint256 l1BaseFeeEstimate)" +]); +const WORST_CASE_EVM_ADDRESS = "0xffffffffffffffffffffffffffffffffffffffff"; +const WORST_CASE_ERC20_TRANSFER_DATA = encodeFunctionData({ + abi: erc20Abi, + args: [WORST_CASE_EVM_ADDRESS, 2n ** 256n - 1n], + functionName: "transfer" +}); + +const EVM_NATIVE_FEE_CURRENCIES: Record = { + [Networks.Arbitrum]: "ETH" as RampCurrency, + [Networks.Avalanche]: "AVAX" as RampCurrency, + [Networks.Base]: "ETH" as RampCurrency, + [Networks.BaseSepolia]: "ETH" as RampCurrency, + [Networks.BSC]: "BNB" as RampCurrency, + [Networks.Ethereum]: "ETH" as RampCurrency, + [Networks.Moonbeam]: "GLMR" as RampCurrency, + [Networks.Polygon]: "MATIC" as RampCurrency, + [Networks.PolygonAmoy]: "MATIC" as RampCurrency +}; + +export function getEvmNativeFeeCurrency(network: EvmNetworks): RampCurrency { + return EVM_NATIVE_FEE_CURRENCIES[network]; +} + +function throwNetworkFeesTooHigh(): never { + throw new APIError({ message: QuoteError.NetworkFeesTooHigh, status: httpStatus.SERVICE_UNAVAILABLE }); +} + +function applyMarginBps(value: bigint, marginBps: number): bigint { + return (value * BigInt(marginBps) + 9_999n) / 10_000n; +} + +async function getArbitrumL1GasComponent(network: EvmNetworks, to: `0x${string}`, data: `0x${string}`): Promise { + if (network !== Networks.Arbitrum) return 0n; + + const client = EvmClientManager.getInstance().getClient(network); + const result = (await client.readContract({ + abi: ARBITRUM_NODE_INTERFACE_ABI, + account: WORST_CASE_EVM_ADDRESS, + address: ARBITRUM_NODE_INTERFACE_ADDRESS, + args: [to, false, data], + functionName: "gasEstimateL1Component" + })) as readonly [bigint, bigint, bigint]; + return result[0]; +} + +async function getArbitrumExecutionGasLimits( + network: EvmNetworks, + isNativeTransfer: boolean, + marginBps = 10_000 +): Promise<{ fundingGasLimit: bigint; transferGasLimit: bigint }> { + const [fundingL1Gas, payoutL1Gas] = await Promise.all([ + getArbitrumL1GasComponent(network, WORST_CASE_EVM_ADDRESS, "0x"), + getArbitrumL1GasComponent(network, WORST_CASE_EVM_ADDRESS, isNativeTransfer ? "0x" : WORST_CASE_ERC20_TRANSFER_DATA) + ]); + return { + fundingGasLimit: EVM_NATIVE_TRANSFER_GAS_LIMIT + applyMarginBps(fundingL1Gas, marginBps), + transferGasLimit: + (isNativeTransfer ? EVM_NATIVE_TRANSFER_GAS_LIMIT : EVM_ERC20_TRANSFER_GAS_LIMIT) + applyMarginBps(payoutL1Gas, marginBps) + }; +} + +export function calculatePresignedGasBudgetRaw(rawTransaction: `0x${string}`): bigint { + const transaction = parseTransaction(rawTransaction as TransactionSerialized); + const feePerGas = transaction.maxFeePerGas ?? transaction.gasPrice; + if (transaction.gas === undefined || feePerGas === undefined) { + throw new Error("EVM destination transaction is missing gas or fee data"); + } + return transaction.gas * feePerGas; +} + +export async function calculateBoundedPresignedGasBudgetRaw( + presignedTransaction: PresignedTx, + unsignedTransaction: PresignedTx +): Promise { + await validatePresignedEvmTransactionAgainstUnsigned(presignedTransaction, unsignedTransaction); + if (typeof presignedTransaction.txData !== "string") { + throw new Error("EVM destination transaction is not a signed transaction"); + } + return calculatePresignedGasBudgetRaw(presignedTransaction.txData as `0x${string}`); +} + +function isBaseNetwork(network: EvmNetworks): boolean { + return network === Networks.Base || network === Networks.BaseSepolia; +} + +export async function getBaseL1FeeUpperBoundRaw(network: EvmNetworks, unsignedTxSize: bigint): Promise { + if (!isBaseNetwork(network)) return 0n; + + const client = EvmClientManager.getInstance().getClient(network); + return (await client.readContract({ + abi: BASE_GAS_PRICE_ORACLE_ABI, + address: BASE_GAS_PRICE_ORACLE_ADDRESS, + args: [unsignedTxSize], + functionName: "getL1FeeUpperBound" + })) as bigint; +} + +export async function calculateQuotedPresignedExecutionBudgetRaw( + presignedTransaction: PresignedTx, + unsignedTransaction: PresignedTx, + quote: EvmDestinationGasQuote +): Promise { + if (quote.programVersion !== EVM_DESTINATION_FUNDING_PROGRAM_VERSION || quote.network !== presignedTransaction.network) { + throw new Error(`EVM destination funding quote does not support ${presignedTransaction.network}`); + } + if (isBaseNetwork(presignedTransaction.network) && quote.maximumPayoutL1FeeRaw === undefined) { + throw new Error("Base destination gas quote is missing its payout L1 fee envelope"); + } + const l1ReserveRaw = isBaseNetwork(presignedTransaction.network) ? BigInt(quote.maximumPayoutL1FeeRaw as string) : 0n; + return (await calculateBoundedPresignedGasBudgetRaw(presignedTransaction, unsignedTransaction)) + l1ReserveRaw; +} + +export function calculateExpectedExecutionFeeRaw( + maximumFeePerGas: bigint, + fundingGasLimit: bigint, + transferGasLimit: bigint, + maximumL1FeeRaw = 0n +): bigint { + return (fundingGasLimit + transferGasLimit) * maximumFeePerGas + maximumL1FeeRaw; +} + +export function assertPreparedEvmDestinationFeeWithinQuote( + quote: EvmDestinationGasQuote, + network: EvmNetworks, + transaction: EvmTransactionData +): void { + if (quote.network !== network) { + throw new Error(`EVM destination gas quote is for ${quote.network}, not ${network}`); + } + if (transaction.gas !== quote.transferGasLimit) { + throw new Error(`EVM destination gas limit changed from ${quote.transferGasLimit} to ${transaction.gas}`); + } + if (!transaction.maxFeePerGas) { + throw new Error("Prepared EVM destination transaction is missing maxFeePerGas"); + } + + if (BigInt(transaction.maxFeePerGas) > BigInt(quote.maximumFeePerGas)) { + throwNetworkFeesTooHigh(); + } +} + +export async function assertEvmTreasuryFundingFeeWithinQuote( + quote: EvmDestinationGasQuote, + network: EvmNetworks, + maxFeePerGas: bigint +): Promise { + if (quote.network !== network) { + throw new Error(`EVM destination gas quote is for ${quote.network}, not ${network}`); + } + if (quote.programVersion !== EVM_DESTINATION_FUNDING_PROGRAM_VERSION) { + throw new Error(`Unsupported EVM destination funding program ${String(quote.programVersion)}`); + } + if (maxFeePerGas > BigInt(quote.maximumFeePerGas)) { + throwNetworkFeesTooHigh(); + } + + if (network === Networks.Arbitrum) { + const currentGasLimits = await getArbitrumExecutionGasLimits(network, quote.isNativeTransfer); + if ( + currentGasLimits.fundingGasLimit > BigInt(quote.fundingGasLimit) || + currentGasLimits.transferGasLimit > BigInt(quote.transferGasLimit) + ) { + throwNetworkFeesTooHigh(); + } + } + + if (!isBaseNetwork(network)) return; + if (quote.maximumFundingL1FeeRaw === undefined || quote.maximumPayoutL1FeeRaw === undefined) { + throw new Error("Base destination gas quote is missing its L1 fee envelope"); + } + + const payoutTransactionSize = + BigInt(quote.transferGasLimit) === EVM_NATIVE_TRANSFER_GAS_LIMIT + ? EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES + : EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES; + const [currentFundingL1FeeUpperBound, currentPayoutL1FeeUpperBound] = await Promise.all([ + getBaseL1FeeUpperBoundRaw(network, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES), + getBaseL1FeeUpperBoundRaw(network, payoutTransactionSize) + ]); + if ( + currentFundingL1FeeUpperBound > BigInt(quote.maximumFundingL1FeeRaw) || + currentPayoutL1FeeUpperBound > BigInt(quote.maximumPayoutL1FeeRaw) + ) { + throwNetworkFeesTooHigh(); + } +} + +export async function preflightEvmDestinationFeeWithinQuote(quote: EvmDestinationGasQuote): Promise { + const client = EvmClientManager.getInstance().getClient(quote.network); + const { maxFeePerGas } = await client.estimateFeesPerGas(); + await assertEvmTreasuryFundingFeeWithinQuote(quote, quote.network, maxFeePerGas); +} + +export async function getEvmDestinationExecutionFeeUsd(ctx: PhaseCtx): Promise { + const destinationNetwork = getNetworkFromDestination(ctx.request.to); + if ( + ctx.request.rampType !== RampDirection.BUY || + ctx.priceEvmDestinationGas === false || + !destinationNetwork || + !isNetworkEVM(destinationNetwork) + ) { + return "0"; + } + if (ctx.evmDestinationGas !== undefined) { + return ctx.evmDestinationGas.executionFeeUsd; + } + + const tokenDetails = getOnChainTokenDetails(destinationNetwork, ctx.request.outputCurrency as OnChainToken); + if (!tokenDetails || !isEvmTokenDetails(tokenDetails)) { + throw new Error(`${destinationNetwork} output token ${ctx.request.outputCurrency} is not configured`); + } + + const isNativeTransfer = isNativeEvmToken(tokenDetails); + const destinationClient = EvmClientManager.getInstance().getClient(destinationNetwork); + const chain = destinationClient.chain; + if (!chain) { + throw new Error(`Could not get chain info for EVM destination ${destinationNetwork}`); + } + const { maxFeePerGas } = await destinationClient.estimateFeesPerGas(); + const payoutTransactionSize = isNativeEvmToken(tokenDetails) + ? EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES + : EVM_ERC20_UNSIGNED_TRANSACTION_SIZE_BYTES; + const [fundingL1FeeUpperBoundRaw, payoutL1FeeUpperBoundRaw] = await Promise.all([ + getBaseL1FeeUpperBoundRaw(destinationNetwork, EVM_NATIVE_UNSIGNED_TRANSACTION_SIZE_BYTES), + getBaseL1FeeUpperBoundRaw(destinationNetwork, payoutTransactionSize) + ]); + const marginBps = config.evmDestinationGas.networkFeeMarginBps; + const { fundingGasLimit, transferGasLimit } = await getArbitrumExecutionGasLimits( + destinationNetwork, + isNativeTransfer, + marginBps + ); + const maximumFeePerGas = applyMarginBps(maxFeePerGas, marginBps); + const maximumFundingL1FeeRaw = applyMarginBps(fundingL1FeeUpperBoundRaw, marginBps); + const maximumPayoutL1FeeRaw = applyMarginBps(payoutL1FeeUpperBoundRaw, marginBps); + const expectedFeeRaw = calculateExpectedExecutionFeeRaw( + maximumFeePerGas, + fundingGasLimit, + transferGasLimit, + maximumFundingL1FeeRaw + maximumPayoutL1FeeRaw + ); + const expectedFeeUnits = formatUnits(expectedFeeRaw, chain.nativeCurrency.decimals); + const expectedFeeUsd = new Big( + await priceFeedService.convertCurrency( + expectedFeeUnits, + getEvmNativeFeeCurrency(destinationNetwork), + EvmToken.USDC as RampCurrency + ) + ).toFixed(6); + + if (new Big(expectedFeeUsd).gt(config.evmDestinationGas.maxExecutionFeeUsd)) { + throwNetworkFeesTooHigh(); + } + + ctx.evmDestinationGas = { + executionFeeUsd: expectedFeeUsd, + fundingGasLimit: fundingGasLimit.toString(), + isNativeTransfer, + maximumFeePerGas: maximumFeePerGas.toString(), + ...(isBaseNetwork(destinationNetwork) + ? { + maximumFundingL1FeeRaw: maximumFundingL1FeeRaw.toString(), + maximumPayoutL1FeeRaw: maximumPayoutL1FeeRaw.toString() + } + : {}), + network: destinationNetwork, + programVersion: EVM_DESTINATION_FUNDING_PROGRAM_VERSION, + transferGasLimit: transferGasLimit.toString() + }; + ctx.addNote(`${destinationNetwork} destination execution fee: ${expectedFeeUsd} USD`); + return expectedFeeUsd; +} diff --git a/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts b/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts index 743396625..91ae5fb63 100644 --- a/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts +++ b/apps/api/src/api/services/phases/blocks/core/evm-transactions.ts @@ -1,6 +1,7 @@ import { EvmClientManager, type EvmNetworks, type EvmTransactionData } from "@vortexfi/shared"; import { encodeFunctionData } from "viem/utils"; import erc20ABI from "../../../../../contracts/ERC20"; +import { EVM_ERC20_TRANSFER_GAS_LIMIT, EVM_NATIVE_TRANSFER_GAS_LIMIT } from "./evm-destination-gas"; export function encodeEvmTransactionData(data: unknown) { return data; @@ -34,18 +35,19 @@ export async function createDestinationTransferTransaction(params: { toToken: `0x${string}`; amountRaw: string; destinationNetwork: EvmNetworks; + gasLimit?: string; isNativeToken?: boolean; }): Promise { - const { toAddress, amountRaw, destinationNetwork, toToken, isNativeToken } = params; + const { toAddress, amountRaw, destinationNetwork, gasLimit, toToken, isNativeToken } = params; const publicClient = EvmClientManager.getInstance().getClient(destinationNetwork); const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); if (isNativeToken) { return { data: "0x", - gas: "21000", - maxFeePerGas: String(maxFeePerGas * 3n), - maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), + gas: gasLimit ?? EVM_NATIVE_TRANSFER_GAS_LIMIT.toString(), + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), to: toAddress as `0x${string}`, value: amountRaw }; @@ -53,9 +55,9 @@ export async function createDestinationTransferTransaction(params: { return { data: encodeFunctionData({ abi: erc20ABI, args: [toAddress, amountRaw], functionName: "transfer" }), - gas: "100000", - maxFeePerGas: String(maxFeePerGas * 3n), - maxPriorityFeePerGas: String(maxPriorityFeePerGas * 3n), + gas: gasLimit ?? EVM_ERC20_TRANSFER_GAS_LIMIT.toString(), + maxFeePerGas: String(maxFeePerGas), + maxPriorityFeePerGas: String(maxPriorityFeePerGas), to: toToken, value: "0" }; diff --git a/apps/api/src/api/services/phases/blocks/core/fees.ts b/apps/api/src/api/services/phases/blocks/core/fees.ts index 83e3be4df..b2a57bcc2 100644 --- a/apps/api/src/api/services/phases/blocks/core/fees.ts +++ b/apps/api/src/api/services/phases/blocks/core/fees.ts @@ -1,6 +1,7 @@ import { EvmToken, RampCurrency } from "@vortexfi/shared"; import Big from "big.js"; import { priceFeedService } from "../../../priceFeed.service"; +import { getEvmDestinationExecutionFeeUsd } from "./evm-destination-gas"; import { calculateFeeComponents } from "./quote-fees"; import type { PhaseCtx } from "./types"; @@ -14,7 +15,8 @@ export async function overrideFees(ctx: PhaseCtx, override: FeeOverride): Promis throw new Error("Cannot override an incomplete fee snapshot"); } const displayCurrency = ctx.fees.displayFiat.currency; - const [anchorUsd, anchorDisplay, networkUsd, networkDisplay] = await Promise.all([ + const destinationExecutionFeeUsd = await getEvmDestinationExecutionFeeUsd(ctx); + const [anchorUsd, anchorDisplay, baseNetworkUsd, baseNetworkDisplay, destinationExecutionFeeDisplay] = await Promise.all([ priceFeedService.convertCurrency(override.anchor.amount, override.anchor.currency, EvmToken.USDC), priceFeedService.convertCurrency(override.anchor.amount, override.anchor.currency, displayCurrency), override.network @@ -22,8 +24,11 @@ export async function overrideFees(ctx: PhaseCtx, override: FeeOverride): Promis : ctx.fees.usd.network, override.network ? priceFeedService.convertCurrency(override.network.amount, override.network.currency, displayCurrency) - : ctx.fees.displayFiat.network + : ctx.fees.displayFiat.network, + override.network ? priceFeedService.convertCurrency(destinationExecutionFeeUsd, EvmToken.USDC, displayCurrency) : "0" ]); + const networkUsd = new Big(baseNetworkUsd).plus(override.network ? destinationExecutionFeeUsd : "0").toString(); + const networkDisplay = new Big(baseNetworkDisplay).plus(destinationExecutionFeeDisplay).toString(); return { displayFiat: { ...ctx.fees.displayFiat, @@ -60,17 +65,30 @@ export async function calculateFees(ctx: PhaseCtx, override?: FeeOverride): Prom const displayCurrency = ctx.targetFeeFiatCurrency ?? feeCurrency; const anchor = override?.anchor ?? { amount: anchorFee, currency: feeCurrency }; const network = override?.network ?? { amount: "0", currency: USD }; - const [vortexUsd, anchorUsd, partnerUsd, networkUsd, vortexDisplay, anchorDisplay, partnerDisplay, networkDisplay] = - await Promise.all([ - priceFeedService.convertCurrency(vortexFee, feeCurrency, USD), - priceFeedService.convertCurrency(anchor.amount, anchor.currency, USD), - priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, USD), - priceFeedService.convertCurrency(network.amount, network.currency, USD), - priceFeedService.convertCurrency(vortexFee, feeCurrency, displayCurrency), - priceFeedService.convertCurrency(anchor.amount, anchor.currency, displayCurrency), - priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, displayCurrency), - priceFeedService.convertCurrency(network.amount, network.currency, displayCurrency) - ]); + const destinationExecutionFeeUsd = await getEvmDestinationExecutionFeeUsd(ctx); + const [ + vortexUsd, + anchorUsd, + partnerUsd, + baseNetworkUsd, + vortexDisplay, + anchorDisplay, + partnerDisplay, + baseNetworkDisplay, + destinationExecutionFeeDisplay + ] = await Promise.all([ + priceFeedService.convertCurrency(vortexFee, feeCurrency, USD), + priceFeedService.convertCurrency(anchor.amount, anchor.currency, USD), + priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, USD), + priceFeedService.convertCurrency(network.amount, network.currency, USD), + priceFeedService.convertCurrency(vortexFee, feeCurrency, displayCurrency), + priceFeedService.convertCurrency(anchor.amount, anchor.currency, displayCurrency), + priceFeedService.convertCurrency(partnerMarkupFee, feeCurrency, displayCurrency), + priceFeedService.convertCurrency(network.amount, network.currency, displayCurrency), + priceFeedService.convertCurrency(destinationExecutionFeeUsd, USD, displayCurrency) + ]); + const networkUsd = new Big(baseNetworkUsd).plus(destinationExecutionFeeUsd).toString(); + const networkDisplay = new Big(baseNetworkDisplay).plus(destinationExecutionFeeDisplay).toString(); const totalUsd = new Big(vortexUsd).plus(anchorUsd).plus(partnerUsd).plus(networkUsd).toFixed(6); const totalDisplay = new Big(vortexDisplay).plus(anchorDisplay).plus(partnerDisplay).plus(networkDisplay).toFixed(2); diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts index 88b3dfc92..547b47e8b 100644 --- a/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.test.ts @@ -57,6 +57,58 @@ describe("runFinancialOperation", () => { }); }); + it("replays a confirmed target-balance operation when the observed shortfall changes", async () => { + let observedShortfallRaw = "100"; + const perform = mock(async () => ({ amountRaw: observedShortfallRaw, id: "funding-1" })); + const operation = { + ...baseOperation, + attemptClass: "destination-evm-native-funding-v2", + request: { destination: "ephemeral-1", network: "base", targetBalanceRaw: "1000" } + }; + + const first = await runFinancialOperation({ ...operation, perform }); + observedShortfallRaw = "20"; + const replayed = await runFinancialOperation({ ...operation, perform }); + + expect(perform).toHaveBeenCalledTimes(1); + expect(replayed).toEqual(first); + expect(replayed.amountRaw).toBe("100"); + }); + + it("replays a confirmed operation before running a new-side-effect preflight", async () => { + let feesInsideEnvelope = true; + const beforePerform = mock(async () => { + if (!feesInsideEnvelope) throw new Error("network fees too high"); + }); + const perform = mock(async () => ({ id: "funding-1" })); + + const first = await runFinancialOperation({ ...baseOperation, beforePerform, perform }); + feesInsideEnvelope = false; + const replayed = await runFinancialOperation({ ...baseOperation, beforePerform, perform }); + + expect(replayed).toEqual(first); + expect(beforePerform).toHaveBeenCalledTimes(1); + expect(perform).toHaveBeenCalledTimes(1); + }); + + it("leaves an operation unclaimed when its preflight rejects a new side effect", async () => { + let feesInsideEnvelope = false; + const beforePerform = mock(async () => { + if (!feesInsideEnvelope) throw new Error("network fees too high"); + }); + const perform = mock(async () => ({ id: "funding-1" })); + + await expect(runFinancialOperation({ ...baseOperation, beforePerform, perform })).rejects.toThrow( + "network fees too high" + ); + expect(await FinancialOperation.findOne()).toMatchObject({ status: "not_started" }); + + feesInsideEnvelope = true; + await expect(runFinancialOperation({ ...baseOperation, beforePerform, perform })).resolves.toEqual({ id: "funding-1" }); + expect(beforePerform).toHaveBeenCalledTimes(2); + expect(perform).toHaveBeenCalledTimes(1); + }); + it("halts retries after an ambiguous provider failure", async () => { const perform = mock(async () => { throw new Error("connection reset after submission"); diff --git a/apps/api/src/api/services/phases/blocks/core/financial-operation.ts b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts index 7b67f6bed..ef4f8647a 100644 --- a/apps/api/src/api/services/phases/blocks/core/financial-operation.ts +++ b/apps/api/src/api/services/phases/blocks/core/financial-operation.ts @@ -16,6 +16,8 @@ export interface RunFinancialOperationArgs { request: unknown; retryFailed?: boolean; signal?: AbortSignal; + /** Runs only after replay/reconciliation is exhausted and immediately before claiming a new side effect. */ + beforePerform?(): Promise; perform(idempotencyKey: string): Promise; reconcile?: (operation: FinancialOperation) => Promise; externalId?: (result: Result) => string | undefined; @@ -76,6 +78,7 @@ export async function runFinancialOperation({ attemptClass, provider, request, + beforePerform, perform, reconcile, externalId, @@ -153,6 +156,8 @@ export async function runFinancialOperation({ } } + await beforePerform?.(); + const [claimed] = await FinancialOperation.update( { errorMessage: null, status: "submitted" }, { where: { id: operation.id, status: "not_started" } } diff --git a/apps/api/src/api/services/phases/blocks/core/flow.ts b/apps/api/src/api/services/phases/blocks/core/flow.ts index 7e3c3404f..6f6fb11fa 100644 --- a/apps/api/src/api/services/phases/blocks/core/flow.ts +++ b/apps/api/src/api/services/phases/blocks/core/flow.ts @@ -1,4 +1,5 @@ import { EphemeralAccountType, type RampPhase } from "@vortexfi/shared"; +import { config } from "../../../../../config/vars"; import type { PhaseHandler } from "../../../phases/base-phase-handler"; import type { StateMetadata } from "../../../phases/meta-state-types"; import { computeFees } from "./fees"; @@ -296,6 +297,10 @@ export class FlowBuilder { }; }, async simulate(ctx: PhaseCtx) { + // Exact provider-token payouts do not have a fee-distribution phase; + // their same-chain gas remains part of the existing source reserve. + ctx.priceEvmDestinationGas = + config.evmDestinationGas.dynamicFundingEnabled && staticStateMeta.isDirectTransfer !== true; await computeFees(ctx); if (!ctx.fees?.usd) { throw new Error("Flow simulation requires computed USD fees"); @@ -319,7 +324,12 @@ export class FlowBuilder { metadata: { blocks, flow: identity, - globals: { fees: ctx.fees as never, partner: ctx.partner, request: ctx.request } + globals: { + ...(ctx.evmDestinationGas ? { evmDestinationGas: ctx.evmDestinationGas } : {}), + fees: ctx.fees as never, + partner: ctx.partner, + request: ctx.request + } }, output: current as O }; diff --git a/apps/api/src/api/services/phases/blocks/core/metadata.test.ts b/apps/api/src/api/services/phases/blocks/core/metadata.test.ts new file mode 100644 index 000000000..6c0d9234f --- /dev/null +++ b/apps/api/src/api/services/phases/blocks/core/metadata.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { Networks } from "@vortexfi/shared"; +import type { EvmDestinationGasQuote } from "./metadata"; +import { getFlowMetadata } from "./metadata"; + +const validQuote: EvmDestinationGasQuote = { + executionFeeUsd: "0.20", + fundingGasLimit: "21000", + isNativeTransfer: false, + maximumFeePerGas: "1200000000", + network: Networks.Arbitrum, + programVersion: 2, + transferGasLimit: "100000" +}; + +function metadata(evmDestinationGas?: unknown): unknown { + return { + blocks: {}, + globals: { + ...(evmDestinationGas === undefined ? {} : { evmDestinationGas }), + fees: { usd: { anchor: "0", network: "0", partnerMarkup: "0", total: "0", vortex: "0" } }, + partner: null, + request: {} + } + }; +} + +describe("getFlowMetadata EVM destination gas validation", () => { + it("accepts absence as the legacy funding program", () => { + expect(getFlowMetadata(metadata()).globals.evmDestinationGas).toBeUndefined(); + }); + + it("accepts a complete v2 envelope including required Base L1 maxima", () => { + const baseQuote: EvmDestinationGasQuote = { + ...validQuote, + maximumFundingL1FeeRaw: "12000000000000", + maximumPayoutL1FeeRaw: "13000000000000", + network: Networks.Base + }; + + expect(getFlowMetadata(metadata(baseQuote)).globals.evmDestinationGas).toEqual(baseQuote); + }); + + it("rejects malformed or unbounded v2 fields before they reach treasury arithmetic", () => { + const invalidQuotes: unknown[] = [ + { ...validQuote, executionFeeUsd: "0" }, + { ...validQuote, fundingGasLimit: "1e5" }, + { ...validQuote, isNativeTransfer: "false" }, + { ...validQuote, maximumFeePerGas: (2n ** 256n).toString() }, + { ...validQuote, network: "not-a-network" }, + { ...validQuote, programVersion: 3 }, + { ...validQuote, transferGasLimit: "0" } + ]; + + for (const quote of invalidQuotes) { + expect(() => getFlowMetadata(metadata(quote))).toThrow("EVM destination"); + } + }); + + it("requires a paired positive L1 envelope on Base-family quotes", () => { + expect(() => getFlowMetadata(metadata({ ...validQuote, network: Networks.Base }))).toThrow("L1 fee envelope"); + expect(() => + getFlowMetadata( + metadata({ + ...validQuote, + maximumFundingL1FeeRaw: "1", + network: Networks.Base + }) + ) + ).toThrow("L1 fee envelope"); + expect(() => + getFlowMetadata( + metadata({ + ...validQuote, + maximumFundingL1FeeRaw: "0", + maximumPayoutL1FeeRaw: "1", + network: Networks.BaseSepolia + }) + ) + ).toThrow("maximumFundingL1FeeRaw"); + }); +}); diff --git a/apps/api/src/api/services/phases/blocks/core/metadata.ts b/apps/api/src/api/services/phases/blocks/core/metadata.ts index 8125ca687..dd5ad1a8c 100644 --- a/apps/api/src/api/services/phases/blocks/core/metadata.ts +++ b/apps/api/src/api/services/phases/blocks/core/metadata.ts @@ -1,5 +1,12 @@ -import type { CreateQuoteRequest, QuoteFeeStructure, RampCurrency } from "@vortexfi/shared"; -import type { Big } from "big.js"; +import { + type CreateQuoteRequest, + type EvmNetworks, + isNetworkEVM, + Networks, + type QuoteFeeStructure, + type RampCurrency +} from "@vortexfi/shared"; +import Big from "big.js"; import type { StateMetadata } from "../../../phases/meta-state-types"; import type { PartnerInfo } from "../../../quote/core/types"; import type { FlowIdentity } from "./identity"; @@ -23,7 +30,20 @@ export function defineContext() { ({ key, schemaVersion }) as ContextMetadata; } +export interface EvmDestinationGasQuote { + executionFeeUsd: string; + fundingGasLimit: string; + isNativeTransfer: boolean; + maximumFeePerGas: string; + maximumFundingL1FeeRaw?: string; + maximumPayoutL1FeeRaw?: string; + network: EvmNetworks; + programVersion: 2; + transferGasLimit: string; +} + export interface FlowGlobals { + evmDestinationGas?: EvmDestinationGasQuote; fees: { displayFiat?: QuoteFeeStructure; usd: { anchor: string; network: string; partnerMarkup: string; total: string; vortex: string }; @@ -40,6 +60,68 @@ export interface FlowMetadata = Record, + field: keyof EvmDestinationGasQuote, + maximum: bigint +): void { + const fieldValue = value[field]; + if ( + typeof fieldValue !== "string" || + !POSITIVE_INTEGER_PATTERN.test(fieldValue) || + fieldValue.length > maximum.toString().length || + BigInt(fieldValue) > maximum + ) { + throw new Error(`Invalid EVM destination gas quote ${field}`); + } +} + +function assertEvmDestinationGasQuote(value: unknown): asserts value is EvmDestinationGasQuote { + if (!isRecord(value)) { + throw new Error("Invalid EVM destination gas quote envelope"); + } + if (value.programVersion !== 2) { + throw new Error(`Unsupported EVM destination funding program ${String(value.programVersion)}`); + } + if (typeof value.network !== "string" || !isNetworkEVM(value.network as Networks)) { + throw new Error("Invalid EVM destination gas quote network"); + } + if (typeof value.isNativeTransfer !== "boolean") { + throw new Error("Invalid EVM destination gas quote transfer type"); + } + if ( + typeof value.executionFeeUsd !== "string" || + value.executionFeeUsd.length > 128 || + !POSITIVE_DECIMAL_PATTERN.test(value.executionFeeUsd) || + !new Big(value.executionFeeUsd).gt(0) + ) { + throw new Error("Invalid EVM destination gas quote executionFeeUsd"); + } + + assertPositiveIntegerField(value, "fundingGasLimit", MAX_UINT64); + assertPositiveIntegerField(value, "transferGasLimit", MAX_UINT64); + assertPositiveIntegerField(value, "maximumFeePerGas", MAX_UINT256); + + const hasFundingL1Maximum = value.maximumFundingL1FeeRaw !== undefined; + const hasPayoutL1Maximum = value.maximumPayoutL1FeeRaw !== undefined; + if (hasFundingL1Maximum !== hasPayoutL1Maximum) { + throw new Error("Incomplete EVM destination gas quote L1 fee envelope"); + } + const isBase = value.network === Networks.Base || value.network === Networks.BaseSepolia; + if (isBase && !hasFundingL1Maximum) { + throw new Error("Base destination gas quote is missing its L1 fee envelope"); + } + if (hasFundingL1Maximum) { + assertPositiveIntegerField(value, "maximumFundingL1FeeRaw", MAX_UINT256); + assertPositiveIntegerField(value, "maximumPayoutL1FeeRaw", MAX_UINT256); + } +} + export function getFlowMetadata(metadata: unknown): FlowMetadata { const value = metadata as Partial | null; if ( @@ -52,6 +134,9 @@ export function getFlowMetadata(metadata: unknown): FlowMetadata { ) { throw new Error("Quote does not contain block flow metadata"); } + if (value.globals.evmDestinationGas !== undefined) { + assertEvmDestinationGasQuote(value.globals.evmDestinationGas); + } return value as FlowMetadata; } diff --git a/apps/api/src/api/services/phases/blocks/core/types.ts b/apps/api/src/api/services/phases/blocks/core/types.ts index 06ed4e715..56b6a31a0 100644 --- a/apps/api/src/api/services/phases/blocks/core/types.ts +++ b/apps/api/src/api/services/phases/blocks/core/types.ts @@ -41,6 +41,8 @@ export interface PhaseCtx { vortexFeePenPercentage?: number; }; targetFeeFiatCurrency?: RampCurrency; + evmDestinationGas?: FlowMetadata["globals"]["evmDestinationGas"]; + priceEvmDestinationGas?: boolean; } export type FlowInputResolver = (ctx: PhaseCtx) => O | Promise; diff --git a/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts index 4a837fa5d..9324d6c12 100644 --- a/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts +++ b/apps/api/src/api/services/phases/blocks/flows/alfredpay-offramp.ts @@ -7,7 +7,7 @@ import { DistributeFees } from "../phases/distribute-fees"; // Version 2 appends the Polygon fee-collection phase: the vortex/partner fee residual // that AlfredpayOfframp's pricing reserves on the Polygon ephemeral is paid out after // the Alfredpay deposit succeeded. Deploys are gated on draining v1 quotes/ramps. -export const ALFREDPAY_OFFRAMP_FLOW_VERSION = 2; +export const ALFREDPAY_OFFRAMP_FLOW_VERSION = 3; export function makeAlfredpayOfframpFlow(fromToken: EvmToken, fromNetwork: EvmNetworks) { return FlowBuilder.start(evmRequestIO(fromToken, fromNetwork), AlfredpayOfframp(fromToken, fromNetwork)) diff --git a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts index 974df19b5..12c4d22e4 100644 --- a/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/alfredpay-offramp/simulation.ts @@ -5,6 +5,7 @@ import { ALFREDPAY_ONCHAIN_CURRENCY, AlfredpayApiService, AlfredpayChain, + type AlfredpayFeeType, type AlfredpayFiatCurrency, AlfredpayPaymentMethodType, type EvmNetworks, @@ -17,7 +18,7 @@ import { RampDirection } from "@vortexfi/shared"; import Big from "big.js"; -import { priceFeedService } from "../../../../priceFeed.service"; +import { type FiatExchangeRateSource, priceFeedService } from "../../../../priceFeed.service"; import { resolveAlfredpayQuoteCustomerId } from "../../../../quote/alfredpay-customer"; import { calculateExpectedOutput, @@ -48,6 +49,32 @@ export interface AlfredpayOfframpMetadata { network: typeof Networks.Polygon; outputAmountDecimal: SerializableBig; outputAmountRaw: string; + pricing: { + customer: { + allInRate: SerializableBig; + inputAmountUsd: SerializableBig; + referenceDifferenceBps: SerializableBig; + }; + provider: { + baseCurrency: typeof ALFREDPAY_ONCHAIN_CURRENCY; + feeAmount: SerializableBig; + fees: Array<{ amount: string; currency: string; type: AlfredpayFeeType }>; + grossRate: SerializableBig; + grossReferenceDifferenceBps: SerializableBig; + netRate: SerializableBig; + netReferenceDifferenceBps: SerializableBig; + quoteCurrency: FiatToken; + quotedAt: Date; + source: "alfredpay"; + }; + reference: { + baseCurrency: "USD"; + observedAt: Date; + quoteCurrency: FiatToken; + rate: SerializableBig; + source: FiatExchangeRateSource; + }; + }; quoteId: string; subsidyAmountDecimal: SerializableBig; subsidyAmountRaw: string; @@ -55,7 +82,7 @@ export interface AlfredpayOfframpMetadata { toToken: `0x${string}`; } -export const AlfredpayOfframpContext = defineContext()("alfredpayOfframp"); +export const AlfredpayOfframpContext = defineContext()("alfredpayOfframp", 2); function directAlfredpaySettlementQuote(amountDecimal: string) { const outputAmountDecimal = new Big(amountDecimal); @@ -112,6 +139,10 @@ export function simulateAlfredpayOfframp ({ amount, currency, type })), + grossRate: providerGrossRate, + grossReferenceDifferenceBps: providerGrossRate.div(referenceRate).minus(1).mul(10_000), + netRate: providerNetRate, + netReferenceDifferenceBps: providerNetRate.div(referenceRate).minus(1).mul(10_000), + quoteCurrency: ctx.request.outputCurrency as FiatToken, + quotedAt: ctx.now, + source: "alfredpay" + }, + reference: { + baseCurrency: "USD", + observedAt: referenceRateSnapshot.observedAt, + quoteCurrency: ctx.request.outputCurrency as FiatToken, + rate: referenceRate, + source: referenceRateSnapshot.source + } + }, quoteId: providerQuote.quoteId, subsidyAmountDecimal: subsidyFiat.div(oneUnitInFiat), subsidyAmountRaw: multiplyByPowerOfTen(subsidyFiat.div(oneUnitInFiat), ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), diff --git a/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts index 765782256..f7c581951 100644 --- a/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts +++ b/apps/api/src/api/services/phases/blocks/phases/destination-transfer/transactions.ts @@ -5,9 +5,11 @@ import { isEvmTokenDetails, isNativeEvmToken, Networks, - OnChainToken + OnChainToken, + RampDirection } from "@vortexfi/shared"; import { requireAccount } from "../../core/accounts"; +import { assertPreparedEvmDestinationFeeWithinQuote } from "../../core/evm-destination-gas"; import { createDestinationTransferTransaction } from "../../core/evm-transactions"; import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types"; import type { DestinationTransferMetadata } from "./simulation"; @@ -31,10 +33,21 @@ export async function prepareDestinationTransferTxs(ctx: PrepareCtx + transaction.phase === "destinationTransfer" && + transaction.network === destinationNetwork && + transaction.nonce === presignedTransfer.nonce && + transaction.signer.toLowerCase() === presignedTransfer.signer.toLowerCase() + ); + if (!unsignedTransfer || !isEvmTransactionData(unsignedTransfer.txData)) { + throw this.createUnrecoverableError( + `FinalSettlementSubsidyExecutor: missing ${destinationNetwork} destination transfer blueprint` + ); + } + destinationGasReserveRaw = new Big( + ( + await calculateQuotedPresignedExecutionBudgetRaw(presignedTransfer, unsignedTransfer, destinationGasQuote) + ).toString() + ); + } else { + destinationGasReserveRaw = multiplyByPowerOfTen( + LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork], + outTokenDetails.decimals + ); + } + } const requiredBalanceRaw = expectedAmountRaw.plus(destinationGasReserveRaw); const subsidyAmountRaw = calculateSettlementSubsidyRaw( expectedAmountRaw, diff --git a/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts index 705dddf6a..d5f6f0535 100644 --- a/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts +++ b/apps/api/src/api/services/phases/blocks/phases/fund-ephemeral/execution.ts @@ -5,9 +5,10 @@ import { FiatToken, getNetworkFromDestination, isAlfredpayToken, - isNetworkEVM, + isEvmTransactionData, multiplyByPowerOfTen, Networks, + QuoteError, RampDirection, RampPhase, waitUntilTrueWithTimeout @@ -16,23 +17,34 @@ import logger from "../../../../../../config/logger"; import { config } from "../../../../../../config/vars"; import { BASE_EPHEMERAL_STARTING_BALANCE_UNITS, + MOONBEAM_EVM_SOURCE_STARTING_BALANCE_UNITS, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../../../constants/constants"; import QuoteTicket from "../../../../../../models/quoteTicket.model"; import RampState from "../../../../../../models/rampState.model"; +import { APIError } from "../../../../../errors/api-error"; import { PhaseError } from "../../../../../errors/phase-error"; import { fundEphemeralAccount } from "../../../../pendulum/pendulum.service"; import { BasePhaseHandler } from "../../../../phases/base-phase-handler"; import { verifyUserSubmittedTxByHash } from "../../../../phases/helpers/user-tx-verifier"; import { StateMetadata } from "../../../../phases/meta-state-types"; +import { PresignedEvmTransactionRebindError } from "../../../../transactions/validation"; import { abortableCall, throwIfAborted } from "../../core/cancellation"; import { - DESTINATION_EVM_FUNDING_AMOUNTS, + calculateDestinationFundingShortfallRaw, + calculateSourceEvmFundingRequirementRaw, + getDynamicDestinationEvmFundingNetwork, isDestinationEvmEphemeralFunded, - isPendulumEphemeralFunded + isPendulumEphemeralFunded, + LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS } from "../../core/destination-funding"; +import { + assertEvmTreasuryFundingFeeWithinQuote, + calculateQuotedPresignedExecutionBudgetRaw, + EVM_DESTINATION_FUNDING_PROGRAM_VERSION +} from "../../core/evm-destination-gas"; import { getEvmFundingAccount } from "../../core/evm-funding"; -import { getBlockMetadata, getBlockState, getFlowMetadata } from "../../core/metadata"; +import { type EvmDestinationGasQuote, getBlockMetadata, getBlockState, getFlowMetadata } from "../../core/metadata"; import { getNativePrefunding } from "../../core/prepare"; import { AssethubOfframpSourceContext, type AssethubOfframpSourceRegistrationFacts } from "../assethub-offramp-source"; import { EvmOfframpSourceContext, EvmOfframpSourceMetadata } from "../evm-offramp-source/simulation"; @@ -48,7 +60,12 @@ export class FundEphemeralExecutor extends BasePhaseHandler { if (!quote) { throw new Error("Quote not found for the given state"); } - const blocks = getFlowMetadata(quote.metadata).blocks; + const flowMetadata = getFlowMetadata(quote.metadata); + const blocks = flowMetadata.blocks; + const destinationGasQuote = flowMetadata.globals.evmDestinationGas; + if (destinationGasQuote && destinationGasQuote.programVersion !== EVM_DESTINATION_FUNDING_PROGRAM_VERSION) { + throw new Error(`Unsupported EVM destination funding program ${String(destinationGasQuote.programVersion)}`); + } if (blocks[AssethubOfframpSourceContext.key]) { await this.verifyAssethubSourceTransaction(state); const substrateAddress = state.state.substrateEphemeralAddress; @@ -90,11 +107,37 @@ export class FundEphemeralExecutor extends BasePhaseHandler { sourceNetwork === Networks.Polygon ? POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS : sourceNetwork === Networks.Moonbeam - ? DESTINATION_EVM_FUNDING_AMOUNTS[Networks.Moonbeam] + ? destinationGasQuote + ? MOONBEAM_EVM_SOURCE_STARTING_BALANCE_UNITS + : LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS[Networks.Moonbeam] : BASE_EPHEMERAL_STARTING_BALANCE_UNITS; const fixedFundingRaw = BigInt(multiplyByPowerOfTen(fixedFundingUnits, chain.nativeCurrency.decimals).toFixed()); const plannedNativeValueRaw = getNativePrefunding(state.state.transactionPlan, sourceNetwork, evmEphemeralAddress); - const requiredFundingRaw = fixedFundingRaw + plannedNativeValueRaw; + const destinationNetwork = getNetworkFromDestination(state.to); + const dynamicDestinationNetwork = destinationGasQuote + ? getDynamicDestinationEvmFundingNetwork( + destinationNetwork, + state.type === RampDirection.BUY, + state.state.isDirectTransfer + ) + : undefined; + let destinationFundingRaw = 0n; + if (dynamicDestinationNetwork) { + if (!destinationGasQuote) { + throw new Error(`FundEphemeralExecutor: missing ${dynamicDestinationNetwork} destination gas quote`); + } + destinationFundingRaw = await this.getDestinationEvmFundingRequirementRaw( + state, + dynamicDestinationNetwork, + destinationGasQuote + ); + } + const sameNetworkDestinationLiabilityRaw = dynamicDestinationNetwork === sourceNetwork ? destinationFundingRaw : 0n; + const requiredFundingRaw = calculateSourceEvmFundingRequirementRaw( + fixedFundingRaw, + plannedNativeValueRaw, + sameNetworkDestinationLiabilityRaw + ); const currentBalanceRaw = await sourceClient.getBalance({ address: evmEphemeralAddress as `0x${string}` }); if (currentBalanceRaw < requiredFundingRaw) { @@ -104,25 +147,55 @@ export class FundEphemeralExecutor extends BasePhaseHandler { sourceNetwork, requiredFundingRaw - currentBalanceRaw, requiredFundingRaw, + dynamicDestinationNetwork === sourceNetwork ? destinationGasQuote : undefined, signal ); } else { logger.info(`${sourceNetwork} ephemeral address already funded.`); } - const destinationNetwork = getNetworkFromDestination(state.to); - if ( - state.type === RampDirection.BUY && - state.to !== Networks.AssetHub && - destinationNetwork && - isNetworkEVM(destinationNetwork) - ) { - const isFunded = await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork); + if (dynamicDestinationNetwork && dynamicDestinationNetwork !== sourceNetwork) { + const isFunded = await isDestinationEvmEphemeralFunded( + evmEphemeralAddress, + dynamicDestinationNetwork, + destinationFundingRaw + ); if (!isFunded) { - logger.info(`Funding EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); - await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork, signal); + logger.info(`Funding EVM ephemeral account ${evmEphemeralAddress} on ${dynamicDestinationNetwork}`); + await this.fundDestinationEvmEphemeralAccount( + state, + dynamicDestinationNetwork, + destinationFundingRaw, + destinationGasQuote, + signal + ); } else { - logger.info(`EVM ephemeral account already funded on ${destinationNetwork}.`); + logger.info(`EVM ephemeral account already funded on ${dynamicDestinationNetwork}.`); + } + } + + const legacyDestinationNetwork = destinationGasQuote + ? undefined + : getDynamicDestinationEvmFundingNetwork( + destinationNetwork, + state.type === RampDirection.BUY, + state.state.isDirectTransfer + ); + if (legacyDestinationNetwork) { + const legacyClient = EvmClientManager.getInstance().getClient(legacyDestinationNetwork); + const legacyChain = legacyClient.chain; + if (!legacyChain) { + throw new Error(`FundEphemeralExecutor: Could not get chain info for ${legacyDestinationNetwork}`); + } + const legacyRequiredRaw = BigInt( + multiplyByPowerOfTen( + LEGACY_DESTINATION_EVM_FUNDING_AMOUNTS[legacyDestinationNetwork], + legacyChain.nativeCurrency.decimals + ).toFixed() + ); + if (!(await isDestinationEvmEphemeralFunded(evmEphemeralAddress, legacyDestinationNetwork, legacyRequiredRaw))) { + logger.info(`Legacy-funding EVM ephemeral account ${evmEphemeralAddress} on ${legacyDestinationNetwork}`); + await this.fundLegacyDestinationEvmEphemeralAccount(state, legacyDestinationNetwork, legacyRequiredRaw, signal); } } } catch (e) { @@ -132,6 +205,14 @@ export class FundEphemeralExecutor extends BasePhaseHandler { throw e; } + if (e instanceof APIError && e.message === QuoteError.NetworkFeesTooHigh) { + throw this.createRecoverableError(QuoteError.NetworkFeesTooHigh); + } + + if (e instanceof PresignedEvmTransactionRebindError) { + throw this.createUnrecoverableError(e.message); + } + throw this.createRecoverableError("Error funding ephemeral account"); } @@ -206,6 +287,7 @@ export class FundEphemeralExecutor extends BasePhaseHandler { network: EvmNetworks, fundingAmountRaw: bigint, requiredFundingRaw: bigint, + destinationGasQuote?: EvmDestinationGasQuote, signal?: AbortSignal ): Promise { try { @@ -221,14 +303,33 @@ export class FundEphemeralExecutor extends BasePhaseHandler { const fundingAccount = getEvmFundingAccount(network); const walletClient = evmClientManager.getWalletClient(network, fundingAccount); + let checkedFees: { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } | undefined; await this.runFinancialOperation(state, { - attemptClass: "source-evm-native-funding", + attemptClass: destinationGasQuote ? "source-evm-native-funding-v2" : "source-evm-native-funding", + beforePerform: destinationGasQuote + ? async () => { + const fees = await networkClient.estimateFeesPerGas(); + await assertEvmTreasuryFundingFeeWithinQuote(destinationGasQuote, network, fees.maxFeePerGas); + checkedFees = fees; + } + : undefined, externalId: result => result.hash, perform: async () => { throwIfAborted(signal); + const fees = checkedFees; + if (destinationGasQuote && !fees) { + throw new Error(`FundEphemeralExecutor: missing checked ${network} funding fees`); + } const hash = await abortableCall(signal, () => walletClient.sendTransaction({ + ...(fees && destinationGasQuote + ? { + gas: BigInt(destinationGasQuote.fundingGasLimit), + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas + } + : {}), to: ephemeralAddress as `0x${string}`, value: fundingAmountRaw }) @@ -245,7 +346,9 @@ export class FundEphemeralExecutor extends BasePhaseHandler { }, provider: network, request: { - amountRaw: fundingAmountRaw.toString(), + ...(destinationGasQuote + ? { targetBalanceRaw: requiredFundingRaw.toString() } + : { amountRaw: fundingAmountRaw.toString() }), destination: ephemeralAddress, network, source: fundingAccount.address @@ -270,7 +373,7 @@ export class FundEphemeralExecutor extends BasePhaseHandler { } } catch (error) { logger.error(`FundEphemeralExecutor: Error during funding ${network} ephemeral:`, error); - if (error instanceof PhaseError) throw error; + if (error instanceof PhaseError || error instanceof APIError) throw error; throw new Error(`FundEphemeralExecutor: Error during funding ${network} ephemeral: ` + error); } } @@ -278,6 +381,8 @@ export class FundEphemeralExecutor extends BasePhaseHandler { protected async fundDestinationEvmEphemeralAccount( state: RampState, destinationNetwork: EvmNetworks, + requiredFundingRaw: bigint, + destinationGasQuote: EvmDestinationGasQuote | undefined, signal?: AbortSignal ): Promise { try { @@ -290,21 +395,40 @@ export class FundEphemeralExecutor extends BasePhaseHandler { } const ephemeralAddress = state.state.evmEphemeralAddress; - const fundingAmountUnits = DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork]; - const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, chain.nativeCurrency.decimals).toFixed(); + const currentBalanceRaw = await destinationClient.getBalance({ address: ephemeralAddress as `0x${string}` }); + const fundingAmountRaw = calculateDestinationFundingShortfallRaw(requiredFundingRaw, currentBalanceRaw); + if (fundingAmountRaw === 0n) { + return; + } const fundingAccount = getEvmFundingAccount(destinationNetwork); const walletClient = evmClientManager.getWalletClient(destinationNetwork, fundingAccount); + if (!destinationGasQuote) { + throw new Error(`FundEphemeralExecutor: missing ${destinationNetwork} destination gas quote`); + } + let checkedFees: { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } | undefined; await this.runFinancialOperation(state, { - attemptClass: "destination-evm-native-funding", + attemptClass: "destination-evm-native-funding-v2", + beforePerform: async () => { + const fees = await destinationClient.estimateFeesPerGas(); + await assertEvmTreasuryFundingFeeWithinQuote(destinationGasQuote, destinationNetwork, fees.maxFeePerGas); + checkedFees = fees; + }, externalId: result => result.hash, perform: async () => { throwIfAborted(signal); + const fees = checkedFees; + if (!fees) { + throw new Error(`FundEphemeralExecutor: missing checked ${destinationNetwork} funding fees`); + } const hash = await abortableCall(signal, () => walletClient.sendTransaction({ + gas: BigInt(destinationGasQuote.fundingGasLimit), + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, to: ephemeralAddress as `0x${string}`, - value: BigInt(fundingAmountRaw) + value: fundingAmountRaw }) ); const receipt = await abortableCall(signal, () => @@ -319,17 +443,17 @@ export class FundEphemeralExecutor extends BasePhaseHandler { }, provider: destinationNetwork, request: { - amountRaw: fundingAmountRaw, destination: ephemeralAddress, network: destinationNetwork, - source: fundingAccount.address + source: fundingAccount.address, + targetBalanceRaw: requiredFundingRaw.toString() }, signal }); try { await waitUntilTrueWithTimeout( - () => isDestinationEvmEphemeralFunded(ephemeralAddress, destinationNetwork), + () => isDestinationEvmEphemeralFunded(ephemeralAddress, destinationNetwork, requiredFundingRaw), 1000, 30000, signal @@ -341,11 +465,82 @@ export class FundEphemeralExecutor extends BasePhaseHandler { } } catch (error) { logger.error(`FundEphemeralExecutor: Error during funding ${destinationNetwork} ephemeral:`, error); - if (error instanceof PhaseError) throw error; + if (error instanceof PhaseError || error instanceof APIError) throw error; throw new Error(`FundEphemeralExecutor: Error during funding ${destinationNetwork} ephemeral: ` + error); } } + private async getDestinationEvmFundingRequirementRaw( + state: RampState, + destinationNetwork: EvmNetworks, + destinationGasQuote: EvmDestinationGasQuote + ): Promise { + const presignedTransfer = this.getPresignedTransaction(state, "destinationTransfer"); + if (!presignedTransfer?.txData || presignedTransfer.network !== destinationNetwork) { + throw new Error(`FundEphemeralExecutor: missing ${destinationNetwork} destination transfer`); + } + const unsignedTransfer = state.unsignedTxs.find( + transaction => + transaction.phase === "destinationTransfer" && + transaction.network === destinationNetwork && + transaction.nonce === presignedTransfer.nonce && + transaction.signer.toLowerCase() === presignedTransfer.signer.toLowerCase() + ); + if (!unsignedTransfer || !isEvmTransactionData(unsignedTransfer.txData)) { + throw new Error(`FundEphemeralExecutor: missing ${destinationNetwork} destination transfer blueprint`); + } + return calculateQuotedPresignedExecutionBudgetRaw(presignedTransfer, unsignedTransfer, destinationGasQuote); + } + + private async fundLegacyDestinationEvmEphemeralAccount( + state: RampState, + destinationNetwork: EvmNetworks, + requiredFundingRaw: bigint, + signal?: AbortSignal + ): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const destinationClient = evmClientManager.getClient(destinationNetwork); + const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; + const currentBalanceRaw = await destinationClient.getBalance({ address: ephemeralAddress }); + const fundingAmountRaw = calculateDestinationFundingShortfallRaw(requiredFundingRaw, currentBalanceRaw); + if (fundingAmountRaw === 0n) return; + + const fundingAccount = getEvmFundingAccount(destinationNetwork); + const walletClient = evmClientManager.getWalletClient(destinationNetwork, fundingAccount); + await this.runFinancialOperation(state, { + attemptClass: "destination-evm-native-funding", + externalId: result => result.hash, + perform: async () => { + throwIfAborted(signal); + const hash = await abortableCall(signal, () => + walletClient.sendTransaction({ to: ephemeralAddress, value: fundingAmountRaw }) + ); + const receipt = await abortableCall(signal, () => + destinationClient.waitForTransactionReceipt({ hash: hash as `0x${string}` }) + ); + if (!receipt || receipt.status !== "success") { + throw new Error(`FundEphemeralExecutor: Transaction ${hash} failed or was not found on ${destinationNetwork}`); + } + return { hash }; + }, + provider: destinationNetwork, + request: { + amountRaw: fundingAmountRaw.toString(), + destination: ephemeralAddress, + network: destinationNetwork, + source: fundingAccount.address + }, + signal + }); + + await waitUntilTrueWithTimeout( + () => isDestinationEvmEphemeralFunded(ephemeralAddress, destinationNetwork, requiredFundingRaw), + 1000, + 30000, + signal + ); + } + private async fundSubstrateEphemeralAccount( state: RampState, substrateAddress: string, diff --git a/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts index eb361f4d5..4527d269c 100644 --- a/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts +++ b/apps/api/src/api/services/phases/blocks/phases/subsidize-pre/simulation.ts @@ -165,7 +165,7 @@ export async function simulateAlfredpaySubsidizePre { + logger.error(`Error enqueuing completion email for ${state.id}: ${error}`); + }); } else if (updatedState.currentPhase === "failed") { logger.error(`Ramp ${state.id} failed unrecoverably, giving up.`); this.retriesMap.delete(state.id); diff --git a/apps/api/src/api/services/priceFeed.service.test.ts b/apps/api/src/api/services/priceFeed.service.test.ts index 2e27bb4d0..5e14f2560 100644 --- a/apps/api/src/api/services/priceFeed.service.test.ts +++ b/apps/api/src/api/services/priceFeed.service.test.ts @@ -210,6 +210,43 @@ describe("PriceFeedService", () => { }); describe("getUsdToFiatExchangeRate", () => { + it("returns the selected provider and observation time with the reference rate", async () => { + const instance = PriceFeedService.getInstance(); + const observedAt = 1_000_000; + Date.now = () => observedAt; + fetchMock = mock(async () => mockFastforexResponse(18.5, MXN)); + global.fetch = fetchMock as unknown as typeof fetch; + + const snapshot = await instance.getUsdToFiatExchangeRateSnapshot(MXN); + + expect(snapshot).toEqual({ observedAt: new Date(observedAt), rate: 18.5, source: "fastforex" }); + }); + + it("preserves the original source and observation time on cache hits", async () => { + const instance = PriceFeedService.getInstance(); + const observedAt = 1_000_000; + Date.now = () => observedAt; + fetchMock = mock(async () => mockFastforexResponse(18.5, MXN)); + global.fetch = fetchMock as unknown as typeof fetch; + const first = await instance.getUsdToFiatExchangeRateSnapshot(MXN); + fetchMock.mockClear(); + Date.now = () => observedAt + 1_000; + + const cached = await instance.getUsdToFiatExchangeRateSnapshot(MXN); + + expect(cached).toEqual(first); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("identifies the fixed USD reference without calling an external provider", async () => { + const instance = PriceFeedService.getInstance(); + + const snapshot = await instance.getUsdToFiatExchangeRateSnapshot(USD); + + expect(snapshot).toMatchObject({ rate: 1, source: "identity" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("should use Binance spot as the primary source for BRL", async () => { const instance = PriceFeedService.getInstance(); instance.getCryptoPrice = mock(async () => 5.86); diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 87e950d7d..199322375 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -10,6 +10,14 @@ interface CacheEntry { expiresAt: number; } +export type FiatExchangeRateSource = "binance" | "coingecko" | "fastforex" | "identity"; + +export interface FiatExchangeRateSnapshot { + observedAt: Date; + rate: number; + source: FiatExchangeRateSource; +} + const FIAT_SANITY_SPREAD_LIMITS: Record = { ARS: 0.25, BRL: 0.02, @@ -61,7 +69,7 @@ export class PriceFeedService { // Cache storage private cryptoPriceCache: Map> = new Map(); - private fiatExchangeRateCache: Map> = new Map(); + private fiatExchangeRateCache: Map> = new Map(); /** * Private constructor to enforce singleton pattern @@ -205,6 +213,10 @@ export class PriceFeedService { * @returns The exchange rate (how much of toCurrency equals 1 unit of fromCurrency) */ public async getUsdToFiatExchangeRate(toCurrency: RampCurrency): Promise { + return (await this.getUsdToFiatExchangeRateSnapshot(toCurrency)).rate; + } + + public async getUsdToFiatExchangeRateSnapshot(toCurrency: RampCurrency): Promise { const fromCurrency = "USD"; const targetCurrency = toCurrency.toUpperCase() as RampCurrency; @@ -213,7 +225,7 @@ export class PriceFeedService { } if (targetCurrency === "USD") { - return 1; + return { observedAt: new Date(), rate: 1, source: "identity" }; } const cacheKey = `fiat:${fromCurrency}:${targetCurrency}`; @@ -222,7 +234,7 @@ export class PriceFeedService { const hasCoinGeckoFallback = !COINGECKO_UNSUPPORTED_FIAT_CURRENCIES.has(targetCurrency); if (cachedEntry && cachedEntry.expiresAt > now) { - logger.debug(`Cache hit for ${cacheKey}. Using cached exchange rate: ${cachedEntry.value}`); + logger.debug(`Cache hit for ${cacheKey}. Using cached exchange rate: ${cachedEntry.value.rate}`); return cachedEntry.value; } @@ -232,8 +244,9 @@ export class PriceFeedService { try { const rate = await this.getBinanceUsdtToFiatRate(targetCurrency); await this.assertRateWithinSanityBand("Binance", targetCurrency, rate); - this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate }); - return rate; + const snapshot = { observedAt: new Date(now), rate, source: "binance" } as const; + this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: snapshot }); + return snapshot; } catch (binanceError) { logger.warn( `Binance failed for ${fromCurrency}-${targetCurrency}, falling back to fastforex: ${binanceError instanceof Error ? binanceError.message : binanceError}` @@ -247,8 +260,9 @@ export class PriceFeedService { try { const rate = await this.getFastforexRate(fromCurrency, targetCurrency); await this.assertRateWithinSanityBand("fastforex", targetCurrency, rate); - this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate }); - return rate; + const snapshot = { observedAt: new Date(now), rate, source: "fastforex" } as const; + this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: snapshot }); + return snapshot; } catch (ffError) { logger.warn( `fastforex failed for ${fromCurrency}-${targetCurrency}, ${ @@ -274,8 +288,9 @@ export class PriceFeedService { try { const rate = await this.getCryptoPrice("usd-coin", targetCurrency.toLowerCase()); this.assertValidFiatRate("CoinGecko", fromCurrency, targetCurrency, rate); - this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate }); - return rate; + const snapshot = { observedAt: new Date(now), rate, source: "coingecko" } as const; + this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: snapshot }); + return snapshot; } catch (cgError) { if (cgError instanceof Error) { logger.error(`Error fetching fiat exchange rate from ${fromCurrency} to ${targetCurrency}: ${cgError.message}`); diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index da2a109d4..84f8cd49b 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -32,6 +32,14 @@ type BestQuoteFailure = { network: Networks; }; +function isNetworkFeesTooHighError(error: unknown): error is APIError { + return ( + error instanceof APIError && + error.status === httpStatus.SERVICE_UNAVAILABLE && + error.message === QuoteError.NetworkFeesTooHigh + ); +} + export class QuoteService extends BaseRampService { public async createQuote( request: CreateQuoteRequest & { @@ -131,6 +139,9 @@ export class QuoteService extends BaseRampService { if (failures.length > 0 && failures.every(failure => isLowLiquidityQuoteError(failure.error))) { throw createLowLiquidityQuoteError(); } + if (failures.length > 0 && failures.every(failure => isNetworkFeesTooHighError(failure.error))) { + throw new APIError({ message: QuoteError.NetworkFeesTooHigh, status: httpStatus.SERVICE_UNAVAILABLE }); + } throw new APIError({ message: QuoteError.FailedToCalculateQuote, @@ -185,10 +196,6 @@ export class QuoteService extends BaseRampService { throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.BAD_REQUEST }); } - if (request.rampType === RampDirection.BUY && request.to === Networks.Ethereum) { - throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); - } - const resolvedPartner = await resolveQuotePartner(request); const partner = resolvedPartner.partner; @@ -228,6 +235,10 @@ export class QuoteService extends BaseRampService { throw error; } + if (isNetworkFeesTooHighError(error)) { + throw error; + } + if (isLowLiquidityQuoteError(error)) { throw createLowLiquidityQuoteError(); } diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 46aa26edd..e1080c672 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -44,6 +44,7 @@ import { } from "../../services/phases/blocks/core/discount"; import { getTargetFiatCurrency } from "../../services/phases/blocks/core/helpers"; import { accountCapabilities } from "../phases/blocks/core/accounts"; +import { preflightEvmDestinationFeeWithinQuote } from "../phases/blocks/core/evm-destination-gas"; import { getFlowMetadata } from "../phases/blocks/core/metadata"; import { resolvePersistedBlockFlow } from "../phases/blocks/flows/catalog"; import { StateMetadata } from "../phases/meta-state-types"; @@ -936,6 +937,11 @@ export class RampService extends BaseRampService { const metadata = getFlowMetadata(quote.metadata); const flow = resolvePersistedBlockFlow(metadata); const quoteFields = quote.get({ plain: true }); + if (metadata.globals.evmDestinationGas) { + // Run the same persisted-envelope guard before provider registration can + // create an independently durable ticket. prepareTxs keeps its exact check. + await preflightEvmDestinationFeeWithinQuote(metadata.globals.evmDestinationGas); + } const registered = await flow.register({ authenticatedUser: { id: userId }, input: additionalData ?? {}, diff --git a/apps/api/src/api/services/transactions/validation.test.ts b/apps/api/src/api/services/transactions/validation.test.ts index cb892db89..c3749a421 100644 --- a/apps/api/src/api/services/transactions/validation.test.ts +++ b/apps/api/src/api/services/transactions/validation.test.ts @@ -5,6 +5,7 @@ import { EvmTransactionData, Networks, NUMBER_OF_PRESIGNED_TXS, + PRESIGNED_EVM_FEE_MULTIPLIER, PresignedTx, RampDirection, SignedTypedData @@ -88,8 +89,7 @@ async function makeSignedEvmTxWithBackups(overrides: { } // Helper for legacy (type 0) EVM transactions which use `gasPrice` and omit -// maxFeePerGas / maxPriorityFeePerGas entirely. Used to test the zero-minimum branch -// of assertSignedEvmMinimum, since some chains/SDKs sign legacy-style. +// maxFeePerGas / maxPriorityFeePerGas entirely. async function makeLegacySignedEvmTxWithBackups(overrides: { nonce: number; phase: PresignedTx["phase"]; @@ -792,7 +792,7 @@ describe("Presigned Transaction validation", () => { ).rejects.toThrow("maxPriorityFeePerGas"); }); - it("accepts legacy signed EVM tx without maxPriorityFeePerGas when server unsigned minimum is 0", async () => { + it("rejects a nonzero legacy gas price when the server-issued fee envelope is zero", async () => { const unsignedTxData: EvmTransactionData = { data: "0x12345678", gas: "21000", @@ -816,12 +816,104 @@ describe("Presigned Transaction validation", () => { network: Networks.Polygon }); + await expect( + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) + ).rejects.toThrow("exceeds expected maximum 0"); + }); + + it("accepts the production signing multiplier while keeping the server gas limit", async () => { + const unsignedTxData: EvmTransactionData = { + data: "0x12345678", + gas: "21000", + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "500000000", + to: "0x000000000000000000000000000000000000dEaD", + value: "0" + }; + const unsignedTx: PresignedTx = { + meta: {}, + network: Networks.Polygon, + nonce: 5, + phase: "fundEphemeral", + signer: EVM_SIGNER, + txData: unsignedTxData + }; + const presignedTx = await makeSignedEvmTxWithBackups({ + gasLimit: 21000n, + maxFeePerGas: 1000000000n * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: 500000000n * PRESIGNED_EVM_FEE_MULTIPLIER, + nonce: 5, + phase: "fundEphemeral", + network: Networks.Polygon + }); + await expect( validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) ).resolves.toBeUndefined(); }); - it("accepts signed EVM hex blob when gas and fee caps exceed server unsigned values", async () => { + it("rejects a signed EVM gas limit above the server-issued value", async () => { + const unsignedTxData: EvmTransactionData = { + data: "0x12345678", + gas: "21000", + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "500000000", + to: "0x000000000000000000000000000000000000dEaD", + value: "0" + }; + const unsignedTx: PresignedTx = { + meta: {}, + network: Networks.Polygon, + nonce: 5, + phase: "fundEphemeral", + signer: EVM_SIGNER, + txData: unsignedTxData + }; + const presignedTx = await makeSignedEvmTxWithBackups({ + gasLimit: 21001n, + maxFeePerGas: 1000000000n, + maxPriorityFeePerGas: 500000000n, + nonce: 5, + phase: "fundEphemeral", + network: Networks.Polygon + }); + + await expect( + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) + ).rejects.toThrow("gas limit"); + }); + + it("rejects a signed EVM maxFeePerGas above the production signing multiplier", async () => { + const unsignedTxData: EvmTransactionData = { + data: "0x12345678", + gas: "21000", + maxFeePerGas: "1000000000", + maxPriorityFeePerGas: "500000000", + to: "0x000000000000000000000000000000000000dEaD", + value: "0" + }; + const unsignedTx: PresignedTx = { + meta: {}, + network: Networks.Polygon, + nonce: 5, + phase: "fundEphemeral", + signer: EVM_SIGNER, + txData: unsignedTxData + }; + const presignedTx = await makeSignedEvmTxWithBackups({ + maxFeePerGas: 1000000000n * PRESIGNED_EVM_FEE_MULTIPLIER + 1n, + maxPriorityFeePerGas: 500000000n, + nonce: 5, + phase: "fundEphemeral", + network: Networks.Polygon + }); + + await expect( + validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) + ).rejects.toThrow("maxFeePerGas"); + }); + + it("rejects a signed EVM priority fee above the production signing multiplier", async () => { const unsignedTxData: EvmTransactionData = { data: "0x12345678", gas: "21000", @@ -839,9 +931,8 @@ describe("Presigned Transaction validation", () => { txData: unsignedTxData }; const presignedTx = await makeSignedEvmTxWithBackups({ - gasLimit: 30000n, maxFeePerGas: 2000000000n, - maxPriorityFeePerGas: 1000000000n, + maxPriorityFeePerGas: 500000000n * PRESIGNED_EVM_FEE_MULTIPLIER + 1n, nonce: 5, phase: "fundEphemeral", network: Networks.Polygon @@ -849,7 +940,7 @@ describe("Presigned Transaction validation", () => { await expect( validatePresignedTxs(RampDirection.BUY, [presignedTx], { Substrate: "", EVM: EVM_SIGNER }, [unsignedTx]) - ).resolves.toBeUndefined(); + ).rejects.toThrow("maxPriorityFeePerGas"); }); it("should throw error when transaction is missing required properties", async () => { diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index bb4c4b72d..c5ea164a2 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -11,6 +11,7 @@ import { isSignedTypedDataArray, Networks, NUMBER_OF_PRESIGNED_TXS, + PRESIGNED_EVM_FEE_MULTIPLIER, PresignedTx, RampDirection, RampPhase, @@ -34,28 +35,43 @@ interface VerifiedEvmTransaction { chainId: number; } -function assertSignedEvmMinimum(fieldName: string, actual: bigint | undefined, expectedMinimumRaw: string | undefined) { +export class PresignedEvmTransactionRebindError extends APIError { + constructor(message: string) { + super({ message, status: httpStatus.BAD_REQUEST }); + } +} + +function assertSignedEvmFeeWithinBounds(fieldName: string, actual: bigint | undefined, expectedMinimumRaw: string | undefined) { if (expectedMinimumRaw === undefined) { return; } const expectedMinimum = BigInt(expectedMinimumRaw); - // When the server-issued minimum is 0, a missing field is equivalent to "≥ 0" (e.g., legacy txs that - // use gasPrice instead of maxPriorityFeePerGas, or chains that accept zero priority fee). Reject only - // if a concrete value is present and is strictly below the minimum. - if (expectedMinimum === 0n) { - if (actual !== undefined && actual < expectedMinimum) { - throw new APIError({ - message: `Signed EVM transaction ${fieldName} ${actual.toString()} is below expected minimum ${expectedMinimum.toString()}`, - status: httpStatus.BAD_REQUEST - }); - } + if (actual === undefined || actual < expectedMinimum) { + throw new APIError({ + message: `Signed EVM transaction ${fieldName} ${actual?.toString() ?? "missing"} is below expected minimum ${expectedMinimum.toString()}`, + status: httpStatus.BAD_REQUEST + }); + } + + const expectedMaximum = expectedMinimum * PRESIGNED_EVM_FEE_MULTIPLIER; + if (actual > expectedMaximum) { + throw new APIError({ + message: `Signed EVM transaction ${fieldName} ${actual.toString()} exceeds expected maximum ${expectedMaximum.toString()}`, + status: httpStatus.BAD_REQUEST + }); + } +} + +function assertSignedEvmGasLimit(actual: bigint | undefined, expectedRaw: string | undefined) { + if (expectedRaw === undefined) { return; } - if (actual === undefined || actual < expectedMinimum) { + const expected = BigInt(expectedRaw); + if (actual !== expected) { throw new APIError({ - message: `Signed EVM transaction ${fieldName} ${actual?.toString() ?? "missing"} is below expected minimum ${expectedMinimum.toString()}`, + message: `Signed EVM transaction gas limit ${actual?.toString() ?? "missing"} does not match expected ${expected.toString()}`, status: httpStatus.BAD_REQUEST }); } @@ -139,9 +155,9 @@ async function verifySignedEvmTransaction( }); } - assertSignedEvmMinimum("gas limit", parsed.gas, unsignedTxData.gas); - assertSignedEvmMinimum("maxFeePerGas", parsed.maxFeePerGas ?? parsed.gasPrice, unsignedTxData.maxFeePerGas); - assertSignedEvmMinimum( + assertSignedEvmGasLimit(parsed.gas, unsignedTxData.gas); + assertSignedEvmFeeWithinBounds("maxFeePerGas", parsed.maxFeePerGas ?? parsed.gasPrice, unsignedTxData.maxFeePerGas); + assertSignedEvmFeeWithinBounds( "maxPriorityFeePerGas", parsed.maxPriorityFeePerGas ?? parsed.gasPrice, unsignedTxData.maxPriorityFeePerGas @@ -474,6 +490,28 @@ async function validateEvmTransaction( await verifySignedEvmTransaction(txData, signer, tx.nonce, tx.network, evmUnsigned); } +export async function validatePresignedEvmTransactionAgainstUnsigned(tx: PresignedTx, unsignedTx: PresignedTx): Promise { + try { + if ( + tx.phase !== unsignedTx.phase || + tx.network !== unsignedTx.network || + tx.nonce !== unsignedTx.nonce || + tx.signer.toLowerCase() !== unsignedTx.signer.toLowerCase() + ) { + throw new Error("Presigned EVM transaction identity does not match its server-issued unsigned transaction"); + } + if (!isEvmTransactionData(unsignedTx.txData)) { + throw new Error("Server-issued unsigned EVM transaction has invalid transaction data"); + } + await validateEvmTransaction(tx, unsignedTx.signer, unsignedTx.txData); + } catch (error) { + if (error instanceof PresignedEvmTransactionRebindError) throw error; + throw new PresignedEvmTransactionRebindError( + error instanceof Error ? error.message : "Presigned EVM transaction does not match its server-issued transaction" + ); + } +} + function validateSignedTypedData( tx: PresignedTx, expectedSigner: string, diff --git a/apps/api/src/api/workers/alfredpay-status.worker.test.ts b/apps/api/src/api/workers/alfredpay-status.worker.test.ts new file mode 100644 index 000000000..299672982 --- /dev/null +++ b/apps/api/src/api/workers/alfredpay-status.worker.test.ts @@ -0,0 +1,100 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { FindOptions, IncludeOptions, Op } from "sequelize"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; +import AlfredpayStatusWorker from "./alfredpay-status.worker"; + +type TestableWorker = { + cursorId: string | null; + job: { isActive: boolean; waitForCompletion: boolean }; + poll: () => Promise; +}; + +const realFindAll = ProviderCustomer.findAll; + +afterAll(() => { + ProviderCustomer.findAll = realFindAll; +}); + +async function captureQuery(): Promise { + let captured: FindOptions = {}; + ProviderCustomer.findAll = (async (options: FindOptions) => { + captured = options; + return []; + }) as typeof ProviderCustomer.findAll; + + const worker = new AlfredpayStatusWorker() as unknown as TestableWorker; + await worker.poll(); + return captured; +} + +describe("AlfredpayStatusWorker query window", () => { + it("polls only undecided Alfredpay accounts", async () => { + const where = (await captureQuery()).where as Record; + + expect(where.provider).toBe("alfredpay"); + // An account already stored terminal has had its email queued by whichever poll got + // there first; re-polling it would spend provider calls to learn nothing. + expect((where.status as Record)[Op.notIn]).toEqual([ + VerificationStatus.Approved, + VerificationStatus.Rejected + ]); + }); + + // An account abandoned mid-wizard never reaches a terminal status, so without this bound + // the sweep would re-poll every one of them for the life of the deployment. + it("bounds the sweep on the account's last write", async () => { + const where = (await captureQuery()).where as Record>; + + expect(where.updatedAt[Op.gte]).toBeInstanceOf(Date); + }); + + // Each account costs two to three Alfredpay calls, so an unbounded result set would turn + // one cycle into a provider flood. + it("caps how many accounts a single cycle polls", async () => { + expect((await captureQuery()).limit).toBeGreaterThan(0); + }); + + it("does not start on construction and suppresses overlapping cycles", () => { + const { job } = new AlfredpayStatusWorker() as unknown as TestableWorker; + + expect(job.isActive).toBe(false); + expect(job.waitForCompletion).toBe(true); + }); + + it("orders by a stable key so capped cycles can advance instead of starving older rows", async () => { + const options = await captureQuery(); + + expect(options.order).toEqual([["id", "ASC"]]); + }); + + it("continues after the previous full page, then wraps after reaching the end", async () => { + const queries: FindOptions[] = []; + let queryNumber = 0; + ProviderCustomer.findAll = (async (options: FindOptions) => { + queries.push(options); + queryNumber += 1; + if (queryNumber === 1) { + return Array.from({ length: options.limit as number }, (_, index) => ({ id: `account-${index}` })); + } + return []; + }) as typeof ProviderCustomer.findAll; + + const worker = new AlfredpayStatusWorker("15 * * * *", async () => undefined) as unknown as TestableWorker; + await worker.poll(); + await worker.poll(); + + const secondWhere = queries[1].where as Record>; + expect(secondWhere.id[Op.gt]).toBe("account-249"); + expect(worker.cursorId).toBeNull(); + }); + + // Partner-owned entities have no profile to email; excluding them in the query rather + // than the loop keeps the sweep from spending provider calls on accounts it cannot mail. + it("excludes entities with no profile to email", async () => { + const include = (await captureQuery()).include as IncludeOptions[]; + + expect(include).toHaveLength(1); + expect(include[0].required).toBe(true); + expect((include[0].where as Record>).profileId[Op.not]).toBeNull(); + }); +}); diff --git a/apps/api/src/api/workers/alfredpay-status.worker.ts b/apps/api/src/api/workers/alfredpay-status.worker.ts new file mode 100644 index 000000000..c4e880e59 --- /dev/null +++ b/apps/api/src/api/workers/alfredpay-status.worker.ts @@ -0,0 +1,109 @@ +import { CronJob } from "cron"; +import { Op } from "sequelize"; +import logger from "../../config/logger"; +import CustomerEntity from "../../models/customerEntity.model"; +import ProviderCustomer, { VerificationStatus } from "../../models/providerCustomer.model"; +import { refreshAlfredpayCustomerStatus } from "../services/alfredpay/alfredpay-customer.service"; + +const MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; +// Each account costs two to three Alfredpay calls (submission-id resolution, then status), +// so the sweep is bounded rather than sized by whatever the query happens to return. +const MAX_ACCOUNTS_PER_CYCLE = 250; + +/** + * The only background watcher of Alfredpay verification outcomes. Unlike Avenia there is no + * webhook to fall back on — Alfredpay publishes no verification events — so this is the + * primary path, not a reconciliation net, for every user who does not reopen the dashboard. + * + * It only drives `refreshAlfredpayCustomerStatus`, which is where the outcome is persisted + * and the email queued; the dashboard's on-demand refresh calls the same function, so an + * account decided while the user was watching is mailed by that path instead and is already + * terminal by the time this sweep next runs. + * + * Double-sending is impossible: enqueuing is keyed on the Alfredpay submission id, so a + * sweep racing or repeating a dashboard refresh is a no-op. + */ +class AlfredpayStatusWorker { + private job: CronJob; + + private cursorId: string | null = null; + + constructor( + cronTime = "15 * * * *", + private readonly refreshCustomerStatus = refreshAlfredpayCustomerStatus + ) { + this.job = CronJob.from({ + cronTime, + onTick: this.poll.bind(this), + start: false, + timeZone: "UTC", + waitForCompletion: true + }); + } + + public start(): void { + logger.info("Starting Alfredpay status worker"); + 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({ + // Partner-owned entities have no profile to email, and each account here costs + // provider calls — so they are excluded by the query rather than skipped in the loop. + include: [ + { + as: "customerEntity", + attributes: [], + model: CustomerEntity, + required: true, + where: { profileId: { [Op.not]: null } } + } + ], + limit: MAX_ACCOUNTS_PER_CYCLE, + // Walk a stable keyset instead of repeatedly taking the newest 250 rows. A busy + // deployment can otherwise keep older eligible accounts outside every cycle. + order: [["id", "ASC"]], + where: { + ...(this.cursorId ? { id: { [Op.gt]: this.cursorId } } : {}), + provider: "alfredpay", + status: { [Op.notIn]: [VerificationStatus.Approved, VerificationStatus.Rejected] }, + // An account abandoned mid-wizard stays non-terminal forever; without this bound the + // sweep would re-poll every one of them for the life of the deployment. + updatedAt: { [Op.gte]: new Date(Date.now() - MAX_AGE_MS) } + } + }); + + // A short page means the scan reached the end; wrap on the next cycle so failed + // provider calls and newly eligible lower ids get another chance. + this.cursorId = pending.length === MAX_ACCOUNTS_PER_CYCLE ? (pending.at(-1)?.id ?? null) : null; + + if (pending.length === 0) { + return; + } + + logger.info(`Checking Alfredpay verification status for ${pending.length} account(s)`); + if (pending.length === MAX_ACCOUNTS_PER_CYCLE) { + logger.warn( + `Alfredpay status sweep hit its ${MAX_ACCOUNTS_PER_CYCLE}-account cap; the keyset scan continues next cycle` + ); + } + + for (const customer of pending) { + // Best-effort per account: refreshAlfredpayCustomerStatus swallows provider failures + // and leaves the stored status untouched, so one bad account cannot end the cycle. + await this.refreshCustomerStatus(customer); + } + } catch (error) { + const errorDetails = error instanceof Error ? (error.stack ?? error.message) : String(error); + logger.error(`Error during Alfredpay status worker cycle: ${errorDetails}`); + } + } +} + +export default AlfredpayStatusWorker; diff --git a/apps/api/src/api/workers/kyb-status.worker.test.ts b/apps/api/src/api/workers/kyb-status.worker.test.ts new file mode 100644 index 000000000..20bf5c77d --- /dev/null +++ b/apps/api/src/api/workers/kyb-status.worker.test.ts @@ -0,0 +1,138 @@ +import { BrlaApiService, KycAttemptResult, KycAttemptStatus } from "@vortexfi/shared"; +import { afterAll, describe, expect, it, mock } from "bun:test"; +import { FindOptions, Op } from "sequelize"; +import EmailNotification from "../../models/emailNotification.model"; +import KycCase from "../../models/kycCase.model"; +import KybStatusWorker from "./kyb-status.worker"; + +type TestableWorker = { + job: { isActive: boolean; waitForCompletion: boolean }; + poll: () => Promise; +}; + +const realFindAll = KycCase.findAll; + +afterAll(() => { + KycCase.findAll = realFindAll; +}); + +async function captureQuery(): Promise { + let captured: FindOptions = {}; + KycCase.findAll = (async (options: FindOptions) => { + captured = options; + return []; + }) as typeof KycCase.findAll; + + const worker = new KybStatusWorker() as unknown as TestableWorker; + await worker.poll(); + return captured; +} + +describe("KybStatusWorker query window", () => { + it("bounds the poll on the case's last write, not its creation", async () => { + const where = (await captureQuery()).where as Record>; + + // A kyc_case row is reused across attempts, so a months-old row can hold a brand new + // attempt — exactly the one the webhook fallback exists for. Filtering on createdAt + // dropped it. + expect(where.createdAt).toBeUndefined(); + expect(where.updatedAt[Op.gte]).toBeInstanceOf(Date); + }); + + it("still restricts the poll to undecided Avenia KYB cases with a bound attempt", async () => { + const where = (await captureQuery()).where as Record; + + expect(where.provider).toBe("avenia"); + expect(where.type).toBe("kyb"); + expect(where.providerCaseId).toBeDefined(); + expect(where.status).toBeDefined(); + }); + + it("does not start on construction and suppresses overlapping cycles", () => { + const { job } = new KybStatusWorker() as unknown as TestableWorker; + + expect(job.isActive).toBe(false); + expect(job.waitForCompletion).toBe(true); + }); + + // Nothing here writes the terminal status back to kyc_cases, so without the anti-join + // a settled attempt costs one Avenia request per hour until it ages out of the window. + it("excludes attempts whose outcome is already queued and bounds the batch", async () => { + const options = await captureQuery(); + const anti = (options.where as Record)[Op.and]; + + expect(String(anti?.val)).toContain("NOT EXISTS"); + expect(String(anti?.val)).toContain("email_notifications"); + expect(options.limit).toBe(250); + expect(options.order).toEqual([["id", "ASC"]]); + }); + + it("filters partner-owned entities in the join so they cannot occupy batch slots", async () => { + const options = await captureQuery(); + const include = (options.include as Array<{ where?: Record }>)[0]; + + expect(include.where?.profileId).toBeDefined(); + }); + + // A poll does not modify a still-pending case, so without the cursor the same first + // batch would be re-selected every hour and everything behind it starved. + it("advances the keyset cursor when a cycle fills its cap and resets it when one does not", async () => { + const captured: FindOptions[] = []; + // profileId null makes each row a fast no-op in the poll loop. + const fakeCase = (id: string) => ({ customerEntity: { profileId: null }, id, providerCaseId: `attempt-${id}` }); + KycCase.findAll = (async (options: FindOptions) => { + captured.push(options); + return captured.length === 1 ? Array.from({ length: 250 }, (_, i) => fakeCase(String(i).padStart(3, "0"))) : []; + }) as unknown as typeof KycCase.findAll; + + const worker = new KybStatusWorker() as unknown as TestableWorker; + await worker.poll(); + await worker.poll(); + await worker.poll(); + + const wheres = captured.map(options => options.where as Record>); + expect(wheres[0].id).toBeUndefined(); + expect(wheres[1].id[Op.gt]).toBe("249"); + // The second cycle came back under the cap, so the third starts from the top again. + expect(wheres[2].id).toBeUndefined(); + }); + + // Mirrors the authenticated route's guard: a malformed provider response must not + // enqueue another attempt's outcome for this case's profile. + it("discards a provider response whose attempt id does not match the case", async () => { + const polledCase = { customerEntity: { profileId: "user-1" }, id: "case-1", providerCaseId: "attempt-1" }; + KycCase.findAll = (async () => [polledCase]) as unknown as typeof KycCase.findAll; + + const realGetInstance = BrlaApiService.getInstance; + const realFindOne = EmailNotification.findOne; + // First touch of any enqueue is the dedupe lookup; recording it observes whether + // the guard let the outcome through. + const enqueueTouched = mock(async () => ({}) as EmailNotification); + EmailNotification.findOne = enqueueTouched as unknown as typeof EmailNotification.findOne; + + const respondWith = (id: string) => + mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ + attempt: { id, result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED, updatedAt: "2026-08-07" } + })) + }) as unknown as BrlaApiService + ); + + try { + const worker = new KybStatusWorker() as unknown as TestableWorker; + + BrlaApiService.getInstance = respondWith("attempt-OTHER"); + await worker.poll(); + expect(enqueueTouched).not.toHaveBeenCalled(); + + BrlaApiService.getInstance = respondWith("attempt-1"); + await worker.poll(); + expect(enqueueTouched).toHaveBeenCalledTimes(1); + } finally { + BrlaApiService.getInstance = realGetInstance; + EmailNotification.findOne = realFindOne; + } + }); +}); diff --git a/apps/api/src/api/workers/kyb-status.worker.ts b/apps/api/src/api/workers/kyb-status.worker.ts new file mode 100644 index 000000000..86e22d7e5 --- /dev/null +++ b/apps/api/src/api/workers/kyb-status.worker.ts @@ -0,0 +1,146 @@ +import { BrlaApiService } from "@vortexfi/shared"; +import { CronJob } from "cron"; +import { literal, Op } from "sequelize"; +import logger from "../../config/logger"; +import CustomerEntity from "../../models/customerEntity.model"; +import { NotificationProvider } from "../../models/emailNotification.model"; +import KycCase from "../../models/kycCase.model"; +import { VerificationStatus } from "../../models/providerCustomer.model"; +import { enqueueVerificationNotification } from "../services/avenia/verification-notifications"; + +const MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; +const MAX_CASES_PER_CYCLE = 250; + +/** + * Reconciliation safety net behind the Avenia webhook receiver, which is the primary + * path for both KYC and KYB outcomes. + * + * It exists because Avenia documents no KYB subscription: company attempts are only + * expected to arrive over the wildcard subscription because they share the attempts + * resource with KYC, and that is unconfirmed. If they do not arrive, this poll is what + * still sends the email. Retire it once company events are observed on the webhook. + * + * Double-sending is impossible: enqueuing is keyed on the attempt id, so a poll that + * races or repeats a webhook is a no-op. + * + * Polls the attempt recorded at initiation rather than listing the subaccount's + * attempts: the list endpoint returns no documented ordering, so picking one from it + * would guess at both the level and the newest entry. + */ +class KybStatusWorker { + private job: CronJob; + + // Keyset cursor: null selects from the top; set only when a cycle filled its cap, so + // the next cycle continues behind the last row instead of re-taking the same prefix. + private cursorId: string | null = null; + + constructor(cronTime = "0 * * * *") { + this.job = CronJob.from({ + cronTime, + onTick: this.poll.bind(this), + start: false, + timeZone: "UTC", + waitForCompletion: true + }); + } + + public start(): void { + logger.info("Starting KYB status worker"); + 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({ + include: [ + { + as: "customerEntity", + model: CustomerEntity, + required: true, + // Partner-owned entities have no profile to email. Filtered in the join, not + // after the fetch, so they cannot occupy the batch's slots. + where: { profileId: { [Op.not]: null } } + } + ], + limit: MAX_CASES_PER_CYCLE, + // Walk a stable keyset: a poll does not modify a still-pending case, so a plain + // oldest-first prefix would re-select the same rows every cycle and starve the + // rest whenever more than one batch is pending. + order: [["id", "ASC"]], + where: { + ...(this.cursorId ? { id: { [Op.gt]: this.cursorId } } : {}), + // An attempt whose outcome is already queued (webhook or an earlier poll) is + // settled for this worker's purpose. Without the anti-join every settled case + // costs one Avenia request per hour until it ages out of the window, since + // nothing here writes the terminal status back to kyc_cases. + [Op.and]: literal(`NOT EXISTS ( + SELECT 1 + FROM email_notifications + WHERE provider = '${NotificationProvider.Avenia}' + AND resource_id = "KycCase"."provider_case_id" + )`), + provider: "avenia", + providerCaseId: { [Op.not]: null }, + status: { [Op.notIn]: [VerificationStatus.Approved, VerificationStatus.Rejected] }, + type: "kyb", + // The case row is reused across attempts (re-initiation rebinds it to a fresh + // attempt id), so its creation date says nothing about the attempt being polled. + // Bounding on the last write keeps a resumed attempt in scope no matter how old + // the row is, which is exactly when the webhook fallback has to work. + updatedAt: { [Op.gte]: new Date(Date.now() - MAX_AGE_MS) } + } + }); + + this.cursorId = pending.length === MAX_CASES_PER_CYCLE ? (pending.at(-1)?.id ?? null) : null; + + if (pending.length === 0) { + return; + } + + if (pending.length === MAX_CASES_PER_CYCLE) { + logger.info(`KYB status sweep hit its ${MAX_CASES_PER_CYCLE}-case cap; the keyset scan continues next cycle`); + } + + logger.info(`Checking KYB status for ${pending.length} company account(s)`); + + const brlaApiService = BrlaApiService.getInstance(); + + for (const kycCase of pending) { + try { + // Non-null by the join filter above; kept for type narrowing. + const profileId = kycCase.customerEntity?.profileId; + if (!profileId) { + continue; + } + + // Non-null by the providerCaseId filter in the query above. + const { attempt } = await brlaApiService.getKybAttemptStatus(kycCase.providerCaseId as string); + if (!attempt) { + continue; + } + + // Mirror the authenticated route's mismatch guard: a malformed provider response + // must not enqueue another attempt's outcome (and reason) for this case's profile. + if (attempt.id !== kycCase.providerCaseId) { + logger.error(`Avenia returned attempt ${attempt.id} when asked for ${kycCase.providerCaseId}; skipping`); + continue; + } + + await enqueueVerificationNotification(attempt, profileId, "business"); + } catch (error) { + logger.error(`Error checking KYB status for attempt ${kycCase.providerCaseId}: ${error}`); + } + } + } catch (error) { + const errorDetails = error instanceof Error ? (error.stack ?? error.message) : String(error); + logger.error(`Error during KYB status worker cycle: ${errorDetails}`); + } + } +} + +export default KybStatusWorker; diff --git a/apps/api/src/api/workers/notification-dispatch.worker.test.ts b/apps/api/src/api/workers/notification-dispatch.worker.test.ts new file mode 100644 index 000000000..e94eb7626 --- /dev/null +++ b/apps/api/src/api/workers/notification-dispatch.worker.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test"; +import NotificationDispatchWorker from "./notification-dispatch.worker"; + +type TestableWorker = { + dispatchJob: { isActive: boolean; waitForCompletion: boolean }; + reconcileJob: { isActive: boolean; waitForCompletion: boolean }; +}; + +describe("NotificationDispatchWorker scheduling", () => { + it("does not run on construction and suppresses overlapping cycles", () => { + const { dispatchJob, reconcileJob } = new NotificationDispatchWorker() as unknown as TestableWorker; + + expect(dispatchJob.isActive).toBe(false); + expect(dispatchJob.waitForCompletion).toBe(true); + expect(reconcileJob.isActive).toBe(false); + expect(reconcileJob.waitForCompletion).toBe(true); + }); +}); diff --git a/apps/api/src/api/workers/notification-dispatch.worker.ts b/apps/api/src/api/workers/notification-dispatch.worker.ts new file mode 100644 index 000000000..34c231b64 --- /dev/null +++ b/apps/api/src/api/workers/notification-dispatch.worker.ts @@ -0,0 +1,69 @@ +import { CronJob } from "cron"; +import logger from "../../config/logger"; +import { dispatchPendingNotifications, reconcileMissedRampCompletedEmails } from "../services/email"; + +/** + * Sends queued email notifications and retries the ones that failed. + * The notifications table is the only send path, so a lost in-process call + * cannot lose a user-facing email. + * + * The hourly reconcile closes the one gap the table cannot close by itself: enqueuing at + * ramp completion is not atomic with the phase write, so a row that was never written has + * to be recovered from the completed ramps themselves. + */ +class NotificationDispatchWorker { + private dispatchJob: CronJob; + + private reconcileJob: CronJob; + + constructor(dispatchCronTime = "* * * * *", reconcileCronTime = "15 * * * *") { + this.dispatchJob = CronJob.from({ + cronTime: dispatchCronTime, + onTick: this.dispatch.bind(this), + start: false, + timeZone: "UTC", + waitForCompletion: true + }); + this.reconcileJob = CronJob.from({ + cronTime: reconcileCronTime, + onTick: this.reconcile.bind(this), + start: false, + timeZone: "UTC", + waitForCompletion: true + }); + } + + public start(): void { + logger.info("Starting notification dispatch worker"); + this.dispatchJob.start(); + 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 { + await dispatchPendingNotifications(); + } catch (error) { + const errorDetails = error instanceof Error ? (error.stack ?? error.message) : String(error); + logger.error(`Error during notification dispatch cycle: ${errorDetails}`); + } + } + + // eslint-disable-next-line class-methods-use-this + private async reconcile(): Promise { + try { + await reconcileMissedRampCompletedEmails(); + } catch (error) { + const errorDetails = error instanceof Error ? (error.stack ?? error.message) : String(error); + logger.error(`Error during completion email reconciliation cycle: ${errorDetails}`); + } + } +} + +export default NotificationDispatchWorker; diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index 38f96b188..eee503540 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -11,6 +11,7 @@ import morgan from "morgan"; import { converter, handler, notFound } from "../api/middlewares/error"; import { requestContext } from "../api/observability/requestContext"; import routes from "../api/routes/v1"; +import aveniaWebhookRoutes from "../api/routes/v1/avenia-webhook.route"; import { buildDashboardPreviewOriginRegex, parseDashboardOrigins } from "./corsOrigins"; import { config } from "./vars"; @@ -76,6 +77,12 @@ app.use(requestContext); // request logging. dev: console | production: file app.use(morgan(logs)); +// Mounted ahead of the JSON parser: Avenia signs the raw request body, and a payload +// that has been parsed and re-serialised does not reproduce those bytes exactly. +// Own, small limit: webhook events are a few KB, and this unauthenticated route should +// not buffer the 20mb the JSON API allows before the signature is even checked. +app.use("/v1/webhooks/avenia", bodyParser.raw({ limit: "100kb", type: "*/*" }), aveniaWebhookRoutes); + // parse body params and attach them to req.body app.use(bodyParser.json({ limit: REQUEST_BODY_LIMIT })); app.use(bodyParser.urlencoded({ extended: true, limit: REQUEST_BODY_LIMIT })); diff --git a/apps/api/src/config/vars.test.ts b/apps/api/src/config/vars.test.ts index 2549967ec..2d42c2957 100644 --- a/apps/api/src/config/vars.test.ts +++ b/apps/api/src/config/vars.test.ts @@ -140,4 +140,52 @@ describe("vars deployment environment validation", () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain("RECIPIENT_INVITE_MAX_DISCOUNT_BPS must be an integer between 0 and 300"); }); + + it("rejects an EVM destination network-fee margin below 100 percent", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS: "9999", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS must be an integer between 10000 and 30000"); + }); + + it("rejects a non-positive EVM destination execution-fee ceiling", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + EVM_DESTINATION_MAX_EXECUTION_FEE_USD: "0", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EVM_DESTINATION_MAX_EXECUTION_FEE_USD must be a positive number"); + }); + + it("rejects non-decimal EVM destination execution-fee ceilings during startup", async () => { + for (const invalidValue of ["0x10", "1e1"]) { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + EVM_DESTINATION_MAX_EXECUTION_FEE_USD: invalidValue, + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("EVM_DESTINATION_MAX_EXECUTION_FEE_USD must be a positive number"); + } + }); + + it("rejects non-decimal Mykobo fallback fees before returning strings to fee arithmetic", async () => { + const result = await importVarsWithEnv({ + DEPLOYMENT_ENV: "production", + MYKOBO_FALLBACK_DEPOSIT_FEE: "0x10", + MYKOBO_FALLBACK_WITHDRAW_FEE: "1", + MYKOBO_FEE_FALLBACK_ENABLED: "true", + NODE_ENV: "production" + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("MYKOBO_FALLBACK_DEPOSIT_FEE must be a non-negative number"); + }); }); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 610fba5be..870049ab6 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -23,6 +23,7 @@ interface SpreadsheetConfig { } type DeploymentEnv = "development" | "production" | "sandbox" | "staging" | "test"; +const DECIMAL_STRING_PATTERN = /^(?:\d+(?:\.\d+)?|\.\d+)$/; // Identifies which onramp flow this backend instance serves. Two backends // share one database; each ignores ramps/quotes belonging to the other flow. @@ -82,7 +83,7 @@ function readNonNegativeDecimalEnv(name: string): string { throw new Error(`${name} is required when MYKOBO_FEE_FALLBACK_ENABLED=true`); } const value = Number(rawValue); - if (!Number.isFinite(value) || value < 0) { + if (!DECIMAL_STRING_PATTERN.test(rawValue) || !Number.isFinite(value) || value < 0) { throw new Error(`${name} must be a non-negative number (got '${rawValue}')`); } return rawValue; @@ -104,6 +105,33 @@ function readFractionEnv(name: string, defaultValue: string): number { return value; } +function readPositiveDecimalEnv(name: string, defaultValue: string): string { + const rawValue = process.env[name] ?? defaultValue; + const trimmedValue = rawValue.trim(); + const value = Number(trimmedValue); + if (!DECIMAL_STRING_PATTERN.test(trimmedValue) || !Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a positive number`); + } + return trimmedValue; +} + +function readEvmDestinationNetworkFeeMarginBps(): number { + const name = "EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS"; + const rawValue = process.env[name] ?? "12000"; + const value = Number(rawValue.trim()); + if (!Number.isInteger(value) || value < 10_000 || value > 30_000 || rawValue.trim() === "") { + throw new Error(`${name} must be an integer between 10000 and 30000`); + } + return value; +} + +function readEmailAllowlist(): string[] { + return (process.env.EMAIL_RECIPIENT_ALLOWLIST || "") + .split(",") + .map(entry => entry.trim().toLowerCase()) + .filter(entry => entry.length > 0); +} + export const RECIPIENT_INVITE_DISCOUNT_HARD_CAP_BPS = 300; function readRecipientInviteDiscountLimit(): number { @@ -195,6 +223,19 @@ interface Config { alchemy: { apiKey: string | undefined; }; + avenia: { + // Public URL of this backend's /v1/webhooks/avenia receiver, used only by the + // subscription registration script. + webhookUrl: string | undefined; + }; + resend: { + apiKey: string | undefined; + fromAddress: string; + replyToAddress: string | undefined; + // Outside production, only these recipients receive mail; everything else is + // recorded as skipped. Empty means no recipient at all outside production. + recipientAllowlist: string[]; + }; slack: { webhookToken: string | undefined; userId: string | undefined; @@ -207,6 +248,11 @@ interface Config { defaults: { vortexEvmPayoutAddress: string | undefined; }; + evmDestinationGas: { + dynamicFundingEnabled: boolean; + maxExecutionFeeUsd: string; + networkFeeMarginBps: number; + }; } export const config: Config = { @@ -227,12 +273,28 @@ export const config: Config = { }, deploymentEnv: readDeploymentEnv(), env: nodeEnv, + evmDestinationGas: { + // Two-phase rollout guard: deploy readers/executors first, then enable quote + // production only after every worker understands funding program v2. + dynamicFundingEnabled: process.env.EVM_DYNAMIC_DESTINATION_FUNDING_ENABLED === "true", + maxExecutionFeeUsd: readPositiveDecimalEnv("EVM_DESTINATION_MAX_EXECUTION_FEE_USD", "5"), + networkFeeMarginBps: readEvmDestinationNetworkFeeMarginBps() + }, flowVariant: readFlowVariant(), integrations: { alchemy: { apiKey: process.env.ALCHEMY_API_KEY }, + avenia: { + webhookUrl: process.env.AVENIA_WEBHOOK_URL + }, + resend: { + apiKey: process.env.RESEND_API_KEY, + fromAddress: process.env.EMAIL_FROM_ADDRESS || "Vortex Finance ", + recipientAllowlist: readEmailAllowlist(), + replyToAddress: process.env.EMAIL_REPLY_TO_ADDRESS + }, slack: { userId: process.env.SLACK_USER_ID, webhookToken: process.env.SLACK_WEB_HOOK_TOKEN diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index 2a609a8ce..91da296ce 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -7,6 +7,7 @@ const SUBSIDY_MINIMUM_RATIO_FUND_UNITS = "5"; // 5 Subsidies considering maximum 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 @@ -41,6 +42,7 @@ export { 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, diff --git a/apps/api/src/database/062-create-email-notifications-table.test.ts b/apps/api/src/database/062-create-email-notifications-table.test.ts new file mode 100644 index 000000000..6ed681f6f --- /dev/null +++ b/apps/api/src/database/062-create-email-notifications-table.test.ts @@ -0,0 +1,33 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import sequelize from "../config/database"; +import EmailNotification, { NotificationStatus } from "../models/emailNotification.model"; +import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; +import { createTestRampState, createTestUser } from "../test-utils/factories"; +import { down, up } from "./migrations/062-create-email-notifications-table"; + +describe("062-create-email-notifications-table backfill", () => { + beforeAll(async () => { + await setupTestDatabase(); + await resetTestDatabase(); + }); + + it("tombstones pre-existing completed ramps so the reconcile sweep cannot mail them", async () => { + const user = await createTestUser(); + const completed = await createTestRampState({ currentPhase: "complete", userId: user.id }); + await createTestRampState({ currentPhase: "nablaSwap", userId: user.id }); + await createTestRampState({ currentPhase: "complete", userId: null }); + + // Re-run the migration against a database that already holds those ramps — + // the first-deploy scenario the backfill exists for. + const queryInterface = sequelize.getQueryInterface(); + await down(queryInterface); + await up(queryInterface); + + const rows = await EmailNotification.findAll(); + expect(rows).toHaveLength(1); + expect(rows[0].resourceId).toBe(completed.id); + expect(rows[0].userId).toBe(user.id); + expect(rows[0].status).toBe(NotificationStatus.Skipped); + expect(rows[0].lastError).toContain("Backfilled"); + }); +}); diff --git a/apps/api/src/database/migrations/062-create-email-notifications-table.ts b/apps/api/src/database/migrations/062-create-email-notifications-table.ts new file mode 100644 index 000000000..c169730d4 --- /dev/null +++ b/apps/api/src/database/migrations/062-create-email-notifications-table.ts @@ -0,0 +1,128 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +// Named `email_notifications`, not `notifications`: migration 043 already owns the +// `notifications` table for in-app notifications. This is the outbound-email queue. + +export async function up(queryInterface: QueryInterface): Promise { + await queryInterface.createTable("email_notifications", { + attempts: { + allowNull: false, + defaultValue: 0, + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + lastError: { + allowNull: true, + field: "last_error", + type: DataTypes.TEXT + }, + locale: { + allowNull: false, + type: DataTypes.STRING(10) + }, + nextAttemptAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "next_attempt_at", + type: DataTypes.DATE + }, + payload: { + allowNull: false, + defaultValue: {}, + type: DataTypes.JSONB + }, + // Kept as a plain string rather than an enum so a new upstream provider + // (Alfredpay) can be added without an ALTER TYPE migration. + provider: { + allowNull: false, + type: DataTypes.STRING(32) + }, + providerMessageId: { + allowNull: true, + field: "provider_message_id", + type: DataTypes.STRING(255) + }, + // Identifier of the upstream thing this notification is about (ramp id, KYB attempt id). + // NOT NULL together with provider/type so the dedupe index below actually holds: + // Postgres treats NULLs as distinct, so a nullable member would silently disable it. + resourceId: { + allowNull: false, + field: "resource_id", + type: DataTypes.STRING(255) + }, + sentAt: { + allowNull: true, + field: "sent_at", + type: DataTypes.DATE + }, + status: { + allowNull: false, + defaultValue: "pending", + type: DataTypes.STRING(16) + }, + type: { + allowNull: false, + type: DataTypes.STRING(64) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + userId: { + allowNull: false, + field: "user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + } + }); + + await queryInterface.addIndex("email_notifications", ["provider", "type", "resource_id"], { + name: "uniq_email_notifications_provider_type_resource", + unique: true + }); + + await queryInterface.addIndex("email_notifications", ["status", "next_attempt_at"], { + name: "idx_email_notifications_dispatch" + }); + + await queryInterface.addIndex("email_notifications", ["user_id"], { + name: "idx_email_notifications_user_id" + }); + + // Tombstone every ramp that completed before this table existed. The hourly + // reconciliation sweep re-enqueues any completed ramp without a row here, so an + // empty table on first deploy would mass-mail the entire history of completions. + await queryInterface.sequelize.query(` + INSERT INTO email_notifications + (id, provider, type, user_id, resource_id, locale, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at) + SELECT + uuid_generate_v4(), 'vortex', 'ramp_completed', user_id, id::text, 'en-US', '{}'::jsonb, + 'skipped', 0, NOW(), 'Backfilled at table creation: ramp completed before email notifications existed', NOW(), NOW() + FROM ramp_states + WHERE current_phase = 'complete' AND user_id IS NOT NULL + `); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeIndex("email_notifications", "idx_email_notifications_user_id"); + await queryInterface.removeIndex("email_notifications", "idx_email_notifications_dispatch"); + await queryInterface.removeIndex("email_notifications", "uniq_email_notifications_provider_type_resource"); + await queryInterface.dropTable("email_notifications"); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d16949384..15c93e98a 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -16,8 +16,11 @@ import { registerBlockFlowHandlers } from "./api/services/phases/blocks/register-handlers"; import { priceFeedService } from "./api/services/priceFeed.service"; +import AlfredpayStatusWorker from "./api/workers/alfredpay-status.worker"; import ApiClientEventsRetentionWorker from "./api/workers/api-client-events-retention.worker"; import CleanupWorker from "./api/workers/cleanup.worker"; +import KybStatusWorker from "./api/workers/kyb-status.worker"; +import NotificationDispatchWorker from "./api/workers/notification-dispatch.worker"; import RampRecoveryWorker from "./api/workers/ramp-recovery.worker"; import UnhandledPaymentWorker from "./api/workers/unhandled-payment.worker"; @@ -76,6 +79,16 @@ const initializeApp = async () => { new ApiClientEventsRetentionWorker().start(); new RampRecoveryWorker().start(); new UnhandledPaymentWorker().start(); + new NotificationDispatchWorker().start(); + // Both flow-variant backends share this database and these provider accounts. Give + // the replacement backend sole ownership of external status polling so the legacy + // grace-period backend does not make every Avenia/Alfredpay request a second time. + if (config.flowVariant === "mykobo") { + new KybStatusWorker().start(); + new AlfredpayStatusWorker().start(); + } else { + logger.info("Provider status workers are owned by the mykobo backend"); + } // Start AlfredPay limits refresh loop (daily; falls back to hardcoded if stale) AlfredpayLimitsService.getInstance().start(); diff --git a/apps/api/src/models/emailNotification.model.ts b/apps/api/src/models/emailNotification.model.ts new file mode 100644 index 000000000..783532e09 --- /dev/null +++ b/apps/api/src/models/emailNotification.model.ts @@ -0,0 +1,208 @@ +import { EmailNotificationType } from "@vortexfi/shared"; +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +// Upstream system a notification originates from. Stored as a plain string so +// adding a provider does not require a schema migration. +export enum NotificationProvider { + Alfredpay = "alfredpay", + Avenia = "avenia", + Vortex = "vortex" +} + +// The stored type values live in @vortexfi/shared: they are the wire contract with the +// dashboard's notification-preference toggles, which write prefs keyed by these strings. +// Re-exported under the model's historical name for the API's existing imports. +export { EmailNotificationType as NotificationType }; +type NotificationType = EmailNotificationType; + +export enum NotificationStatus { + Abandoned = "abandoned", + Failed = "failed", + Pending = "pending", + // Claimed by a dispatch cycle. Both flow-variant backends share one database, + // so a row must be claimed before sending or a user gets the same email twice. + Sending = "sending", + Sent = "sent", + Skipped = "skipped" +} + +// Identifies the event a notification was raised for. Backed by the unique index +// on (provider, type, resource_id), which is what makes enqueuing idempotent. +export interface NotificationKey { + provider: NotificationProvider; + type: NotificationType; + resourceId: string; +} + +export interface EmailNotificationAttributes { + id: string; + provider: NotificationProvider; + type: NotificationType; + userId: string; + resourceId: string; + locale: string; + payload: Record; + status: NotificationStatus; + attempts: number; + nextAttemptAt: Date; + sentAt: Date | null; + providerMessageId: string | null; + lastError: string | null; + createdAt: Date; + updatedAt: Date; +} + +export type EmailNotificationCreationAttributes = Optional< + EmailNotificationAttributes, + | "id" + | "createdAt" + | "updatedAt" + | "payload" + | "status" + | "attempts" + | "nextAttemptAt" + | "sentAt" + | "providerMessageId" + | "lastError" +>; + +class EmailNotification + extends Model + implements EmailNotificationAttributes +{ + declare id: string; + + declare provider: NotificationProvider; + + declare type: NotificationType; + + declare userId: string; + + declare resourceId: string; + + declare locale: string; + + declare payload: Record; + + declare status: NotificationStatus; + + declare attempts: number; + + declare nextAttemptAt: Date; + + declare sentAt: Date | null; + + declare providerMessageId: string | null; + + declare lastError: string | null; + + declare createdAt: Date; + + declare updatedAt: Date; +} + +EmailNotification.init( + { + attempts: { + allowNull: false, + defaultValue: 0, + type: DataTypes.INTEGER + }, + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + lastError: { + allowNull: true, + field: "last_error", + type: DataTypes.TEXT + }, + locale: { + allowNull: false, + type: DataTypes.STRING(10) + }, + nextAttemptAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "next_attempt_at", + type: DataTypes.DATE + }, + payload: { + allowNull: false, + defaultValue: {}, + type: DataTypes.JSONB + }, + provider: { + allowNull: false, + type: DataTypes.STRING(32) + }, + providerMessageId: { + allowNull: true, + field: "provider_message_id", + type: DataTypes.STRING(255) + }, + resourceId: { + allowNull: false, + field: "resource_id", + type: DataTypes.STRING(255) + }, + sentAt: { + allowNull: true, + field: "sent_at", + type: DataTypes.DATE + }, + status: { + allowNull: false, + defaultValue: NotificationStatus.Pending, + type: DataTypes.STRING(16) + }, + type: { + allowNull: false, + type: DataTypes.STRING(64) + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + userId: { + allowNull: false, + field: "user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + } + }, + { + indexes: [ + { + fields: ["provider", "type", "resource_id"], + name: "uniq_email_notifications_provider_type_resource", + unique: true + }, + { + fields: ["status", "next_attempt_at"], + name: "idx_email_notifications_dispatch" + } + ], + modelName: "EmailNotification", + sequelize, + tableName: "email_notifications", + timestamps: true + } +); + +export default EmailNotification; diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index 8ea79bd55..ead5fcb46 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -3,6 +3,7 @@ import Anchor from "./anchor.model"; import ApiClientEvent from "./apiClientEvent.model"; import ApiCredential from "./apiCredential.model"; import CustomerEntity from "./customerEntity.model"; +import EmailNotification from "./emailNotification.model"; import FinancialOperation from "./financialOperation.model"; import KycCase from "./kycCase.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; @@ -40,6 +41,9 @@ QuoteTicket.belongsTo(User, { as: "user", foreignKey: "userId" }); User.hasMany(RampState, { as: "rampStates", foreignKey: "userId" }); RampState.belongsTo(User, { as: "user", foreignKey: "userId" }); +User.hasMany(EmailNotification, { as: "emailNotifications", foreignKey: "userId" }); +EmailNotification.belongsTo(User, { as: "user", foreignKey: "userId" }); + User.hasMany(ProfilePartnerAssignment, { as: "partnerAssignments", foreignKey: "userId" }); ProfilePartnerAssignment.belongsTo(User, { as: "user", foreignKey: "userId" }); @@ -98,6 +102,7 @@ const models = { ApiClientEvent, ApiCredential, CustomerEntity, + EmailNotification, FinancialOperation, KycCase, MaintenanceSchedule, diff --git a/apps/api/src/models/kycCase.model.ts b/apps/api/src/models/kycCase.model.ts index 14f944ffe..d5451ec4a 100644 --- a/apps/api/src/models/kycCase.model.ts +++ b/apps/api/src/models/kycCase.model.ts @@ -1,5 +1,6 @@ import { DataTypes, Model, Optional } from "sequelize"; import sequelize from "../config/database"; +import type CustomerEntity from "./customerEntity.model"; import type { ProviderName, VerificationStatus } from "./providerCustomer.model"; export type KycCaseType = "kyc" | "kyb"; @@ -56,6 +57,9 @@ class KycCase extends Model implem declare rejectedAt: Date | null; declare createdAt: Date; declare updatedAt: Date; + + // Association helper + declare customerEntity?: CustomerEntity; } KycCase.init( diff --git a/apps/api/src/scripts/auth-email-templates.ts b/apps/api/src/scripts/auth-email-templates.ts new file mode 100644 index 000000000..4bb417a0b --- /dev/null +++ b/apps/api/src/scripts/auth-email-templates.ts @@ -0,0 +1,98 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { EmailBody, renderHtml } from "../api/services/email/templates/layout"; + +// Supabase renders auth emails itself from Go text/template, so these cannot import the +// layout at send time — they are generated here and pasted into the Supabase Dashboard +// (Authentication -> Emails). Re-run this script after any layout.ts change and re-paste. +const OUTPUT_DIR = join(__dirname, "../../.email-previews/supabase"); + +// supabase.service.ts passes `options.data = { locale }` on signInWithOtp, so the locale is on +// the user's metadata. The spelling list matches the one already live in the Dashboard; users +// created before that call shipped have no locale at all and fall through to English. +const BRAZIL_SPELLINGS = ["pt-BR", "pt_BR", "pt-br", "pt_br", "br", "BR"]; + +const PREAMBLE = `{{ $locale := index .Data "locale" }} +{{ $isBrazil := or ${BRAZIL_SPELLINGS.map(spelling => `(eq $locale "${spelling}")`).join(" ")} }} +`; + +/** Emits a Go conditional so one template serves both locales. */ +function i18n(en: string, pt: string): string { + return `{{ if $isBrazil }}${pt}{{ else }}${en}{{ end }}`; +} + +/** + * The Dashboard's subject field is a separate Go template that never sees the body's + * preamble, so the subject inlines the whole locale conditional. + */ +function subjectI18n(en: string, pt: string): string { + const isBrazil = `or ${BRAZIL_SPELLINGS.map(spelling => `(eq (index .Data "locale") "${spelling}")`).join(" ")}`; + return `{{ if ${isBrazil} }}${pt}{{ else }}${en}{{ end }}`; +} + +// The site serves both locales; "pt" is the path prefix the frontend maps to pt-BR. +const LINKS = [ + { + href: i18n("https://www.vortexfinance.co/en/terms-and-conditions", "https://www.vortexfinance.co/pt/terms-and-conditions"), + label: i18n("Terms of Service", "Termos de Serviço") + }, + { + href: i18n("https://www.vortexfinance.co/en/privacy-policy", "https://www.vortexfinance.co/pt/privacy-policy"), + label: i18n("Privacy Policy", "Política de Privacidade") + } +]; + +// otp_expiry in supabase/config.toml is 3600s. +const EXPIRY = { label: i18n("Expires in", "Expira em"), value: i18n("1 hour", "1 hora") }; + +const TEMPLATES: { name: string; subject: string; body: EmailBody }[] = [ + { + body: { + details: [EXPIRY], + heading: i18n("Welcome back", "Bem-vindo(a) de volta"), + highlight: { + code: true, + label: i18n("Your verification code", "Seu código de verificação"), + value: "{{ .Token }}" + }, + intro: i18n("Enter this code to sign in to your Vortex account.", "Digite este código para entrar na sua conta Vortex."), + links: LINKS, + outro: i18n( + "If you didn't request this code, you can safely ignore this email.", + "Se você não solicitou este código, pode ignorar este email com segurança." + ) + }, + name: "magic_link", + subject: subjectI18n("Your Vortex Verification Code", "Seu código de verificação Vortex") + }, + { + body: { + details: [EXPIRY], + heading: i18n("Confirm your email address", "Confirme seu endereço de email"), + highlight: { + code: true, + label: i18n("Your confirmation code", "Seu código de confirmação"), + value: "{{ .Token }}" + }, + intro: i18n( + "Thanks for signing up for Vortex. Enter this code to verify your account.", + "Obrigado por se cadastrar na Vortex. Digite este código para verificar sua conta." + ), + links: LINKS, + outro: i18n( + "If you didn't create a Vortex account, you can safely ignore this email.", + "Se você não criou uma conta na Vortex, pode ignorar este email com segurança." + ) + }, + name: "signup", + subject: subjectI18n("Confirm your signup", "Confirme seu cadastro na Vortex") + } +]; + +mkdirSync(OUTPUT_DIR, { recursive: true }); + +for (const { name, subject, body } of TEMPLATES) { + const path = join(OUTPUT_DIR, `${name}.html`); + writeFileSync(path, PREAMBLE + renderHtml(body)); + console.log(`${name} subject: ${subject}\n ${path}`); +} diff --git a/apps/api/src/scripts/preview-emails.ts b/apps/api/src/scripts/preview-emails.ts new file mode 100644 index 000000000..5120a1cdc --- /dev/null +++ b/apps/api/src/scripts/preview-emails.ts @@ -0,0 +1,61 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderRampCompleted } from "../api/services/email/templates/ramp-completed"; +import { renderVerificationStatus } from "../api/services/email/templates/verification-status"; +import { EmailLocale, RenderedEmail, SUPPORTED_LOCALES, VerificationKind } from "../api/services/email/types"; + +const OUTPUT_DIR = join(__dirname, "../../.email-previews"); + +const RAMP_COMPLETED_SAMPLE = { + completedAt: "2026-07-29T14:32:00.000Z", + fiatAmount: "1,250.00", + fiatCurrency: "BRL", + network: "polygon", + rampId: "6b1f0c2e-9a4d-4f83-9d1a-2c7f5e8b1234", + rampType: "sell" as const, + tokenAmount: "230.45", + tokenSymbol: "USDC" +}; + +const VERIFICATION_SAMPLES: Record = { + approved: { reason: null, updatedAt: "2026-07-29T14:32:00.000Z" }, + expired: { reason: null, updatedAt: "2026-07-29T14:32:00.000Z" }, + rejected: { reason: "The submitted company registration document was not legible.", updatedAt: "2026-07-29T14:32:00.000Z" } +}; + +function previews(locale: EmailLocale): { name: string; email: RenderedEmail }[] { + return [ + { email: renderRampCompleted(locale, RAMP_COMPLETED_SAMPLE), name: "ramp-completed" }, + ...(Object.keys(VERIFICATION_SAMPLES) as VerificationKind[]).map(kind => ({ + email: renderVerificationStatus(kind, locale, VERIFICATION_SAMPLES[kind]), + name: `verification-${kind}` + })) + ]; +} + +mkdirSync(OUTPUT_DIR, { recursive: true }); + +const links: string[] = []; + +for (const locale of SUPPORTED_LOCALES) { + for (const { name, email } of previews(locale)) { + const fileName = `${name}.${locale}.html`; + writeFileSync(join(OUTPUT_DIR, fileName), email.html); + writeFileSync(join(OUTPUT_DIR, `${name}.${locale}.txt`), email.text); + links.push(`
  • ${fileName}${email.subject}
  • `); + } +} + +writeFileSync( + join(OUTPUT_DIR, "index.html"), + ` + + +

    Vortex email previews

    +
      ${links.join("\n ")}
    + +` +); + +console.log(`Wrote ${links.length} previews to ${OUTPUT_DIR}`); +console.log(`Open ${join(OUTPUT_DIR, "index.html")}`); diff --git a/apps/api/src/scripts/register-avenia-webhook.ts b/apps/api/src/scripts/register-avenia-webhook.ts new file mode 100644 index 000000000..6b93e5405 --- /dev/null +++ b/apps/api/src/scripts/register-avenia-webhook.ts @@ -0,0 +1,48 @@ +import { AveniaWebhookSubscription, BrlaApiService } from "@vortexfi/shared"; +import { config } from "../config/vars"; + +/** + * Registers (or repoints) this backend's Avenia webhook subscription. + * + * Run once per environment. Avenia allows at most 3 webhooks per account, so an + * existing registration for the same URL is patched rather than duplicated. + * + * Subscribes with "*" deliberately: Avenia documents no KYB subscription, and the + * wildcard is the only setting that can deliver company verification events if they + * exist at all. Events we do not handle are acknowledged and dropped by the receiver. + */ +async function main(): Promise { + const webhookUrl = config.integrations.avenia.webhookUrl; + + if (!webhookUrl) { + throw new Error("AVENIA_WEBHOOK_URL is not set"); + } + + if (!webhookUrl.startsWith("https://")) { + throw new Error(`AVENIA_WEBHOOK_URL must be https, got ${webhookUrl}`); + } + + const brlaApiService = BrlaApiService.getInstance(); + const subscriptions = [AveniaWebhookSubscription.All]; + + const { webhooks } = await brlaApiService.listWebhooks(); + const existing = webhooks?.find(webhook => webhook.url === webhookUrl); + + if (existing) { + await brlaApiService.updateWebhook(existing.id, webhookUrl, subscriptions); + console.log(`Updated Avenia webhook ${existing.id} -> ${webhookUrl} ${JSON.stringify(subscriptions)}`); + return; + } + + if (webhooks && webhooks.length >= 3) { + throw new Error(`Avenia allows 3 webhooks; ${webhooks.length} are registered: ${webhooks.map(w => w.url).join(", ")}`); + } + + const created = await brlaApiService.createWebhook(webhookUrl, subscriptions); + console.log(`Registered Avenia webhook ${created.webhookId} -> ${webhookUrl} ${JSON.stringify(subscriptions)}`); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/apps/api/src/test-utils/factories.ts b/apps/api/src/test-utils/factories.ts index c032e476f..56b4e1de2 100644 --- a/apps/api/src/test-utils/factories.ts +++ b/apps/api/src/test-utils/factories.ts @@ -26,6 +26,8 @@ import RampState, { type RampStateAttributes } from "../models/rampState.model"; import User from "../models/user.model"; let sequence = 0; +const TEST_VORTEX_EVM_PAYOUT_ADDRESS = "0x000000000000000000000000000000000000fee5"; + function nextSeq(): number { return ++sequence; } @@ -162,11 +164,17 @@ export async function createTestQuote(overrides: Partial /** * Baseline configuration the quote pipeline expects in every environment: * the "vortex" partner rows carrying the default platform fee (zero here; - * tests that assert fee math override via createTestPartner). + * tests that assert fee math override via createTestPartner). The payout + * address is required whenever a corridor prices a positive network fee. */ export async function seedVortexPartners(): Promise { for (const rampType of [RampDirection.BUY, RampDirection.SELL]) { - await createTestPartner({ displayName: "Vortex", name: "vortex", rampType }); + await createTestPartner({ + displayName: "Vortex", + name: "vortex", + payoutAddressEvm: TEST_VORTEX_EVM_PAYOUT_ADDRESS, + rampType + }); } } diff --git a/apps/api/src/test-utils/fake-world/fake-auth.ts b/apps/api/src/test-utils/fake-world/fake-auth.ts index 77a09079b..dca78b266 100644 --- a/apps/api/src/test-utils/fake-world/fake-auth.ts +++ b/apps/api/src/test-utils/fake-world/fake-auth.ts @@ -27,6 +27,7 @@ export interface FakeSupabaseAuth { export function installFakeSupabaseAuth(): FakeSupabaseAuth { const originals = { checkUserExists: SupabaseAuthService.checkUserExists, + getUserLocale: SupabaseAuthService.getUserLocale, refreshToken: SupabaseAuthService.refreshToken, sendOTP: SupabaseAuthService.sendOTP, verifyOTP: SupabaseAuthService.verifyOTP, @@ -57,6 +58,10 @@ export function installFakeSupabaseAuth(): FakeSupabaseAuth { SupabaseAuthService.checkUserExists = async (email: string) => existingEmails.has(email); + // Notification enqueues resolve the recipient locale; without this stub every enqueue in + // an integration test dials the neutralized Supabase host and falls back via its error path. + SupabaseAuthService.getUserLocale = async () => "en-US"; + SupabaseAuthService.sendOTP = async (email: string) => { otpRequests.push(email); pendingOtps.add(email); @@ -93,6 +98,7 @@ export function installFakeSupabaseAuth(): FakeSupabaseAuth { restore: () => { SupabaseAuthService.verifyToken = originals.verifyToken; SupabaseAuthService.checkUserExists = originals.checkUserExists; + SupabaseAuthService.getUserLocale = originals.getUserLocale; SupabaseAuthService.sendOTP = originals.sendOTP; SupabaseAuthService.verifyOTP = originals.verifyOTP; SupabaseAuthService.refreshToken = originals.refreshToken; diff --git a/apps/api/src/test-utils/fake-world/fake-evm.ts b/apps/api/src/test-utils/fake-world/fake-evm.ts index 4f4c6c426..0a2e457e0 100644 --- a/apps/api/src/test-utils/fake-world/fake-evm.ts +++ b/apps/api/src/test-utils/fake-world/fake-evm.ts @@ -5,6 +5,9 @@ export interface RecordedEvmTx { from?: string; to?: string; data?: string; + gas?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; value?: bigint; serialized?: string; hash: `0x${string}`; @@ -40,6 +43,7 @@ const MAX_UINT256 = 2n ** 256n - 1n; */ export class FakeEvm { private balances = new Map(); + private feeEstimates = new Map(); private nonces = new Map(); private txCounter = 0; readonly sentTransactions: RecordedEvmTx[] = []; @@ -57,6 +61,9 @@ export class FakeEvm { sendFailureMessage = "FakeEvm: scripted transaction failure"; /** Hashes whose receipts report a mined-but-reverted transaction. */ readonly revertedReceiptHashes = new Set(); + baseL1FeeRaw = 8_000_000_000_000n; + baseL1FeeUpperBoundRaw = 10_000_000_000_000n; + arbitrumL1GasComponent = 520n; private key(network: string, token: string, holder: string): string { return `${network}:${token.toLowerCase()}:${holder.toLowerCase()}`; @@ -78,6 +85,10 @@ export class FakeEvm { return this.balances.get(this.key(network, "native", holder)) ?? 0n; } + setFeeEstimate(network: string, maxFeePerGas: bigint, maxPriorityFeePerGas = maxFeePerGas): void { + this.feeEstimates.set(network, { maxFeePerGas, maxPriorityFeePerGas }); + } + /** * Records a transaction as if a user wallet had broadcast it (outside the * EvmClientManager seam) and returns its hash — for corridors where the @@ -116,6 +127,12 @@ export class FakeEvm { return MAX_UINT256; case "getAmountOut": return this.onGetAmountOut(network, params.address, params.args?.[0] as bigint); + case "getL1Fee": + return this.baseL1FeeRaw; + case "getL1FeeUpperBound": + return this.baseL1FeeUpperBoundRaw; + case "gasEstimateL1Component": + return [this.arbitrumL1GasComponent, 1_000_000_000n, 1_000_000_000n] as const; default: throw new Error( `FakeEvm: readContract '${params.functionName}' on ${network} is not implemented — ` + @@ -162,7 +179,8 @@ export class FakeEvm { // Dry-runs (eth_call) succeed generically; scripted failures go through failNextSends instead. call: async () => ({ data: "0x" as `0x${string}` }), chain: { id: CHAIN_IDS[network] ?? 0, name: network, nativeCurrency: { decimals: 18, name: "Ether", symbol: "ETH" } }, - estimateFeesPerGas: async () => ({ maxFeePerGas: 1_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n }), + estimateFeesPerGas: async () => + this.feeEstimates.get(network) ?? { maxFeePerGas: 1_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n }, estimateGas: async () => 21_000n, getBalance: async ({ address }: { address: string }) => this.nativeBalance(network, address), getGasPrice: async () => 1_000_000_000n, @@ -195,8 +213,24 @@ export class FakeEvm { return this.makeUnimplementedProxy( { account, - sendTransaction: async (params: { to?: string; data?: string; value?: bigint }) => - this.recordTransaction({ data: params.data, from: account.address, network, to: params.to, value: params.value }), + sendTransaction: async (params: { + to?: string; + data?: string; + gas?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + value?: bigint; + }) => + this.recordTransaction({ + data: params.data, + from: account.address, + gas: params.gas, + maxFeePerGas: params.maxFeePerGas, + maxPriorityFeePerGas: params.maxPriorityFeePerGas, + network, + to: params.to, + value: params.value + }), writeContract: async (params: { address: string; functionName: string }) => this.recordTransaction({ data: params.functionName, from: account.address, network, to: params.address }) }, diff --git a/apps/api/src/test-utils/fake-world/fake-prices.ts b/apps/api/src/test-utils/fake-world/fake-prices.ts index ef4157ebb..dee740281 100644 --- a/apps/api/src/test-utils/fake-world/fake-prices.ts +++ b/apps/api/src/test-utils/fake-world/fake-prices.ts @@ -10,6 +10,8 @@ import { priceFeedService } from "../../api/services/priceFeed.service"; export class FakePrices { /** CoinGecko-style token id → USD price. */ cryptoUsd: Record = { + "avalanche-2": 25, + binancecoin: 600, ethereum: 2500, moonbeam: 0.08, "polygon-ecosystem-token": 0.5, @@ -18,14 +20,24 @@ export class FakePrices { /** Fiat/RampCurrency code (lowercased) → units of that currency per 1 USD. */ perUsd: Record = { ars: 1000, + // Consistent with cryptoUsd["avalanche-2"] = 25. + avax: 0.04, + // Consistent with cryptoUsd["binancecoin"] = 600. + bnb: 1 / 600, brl: 5, // BRLA is the on-chain twin of BRL and shares its peg. brla: 5, cop: 4000, + // Consistent with cryptoUsd["ethereum"] = 2500. + eth: 1 / 2500, eur: 0.9, + // Consistent with cryptoUsd["moonbeam"] = 0.08. + glmr: 12.5, // Consistent with cryptoUsd["polygon-ecosystem-token"] = 0.5. matic: 2, mxn: 17, + // Alias used by newer Polygon clients for the same native asset. + pol: 2, usd: 1, usdc: 1, "usdc.e": 1, @@ -49,7 +61,12 @@ export class FakePrices { } } -type PatchedMethods = "getCryptoPrice" | "getFiatToUsdExchangeRate" | "getUsdToFiatExchangeRate" | "convertCurrency"; +type PatchedMethods = + | "getCryptoPrice" + | "getFiatToUsdExchangeRate" + | "getUsdToFiatExchangeRate" + | "getUsdToFiatExchangeRateSnapshot" + | "convertCurrency"; export function installFakePrices(): { fakePrices: FakePrices; restore: () => void } { const fakePrices = new FakePrices(); @@ -57,13 +74,22 @@ export function installFakePrices(): { fakePrices: FakePrices; restore: () => vo convertCurrency: priceFeedService.convertCurrency, getCryptoPrice: priceFeedService.getCryptoPrice, getFiatToUsdExchangeRate: priceFeedService.getFiatToUsdExchangeRate, - getUsdToFiatExchangeRate: priceFeedService.getUsdToFiatExchangeRate + getUsdToFiatExchangeRate: priceFeedService.getUsdToFiatExchangeRate, + getUsdToFiatExchangeRateSnapshot: priceFeedService.getUsdToFiatExchangeRateSnapshot }; priceFeedService.getCryptoPrice = async (tokenId: string) => fakePrices.getCryptoUsd(tokenId); priceFeedService.getFiatToUsdExchangeRate = async (fromCurrency: RampCurrency) => new Big(1).div(fakePrices.getPerUsd(fromCurrency as string)); priceFeedService.getUsdToFiatExchangeRate = async (toCurrency: RampCurrency) => fakePrices.getPerUsd(toCurrency as string); + priceFeedService.getUsdToFiatExchangeRateSnapshot = async (toCurrency: RampCurrency) => { + const normalizedCurrency = toCurrency.toLowerCase(); + return { + observedAt: new Date(0), + rate: fakePrices.getPerUsd(normalizedCurrency), + source: normalizedCurrency === "usd" ? "identity" : ["brl", "cop"].includes(normalizedCurrency) ? "binance" : "fastforex" + }; + }; priceFeedService.convertCurrency = async ( amount: string, fromCurrency: RampCurrency, diff --git a/apps/api/src/test-utils/fake-world/fake-squidrouter.ts b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts index ab71a8df7..06cb4acbc 100644 --- a/apps/api/src/test-utils/fake-world/fake-squidrouter.ts +++ b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts @@ -2,6 +2,10 @@ import { mock } from "bun:test"; import type { RouteParams } from "@vortexfi/shared"; import * as shared from "@vortexfi/shared"; +// Snapshot before any mock.module call: bun mutates the imported namespace in place, +// so restore() spreading `shared` afterwards would reinstall the fake, not the real fns. +const sharedReal = { ...shared }; + /** * Fake SquidRouter route source. getRoute is a plain function export of * @vortexfi/shared (not a singleton), so it is replaced via mock.module with @@ -74,7 +78,7 @@ export function installFakeSquidRouter(): { fakeSquidRouter: FakeSquidRouter; re return { fakeSquidRouter, restore: () => { - mock.module("@vortexfi/shared", () => ({ ...shared })); + mock.module("@vortexfi/shared", () => sharedReal); } }; } diff --git a/apps/api/src/test-utils/preload.ts b/apps/api/src/test-utils/preload.ts index 21fefb637..32546be4d 100644 --- a/apps/api/src/test-utils/preload.ts +++ b/apps/api/src/test-utils/preload.ts @@ -8,6 +8,7 @@ */ if (!process.env.RUN_LIVE_TESTS) { process.env.NODE_ENV = "test"; + process.env.EVM_DYNAMIC_DESTINATION_FUNDING_ENABLED ??= "true"; process.env.DEPLOYMENT_ENV = "test"; process.env.FLOW_VARIANT = process.env.FLOW_VARIANT || "mykobo"; diff --git a/apps/api/src/tests/aaa-leak-probe.test.ts b/apps/api/src/tests/aaa-leak-probe.test.ts index fcb45c855..b76058717 100644 --- a/apps/api/src/tests/aaa-leak-probe.test.ts +++ b/apps/api/src/tests/aaa-leak-probe.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { ApiManager, BrlaApiService, EvmClientManager, MykoboApiService } from "@vortexfi/shared"; +import { ApiManager, BrlaApiService, EvmClientManager, getRoute, MykoboApiService } from "@vortexfi/shared"; import QuoteTicket from "../models/quoteTicket.model"; import RampState from "../models/rampState.model"; @@ -33,7 +33,27 @@ describe("leak canary: no test file leaked a singleton patch", () => { expect(ApiManager.getInstance.name, "ApiManager.getInstance was left faked").toBe("getInstance"); }); + it("service singletons are real classes, not object-literal module stubs", () => { + // The name check above misses `{ getInstance: () => … }`: a method shorthand or + // property arrow in an object literal *infers* the name "getInstance". A leaked + // mock.module stub of that shape only fails the class-ness check. + for (const [name, ctor] of [ + ["EvmClientManager", EvmClientManager], + ["BrlaApiService", BrlaApiService], + ["MykoboApiService", MykoboApiService], + ["ApiManager", ApiManager] + ] as const) { + expect(typeof ctor, `${name} was replaced by a non-class module stub`).toBe("function"); + } + }); + it("global fetch is not a leftover fetch guard", () => { expect(globalThis.fetch.name, "the fetch guard was left installed").toBe("fetch"); }); + + it("shared getRoute is not a leftover FakeSquidRouter stub", () => { + // The fake's replacement is an arrow closing over its fakeSquidRouter instance; the + // name and typeof checks can't tell them apart, but the source can. + expect(String(getRoute), "getRoute was left pointing at the FakeSquidRouter").not.toContain("fakeSquidRouter"); + }); }); diff --git a/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts b/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts index 561866250..e2e7b937a 100644 --- a/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts +++ b/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts @@ -7,6 +7,7 @@ import { AlfredpayKycStatus } from "@vortexfi/shared"; import { createAlfredpayCustomer } from "../api/services/alfredpay/alfredpay-customer.service"; +import EmailNotification, { NotificationProvider, NotificationType } from "../models/emailNotification.model"; import KycCase from "../models/kycCase.model"; import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; @@ -23,6 +24,7 @@ import { startTestApp, type TestApp } from "../test-utils/test-app"; let api: TestApp; let fakeAuth: FakeSupabaseAuth; const realGetInstance = AlfredpayApiService.getInstance; +const realNotificationFindOrCreate = EmailNotification.findOrCreate; beforeAll(async () => { await setupTestDatabase(); @@ -41,6 +43,7 @@ beforeEach(async () => { afterEach(() => { AlfredpayApiService.getInstance = realGetInstance; + EmailNotification.findOrCreate = realNotificationFindOrCreate; }); function authHeaders(token: string): Record { @@ -440,6 +443,59 @@ describe("Alfredpay KYB PENDING submission", () => { expect(customer?.statusExternal).toBe("PENDING"); }); + it("queues a terminal KYB outcome before getKycStatus stops polling the customer", async () => { + const { token, user } = await createBusinessCustomer("kyb-completed-status@example.com"); + + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.COMPLETED, updatedAt: "2026-08-06T10:00:00Z" })), + getLastKybSubmission: mock(async () => ({ submissionId: "kyb-sub-completed" })) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/getKycStatus?country=CO&type=BUSINESS", { + headers: authHeaders(token) + }); + expect(response.status).toBe(200); + + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + expect(customer?.status).toBe(VerificationStatus.Approved); + + const notification = await EmailNotification.findOne({ + where: { + provider: NotificationProvider.Alfredpay, + resourceId: "kyb-sub-completed", + type: NotificationType.VerificationApproved + } + }); + expect(notification).not.toBeNull(); + expect(notification?.userId).toBe(user.id); + expect(notification?.payload).toEqual({ reason: null, subject: "business", updatedAt: "2026-08-06T10:00:00Z" }); + }); + + it("does not persist a terminal status when its notification cannot be enqueued", async () => { + const { token } = await createBusinessCustomer("kyb-enqueue-failure@example.com"); + EmailNotification.findOrCreate = (async () => { + throw new Error("queue unavailable"); + }) as unknown as typeof EmailNotification.findOrCreate; + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybStatus: mock(async () => ({ status: AlfredpayKycStatus.COMPLETED, updatedAt: "2026-08-06T10:00:00Z" })), + getLastKybSubmission: mock(async () => ({ submissionId: "kyb-sub-retry" })) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/getKycStatus?country=CO&type=BUSINESS", { + headers: authHeaders(token) + }); + expect(response.status).toBe(500); + + const customer = await ProviderCustomer.findOne({ where: { providerCustomerId: "ap-kyb-pending" } }); + expect(customer?.status).toBe(VerificationStatus.Started); + }); + it("normalizes a lowercase provider status: re-submit updates the pending submission in place", async () => { const { token } = await createBusinessCustomer("kyb-lowercase-resubmit@example.com"); diff --git a/apps/api/src/tests/contracts/avenia.contract.test.ts b/apps/api/src/tests/contracts/avenia.contract.test.ts index 6ee19da5d..1f8e48567 100644 --- a/apps/api/src/tests/contracts/avenia.contract.test.ts +++ b/apps/api/src/tests/contracts/avenia.contract.test.ts @@ -7,13 +7,16 @@ * pre-provisioned, KYC-approved sandbox subaccount (see .env.example): * * - AVENIA_CONTRACT_SUBACCOUNT_ID + * - AVENIA_CONTRACT_WEBHOOK_URL (temporary webhook-management lifecycle) * - * Per PRD, only one transaction (a PIX pay-in ticket, which expires unpaid) is - * created per run. Payout tickets are covered hermetically only — creating one - * live would move BRLA balance, and reading one needs the id of a real payout. + * Per PRD, only one transaction (a PIX pay-in ticket, which expires unpaid) and + * one temporary webhook are created per run. The webhook is deleted in `finally`. + * Payout tickets are covered hermetically only — creating one live would move + * BRLA balance, and reading one needs the id of a real payout. * `createOnchainSwapQuote`/`createOnchainSwapTicket`/`getMainAccountBalance`/ * `getAveniaSwapTicket` have no production consumers and are deliberately uncovered. */ +import { randomUUID } from "node:crypto"; import { describe, expect, test } from "bun:test"; import { aveniaAccountBalanceSchema, @@ -25,6 +28,9 @@ import { aveniaPixInputTicketSchema, aveniaPixKeyDataSchema, aveniaQuoteResponseSchema, + AveniaWebhookSubscription, + aveniaWebhookRegistrationSchema, + aveniaWebhooksListSchema, BlockchainSendMethod, BrlaApiService, BrlaCurrency, @@ -36,10 +42,22 @@ import { FakeBrla } from "../../test-utils/fake-world/fake-anchors"; const RUN_LIVE = !!process.env.RUN_LIVE_TESTS; const HAS_CREDS = !!(process.env.BRLA_API_KEY && process.env.BRLA_PRIVATE_KEY); const SUBACCOUNT_ID = process.env.AVENIA_CONTRACT_SUBACCOUNT_ID; +const WEBHOOK_URL = process.env.AVENIA_CONTRACT_WEBHOOK_URL; if (RUN_LIVE && !HAS_CREDS) { console.warn("[contract:live] Avenia live half skipped: BRLA_API_KEY/BRLA_PRIVATE_KEY not set"); } +if (RUN_LIVE && HAS_CREDS && !WEBHOOK_URL) { + console.warn("[contract:live] Avenia webhook lifecycle skipped: AVENIA_CONTRACT_WEBHOOK_URL not set"); +} + +async function requireLive(label: string, call: () => Promise): Promise { + const result = await runLive(label, call); + if (result === null) { + throw new Error(`${label} did not complete; webhook management has not been verified`); + } + return result; +} // Mirrors OnRampInitializeAveniaEngine / prepareOnrampBrlTransactions: BRL arrives // via PIX and lands as BRLA on the (sub)account's internal balance. @@ -192,6 +210,72 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Avenia external API contract — live" }, 120_000 ); + + test.skipIf(!WEBHOOK_URL)( + "POST + GET /notifications/webhooks register a webhook that can be deleted", + async () => { + const contractUrl = new URL(WEBHOOK_URL as string); + contractUrl.searchParams.set("contractRun", randomUUID()); + const webhookUrl = contractUrl.toString(); + const subscriptions = [AveniaWebhookSubscription.All]; + let webhookId: string | null = null; + + try { + const before = aveniaWebhooksListSchema.parse( + await requireLive("avenia listWebhooks (before registration)", () => api().listWebhooks()) + ); + + // A previous run that died between create and delete (runner crash, cancelled job) + // leaks its webhook; with the hard 3-slot sandbox cap that would fail every later + // run until someone cleans up by hand. Reclaim marked leftovers first. + for (const stale of before.webhooks.filter(webhook => webhook.url.includes("contractRun="))) { + console.warn(`[contract:live] deleting stale contract-test webhook ${stale.id} (${stale.url})`); + await requireLive("avenia deleteWebhook (stale contract webhook)", () => api().deleteWebhook(stale.id)); + } + + const occupied = before.webhooks.filter(webhook => !webhook.url.includes("contractRun=")).length; + if (occupied >= 3) { + throw new Error(`Avenia sandbox already has ${occupied} webhooks; no free contract-test slot`); + } + + const created = await requireLive("avenia createWebhook", () => api().createWebhook(webhookUrl, subscriptions)); + webhookId = typeof created.webhookId === "string" ? created.webhookId : null; + const registration = aveniaWebhookRegistrationSchema.parse(created); + webhookId = registration.webhookId; + + const after = aveniaWebhooksListSchema.parse( + await requireLive("avenia listWebhooks (after registration)", () => api().listWebhooks()) + ); + expect(after.webhooks).toContainEqual( + expect.objectContaining({ id: webhookId, subscriptions, url: webhookUrl }) + ); + } finally { + if (!webhookId) { + try { + const current = aveniaWebhooksListSchema.parse(await api().listWebhooks()); + webhookId = current.webhooks.find(webhook => webhook.url === webhookUrl)?.id ?? null; + } catch (error) { + console.warn( + `[contract:live] could not look up temporary Avenia webhook for cleanup: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + if (webhookId) { + try { + await api().deleteWebhook(webhookId); + } catch (error) { + // A throw here would mask the error that actually failed the test; the + // stale-webhook sweep above reclaims the slot on the next run instead. + console.warn( + `[contract:live] could not delete temporary Avenia webhook ${webhookId}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + }, + 60_000 + ); }); // Not gated on HAS_CREDS: in the nightly (CONTRACT_EXPECT_LIVE=1) missing credentials diff --git a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts index 6c88ff574..cfaab204d 100644 --- a/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts +++ b/apps/api/src/tests/corridors/alfredpay-currencies.scenario.test.ts @@ -5,12 +5,14 @@ import { AlfredPayCountry, AlfredpayOfframpStatus, AlfredpayOnrampStatus, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, getAnyFiatTokenDetails, multiplyByPowerOfTen, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -263,20 +265,19 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { /** Signs a blueprint exactly as issued; the nonce may be overridden for backups. */ async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx, nonce?: number): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; const chainId = CHAIN_IDS[blueprint.network]; if (!chainId) { throw new Error(`No chain id mapped for ${blueprint.network}`); } return ephemeral.signTransaction({ chainId, - data: txData.data, - gas: 600_000n, - // validatePresignedTxs enforces the blueprint's fee minimums (3 gwei floor on Polygon). - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: nonce ?? blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); @@ -363,14 +364,19 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { const mintAmountRaw = BigInt(metadata?.blocks.alfredpayMint?.outputAmountRaw ?? "0"); expect(mintAmountRaw).toBeGreaterThan(0n); const amountRaw = parseUnits(quote.outputAmount, ALFREDPAY_ERC20_DECIMALS); + const registered = await RampState.findByPk(ramp.id); + const transferBlueprint = registered?.unsignedTxs.find(tx => tx.phase === "destinationTransfer"); + if (!transferBlueprint) throw new Error("destinationTransfer blueprint missing"); + const transferTxData = transferBlueprint.txData as EvmTransactionData; const signTransfer = (nonce: number) => ephemeral.signTransaction({ chainId: 137, data: encodeFunctionData({ abi: erc20Abi, args: [destination, amountRaw], functionName: "transfer" }), - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + gas: BigInt(transferTxData.gas), + maxFeePerGas: BigInt(transferTxData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(transferTxData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, to: ALFREDPAY_ERC20_TOKEN, type: "eip1559" @@ -453,17 +459,18 @@ describe("Alfredpay currency corridors (USD/COP/ARS, on- and offramp)", () => { expect(userTransferBlueprint).toBeDefined(); expect(offrampTransferBlueprint).toBeDefined(); const userTxData = userTransferBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; - const offrampTxData = offrampTransferBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const offrampTxData = offrampTransferBlueprint?.txData as EvmTransactionData; const signOfframpTransfer = (nonce: number) => ephemeral.signTransaction({ chainId: 137, - data: offrampTxData.data, - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: offrampTxData.data as `0x${string}`, + gas: BigInt(offrampTxData.gas), + maxFeePerGas: BigInt(offrampTxData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(offrampTxData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: offrampTxData.to, + to: offrampTxData.to as `0x${string}`, type: "eip1559" }); const backups: Record = {}; 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 e015fd72f..8a34e7c6e 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,10 +1,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { AveniaTicketStatus, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -179,15 +181,15 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via } async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; return ephemeral.signTransaction({ chainId: 8453, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); 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 33782af01..153e7f64f 100644 --- a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts @@ -2,10 +2,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test" import { AveniaTicketStatus, type CleanupPhase, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -181,15 +183,15 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { } async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx, nonce?: number): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; return ephemeral.signTransaction({ chainId: 8453, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: nonce ?? blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); @@ -200,27 +202,16 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { * nonces, honoring the blueprint's fee/gas minimums, shaped for /v1/ramp/update. */ async function signBlueprintWithBackups(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx) { - const txData = blueprint.txData as unknown as { - to: `0x${string}`; - data: `0x${string}`; - value?: string; - gas?: string; - maxFeePerGas?: string; - maxPriorityFeePerGas?: string; - }; - const atLeast = (raw: string | undefined, floor: bigint) => { - const value = BigInt(raw ?? "0"); - return value > floor ? value : floor; - }; + const txData = blueprint.txData as EvmTransactionData; const sign = (nonce: number) => ephemeral.signTransaction({ chainId: 8453, - data: txData.data, - gas: atLeast(txData.gas, 600_000n), - maxFeePerGas: atLeast(txData.maxFeePerGas, 5_000_000_000n), - maxPriorityFeePerGas: atLeast(txData.maxPriorityFeePerGas, 5_000_000_000n), + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); @@ -577,7 +568,7 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { const rampState = await RampState.findByPk(ramp.id); const payoutBlueprint = blueprintOf(rampState?.unsignedTxs ?? [], "brlaPayoutOnBase"); - const blueprintData = payoutBlueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const blueprintData = payoutBlueprint.txData as EvmTransactionData; const { args } = decodeFunctionData({ abi: erc20Abi, data: blueprintData.data }); const amount = (args as [string, bigint])[1]; @@ -587,11 +578,12 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { ephemeral.signTransaction({ chainId: 8453, data: encodeFunctionData({ abi: erc20Abi, args: [attacker, amount], functionName: "transfer" }), - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + gas: BigInt(blueprintData.gas), + maxFeePerGas: BigInt(blueprintData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(blueprintData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: blueprintData.to, + to: blueprintData.to as `0x${string}`, type: "eip1559" }); const tamperedPayout = await tamper(payoutBlueprint.nonce); diff --git a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts index f947aa301..ecf87ec94 100644 --- a/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-onramp-crosschain.scenario.test.ts @@ -1,17 +1,22 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { EvmToken, + type EvmNetworks, evmTokenConfig, FiatToken, Networks, + QuoteError, RampDirection, type RampPhase, + signUnsignedTransactions, type UnsignedTx } from "@vortexfi/shared"; +import Big from "big.js"; import { decodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; -import { getBlockMetadata } from "../../api/services/phases/blocks/core/metadata"; +import { config } from "../../config/vars"; +import { getBlockMetadata, getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; import { NablaSwapContext } from "../../api/services/phases/blocks/phases/nabla-swap/simulation"; import { SquidRouterSwapContext } from "../../api/services/phases/blocks/phases/squid-router-swap/simulation"; import QuoteTicket from "../../models/quoteTicket.model"; @@ -34,11 +39,33 @@ const USDC_ON_ARBITRUM = requireToken(Networks.Arbitrum, EvmToken.USDC).erc20Add const BRLA_ON_BASE = requireToken(Networks.Base, EvmToken.BRLA).erc20AddressSourceChain as `0x${string}`; const TAX_ID = "12345678901"; +const BASE_CHAIN_ID_HEX = "0x2105"; +const ARBITRUM_CHAIN_ID_HEX = "0xa4b1"; -const CHAIN_IDS: Partial> = { - [Networks.Arbitrum]: 42161, - [Networks.Base]: 8453 -}; +function installChainIdShim(): { restore: () => void } { + const guardedFetch = globalThis.fetch; + const shim = (async (input: Parameters[0], init?: Parameters[1]) => { + if (typeof init?.body === "string") { + try { + const payload = JSON.parse(init.body) as { id?: number; method?: string }; + if (payload.method === "eth_chainId") { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const chainId = url.includes("base") ? BASE_CHAIN_ID_HEX : ARBITRUM_CHAIN_ID_HEX; + return Response.json({ id: payload.id ?? 1, jsonrpc: "2.0", result: chainId }); + } + } catch { + // Not a JSON-RPC request; retain the hermetic fetch guard below. + } + } + return guardedFetch(input, init); + }) as typeof fetch; + globalThis.fetch = Object.assign(shim, guardedFetch); + return { + restore: () => { + globalThis.fetch = guardedFetch; + } + }; +} // Unlike the direct pix→BRLA-on-Base corridor, the full swap-and-bridge chain // executes here: Nabla swaps the minted BRLA into USDC on Base, the squid @@ -79,6 +106,12 @@ interface CorridorSetup { destination: `0x${string}`; } +interface DestinationFundingExpectation { + initialBalanceRaw: bigint; + liabilityRaw: bigint; + shortfallRaw: bigint; +} + /** * Corridor scenario tests for the CROSS-CHAIN BRL onramp (pix → BRLA minted on * Base → Nabla swap to USDC → SquidRouter bridge → USDC on Arbitrum). This is @@ -93,10 +126,12 @@ interface CorridorSetup { describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Arbitrum)", () => { let world: FakeWorld; let auth: { restore: () => void }; + let chainIdShim: { restore: () => void }; let app: TestApp; beforeAll(async () => { world = installFakeWorld(); + chainIdShim = installChainIdShim(); auth = installFakeSupabaseAuth(); await setupTestDatabase(); app = await startTestApp(); @@ -105,6 +140,7 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar afterAll(async () => { await app?.close(); auth?.restore(); + chainIdShim?.restore(); world?.restore(); }); @@ -114,6 +150,8 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar // partner's EVM payout address even when the resulting fees are zero. await updatePartnerPricing("vortex", RampDirection.BUY, { payoutAddressEvm: "0x000000000000000000000000000000000000fee5" }); world.evm.failNextSends = 0; + world.evm.setFeeEstimate(Networks.Arbitrum, 1_000_000_000n); + world.evm.setFeeEstimate(Networks.Base, 1_000_000_000n); world.evm.onTransaction = undefined; world.brla.onPixOutputTicket = undefined; world.brla.accountBalances = { BRLA: 1_000_000, USDC: 0, USDM: 0, USDT: 0 }; @@ -132,22 +170,24 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar }; }); - async function createQuoteViaApi(): Promise<{ id: string; outputAmount: string }> { + async function createQuoteViaApi( + destinationNetwork: EvmNetworks = Networks.Arbitrum + ): Promise<{ id: string; networkFeeUsd: string; outputAmount: string }> { const response = await app.request("/v1/quotes", { body: JSON.stringify({ from: "pix", inputAmount: "500", inputCurrency: FiatToken.BRL, - network: Networks.Arbitrum, + network: destinationNetwork, outputCurrency: EvmToken.USDC, rampType: RampDirection.BUY, - to: Networks.Arbitrum + to: destinationNetwork }), headers: { "Content-Type": "application/json" }, method: "POST" }); expect(response.status, `quote creation failed: ${await response.clone().text()}`).toBe(201); - return (await response.json()) as { id: string; outputAmount: string }; + return (await response.json()) as { id: string; networkFeeUsd: string; outputAmount: string }; } async function registerViaApi( @@ -178,38 +218,31 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar return blueprint as UnsignedTx; } - async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; - const chainId = CHAIN_IDS[blueprint.network]; - if (!chainId) { - throw new Error(`No chain id mapped for ${blueprint.network}`); - } - return ephemeral.signTransaction({ - chainId, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, - nonce: blueprint.nonce, - to: txData.to, - type: "eip1559", - value: BigInt(txData.value ?? "0") - }); - } - /** - * Creates quote + registration through the HTTP API, then signs the - * ephemeral phase blueprints exactly as issued — the Nabla pair and squid - * pair on Base plus the destination transfer on Arbitrum — and stores them - * as presigned transactions the way /v1/ramp/update would. + * Creates quote + registration through the HTTP API, signs every ephemeral + * blueprint with the shared production signer (including backups), and + * submits the result through the real /v1/ramp/update validation path. */ - async function setUpRegisteredRamp(): Promise { - const ephemeral = privateKeyToAccount(generatePrivateKey()); + async function setUpRegisteredRamp(options: { legacyDestinationFunding?: boolean } = {}): Promise { + const ephemeralSecret = generatePrivateKey(); + const ephemeral = privateKeyToAccount(ephemeralSecret); const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; const user = await createTestUser(); await createTestTaxId(user.id, { taxId: TAX_ID }); const quote = await createQuoteViaApi(); + expect(new Big(quote.networkFeeUsd).gt("2.5")).toBe(true); + if (options.legacyDestinationFunding) { + const legacyQuote = await QuoteTicket.findByPk(quote.id); + if (!legacyQuote) throw new Error("Quote not found before legacy compatibility setup"); + const legacyMetadata = getFlowMetadata(legacyQuote.metadata); + const { evmDestinationGas: _dynamicFunding, ...legacyGlobals } = legacyMetadata.globals; + await legacyQuote.update({ + metadata: { ...legacyMetadata, globals: legacyGlobals } as unknown as QuoteTicket["metadata"] + }); + // The historical 0.0002 ETH reserve covers this signed 100k-gas payout. + world.evm.setFeeEstimate(Networks.Arbitrum, 500_000_000n); + } const ramp = await registerViaApi(quote.id, user.id, ephemeral, destination); const persistedQuote = await QuoteTicket.findByPk(quote.id); @@ -236,34 +269,37 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar const squidApproveBlueprint = blueprintOf(unsignedTxs, "squidRouterApprove"); const squidSwapBlueprint = blueprintOf(unsignedTxs, "squidRouterSwap"); const transferBlueprint = blueprintOf(unsignedTxs, "destinationTransfer"); + expect(nablaApproveBlueprint.network).toBe(Networks.Base); + expect(nablaSwapBlueprint.network).toBe(Networks.Base); expect(squidApproveBlueprint.network).toBe(Networks.Base); expect(squidSwapBlueprint.network).toBe(Networks.Base); expect(transferBlueprint.network).toBe(Networks.Arbitrum); - const signedNablaApprove = await signBlueprint(ephemeral, nablaApproveBlueprint); - const signedNablaSwap = await signBlueprint(ephemeral, nablaSwapBlueprint); - const signedSquidApprove = await signBlueprint(ephemeral, squidApproveBlueprint); - const signedSquidSwap = await signBlueprint(ephemeral, squidSwapBlueprint); - const signedTransfer = await signBlueprint(ephemeral, transferBlueprint); - - const presign = (blueprint: UnsignedTx, txData: `0x${string}`) => ({ - meta: {}, - network: blueprint.network, - nonce: blueprint.nonce, - phase: blueprint.phase, - signer: ephemeral.address, - txData + const presignedTxs = await signUnsignedTransactions(unsignedTxs, { + evmEphemeral: { + address: ephemeral.address, + secret: ephemeralSecret + } }); + const signedFor = (phase: RampPhase) => { + const transaction = presignedTxs.find(tx => tx.phase === phase); + expect(transaction, `production signer omitted ${phase}`).toBeDefined(); + return transaction?.txData as `0x${string}`; + }; + const signedNablaSwap = signedFor("nablaSwap"); + const signedSquidApprove = signedFor("squidRouterApprove"); + const signedSquidSwap = signedFor("squidRouterSwap"); + const signedTransfer = signedFor("destinationTransfer"); - await rampState.update({ - presignedTxs: [ - presign(nablaApproveBlueprint, signedNablaApprove), - presign(nablaSwapBlueprint, signedNablaSwap), - presign(squidApproveBlueprint, signedSquidApprove), - presign(squidSwapBlueprint, signedSquidSwap), - presign(transferBlueprint, signedTransfer) - ] + const updateResponse = await app.request("/v1/ramp/update", { + body: JSON.stringify({ presignedTxs, rampId: ramp.id }), + headers: { + Authorization: `Bearer ${testUserToken(user.id)}`, + "Content-Type": "application/json" + }, + method: "POST" }); + expect(updateResponse.status, `ramp update failed: ${await updateResponse.clone().text()}`).toBe(200); const transferTxData = transferBlueprint.txData as unknown as { data: `0x${string}` }; const { args } = decodeFunctionData({ abi: erc20Abi, data: transferTxData.data }); @@ -289,14 +325,25 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar * Scripts the fake world so every polling loop succeeds on its first check: * - the Avenia subaccount holds the minted BRL and the mint ticket credits * the ephemeral's BRLA on Base instantly, - * - the ephemeral has gas on Base AND Arbitrum (destination funding), + * - the ephemeral has source gas on Base but only a partial destination gas + * balance on Arbitrum, so fundEphemeral must supply the exact shortfall, * - the broadcast Nabla swap credits the ephemeral's Base USDC, * - the broadcast squid swap credits the bridged USDC on Arbitrum, - * - raw ERC-20 transfers are applied to the in-memory ledger. + * - the destination payout is accepted only if the funded native balance can + * cover its full signed fee cap, then raw ERC-20 transfers are applied to + * the in-memory ledger. */ - function scriptHappyWorld(setup: CorridorSetup): void { + function scriptHappyWorld(setup: CorridorSetup): DestinationFundingExpectation { + const parsedTransfer = parseTransaction(setup.signedTransfer); + if (parsedTransfer.gas === undefined || parsedTransfer.maxFeePerGas === undefined) { + throw new Error("Signed destination transfer is missing its gas fee cap"); + } + const liabilityRaw = parsedTransfer.gas * parsedTransfer.maxFeePerGas; + const initialBalanceRaw = liabilityRaw / 4n; + const shortfallRaw = liabilityRaw - initialBalanceRaw; + world.evm.setNativeBalance(Networks.Base, setup.ephemeral.address, parseUnits("2", 18)); - world.evm.setNativeBalance(Networks.Arbitrum, setup.ephemeral.address, parseUnits("2", 18)); + world.evm.setNativeBalance(Networks.Arbitrum, setup.ephemeral.address, initialBalanceRaw); world.brla.onPixOutputTicket = ({ walletAddress }) => { if (walletAddress) { // Generous credit (same as the direct corridor): the mint handler @@ -306,6 +353,14 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar } }; world.evm.onTransaction = tx => { + if (!tx.serialized && tx.to?.toLowerCase() === setup.ephemeral.address.toLowerCase() && tx.value !== undefined) { + world.evm.setNativeBalance( + tx.network, + setup.ephemeral.address, + world.evm.nativeBalance(tx.network, setup.ephemeral.address) + tx.value + ); + return; + } if (tx.serialized === setup.signedNablaSwap) { world.evm.setErc20Balance(Networks.Base, USDC_ON_BASE, setup.ephemeral.address, setup.swapOutputRaw); return; @@ -319,6 +374,18 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar ); return; } + if (tx.serialized === setup.signedTransfer) { + const fundedBalanceRaw = world.evm.nativeBalance(Networks.Arbitrum, setup.ephemeral.address); + if (fundedBalanceRaw < liabilityRaw) { + throw new Error( + `FakeEvm: destination payout needs ${liabilityRaw} native units but ephemeral holds ${fundedBalanceRaw}` + ); + } + // Charge the full signed fee cap. Real execution normally spends less, but + // this proves the selected funding survives the worst case authorized by + // the transaction before the fake RPC accepts the submission. + world.evm.setNativeBalance(Networks.Arbitrum, setup.ephemeral.address, fundedBalanceRaw - liabilityRaw); + } const parsed = tx.serialized ? parseTransaction(tx.serialized as `0x${string}`) : { data: tx.data, to: tx.to }; if (!parsed.to || !parsed.data) { return; @@ -340,17 +407,95 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar world.evm.erc20Balance(tx.network, parsed.to, recipient) + amount ); }; + + return { initialBalanceRaw, liabilityRaw, shortfallRaw }; } function submissionsOf(signedTx: `0x${string}`): number { return world.evm.sentTransactions.filter(tx => tx.serialized === signedTx).length; } + it("prices destination execution for ETH, MATIC, BNB, and AVAX gas chains", async () => { + for (const network of [ + Networks.Ethereum, + Networks.Arbitrum, + Networks.Polygon, + Networks.BSC, + Networks.Avalanche + ] as const) { + const quote = await createQuoteViaApi(network); + const persistedQuote = await QuoteTicket.findByPk(quote.id); + + expect(persistedQuote?.network).toBe(network); + expect(persistedQuote?.to).toBe(network); + expect(new Big(quote.networkFeeUsd).gt("2.5")).toBe(true); + expect(getFlowMetadata(persistedQuote?.metadata).globals.evmDestinationGas?.network).toBe(network); + } + }); + + it("returns the typed 503 for normal and all-high best-quote requests", async () => { + const originalCeiling = config.evmDestinationGas.maxExecutionFeeUsd; + config.evmDestinationGas.maxExecutionFeeUsd = "0.000001"; + try { + const request = { + from: "pix", + inputAmount: "500", + inputCurrency: FiatToken.BRL, + outputCurrency: EvmToken.USDC, + rampType: RampDirection.BUY + }; + const quoteResponse = await app.request("/v1/quotes", { + body: JSON.stringify({ ...request, network: Networks.Arbitrum, to: Networks.Arbitrum }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(quoteResponse.status).toBe(503); + expect(await quoteResponse.json()).toMatchObject({ message: QuoteError.NetworkFeesTooHigh }); + + const bestResponse = await app.request("/v1/quotes/best", { + body: JSON.stringify({ ...request, networks: [Networks.Arbitrum] }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + expect(bestResponse.status).toBe(503); + expect(await bestResponse.json()).toMatchObject({ message: QuoteError.NetworkFeesTooHigh }); + } finally { + config.evmDestinationGas.maxExecutionFeeUsd = originalCeiling; + } + }); + + it("rejects moved fees before creating an Avenia registration ticket", async () => { + const ephemeral = privateKeyToAccount(generatePrivateKey()); + const destination = privateKeyToAccount(generatePrivateKey()).address as `0x${string}`; + const user = await createTestUser(); + await createTestTaxId(user.id, { taxId: TAX_ID }); + const quote = await createQuoteViaApi(); + const ticketsBefore = world.brla.pixInputTickets.length; + world.evm.setFeeEstimate(Networks.Arbitrum, 1_200_000_001n); + + const response = await app.request("/v1/ramp/register", { + body: JSON.stringify({ + additionalData: { destinationAddress: destination, taxId: TAX_ID }, + quoteId: quote.id, + signingAccounts: [{ address: ephemeral.address, type: "EVM" }] + }), + headers: { + Authorization: `Bearer ${testUserToken(user.id)}`, + "Content-Type": "application/json" + }, + method: "POST" + }); + + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ message: QuoteError.NetworkFeesTooHigh }); + expect(world.brla.pixInputTickets).toHaveLength(ticketsBefore); + }); + it( - "happy path: mints on Base, swaps BRLA to USDC via Nabla, bridges via squid, and pays the destination on Arbitrum", + "dynamically funds the signed payout shortfall, then submits the full cross-chain payout", async () => { const setup = await setUpRegisteredRamp(); - scriptHappyWorld(setup); + const destinationFunding = scriptHappyWorld(setup); const pixOutBefore = world.brla.pixOutputTickets.length; // Registration requested a Base USDC → Arbitrum USDC squid route. @@ -383,8 +528,78 @@ describe("BRL onramp cross-chain corridor (pix → Base mint+swap → USDC on Ar expect(submissionsOf(setup.signedSquidApprove)).toBe(1); expect(submissionsOf(setup.signedSquidSwap)).toBe(1); expect(submissionsOf(setup.signedTransfer)).toBe(1); + const destinationFundingTxs = world.evm.sentTransactions.filter( + tx => + !tx.serialized && + tx.network === Networks.Arbitrum && + tx.to?.toLowerCase() === setup.ephemeral.address.toLowerCase() && + tx.value !== undefined + ); + expect(destinationFundingTxs).toHaveLength(1); + expect(destinationFundingTxs[0].value).toBe(destinationFunding.shortfallRaw); + const gasQuote = getFlowMetadata(quote?.metadata).globals.evmDestinationGas; + expect(gasQuote?.fundingGasLimit).toBe("21624"); + expect(gasQuote?.transferGasLimit).toBe("100624"); + expect(destinationFundingTxs[0].gas).toBe(BigInt(gasQuote?.fundingGasLimit ?? "0")); + expect(destinationFundingTxs[0].maxFeePerGas).toBe(1_000_000_000n); + expect(destinationFundingTxs[0].maxPriorityFeePerGas).toBe(1_000_000_000n); + expect(destinationFunding.initialBalanceRaw + (destinationFundingTxs[0].value ?? 0n)).toBe( + destinationFunding.liabilityRaw + ); + expect(world.evm.nativeBalance(Networks.Arbitrum, setup.ephemeral.address)).toBe(0n); expect(world.evm.erc20Balance(Networks.Arbitrum, USDC_ON_ARBITRUM, setup.destination)).toBe(setup.amountRaw); }, 30000 ); + + it( + "completes an in-flight legacy cross-chain quote without dynamic funding metadata", + async () => { + const setup = await setUpRegisteredRamp({ legacyDestinationFunding: true }); + const destinationFunding = scriptHappyWorld(setup); + + await phaseProcessor.processRamp(setup.rampId); + + const final = await RampState.findByPk(setup.rampId); + expect(final?.currentPhase).toBe("complete"); + const destinationFundingTxs = world.evm.sentTransactions.filter( + tx => + !tx.serialized && + tx.network === Networks.Arbitrum && + tx.to?.toLowerCase() === setup.ephemeral.address.toLowerCase() && + tx.value !== undefined + ); + expect(destinationFundingTxs).toHaveLength(1); + expect(destinationFundingTxs[0].gas).toBeUndefined(); + expect(destinationFunding.initialBalanceRaw + (destinationFundingTxs[0].value ?? 0n)).toBe(parseUnits("0.0002", 18)); + expect(submissionsOf(setup.signedTransfer)).toBe(1); + }, + 30000 + ); + + it( + "pauses without treasury spend when live destination fees exceed the quote envelope", + async () => { + const setup = await setUpRegisteredRamp(); + scriptHappyWorld(setup); + world.evm.setFeeEstimate(Networks.Arbitrum, 1_200_000_001n, 1_000_000_000n); + + await phaseProcessor.processRamp(setup.rampId); + + const paused = await RampState.findByPk(setup.rampId); + expect(paused?.currentPhase).toBe("fundEphemeral"); + expect( + world.evm.sentTransactions.filter( + tx => + !tx.serialized && + tx.network === Networks.Arbitrum && + tx.to?.toLowerCase() === setup.ephemeral.address.toLowerCase() + ) + ).toHaveLength(0); + expect(submissionsOf(setup.signedTransfer)).toBe(0); + expect(paused?.errorLogs.some(log => log.phase === "fundEphemeral" && log.recoverable)).toBe(true); + expect(paused?.errorLogs.at(-1)?.error).toBe(QuoteError.NetworkFeesTooHigh); + }, + 30000 + ); }); diff --git a/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts b/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts index d5cb42110..8d844d5b6 100644 --- a/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/eur-offramp.scenario.test.ts @@ -1,12 +1,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { EphemeralAccountType, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, MykoboTransactionStatus, MykoboTransactionType, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -185,15 +187,15 @@ describe("EUR offramp corridor (USDC on Base → SEPA via Mykobo)", () => { } async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; return ephemeral.signTransaction({ chainId: 8453, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); diff --git a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts index bf488f39a..fb1fb4bc7 100644 --- a/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts @@ -3,9 +3,11 @@ import { ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, AlfredpayOfframpStatus, + type EvmTransactionData, EvmToken, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -41,10 +43,9 @@ const HAPPY_PATH_PHASES: RampPhase[] = [ const ALFREDPAY_OFFRAMP_RATE = 20; const FIAT_ACCOUNT_ID = "test-fiat-account-1"; -interface EvmTxBlueprint { +interface EvmTxBlueprint extends EvmTransactionData { to: `0x${string}`; data: `0x${string}`; - value?: string; } interface CorridorSetup { @@ -192,10 +193,11 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () return ephemeral.signTransaction({ chainId: 137, data: offrampTransferBlueprint.data, - gas: 100_000n, - // validatePresignedTxs enforces a 3 gwei floor on Polygon fees. - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + gas: BigInt(offrampTransferBlueprint.gas), + maxFeePerGas: + BigInt(offrampTransferBlueprint.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(offrampTransferBlueprint.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, to: offrampTransferBlueprint.to, type: "eip1559" @@ -302,6 +304,32 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () alfredpayOfframp?: { bridgeInputAmountRaw?: string; bridgeOutputAmountRaw?: string; + pricing?: { + customer: { + allInRate: string; + inputAmountUsd: string; + referenceDifferenceBps: string; + }; + provider: { + baseCurrency: string; + feeAmount: string; + fees: Array<{ amount: string; currency: string; type: string }>; + grossRate: string; + grossReferenceDifferenceBps: string; + netRate: string; + netReferenceDifferenceBps: string; + quoteCurrency: string; + quotedAt: string; + source: string; + }; + reference: { + baseCurrency: string; + observedAt: string; + quoteCurrency: string; + rate: string; + source: string; + }; + }; }; }; } @@ -310,6 +338,31 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () expect(metadata?.blocks.alfredpayOfframp?.bridgeInputAmountRaw).toBe(expectedRaw); expect(metadata?.blocks.alfredpayOfframp?.bridgeOutputAmountRaw).toBe(expectedRaw); + + const pricing = metadata?.blocks.alfredpayOfframp?.pricing; + expect(pricing?.reference).toEqual({ + baseCurrency: "USD", + observedAt: "1970-01-01T00:00:00.000Z", + quoteCurrency: FiatToken.MXN, + rate: "17", + source: "fastforex" + }); + expect(pricing?.provider).toMatchObject({ + baseCurrency: EvmToken.USDT, + feeAmount: "0", + fees: [], + grossRate: "20", + netRate: "20", + quoteCurrency: FiatToken.MXN, + source: "alfredpay" + }); + expect(Number(pricing?.provider.grossReferenceDifferenceBps)).toBeCloseTo((20 / 17 - 1) * 10_000); + expect(Number(pricing?.provider.netReferenceDifferenceBps)).toBeCloseTo((20 / 17 - 1) * 10_000); + expect(Number(pricing?.customer.inputAmountUsd)).toBe(Number(quote.inputAmount)); + expect(Number(pricing?.customer.allInRate)).toBeCloseTo(Number(quote.outputAmount) / Number(quote.inputAmount)); + expect(Number(pricing?.customer.referenceDifferenceBps)).toBeCloseTo( + (Number(pricing?.customer.allInRate) / Number(pricing?.reference.rate) - 1) * 10_000 + ); }); it( @@ -371,16 +424,17 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () // Presign the single distributeFees transfer (vortex only) as blueprinted. const feeBlueprint = allUnsignedTxs.find(tx => tx.phase === "distributeFees"); expect(feeBlueprint).toBeDefined(); - const feeData = feeBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const feeData = feeBlueprint?.txData as EvmTransactionData; const signFee = (nonce: number) => setup.ephemeral.signTransaction({ chainId: 137, - data: feeData.data, - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: feeData.data as `0x${string}`, + gas: BigInt(feeData.gas), + maxFeePerGas: BigInt(feeData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(feeData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: feeData.to, + to: feeData.to as `0x${string}`, type: "eip1559" }); const feeBackups: Record = {}; @@ -468,16 +522,17 @@ describe("MXN offramp direct corridor (USDT on Polygon → spei, no-permit)", () const feeBlueprint = allUnsignedTxs.find(tx => tx.phase === "distributeFees"); expect(feeBlueprint).toBeDefined(); - const feeData = feeBlueprint?.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const feeData = feeBlueprint?.txData as EvmTransactionData; const signFee = (nonce: number) => setup.ephemeral.signTransaction({ chainId: 137, - data: feeData.data, - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: feeData.data as `0x${string}`, + gas: BigInt(feeData.gas), + maxFeePerGas: BigInt(feeData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(feeData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: feeData.to, + to: feeData.to as `0x${string}`, type: "eip1559" }); const feeBackups: Record = {}; diff --git a/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts index 4dc3d55ba..4594d8326 100644 --- a/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-onramp-crosschain.scenario.test.ts @@ -2,10 +2,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test" import { ALFREDPAY_ERC20_TOKEN, AlfredpayOnrampStatus, + type EvmTransactionData, EvmToken, evmTokenConfig, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase, type UnsignedTx @@ -161,20 +163,19 @@ describe("MXN onramp cross-chain corridor (spei → Polygon mint → USDT on Arb /** Signs a blueprint exactly as issued; the nonce may be overridden for backups. */ async function signBlueprint(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx, nonce?: number): Promise<`0x${string}`> { - const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; const chainId = CHAIN_IDS[blueprint.network]; if (!chainId) { throw new Error(`No chain id mapped for ${blueprint.network}`); } return ephemeral.signTransaction({ chainId, - data: txData.data, - gas: 600_000n, - // validatePresignedTxs enforces the blueprint's fee minimums (3 gwei floor on Polygon). - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce: nonce ?? blueprint.nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); diff --git a/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts b/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts index c941c3eac..037229980 100644 --- a/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts @@ -3,12 +3,15 @@ import { ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, AlfredpayOnrampStatus, + type EvmTransactionData, EvmToken, FiatToken, Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, RampDirection, type RampPhase } from "@vortexfi/shared"; +import Big from "big.js"; import { decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; @@ -195,6 +198,10 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { expect(mintAmountRaw).toBeGreaterThan(0n); const amountRaw = parseUnits(quote.outputAmount, ALFREDPAY_ERC20_DECIMALS); + const registered = await RampState.findByPk(ramp.id); + const transferBlueprint = registered?.unsignedTxs.find(tx => tx.phase === "destinationTransfer"); + if (!transferBlueprint) throw new Error("destinationTransfer blueprint missing"); + const transferTxData = transferBlueprint.txData as EvmTransactionData; async function signTransfer(recipient: `0x${string}`, nonce: number): Promise<`0x${string}`> { return ephemeral.signTransaction({ chainId: 137, @@ -203,10 +210,10 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { args: [recipient, amountRaw], functionName: "transfer" }), - gas: 100_000n, - // validatePresignedTxs enforces a 3 gwei floor on Polygon fees. - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + gas: BigInt(transferTxData.gas), + maxFeePerGas: BigInt(transferTxData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(transferTxData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, to: ALFREDPAY_ERC20_TOKEN, type: "eip1559" @@ -244,16 +251,17 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { const feeBlueprints = (rampState.unsignedTxs ?? []).filter(tx => tx.phase === "distributeFees"); const signedFeeTransfers: `0x${string}`[] = []; for (const blueprint of feeBlueprints) { - const blueprintData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}` }; + const blueprintData = blueprint.txData as EvmTransactionData; const signFee = (nonce: number) => ephemeral.signTransaction({ chainId: 137, - data: blueprintData.data, - gas: 100_000n, - maxFeePerGas: 5_000_000_000n, - maxPriorityFeePerGas: 5_000_000_000n, + data: blueprintData.data as `0x${string}`, + gas: BigInt(blueprintData.gas), + maxFeePerGas: BigInt(blueprintData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: + BigInt(blueprintData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: blueprintData.to, + to: blueprintData.to as `0x${string}`, type: "eip1559" }); const feeBackups: Record = {}; @@ -386,18 +394,20 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { const setup = await setUpRegisteredRamp(); // Quote: 2000 MXN mints 100 USDT. A 1% target promises the user 101 USDT - // after fees, so Vortex contributes 2 USDT: 1 for the rate improvement and - // 1 that economically offsets the separately collected fee. + // after fees, so Vortex contributes 2 USDT plus the dynamic destination + // network fee: 1 for the rate improvement and the rest to offset fees. const quote = await QuoteTicket.findByPk(setup.quoteId); const metadata = getFlowMetadata(quote?.metadata); + const networkFeeUsd = new Big(metadata.globals.fees.usd.network); expect(Number(quote?.outputAmount)).toBe(101); + expect(networkFeeUsd.gt(0)).toBe(true); expect(Number(metadata.globals.fees?.usd?.vortex)).toBe(1); const preSwap = metadata.blocks.subsidizePreSwap as { subsidyAmountInOutputTokenDecimal: string; feeReserveRaw: string }; - expect(Number(preSwap.subsidyAmountInOutputTokenDecimal)).toBe(2); - expect(preSwap.feeReserveRaw).toBe(parseUnits("1", 6).toString()); + expect(new Big(preSwap.subsidyAmountInOutputTokenDecimal).toFixed(6)).toBe(networkFeeUsd.plus(2).toFixed(6)); + expect(preSwap.feeReserveRaw).toBe(parseUnits(networkFeeUsd.plus(1).toFixed(6), 6).toString()); // Registration prepared ONE Polygon distributeFees transfer (vortex only) - // paying the 1 USDT residual; setUpRegisteredRamp presigned it. + // paying the 1 USDT fee plus the network fee; setUpRegisteredRamp presigned it. expect(setup.signedFeeTransfers).toHaveLength(1); scriptHappyWorld(setup); @@ -407,10 +417,12 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { expect(final?.currentPhase).toBe("complete"); expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); - // Destination received the promised net 101 USDT; the fee metadata and - // on-chain collection remain the full 1 USDT. + // Destination received the promised net 101 USDT; Vortex receives its + // full 1 USDT fee plus the priced destination network fee. expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, setup.destination)).toBe(parseUnits("101", 6)); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe(parseUnits("1", 6)); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe( + parseUnits(networkFeeUsd.plus(1).toFixed(6), 6) + ); expect(submissionsOf(setup.signedFeeTransfers[0])).toBe(1); }, 30000 @@ -439,7 +451,9 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { // used to collapse the two distributeFees transfers into one. const setup = await setUpRegisteredRamp({ pricingPartner: partner }); const quote = await QuoteTicket.findByPk(setup.quoteId); - expect(Number(quote?.outputAmount)).toBe(98); + const networkFeeUsd = new Big(getFlowMetadata(quote?.metadata).globals.fees.usd.network); + expect(networkFeeUsd.gt(0)).toBe(true); + expect(new Big(quote?.outputAmount ?? 0).toFixed(6)).toBe(new Big(98).minus(networkFeeUsd).toFixed(6)); expect(setup.signedFeeTransfers).toHaveLength(2); const merged = await RampState.findByPk(setup.rampId); expect(merged?.presignedTxs?.filter(tx => tx.phase === "distributeFees")).toHaveLength(2); @@ -463,7 +477,9 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { expect(afterFirstRun?.errorLogs.some(log => log.error.includes("requires reconciliation"))).toBe(true); // The first transfer was paid exactly once; the second never credited anyone. expect(submissionsOf(setup.signedFeeTransfers[0])).toBe(1); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe(parseUnits("1", 6)); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe( + parseUnits(networkFeeUsd.plus(1).toFixed(6), 6) + ); expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, partnerPayout)).toBe(0n); const operations = await FinancialOperation.findAll({ where: { phase: "distributeFees", scopeId: setup.rampId, scopeType: "ramp" } @@ -477,7 +493,9 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { // halts on the ambiguous one again — no recipient is ever double-paid. await phaseProcessor.processRamp(setup.rampId); expect(submissionsOf(setup.signedFeeTransfers[0])).toBe(1); - expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe(parseUnits("1", 6)); + expect(world.evm.erc20Balance(Networks.Polygon, ALFREDPAY_ERC20_TOKEN, vortexPayout)).toBe( + parseUnits(networkFeeUsd.plus(1).toFixed(6), 6) + ); }, 30000 ); @@ -612,7 +630,7 @@ describe("MXN onramp direct corridor (spei → USDT on Polygon)", () => { expect(final?.currentPhase).toBe("failed"); expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); expect(final?.processingLock).toEqual({ locked: false, lockedAt: null }); - expect(final?.errorLogs.some(log => log.error.includes("recipient mismatch"))).toBe(true); + expect(final?.errorLogs.some(log => log.error.includes("does not match expected data"))).toBe(true); // The mismatching transfer must never reach the chain, and nobody gets paid. expect(submissionsOf(setup.signedTransfer)).toBe(0); diff --git a/apps/api/src/tests/notifications-onboarding.integration.test.ts b/apps/api/src/tests/notifications-onboarding.integration.test.ts index 4fe5944e3..cda4db354 100644 --- a/apps/api/src/tests/notifications-onboarding.integration.test.ts +++ b/apps/api/src/tests/notifications-onboarding.integration.test.ts @@ -10,13 +10,23 @@ import { KycAttemptStatus } from "@vortexfi/shared"; import { createAlfredpayCustomer } from "../api/services/alfredpay/alfredpay-customer.service"; +import { reconcileMissedRampCompletedEmails } from "../api/services/email"; import { emitNotification } from "../api/services/notifications/notification.service"; +import KybStatusWorker from "../api/workers/kyb-status.worker"; +import ApiCredential from "../models/apiCredential.model"; import CustomerEntity from "../models/customerEntity.model"; +import EmailNotification, { NotificationProvider, NotificationStatus, NotificationType } from "../models/emailNotification.model"; import KycCase from "../models/kycCase.model"; import ProviderCustomer, { VerificationStatus } from "../models/providerCustomer.model"; import User from "../models/user.model"; import { resetTestDatabase, setupTestDatabase } from "../test-utils/db"; -import { createTestAlfredpayCustomer, createTestTaxId, createTestUser } from "../test-utils/factories"; +import { + createTestAlfredpayCustomer, + createTestQuote, + createTestRampState, + createTestTaxId, + createTestUser +} from "../test-utils/factories"; import { type FakeSupabaseAuth, installFakeSupabaseAuth, testUserToken } from "../test-utils/fake-world/fake-auth"; import { startTestApp, type TestApp } from "../test-utils/test-app"; @@ -67,6 +77,134 @@ describe("GET /v1/notifications", () => { }); }); +describe("ramp completion notification reconciliation", () => { + it("re-enqueues exactly the completed ramps that have no notification row", async () => { + const { user } = await createAuthedUser("reconcile@example.com"); + const missed = await createTestRampState({ currentPhase: "complete", userId: user.id }); + const already = await createTestRampState({ currentPhase: "complete", userId: user.id }); + await EmailNotification.create({ + locale: "en-US", + provider: NotificationProvider.Vortex, + resourceId: already.id, + status: NotificationStatus.Sent, + type: NotificationType.RampCompleted, + userId: user.id + }); + await createTestRampState({ currentPhase: "nablaSwap", userId: user.id }); + await createTestRampState({ currentPhase: "complete", userId: null }); + + await reconcileMissedRampCompletedEmails(); + + const missedRow = await EmailNotification.findOne({ where: { resourceId: missed.id } }); + expect(missedRow?.status).toBe(NotificationStatus.Pending); + expect(missedRow?.userId).toBe(user.id); + expect(await EmailNotification.count()).toBe(2); + + // A second sweep must be a no-op: the freshly written row now satisfies the anti-join. + await reconcileMissedRampCompletedEmails(); + expect(await EmailNotification.count()).toBe(2); + }); + + it("polls only undecided KYB cases whose attempt has no queued outcome", async () => { + const fresh = await createAuthedUser("kyb-poll-fresh@example.com"); + const freshBusiness = await createTestTaxId(fresh.user.id, { + customerType: "business", + subAccountId: "kyb-poll-fresh-sub", + taxId: "11222333000181" + }); + await KycCase.create({ + customerEntityId: freshBusiness.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-fresh", + providerCustomerId: freshBusiness.id, + status: VerificationStatus.InReview, + type: "kyb" + }); + + // Partner-owned: an entity with no profile has nobody to email and must not + // occupy a batch slot (the worker filters it in the join). + const partnerEntity = await CustomerEntity.create({ profileId: null, status: "active", type: "business" }); + await KycCase.create({ + customerEntityId: partnerEntity.id, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-partner", + providerCustomerId: null, + status: VerificationStatus.InReview, + type: "kyb" + }); + + const settled = await createAuthedUser("kyb-poll-settled@example.com"); + const settledBusiness = await createTestTaxId(settled.user.id, { + customerType: "business", + subAccountId: "kyb-poll-settled-sub", + taxId: "22333444000162" + }); + await KycCase.create({ + customerEntityId: settledBusiness.customerEntityId, + level: "level_1", + provider: "avenia", + providerCaseId: "attempt-settled", + providerCustomerId: settledBusiness.id, + status: VerificationStatus.InReview, + type: "kyb" + }); + await EmailNotification.create({ + locale: "en-US", + provider: NotificationProvider.Avenia, + resourceId: "attempt-settled", + status: NotificationStatus.Sent, + type: NotificationType.VerificationApproved, + userId: settled.user.id + }); + + const polled: string[] = []; + const getInstance = BrlaApiService.getInstance; + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async (attemptId: string) => { + polled.push(attemptId); + return { attempt: { id: attemptId, status: KycAttemptStatus.PENDING, updatedAt: "2026-08-06" } }; + }) + }) as unknown as BrlaApiService + ); + + try { + const worker = new KybStatusWorker() as unknown as { poll: () => Promise }; + await worker.poll(); + } finally { + BrlaApiService.getInstance = getInstance; + } + + expect(polled).toEqual(["attempt-fresh"]); + }); + + it("tombstones a completed partner-API ramp instead of enqueuing mail", async () => { + const { user } = await createAuthedUser("partner-ramp@example.com"); + const credential = await ApiCredential.create({ + environment: "live", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + name: "partner credential", + partnerId: null, + profileId: user.id, + publicKeyValue: "pk_test_reconcile", + secretKeyDigest: "a".repeat(64), + secretKeyPrefix: "sk_test_12345678" + }); + const quote = await createTestQuote({ apiCredentialId: credential.id, userId: user.id }); + const ramp = await createTestRampState({ currentPhase: "complete", quoteId: quote.id, userId: user.id }); + + await reconcileMissedRampCompletedEmails(); + + const rows = await EmailNotification.findAll({ where: { resourceId: ramp.id } }); + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe(NotificationStatus.Skipped); + expect(rows[0].lastError).toContain("Partner-API ramp"); + }); +}); + describe("notification read state", () => { it("marks a single notification read, scoped to the owner", async () => { const { user, token } = await createAuthedUser("user@example.com"); diff --git a/apps/api/src/tests/quote-consumption.invariants.test.ts b/apps/api/src/tests/quote-consumption.invariants.test.ts index 3d9aad3ba..6ad58e593 100644 --- a/apps/api/src/tests/quote-consumption.invariants.test.ts +++ b/apps/api/src/tests/quote-consumption.invariants.test.ts @@ -1,5 +1,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import { EvmToken, FiatToken, Networks, RampDirection, type PresignedTx, type UnsignedTx } from "@vortexfi/shared"; +import { + type EvmTransactionData, + EvmToken, + FiatToken, + Networks, + PRESIGNED_EVM_FEE_MULTIPLIER, + RampDirection, + type PresignedTx, + type UnsignedTx +} from "@vortexfi/shared"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import QuoteTicket from "../models/quoteTicket.model"; import RampState from "../models/rampState.model"; @@ -76,15 +85,15 @@ describe("quote consumption invariants (BRL onramp)", () => { } async function signBlueprint(account: PrivateKeyAccount, blueprint: UnsignedTx, nonce: number): Promise<`0x${string}`> { - const txData = blueprint.txData as { data: `0x${string}`; to: `0x${string}`; value?: string }; + const txData = blueprint.txData as EvmTransactionData; return account.signTransaction({ chainId: 8453, - data: txData.data, - gas: 600_000n, - maxFeePerGas: 10_000_000_000n, - maxPriorityFeePerGas: 10_000_000_000n, + data: txData.data as `0x${string}`, + gas: BigInt(txData.gas), + maxFeePerGas: BigInt(txData.maxFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, + maxPriorityFeePerGas: BigInt(txData.maxPriorityFeePerGas ?? "0") * PRESIGNED_EVM_FEE_MULTIPLIER, nonce, - to: txData.to, + to: txData.to as `0x${string}`, type: "eip1559", value: BigInt(txData.value ?? "0") }); diff --git a/apps/api/src/tests/quote-pricing.golden.test.ts b/apps/api/src/tests/quote-pricing.golden.test.ts index ea18baf8c..b8c882661 100644 --- a/apps/api/src/tests/quote-pricing.golden.test.ts +++ b/apps/api/src/tests/quote-pricing.golden.test.ts @@ -6,9 +6,10 @@ import { startTestApp, type TestApp } from "../test-utils/test-app"; /** * Golden tests for the quote pricing math. Every external input is pinned: - * FakePrices rates (BRL 5/USD, USDC 1/USD), FakeBrla pay-in/pay-out rate 1, - * and a scripted Nabla quoter at 0.18 USDC per BRLA. Under those inputs the - * fee/output values below are pure functions of the pricing engines. + * FakePrices rates (BRL 5/USD, ETH 2,500/USD, USDC 1/USD), FakeEvm fees + * (1 gwei), FakeBrla pay-in/pay-out rate 1, and a scripted Nabla quoter at + * 0.18 USDC per BRLA. Under those inputs the fee/output values below are pure + * functions of the pricing engines. * * A diff here means the pricing math changed. If that is intentional, update * the goldens consciously and call out the fee impact in the PR description — @@ -139,9 +140,9 @@ describe("quote pricing goldens (fixed input matrix)", () => { inputAmount: "100.00", inputCurrency: "BRL", network: "base", - networkFeeFiat: "0", - networkFeeUsd: "0", - outputAmount: "17.982", + networkFeeFiat: "2.115", + networkFeeUsd: "0.423", + outputAmount: "17.559", outputCurrency: "USDC", partnerFeeFiat: "0", partnerFeeUsd: "0", @@ -150,8 +151,8 @@ describe("quote pricing goldens (fixed input matrix)", () => { processingFeeUsd: "0.02", rampType: "BUY", to: "base", - totalFeeFiat: "0.10", - totalFeeUsd: "0.020000", + totalFeeFiat: "2.22", + totalFeeUsd: "0.443000", vortexFeeFiat: "0", vortexFeeUsd: "0" }, diff --git a/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts index c86b9d836..490a237a7 100644 --- a/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts +++ b/apps/api/src/tests/sdk-contract.alfredpay-onramp.test.ts @@ -278,6 +278,7 @@ describe("SDK ↔ API contract (Alfredpay onramps, fiat → USDT on Polygon)", ( for (const fee of feeFields) { expect(Number.isFinite(Number(fee))).toBe(true); } + expect(Number(quote.networkFeeUsd)).toBeGreaterThan(0); expect(quote.feeCurrency).toBeTruthy(); // registerRamp runs the SDK's full internal Alfredpay BUY flow: ephemeral @@ -303,9 +304,9 @@ describe("SDK ↔ API contract (Alfredpay onramps, fiat → USDT on Polygon)", ( expect(rampProcess.achPaymentData?.reference).toBeTruthy(); // The ephemeral surface of the direct Alfredpay BUY route: the - // destination transfer plus the Polygon dust cleanup. + // destination and network-fee transfers plus Polygon dust cleanup. const unsigned = rampProcess.unsignedTxs ?? []; - expect(unsigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "polygonCleanup"]); + expect(unsigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "distributeFees", "polygonCleanup"]); expect(unsigned.every(tx => tx.network === Networks.Polygon)).toBe(true); const destinationTransferTx = unsigned.find(tx => tx.phase === "destinationTransfer"); if (!destinationTransferTx) { @@ -320,7 +321,7 @@ describe("SDK ↔ API contract (Alfredpay onramps, fiat → USDT on Polygon)", ( expect(stored?.userId).toBe(userId); expect(stored?.state.alfredpayTransactionId).toBeTruthy(); const presigned = stored?.presignedTxs ?? []; - expect(presigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "polygonCleanup"]); + expect(presigned.map(tx => tx.phase).sort()).toEqual(["destinationTransfer", "distributeFees", "polygonCleanup"]); const presignedTransfer = presigned.find(tx => tx.phase === "destinationTransfer"); if (!presignedTransfer) { throw new Error("No presigned destinationTransfer"); diff --git a/apps/dashboard/src/hooks/useNotificationPreferences.test.ts b/apps/dashboard/src/hooks/useNotificationPreferences.test.ts new file mode 100644 index 000000000..588528cc2 --- /dev/null +++ b/apps/dashboard/src/hooks/useNotificationPreferences.test.ts @@ -0,0 +1,64 @@ +import { QueryClient } from "@tanstack/react-query"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { NotificationPreferencesDto } from "@/services/api/notification-preferences.service"; +import { createPreferencesMutationHandlers, NOTIFICATION_PREFERENCES_QUERY_KEY } from "./useNotificationPreferences"; + +const SAVED: NotificationPreferencesDto = { emailEnabled: true, prefs: { ramp_completed: true, verification_approved: true } }; + +function clientWith(data: NotificationPreferencesDto | undefined) { + const queryClient = new QueryClient(); + if (data) { + queryClient.setQueryData(NOTIFICATION_PREFERENCES_QUERY_KEY, data); + } + return queryClient; +} + +function cached(queryClient: QueryClient): NotificationPreferencesDto | undefined { + return queryClient.getQueryData(NOTIFICATION_PREFERENCES_QUERY_KEY); +} + +describe("preferences mutation handlers", () => { + it("optimistically applies the update and returns the snapshot", async () => { + const queryClient = clientWith(SAVED); + const handlers = createPreferencesMutationHandlers(queryClient); + + const context = await handlers.onMutate({ prefs: { ...SAVED.prefs, ramp_completed: false } }); + + assert.deepEqual(context.previous, SAVED); + assert.deepEqual(cached(queryClient), { + emailEnabled: true, + prefs: { ramp_completed: false, verification_approved: true } + }); + }); + + it("carries a lifted master switch into the optimistic cache", async () => { + const queryClient = clientWith({ emailEnabled: false, prefs: {} }); + const handlers = createPreferencesMutationHandlers(queryClient); + + await handlers.onMutate({ emailEnabled: true, prefs: { ramp_completed: true } }); + + assert.equal(cached(queryClient)?.emailEnabled, true); + }); + + it("rolls the cache back when the PUT fails", async () => { + const queryClient = clientWith(SAVED); + const handlers = createPreferencesMutationHandlers(queryClient); + + const context = await handlers.onMutate({ prefs: { ramp_completed: false } }); + handlers.onError(new Error("PUT failed"), { prefs: { ramp_completed: false } }, context); + + assert.deepEqual(cached(queryClient), SAVED); + }); + + it("does not fabricate cache state when nothing was loaded", async () => { + const queryClient = clientWith(undefined); + const handlers = createPreferencesMutationHandlers(queryClient); + + const context = await handlers.onMutate({ prefs: { ramp_completed: false } }); + handlers.onError(new Error("PUT failed"), { prefs: { ramp_completed: false } }, context); + + assert.equal(context.previous, undefined); + assert.equal(cached(queryClient), undefined); + }); +}); diff --git a/apps/dashboard/src/hooks/useNotificationPreferences.ts b/apps/dashboard/src/hooks/useNotificationPreferences.ts new file mode 100644 index 000000000..0dcd5ed57 --- /dev/null +++ b/apps/dashboard/src/hooks/useNotificationPreferences.ts @@ -0,0 +1,81 @@ +import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + type EmailNotificationCategory, + isCategoryEnabled, + type NotificationPreferencesDto, + NotificationPreferencesService, + type NotificationPreferencesUpdate, + preferencesUpdateFor +} from "@/services/api/notification-preferences.service"; + +export const NOTIFICATION_PREFERENCES_QUERY_KEY = ["notification-preferences"] as const; + +/** + * Optimistic-update handlers for the preferences mutation, extracted so the cache and + * rollback semantics are testable against a real QueryClient without rendering React. + */ +export function createPreferencesMutationHandlers(queryClient: QueryClient) { + return { + onError: ( + _error: unknown, + _update: NotificationPreferencesUpdate, + context: { previous: NotificationPreferencesDto | undefined } | undefined + ) => { + if (context?.previous) { + queryClient.setQueryData(NOTIFICATION_PREFERENCES_QUERY_KEY, context.previous); + } + }, + onMutate: async (update: NotificationPreferencesUpdate) => { + await queryClient.cancelQueries({ queryKey: NOTIFICATION_PREFERENCES_QUERY_KEY }); + const previous = queryClient.getQueryData(NOTIFICATION_PREFERENCES_QUERY_KEY); + if (previous) { + queryClient.setQueryData(NOTIFICATION_PREFERENCES_QUERY_KEY, { + emailEnabled: update.emailEnabled ?? previous.emailEnabled, + prefs: update.prefs + }); + } + return { previous }; + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: NOTIFICATION_PREFERENCES_QUERY_KEY }) + }; +} + +/** + * The user's email notification opt-outs, exposed as per-category toggles. + * Toggles apply optimistically — a checkbox that lags its click reads as broken — + * and roll back if the PUT fails. + */ +export function useNotificationPreferences() { + const queryClient = useQueryClient(); + + const query = useQuery({ + queryFn: NotificationPreferencesService.get, + queryKey: NOTIFICATION_PREFERENCES_QUERY_KEY + }); + + const mutation = useMutation({ + mutationFn: NotificationPreferencesService.update, + ...createPreferencesMutationHandlers(queryClient) + }); + + const categoryEnabled = (category: EmailNotificationCategory): boolean => + query.data ? isCategoryEnabled(query.data, category) : true; + + // Never PUT before the GET has resolved: the update is a full document, so a body + // built from a fallback would replace the saved preferences and wipe keys this page + // does not own. + const setCategoryEnabled = (category: EmailNotificationCategory, enabled: boolean): void => { + if (!query.data) { + return; + } + mutation.mutate(preferencesUpdateFor(query.data, category, enabled)); + }; + + return { + categoryEnabled, + // Interactive only with loaded data and no PUT in flight: overlapping full-document + // PUTs can complete out of order, with the older snapshot winning. + controlsDisabled: query.data === undefined || mutation.isPending, + setCategoryEnabled + }; +} diff --git a/apps/dashboard/src/routes/_app/settings.tsx b/apps/dashboard/src/routes/_app/settings.tsx index 6d61dd0fd..54741d1a1 100644 --- a/apps/dashboard/src/routes/_app/settings.tsx +++ b/apps/dashboard/src/routes/_app/settings.tsx @@ -7,28 +7,22 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useActiveAccount } from "@/hooks/useActiveAccount"; +import { useNotificationPreferences } from "@/hooks/useNotificationPreferences"; +import type { EmailNotificationCategory } from "@/services/api/notification-preferences.service"; import { useAuthStore } from "@/stores/auth.store"; -const NOTIFICATION_PREFS = [ +const NOTIFICATION_PREFS: Array<{ id: EmailNotificationCategory; label: string; description: string }> = [ { - defaultChecked: true, description: "When a corridor's KYB/KYC is approved or rejected.", id: "onboarding", label: "Onboarding updates" }, { - defaultChecked: true, - description: "When an invited recipient completes KYC/KYB.", - id: "recipients", - label: "Recipient approvals" - }, - { - defaultChecked: true, - description: "When a wallet-to-fiat pay-out settles or fails.", + description: "When a ramp settles.", id: "transfers", label: "Transfer status" } -] as const; +]; export const Route = createFileRoute("/_app/settings")({ component: SettingsPage @@ -37,6 +31,7 @@ export const Route = createFileRoute("/_app/settings")({ function SettingsPage() { const user = useAuthStore(state => state.user); const account = useActiveAccount(); + const { categoryEnabled, controlsDisabled, setCategoryEnabled } = useNotificationPreferences(); return ( @@ -77,7 +72,12 @@ function SettingsPage() { htmlFor={pref.id} key={pref.id} > - + setCategoryEnabled(pref.id, checked === true)} + /> {pref.label} {pref.description} diff --git a/apps/dashboard/src/services/api/notification-preferences.service.test.ts b/apps/dashboard/src/services/api/notification-preferences.service.test.ts new file mode 100644 index 000000000..a98a57ade --- /dev/null +++ b/apps/dashboard/src/services/api/notification-preferences.service.test.ts @@ -0,0 +1,96 @@ +import { EmailNotificationType } from "@vortexfi/shared"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + CATEGORY_TYPE_KEYS, + isCategoryEnabled, + type NotificationPreferencesDto, + preferencesUpdateFor +} from "./notification-preferences.service"; + +function dto(prefs: Record = {}, emailEnabled = true): NotificationPreferencesDto { + return { emailEnabled, prefs }; +} + +describe("category type keys", () => { + // The dispatch worker consults prefs[]; both sides consume the shared + // EmailNotificationType enum so the strings cannot drift between workspaces. + it("cover every stored notification type exactly once", () => { + const mapped = Object.values(CATEGORY_TYPE_KEYS).flat().sort(); + assert.deepEqual(mapped, Object.values(EmailNotificationType).sort()); + }); + + it("group verification under onboarding and ramp completion under transfers", () => { + assert.deepEqual(CATEGORY_TYPE_KEYS.onboarding, [ + EmailNotificationType.VerificationApproved, + EmailNotificationType.VerificationRejected, + EmailNotificationType.VerificationExpired + ]); + assert.deepEqual(CATEGORY_TYPE_KEYS.transfers, [EmailNotificationType.RampCompleted]); + }); +}); + +describe("isCategoryEnabled", () => { + it("treats missing keys as enabled (email is opt-out)", () => { + assert.equal(isCategoryEnabled(dto(), "onboarding"), true); + assert.equal(isCategoryEnabled(dto(), "transfers"), true); + }); + + it("only an explicit false mutes", () => { + assert.equal(isCategoryEnabled(dto({ ramp_completed: true }), "transfers"), true); + assert.equal(isCategoryEnabled(dto({ ramp_completed: false }), "transfers"), false); + }); + + it("shows a partially muted category as off", () => { + assert.equal(isCategoryEnabled(dto({ verification_rejected: false }), "onboarding"), false); + }); + + it("shows every category as off while the master switch is off", () => { + assert.equal(isCategoryEnabled(dto({}, false), "onboarding"), false); + assert.equal(isCategoryEnabled(dto({}, false), "transfers"), false); + }); +}); + +describe("preferencesUpdateFor", () => { + it("normally updates only prefs, setting every type of the category", () => { + const update = preferencesUpdateFor(dto(), "onboarding", false); + + assert.equal(update.emailEnabled, undefined); + assert.deepEqual(update.prefs, { + verification_approved: false, + verification_expired: false, + verification_rejected: false + }); + }); + + it("re-enabling heals a partial mute", () => { + const update = preferencesUpdateFor(dto({ verification_rejected: false }), "onboarding", true); + + assert.equal(isCategoryEnabled({ emailEnabled: true, prefs: update.prefs }, "onboarding"), true); + }); + + it("preserves keys it does not own", () => { + const update = preferencesUpdateFor(dto({ ramp_completed: false, someday_a_new_type: false }), "onboarding", true); + + assert.equal(update.prefs.ramp_completed, false); + assert.equal(update.prefs.someday_a_new_type, false); + }); + + // A profile stored with emailEnabled=false shows both categories off. Enabling one + // must lift the master switch without silently re-enabling the other category. + it("enabling under a global mute lifts the switch and pins other categories to muted", () => { + const update = preferencesUpdateFor(dto({}, false), "transfers", true); + + assert.equal(update.emailEnabled, true); + const after = { emailEnabled: true, prefs: update.prefs }; + assert.equal(isCategoryEnabled(after, "transfers"), true); + assert.equal(isCategoryEnabled(after, "onboarding"), false); + }); + + it("disabling under a global mute leaves the switch alone", () => { + const update = preferencesUpdateFor(dto({}, false), "transfers", false); + + assert.equal(update.emailEnabled, undefined); + assert.equal(update.prefs.ramp_completed, false); + }); +}); diff --git a/apps/dashboard/src/services/api/notification-preferences.service.ts b/apps/dashboard/src/services/api/notification-preferences.service.ts new file mode 100644 index 000000000..cdfbfd545 --- /dev/null +++ b/apps/dashboard/src/services/api/notification-preferences.service.ts @@ -0,0 +1,78 @@ +import { EmailNotificationType } from "@vortexfi/shared"; +import { apiClient } from "./api-client"; + +/** The settings-page categories, each fanning out to one or more stored types. */ +export type EmailNotificationCategory = "onboarding" | "transfers"; + +export const CATEGORY_TYPE_KEYS: Record = { + onboarding: [ + EmailNotificationType.VerificationApproved, + EmailNotificationType.VerificationRejected, + EmailNotificationType.VerificationExpired + ], + transfers: [EmailNotificationType.RampCompleted] +}; + +const CATEGORIES = Object.keys(CATEGORY_TYPE_KEYS) as EmailNotificationCategory[]; + +export interface NotificationPreferencesDto { + emailEnabled: boolean; + prefs: Record; +} + +export interface NotificationPreferencesUpdate { + emailEnabled?: boolean; + prefs: Record; +} + +/** + * Delivery requires the master switch on AND no type of the category muted, so that is + * what the checkbox reflects. A partial mute (written by something other than this page) + * shows as off; re-enabling rewrites every key of the category, which heals it. + */ +export function isCategoryEnabled(preferences: NotificationPreferencesDto, category: EmailNotificationCategory): boolean { + return preferences.emailEnabled && CATEGORY_TYPE_KEYS[category].every(type => preferences.prefs[type] !== false); +} + +/** Returns `prefs` with every type of `category` set, preserving unrelated keys. */ +function prefsWithCategory( + prefs: Record, + category: EmailNotificationCategory, + enabled: boolean +): Record { + return { ...prefs, ...Object.fromEntries(CATEGORY_TYPE_KEYS[category].map(type => [type, enabled])) }; +} + +/** + * The wire update a toggle produces. Normally only `prefs` changes. Enabling a category + * while the master switch is off additionally lifts the switch and pins every *other* + * category to muted — its current effective state — so turning one toggle on cannot + * silently re-enable the rest. + */ +export function preferencesUpdateFor( + current: NotificationPreferencesDto, + category: EmailNotificationCategory, + enabled: boolean +): NotificationPreferencesUpdate { + if (enabled && !current.emailEnabled) { + let prefs = { ...current.prefs }; + for (const other of CATEGORIES) { + if (other !== category) { + prefs = prefsWithCategory(prefs, other, false); + } + } + return { emailEnabled: true, prefs: prefsWithCategory(prefs, category, true) }; + } + + return { prefs: prefsWithCategory(current.prefs, category, enabled) }; +} + +export const NotificationPreferencesService = { + get(): Promise { + return apiClient.get("/notifications/preferences"); + }, + + update(update: NotificationPreferencesUpdate): Promise { + return apiClient.put("/notifications/preferences", update); + } +}; diff --git a/apps/frontend/public/vortex-mark-email.png b/apps/frontend/public/vortex-mark-email.png new file mode 100644 index 000000000..6a792907d Binary files /dev/null and b/apps/frontend/public/vortex-mark-email.png differ diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 09362dc72..30f2f27cb 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -75,7 +75,8 @@ const friendlyErrorMessages: Record = { [QuoteError.FailedToCalculatePreNablaDeductibleFees]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculateFeeComponents]: "pages.swap.error.tryDifferentAmount", [QuoteError.UnsupportedCurrency]: "pages.swap.error.unsupportedCurrency", - [QuoteError.AnchorTemporarilyUnavailable]: "pages.swap.error.anchorUnavailable" + [QuoteError.AnchorTemporarilyUnavailable]: "pages.swap.error.anchorUnavailable", + [QuoteError.NetworkFeesTooHigh]: "pages.swap.error.networkFeesTooHigh" }; function getFriendlyErrorMessage(error: unknown) { diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 0ba943eff..52c7f0a81 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1582,6 +1582,7 @@ "buy": "Maximum buy amount is {{maxAmountUnits}} {{assetSymbol}}.", "sell": "Maximum sell amount is {{maxAmountUnits}} {{assetSymbol}}." }, + "networkFeesTooHigh": "Destination network fees are temporarily too high. Please try again later.", "preNablaDeductibleFees": "Failed to get quote. Please try again with a different amount.", "quoteNotFound": "Quote not found", "tryDifferentAmount": "Failed to calculate quote. Please try a different amount.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 1a29211ad..94628c322 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1586,6 +1586,7 @@ "buy": "O valor máximo de compra é {{maxAmountUnits}} {{assetSymbol}}.", "sell": "O valor máximo de venda é {{maxAmountUnits}} {{assetSymbol}}." }, + "networkFeesTooHigh": "As taxas da rede de destino estão temporariamente muito altas. Tente novamente mais tarde.", "preNablaDeductibleFees": "Falha ao obter cotação. Por favor, tente novamente com um valor diferente.", "quoteNotFound": "Cotação não encontrada", "tryDifferentAmount": "Falha ao calcular cotação. Tente um valor diferente.", diff --git a/docs/README.md b/docs/README.md index aa9125fba..ab0ca4878 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ The smaller set of general project documents stays directly in `docs/`: |---|---| | [`adr-0001-user-gated-ramp-registration.md`](adr-0001-user-gated-ramp-registration.md) | Accepted architectural decision and rationale | | [`adr-0002-alfredpay-fee-collection.md`](adr-0002-alfredpay-fee-collection.md) | Accepted decision on Alfredpay fee collection and sequential EVM fee distribution | +| [`architecture-email-notifications.md`](architecture-email-notifications.md) | Current transactional/auth email architecture: queue, dispatch, producers | | [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | | [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | | [`operations-testing.md`](operations-testing.md) | Maintained test strategy and suite boundaries | diff --git a/docs/api/README.md b/docs/api/README.md index 69dc8366b..6907410cb 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -5,6 +5,7 @@ This directory is the repository source of truth for the partner-facing Vortex A ## Structure - `openapi/vortex.openapi.json` is the OpenAPI reference used for the Apidog endpoint catalog. +- `wire-contract.snapshot.md` is the generated snapshot of the typed partner-facing surface (shared endpoint types + public SDK API). CI fails when it is stale; regenerate with `bun run wire-contract:update` and review the diff for backward compatibility. - `pages/*.md` contains the pure Markdown guide pages that sit around the endpoint reference. - `apidog/page-manifest.json` records the intended page order, source files, current Apidog project ID, and endpoint grouping decisions. - `scripts/*.ts` contains the local export, validation, and type-generation helpers for this docs source. diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 05201359e..baeb34986 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2659,6 +2659,21 @@ }, "description": "Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message; unexpected internal failures remain masked.", "headers": {} + }, + "503": { + "content": { + "application/json": { + "example": { + "code": 503, + "message": "Destination network fees are temporarily too high. Please try again later." + }, + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable. Destination network fees currently exceed the configured safety limit; retry the quote later.", + "headers": {} } }, "security": [], @@ -2889,6 +2904,21 @@ }, "description": "Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message when every eligible route cannot serve the requested amount; unexpected internal failures remain masked.", "headers": {} + }, + "503": { + "content": { + "application/json": { + "example": { + "code": 503, + "message": "Destination network fees are temporarily too high. Please try again later." + }, + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable. Every eligible destination network currently exceeds the configured fee safety limit; retry the quote later.", + "headers": {} } }, "security": [], diff --git a/docs/api/wire-contract.snapshot.md b/docs/api/wire-contract.snapshot.md new file mode 100644 index 000000000..1bbf78c03 --- /dev/null +++ b/docs/api/wire-contract.snapshot.md @@ -0,0 +1,8132 @@ +# Wire-contract snapshot + +Generated by `bun run wire-contract:update` — do not edit by hand. + +This file is a canonical, structurally expanded rendering of the typed partner-facing +surface: the shared endpoint request/response types and the public SDK API. CI runs +`bun run wire-contract:check` and fails when this snapshot is stale, so every change +to what integrators consume appears as an explicit, reviewable diff in this file. +A diff here means: check backward compatibility for live integrations, and keep +`docs/api/openapi/vortex.openapi.json` and the SDK error mappings in sync. + +## packages/shared — partner wire contract (`src/endpoints`) + +```text +AcceptedRecipientInvite: { + id: string; + invitation: { + country: string; + id: string; + inviteeType: "business" | "individual"; + payoutCurrency: string; + rail: string; + }; + relationshipStatus: "active" | "archived"; +} + +AccountMeta: { + address: string; + type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; +} + +AlchemyPayPriceResponse: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "alchemypay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; +} + +AlfredpayAddFiatAccountRequest: { + accountBankCode?: string; + accountName?: string; + accountNumber: string; + accountType?: string; + bankCity?: string; + bankCountry?: string; + bankPostalCode?: string; + bankState?: string; + bankStreet?: string; + beneficiaryCity?: string; + beneficiaryCountry?: string; + beneficiaryPostalCode?: string; + beneficiaryState?: string; + beneficiaryStreet?: string; + country: string; + documentNumber?: string; + documentType?: string; + isExternal?: boolean; + routingNumber?: string; + type: enum AlfredpayFiatAccountType { ACH = "ACH", ACH_BOL = "ACH_BOL", ACH_CHL = "ACH_CHL", ACH_DOM = "ACH_DOM", B89 = "B89", BANK_CN = "BANK_CN", BANK_USA = "BANK_USA", COELSA = "COELSA", PIX = "PIX", SPEI = "SPEI" }; +} + +AlfredpayAddFiatAccountResponse: { + fiatAccountId: string; +} + +AlfredpayCreateCustomerRequest: { + country: string; +} + +AlfredpayCreateCustomerResponse: { + createdAt: string; +} + +AlfredpayDeleteFiatAccountRequest: { + country: string; +} + +AlfredpayFiatAccountRequirement: { + field: string; + hint?: string; + label: string; + options?: Array<{ + label: string; + value: string; + }>; + placeholder?: string; + required: boolean; + type: "email" | "phone" | "select" | "text"; +} + +AlfredpayFiatAccountRequirementsRequest: { + country: string; + paymentMethod: string; +} + +AlfredpayFiatAccountRequirementsResponse: Array<{ + field: string; + hint?: string; + label: string; + options?: Array<{ + label: string; + value: string; + }>; + placeholder?: string; + required: boolean; + type: "email" | "phone" | "select" | "text"; +}> + +AlfredpayGetKybRedirectLinkResponse: { + submissionId: string; + verification_url: string; +} + +AlfredpayGetKybStatusRequest: { + country: string; + type?: AlfredpayCustomerType.BUSINESS | AlfredpayCustomerType.INDIVIDUAL; +} + +AlfredpayGetKybStatusResponse: { + alfred_pay_id: string; + country: string; + lastFailure?: string; + status: enum AlfredPayStatus { Consulted = "CONSULTED", Failed = "FAILED", LinkOpened = "LINK_OPENED", Success = "SUCCESS", UpdateRequired = "UPDATE_REQUIRED", UserCompleted = "USER_COMPLETED", Verifying = "VERIFYING" }; + updated_at: string; +} + +AlfredpayGetKycRedirectLinkRequest: { + country: string; + type?: AlfredpayCustomerType.BUSINESS | AlfredpayCustomerType.INDIVIDUAL; +} + +AlfredpayGetKycRedirectLinkResponse: { + submissionId: string; + verification_url: string; +} + +AlfredpayGetKycStatusRequest: { + country: string; + type?: AlfredpayCustomerType.BUSINESS | AlfredpayCustomerType.INDIVIDUAL; +} + +AlfredpayGetKycStatusResponse: { + alfred_pay_id: string; + country: string; + lastFailure?: string; + status: enum AlfredPayStatus { Consulted = "CONSULTED", Failed = "FAILED", LinkOpened = "LINK_OPENED", Success = "SUCCESS", UpdateRequired = "UPDATE_REQUIRED", UserCompleted = "USER_COMPLETED", Verifying = "VERIFYING" }; + updated_at: string; +} + +AlfredpayKycRedirectFinishedRequest: { + country: string; + type?: AlfredpayCustomerType.BUSINESS | AlfredpayCustomerType.INDIVIDUAL; +} + +AlfredpayKycRedirectFinishedResponse: { + success: boolean; +} + +AlfredpayKycRedirectOpenedRequest: { + country: string; + type?: AlfredpayCustomerType.BUSINESS | AlfredpayCustomerType.INDIVIDUAL; +} + +AlfredpayKycRedirectOpenedResponse: { + success: boolean; +} + +AlfredpayListFiatAccountsRequest: { + country: string; +} + +AlfredpayListFiatAccountsResponse: Array<{ + accountName?: string; + accountNumber: string; + accountType: string; + bankCity?: string; + bankCountry?: string; + bankPostalCode?: string; + bankState?: string; + bankStreet?: string; + createdAt?: string; + customerId: string; + fiatAccountId: string; + metadata?: { + accountHolderName?: string; + bankCity?: string; + bankCountry?: string; + bankPostalCode?: string; + bankState?: string; + bankStreet?: string; + beneficiaryAddress?: { + city?: string; + country?: string; + postalCode?: string; + stateProvince?: string; + street?: string; + }; + documentNumber?: string; + documentType?: string; + }; + routingNumber?: string; + type: enum AlfredpayFiatAccountType { ACH = "ACH", ACH_BOL = "ACH_BOL", ACH_CHL = "ACH_CHL", ACH_DOM = "ACH_DOM", B89 = "B89", BANK_CN = "BANK_CN", BANK_USA = "BANK_USA", COELSA = "COELSA", PIX = "PIX", SPEI = "SPEI" }; +}> + +AlfredpayRetryKycRequest: { + country: string; + type?: AlfredpayCustomerType.BUSINESS | AlfredpayCustomerType.INDIVIDUAL; +} + +AlfredpayStatusRequest: { + country: string; +} + +AlfredpayStatusResponse: { + country: string; + creationTime: string; + status: enum AlfredPayStatus { Consulted = "CONSULTED", Failed = "FAILED", LinkOpened = "LINK_OPENED", Success = "SUCCESS", UpdateRequired = "UPDATE_REQUIRED", UserCompleted = "USER_COMPLETED", Verifying = "VERIFYING" }; +} + +AllPricesResponse: { + alchemypay?: { + reason: { + message: string; + status?: number; + }; + status: "rejected"; + } | { + status: "fulfilled"; + value: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "alchemypay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "moonpay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "transak"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "vortex"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + }; + }; + moonpay?: { + reason: { + message: string; + status?: number; + }; + status: "rejected"; + } | { + status: "fulfilled"; + value: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "alchemypay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "moonpay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "transak"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "vortex"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + }; + }; + transak?: { + reason: { + message: string; + status?: number; + }; + status: "rejected"; + } | { + status: "fulfilled"; + value: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "alchemypay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "moonpay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "transak"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "vortex"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + }; + }; + vortex?: { + reason: { + message: string; + status?: number; + }; + status: "rejected"; + } | { + status: "fulfilled"; + value: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "alchemypay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "moonpay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "transak"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "vortex"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + }; + }; +} + +AssethubToBrlaStorageRequest: { + flowType: OfframpHandlerType.ASSETHUB_TO_BRLA; + inputAmount: string; + inputTokenType: string; + nablaApprovalTx: string; + nablaSwapTx: string; + offramperAddress: string; + outputAmount: string; + outputTokenType: string; + pendulumEphemeralPublicKey: string; + pendulumToMoonbeamXcmTx: string; + timestamp: string; +} + +AveniaKYCDataUpload: { + idUpload: { + id: string; + uploadURLBack?: string; + uploadURLFront: string; + }; + selfieUpload: { + id: string; + livenessUrl?: string; + uploadURLFront: string; + validateLivenessToken?: string; + }; +} + +AveniaKYCDataUploadRequest: { + documentType: enum AveniaDocumentType { DRIVERS_LICENSE = "DRIVERS-LICENSE", ID = "ID", PASSPORT = "PASSPORT", SELFIE = "SELFIE", SELFIE_FROM_LIVENESS = "SELFIE-FROM-LIVENESS" }; + isDoubleSided?: boolean; + taxId: string; +} + +BrlaAddress: { + cep: string; + city: string; + complement?: string; + district: string; + number: string; + state: string; + street: string; +} + +BrlaCreateSubaccountRequest: { + accountType: enum AveniaAccountType { COMPANY = "COMPANY", INDIVIDUAL = "INDIVIDUAL" }; + name: string; + quoteId?: string; + sessionId?: string; + taxId: string; +} + +BrlaCreateSubaccountResponse: { + subAccountId: string; +} + +BrlaErrorResponse: { + details?: string; + error: string; +} + +BrlaGetKycStatusRequest: { + quoteId: string; + sessionId?: string; + taxId: string; +} + +BrlaGetKycStatusResponse: { + failureReason?: KycFailureReason.BIRTHDATE | KycFailureReason.FACE | KycFailureReason.NAME | KycFailureReason.TAX_ID | KycFailureReason.UNKNOWN; + level: string; + result: enum KycAttemptResult { APPROVED = "APPROVED", REJECTED = "REJECTED" }; + status: enum KycAttemptStatus { COMPLETED = "COMPLETED", EXPIRED = "EXPIRED", PENDING = "PENDING", PROCESSING = "PROCESSING" }; + type: "KYC"; +} + +BrlaGetRampStatusRequest: { + taxId: string; +} + +BrlaGetRampStatusResponse: { + status: string; + type: string; +} + +BrlaGetSelfieLivenessUrlRequest: { + taxId: string; +} + +BrlaGetSelfieLivenessUrlResponse: { + id: string; + livenessUrl: string; + uploadURLFront: string; + validateLivenessToken: string; +} + +BrlaGetUserRemainingLimitRequest: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + taxId: string; +} + +BrlaGetUserRemainingLimitResponse: { + remainingLimit: number; +} + +BrlaGetUserRequest: { + taxId: string; +} + +BrlaGetUserResponse: { + evmAddress: string; + identityStatus: "CONFIRMED" | "NOT-IDENTIFIED"; + kycLevel: number; + subAccountId: string; +} + +BrlaKYCDocType: enum BrlaKYCDocType { CNH = "CNH", RG = "RG" } + +BrlaPostRecordInitialKycAttemptRequest: { + quoteId: string; + sessionId?: string; + taxId: string; +} + +BrlaToAssethubStorageRequest: { + flowType: OnrampHandlerType.BRLA_TO_ASSETHUB; + inputAmount: string; + inputTokenType: string; + moonbeamToPendulumXcmTx: string; + nablaApprovalTx: string; + nablaSwapTx: string; + outputAmount: string; + outputTokenType: string; + pendulumEphemeralPublicKey: string; + pendulumToAssetHubXcmTx: string; + timestamp: string; +} + +BrlaToEvmStorageRequest: { + flowType: OnrampHandlerType.BRLA_TO_EVM; + inputAmount: string; + inputTokenType: string; + moonbeamToPendulumXcmTx: string; + nablaApprovalTx: string; + nablaSwapTx: string; + outputAmount: string; + outputTokenType: string; + pendulumEphemeralPublicKey: string; + pendulumToMoonbeamXcmTx: string; + squidRouterApproveTx: string; + squidRouterSwapTx: string; + timestamp: string; +} + +BrlaValidatePixKeyRequest: { + pixKey: string; +} + +BrlaValidatePixKeyResponse: { + valid: boolean; +} + +BundledPriceResult: { + reason: { + message: string; + status?: number; + }; + status: "rejected"; +} | { + status: "fulfilled"; + value: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "alchemypay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "moonpay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "transak"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + } | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "vortex"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; + }; +} + +CleanupPhase: "assetHubCleanup" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "ethereumCleanupUsdc" | "hydrationCleanup" | "moonbeamCleanup" | "pendulumCleanup" | "polygonCleanup" | "polygonCleanupAxlUsdc" + +CreateBestQuoteRequest: { + api?: boolean; + apiKey?: string; + countryCode?: string; + from?: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + networks?: Array; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerId?: string; + paymentMethod?: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + to?: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; +} + +CreateQuoteRequest: { + api?: boolean; + apiKey?: string; + countryCode?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerId?: string; + paymentMethod?: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; +} + +CreateSiweRequest: { + walletAddress: string; +} + +CreateSiweResponse: { + nonce: string; +} + +CryptoCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT + +Currency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD + +DeleteWebhookRequest: { + id: string; +} + +DeleteWebhookResponse: { + message: string; + success: boolean; +} + +EPaymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" } + +EphemeralAccountType: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" } + +EvmToBrlaStorageRequest: { + flowType: OfframpHandlerType.EVM_TO_BRLA; + inputAmount: string; + inputTokenType: string; + nablaApprovalTx: string; + nablaSwapTx: string; + offramperAddress: string; + outputAmount: string; + outputTokenType: string; + pendulumEphemeralPublicKey: string; + pendulumToMoonbeamXcmTx: string; + squidRouterReceiverHash: string; + squidRouterReceiverId: string; + timestamp: string; +} + +EvmTransactionData: { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; +} + +FiatCurrency: FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD + +FlowType: OfframpHandlerType.ASSETHUB_TO_BRLA | OfframpHandlerType.EVM_TO_BRLA | OnrampHandlerType.BRLA_TO_ASSETHUB | OnrampHandlerType.BRLA_TO_EVM + +GetQuoteRequest: { + id: string; +} + +GetRampErrorLogsRequest: { + id: string; +} + +GetRampErrorLogsResponse: Array<{ + details?: string; + error: string; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + recoverable?: boolean; + timestamp: string; +}> + +GetRampHistoryRequest: { + walletAddress: string; +} + +GetRampHistoryResponse: { + totalCount: number; + transactions: Array<{ + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + date: string; + expiresAt: string; + externalTxExplorerLink?: string; + externalTxHash?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + fromAmount: string; + fromCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + id: string; + status: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" }; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + toAmount: string; + toCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + walletAddress?: string; + }>; +} + +GetRampHistoryTransaction: { + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + date: string; + expiresAt: string; + externalTxExplorerLink?: string; + externalTxHash?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + fromAmount: string; + fromCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + id: string; + status: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" }; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + toAmount: string; + toCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + walletAddress?: string; +} + +GetRampInfoResponse: { + corridors: Record; +} + +GetRampStatusRequest: { + id: string; +} + +GetRampStatusResponse: { + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt?: string; + feeCurrency: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: string; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + walletAddress?: string; +} + +GetSupportedCountriesRequest: { + countryCode?: string; + fiatCurrency?: FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + name?: string; +} + +GetSupportedCountriesResponse: { + countries: Array<{ + countryCode: string; + emoji: string; + name: string; + support: { + buy: boolean; + sell: boolean; + }; + supportedCurrencies: Array; + }>; +} + +GetSupportedCountryResponse: { + country: { + countryCode: string; + emoji: string; + name: string; + support: { + buy: boolean; + sell: boolean; + }; + supportedCurrencies: Array; + }; +} + +GetSupportedCryptocurrenciesRequest: { + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; +} + +GetSupportedCryptocurrenciesResponse: { + cryptocurrencies: Array<{ + assetContractAddress: string; + assetDecimals: number; + assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + assetSymbol: string; + } | { + assetDecimals: number; + assetForeignAssetId?: number; + assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + assetSymbol: string; + }>; +} + +GetSupportedFiatCurrenciesRequest: { + [key: string]: never; +} + +GetSupportedFiatCurrenciesResponse: { + currencies: Array<{ + decimals: number; + enabled: boolean; + name: string; + symbol: enum FiatToken { ARS = "ARS", BRL = "BRL", COP = "COP", EURC = "EUR", MXN = "MXN", USD = "USD" }; + }>; +} + +GetSupportedPaymentMethodsRequest: { + fiat: enum FiatToken { ARS = "ARS", BRL = "BRL", COP = "COP", EURC = "EUR", MXN = "MXN", USD = "USD" }; + type: "buy" | "sell"; +} + +GetSupportedPaymentMethodsResponse: { + paymentMethods: Array<{ + id: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + name: enum PaymentMethodName { ACH = "ACH", CBU = "CBU", PIX = "PIX", SEPA = "SEPA", SPEI = "SPEI", WIRE = "WIRE" }; + supportedFiats: Array<{ + id: enum FiatToken { ARS = "ARS", BRL = "BRL", COP = "COP", EURC = "EUR", MXN = "MXN", USD = "USD" }; + limits: { + max: number; + min: number; + }; + name: string; + }>; + }>; +} + +GetUserLimitsRequest: { + corridors: Array<"AR" | "BR" | "CO" | "MX" | "US">; +} + +GetUserLimitsResponse: { + limits: Array<{ + corridor: "AR" | "BR" | "CO" | "MX" | "US"; + currency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + max: string; + period: { + endsAt: string; + startsAt: string; + type: "calendar_month"; + }; + used: string; + }>; +} + +GetWidgetUrlLocked: { + callbackUrl?: string; + externalSessionId: string; + quoteId: string; + walletAddressLocked?: string; +} + +GetWidgetUrlRefresh: { + apiKey?: string; + callbackUrl?: string; + countryCode?: string; + cryptoLocked?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT; + externalSessionId: string; + fiat?: FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + inputAmount: string; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + partnerId?: string; + paymentMethod?: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + walletAddressLocked?: string; +} + +GetWidgetUrlResponse: { + url: string; +} + +IbanPaymentData: { + bic: string; + iban: string; + receiverName: string; + reference?: string; +} + +KycFailureReason: enum KycFailureReason { BIRTHDATE = "birthdate", FACE = "face", NAME = "name", TAX_ID = "tax_id", UNKNOWN = "unknown" } + +LimitsCorridor: "AR" | "BR" | "CO" | "MX" | "US" + +MoonbeamExecuteXcmRequest: { + id: string; + payload: string; +} + +MoonbeamExecuteXcmResponse: { + hash: `0x${string}`; +} + +MoonpayPriceResponse: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "moonpay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; +} + +OfframpHandlerType: enum OfframpHandlerType { ASSETHUB_TO_BRLA = "assethub-to-brla", EVM_TO_BRLA = "evm-to-brla" } + +OnrampHandlerType: enum OnrampHandlerType { BRLA_TO_ASSETHUB = "brla-to-assethub", BRLA_TO_EVM = "brla-to-evm" } + +PaymentData: { + amount: string; + memo: string; + memoType: "hash" | "id" | "text"; +} + +PaymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" } + +PaymentMethodConfig: { + id: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + name: enum PaymentMethodName { ACH = "ACH", CBU = "CBU", PIX = "PIX", SEPA = "SEPA", SPEI = "SPEI", WIRE = "WIRE" }; + supportedFiats: Array<{ + id: enum FiatToken { ARS = "ARS", BRL = "BRL", COP = "COP", EURC = "EUR", MXN = "MXN", USD = "USD" }; + limits: { + max: number; + min: number; + }; + name: string; + }>; +} + +PaymentMethodConfigFiatToken: { + id: enum FiatToken { ARS = "ARS", BRL = "BRL", COP = "COP", EURC = "EUR", MXN = "MXN", USD = "USD" }; + limits: { + max: number; + min: number; + }; + name: string; +} + +PaymentMethodLimits: { + max: number; + min: number; +} + +PaymentMethodName: enum PaymentMethodName { ACH = "ACH", CBU = "CBU", PIX = "PIX", SEPA = "SEPA", SPEI = "SPEI", WIRE = "WIRE" } + +PaymentMethodType: "buy" | "sell" + +PaymentMethodTypes: enum PaymentMethodTypes { BUY = "buy", SELL = "sell" } + +PendulumFundEphemeralErrorResponse: { + details?: string; + error: string; +} + +PendulumFundEphemeralRequest: { + ephemeralAddress: string; + requiresGlmr?: boolean; +} + +PendulumFundEphemeralResponse: { + data: undefined; + status: "success"; +} + +PresignedTx: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; +} + +PriceErrorResponse: { + error: string; +} + +PriceProvider: "alchemypay" | "moonpay" | "transak" | "vortex" + +PriceRequest: { + amount: string; + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + network?: string; + provider: "alchemypay" | "moonpay" | "transak" | "vortex"; + sourceCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + targetCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; +} + +PriceResponse: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "alchemypay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; +} | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "moonpay"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; +} | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "transak"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; +} | { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "vortex"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; +} + +PriceResponseBase: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + quoteAmount: number; + requestedAmount: number; + totalFee: number; +} + +QuoteError: enum QuoteError { AboveUpperLimitBuy = "Input amount exceeds maximum BUY limit of", AboveUpperLimitSell = "Output amount exceeds maximum SELL limit of", AnchorTemporarilyUnavailable = "This payment provider is temporarily unavailable. Please try again in a few minutes.", AssetHubNotSupportedForAlfredPay = "AssetHub is not supported for this currency. Please select a different network.", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", BelowLowerLimitSell = "Output amount below minimum SELL limit of", FailedToCalculateFeeComponents = "Failed to calculate fee components", FailedToCalculatePreNablaDeductibleFees = "Failed to calculate pre-Nabla deductible fees", FailedToCalculateQuote = "Failed to calculate the quote. Please try a lower amount.", InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", InputAmountTooLowToCoverFees = "Input amount too low to cover fees", InvalidNetworks = "Invalid 'networks' value: must be an array of valid network identifiers", InvalidRampType = "Invalid ramp type, must be \"BUY\" or \"SELL\"", LowLiquidity = "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", MissingFromField = "BUY rampType requires 'from' parameter", MissingRequiredFields = "Missing required fields", MissingToField = "SELL rampType requires 'to' parameter", NetworkFeesTooHigh = "Destination network fees are temporarily too high. Please try again later.", QuoteNotFound = "Quote not found", UnableToGetPendulumTokenDetails = "Unable to get Pendulum token details", UnsupportedCurrency = "Currency not supported" } + +QuoteFeeStructure: { + anchor: string; + currency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: string; + partnerMarkup: string; + total: string; + vortex: string; +} + +QuoteResponse: { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} + +RampErrorLog: { + details?: string; + error: string; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + recoverable?: boolean; + timestamp: string; +} + +RampPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut" + +RampProcess: { + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; +} + +RecipientInviteeType: "business" | "individual" + +RegisterRampRequest: { + additionalData?: { + [key: string]: unknown; + destinationAddress?: string; + email?: string; + fiatAccountId?: string; + ipAddress?: string; + paymentData?: { + amount: string; + memo: string; + memoType: "hash" | "id" | "text"; + }; + pixDestination?: string; + receiverTaxId?: string; + recipientId?: undefined; + recipientPayoutReferenceId?: undefined; + recipientRelationshipId?: undefined; + senderRecipientId?: undefined; + sessionId?: string; + taxId?: string; + walletAddress?: string; + }; + quoteId: string; + signingAccounts: Array<{ + address: string; + type: enum EphemeralAccountType { EVM = "EVM", Substrate = "Substrate" }; + }>; + userId?: string; +} + +RegisterRampResponse: { + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; +} + +RegisterWebhookRequest: { + events?: Array; + quoteId?: string; + sessionId?: string; + url: string; +} + +RegisterWebhookResponse: { + createdAt: string; + events: Array; + id: string; + isActive: boolean; + quoteId: null | string; + sessionId: null | string; + url: string; +} + +SUPPORTED_COUNTRIES: Array<{ + countryCode: string; + emoji: string; + name: string; + support: { + buy: boolean; + sell: boolean; + }; + supportedCurrencies: Array; +}> + +SUPPORTED_FIAT_CURRENCIES: Array<{ + decimals: number; + enabled: boolean; + name: string; + symbol: enum FiatToken { ARS = "ARS", BRL = "BRL", COP = "COP", EURC = "EUR", MXN = "MXN", USD = "USD" }; +}> + +Signature: { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; +} + +SignedTypedData: { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; +} + +SiweErrorResponse: { + error: string; +} + +StartRampRequest: { + rampId: string; +} + +StartRampResponse: { + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; +} + +StatusChangeWebhookPayload: { + eventId: string; + eventType: WebhookEventType.STATUS_CHANGE; + payload: { + quoteId: string; + sessionId: null | string; + transactionId: string; + transactionStatus: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" }; + transactionType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + }; + timestamp: string; +} + +StorageRequestBase: { + flowType: OfframpHandlerType.ASSETHUB_TO_BRLA | OfframpHandlerType.EVM_TO_BRLA | OnrampHandlerType.BRLA_TO_ASSETHUB | OnrampHandlerType.BRLA_TO_EVM; + inputAmount: string; + inputTokenType: string; + nablaApprovalTx: string; + nablaSwapTx: string; + outputAmount: string; + outputTokenType: string; + pendulumEphemeralPublicKey: string; + timestamp: string; +} + +StoreDataErrorResponse: { + details?: string; + error: string; +} + +StoreDataRequest: { + flowType: OfframpHandlerType.ASSETHUB_TO_BRLA; + inputAmount: string; + inputTokenType: string; + nablaApprovalTx: string; + nablaSwapTx: string; + offramperAddress: string; + outputAmount: string; + outputTokenType: string; + pendulumEphemeralPublicKey: string; + pendulumToMoonbeamXcmTx: string; + timestamp: string; +} | { + flowType: OfframpHandlerType.EVM_TO_BRLA; + inputAmount: string; + inputTokenType: string; + nablaApprovalTx: string; + nablaSwapTx: string; + offramperAddress: string; + outputAmount: string; + outputTokenType: string; + pendulumEphemeralPublicKey: string; + pendulumToMoonbeamXcmTx: string; + squidRouterReceiverHash: string; + squidRouterReceiverId: string; + timestamp: string; +} | { + flowType: OnrampHandlerType.BRLA_TO_ASSETHUB; + inputAmount: string; + inputTokenType: string; + moonbeamToPendulumXcmTx: string; + nablaApprovalTx: string; + nablaSwapTx: string; + outputAmount: string; + outputTokenType: string; + pendulumEphemeralPublicKey: string; + pendulumToAssetHubXcmTx: string; + timestamp: string; +} | { + flowType: OnrampHandlerType.BRLA_TO_EVM; + inputAmount: string; + inputTokenType: string; + moonbeamToPendulumXcmTx: string; + nablaApprovalTx: string; + nablaSwapTx: string; + outputAmount: string; + outputTokenType: string; + pendulumEphemeralPublicKey: string; + pendulumToMoonbeamXcmTx: string; + squidRouterApproveTx: string; + squidRouterSwapTx: string; + timestamp: string; +} + +StoreDataResponse: { + message: string; +} + +StoreEmailErrorResponse: { + details?: string; + error: string; +} + +StoreEmailRequest: { + email: string; + timestamp: string; + transactionId: string; +} + +StoreEmailResponse: { + message: string; +} + +StoreRatingErrorResponse: { + details?: string; + error: string; +} + +StoreRatingRequest: { + rating: number; + timestamp: string; + walletAddress: string; +} + +StoreRatingResponse: { + message: string; +} + +SubmitContactErrorResponse: { + details?: string; + error: string; +} + +SubmitContactRequest: { + email: string; + fullName: string; + inquiry: string; + projectName: string; + timestamp: string; +} + +SubmitContactResponse: { + message: string; +} + +SubsidizeErrorResponse: { + details?: string; + error: string; +} + +SubsidizePostSwapRequest: { + address: string; + amountRaw: string; + token: string; +} + +SubsidizePostSwapResponse: { + message: string; +} + +SubsidizePreSwapRequest: { + address: string; + amountRaw: string; + tokenToSubsidize: string; +} + +SubsidizePreSwapResponse: { + message: string; +} + +SupportedAssetHubCryptocurrencyDetails: { + assetDecimals: number; + assetForeignAssetId?: number; + assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + assetSymbol: string; +} + +SupportedCountry: { + countryCode: string; + emoji: string; + name: string; + support: { + buy: boolean; + sell: boolean; + }; + supportedCurrencies: Array; +} + +SupportedCryptocurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT + +SupportedCryptocurrencyDetails: { + assetContractAddress: string; + assetDecimals: number; + assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + assetSymbol: string; +} | { + assetDecimals: number; + assetForeignAssetId?: number; + assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + assetSymbol: string; +} + +SupportedCryptocurrencyDetailsBase: { + assetDecimals: number; + assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + assetSymbol: string; +} + +SupportedEVMCryptocurrencyDetails: { + assetContractAddress: string; + assetDecimals: number; + assetNetwork: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + assetSymbol: string; +} + +SupportedFiatCurrency: { + decimals: number; + enabled: boolean; + name: string; + symbol: enum FiatToken { ARS = "ARS", BRL = "BRL", COP = "COP", EURC = "EUR", MXN = "MXN", USD = "USD" }; +} + +TaxIdType: "CNPJ" | "CPF" + +TransactionCreatedWebhookPayload: { + eventId: string; + eventType: WebhookEventType.TRANSACTION_CREATED; + payload: { + quoteId: string; + sessionId: null | string; + transactionId: string; + transactionStatus: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" }; + transactionType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + }; + timestamp: string; +} + +TransactionStatus: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" } + +TransakPriceResponse: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "transak"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; +} + +TypedDataDomain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; +} + +TypedDataField: { + name: string; + type: string; +} + +UnsignedTx: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; +} + +UpdateRampRequest: { + additionalData?: { + [key: string]: unknown; + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterNoPermitApproveHash?: string; + squidRouterNoPermitSwapHash?: string; + squidRouterNoPermitTransferHash?: string; + squidRouterSwapHash?: string; + }; + presignedTxs: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + rampId: string; +} + +UpdateRampResponse: { + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; +} + +UserLimit: { + corridor: "AR" | "BR" | "CO" | "MX" | "US"; + currency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + max: string; + period: { + endsAt: string; + startsAt: string; + type: "calendar_month"; + }; + used: string; +} + +UserLimitPeriod: { + endsAt: string; + startsAt: string; + type: "calendar_month"; +} + +VALID_CRYPTO_CURRENCIES: Array + +VALID_FIAT_CURRENCIES: Array + +VALID_PROVIDERS: readonly ["alchemypay", "moonpay", "transak", "vortex"] + +ValidateSiweRequest: { + nonce: string; + signature: string; + siweMessage: string; +} + +ValidateSiweResponse: { + message: string; +} + +VortexPriceResponse: { + direction: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + provider: "vortex"; + quoteAmount: number; + requestedAmount: number; + totalFee: number; +} + +WebhookDeliveryAttempt: { + attempt: number; + maxAttempts: number; + nextRetryAt?: Date; + payload: { + eventId: string; + eventType: WebhookEventType.STATUS_CHANGE; + payload: { + quoteId: string; + sessionId: null | string; + transactionId: string; + transactionStatus: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" }; + transactionType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + }; + timestamp: string; + } | { + eventId: string; + eventType: WebhookEventType.TRANSACTION_CREATED; + payload: { + quoteId: string; + sessionId: null | string; + transactionId: string; + transactionStatus: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" }; + transactionType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + }; + timestamp: string; + }; + url: string; + webhookId: string; +} + +WebhookEventType: enum WebhookEventType { STATUS_CHANGE = "STATUS_CHANGE", TRANSACTION_CREATED = "TRANSACTION_CREATED" } + +WebhookPayload: { + eventId: string; + eventType: WebhookEventType.STATUS_CHANGE; + payload: { + quoteId: string; + sessionId: null | string; + transactionId: string; + transactionStatus: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" }; + transactionType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + }; + timestamp: string; +} | { + eventId: string; + eventType: WebhookEventType.TRANSACTION_CREATED; + payload: { + quoteId: string; + sessionId: null | string; + transactionId: string; + transactionStatus: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" }; + transactionType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + }; + timestamp: string; +} + +WebhookPayloadBase: { + quoteId: string; + sessionId: null | string; + transactionId: string; + transactionStatus: enum TransactionStatus { COMPLETE = "COMPLETE", FAILED = "FAILED", PENDING = "PENDING" }; + transactionType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; +} + +isEvmTransactionData: (data: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; +}> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; +} | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; +}) => boolean + +isSignedTypedData: (data: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; +}> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; +} | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; +}) => boolean + +isSignedTypedDataArray: (data: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; +}> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; +} | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; +}) => boolean + +isSupportedFiatCurrency: (value: unknown) => boolean + +isValidCryptoCurrency: (value: unknown) => boolean + +isValidCurrencyForDirection: (currency: unknown, expectedType: "crypto" | "fiat") => boolean + +isValidDirection: (value: unknown) => boolean + +isValidFiatCurrency: (value: unknown) => boolean + +isValidPriceProvider: (value: unknown) => boolean +``` + +## packages/sdk — public SDK surface (`src/index.ts`) + +```text +APIConnectionError: class APIConnectionError { + constructor(endpoint: string, originalError?: Error); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +APIErrorResponse: { + code?: string; + errors?: Array; + isPublic?: boolean; + message: string; + status: number; +} + +APINotInitializedError: class APINotInitializedError { + constructor(apiName: string); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +APIResponseError: class APIResponseError { + constructor(endpoint: string, status: number, statusText: string); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +AlfredPayCountry: enum AlfredPayCountry { AR = "AR", BO = "BO", BR = "BR", CL = "CL", CN = "CN", CO = "CO", DO = "DO", HK = "HK", MX = "MX", PE = "PE", US = "US" } + +AlfredpayCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD + +AlfredpayFiatAccount: { + accountName?: string; + accountNumber: string; + accountType: string; + bankCity?: string; + bankCountry?: string; + bankPostalCode?: string; + bankState?: string; + bankStreet?: string; + createdAt?: string; + customerId: string; + fiatAccountId: string; + metadata?: { + accountHolderName?: string; + bankCity?: string; + bankCountry?: string; + bankPostalCode?: string; + bankState?: string; + bankStreet?: string; + beneficiaryAddress?: { + city?: string; + country?: string; + postalCode?: string; + stateProvince?: string; + street?: string; + }; + documentNumber?: string; + documentType?: string; + }; + routingNumber?: string; + type: enum AlfredpayFiatAccountType { ACH = "ACH", ACH_BOL = "ACH_BOL", ACH_CHL = "ACH_CHL", ACH_DOM = "ACH_DOM", B89 = "B89", BANK_CN = "BANK_CN", BANK_USA = "BANK_USA", COELSA = "COELSA", PIX = "PIX", SPEI = "SPEI" }; +} + +AlfredpayFiatAccountType: enum AlfredpayFiatAccountType { ACH = "ACH", ACH_BOL = "ACH_BOL", ACH_CHL = "ACH_CHL", ACH_DOM = "ACH_DOM", B89 = "B89", BANK_CN = "BANK_CN", BANK_USA = "BANK_USA", COELSA = "COELSA", PIX = "PIX", SPEI = "SPEI" } + +AlfredpayOfframpAdditionalData: { + fiatAccountId: string; + sessionId?: string; + walletAddress: string; +} + +AlfredpayOfframpError: class AlfredpayOfframpError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +AlfredpayOfframpQuote: { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + outputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.SELL; +} + +AlfredpayOfframpUpdateAdditionalData: { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; +} + +AlfredpayOnrampAdditionalData: { + destinationAddress: string; + fiatAccountId?: string; + sessionId?: string; + walletAddress?: string; +} + +AlfredpayOnrampError: class AlfredpayOnrampError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +AlfredpayOnrampKycRequiredError: class AlfredpayOnrampKycRequiredError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +AlfredpayOnrampQuote: { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + inputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.BUY; +} + +AmountExceedsLimitError: class AmountExceedsLimitError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +AnyAdditionalData: { + destinationAddress: string; + email: string; + ipAddress: string; + walletAddress: string; +} | { + destinationAddress: string; + email: string; + ipAddress: string; +} | { + destinationAddress: string; + fiatAccountId?: string; + sessionId?: string; + walletAddress?: string; +} | { + destinationAddress: string; + taxId?: string; +} | { + fiatAccountId: string; + sessionId?: string; + walletAddress: string; +} | { + pixDestination: string; + receiverTaxId?: string; + taxId?: string; + walletAddress: string; +} + +AnyQuote: { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.PIX; + rampType: RampDirection.BUY; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.SEPA; + rampType: RampDirection.BUY; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + inputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.BUY; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + outputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.SELL; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.PIX; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.SEPA; +} + +AnyUpdateAdditionalData: { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; +} + +BrlKycResponse: { + evmAddress: string; + kycLevel: number; +} + +BrlKycStatusError: class BrlKycStatusError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +BrlOfframpAdditionalData: { + pixDestination: string; + receiverTaxId?: string; + taxId?: string; + walletAddress: string; +} + +BrlOfframpError: class BrlOfframpError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +BrlOfframpQuote: { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.PIX; +} + +BrlOfframpUpdateAdditionalData: { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; +} + +BrlOnrampAdditionalData: { + destinationAddress: string; + taxId?: string; +} + +BrlOnrampError: class BrlOnrampError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +BrlOnrampQuote: { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.PIX; + rampType: RampDirection.BUY; +} + +CreateQuoteRequest: { + api?: boolean; + apiKey?: string; + countryCode?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerId?: string; + paymentMethod?: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; +} + +EPaymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" } + +EphemeralChain: "EVM" | "Substrate" + +EphemeralFreshnessCheckError: class EphemeralFreshnessCheckError { + constructor(message: string, chain: "EVM" | "Substrate", ephemeralAddress: string); + readonly chain: "EVM" | "Substrate"; + readonly code?: string; + readonly ephemeralAddress: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +EphemeralGenerationError: class EphemeralGenerationError { + constructor(network: string, originalError?: Error); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +EphemeralNotFreshError: class EphemeralNotFreshError { + constructor(message: string, chain: "EVM" | "Substrate", ephemeralAddress: string, status?: number); + readonly chain: "EVM" | "Substrate"; + readonly code?: string; + readonly ephemeralAddress: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +EurOfframpAdditionalData: { + destinationAddress: string; + email: string; + ipAddress: string; + walletAddress: string; +} + +EurOfframpQuote: { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.SEPA; +} + +EurOfframpUpdateAdditionalData: { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; +} + +EurOnrampAdditionalData: { + destinationAddress: string; + email: string; + ipAddress: string; +} + +EurOnrampQuote: { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.SEPA; + rampType: RampDirection.BUY; +} + +EvmToken: enum EvmToken { AXLUSDC = "AXLUSDC", BRLA = "BRLA", ETH = "ETH", EURC = "EURC", POL = "POL", USDC = "USDC", USDCE = "USDC.E", USDT = "USDT" } + +EvmTransactionData: { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; +} + +ExtendedQuoteResponse: T extends { + inputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.BUY; +} ? { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + inputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.BUY; +} : T extends { + from: EPaymentMethod.PIX; + rampType: RampDirection.BUY; +} ? { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.PIX; + rampType: RampDirection.BUY; +} : T extends { + from: EPaymentMethod.SEPA; + rampType: RampDirection.BUY; +} ? { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.SEPA; + rampType: RampDirection.BUY; +} : T extends { + outputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.SELL; +} ? { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + outputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.SELL; +} : T extends { + rampType: RampDirection.SELL; + to: EPaymentMethod.PIX; +} ? { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.PIX; +} : T extends { + rampType: RampDirection.SELL; + to: EPaymentMethod.SEPA; +} ? { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.SEPA; +} : { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.PIX; + rampType: RampDirection.BUY; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.SEPA; + rampType: RampDirection.BUY; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + inputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.BUY; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + outputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.SELL; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.PIX; +} | { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.SEPA; +} + +FiatToken: enum FiatToken { ARS = "ARS", BRL = "BRL", COP = "COP", EURC = "EUR", MXN = "MXN", USD = "USD" } + +InsufficientBalanceError: class InsufficientBalanceError { + constructor(requiredAmount: string, currency: string, network: string, walletAddress: string); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +InvalidAdditionalDataError: class InvalidAdditionalDataError { + constructor(field: string); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +InvalidNetworkError: class InvalidNetworkError { + constructor(network: string); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +InvalidPixKeyError: class InvalidPixKeyError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +InvalidPresignedTxsError: class InvalidPresignedTxsError { + constructor(details?: string); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +KycInvalidError: class KycInvalidError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MissingAlfredpayOfframpParametersError: class MissingAlfredpayOfframpParametersError { + constructor(message?: string); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MissingAlfredpayOnrampParametersError: class MissingAlfredpayOnrampParametersError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MissingBrlOfframpParametersError: class MissingBrlOfframpParametersError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MissingBrlParametersError: class MissingBrlParametersError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MissingMoneriumOfframpParametersError: class MissingMoneriumOfframpParametersError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MissingMoneriumOnrampParametersError: class MissingMoneriumOnrampParametersError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MissingMykoboOfframpParametersError: class MissingMykoboOfframpParametersError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MissingMykoboOnrampParametersError: class MissingMykoboOnrampParametersError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MissingRequiredFieldsError: class MissingRequiredFieldsError { + constructor(missingFields: Array); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MoneriumError: class MoneriumError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MoonbeamEphemeralNotFoundError: class MoonbeamEphemeralNotFoundError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MykoboError: class MykoboError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +MykoboKycRequiredError: class MykoboKycRequiredError { + constructor(message: string, status?: number); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +NetworkApiInitializationError: class NetworkApiInitializationError { + constructor(network: string, timeoutMs: number, originalError?: Error); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly network: string; + readonly originalError?: Error; + readonly status: number; + readonly timeoutMs: number; +} + +NetworkConfig: { + name: string; + wsUrl: string; +} + +NetworkError: class NetworkError { + constructor(message: string, originalError?: Error); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +Networks: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" } + +NoPresignedTransactionsError: class NoPresignedTransactionsError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +OfframpUpdateAdditionalData: { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; +} + +PaymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" } + +QuoteExpiredError: class QuoteExpiredError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +QuoteNotFoundError: class QuoteNotFoundError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +QuoteResponse: { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} + +RampDirection: enum RampDirection { BUY = "BUY", SELL = "SELL" } + +RampHandler: {} + +RampNotFoundError: class RampNotFoundError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +RampNotUpdatableError: class RampNotUpdatableError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +RampState: { + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + ephemerals: { + evmEphemeral?: { + address: string; + secret: string; + }; + substrateEphemeral?: { + address: string; + secret: string; + }; + }; + quoteId: string; + rampId: string; + unsignedTxs: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; +} + +RampStateNotFoundError: class RampStateNotFoundError { + constructor(rampId: string); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +RegisterRampAdditionalData: Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + inputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.BUY; +} ? { + destinationAddress: string; + fiatAccountId?: string; + sessionId?: string; + walletAddress?: string; +} : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.PIX; + rampType: RampDirection.BUY; +} ? { + destinationAddress: string; + taxId?: string; +} : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.SEPA; + rampType: RampDirection.BUY; +} ? { + destinationAddress: string; + email: string; + ipAddress: string; +} : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + outputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.SELL; +} ? { + fiatAccountId: string; + sessionId?: string; + walletAddress: string; +} : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.PIX; +} ? { + pixDestination: string; + receiverTaxId?: string; + taxId?: string; + walletAddress: string; +} : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.SEPA; +} ? { + destinationAddress: string; + email: string; + ipAddress: string; + walletAddress: string; +} : { + destinationAddress: string; + email: string; + ipAddress: string; + walletAddress: string; +} | { + destinationAddress: string; + email: string; + ipAddress: string; +} | { + destinationAddress: string; + fiatAccountId?: string; + sessionId?: string; + walletAddress?: string; +} | { + destinationAddress: string; + taxId?: string; +} | { + fiatAccountId: string; + sessionId?: string; + walletAddress: string; +} | { + pixDestination: string; + receiverTaxId?: string; + taxId?: string; + walletAddress: string; +} + +RegisterRampError: class RegisterRampError { + constructor(message: string, status?: number, originalError?: Error); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +StartRampError: class StartRampError { + constructor(message: string, status?: number, originalError?: Error); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +SubaccountNotFoundError: class SubaccountNotFoundError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +SubmitUserTransactionsHandlers: { + handleUnsupported?: (tx: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }) => Promise; + includeDomainType?: boolean; + sendTransaction?: (transaction: { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + }, context: { + unsignedTransaction: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }; + }) => Promise; + signTypedData?: (payload: { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }, context: { + payloadCount: number; + payloadIndex: number; + unsignedTransaction: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }; + }) => Promise; +} + +TimeWindowExceededError: class TimeWindowExceededError { + constructor(); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +TransactionSigningError: class TransactionSigningError { + constructor(details?: string, originalError?: Error); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +UpdateRampAdditionalData: Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + inputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.BUY; +} ? never : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.PIX; + rampType: RampDirection.BUY; +} ? never : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + from: EPaymentMethod.SEPA; + rampType: RampDirection.BUY; +} ? never : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + outputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.SELL; +} ? { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; +} : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.PIX; +} ? { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; +} : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; +} & { + rampType: RampDirection.SELL; + to: EPaymentMethod.SEPA; +} ? { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; +} : { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; +} + +UpdateRampError: class UpdateRampError { + constructor(message: string, status?: number, originalError?: Error); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +UserEvmTransactionContext: { + unsignedTransaction: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }; +} + +UserTransactionType: "evm-transaction" | "evm-typed-data" | "unsupported" + +UserTypedDataSigningContext: { + payloadCount: number; + payloadIndex: number; + unsignedTransaction: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }; +} + +VortexSdk: class VortexSdk { + constructor(config: { + alchemyApiKey?: string; + apiBaseUrl: string; + autoReconnect?: boolean; + hydrationWsUrl?: string; + moonbeamWsUrl?: string; + networkInitializationTimeoutMs?: number; + pendulumWsUrl?: string; + publicKey?: string; + secretKey?: string; + storeEphemeralKeys?: boolean; + }); + createQuote: (request: T) => Promise; + getQuote: (quoteId: string) => Promise<{ + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + }>; + getRampInfo: () => Promise<{ + corridors: Record; + }>; + getRampStatus: (rampId: string) => Promise<{ + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt?: string; + feeCurrency: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: string; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + walletAddress?: string; + }>; + getTransactionToBroadcast: (tx: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }) => { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + }; + getTypedDataToSign: (tx: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }, options?: { + includeDomainType?: boolean; + }) => Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }>; + getUserTransactionType: (tx: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }) => "evm-transaction" | "evm-typed-data" | "unsupported"; + getUserTransactions: (rampProcess: { + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; + }, userAddress: string) => Promise>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>>; + listAlfredpayFiatAccounts: (country: enum AlfredPayCountry { AR = "AR", BO = "BO", BR = "BR", CL = "CL", CN = "CN", CO = "CO", DO = "DO", HK = "HK", MX = "MX", PE = "PE", US = "US" }) => Promise>; + registerRamp: (quote: Q, additionalData: Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + inputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.BUY; + } ? { + destinationAddress: string; + fiatAccountId?: string; + sessionId?: string; + walletAddress?: string; + } : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + from: EPaymentMethod.PIX; + rampType: RampDirection.BUY; + } ? { + destinationAddress: string; + taxId?: string; + } : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + from: EPaymentMethod.SEPA; + rampType: RampDirection.BUY; + } ? { + destinationAddress: string; + email: string; + ipAddress: string; + } : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + outputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.SELL; + } ? { + fiatAccountId: string; + sessionId?: string; + walletAddress: string; + } : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + rampType: RampDirection.SELL; + to: EPaymentMethod.PIX; + } ? { + pixDestination: string; + receiverTaxId?: string; + taxId?: string; + walletAddress: string; + } : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + rampType: RampDirection.SELL; + to: EPaymentMethod.SEPA; + } ? { + destinationAddress: string; + email: string; + ipAddress: string; + walletAddress: string; + } : { + destinationAddress: string; + email: string; + ipAddress: string; + walletAddress: string; + } | { + destinationAddress: string; + email: string; + ipAddress: string; + } | { + destinationAddress: string; + fiatAccountId?: string; + sessionId?: string; + walletAddress?: string; + } | { + destinationAddress: string; + taxId?: string; + } | { + fiatAccountId: string; + sessionId?: string; + walletAddress: string; + } | { + pixDestination: string; + receiverTaxId?: string; + taxId?: string; + walletAddress: string; + }) => Promise<{ + rampProcess: { + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; + }; + unsignedTransactions: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + }>; + startRamp: (rampId: string) => Promise<{ + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; + }>; + storeEphemerals: (ephemerals: { + EVM?: { + address: string; + secret: string; + }; + Substrate?: { + address: string; + secret: string; + }; + }, rampId: string) => Promise; + submitUserSignature: (rampId: string, tx: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }, signatures: Array | string) => Promise<{ + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; + }>; + submitUserTransactions: (rampId: string, unsignedTransactions: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>, handlers: { + handleUnsupported?: (tx: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }) => Promise; + includeDomainType?: boolean; + sendTransaction?: (transaction: { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + }, context: { + unsignedTransaction: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }; + }) => Promise; + signTypedData?: (payload: { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }, context: { + payloadCount: number; + payloadIndex: number; + unsignedTransaction: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }; + }) => Promise; + }) => Promise<{ + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; + }>; + submitUserTxHash: (rampId: string, tx: { + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }, hash: string) => Promise<{ + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; + }>; + updateRamp: (quote: Q, rampId: string, additionalUpdateData: Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + inputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.BUY; + } ? never : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + from: EPaymentMethod.PIX; + rampType: RampDirection.BUY; + } ? never : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + from: EPaymentMethod.SEPA; + rampType: RampDirection.BUY; + } ? never : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + outputCurrency: FiatToken.ARS | FiatToken.COP | FiatToken.MXN | FiatToken.USD; + rampType: RampDirection.SELL; + } ? { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; + } : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + rampType: RampDirection.SELL; + to: EPaymentMethod.PIX; + } ? { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; + } : Q extends { + alfredpayInputLimits?: { + max: string; + min: string; + }; + anchorFeeFiat: string; + anchorFeeUsd: string; + createdAt: Date; + discountCurrency?: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + discountFiat?: string; + discountUsd?: string; + expiresAt: Date; + feeCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + id: string; + inputAmount: string; + inputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + networkFeeFiat: string; + networkFeeUsd: string; + outputAmount: string; + outputCurrency: AssetHubToken.DOT | AssetHubToken.USDC | AssetHubToken.USDT | EvmToken.AXLUSDC | EvmToken.BRLA | EvmToken.ETH | EvmToken.EURC | EvmToken.POL | EvmToken.USDC | EvmToken.USDCE | EvmToken.USDT | FiatToken.ARS | FiatToken.BRL | FiatToken.COP | FiatToken.EURC | FiatToken.MXN | FiatToken.USD; + partnerFeeFiat: string; + partnerFeeUsd: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + processingFeeFiat: string; + processingFeeUsd: string; + rampType: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + sessionId?: string; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + totalFeeFiat: string; + totalFeeUsd: string; + vortexFeeFiat: string; + vortexFeeUsd: string; + } & { + rampType: RampDirection.SELL; + to: EPaymentMethod.SEPA; + } ? { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; + } : { + assethubToPendulumHash?: string; + squidRouterApproveHash?: string; + squidRouterSwapHash?: string; + }) => Promise<{ + achPaymentData?: { + [key: string]: unknown; + accountHolderName?: string; + bankAccountNumber?: string; + bankBeneficiaryAddress?: string; + bankBeneficiaryName?: string; + bankName?: string; + bankRoutingNumber?: string; + clabe?: string; + expirationDate?: string; + externalId?: string; + paymentDescription?: string; + paymentType: string; + reference?: string; + }; + countryCode?: string; + createdAt: string; + currentPhase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + depositQrCode?: string; + expiresAt?: string; + from: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + ibanPaymentData?: { + bic: string; + iban: string; + receiverName: string; + reference?: string; + }; + id: string; + inputAmount: string; + inputCurrency: string; + network?: Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + outputAmount: string; + outputCurrency: string; + paymentMethod: enum EPaymentMethod { ACH = "ach", CBU = "cbu", PIX = "pix", SEPA = "sepa", SPEI = "spei", WIRE = "wire" }; + quoteId: string; + sessionId?: string; + status?: TransactionStatus.COMPLETE | TransactionStatus.FAILED | TransactionStatus.PENDING; + to: EPaymentMethod.ACH | EPaymentMethod.CBU | EPaymentMethod.PIX | EPaymentMethod.SEPA | EPaymentMethod.SPEI | EPaymentMethod.WIRE | Networks.Arbitrum | Networks.AssetHub | Networks.Avalanche | Networks.BSC | Networks.Base | Networks.BaseSepolia | Networks.Ethereum | Networks.Hydration | Networks.Moonbeam | Networks.Paseo | Networks.Pendulum | Networks.Polygon | Networks.PolygonAmoy; + transactionExplorerLink?: string; + transactionHash?: string; + type: enum RampDirection { BUY = "BUY", SELL = "SELL" }; + unsignedTxs?: Array<{ + meta: { + additionalTxs?: Record>; + expectedSequenceNumber?: string; + }; + network: enum Networks { Arbitrum = "arbitrum", AssetHub = "assethub", Avalanche = "avalanche", BSC = "bsc", Base = "base", BaseSepolia = "base-sepolia", Ethereum = "ethereum", Hydration = "hydration", Moonbeam = "moonbeam", Paseo = "paseo", Pendulum = "pendulum", Polygon = "polygon", PolygonAmoy = "polygonAmoy" }; + nonce: number; + phase: "alfredOnrampMintFallback" | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "alfredpayOnrampMint" | "assetHubCleanup" | "assethubToPendulum" | "backupApprove" | "backupSquidRouterApprove" | "backupSquidRouterSwap" | "baseCleanupAxlUsdc" | "baseCleanupBrla" | "baseCleanupEurc" | "baseCleanupUsdc" | "baseTransfer" | "brlaOnrampMint" | "brlaPayoutOnBase" | "complete" | "destinationTransfer" | "distributeFees" | "ethereumCleanupUsdc" | "failed" | "finalSettlementSubsidy" | "fundEphemeral" | "hydrationCleanup" | "hydrationSwap" | "hydrationToAssethubXcm" | "initial" | "moonbeamCleanup" | "moonbeamToPendulum" | "moonbeamToPendulumXcm" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" | "nablaApprove" | "nablaSwap" | "onHoldForComplianceCheck" | "pendulumCleanup" | "pendulumToAssethubXcm" | "pendulumToHydrationXcm" | "pendulumToMoonbeamXcm" | "polygonCleanup" | "polygonCleanupAxlUsdc" | "squidRouterApprove" | "squidRouterNoPermitApprove" | "squidRouterNoPermitSwap" | "squidRouterNoPermitTransfer" | "squidRouterPay" | "squidRouterPermitExecute" | "squidRouterSwap" | "subsidizePostSwap" | "subsidizePreSwap" | "timedOut"; + signer: string; + txData: Array<{ + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }> | string | { + data: `0x${string}`; + gas: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + to: `0x${string}`; + value: string; + } | { + domain: { + chainId?: number; + name?: string; + salt?: `0x${string}`; + verifyingContract: `0x${string}`; + version?: string; + }; + message: Record; + primaryType: string; + signature?: Array<{ + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }> | { + deadline: number; + r: `0x${string}`; + s: `0x${string}`; + v: number; + }; + types: Record>; + }; + }>; + updatedAt: string; + walletAddress?: string; + }>; +} + +VortexSdkConfig: { + alchemyApiKey?: string; + apiBaseUrl: string; + autoReconnect?: boolean; + hydrationWsUrl?: string; + moonbeamWsUrl?: string; + networkInitializationTimeoutMs?: number; + pendulumWsUrl?: string; + publicKey?: string; + secretKey?: string; + storeEphemeralKeys?: boolean; +} + +VortexSdkContext: { + storeEphemerals: (ephemerals: { + EVM?: { + address: string; + secret: string; + }; + Substrate?: { + address: string; + secret: string; + }; + }, rampId: string) => Promise; +} + +VortexSdkError: class VortexSdkError { + constructor(message: string, status?: number, isPublic?: boolean, errors?: Array, originalError?: Error, code?: string); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +VortexSdkInternalError: class VortexSdkInternalError { + constructor(message: string, originalError?: Error); + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} + +handleAPIResponse: (response: Response, endpoint: string) => Promise + +parseAPIError: (response: unknown, fallbackStatus?: number) => { + readonly code?: string; + readonly errors?: Array; + readonly isPublic: boolean; + readonly originalError?: Error; + readonly status: number; +} +``` diff --git a/docs/architecture-email-notifications.md b/docs/architecture-email-notifications.md new file mode 100644 index 000000000..33c01cacb --- /dev/null +++ b/docs/architecture-email-notifications.md @@ -0,0 +1,545 @@ +# Email Notifications — Architecture + +How outbound email works in Vortex. The before/after comparison in §1 describes the +introduction of transactional email (mid-2026) and stays as context for why the queue +exists. + +Security-facing detail (invariants, threat model, audit checklist) lives in +[`docs/security-spec/05-integrations/resend.md`](security-spec/05-integrations/resend.md). +This page is the shape of the system. + +--- + +## 1. Before vs. after, in one picture + +**Before** — there was no transactional email at all. Auth mail was the only mail, and it +came out of Supabase's built-in Inbucket capture in local dev with no production SMTP +configured. Nothing in `apps/api` ever sent an email. + +```mermaid +flowchart LR + subgraph before["BEFORE"] + U1[User] -->|request OTP| SB1[Supabase Auth] + SB1 -.->|"local: Inbucket
    hosted: Supabase default sender"| MB1[Mailbox] + API1["apps/api"] -.->|no email path exists| NONE(["nothing"]) + end +``` + +- Ramp completion: user found out by watching the widget. Close the tab, no signal. +- KYC/KYB outcome (Avenia, hours-to-days): user had to come back and re-check. No webhook + consumer, no polling, no notification — `TaxId.kycAttempt` was declared on the model but + never written, and nothing in the codebase listened to Avenia at all. +- Main's in-app notification centre (migration 043, `notifications` table) existed with a + comment marking where email dispatch *would* hook in. Nothing was wired. + +**After** — two independent classes of mail, both through Resend, sharing only the +sending domain. + +```mermaid +flowchart LR + subgraph after["AFTER"] + U2[User] -->|request OTP| SB2[Supabase Auth] + SB2 -->|"SMTP smtp.resend.com:587"| R[Resend] + + RS["PhaseProcessor
    phase → complete"] -->|enqueue| Q[(email_notifications)] + AV["Avenia"] -->|"webhook: KYC + KYB"| WH["POST /v1/webhooks/avenia"] + WH -->|enqueue| Q + KW["KybStatusWorker
    cron hourly"] -.->|"reconcile (deduped)"| Q + Q -->|claim + send| NDW["NotificationDispatchWorker
    send every 1 min
    reconcile hourly"] + NDW -.->|"re-enqueue completed ramps
    with no row"| Q + NDW -->|"HTTPS api.resend.com"| R + + R --> MB2[Mailbox] + end +``` + +| | Before | After | +|---|---|---| +| Auth mail transport | Supabase default / Inbucket | Resend SMTP relay, `vortexfinance.co` | +| Transactional mail | none | Resend HTTPS API from `apps/api` | +| Durability | n/a | every email is a DB row before any send | +| Retries | n/a | 6 attempts, backoff 1/5/15/60/180 min | +| Dedupe | n/a | unique `(provider, type, resource_id)` | +| KYC/KYB outcome visibility | user re-checks manually | Avenia webhook, emailed on settle; hourly poll as fallback | +| Inbound Avenia events | not consumed | RSA-PSS verified receiver, raw-body mounted | +| Non-prod safety | n/a | recipient allowlist gate | + +--- + +## 2. The two mail classes + +They are genuinely separate systems that happen to share one vendor and one domain. + +```mermaid +flowchart TB + subgraph auth["Authentication mail — no Vortex code involved"] + A1["Supabase Auth / GoTrue"] -->|renders + sends| A2[Resend SMTP] + end + subgraph tx["Transactional mail — this feature"] + B1["apps/api"] -->|renders + sends| B2[Resend HTTPS API] + end +``` + +- **Auth mail** is configured *outside the repo*. `supabase/config.toml` only governs the + local stack; staging and production must be set in the Supabase Dashboard + (Project Settings → Authentication → SMTP). Nothing in CI pushes that file. +- **Transactional mail** is the rest of this document. + +Shared-domain consequence: a reputation incident in either class affects both. Accepted +deliberately, in exchange for a recognisable sender. + +--- + +## 3. Producers — what enqueues, and when + +Four producers, all fire-and-forget into the same table. None of them ever sends. + +```mermaid +flowchart LR + subgraph producers["Producers"] + P1["PhaseProcessor.processPhase()
    currentPhase === 'complete'"] + P2["POST /v1/webhooks/avenia
    KYC + KYB events"] + P3["KybStatusWorker.poll()
    hourly reconciliation"] + P4["refreshAlfredpayCustomerStatus()
    dashboard refresh + hourly sweep"] + end + P1 -->|"provider: vortex
    type: ramp_completed
    resourceId: rampState.id"| Q[(email_notifications)] + P2 -->|"provider: avenia
    type: verification_*
    resourceId: attempt.id"| Q + P3 -.->|"same key — deduped"| Q + P4 -->|"provider: alfredpay
    type: verification_*
    resourceId: submissionId"| Q +``` + +P2 and P3 deliberately overlap. They enqueue through the same +`enqueueVerificationNotification()` on the same `(provider, type, attempt.id)` key, so the +poll racing or repeating a webhook is a no-op rather than a second email. + +**Ramp completion** — `enqueueRampCompletedEmail()` in +`apps/api/src/api/services/email/ramp-completion.ts`, called from the terminal `complete` +branch of `apps/api/src/api/services/phases/phase-processor.ts`. + +The hook belongs on the phase processor because that is the *only* place a ramp actually +reaches `complete` — it is the "single source of authority for phase transitions" and writes +`currentPhase` straight onto the model. `RampService.logPhaseTransition()` (and the +`notifyStatusChangeIfNeeded()` it wraps) has no call sites, so anything hung off it never +runs. The producer lives in the email module rather than on `RampService` because +`ramp.service.ts` imports `phase-processor`, so the reverse import would be a cycle. + +Only ramps with a non-null `rampState.userId` produce mail, and API-credential ramps are +excluded explicitly: `getEffectiveUserId` fills `userId` with the credential's linked +profile (`req.userId ?? req.credential.profileId`), so relying on the null check alone +would email the partner once per end-customer ramp. When the ramp's quote carries an +`apiCredentialId`, the producer records a `skipped` tombstone row instead — no mail, and +the reconcile sweep stops re-surfacing the ramp. A partner-driven ramp has no Vortex-side +recipient: the address on `additionalData.email` belongs to the *partner's* customer, not +to us. + +The payload carries both legs of the trade, already resolved to the user's perspective. On a +buy the user pays fiat and receives the token; on a sell it is reversed, so which side of the +quote each leg reads from swaps with `rampState.type`: + +| Payload field | `BUY` (onramp) | `SELL` (offramp) | +| --- | --- | --- | +| `fiatAmount` / `fiatCurrency` | `quote.inputAmount` / `inputCurrency` | `quote.outputAmount` / `outputCurrency` | +| `tokenAmount` / `tokenSymbol` | `quote.outputAmount` / `outputCurrency` | `quote.inputAmount` / `inputCurrency` | + +Plus `network`, `rampId`, `rampType` and `completedAt`. The timestamp comes from the recorded +`complete` entry in `phaseHistory`, so delayed reconciliation does not claim the ramp completed +when the email was finally queued. Enqueue is fire-and-forget: a failure is logged but never +fails a ramp that already succeeded. + +That isolation costs atomicity — the enqueue runs after the terminal phase is persisted, so a +backend that dies in between leaves a completed ramp with no row, and `complete` is never +revisited. `NotificationDispatchWorker` therefore reconciles hourly +(`reconcileMissedRampCompletedEmails()`): it asks PostgreSQL for all ramps with a `userId` that +reached `complete` but have no `(vortex, ramp_completed, )` row, with no age cutoff. +The indexed anti-join returns only anomalies instead of rescanning every historical ramp. It +shares the same idempotency key, so a row the inline path did write is untouched. + +**Verification (KYC + KYB), primary path** — +`apps/api/src/api/controllers/avenia-webhook.controller.ts` + +Avenia pushes attempt updates to `POST /v1/webhooks/avenia`. Both verification kinds are +handled here. + +Three things make this endpoint unusual and are worth understanding before touching it: + +1. **It is authenticated by signature, not by API key or session.** Avenia signs the raw + body with RSA-PSS / SHA-256; we verify against their published key from + `GET /v2/public-key`. The key is cached for an hour and refetched on a miss — coalesced + and rate-limited to one fetch per 30s, so forged bodies cannot amplify into Avenia load. + The fetch itself aborts after 10 seconds, so a stalled provider cannot tie up signature + verification indefinitely. Avenia's guide states the key rotates and must never be pinned. +2. **It is mounted ahead of the global JSON body parser** in `config/express.ts`, using + `bodyParser.raw`. The signature covers the exact bytes sent; parsing and re-serialising + the JSON does not reproduce them byte for byte, so a normally-mounted route could never + verify. +3. **Which kind of verification an event describes is read from our own database**, not + from the payload — `provider_customers.customer_type` for the normalized account id. + It also decides whether the mail says identity or business verification. So this keeps + working regardless of how Avenia labels company events. + +Everything after signature verification answers `200`: a ticket event, an unknown +subaccount, a partner-owned subaccount, or a still-in-progress attempt are all deliberate +no-ops, and Avenia must not retry them. Only an unverified or malformed body is rejected. + +"Malformed" is decided by runtime validation, not by a TypeScript cast. A signature proves +Avenia sent the bytes; it says nothing about their shape, and `JSON.parse` will happily +return `null`, an array, or an attempt with no `status`. Since the payload is persisted and +later rendered into someone's inbox, the envelope and the attempt are both checked before +the first property read, and anything that fails gets a deterministic `400`. An unrecognised +*value* — a status Avenia adds later — is not malformed: it is acknowledged `200` and maps +to no email, because a `400` would make Avenia retry it forever. + +Avenia's guides document two envelope shapes. The receiver accepts both the management +shape (`{ subAccountId, subscription, data }`) and the event-specific shape +(`{ event: { accountId, subscription, data } }`), then validates one normalized event. + +**Verification, reconciliation path** — `apps/api/src/api/workers/kyb-status.worker.ts` + +Runs hourly. It exists because **Avenia documents no KYB subscription**: their subscription +list is `TICKET`, `KYC`, `LIMIT-UPDATE`, `*`. Company attempts are *expected* to arrive +under the wildcard because Avenia fetches both kinds from the same `/v2/kyc/attempts` +resource — but that is an inference, not a documented guarantee, and if it is wrong the +failure is silent (no KYB emails, no error). The poll is what makes being wrong survivable. + +It selects `kyc_cases` rows that are `provider = 'avenia'` + `type = 'kyb'` + undecided + +have a `providerCaseId` + belong to an entity with a `profileId` + were last written within +60 days, then calls `getKybAttemptStatus(providerCaseId)` for each one. It polls **one known +attempt id**, not a list: `GET /v2/kyc/attempts` has no documented ordering, so picking from +it would guess at which attempt a notification describes — and that attempt id *is* the +dedupe key. The window is on `updatedAt`, not `createdAt`: the case row is rebound to a +fresh attempt on re-initiation, so its creation date says nothing about the attempt in +flight. + +```mermaid +stateDiagram-v2 + [*] --> observed: webhook event, or hourly poll of TaxId.kycAttempt + observed --> observed: status ≠ COMPLETED/EXPIRED (no-op) + observed --> approved: COMPLETED + APPROVED + observed --> rejected: COMPLETED + REJECTED + observed --> expired: EXPIRED + approved --> [*]: enqueue verification_approved + rejected --> [*]: enqueue verification_rejected (reason ≤200 chars) + expired --> [*]: enqueue verification_expired +``` + +Both paths share `enqueueVerificationNotification()` in +`apps/api/src/api/services/avenia/verification-notifications.ts`, which owns the +terminal-state mapping above. That shared key is also the **replay defence**: Avenia's +signature carries no timestamp or nonce, so a captured event can be re-posted freely — it +just collapses to an existing row. + +> **Deployment note:** the webhook must be registered once per environment with +> `bun register:avenia-webhook` (reads `AVENIA_WEBHOOK_URL`, subscribes with `*`). Until +> it is, no verification email is sent by the primary path. + +> **Deployment note:** the poller's `providerCaseId IS NOT NULL` filter means KYB attempts +> started before this deploys are never observed by reconciliation. In dev all 15 `COMPANY` +> tax_ids have `kyc_attempt = NULL`. Either accept that those never notify, or backfill. + +**Verification, Alfredpay (MX / CO / AR / US)** — +`apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts` + +Alfredpay is the one provider with **no webhook at all** — `AlfredpayApiService` exposes +only request/response methods, and Alfredpay publishes no verification events. So there is +no primary push path here and nothing to reconcile against: a status poll is the only way an +outcome is ever seen. + +That poll is `refreshAlfredpayCustomerStatus()`, which resolves the account's latest +submission id, calls `getKycStatus`/`getKybStatus` for it, maps the result, and persists it. +Its background/onboarding callers share `refreshAlfredpayCustomerStatus()`. The two legacy +Alfredpay status endpoints perform the same terminal enqueue through +`enqueueAlfredpayVerificationNotification()` before they write their legacy-shaped view: + +| Caller | When | Covers | +| --- | --- | --- | +| `onboarding.controller.ts` | dashboard status aggregation, TTL-throttled per account | the user who comes back to look | +| `alfredpay.controller.ts` | `/alfredpayStatus` and `/getKycStatus` | legacy clients that poll either status endpoint | +| `AlfredpayStatusWorker` | hourly, `15 * * * *` | the user who never returns | + +These paths select on or eventually exclude a *terminal stored status*, so an account drops out of every +future poll the moment its outcome is written. Whichever caller observes the transition is +therefore the only one that may see it. Every observer consequently uses the same idempotent +enqueue helper before persisting the terminal outcome. + +For the same reason the enqueue is ordered **before** the status write. An account persisted +terminal while the enqueue failed would be filtered out of every subsequent poll and its +mail lost for good; failing first leaves the account non-terminal so the next poll retries +the outcome and the email together. (The Avenia path does not need this — its webhook +re-delivers, and the reconciliation poll keys off an attempt id that stays pollable.) + +The provider pollers run only on the `mykobo` flow-variant backend. Both flow variants share +the same database and provider accounts, so starting them on the legacy `monerium` backend as +well only duplicated every external status request. Cron jobs use `waitForCompletion`, which +also prevents a slow cycle overlapping its next tick within one process. + +Two behavioural differences from Avenia worth knowing: + +- **`verification_expired` never fires for Alfredpay.** `AlfredpayKycStatus` has no expiry. + `COMPLETED` and `FAILED` are the only terminal values; `CREATED`, `PENDING` and + `IN_REVIEW` are still in flight and `UPDATE_REQUIRED` is resumable in the wizard. +- **The dedupe key is the submission id**, not an attempt id. A resubmission after a + rejection carries a fresh `submissionId`, so it correctly mails again rather than + collapsing into the earlier row. + +The sweep is bounded on both axes — 60 days of `provider_customers.updatedAt` and 250 +accounts per cycle — because an account abandoned mid-wizard stays non-terminal forever +and each one costs two to three Alfredpay calls. It advances through a stable `id` keyset +and wraps after the last page, so a steady stream of newer accounts cannot starve older +eligible rows. A truncated cycle logs a warning rather than silently dropping the +remainder. Entities with no `profileId` (partner-owned) are excluded in the query, not +skipped in the loop, so they never spend provider calls. + +> **Locale note:** Alfredpay's users are MX/CO/AR/US, and `SUPPORTED_LOCALES` is still +> `en-US` and `pt-BR` only. `toEmailLocale` falls back silently, so these users receive +> **English**. See §10. + +--- + +## 4. The queue — `email_notifications` + +The table *is* the design. It is simultaneously the queue, the retry ledger, the audit +trail, and the idempotency key. + +Migration `062-create-email-notifications-table.ts`, model +`apps/api/src/models/emailNotification.model.ts`. + +| Column | Purpose | +|---|---| +| `provider` / `type` / `resource_id` | unique together — the idempotency key. All three `NOT NULL` because Postgres treats NULLs as distinct and would let duplicates through | +| `user_id` | FK → `profiles`, CASCADE. The *only* source of a recipient | +| `locale` | resolved at enqueue from Supabase `user_metadata.locale` | +| `payload` | JSONB snapshot of the facts at enqueue time | +| `status` | see the lifecycle below | +| `attempts` | incremented **at claim time**, not after success | +| `next_attempt_at` | backoff schedule; also the dispatch ordering key | +| `sent_at`, `provider_message_id` | proof of delivery | +| `last_error` | truncated to 2000 chars, never contains the API key | + +Indexes: unique `uniq_email_notifications_provider_type_resource`, plus +`idx_email_notifications_dispatch` and `idx_email_notifications_user_id`. + +> **Name collision:** this is `email_notifications`, *not* `notifications`. Migration 043 on +> `main` already owns `notifications` for the in-app notification centre. See §7. + +### Status lifecycle + +```mermaid +stateDiagram-v2 + [*] --> pending: enqueueNotification (findOrCreate) + pending --> sending: claimed (FOR UPDATE SKIP LOCKED, attempts++) + failed --> sending: claimed after backoff, attempts under 6 + sending --> sent: Resend 2xx + sending --> skipped: no profile email, opted out,
    or not in allowlist (non-prod) + sending --> failed: send error, attempts under 6 + sending --> abandoned: send error, attempts = 6 → Slack alert + sending --> pending: RESEND_API_KEY missing (never consumed) + sending --> failed: stale over 15 min, attempts under 6 — crash release + sending --> abandoned: stale over 15 min, attempts = 6 → Slack alert + sent --> [*] + skipped --> [*] + abandoned --> [*] +``` + +--- + +## 5. The dispatcher — one send path + +`NotificationDispatchWorker`, cron `* * * * *`. It is the **only** code that calls Resend. + +```mermaid +sequenceDiagram + participant W as NotificationDispatchWorker + participant DB as Postgres + participant P as profiles + participant T as Templates + participant R as Resend + + W->>W: RESEND_API_KEY set? else warn + leave pending + W->>DB: rows stuck in sending over 15 min:
    attempts < 6 → failed, attempts = 6 → abandoned + Slack + W->>DB: BEGIN + DB-->>W: SELECT … WHERE next_attempt_at ≤ now()
    AND status IN pending, failed
    AND attempts < 6
    LIMIT 25 FOR UPDATE SKIP LOCKED + W->>DB: UPDATE → sending, attempts = attempts + 1 + W->>DB: COMMIT + loop each claimed row + W->>DB: notification_preferences WHERE profile_id = user_id + alt opted out + W->>DB: status = skipped (no request made) + else + W->>P: profiles.email WHERE id = user_id + alt no email + W->>DB: status = skipped + else non-prod and not allowlisted + W->>DB: status = skipped (no request made) + else + W->>T: renderNotification(row) → subject/html/text + W->>R: POST https://api.resend.com/emails
    Idempotency-Key: row id + alt 2xx + R-->>W: { id } + W->>DB: status = sent, sent_at, provider_message_id + else error + W->>DB: status = failed + next_attempt_at,
    or abandoned + Slack alert at attempt 6 + end + end + end + end +``` + +Four properties worth naming, because each one is load-bearing: + +1. **`FOR UPDATE SKIP LOCKED`.** Both flow-variant backends run against one database. Without + the claim, both dispatch the same row and the user gets the email twice. This is the single + most important line in the feature. +2. **Recipient resolved at send time**, never snapshotted and never caller-supplied. It comes + from `profiles.email` via `user_id`. No request payload can influence where mail goes. +3. **`attempts` increments at claim, not after success.** A process that dies mid-send still + burns an attempt. That alone does not stop the loop, though: a crashed send records no + failure, so the cap in `handleDeliveryFailure` never runs for it. The stale-claim sweep + therefore abandons rows at the cap rather than releasing them, and the claim query skips + them — those two are what actually terminate a crash loop. +4. **The row id is Resend's `Idempotency-Key`.** The unique index stops two *rows* for one + event; it says nothing about the window between Resend accepting a send and `sent` being + persisted. A crash in there returns the row to the queue with the mail already gone, and + the key is what makes the retry a replay rather than a second email. + +--- + +## 6. Rendering + +```mermaid +flowchart LR + N["row.type + row.locale + row.payload"] --> RN["renderNotification()"] + RN -->|"ramp_completed"| T1["ramp-completed.ts"] + RN -->|"verification_approved
    verification_rejected
    verification_expired"| T2["verification-status.ts"] + T1 --> L["layout.ts"] + T2 --> L + L --> O["{ subject, html, text }"] +``` + +- Dispatch is on **type only**, not provider — which is why Alfredpay rows needed no + template work at all: they render through the same verification templates as Avenia. +- Locales: `en-US`, `pt-BR`. `toEmailLocale` falls back to `en-US` for anything else, which + today silently catches Alfredpay's MX/CO/AR users — see the `es-419` follow-up in §10. +- Templates import nothing from the database layer, which is what makes + `bun preview:emails` able to render them standalone. +- Every interpolated value is HTML-escaped; Avenia's `resultMessage` is additionally capped + at 200 chars and only included on rejection. + +--- + +## 7. Relationship to the in-app notification centre + +`main` has a separate, older feature also called notifications: + +| | In-app notifications (`main`) | Email notifications (this branch) | +|---|---|---| +| Table | `notifications` (migration 043) | `email_notifications` (migration 062) | +| Model | `models/notification.model.ts` | `models/emailNotification.model.ts` | +| Service | `api/services/notifications/` | `api/services/email/` | +| Preferences | `notification_preferences.email_enabled` | same row, read at delivery | +| Surface | API routes, read by the client | no route; write-only, worker-read | + +The two tables stay separate, but they share one opt-out. `notification_preferences` is +already the user-facing switch (`GET`/`PUT /v1/notifications/preferences`), so the dispatcher +reads it rather than introducing a second one: + +- `email_enabled = false` silences every email. +- `prefs[] = false` silences one type. The key is the stored `type` value — + `ramp_completed`, `verification_approved`, `verification_rejected`, `verification_expired`. + Any other value, including an absent key, means enabled. + +Both fields can only ever *suppress* mail, which is what makes the missing-row case safe: a +profile that has never touched its preferences has no row, and is treated exactly as the +default row `getOrCreateNotificationPreferences` would write. The dispatcher reads rather +than creates, so a send never writes a preferences row as a side effect. + +The check runs at delivery, not at enqueue — an opt-out registered while a row is still in +the queue is honoured, and an opted-out row is recorded `skipped` with no request to Resend. + +--- + +## 8. Configuration + +| Variable | Effect | +|---|---| +| `RESEND_API_KEY` | Missing → the worker warns and leaves rows `pending`. Never marks them sent/failed/abandoned, so the backlog flushes when the key arrives | +| `EMAIL_FROM_ADDRESS` | Defaults to `Vortex Finance ` | +| `EMAIL_REPLY_TO_ADDRESS` | Optional | +| `EMAIL_RECIPIENT_ALLOWLIST` | Comma-separated. Enforced whenever `DEPLOYMENT_ENV !== "production"`. **Empty = nothing is ever sent outside production** | +| `AVENIA_WEBHOOK_URL` | Public https URL of this backend's `/v1/webhooks/avenia`. Read only by `bun register:avenia-webhook`; the receiver itself needs no config | + +Domain requirements on `vortexfinance.co`: Resend DKIM CNAMEs, exactly **one** SPF record +(Resend merged into any existing sender — two records fail SPF outright), and a published +DMARC policy. + +Auth-mail SMTP is *not* configured by this repo outside local dev. Set it in the Supabase +Dashboard per hosted project. + +--- + +## 9. File map + +``` +apps/api/src/ +├── api/ +│ ├── services/avenia/ +│ │ ├── verification-notifications.ts terminal-state mapping, shared enqueue +│ │ ├── webhook-signature.ts RSA-PSS verify + cached Avenia public key +│ │ └── webhook-signature.test.ts +│ ├── services/alfredpay/ +│ │ ├── verification-notifications.ts terminal-state mapping, enqueue (submissionId key) +│ │ └── alfredpay-customer.service.ts refreshAlfredpayCustomerStatus — the only producer +│ ├── services/email/ +│ │ ├── index.ts barrel +│ │ ├── notification.service.ts enqueue, claim, deliver, retry, stale-release +│ │ ├── dispatch.test.ts preference gate, idempotency key, crash-loop cap +│ │ ├── ramp-completion.ts ramp-completion producer (payload from quote) +│ │ ├── resend.transport.ts the only HTTP call to Resend +│ │ ├── types.ts locales + payload shapes +│ │ └── templates/ +│ │ ├── index.ts type → template dispatch +│ │ ├── layout.ts shared HTML shell +│ │ ├── ramp-completed.ts +│ │ └── verification-status.ts approved / rejected / expired +│ ├── workers/ +│ │ ├── notification-dispatch.worker.ts cron 1m — the only sender +│ │ ├── kyb-status.worker.ts cron 1h — reconciliation behind the webhook +│ │ └── alfredpay-status.worker.ts cron 1h — Alfredpay's only background watcher +│ ├── routes/v1/avenia-webhook.route.ts POST /v1/webhooks/avenia +│ ├── controllers/avenia-webhook.controller.ts primary KYC + KYB producer +│ ├── services/phases/phase-processor.ts fires the ramp-completion producer +│ └── controllers/brla.controller.ts persists attemptId → TaxId.kycAttempt +├── models/emailNotification.model.ts +├── database/migrations/062-create-email-notifications-table.ts +├── config/express.ts raw-body mount, ahead of the JSON parser +├── config/vars.ts config.integrations.{resend,avenia} +├── scripts/preview-emails.ts bun preview:emails +└── scripts/register-avenia-webhook.ts bun register:avenia-webhook +``` + +## 10. Open follow-ups + +- **Confirm whether Avenia delivers KYB events at all.** Their docs list no KYB + subscription; we subscribe with `*` and infer company attempts will arrive because both + kinds share `/v2/kyc/attempts`. Run one company attempt to a terminal state in sandbox and + check for the `Avenia COMPANY verification webhook` log line. If it appears, delete + `KybStatusWorker` — the reconciliation poll exists only to cover this unknown. If it does + not, the poll is load-bearing and must stay. +- **Spanish copy.** Alfredpay verification mail now ships, but `SUPPORTED_LOCALES` has no + `es-*`, so MX/CO/AR users are silently served `en-US` (`toEmailLocale` falls back + silently). Deliberately deferred: it is translation work, not plumbing, and holding the + producer back for it would have left the larger gap — no mail at all — open. To close it: + add `es-419` to `SUPPORTED_LOCALES` in `services/email/types.ts`, translate + `verification-status.ts` (approved/rejected/expired, both the individual and business + variants) and `ramp-completed.ts`, and decide how MX/CO/AR profiles with no stored locale + map onto it. Vendor `failureReason` text stays untranslated either way — the same caveat + that already applies to Avenia's `resultMessage` for pt-BR. +- Decide whether to backfill `TaxId.kycAttempt` for in-flight KYB attempts (§3). The + attemptId is only persisted from `initiateKybLevel1` onward, so COMPANY `tax_ids` rows + created before that change have a null `kyc_attempt`, are filtered out by the worker, and + will never be emailed. +- No `Hi ,` greeting. [#1144](https://github.com/pendulum-chain/vortex/issues/1144) + asks for one, but the `User` model holds only `id` and `email` — there is no name to + interpolate. Sourcing it (Supabase `user_metadata`, or the KYC/KYB submission data) is a + separate change. +- Avenia's `resultMessage` is surfaced verbatim as the `Reason` row, so a pt-BR reader can + receive untranslated vendor copy (§6). diff --git a/docs/product-dashboard.md b/docs/product-dashboard.md index 8b5004ac5..3ad5e1d69 100644 --- a/docs/product-dashboard.md +++ b/docs/product-dashboard.md @@ -135,9 +135,11 @@ two people. remain in the `initial` phase are omitted from history. ### Notifications & settings -- As a user, I get in-app and email alerts when a corridor's KYC/KYB resolves, when an invited - recipient completes onboarding, and when a payout settles or fails. -- As a user, I toggle each of those three notification categories. +- As a user, I get in-app and email alerts when a corridor's KYC/KYB resolves and when a ramp + settles. +- As a user, I toggle each of those two email notification categories on Settings. (A third + category — recipient-approval alerts — was dropped for now: no such notification type exists + in the backend yet.) ## High-level implementation strategy @@ -263,15 +265,19 @@ provider-shaped rather than UI-shaped. #2 stops at "onboarded", not "payable". The product and provider contract must define how payout instruments are created for both senders creating links and recipients redeeming them, while keeping raw bank PII provider-side. -- The notification feed rendered in the dashboard shell and the three notification preference - toggles on Settings are still client-mocked even though `/v1/notifications` exists; wiring them - up is listed under next steps. +- The notification feed rendered in the dashboard shell is still client-mocked even though + `/v1/notifications` exists; wiring it up is listed under next steps. The Settings email + preference toggles are wired to `/v1/notifications/preferences`: "Onboarding updates" maps to + the three `verification_*` types and "Transfer status" to `ramp_completed`, the stored type + strings the email dispatch worker consults at delivery time (shared `EmailNotificationType` + enum). The stored master switch is honored too: a globally muted profile shows both + categories off, and re-enabling one lifts the switch while pinning the other to muted. ## Next steps - Display relationship status and authoritative transfer eligibility, including the reason a recipient is not payable, instead of deriving availability from onboarding status alone. -- Connect the dashboard notification feed and its three preference controls to the backend. +- Connect the dashboard notification feed to the backend. - Consider persisting intended corridor selection independently of provider entities. A small backend table could support adding/removing tracked corridors and explicit status management; provider-created entities remain the authoritative persisted onboarding state meanwhile. diff --git a/docs/security-spec/03-ramp-engine/block-flow-architecture.md b/docs/security-spec/03-ramp-engine/block-flow-architecture.md index e2eecb522..1a39a0a51 100644 --- a/docs/security-spec/03-ramp-engine/block-flow-architecture.md +++ b/docs/security-spec/03-ramp-engine/block-flow-architecture.md @@ -62,6 +62,8 @@ runtime validation, and startup wiring checks therefore remain mandatory. 11. **Schema evolution.** A change that reinterprets a persisted field MUST increment its schema version and either retain its old reader or provide an explicit, one-directional migration. Missing fields MUST NOT silently acquire a new meaning. + Executor-relevant optional globals MUST additionally carry their own program + version when absence remains a supported legacy program. 12. **Namespaced ownership.** Blocks MUST read their own metadata and state by context key. Compatibility projection into legacy top-level `StateMetadata` or API response fields MUST reject conflicting destinations. New executor dependencies MUST use @@ -105,9 +107,11 @@ runtime validation, and startup wiring checks therefore remain mandatory. | Old or manually edited JSONB is cast into a new TypeScript type | Versioned envelope validation before registration, start, or recovery | | Two blocks flatten different values into one legacy field | Compatibility merge rejects conflicting values | | An old flow implementation is removed too early | Per-variant deployment/removal check against unexpired pending quotes and resumable ramps; update and start reject expired initial ramps before lifecycle hooks | +| An old worker consumes newly introduced executor metadata during a rolling deploy | Keep new quote production behind a default-off activation flag; deploy the dual legacy/v2 executor everywhere before enabling the new program | | A provider accepts an order and the database transaction later rolls back | Independent durable financial-operation claim; retry reuses the confirmed response or halts on ambiguity | | Two workers attempt the same external side effect | Unique operation key and atomic `not_started` → `submitted` claim | | A provider has no idempotency-key API | Unknown outcomes require reconciliation; automatic repetition is forbidden | +| A live balance changes between funding retries | Dynamic funding request hashes bind the stable target balance, not the observed shortfall; program-specific attempt classes prevent collision with historical operations | ## Audit Checklist diff --git a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md index 252bf4836..a17963b14 100644 --- a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md +++ b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md @@ -15,6 +15,7 @@ Ephemeral accounts may be created on: - **AssetHub** — For XCM transfers to/from Pendulum and Hydration - **Hydration** — For Hydration DEX swaps and XCM transfers - **Base** — Hub for all BRL **and EUR** on/off-ramp flows. Hosts BRLA mint/burn (via Avenia), Mykobo EUR settlement (EURC on Base), Nabla-on-EVM swap (USDC↔BRLA, USDC↔EURC), and EVM fee distribution via sequential ERC-20 transfers. +- **EVM destinations** — BUY ramps may pay out on any supported EVM destination. Every destination reserve is derived from a validated presigned payout whose signer, chain, nonce, target, calldata, value, gas limit, and fee bounds are rebound to the matching server blueprint at execution; both early funding and native final-settlement reserve calculation use the same validator. Only the native-balance shortfall is funded. On fee-collecting flows, the quote's network fee includes the destination funding transaction plus payout, priced in the chain's native currency with `EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS`; quotes above `EVM_DESTINATION_MAX_EXECUTION_FEE_USD` are rejected. The quote persists the resulting absolute fee and gas ceilings. Arbitrum gas limits include NodeInterface parent-chain poster gas. For Base and Base Sepolia, both transactions' L1 security fees are included through GasPriceOracle `getL1FeeUpperBound`; the payout reserve holds the persisted maximum rather than calling the oracle again after bridge delivery. Registration preflights the envelope before provider ticket creation, and execution re-estimates immediately before the treasury send, applies explicit EIP-1559 caps, and pauses recoverably without sending if live fees exceed it. Quotes without funding-program metadata retain the historical static-funding path for in-flight compatibility. Exact provider-token direct payouts have no fee-distribution phase, so they do not advertise an uncollectible destination fee and remain covered by their existing source reserve. Other source-chain reserves remain separate because they cover the full upstream phase plan rather than only the final payout. There is no generic destination-chain native-dust sweep, so the difference between the signed fee cap and actual gas consumption can remain on the ephemeral account. ### Cleanup Architecture @@ -39,6 +40,14 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { 5. **Cleanup transactions MUST be submitted with the server's cosigner authority** — The ephemeral account's keypair is generated client-side and may not be available post-ramp. Cleanup relies on the server's cosigner (multisig on Substrate) to authorize the sweep. 6. **The Moonbeam 3-hour delay MUST be enforced before cleanup** — SquidRouter cross-chain swaps can trigger refunds via Axelar. Cleaning up before refunds land means the refunded tokens are sent to an account nobody controls. 7. **Cleanup failures MUST be logged and retried** — A single cleanup failure should not cause permanent fund loss. The worker should re-attempt on subsequent cycles. +8. **Destination funding MUST NOT trust unbounded client gas fields** — Presigned gas limits and fee caps must remain within the server-issued signing envelope at API validation and again when execution calculates the native reserve. +9. **Base-family destination fees MUST include the L1 security component** — Quotes for Base/Base Sepolia must price both L2 execution and L1 data publication for the treasury funding transfer and payout. The ephemeral reserve for a signed payout must include the persisted accepted payout L1 maximum. +10. **Treasury funding MUST remain inside the persisted quote envelope** — Immediately before a dynamic funding transfer, the backend must re-estimate fees, compare L2 `maxFeePerGas`, chain-specific gas requirements, and Base L1 upper bounds to the absolute persisted maxima, and perform no send when any limit is exceeded. +11. **Dynamic treasury transfers MUST carry explicit fee caps** — The funding transaction must use the checked gas limit, `maxFeePerGas`, and `maxPriorityFeePerGas`; relying on wallet defaults after performing the quote-envelope check would create a time-of-check/time-of-use mismatch. +12. **Funding retries MUST bind a stable target** — A dynamic funding operation's request hash must bind the required target balance and v2 program identity, not a live balance-derived shortfall that can change after a confirmed send. +13. **Funding-program rollout MUST be two phase** — New quote production stays disabled until every API and worker replica supports both legacy static metadata and program v2. +14. **A confirmed funding operation MUST replay before live-fee preflight** — Fee movement after a receipt-confirmed send must not prevent the journal from returning that persisted result; the live envelope guard applies only before a genuinely new treasury broadcast. +15. **Persisted funding envelopes MUST be validated at runtime** — Presence selects program v2 only after all version, network, transfer-kind, bounded positive fee/gas, and Base-family L1 fields pass structural validation. Absence alone selects the legacy program. ## Threat Vectors & Mitigations @@ -46,6 +55,11 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { |---|---|---| | **Stuck funds on failed ramp** | Ramp fails after `fundEphemeral` but before any swap executes. Tokens sit on ephemeral Pendulum account. | The cleanup worker selects on `currentPhase ∈ {"complete", "failed", "timedOut"}`, so failed/timed-out ramps with funded ephemerals on Pendulum/Moonbeam/Polygon/Hydration are picked up by their respective post-process handlers. F-044 is therefore largely addressed at the worker-selection level. Remaining per-chain gaps: `BaseChainPostProcessHandler` is registered but its `shouldProcess` returns `false` unless `currentPhase === "complete"`, so **failed/timed-out Base ramps are never swept** despite the worker selecting them; AssetHub is a no-op stub. | | **Stuck ERC-20 dust on Base** | BRL on/off-ramps could leave BRLA/USDC residuals on the Base ephemeral. | **Mitigated.** `BaseChainPostProcessHandler` sweeps both BRLA and USDC after `currentPhase === "complete"` via presigned `approve` + funding-key `transferFrom`. ETH gas dust is not swept. | +| **Native gas dust on cross-chain EVM destinations** | A native-token reserve funds a destination ephemeral for its payout. Any unused reserve remains after the ramp. | **Known gap.** All EVM destination funding is derived from the signed transaction fee cap and only the balance shortfall is sent. Quotes charge the estimated funding-plus-payout execution cost rather than the maximum signed reserve. The remaining `maxFeePerGas` versus effective-gas-price difference is intentionally accepted until the flow moves to a smart-contract or paymaster model. | +| **Client-inflated destination reserve** | The client signs the expected payout call with an excessive gas limit or fee cap, causing Vortex to transfer a large native balance to an ephemeral whose key the client controls. | **Mitigated.** API validation requires exact server gas and bounds both fee fields by the production 3× multiplier for primaries and backups. `fundEphemeral` re-binds the signed transaction to the unsigned blueprint before computing its reserve. | +| **Base L1 fee omitted or stale** | A Base/Base Sepolia payout is funded only for L2 execution, or uses an exact fee read long before payout, even though it later pays for Ethereum data publication. | **Mitigated.** Quote pricing persists conservative funding and payout GasPriceOracle upper bounds after margin. The signed payout funding requirement reserves the persisted payout maximum and final settlement performs no late oracle call. | +| **Arbitrum poster gas omitted** | A funding or payout transaction is capped at 21,000/100,000 although Arbitrum accounts for parent-chain calldata posting in its gas limit. | **Mitigated.** Quote-time NodeInterface estimates are added to both persisted gas limits and checked again before funding. | +| **Execution-time fee spike** | Fees rise after quote/registration and wallet defaults submit a treasury funding transaction whose cost no longer matches what the user was quoted. | **Mitigated.** The live L2 estimate and both Base L1 upper bounds (funding and payout) are checked against the persisted envelope before the financial operation is claimed; excessive drift produces a recoverable pause and no send. Accepted transactions use those exact checked EIP-1559 caps. | | **No-op AssetHub cleanup** | An AssetHub ephemeral holds residual tokens after an AssetHub-routed ramp. The registered `AssetHubPostProcessHandler` always returns `shouldProcess=false`. | **Known gap.** The handler is a placeholder. If AssetHub ephemerals can hold residual tokens, this needs to be implemented; otherwise the handler can be removed and the gap accepted. | | **SEPA ramp exclusion (historical)** | An older revision of the worker excluded `from: "sepa"` from cleanup. If still in place, residual Monerium EURe on the Polygon ephemeral from a failed SEPA onramp would be unrecoverable. | **No longer exclusionary.** The `cleanup.worker.ts` query no longer filters on `from`; SEPA ramps are now eligible. The PolygonPostProcessHandler runs against them and sweeps any user-approved residual via `transferFrom`. F-046 is therefore resolved by the worker change. | | **Premature Moonbeam cleanup** | Cleanup runs before the 3-hour SquidRouter refund window expires. Refunded tokens land on an already-swept ephemeral account. | MoonbeamPostProcessHandler enforces `MOONBEAM_CLEANUP_DELAY_MS` (3 hours). Verify this delay is checked before every Moonbeam cleanup, not just on first attempt. | @@ -64,6 +78,9 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { - [x] HydrationPostProcessHandler submits `hydrationCleanup` extrinsic from ramp state — verified - [ ] **AssetHubPostProcessHandler is a no-op stub** (`shouldProcess` always returns `false`). Either implement an AssetHub cleanup or remove the handler from the registry. - [x] **Base post-process handler implemented** (`BaseChainPostProcessHandler`). Sweeps residual BRLA/USDC/EURC/AxlUSDC on Base ephemerals to the funding account via presigned `approve` + funding-key `transferFrom`. ETH gas dust is not swept (accepted residual). +- [x] Dynamic EVM destination presigns cannot expand treasury liability beyond exact server gas and the shared 3× fee multiplier; backups and execution-time blueprint binding are covered. +- [x] Base/Base Sepolia quote pricing and payout reserve include GasPriceOracle L1 security fees. +- [x] Dynamic treasury funding rechecks the persisted L2/Base-L1 quote envelope immediately before broadcast and sends with explicit EIP-1559 caps; excessive drift pauses without treasury spend. - [x] Cleanup worker runs every 5 minutes via `node-cron` — verified - [x] Cleanup worker processes at most 5 ramps per cycle — verified - [x] Cleanup worker marks ramps as cleaned (`postProcessDone: true` via `postCompleteState.cleanup.cleanupCompleted`) to prevent re-processing — verified diff --git a/docs/security-spec/03-ramp-engine/fee-integrity.md b/docs/security-spec/03-ramp-engine/fee-integrity.md index e14c331e8..b642cddad 100644 --- a/docs/security-spec/03-ramp-engine/fee-integrity.md +++ b/docs/security-spec/03-ramp-engine/fee-integrity.md @@ -85,6 +85,51 @@ always occur only after all user-facing phases is incorrect. - Distributed fees are final. The current implementation has no automatic clawback if a later delivery phase fails. +### Dynamic EVM destination execution fees + +- BUY flows with a non-direct EVM payout quote the native execution cost of both the + treasury-to-ephemeral funding transfer and the presigned payout. Base and Base Sepolia + additionally query the GasPriceOracle for each transaction's L1 security fee upper + bound; omitting this component underprices a fee charged on every normal Base-family + transaction. +- The quote applies `EVM_DESTINATION_NETWORK_FEE_MARGIN_BPS` once and persists the + resulting absolute `maximumFeePerGas`, funding/payout gas limits, and Base L1 + maxima. Runtime acceptance never reconstructs a ceiling from current deployment + configuration. Arbitrum gas limits add the NodeInterface parent-chain poster-gas + component; a plain transfer is not assumed to fit in 21,000 gas there. +- The persisted-metadata read boundary validates every funding-program-v2 field before + treasury arithmetic: the version and EVM network, transfer kind, positive bounded + decimal-integer fee/gas fields, positive execution-fee decimal, and paired Base-family + L1 maxima. An absent envelope remains the legacy static program. +- Registration preflights the persisted envelope before provider registration hooks + can create an independently durable payment ticket, then checks the exact prepared + payout against the same absolute limits. +- Immediately before the treasury funding transfer, execution re-estimates the L2 fee + and both Base L1 upper bounds (funding and payout). If any exceeds the persisted absolute envelope, + the phase pauses recoverably before claiming or broadcasting a new financial operation. + The journal resolves an already-confirmed operation before this preflight, so a + receipt-confirmed send remains replayable after a balance-poll timeout even if fees + subsequently rise. An accepted new transfer carries the checked gas and EIP-1559 fee + caps explicitly. +- The native amount delivered to the ephemeral is based on the bounded signed payout + liability, not arbitrary client fields. Before both funding-time and native-settlement + reserve calculations, the signed payout's identity, signer, chain, nonce, target, + calldata, value, exact gas limit, and lower/upper fee bounds must match the server + blueprint through the production EVM validator. + Base-family payouts reserve the persisted maximum payout L1 fee, rather than an + early exact oracle value that can become stale before settlement. +- Funding metadata carries program version 2. Quotes without that metadata execute + the historical static-funding program and operation identities. Dynamic financial + operations use v2 attempt classes and bind their request hash to the stable target + balance, so a confirmed send can be replayed after an RPC-balance polling timeout. +- Dynamic quote production is opt-in through + `EVM_DYNAMIC_DESTINATION_FUNDING_ENABLED`. Deploy the dual-reader/dual-executor code + to every API and worker replica while disabled, then enable quote production. This + prevents an old worker from consuming v2 metadata during a rolling deployment. +- The residual between a signed/quoted cap and the effective fee is accepted native + dust for now. It is not solved by this policy and remains documented in + `ephemeral-accounts.md`; a future smart-contract or paymaster flow can eliminate it. + ### Alfredpay corridors: solvency and failure safety - **Charging** — the onramp deducts vortex/partner components from the provider mint diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index c423ea3c9..3ae28e65e 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -60,7 +60,7 @@ offramp block executors raise a recoverable, zero-retry pause at `brlaPayoutOnBa before reading partner state or broadcasting the anchor-bound transfer. The ramp remains in the payout phase and is not cleanup-eligible, leaving the client-custodied ephemeral key available for fund recovery. The switch is active only when `NODE_ENV=development`. -- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow version 2). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Final transfer and recovery fallback share Polygon nonce 0, fee-charging quotes place one `distributeFees` transfer per recipient at the following main-lane nonces, and `polygonCleanupAxlUsdc` comes last. +- **Catalog-backed Alfredpay offramp family:** USD/ACH, MXN/SPEI, COP/ACH, and ARS/CBU use `initial` → `squidRouterPermitExecute` → `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` → `distributeFees` → `complete` (flow version 3). The source preparer statically selects direct Polygon USDT, Polygon same-chain Squid, or cross-chain Squid. EIP-2612 sources emit permit/relayer typed data; unsupported tokens emit user-wallet transfer or approve/swap blueprints whose reported hashes are content-verified before funding. Final transfer and recovery fallback share Polygon nonce 0, fee-charging quotes place one `distributeFees` transfer per recipient at the following main-lane nonces, and `polygonCleanupAxlUsdc` comes last. Version 3 adds source-labelled reference, provider, and customer all-in pricing observations to the persisted block metadata without changing quote arithmetic or making the provider rate a global price source. - **Degenerate Polygon same-token onramp case:** Alfredpay mints `ALFREDPAY_EVM_TOKEN` (USDT) on Polygon. `AlfredpayOnrampDirect` composes a Squid passthrough block when the requested output is that same token and a same-chain Squid block for every other Polygon output. Both continue through `finalSettlementSubsidy`, `destinationTransfer`, and `distributeFees` (flow version 2). See `05-integrations/alfredpay.md`. - **Amount precision on routed Alfredpay onramps:** when Alfredpay mints on Polygon and the user requests a different EVM output token, the routed Squid output is the final settlement amount. `evmToEvm.inputAmountRaw` remains the Polygon source-token raw amount, while `evmToEvm.outputAmountRaw` and `quote.outputAmount` MUST use the final destination token's raw/decimal precision. The direct Polygon same-token case remains at the minted token's precision. - **Alfredpay offramp always runs `finalSettlementSubsidy`:** `phases/blocks/phases/alfredpay-offramp/index.ts` declares `fundEphemeral` → `finalSettlementSubsidy` → `alfredpayOfframpTransfer` for every source variant. No executor short-circuits this sequence. diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index 2d9dd9014..49df9a2aa 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -49,6 +49,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p 7. **Validation MUST occur before any presigned transaction is persisted or executed** — The `updateRamp` and `startRamp` flows must reject invalid transactions before merging them into ramp state. 8. **Ephemeral addresses submitted at `registerRamp` MUST be proven fresh on every chain their route signs on, before transactions are built** — Address format validation is insufficient. For each ephemeral type the client submits, the server MUST query the chains the quote's route actually signs on (`quoteToSigningNetworks`) and reject the registration if any check finds a non-fresh account. Freshness covers nonce **and** balance: Substrate `nonce === 0 && free === 0`; EVM `nonce === 0 && native balance === 0` (a nonce-0 EVM account can still hold funds). The route-derived set MUST be kept in sync with the route builders — under-listing a chain the ephemeral signs on silently reopens the freshness gap. Fail-closed on RPC errors. Without this, the server builds presigned transactions with assumed-fresh nonces, and execution halts mid-ramp on the first chain where the assumption breaks. See `02-signing-keys/ephemeral-accounts.md` invariant 7. 9. **Multi-account block preparation MUST preserve signer capabilities and nonce domains** — EVM and Substrate account metadata are supplied as typed capabilities to each phase. Nonces are allocated independently per `(network, signer)`. A transaction that consumes more than one nonce declares a positive `nonceSpan`. +10. **Client signatures MUST NOT expand platform gas liability beyond the server-issued envelope** — A raw EVM signature must preserve the unsigned gas limit exactly. `maxFeePerGas` and `maxPriorityFeePerGas` must be at least the server estimate but no greater than `PRESIGNED_EVM_FEE_MULTIPLIER` times that estimate (currently 3×, matching the shared production signer). The same checks apply independently to every backup transaction. Any execution-time treasury calculation derived from a presign must re-bind it to the matching unsigned blueprint as defense in depth. ## Threat Vectors & Mitigations @@ -59,7 +60,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p | **Off-ramp SquidRouter bypass** | SELL-direction ramps previously skipped SquidRouter swap/approve validation entirely. Client could submit a swap routing funds to an attacker's EVM address. | **MITIGATED (F-041)**: SELL-direction `squidRouterApprove`/`squidRouterSwap` are now (a) rejected by `validatePresignedTxs` if a presigned tx is submitted for them, and (b) verified by-hash at the top of `FundEphemeralPhaseHandler.executePhase` via `verifyUserSubmittedSquidHashes` against the server-issued `to`/`data`/`value`/`signer`. The swap hash is mandatory; the approve hash is verified only when reported (pre-existing allowances make the approve tx optional — an unapproved swap fails its own on-chain receipt check). | | **User-wallet phase presigned-tx smuggling** | Client submits an unrelated EVM/Substrate presigned tx labeled with a user-wallet phase name (`moneriumOnrampMint`, `squidRouterApprove`/`Swap` for SELL, `squidRouterNoPermit*`). Previously `validatePresignedTxs` `continue`d on these phases, letting the tx through without content validation. | **MITIGATED**: `validatePresignedTxs` now throws `APIError(BAD_REQUEST)` for any presigned tx whose phase is in the user-wallet set. User-wallet phases are verified by on-chain hash + receipt + calldata only. | | **Transaction data substitution via metadata matching** | Client submits transactions with correct phase/network/nonce/signer metadata but different txData content. | **MITIGATED (F-043)**: `validatePresignedTxs` resolves the matching unsigned transaction by the same identity keys and performs content validation before `areAllTxsIncluded` is used as the final inclusion guard. | -| **EVM contract target or execution-parameter substitution** | Client signs a raw EVM transaction to an attacker-controlled contract, or signs the expected transaction with gas/fee parameters too low to execute reliably. | **MITIGATED (F-050)**: Raw signed EVM transactions are recovered and compared to the server-issued unsigned `to`, `data`, `value`, and `nonce`; gas limit and fee caps must be at least the server-issued values, and contract-creation transactions are rejected. | +| **EVM contract target or execution-parameter substitution** | Client signs a raw EVM transaction to an attacker-controlled contract, signs below the executable gas policy, or inflates gas/fee fields so a treasury prefund sends excessive native currency to the client-controlled ephemeral. | **MITIGATED (F-050)**: Raw signed EVM transactions are recovered and compared to the server-issued unsigned `to`, `data`, `value`, and `nonce`; the gas limit must match exactly, fee caps must remain between the server estimate and the shared production signer's 3× multiplier, every backup is checked, and contract-creation transactions are rejected. Both funding-time and native final-settlement treasury-liability calculations call that same complete validator again before deriving a reserve. | | **New phase/format added without validation** | A developer adds a new phase and the validator silently treats it as EVM because the phase type falls through to a default. | **MITIGATED (F-047)**: `getTransactionTypeForPhase` now throws for unknown phases instead of defaulting to EVM. | | **Non-fresh ephemeral submitted at registration** | Client submits an ephemeral address that already has on-chain history — non-zero nonce, or a funded native balance on a nonce-0 account. Backend builds presigned transactions assuming a clean account; execution halts mid-ramp on the first signed broadcast after subsidies/funding have already been committed. | **MITIGATED (F-072)**: `registerRamp` invokes `validateEphemeralAccountsFresh(ephemerals, quote)` after `normalizeAndValidateSigningAccounts`. For each ephemeral type the client provides, it checks the chains the quote's route signs on (`quoteToSigningNetworks`). Substrate: `nonce === 0 && free === 0`. EVM: `nonce === 0 && native balance === 0`. Fail-closed on RPC errors. | @@ -70,10 +71,10 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [ ] **F-042**: Substrate transaction validation checks signer and decodable method, but NOT expected method, parameters, amounts, or destinations. - [x] **F-043**: `areAllTxsIncluded` remains metadata-only, but content substitution is blocked earlier by identity-keyed unsigned transaction lookup plus per-format content validation. - [x] **F-047**: `getTransactionTypeForPhase` throws on unknown phases instead of defaulting to EVM. -- [x] **F-050**: EVM validation checks raw transaction `to`, `data`, `value`, `nonce`, signer, chain ID, gas limit, and fee caps against the server-issued unsigned transaction; contract creation is rejected. Native-token destination transfers (where viem's `parseTransaction` returns `data: undefined`) are normalized: both sides of the calldata equality check coerce empty/undefined calldata to `"0x"` so legitimate native transfers are not rejected (`apps/api/src/api/services/transactions/validation.ts:126`). +- [x] **F-050**: EVM validation checks raw transaction `to`, `data`, `value`, `nonce`, signer, chain ID, gas limit, and fee caps against the server-issued unsigned transaction; gas is exact and fee caps are bounded by the shared 3× signing multiplier. Contract creation is rejected. Native-token destination transfers (where viem's `parseTransaction` returns `data: undefined`) are normalized: both sides of the calldata equality check coerce empty/undefined calldata to `"0x"` so legitimate native transfers are not rejected. `FundEphemeral` and native `FinalSettlementSubsidy` reuse this validator before deriving destination gas liability. - [x] `validatePresignedTxs` is called in both `updateRamp` and `startRamp` — dual validation confirmed - [x] `validateAllPresignedTransactionsSigned` checks every expected transaction has a corresponding signed entry -- [x] EVM raw transaction validation (`validateEvmTransaction`) checks `from`, `chainId`, `nonce`, `to`, `data`, `value`, gas limit, and fee caps against expected signer, chain, and server-issued unsigned payload +- [x] EVM raw transaction validation checks `from`, `chainId`, `nonce`, `to`, `data`, `value`, exact gas limit, and bounded fee caps against expected signer, chain, and server-issued unsigned payload - [x] Onramp-specific validation (`validateAveniaOnramp`, `validateMoneriumOnramp`) checks quote amounts and integration-specific fields - [x] Offramp-specific validation (`validateOfframpQuote`, `validateBRLOfframp`) checks quote consistency - [x] `RAMP_START_EXPIRATION_TIME_SECONDS` enforces a time window between registration and start — prevents stale presigned transactions from being executed @@ -84,7 +85,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [ ] **F-058**: No per-presigned-transaction TTL after ramp starts — `getPresignedTransaction` performs no age check, presigned txs remain valid indefinitely through recovery retries. - [x] Presigned-tx partitioning via `partitionUnsignedTxs` + `filterUnsignedTxsForResponse`. **PASS** — ephemeral txs hidden from SDK response until `ephemeralPresignChecksPass` flips true. - [x] Deposit QR code (BRL onramp) gated on `ephemeralPresignChecksPass`. **PASS** — verified in `meta-state-types.ts`. -- [x] Signed presigned transaction matching accepts normal signed payload mutations while still binding EVM raw transactions to the unsigned server-built `to`/`data`/`value`/`nonce` and minimum gas/fee parameters, and typed-data payloads to the unsigned typed-data content with signatures stripped for comparison. +- [x] Signed presigned transaction matching accepts the production signer's bounded fee multiplier while still binding EVM raw transactions to the unsigned server-built `to`/`data`/`value`/`nonce`/gas envelope, and typed-data payloads to the unsigned typed-data content with signatures stripped for comparison. - [x] **No-permit fallback receipt validation hardened**: `waitForUserHash` verifies receipt `from`, receipt `to`, and transaction `input` against the expected user address and presigned EVM transaction payload before advancing. - [x] User-submitted phase types (`moneriumOnrampMint`, SELL `squidRouterApprove`/`squidRouterSwap`, `squidRouterNoPermit*`) are **rejected** by `validatePresignedTxs` if presigned and **verified by on-chain hash + receipt + calldata** at runtime via `verifyUserSubmittedTxByHash` in `apps/api/src/api/services/phases/helpers/user-tx-verifier.ts`. - [x] **Typed-data full-field binding (F-038 hardening)**: `validateSignedTypedData` deep-compares the signed typed data against the server-issued unsigned typed data (`domain`, `primaryType`, `types`, `message`) before recovering the signature, so the user cannot substitute spender/token/value/deadline/nonce/verifyingContract while still producing a valid signature over a tampered struct. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 51641e3dc..7b76113f2 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -9,6 +9,8 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **Chains involved:** Polygon (Alfredpay-side, USDT / `ALFREDPAY_EVM_TOKEN`), EVM destinations via SquidRouter (Polygon → Base/other) **Customer types:** Individual (KYC) and Business (KYB) — selected via `AlfredpayCustomerType`. The controller maps Alfredpay's KYB status to the platform's `AlfredPayStatus` via `mapKybStatus`; KYC is handled by `mapKycStatus`. Branch in `alfredpay.controller.ts` on `AlfredpayCustomerType.BUSINESS`. +**Verification outcome delivery:** Alfredpay exposes no verification webhook — `AlfredpayApiService` carries only request/response methods — so an outcome is only ever learned by polling `getKycStatus`/`getKybStatus`. `refreshAlfredpayCustomerStatus` owns that poll, persists the result, and queues the user's `verification_approved`/`verification_rejected` email; it runs both from the dashboard's status aggregation (TTL-throttled) and from `AlfredpayStatusWorker` (hourly) for users who never return. Alfredpay has no expiry status, so `verification_expired` is never produced for this provider. + **Verification collection:** MX and CO individual KYC and company KYB are submitted through the authenticated API flow. Company KYB requires tax ID, incorporation, and address documents plus the authorized representative's ID front and back. Company documents are keyed by `submissionId`; the representative's documents are keyed by an Alfredpay-generated `idRelatedPerson`, which only exists once the company record is created — the client therefore fetches it back via `GET /findKybCustomerAndBusiness` (`getKybBusinessDetails`) between the two upload steps. That endpoint returns *every* business the customer has, so the response carries each business's `submissionId` and the client selects the related persons of the submission it is filing. US individual and company verification use Alfredpay's hosted redirect flow. AR supports individual KYC only; the shared client state machine rejects `country = AR` with `business = true` before making any provider request and does not allow an AR individual flow to toggle to business. **KYB requirement set (provider-defined, per country):** Alfredpay self-describes what a KYB submission must carry at `GET …/penny/kybRequirements?country=` (`MEX` and `MX` both resolve; every corridor answers). It is the source of truth: `sendKybSubmission` rejects a submission missing any required field with `110002 "Invalid field(s)"` naming them. Beyond the company/representative identity fields, it requires a compliance questionnaire (`walletAddresses`, `sourceOfFunds`, `transmitsCustomerFunds`, `operatesInSanctionedCountries`, `isRegulatedBusiness`, `businessActivities`, `accountPurpose`, `expectedMonthlyVolumeUsd`, `expectedMonthlyTransactions`) sent flat alongside the company fields and stored by Alfredpay nested under `questionnaire`, plus a fourth company document, `shareholderRegistry`. Two branches are conditional: `transmitsCustomerFunds = true` additionally requires `conductsComplianceScreening` (and `complianceScreeningDescription` when that is true), and `isRegulatedBusiness = true` additionally requires the `businessLicense` and `uploadAmlPolicy` documents. `pep` on the representative is required for CO/US/AR but not MX — the only field that differs between corridors, so the form always asks it. @@ -35,7 +37,7 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations For routed Alfredpay onramps (any non-passthrough output), the final quote output is the Squid destination-token amount. `quote.outputAmount` MUST be stored with the destination token's decimals, and `evmToEvm.outputAmountRaw` MUST preserve Squid's destination-token raw output. The Polygon-minted Alfredpay token remains the Squid source amount; the spec must not treat Polygon source-token decimals as final settlement precision. **Off-ramp flow:** -1. The catalog `AlfredpayOfframp` block stores provider quote facts under `metadata.blocks.alfredpayOfframp` and returns the provider expiration as the Vortex quote TTL. Its registration hook validates `fiatAccountId` and wallet address, resolves the authenticated KYC-approved Alfredpay customer, refreshes the provider quote with exact `toAmount` and fee equality, updates only that block's `quoteId`/expiration, and creates the order transactionally. Drift hard-fails registration. +1. The catalog `AlfredpayOfframp` block stores provider quote facts under `metadata.blocks.alfredpayOfframp` and returns the provider expiration as the Vortex quote TTL. Its `pricing` metadata records three separate observations: the source-labelled Vortex USD/fiat reference, Alfredpay's gross rate and fee-adjusted net rate, and the final customer all-in rate after Vortex pricing. These values are diagnostic; Alfredpay's rate does not replace the general Vortex conversion source. Its registration hook validates `fiatAccountId` and wallet address, resolves the authenticated KYC-approved Alfredpay customer, refreshes the provider quote with exact `toAmount` and fee equality, updates only that block's `quoteId`/expiration, and creates the order transactionally. Drift hard-fails registration. 2. `squidRouterPermitExecute` or `squidRouterNoPermitTransfer/Approve/Swap` phase: executes the user-signed permit (or the no-permit equivalent) and lands the Alfredpay on-chain token on Polygon. 3. `finalSettlementSubsidy` phase: always runs for Alfredpay offramps because `AlfredpayOfframp` declares it between funding and provider transfer for every source variant; its target is the Alfredpay deposit PLUS the charged vortex/partner fees so the later fee transfers stay funded. 4. `alfredpayOfframpTransfer` phase: transfers the Alfredpay on-chain token to Alfredpay's settlement address for fiat payout. If Alfredpay rejects the stored `quoteId` as expired, the handler requests a fresh provider quote at execute time and re-attempts (`alfredpayOfframpTransferFallback` phase records the re-attempt). @@ -72,6 +74,9 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 22. **Uploaded filenames MUST be sanitized to ASCII before reaching Alfredpay** — `AlfredpayApiService` rewrites the multipart filename of every KYC/KYB upload to `[A-Za-z0-9._-]` (accents transliterated, everything else replaced) rather than forwarding the name the user's file happened to carry. Alfredpay's relate-person endpoint answers a non-ASCII filename with a bare 5xx `111301 UNKNOWN_ERROR` that names no field, which stranded MX company onboardings at the representative's ID upload. The trigger is invisible: macOS separates the time from AM/PM with U+202F, so `Screenshot 2026-07-09 at 12.23.56 PM.png` is rejected while the same name retyped with an ordinary space is accepted, and accented filenames fail for the same reason — the provider stores every upload under a generated `{uuid}.{ext}`, so the submitted name is discarded on arrival and nothing is lost by rewriting it. This also keeps user-controlled text out of a downstream `Content-Disposition` header. The sanitizer MUST copy the bytes into a new `File`: under Bun, `new File([file], name)`, `new Blob([file])` and `FormData.append(field, file, name)` all alias or ignore their way back to the original name, so the guarantee is asserted on the value that reaches the wire (`alfredpayApiService.test.ts`), not on the helper alone. 23. **Dashboard Alfredpay BUY confirmation MUST only start processing, never assert settlement** — The dashboard renders the server-issued MXN/USD/COP/ARS payment instructions after registration and keeps the ramp unstarted. `I have made the payment` may call `/ramp/start`, but token crediting still depends on Alfredpay's independently verified payment status; the client confirmation is not proof of payment. 24. **Reported Alfredpay usage MUST be user-scoped and provider-leg denominated** — `POST /v1/limits` derives the effective user from authentication and counts only that user's ramps whose `complete` phase-history timestamp falls in the current UTC calendar month. Routed BUY usage is the Alfredpay fiat input; routed SELL usage is `metadata.blocks.alfredpayOfframp.inputAmountDecimal` in `ALFREDPAY_EVM_TOKEN`, not the public source-token amount. This informational aggregate is cached in memory for 60 seconds; quote-time limit enforcement never reads that cache. Alfredpay does not document whether its cumulative quota resets by calendar month or uses a rolling window, so the calendar-month period is an explicit Vortex assumption rather than provider-confirmed semantics. +25. **A terminal verification outcome MUST be queued for notification before it is persisted** — Alfredpay publishes no verification webhook, so every observer that can make the customer terminal — the dashboard's shared refresh, `AlfredpayStatusWorker`, `/alfredpayStatus`, and `/getKycStatus` — MUST enqueue before its status write. An account written terminal while its enqueue failed could be excluded from every subsequent poll and never notified. A failure must leave the account non-terminal so a later poll retries both. The notification key is `(alfredpay, verification_*, submissionId)`, which makes retries and racing observers idempotent. See `resend.md` invariant 13. +26. **The background verification sweep MUST be bounded, fair, and MUST NOT poll accounts it cannot notify** — `AlfredpayStatusWorker` costs two to three Alfredpay calls per account (submission-id resolution, then status). It MUST bound the sweep by account age (60 days on `provider_customers.updatedAt`, since an account abandoned mid-wizard never reaches a terminal status) and by batch size. A stable keyset cursor advances after every full page and wraps at the end; repeatedly selecting only the newest page would starve older eligible accounts. Entities with a null `profile_id` are partner-owned and have no profile to email; they MUST be excluded in the query so they never consume provider requests. Only the `mykobo` flow-variant backend owns the provider status workers, and each cron uses `waitForCompletion`, preventing duplicate cross-backend polls and overlapping same-process cycles. +27. **Alfredpay offramp pricing observations MUST remain source-labelled and descriptive** — The persisted block metadata records the actual Vortex reference-feed source and observation time, Alfredpay's returned gross rate and fee breakdown, the provider net rate derived from `toAmount ÷ fromAmount`, and the customer all-in rate derived from final fiat output divided by the USD-valued quote input. These observations MUST NOT replace the normal Vortex price-conversion source or alter quote amounts, discounts, fees, or subsidies. ## Threat Vectors & Mitigations @@ -86,6 +91,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu | **Provider quote-quote-fall fallback abuse** | Attacker times provider quote drift between Vortex quote and ramp start to maximise the discount-engine fallback subsidy | Provider quote TTL is ~30s; `refreshAlfredpayOnrampQuoteIfMatching` only re-binds on byte-identical `toAmount`/`fee`; otherwise the fallback path is bounded by `maxSubsidy × expectedOutput` and only fires when `targetDiscount > 0` | | **Expired provider quote on offramp transfer** | Provider rejects the stored `quoteId` at transfer time, blocking settlement | `phases/blocks/phases/alfredpay-offramp/execution.ts` re-quotes at execute time and emits `alfredpayOfframpTransferFallback`; the Vortex `QuoteTicket` is untouched | | **Offramp quote drift at prep time** | Market moves between quote creation and ramp registration; the refreshed Alfredpay offramp quote has different `toAmount`/`fee` | `refreshAlfredpayOfframpQuoteIfMatching` compares `toAmount` and `fee` exactly; any drift throws `INTERNAL_SERVER_ERROR`, aborting registration. The user must re-quote. | +| **Offramp pricing source confusion** | Diagnostics present Alfredpay's executable rate as the general market reference, obscuring whether a difference comes from the reference feed, provider fees, or Vortex pricing | Persist separate source-labelled reference, provider gross/net, and customer all-in observations. Quote arithmetic continues to use the configured Vortex price feed. | | **Alfredpay offramp skipping subsidy** | An Alfredpay offramp reaches provider transfer without `finalSettlementSubsidy`, under-funding the settlement | The `AlfredpayOfframp` block declares subsidy before transfer for every source variant; flow tests pin the sequence | | **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | | **Polygon wrong-token delivery** | A user on-ramps via Alfredpay and requests a non-USDT Polygon output (e.g. USDC); the flow skips the swap on destination-network alone and transfers the minted USDT | `AlfredpayOnrampDirect` selects passthrough only for `ALFREDPAY_EVM_TOKEN`; non-USDT Polygon outputs compose `SameChainSquidRouterSwap` | @@ -114,6 +120,7 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu - [x] `AlfredpayMint.start` only re-binds the provider `quoteId` when `toAmount` and `fee` match byte-identically, creates the order once, and returns/persists the provider payment instructions through the generic flow lifecycle. **PASS** — block lifecycle tests. - [x] `AlfredpayOfframp.register` re-fetches a fresh provider quote, compares `toAmount` and `fee` exactly, updates only its own metadata identity/expiry, and throws on drift before order creation. **PASS** — block registration tests. - [x] `AlfredpayOfframp` always includes `finalSettlementSubsidy` before provider transfer. **PASS** — explicit phase list and flow tests. +- [x] Alfredpay offramp metadata separates the source-labelled Vortex reference, provider gross/net, and customer all-in rates without changing quote arithmetic. **PASS** — flow version 3 block metadata and MXN corridor coverage. - [x] AlfredPay offramp order is created by the block phase registration hook; `AlfredpayOfframp.start` retains the defensive validation-only no-op and is idempotent after registration. **PASS** — block lifecycle tests. - [x] Routed Alfredpay onramp quote output precision follows destination token decimals; direct Polygon same-token passthrough remains at minted-token precision. **PASS** — Alfredpay flow and transaction tests. - [x] Alfredpay onramp registration rejects missing customer context before customer lookup and requires a `Success` Alfredpay customer status. **PASS** — `phases/blocks/phases/alfredpay-mint/registration.ts`. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index c365cdbba..1dfa5df39 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -48,6 +48,36 @@ Avenia requires a subaccount per user, identified by tax ID (CPF for individuals `POST /v1/brla/createSubaccount` accepts an **optional** `quoteId`. In the normal ramp flow it is the quote that triggered onboarding; in quote-less onboarding paths such as the **KYB deep link** (`?kyb` / `?kybLocked` widget entry, where business verification starts before any quote exists) and authenticated dashboard sender onboarding, it is omitted. Quote provenance is not persisted because those write-only fields were dropped in the `provider_customers` cutover. The value is never used as an authorization input, so its absence does not weaken any access check: the ownership guard (below) and authenticated user context gate subaccount creation independently of whether a quote is present. +### Inbound verification webhook (`POST /v1/webhooks/avenia`) + +Avenia pushes KYC (individual) and — expected but unconfirmed — KYB (company) attempt +updates to this endpoint. It is the primary trigger for verification result emails; the +hourly `KybStatusWorker` poll is a reconciliation fallback behind it. + +This is the only Vortex endpoint authenticated purely by an **inbound RSA signature** +rather than an API key or session. Avenia signs the raw request body with RSA-PSS / +SHA-256; the receiver verifies it against Avenia's published key from `GET /v2/public-key`, +cached for one hour and refetched on a verification miss because Avenia rotates it +without notice. Because anyone can force a miss on a public route, those refetches are +coalesced into one in-flight request, rate-limited to one per 30 seconds, and aborted after +10 seconds if Avenia does not respond. + +The route is mounted **ahead of the global JSON body parser** (`config/express.ts`) with +`bodyParser.raw`, because the signature covers the exact bytes sent — a parsed and +re-serialised payload does not reproduce them. + +The receiver normalizes both documented envelopes: top-level `subAccountId` and the +event-specific `{ event: { accountId, ... } }` shape. Verification kind is resolved from the +local `provider_customers.customer_type` for that normalized id, never from the payload, so a caller cannot influence which flow an +event is treated as. The recipient is likewise local: the owning `customer_entities.profile_id`, +and an unknown or partner-owned subaccount is acknowledged without notifying anyone. Only +`attempt.id`, `status`, `result`, `resultMessage` and `updatedAt` are consumed; nothing in the +payload updates ramp, quote, or KYC-status state. + +Those five fields are runtime-validated before any of them is read (invariant 30). A signed +body is still an untrusted shape: the payload is persisted and later rendered into a user's +inbox, so a missing `status` or `updatedAt` is rejected `400` rather than queued. + ### The three-amount model (off-ramp) Three distinct BRL amounts are involved in `brlaPayoutOnBase`. They are **intentionally different**: @@ -88,6 +118,12 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 24. **Dashboard BRL BUY confirmation MUST not bypass PIX verification** — The dashboard displays the server-generated `depositQrCode`, keeps the ramp unstarted, and calls `/ramp/start` only after the user confirms submitting PIX. That click is not proof of settlement; `brlaOnrampMint` must still verify the Avenia/Base balance before advancing. 25. **Unified BRL limit reads MUST use the authenticated user's provider account** — `POST /v1/limits` MUST derive the Avenia subaccount through `resolveAveniaAccountForUser`; it MUST NOT accept a caller-supplied tax ID or subaccount. BRL `max`, `used`, year, and month are mapped directly from Avenia's BRL fiat-in/fiat-out limit row. Tax IDs and provider subaccount IDs are never returned. +26. **The Avenia webhook MUST reject any body whose RSA-PSS signature does not verify** — Verification runs against the raw request bytes before the payload is parsed or any lookup happens. An absent `Signature` header, a non-buffer body, or a failed verify MUST return 401 and MUST NOT enqueue anything. +27. **The Avenia webhook MUST NOT mutate ramp, quote, or verification state** — Its only effect is an `email_notifications` row. A forged or replayed event therefore cannot advance a ramp, approve a user, or move funds; the worst case is a duplicate-suppressed email. +28. **Webhook-triggered emails MUST remain idempotent under replay** — Avenia's signature carries no timestamp or nonce, so replay is not prevented at the transport level. It is neutralised by the `(provider, type, resource_id)` unique index keyed on the Avenia attempt id: a replayed event, or a poll racing a webhook, cannot produce a second email. +29. **Public-key refetches on a signature miss MUST be bounded** — The route is unauthenticated, so any caller can force a miss. Refetches are coalesced into one in-flight request, rate-limited to one per 30-second cooldown, and aborted after 10 seconds; a miss inside the cooldown is rejected without an outbound call. Key rotation is still picked up (within the cooldown), but forged bodies cannot be amplified into load on Avenia or leave a verifier waiting indefinitely. +30. **The webhook body MUST be runtime-validated before any property is read** — A valid signature proves only that Avenia sent the bytes. `JSON.parse` alone admits `null`, arrays, scalars, and attempts missing the fields an email is rendered from, so the receiver accepts Avenia's two documented envelopes (top-level `subAccountId` or nested `event.accountId`), normalizes them, and validates the account id plus `subscription` and, when one is present, the attempt (`id`, `status`, `updatedAt` as non-empty strings; `result` and `resultMessage` as strings when present) before the first property access or database lookup. Anything failing that returns a deterministic `400` and enqueues nothing. An unrecognised *value* of `status` or `result` is not a validation failure: it is a well-formed event with no email mapped to it, and is acknowledged `200` so Avenia does not retry it indefinitely. + ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | @@ -105,6 +141,10 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **BRL→BRLA-Base self-swap drain** | The generic pipeline swaps the user's already-minted BRLA to USDC and back, charging two swaps of slippage/fees and triggering `finalSettlementSubsidy` against bridge-less dust (over-subsidy + strand) | `isBrlToBrlaBaseDirect` collapses the corridor to a single `destinationTransfer` with `isDirectTransfer = true`; Nabla/distributeFees/Squid/finalSettlementSubsidy/cleanup are skipped at both route-build and handler level. | | **Anonymous BRL register on someone else's subaccount** | An anonymous SDK caller (no Supabase session, no linked secret key) uses an anonymous BRL quote to register a ramp on top of another user's Avenia subaccount via the quoteId guess | `RampService.registerRamp` rejects provider-backed ramps without an effective user with `400 Invalid quote`; an attacker cannot bind a BRL ramp to a subaccount they do not own. | | **Claiming an anonymous BRL estimate at register time** | Attacker mints an anonymous BRL quote, then presents a Supabase token (or a different user's linked secret API key) at register time to bind the resulting ramp to a different user's Avenia provider customer | An authenticated caller may claim an ownerless quote; `RampService.registerRamp` rejects only when both `quote.userId` and `request.userId` are non-null and differ. Provider identity is derived from the authenticated caller's canonical Avenia account, so the quote cannot select another user's provider customer. | +| **Forged verification webhook** | Attacker posts a fabricated `verification_approved` event for another user's subaccount | Body must carry a valid RSA-PSS signature from Avenia's published key; unsigned or mis-signed bodies are rejected 401 before any DB lookup. Even a valid event only enqueues an email — it grants no entitlement. | +| **Webhook replay** | Attacker re-sends a captured, correctly-signed event repeatedly | Avenia provides no timestamp or nonce to check. Enqueue is idempotent on the attempt id, so replays collapse to a no-op; no email amplification is possible. | +| **Key-rotation denial of service** | Avenia rotates the signing key; genuine events start failing verification | Key is fetched, never pinned; a failed verify against the cached key triggers exactly one refetch before rejection, so rotation self-heals within one request. | +| **Unknown-subaccount probing** | Attacker uses signed events to enumerate which subaccounts Vortex knows | Requires a valid Avenia signature, so it is not reachable by an external attacker; responses are an identical `200 {received:true}` for known, unknown, and partner-owned subaccounts. | | **Destination-token decimal under-delivery** | A BRL on-ramp targets an 18-decimal token such as BSC USDT, but the quote output is truncated to 6 decimals before `destinationTransfer` raw amount construction. | On-ramp finalization uses destination-token decimals for BRL EVM outputs; Squid metadata preserves destination raw output from `route.estimate.toAmount`. | | **Company KYB status bypass or cross-user attempt lookup** | A browser asserts that hosted verification finished, or probes another user's Avenia attempt ID and receives provider submission metadata. | Initiation binds the attempt to the authenticated user's KYB case; status lookup checks that binding before the provider call, minimizes its response, and the client/parent accept only provider-confirmed `COMPLETED` + `APPROVED`. | diff --git a/docs/security-spec/05-integrations/resend.md b/docs/security-spec/05-integrations/resend.md new file mode 100644 index 000000000..26f5710f5 --- /dev/null +++ b/docs/security-spec/05-integrations/resend.md @@ -0,0 +1,122 @@ +# Resend (Email Notifications) + +## What This Does + +Resend is the outbound email provider for Vortex. It carries two independent classes of mail: + +1. **Authentication mail** — OTP / magic-link messages generated by Supabase Auth (GoTrue). Supabase renders and sends these itself; Resend is configured as its SMTP relay. No Vortex application code is involved. +2. **Transactional notifications** — Emails the API sends about things that happened to a user's account: a completed ramp (`ramp_completed`) and a settled verification (`verification_approved` / `verification_rejected` / `verification_expired`) from either Avenia (KYC + KYB) or Alfredpay (KYC + KYB). These go through the Resend HTTPS API from `apps/api`. + +Only the second class is application code and the subject of this spec. + +For the architecture — component diagram, enqueue/dispatch sequence, status lifecycle, and a +before/after comparison — see [`docs/architecture-email-notifications.md`](../../architecture-email-notifications.md). + +Every notification is persisted to the `email_notifications` table before any send is attempted. The table is the queue, the retry ledger, the audit trail, and the de-duplication key. Nothing is sent outside that path. + +**Provider type:** outbound email only (no inbound, no webhooks consumed) +**Sending domain:** `vortexfinance.co`, envelope sender `support@vortexfinance.co` +**API auth method:** bearer API key (`RESEND_API_KEY`) over HTTPS +**Code:** +- `apps/api/src/api/services/email/` — transport, templates, queue service +- `apps/api/src/api/workers/notification-dispatch.worker.ts` — the only sender +- `apps/api/src/api/workers/kyb-status.worker.ts` — enqueues Avenia KYB outcomes +- `apps/api/src/api/services/alfredpay/alfredpay-customer.service.ts` — enqueues Alfredpay KYC/KYB outcomes +- `apps/api/src/api/workers/alfredpay-status.worker.ts` — drives the Alfredpay poll for unattended accounts +- `apps/api/src/api/services/phases/phase-processor.ts` — enqueues ramp completions on the terminal `complete` transition +- `apps/api/src/models/emailNotification.model.ts`, migration `062-create-email-notifications-table.ts` + +The table is `email_notifications`, not `notifications`: migration 043 already owns `notifications` +for the in-app notification centre. The two tables are unrelated, but they share one opt-out: +dispatch reads `notification_preferences` for the recipient before every send (invariant 15). + +The KYB worker polls `GET /v2/kyc/attempts/{attemptId}` for the specific attempt id recorded +in `kyc_cases.provider_case_id` when `initiateKybLevel1` ran, and addresses the mail to the +owning `customer_entities.profile_id` — a partner-owned entity has no profile, so it is +excluded in the join itself, never notified and never occupying batch slots. Attempts whose +outcome is already queued are excluded by an +anti-join against `email_notifications` (the worker never writes status back to +`kyc_cases`, so the queue row is what retires an attempt). Each cycle walks a 250-case +keyset (id-ordered cursor, like the Alfredpay sweep) so a backlog larger than one batch +drains instead of re-selecting the same prefix, and a returned attempt whose id does not +match the case's `provider_case_id` is discarded — the same mismatch guard the +authenticated route applies. The authenticated `GET /v1/brla/kyb/attempt-status` route +also enqueues the outcome *before* persisting a terminal status: once a case is +Approved/Rejected both that route's short-circuit and this worker stop observing the +attempt, so a client polling ahead of a lost webhook would otherwise lose the email +forever. It does not list a subaccount's attempts +and pick one: that endpoint has no documented ordering, so selecting from it would guess at +which attempt a notification describes, and `resource_id` — the dedupe key — is that attempt id. + +Alfredpay publishes no verification webhook, so its outcomes are only ever observed by a +status poll. `refreshAlfredpayCustomerStatus` owns that poll and the enqueue, and is reached +both from the dashboard's status aggregation and from `AlfredpayStatusWorker` (hourly). It +addresses the mail to the owning `customer_entities.profile_id` — partner-owned entities are +excluded by the worker's query rather than skipped after the provider call, so they cost no +Alfredpay requests. `resource_id` is the Alfredpay `submissionId`; a resubmission after a +rejection normally carries a new one and is therefore a new notification, not a suppressed +duplicate. Known limit: an in-place retry that retains the submission id would dedupe a +second rejection of that same submission — Alfredpay exposes no per-outcome id to key on. + +**Data sent to Resend:** recipient address, subject, and rendered body. Bodies contain the ramp id, output amount, currency, network, and completion timestamp, or a KYC/KYB outcome and its rejection reason — the copy names identity or business verification according to our own `provider_customers.customer_type`, since neither the Avenia attempt nor the Alfredpay status distinguishes them. No tax ids, no wallet keys, no session tokens, no API keys. + +## Security Invariants + +1. **A notification MUST only ever be addressed to a Vortex-authenticated user's own verified address.** The recipient is resolved at send time as `profiles.email` for `email_notifications.user_id`. No caller supplies a recipient address. +2. **Partner-supplied and ramp-supplied addresses MUST NOT be used as recipients, and partner-API ramps MUST NOT produce mail.** `RampState.state.additionalData.email` belongs to a *partner's* customer on API-driven ramps. A ramp only produces a notification when `RampState.userId` is non-null **and** its quote carries no `api_credential_id`: credential-authenticated requests fill `userId` with the credential's linked profile (`getEffectiveUserId`), so without the quote check every end-customer ramp would mail the partner. Excluded ramps are recorded as `skipped` tombstone rows so the reconcile sweep does not re-surface them. +3. **A given upstream event MUST produce at most one email.** The unique index `uniq_email_notifications_provider_type_resource` on `(provider, type, resource_id)` is the idempotency key; enqueuing uses `findOrCreate` against it. Re-polling a settled KYB attempt or replaying a phase transition cannot re-notify. The unique index alone does not close the window between Resend accepting a send and `sent` being persisted — a crash in between returns the row to the queue with the mail already away — so each send additionally carries the row id as Resend's `Idempotency-Key`, and the provider replays the original response instead of sending again. +4. **A due notification MUST be claimed before it is sent.** Both flow-variant backends share one database. Dispatch claims rows inside a transaction using `FOR UPDATE SKIP LOCKED` and flips them to `sending` with `attempts` incremented, so two backends cannot send the same row. +5. **A notification MUST NOT be lost when a send fails.** Enqueue only writes a row; the cron worker is the only sender. Failures are recorded with a backoff schedule (1/5/15/60/180 minutes) and retried up to 6 attempts — one initial send plus one retry per backoff step — after which the row is `abandoned` and a Slack alert fires. +6. **A crashed send MUST NOT stall the queue, and MUST NOT retry without bound.** Rows left in `sending` for more than 15 minutes are recovered on the next cycle: those still under the attempt cap are released back to `failed` and become eligible again, while those at or above it are set `abandoned` with the same Slack alert a normal exhaustion raises. The split is what caps a crash loop — a process dying between claim and resolution records no failure, so the cap in `handleDeliveryFailure` never runs for it. `claimDueNotifications` additionally refuses to claim any row at the cap. +7. **Outside production, mail MUST NOT reach arbitrary recipients.** When `DEPLOYMENT_ENV !== "production"`, a recipient not present in `EMAIL_RECIPIENT_ALLOWLIST` is recorded as `skipped` and no request is made to Resend. An empty allowlist means nothing is sent. +8. **The Resend API key MUST be environment-only** and MUST NOT appear in logs, error text, or the `email_notifications.last_error` column. Only the response status and a truncated body are persisted on failure. +9. **Template output MUST be escaped.** All payload values are HTML-escaped before interpolation, so a value carried from an upstream provider (for example an Avenia rejection message) cannot inject markup into the mail body. +10. **Provider text MUST be bounded before it reaches a user.** Avenia's `resultMessage` and Alfredpay's `metadata.failureReason` are each truncated to 200 characters and included only on rejection. +11. **A missing `RESEND_API_KEY` MUST NOT destroy queued mail.** With no key configured the worker logs a warning and leaves rows `pending`; the backlog flushes once the key is set. It must never mark them sent, skipped, or abandoned. +12. **A completed ramp MUST NOT lose its notification to a crash between the phase write and the enqueue.** The enqueue at ramp completion runs after the terminal phase is persisted and must not fail the ramp, so it is not atomic with it. `NotificationDispatchWorker` reconciles hourly with an indexed anti-join: every ramp that reached `complete` with a non-null `userId` and no `(vortex, ramp_completed, )` row is eligible, with no age cutoff that could turn an outage into permanent loss. The reconcile is idempotent against invariant 3, so a row the inline path did write is untouched. `completedAt` is recovered from the ramp's `complete` phase-history entry, not reconciliation time. +13. **A settled Alfredpay verification MUST NOT lose its notification to the status write that ends its polling.** Alfredpay has no webhook, and the onboarding refresh, background worker, and legacy status endpoints eventually exclude a terminal stored status — so an account written terminal while its enqueue failed could be excluded from every subsequent poll and never notified. Every observer queues through the same idempotent helper before the status write: a failure leaves the account non-terminal and a later poll retries the outcome and the email together. Invariant 3 makes the retry idempotent. +14. **The sending domain MUST be SPF/DKIM/DMARC aligned.** `vortexfinance.co` carries one SPF record (Resend merged into any existing sender), Resend's DKIM CNAMEs, and a DMARC policy. Because auth mail and transactional mail share the root domain, a reputation incident in either affects both — this was accepted deliberately in exchange for sender recognisability. +15. **A recipient who has opted out MUST NOT be emailed.** Dispatch resolves `notification_preferences` for `email_notifications.user_id` before every send and records the row as `skipped` — with no request to Resend — when the recipient has opted out. `email_enabled = false` silences everything; `prefs[] = false`, keyed by the stored `type` value (`ramp_completed`, `verification_approved`, …), silences one type. Opting out is the only meaning either field carries: a profile with no preferences row is treated exactly as the default row `getOrCreateNotificationPreferences` writes, so a missing row can never suppress mail. The check is at delivery, not enqueue, so an opt-out registered while a row waits in the queue is honoured. + +## Threat Vectors & Mitigations + +| Threat | Attack Scenario | Mitigation | +|---|---|---| +| **Mail sent to an attacker's address** | Attacker drives a ramp with `additionalData.email` set to their own address and receives the victim's transaction details | Recipient is never taken from ramp or request data; it is looked up from `profiles.email` by `user_id` at send time (inv. 1, 2) | +| **Partner impersonating a user** | Partner uses an `sk_` key to create a ramp and expects the notification to be addressed under their control | The recipient can only ever be `profiles.email` of the ramp's `userId`, and a ramp whose quote carries an `api_credential_id` enqueues a `skipped` tombstone instead of mail (inv. 2) | +| **Duplicate email flood** | Recovery worker or a second backend re-processes the same ramp/attempt | Unique dedupe index plus transactional row claim (inv. 3, 4) | +| **Silent mail loss** | The in-process enqueue call is fire-and-forget; an exception would previously vanish | Enqueue writes a durable row before any send; the worker retries independently of the request that queued it (inv. 5) | +| **Queue stall** | Process is killed between claim and send, leaving rows `sending` forever | Stale-claim release after 15 minutes (inv. 6) | +| **Crash-loop mail flood** | A backend dies mid-send on every cycle, so no failure is ever recorded and the row is requeued indefinitely | Stale claims at the attempt cap are abandoned rather than released, and the claim query refuses rows at the cap (inv. 6) | +| **Double send across a crash window** | The process dies after Resend accepts but before `sent` is persisted; the recovered row is sent again | The row id travels as Resend's `Idempotency-Key`, so the retry replays the original send (inv. 3) | +| **Mailing a user who opted out** | A user disables email via `/v1/notifications/preferences` and still receives ramp and verification mail | Preferences are resolved at delivery time and an opted-out recipient is recorded `skipped` with no outbound request (inv. 15) | +| **Leaking production mail from staging** | A staging deploy pointed at production-like data emails real users | Allowlist gate outside `DEPLOYMENT_ENV=production` (inv. 7) | +| **API key compromise** | Attacker obtains `RESEND_API_KEY` and sends mail as `support@vortexfinance.co` | Env-only storage, no logging of the key, rotation via the Resend dashboard; DMARC limits third-party spoofing of the domain itself (inv. 8, 14) | +| **HTML injection via provider text** | Avenia returns a `resultMessage`, or Alfredpay a `metadata.failureReason`, containing markup or a link | All interpolated values are HTML-escaped and the reason is length-capped (inv. 9, 10) | +| **Verification outcome silently unnotified** | A poll writes the terminal status but its enqueue fails, and the account is then filtered out of every future poll | The enqueue is ordered before the status write, so the account stays pollable until both succeed (inv. 13) | +| **PII over-disclosure** | Email body reveals more than the recipient should receive by mail | Bodies carry only amount, currency, network, ramp id, and timestamp — no tax id, no address, no counterparty | +| **Enumeration via notification rows** | Attacker infers user activity from the table | `email_notifications` is not exposed through any API route; there is no read endpoint | + +## Audit Checklist + +- [ ] `RESEND_API_KEY` is read only from `config.integrations.resend.apiKey` and never logged +- [ ] Recipient is resolved from `profiles.email` via `email_notifications.user_id` — grep for any code path that passes a request-supplied address to `sendEmail` +- [ ] `enqueueRampCompletedEmail` returns early when `RampState.userId` is null, and tombstones API-credential ramps as `skipped` +- [ ] Migration `062` creates `uniq_email_notifications_provider_type_resource` and all three member columns are `NOT NULL` +- [ ] `enqueueNotification` uses `findOrCreate` keyed on `(provider, type, resourceId)` +- [ ] Dispatch claims rows with `lock: transaction.LOCK.UPDATE` and `skipLocked: true` before sending +- [ ] `attempts` is incremented at claim time, not after a successful send +- [ ] Retry cap is enforced and exhaustion sets `abandoned` plus a Slack alert +- [ ] Stale `sending` rows are released on every cycle, and ones at the attempt cap are abandoned instead of requeued +- [ ] `claimDueNotifications` filters on `attempts < MAX_ATTEMPTS` +- [ ] Every call to `sendEmail` carries the row id as `Idempotency-Key` +- [ ] Dispatch consults `notification_preferences` before sending, and a missing row behaves as opted in +- [ ] Non-production deploys have `EMAIL_RECIPIENT_ALLOWLIST` set; verify a non-allowlisted recipient yields `skipped` with no outbound request +- [ ] `last_error` never contains the API key or a full provider payload (truncated to 2000 chars) +- [ ] All template interpolation passes through `escapeHtml` +- [ ] Avenia `resultMessage` and Alfredpay `metadata.failureReason` are truncated and only present on rejection +- [ ] `refreshAlfredpayCustomerStatus` enqueues before it persists the terminal status, and both of its callers filter to non-terminal accounts +- [ ] `AlfredpayStatusWorker` excludes entities with a null `profile_id` in its query, and bounds the sweep by age and batch size +- [ ] Missing API key leaves rows `pending`, not `failed` or `skipped` +- [ ] `vortexfinance.co` has exactly one SPF record, Resend DKIM CNAMEs resolve, and DMARC is published +- [ ] No route exposes the `email_notifications` table diff --git a/docs/security-spec/07-operations/notifications.md b/docs/security-spec/07-operations/notifications.md index d274afd24..4e14a7f4f 100644 --- a/docs/security-spec/07-operations/notifications.md +++ b/docs/security-spec/07-operations/notifications.md @@ -39,8 +39,8 @@ content is rendered verbatim to users and may later be emailed, so it is a PII-l (`notifications-onboarding.integration.test.ts`). - **Client-forged notifications (phishing inside the product UI)**: no write endpoint exists; emission is server-side only (invariant 2). -- **PII leakage via feed or future email**: content rules (invariant 4) apply to every emitter; - the planned invite email must contain the invite **link only**, never payout or identity data. +- **PII leakage via feed or email**: content rules (invariant 4) apply to every emitter; + a future invite email must contain the invite **link only**, never payout or identity data. - **Unbounded query cost**: limit clamp + indexed `(profile_id, created_at)` reads. ## Audit Checklist @@ -50,7 +50,12 @@ content is rendered verbatim to users and may later be emailed, so it is a PII-l services/controllers acting on server-derived events. - [ ] `emitNotification` cannot throw into its caller. - [ ] Existing emitters carry no PII in `title`/`body`/`metadata`. -- [ ] **Email dispatch is NOT implemented** (plan D7 — Supabase SMTP/edge function pending): - `email_enabled` is a stored preference with no effect yet. When the transport lands it must - gate on preferences, be server-side triggered only, and follow the content rules — update - this spec in the same change. +- [ ] **Email dispatch is implemented and gates on these preferences at delivery time** + (see [`05-integrations/resend.md`](../05-integrations/resend.md) for the transport, + queue, and its own invariants). Before every send the dispatch worker re-reads + `notification_preferences`: `email_enabled` is the master switch, and + `prefs[] === false` mutes one type — the stored type strings are the + shared `EmailNotificationType` enum consumed by both the worker and the dashboard's + Settings toggles. A muted row is recorded `skipped`, never sent. Sending remains + server-side triggered only; the `email_notifications` queue is unrelated to the + in-app `notifications` table this spec covers, and no client can write either. diff --git a/docs/security-spec/README.md b/docs/security-spec/README.md index 804d709eb..bed12e460 100644 --- a/docs/security-spec/README.md +++ b/docs/security-spec/README.md @@ -63,6 +63,7 @@ documents win. | Alfredpay | `05-integrations/alfredpay.md` | Alfredpay on/off-ramp | | Binance | `05-integrations/binance.md` | Binance USDT spot price used as the primary USD<>BRL rate source | | FastForex | `05-integrations/fastforex.md` | Fiat forex price provider used by quote/conversion math | +| Resend | `05-integrations/resend.md` | Outbound email — auth mail relay and transactional notifications | | Squid Router | `05-integrations/squid-router.md` | Cross-chain EVM routing | | XCM Transfers | `06-cross-chain/xcm-transfers.md` | Pendulum↔Moonbeam↔AssetHub↔Hydration | | Fund Routing | `06-cross-chain/fund-routing.md` | Subsidization, fee distribution, amount integrity | diff --git a/package.json b/package.json index 5a14dd338..ca0191243 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,9 @@ "test:sdk": "bun run --cwd packages/sdk test", "test:shared": "cd packages/shared && bun test", "typecheck": "bun run --cwd packages/shared typecheck && bun run --cwd packages/sdk typecheck && bun run --cwd packages/kyc typecheck && bun run --cwd apps/api typecheck && bun run --cwd apps/frontend typecheck && bun run --cwd apps/dashboard typecheck && bun run --cwd apps/rebalancer typecheck", - "verify": "biome check --no-errors-on-unmatched" + "verify": "biome check --no-errors-on-unmatched", + "wire-contract:check": "bun scripts/wire-contract/generate-report.ts --check", + "wire-contract:update": "bun scripts/wire-contract/generate-report.ts --update" }, "workspaces": [ "apps/*", diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 0c9e21cb8..34269b4b7 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -116,6 +116,7 @@ export enum QuoteError { // Availability errors UnsupportedCurrency = "Currency not supported", AnchorTemporarilyUnavailable = "This payment provider is temporarily unavailable. Please try again in a few minutes.", + NetworkFeesTooHigh = "Destination network fees are temporarily too high. Please try again later.", // Compatibility errors AssetHubNotSupportedForAlfredPay = "AssetHub is not supported for this currency. Please select a different network.", diff --git a/packages/shared/src/helpers/ephemerals.ts b/packages/shared/src/helpers/ephemerals.ts index e6cdf95a4..34805193a 100644 --- a/packages/shared/src/helpers/ephemerals.ts +++ b/packages/shared/src/helpers/ephemerals.ts @@ -22,10 +22,10 @@ export function createMoonbeamEphemeral(): EphemeralAccount { } export async function createPendulumEphemeral(): Promise { + await cryptoWaitReady(); const seedPhrase = mnemonicGenerate(); const keyring = new Keyring({ type: "sr25519" }); - await cryptoWaitReady(); const ephemeralAccountKeypair = keyring.addFromUri(seedPhrase); return { address: ephemeralAccountKeypair.address, secret: seedPhrase }; diff --git a/packages/shared/src/helpers/signUnsigned.test.ts b/packages/shared/src/helpers/signUnsigned.test.ts index f7af85cdd..0a5250b4a 100644 --- a/packages/shared/src/helpers/signUnsigned.test.ts +++ b/packages/shared/src/helpers/signUnsigned.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import type { WalletClient } from "viem"; -import { polygonAmoy } from "viem/chains"; +import { parseTransaction, type WalletClient } from "viem"; +import { baseSepolia, polygonAmoy } from "viem/chains"; import type { UnsignedTx } from "../endpoints/ramp.endpoints"; // Importing ./signUnsigned pulls in the package barrel, which freezes src/constants.ts from @@ -10,7 +10,7 @@ process.env.ALFREDPAY_API_KEY ||= "test-key"; process.env.ALFREDPAY_API_SECRET ||= "test-secret"; const { Networks } = await import("./networks"); -const { createEvmClient, groupUnsignedTxsForSigning } = await import("./signUnsigned"); +const { createEvmClient, groupUnsignedTxsForSigning, signUnsignedTransactions } = await import("./signUnsigned"); const EPHEMERAL = { address: "0x0000000000000000000000000000000000000000", @@ -67,13 +67,13 @@ describe("groupUnsignedTxsForSigning", () => { expect(groups.destinationNetworkTxs).toEqual([]); }); - it("keeps destination-phase transactions on other networks in the destination group", () => { + it("assigns Base Sepolia destination transactions to the EVM group", () => { const tx = makeTx(Networks.BaseSepolia, "destinationTransfer"); const groups = groupUnsignedTxsForSigning([tx]); - expect(groups.destinationNetworkTxs).toEqual([tx]); - expect(groups.evmTxs).toEqual([]); + expect(groups.evmTxs).toEqual([tx]); + expect(groups.destinationNetworkTxs).toEqual([]); }); it("never assigns a transaction to both the EVM and destination groups", () => { @@ -92,3 +92,31 @@ describe("groupUnsignedTxsForSigning", () => { } }); }); + +describe("Base Sepolia signing", () => { + it("signs the primary transaction and all nonce backups for Base Sepolia", async () => { + const tx = makeTx(Networks.BaseSepolia, "destinationTransfer"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_input, init) => { + const request = JSON.parse(String(init?.body)) as { id: number; method: string }; + expect(request.method).toBe("eth_chainId"); + return new Response(JSON.stringify({ id: request.id, jsonrpc: "2.0", result: `0x${baseSepolia.id.toString(16)}` }), { + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + try { + const [primaryTx] = await signUnsignedTransactions([tx], { evmEphemeral: EPHEMERAL }); + const signedVariants = [primaryTx, ...Object.values(primaryTx.meta.additionalTxs ?? {})]; + const parsedVariants = signedVariants.map(variant => parseTransaction(variant.txData as `0x${string}`)); + + expect(signedVariants).toHaveLength(5); + expect(parsedVariants.map(parsed => parsed.chainId)).toEqual(Array(5).fill(baseSepolia.id)); + expect(parsedVariants.map(parsed => parsed.nonce)).toEqual([0, 1, 2, 3, 4]); + expect(parsedVariants.map(parsed => parsed.maxFeePerGas)).toEqual(Array(5).fill(3n)); + expect(parsedVariants.map(parsed => parsed.maxPriorityFeePerGas)).toEqual(Array(5).fill(3n)); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 869265a51..58dc24cae 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -4,7 +4,7 @@ import { hexToU8a } from "@polkadot/util"; import { cryptoWaitReady } from "@polkadot/util-crypto"; import { createWalletClient, fallback, http, WalletClient } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { arbitrum, avalanche, base, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; +import { arbitrum, avalanche, base, baseSepolia, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; import { decodeSubmittableExtrinsic, EphemeralAccount, @@ -23,6 +23,7 @@ const EVM_EPHEMERAL_SIGNING_NETWORKS: Networks[] = [ Networks.Polygon, Networks.PolygonAmoy, Networks.Base, + Networks.BaseSepolia, Networks.Arbitrum, Networks.Avalanche, Networks.BSC, @@ -36,6 +37,11 @@ const DESTINATION_NETWORK_PHASES = [ "backupApprove" ]; +// Applied once, when the client signs a prepared EVM transaction. Backend +// transaction builders must leave fee estimates unscaled so safety margins do +// not compound across preparation and signing. +export const PRESIGNED_EVM_FEE_MULTIPLIER = 3n; + /** * Groups transactions by the signing flow that handles them. The destination group must * exclude every network the EVM group selects, or the same transaction would be signed @@ -154,6 +160,10 @@ export function createEvmClient( chain = base; rpcUrls = apiKey ? [`https://base-mainnet.g.alchemy.com/v2/${apiKey}`] : []; break; + case Networks.BaseSepolia: + chain = baseSepolia; + rpcUrls = apiKey ? [`https://base-sepolia.g.alchemy.com/v2/${apiKey}`] : []; + break; case Networks.BSC: chain = bsc; rpcUrls = apiKey ? [`https://bnb-mainnet.g.alchemy.com/v2/${apiKey}`] : []; @@ -197,10 +207,10 @@ async function signMultipleEvmTransactions( throw new Error("Wallet client account is undefined"); } const maxPriorityFeePerGas = tx.txData.maxPriorityFeePerGas - ? BigInt(tx.txData.maxPriorityFeePerGas) * 3n + ? BigInt(tx.txData.maxPriorityFeePerGas) * PRESIGNED_EVM_FEE_MULTIPLIER : BigInt(187500000000); const maxFeePerGas = (() => { - const fee = tx.txData.maxFeePerGas ? BigInt(tx.txData.maxFeePerGas) * 3n : BigInt(187500000000); + const fee = tx.txData.maxFeePerGas ? BigInt(tx.txData.maxFeePerGas) * PRESIGNED_EVM_FEE_MULTIPLIER : BigInt(187500000000); return fee > maxPriorityFeePerGas ? fee : maxPriorityFeePerGas; })(); diff --git a/packages/shared/src/services/alfredpay/schemas.test.ts b/packages/shared/src/services/alfredpay/schemas.test.ts index 9e1da1fe0..8cd414b5d 100644 --- a/packages/shared/src/services/alfredpay/schemas.test.ts +++ b/packages/shared/src/services/alfredpay/schemas.test.ts @@ -86,6 +86,16 @@ describe("alfredpayQuoteResponseSchema", () => { expect(() => alfredpayQuoteResponseSchema.parse(body)).toThrow(); }); + test("rejects missing pricing fields consumed by quote metadata", () => { + const missingRate = validQuoteBody(); + delete (missingRate as Record).rate; + expect(() => alfredpayQuoteResponseSchema.parse(missingRate)).toThrow(); + + const missingFeeType = validQuoteBody(); + delete (missingFeeType.fees[0] as Record).type; + expect(() => alfredpayQuoteResponseSchema.parse(missingFeeType)).toThrow(); + }); + test("rejects a non-decimal toAmount", () => { const body = validQuoteBody(); body.toAmount = "28,75"; diff --git a/packages/shared/src/services/alfredpay/schemas.ts b/packages/shared/src/services/alfredpay/schemas.ts index 85ec02509..9ea5aaa44 100644 --- a/packages/shared/src/services/alfredpay/schemas.ts +++ b/packages/shared/src/services/alfredpay/schemas.ts @@ -3,6 +3,7 @@ import { AlfredpayCustomerType } from "../../tokens/types/base"; import { AlfredpayConfigPair, AlfredpayFee, + AlfredpayFeeType, AlfredpayFiatAccount, AlfredpayFiatAccountType, AlfredpayFiatPaymentInstructions, @@ -33,8 +34,8 @@ type ConsumedConfigPair = Pick< AlfredpayConfigPair, "fromCurrency" | "toCurrency" | "minQuantity" | "maxQuantity" | "decimals" | "typeCustomer" >; -type ConsumedFee = Pick; -type ConsumedQuote = Pick & { +type ConsumedFee = Pick; +type ConsumedQuote = Pick & { fees: ConsumedFee[]; }; type ConsumedOnrampTransaction = Pick & { @@ -84,19 +85,21 @@ export const alfredpayConfigsResponseSchema = z.looseObject({ /** * The body of a POST …/quotes response, BUY and SELL alike — the consumed fields are - * direction-independent (`fromCurrency`/`toCurrency`/`rate` are never read back; Vortex - * trusts its own request there). + * direction-independent (`fromCurrency`/`toCurrency` are never read back; Vortex trusts + * its own request there). */ export const alfredpayQuoteResponseSchema = z.looseObject({ expiration: parseableTimestamp, fees: z.array( z.looseObject({ amount: z.string().regex(DECIMAL_STRING), - currency: z.string().min(1) + currency: z.string().min(1), + type: z.enum(AlfredpayFeeType) }) ), fromAmount: z.string().regex(DECIMAL_STRING), quoteId: z.string().min(1), + rate: z.string().regex(DECIMAL_STRING), toAmount: z.string().regex(DECIMAL_STRING) }) satisfies z.ZodType; diff --git a/packages/shared/src/services/brla/brlaApiService.test.ts b/packages/shared/src/services/brla/brlaApiService.test.ts new file mode 100644 index 000000000..b995f37c4 --- /dev/null +++ b/packages/shared/src/services/brla/brlaApiService.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { generateKeyPairSync } from "crypto"; +import { BrlaApiService } from "./brlaApiService"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("BrlaApiService.getAveniaPublicKey", () => { + it("bounds the public-key request with an abort signal", async () => { + let signal: AbortSignal | null | undefined; + globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { + signal = init?.signal; + return new Response(JSON.stringify({ publicKey: "test-public-key" }), { + headers: { "Content-Type": "application/json" }, + status: 200 + }); + }); + + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + + await expect(service.getAveniaPublicKey()).resolves.toBe("test-public-key"); + expect(signal).toBeInstanceOf(AbortSignal); + }); +}); + +describe("BrlaApiService.sendRequest path templating", () => { + // GetKybAttempt is "/v2/kyc/attempts/{attemptId}". Before templating, the path param + // was appended, signing and requesting a literal "/{attemptId}/" URL. + it("interpolates the {attemptId} template instead of appending the path param", async () => { + let requestedUrl: string | undefined; + let signal: AbortSignal | null | undefined; + globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { + requestedUrl = String(input); + signal = init?.signal; + return new Response(JSON.stringify({ attempt: { id: "attempt-9" } }), { + headers: { "Content-Type": "application/json" }, + status: 200 + }); + }); + + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { format: "pem", type: "pkcs1" }, + publicKeyEncoding: { format: "pem", type: "pkcs1" } + }); + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + Object.assign(service, { apiKey: "test-api-key", privateKey }); + + await service.getKybAttemptStatus("attempt-9"); + + expect(requestedUrl).toContain("/v2/kyc/attempts/attempt-9"); + expect(requestedUrl).not.toContain("{attemptId}"); + // A hung connection must not stall callers forever — cron workers with + // waitForCompletion would otherwise never run another cycle. + expect(signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/packages/shared/src/services/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index 45897bbc8..2c98667ad 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -2,7 +2,7 @@ import * as forge from "node-forge"; import { BRLA_API_KEY, BRLA_BASE_URL, BRLA_PRIVATE_KEY, DocumentUploadRequest, DocumentUploadResponse } from "../.."; import logger from "../../logger"; import { ProviderHttpError } from "../providerHttpError"; -import { Endpoint, EndpointMapping, Endpoints, Methods } from "./mappings"; +import { Endpoint, EndpointMethod, EndpointRequestBody, EndpointResponse, Endpoints } from "./mappings"; import { AccountLimitsResponse, AveniaAccountBalanceResponse, @@ -14,8 +14,11 @@ import { AveniaPayinTicket, AveniaPaymentMethod, AveniaPayoutTicket, + AveniaPublicKeyResponse, AveniaQuoteResponse, AveniaSwapTicket, + AveniaWebhookRegistration, + AveniaWebhooksListResponse, BlockchainSendMethod, BrlaCurrency, GetKycAttemptResponse, @@ -39,6 +42,10 @@ interface CachedQuote { const QUOTE_CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes const QUOTE_CACHE_MAX_SIZE = 100; // Maximum number of cached entries +export const AVENIA_PUBLIC_KEY_TIMEOUT_MS = 10_000; +// Bound on every signed API request. A hung connection would otherwise stall callers +// indefinitely — cron workers with waitForCompletion never run their next cycle. +export const BRLA_REQUEST_TIMEOUT_MS = 30_000; /** * Error thrown when an Avenia/BRLA HTTP request fails. See {@link ProviderHttpError} for the @@ -109,19 +116,21 @@ export class BrlaApiService { return BrlaApiService.instance; } - public async sendRequest( + public async sendRequest>( endpoint: E, method: M, queryParams?: string, - payload?: EndpointMapping[E][M]["body"], + payload?: EndpointRequestBody, pathParam?: string - ): Promise { + ): Promise> { const timestamp = Date.now().toString(); const body = payload ? JSON.stringify(payload) : ""; let requestUri = endpoint as string; + // Endpoints that carry a {placeholder} interpolate it; the rest append the segment. + // Appending to a templated path would sign and request a literal "{attemptId}". if (pathParam) { - requestUri += `/${pathParam}`; + requestUri = requestUri.includes("{") ? requestUri.replace(/\{[^}]+\}/, pathParam) : `${requestUri}/${pathParam}`; } if (queryParams) { requestUri += `?${queryParams}`; @@ -147,7 +156,8 @@ export class BrlaApiService { const options: RequestInit = { headers, - method + method, + signal: AbortSignal.timeout(BRLA_REQUEST_TIMEOUT_MS) }; if (payload !== undefined) { @@ -186,9 +196,9 @@ export class BrlaApiService { }); } try { - return await response.json(); + return (await response.json()) as EndpointResponse; } catch { - return undefined; + return undefined as EndpointResponse; } } @@ -400,6 +410,45 @@ export class BrlaApiService { return await this.sendRequest(Endpoint.GetKybAttempt, "GET", undefined, undefined, attemptId); } + public async listWebhooks(): Promise { + return await this.sendRequest(Endpoint.Webhooks, "GET"); + } + + public async createWebhook(webhookUrl: string, subscriptions: string[]): Promise { + return await this.sendRequest(Endpoint.Webhooks, "POST", undefined, { subscriptions, webhookUrl }); + } + + public async updateWebhook(webhookId: string, webhookUrl: string, subscriptions: string[]): Promise { + await this.sendRequest(Endpoint.Webhooks, "PATCH", undefined, { subscriptions, webhookId, webhookUrl }); + } + + public async deleteWebhook(webhookId: string): Promise { + await this.sendRequest(Endpoint.Webhooks, "DELETE", undefined, undefined, webhookId); + } + + /** + * Avenia's webhook-signing public key. Unauthenticated, and Avenia's guide warns it + * rotates, so it is fetched rather than pinned in config. + */ + // eslint-disable-next-line class-methods-use-this + public async getAveniaPublicKey(): Promise { + const response = await fetch(`${BRLA_BASE_URL}/v2/public-key`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(AVENIA_PUBLIC_KEY_TIMEOUT_MS) + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Avenia public key: status '${response.status}'`); + } + + const { publicKey } = (await response.json()) as AveniaPublicKeyResponse; + if (!publicKey) { + throw new Error("Avenia public key response contained no key"); + } + + return publicKey; + } + public async getAccountBalance(subAccountId: string): Promise { const query = `subAccountId=${encodeURIComponent(subAccountId)}`; return await this.sendRequest(Endpoint.Balances, "GET", query); diff --git a/packages/shared/src/services/brla/mappings.ts b/packages/shared/src/services/brla/mappings.ts index 94529058e..ae26232c6 100644 --- a/packages/shared/src/services/brla/mappings.ts +++ b/packages/shared/src/services/brla/mappings.ts @@ -10,6 +10,8 @@ import { AveniaQuoteResponse, AveniaSubaccount, AveniaSwapTicket, + AveniaWebhookRegistration, + AveniaWebhooksListResponse, DocumentUploadRequest, DocumentUploadResponse, GetKycAttemptResponse, @@ -36,7 +38,8 @@ export enum Endpoint { Documents = "/v2/documents", GetKycAttempt = "/v2/kyc/attempts", GetKybAttempt = "/v2/kyc/attempts/{attemptId}", - Balances = "/v2/account/balances" + Balances = "/v2/account/balances", + Webhooks = "/v2/notifications/webhooks" } export interface EndpointMapping { @@ -210,7 +213,35 @@ export interface EndpointMapping { response: undefined; }; }; + [Endpoint.Webhooks]: { + POST: { + body: { webhookUrl: string; subscriptions: string[] }; + response: AveniaWebhookRegistration; + }; + GET: { + body: undefined; + response: AveniaWebhooksListResponse; + }; + PATCH: { + body: { webhookId: string; webhookUrl?: string; subscriptions?: string[] }; + response: undefined; + }; + DELETE: { + body: undefined; + response: undefined; + }; + }; } export type Endpoints = keyof EndpointMapping; -export type Methods = keyof EndpointMapping[Endpoints]; +export type EndpointMethod = Extract; +export type EndpointRequestBody> = EndpointMapping[E][M] extends { + body: infer B; +} + ? B + : never; +export type EndpointResponse> = EndpointMapping[E][M] extends { + response: infer R; +} + ? R + : never; diff --git a/packages/shared/src/services/brla/schemas.test.ts b/packages/shared/src/services/brla/schemas.test.ts index bc2c61b5c..6e5ffef4a 100644 --- a/packages/shared/src/services/brla/schemas.test.ts +++ b/packages/shared/src/services/brla/schemas.test.ts @@ -7,7 +7,9 @@ import { aveniaPayoutTicketSchema, aveniaPixInputTicketSchema, aveniaPixKeyDataSchema, - aveniaQuoteResponseSchema + aveniaQuoteResponseSchema, + aveniaWebhookRegistrationSchema, + aveniaWebhooksListSchema } from "./schemas"; function validQuoteBody() { @@ -137,3 +139,31 @@ describe("aveniaAccountInfoSchema", () => { expect(() => aveniaAccountInfoSchema.parse(body)).toThrow(); }); }); + +describe("Avenia webhook management schemas", () => { + test("accepts the create response's webhookId field", () => { + expect(() => aveniaWebhookRegistrationSchema.parse({ webhookId: "webhook-1" })).not.toThrow(); + expect(() => aveniaWebhookRegistrationSchema.parse({ id: "webhook-1" })).toThrow(); + }); + + test("accepts list entries with url and rejects the request-only webhookUrl field", () => { + const response = { + webhooks: [ + { + createdAt: "2026-01-01T00:00:00Z", + id: "webhook-1", + subscriptions: ["*"], + updatedAt: "2026-01-01T00:00:00Z", + url: "https://example.com/avenia" + } + ] + }; + + expect(() => aveniaWebhooksListSchema.parse(response)).not.toThrow(); + const [webhook] = response.webhooks; + const url = webhook.url; + delete (webhook as Partial).url; + Object.assign(webhook, { webhookUrl: url }); + expect(() => aveniaWebhooksListSchema.parse(response)).toThrow(); + }); +}); diff --git a/packages/shared/src/services/brla/schemas.ts b/packages/shared/src/services/brla/schemas.ts index 9ae7e3c9a..9e5c422e1 100644 --- a/packages/shared/src/services/brla/schemas.ts +++ b/packages/shared/src/services/brla/schemas.ts @@ -10,6 +10,9 @@ import { AveniaSubaccountAccountInfo, AveniaSubaccountWallet, AveniaTicketStatus, + AveniaWebhook, + AveniaWebhookRegistration, + AveniaWebhooksListResponse, Limit, PixInputTicketOutput, PixKeyData, @@ -136,3 +139,20 @@ export const aveniaAccountInfoSchema = z.looseObject({ }) ) }) satisfies z.ZodType; + +/** The body returned after POST /v2/notifications/webhooks. */ +export const aveniaWebhookRegistrationSchema = z.looseObject({ + webhookId: z.string().min(1) +}) satisfies z.ZodType; + +/** An entry in the GET /v2/notifications/webhooks response. */ +export const aveniaWebhookSchema = z.looseObject({ + id: z.string().min(1), + subscriptions: z.array(z.string().min(1)), + url: z.string().url() +}) satisfies z.ZodType; + +/** The body returned by GET /v2/notifications/webhooks. */ +export const aveniaWebhooksListSchema = z.looseObject({ + webhooks: z.array(aveniaWebhookSchema) +}) satisfies z.ZodType; diff --git a/packages/shared/src/services/brla/types.ts b/packages/shared/src/services/brla/types.ts index ff3e6cd93..d4de12889 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -354,6 +354,24 @@ export interface KybLevel1Response { basicCompanyDataUrl: string; } +/** + * Avenia models individual and company verification as the same "attempt" resource + * (both are fetched from /v2/kyc/attempts), so the polled response and the webhook + * payload carry this identical shape. result and resultMessage are absent until an + * attempt settles. + */ +export interface AveniaVerificationAttempt { + id: string; + levelName: string; + submissionData: Record; + status: KycAttemptStatus; + result?: KycAttemptResult; + resultMessage?: string; + retryable: boolean; + createdAt: string; + updatedAt: string; +} + export interface KybAttemptStatusResponse { failureReason?: string; result?: KycAttemptResult; @@ -361,17 +379,7 @@ export interface KybAttemptStatusResponse { } export interface AveniaKybAttemptStatusResponse { - attempt: { - id: string; - levelName: string; - submissionData: Record; - status: KycAttemptStatus; - result?: KycAttemptResult; - resultMessage: string; - retryable: boolean; - createdAt: string; - updatedAt: string; - }; + attempt: AveniaVerificationAttempt; } export enum AveniaDocumentType { @@ -457,3 +465,40 @@ export interface AveniaAccountBalanceResponse { USDT: string; }; } + +/** + * Avenia documents no KYB-specific subscription. Company attempts are expected to + * arrive under KYC because both verification kinds share the attempts resource, but + * that is unconfirmed — subscribing with All is what makes the assumption safe. + */ +export enum AveniaWebhookSubscription { + All = "*", + Kyc = "KYC", + LimitUpdate = "LIMIT-UPDATE", + Ticket = "TICKET" +} + +export interface AveniaWebhookEvent { + subAccountId: string; + subscription: string; + data: Record; + cursor?: string; +} + +export interface AveniaWebhook { + id: string; + url: string; + subscriptions: string[]; +} + +export interface AveniaWebhookRegistration { + webhookId: string; +} + +export interface AveniaWebhooksListResponse { + webhooks: AveniaWebhook[]; +} + +export interface AveniaPublicKeyResponse { + publicKey: string; +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 5c5d6e5c1..fe3cec12a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -11,4 +11,5 @@ export interface EphemeralAccount { address: string; } +export * from "./types/emailNotifications"; export * from "./types/rampDirection"; diff --git a/packages/shared/src/types/emailNotifications.ts b/packages/shared/src/types/emailNotifications.ts new file mode 100644 index 000000000..bc16cfd2d --- /dev/null +++ b/packages/shared/src/types/emailNotifications.ts @@ -0,0 +1,13 @@ +/** + * Stored `email_notifications.type` values — the wire contract between the API's email + * dispatch worker (which mutes a type when `notification_preferences.prefs[type]` is + * `false`) and the dashboard's Settings toggles that write those keys. The preferences + * endpoint accepts arbitrary keys without validation, so a drifted string mutes + * nothing, silently; both sides must consume this enum. + */ +export enum EmailNotificationType { + RampCompleted = "ramp_completed", + VerificationApproved = "verification_approved", + VerificationExpired = "verification_expired", + VerificationRejected = "verification_rejected" +} diff --git a/scripts/wire-contract/fixtures/fixture-surface.ts b/scripts/wire-contract/fixtures/fixture-surface.ts new file mode 100644 index 000000000..383078460 --- /dev/null +++ b/scripts/wire-contract/fixtures/fixture-surface.ts @@ -0,0 +1,52 @@ +// Synthetic surface covering the shapes the serializer must render deterministically. + +export enum FixtureDirection { + BUY = "buy", + SELL = "sell" +} + +export type FixtureCurrency = "ars" | "brl" | "eur"; + +export interface FixtureIndexed { + readonly [key: string]: string; +} + +export interface FixtureNested { + amountRaw: string; + direction: FixtureDirection; + readonly id: string; +} + +export interface FixtureRequest { + amounts: Record; + currency: FixtureCurrency; + memo?: string; + nested: FixtureNested; + next?: FixtureRequest; + roTuple: readonly [string, number]; + tags: string[]; + tuple: [string, number]; + verbose?: boolean; +} + +export type FixtureResult = FixtureNested | null; + +export type FixtureOutcome = T extends { verbose: true } ? FixtureNested : FixtureCurrency; + +export class FixtureClient { + private secret: string; + readonly retries: number; + + constructor(baseUrl: string, timeoutMs?: number) { + this.secret = baseUrl + String(timeoutMs ?? 0); + this.retries = 3; + } + + createRequest(currency: FixtureCurrency, verbose?: boolean): Promise { + return Promise.reject(new Error(`${currency}${String(verbose)}${this.secret}`)); + } + + merge(base: T, patch?: Partial): T { + return { ...base, ...patch }; + } +} diff --git a/scripts/wire-contract/fixtures/tsconfig.json b/scripts/wire-contract/fixtures/tsconfig.json new file mode 100644 index 000000000..983d23bf8 --- /dev/null +++ b/scripts/wire-contract/fixtures/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "lib": ["esnext"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "target": "ESNext" + }, + "include": ["./*.ts"] +} diff --git a/scripts/wire-contract/generate-report.test.ts b/scripts/wire-contract/generate-report.test.ts new file mode 100644 index 000000000..fcfc6a481 --- /dev/null +++ b/scripts/wire-contract/generate-report.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { buildEntryReport } from "./generate-report"; + +const FIXTURE_TSCONFIG = "scripts/wire-contract/fixtures/tsconfig.json"; +const FIXTURE_ENTRY = "scripts/wire-contract/fixtures/fixture-surface.ts"; + +const EXPECTED_FIXTURE_REPORT = `FixtureClient: class FixtureClient { + constructor(baseUrl: string, timeoutMs?: number); + createRequest: (currency: "ars" | "brl" | "eur", verbose?: boolean) => Promise<{ + amounts: Record<"ars" | "brl" | "eur", { + amountRaw: string; + direction: enum FixtureDirection { BUY = "buy", SELL = "sell" }; + readonly id: string; + }>; + currency: "ars" | "brl" | "eur"; + memo?: string; + nested: { + amountRaw: string; + direction: enum FixtureDirection { BUY = "buy", SELL = "sell" }; + readonly id: string; + }; + next?: ; + roTuple: readonly [string, number]; + tags: Array; + tuple: [string, number]; + verbose?: boolean; + }>; + merge: (base: T, patch?: Partial) => T; + readonly retries: number; +} + +FixtureCurrency: "ars" | "brl" | "eur" + +FixtureDirection: enum FixtureDirection { BUY = "buy", SELL = "sell" } + +FixtureIndexed: { + readonly [key: string]: string; +} + +FixtureNested: { + amountRaw: string; + direction: enum FixtureDirection { BUY = "buy", SELL = "sell" }; + readonly id: string; +} + +FixtureOutcome: ; + currency: "ars" | "brl" | "eur"; + memo?: string; + nested: { + amountRaw: string; + direction: enum FixtureDirection { BUY = "buy", SELL = "sell" }; + readonly id: string; + }; + next?: ; + roTuple: readonly [string, number]; + tags: Array; + tuple: [string, number]; + verbose?: boolean; +}> T extends { + verbose: true; +} ? { + amountRaw: string; + direction: enum FixtureDirection { BUY = "buy", SELL = "sell" }; + readonly id: string; +} : "ars" | "brl" | "eur" + +FixtureRequest: { + amounts: Record<"ars" | "brl" | "eur", { + amountRaw: string; + direction: enum FixtureDirection { BUY = "buy", SELL = "sell" }; + readonly id: string; + }>; + currency: "ars" | "brl" | "eur"; + memo?: string; + nested: { + amountRaw: string; + direction: enum FixtureDirection { BUY = "buy", SELL = "sell" }; + readonly id: string; + }; + next?: ; + roTuple: readonly [string, number]; + tags: Array; + tuple: [string, number]; + verbose?: boolean; +} + +FixtureResult: null | { + amountRaw: string; + direction: enum FixtureDirection { BUY = "buy", SELL = "sell" }; + readonly id: string; +}`; + +describe("wire-contract surface serializer", () => { + test("renders the fixture surface exactly (enums with values, structural expansion, sorted props, cycle guard)", () => { + expect(buildEntryReport(FIXTURE_TSCONFIG, FIXTURE_ENTRY)).toBe(EXPECTED_FIXTURE_REPORT); + }); + + test("is deterministic across independent program instances", () => { + expect(buildEntryReport(FIXTURE_TSCONFIG, FIXTURE_ENTRY)).toBe(buildEntryReport(FIXTURE_TSCONFIG, FIXTURE_ENTRY)); + }); + + test("never leaks filesystem paths into the report", () => { + const report = buildEntryReport(FIXTURE_TSCONFIG, FIXTURE_ENTRY); + expect(report).not.toContain("/Users/"); + expect(report).not.toContain("node_modules"); + expect(report).not.toContain("import("); + }); +}); diff --git a/scripts/wire-contract/generate-report.ts b/scripts/wire-contract/generate-report.ts new file mode 100644 index 000000000..0f8134406 --- /dev/null +++ b/scripts/wire-contract/generate-report.ts @@ -0,0 +1,431 @@ +/** + * Wire-contract surface report generator. + * + * Renders the typed partner-facing surface — the shared endpoint request/response types + * and the public SDK API — into a canonical, structurally expanded snapshot at + * docs/api/wire-contract.snapshot.md. Types declared inside this repository are expanded + * to their structural shape, so a change to a transitively referenced type (an enum + * value, a union member, a nested field) surfaces in the snapshot even when no endpoint + * file was edited. + * + * Usage: + * bun scripts/wire-contract/generate-report.ts --check # exit 1 if the snapshot is stale (CI) + * bun scripts/wire-contract/generate-report.ts --update # rewrite the snapshot + * + * The SDK entry resolves @vortexfi/shared through its built declarations, so run + * `bun run build:shared` before regenerating if shared changed. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import ts from "typescript"; + +const REPO_ROOT = resolve(import.meta.dir, "../.."); +const SNAPSHOT_FILE = resolve(REPO_ROOT, "docs/api/wire-contract.snapshot.md"); + +const MAX_DEPTH = 12; +const INDENT = " "; + +interface SurfaceEntry { + heading: string; + tsconfig: string; + entry: string; +} + +const ENTRIES: SurfaceEntry[] = [ + { + entry: "packages/shared/src/endpoints/index.ts", + heading: "packages/shared — partner wire contract (`src/endpoints`)", + tsconfig: "packages/shared/tsconfig.json" + }, + { + entry: "packages/sdk/src/index.ts", + heading: "packages/sdk — public SDK surface (`src/index.ts`)", + tsconfig: "packages/sdk/tsconfig.json" + } +]; + +interface SerializerContext { + checker: ts.TypeChecker; + program: ts.Program; + stack: Set; +} + +function isExternalDeclaration(ctx: SerializerContext, declaration: ts.Declaration): boolean { + const file = declaration.getSourceFile(); + if (ctx.program.isSourceFileDefaultLibrary(file)) return true; + return file.fileName.includes("/node_modules/"); +} + +function isExternalSymbol(ctx: SerializerContext, symbol: ts.Symbol | undefined): boolean { + const declarations = symbol?.declarations; + if (!declarations || declarations.length === 0) return false; + return declarations.every(declaration => isExternalDeclaration(ctx, declaration)); +} + +function typeOfSymbol(ctx: SerializerContext, symbol: ts.Symbol): ts.Type { + const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0]; + if (declaration) return ctx.checker.getTypeOfSymbolAtLocation(symbol, declaration); + return ctx.checker.getTypeOfSymbol(symbol); +} + +function symbolDisplayName(type: ts.Type): string { + if (type.aliasSymbol) return type.aliasSymbol.name; + const name = type.symbol?.name; + if (name && name !== "__type" && name !== "__object") return name; + return "…"; +} + +function indentBlock(text: string): string { + return text + .split("\n") + .map(line => (line.length > 0 ? INDENT + line : line)) + .join("\n"); +} + +function serializeEnum(ctx: SerializerContext, symbol: ts.Symbol): string { + const members: string[] = []; + symbol.exports?.forEach(member => { + const declaration = member.declarations?.[0]; + if (!declaration || !ts.isEnumMember(declaration)) return; + const value = ctx.checker.getConstantValue(declaration); + members.push(`${member.name} = ${typeof value === "string" ? JSON.stringify(value) : String(value)}`); + }); + members.sort(); + return `enum ${symbol.name} { ${members.join(", ")} }`; +} + +function serializeTypeParameterNodes( + ctx: SerializerContext, + nodes: readonly ts.TypeParameterDeclaration[] | undefined, + depth: number +): string { + if (!nodes || nodes.length === 0) return ""; + const parts = nodes.map(node => { + let text = node.name.text; + if (node.constraint) text += ` extends ${serializeType(ctx, ctx.checker.getTypeAtLocation(node.constraint), depth + 1)}`; + if (node.default) text += ` = ${serializeType(ctx, ctx.checker.getTypeAtLocation(node.default), depth + 1)}`; + return text; + }); + return `<${parts.join(", ")}>`; +} + +function serializeSignature(ctx: SerializerContext, signature: ts.Signature, depth: number): string { + const declaration = signature.declaration; + const typeParameters = + declaration && !ts.isJSDocSignature(declaration) ? serializeTypeParameterNodes(ctx, declaration.typeParameters, depth) : ""; + const parameters = signature.parameters.map(parameter => { + const declaration = parameter.valueDeclaration; + const optional = + declaration && + ts.isParameter(declaration) && + (declaration.questionToken !== undefined || declaration.initializer !== undefined); + const rest = declaration && ts.isParameter(declaration) && declaration.dotDotDotToken !== undefined; + const parameterType = serializeType(ctx, typeOfSymbol(ctx, parameter), depth + 1, { dropUndefined: optional === true }); + return `${rest ? "..." : ""}${parameter.name}${optional ? "?" : ""}: ${parameterType}`; + }); + const returnType = serializeType(ctx, signature.getReturnType(), depth + 1); + return `${typeParameters}(${parameters.join(", ")}) => ${returnType}`; +} + +function serializeObject(ctx: SerializerContext, type: ts.Type, depth: number): string { + const lines: string[] = []; + + for (const info of ctx.checker.getIndexInfosOfType(type)) { + const keyType = serializeType(ctx, info.keyType, depth + 1); + const valueType = serializeType(ctx, info.type, depth + 1); + lines.push(`${info.isReadonly ? "readonly " : ""}[key: ${keyType}]: ${valueType};`); + } + + const properties = [...ctx.checker.getPropertiesOfType(type)].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + ); + for (const property of properties) { + const declarations = property.declarations ?? []; + // Skip members inherited from lib types (e.g. Error.message/stack) and non-public class members. + if (declarations.length > 0 && declarations.every(declaration => isExternalDeclaration(ctx, declaration))) continue; + const modifiers = declarations.flatMap(declaration => + ts.canHaveModifiers(declaration) ? [...(ts.getModifiers(declaration) ?? [])] : [] + ); + if ( + modifiers.some( + modifier => modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword + ) + ) { + continue; + } + const optional = (property.flags & ts.SymbolFlags.Optional) !== 0; + const isReadonly = modifiers.some(modifier => modifier.kind === ts.SyntaxKind.ReadonlyKeyword); + const propertyType = serializeType(ctx, typeOfSymbol(ctx, property), depth + 1, { dropUndefined: optional }); + lines.push(`${isReadonly ? "readonly " : ""}${property.name}${optional ? "?" : ""}: ${propertyType};`); + } + + if (lines.length === 0) return "{}"; + return `{\n${indentBlock(lines.join("\n"))}\n}`; +} + +interface SerializeOptions { + dropUndefined?: boolean; +} + +function serializeType(ctx: SerializerContext, type: ts.Type, depth: number, options: SerializeOptions = {}): string { + const { checker } = ctx; + const flags = type.flags; + + if (flags & ts.TypeFlags.EnumLiteral && !(flags & ts.TypeFlags.Union)) { + return checker.typeToString(type); + } + + if (type.symbol && type.symbol.flags & ts.SymbolFlags.Enum) { + if (isExternalSymbol(ctx, type.symbol)) return type.symbol.name; + return serializeEnum(ctx, type.symbol); + } + + if ( + flags & + (ts.TypeFlags.String | + ts.TypeFlags.Number | + ts.TypeFlags.Boolean | + ts.TypeFlags.BigInt | + ts.TypeFlags.StringLiteral | + ts.TypeFlags.NumberLiteral | + ts.TypeFlags.BooleanLiteral | + ts.TypeFlags.BigIntLiteral | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null | + ts.TypeFlags.Void | + ts.TypeFlags.Never | + ts.TypeFlags.Unknown | + ts.TypeFlags.Any | + ts.TypeFlags.ESSymbol | + ts.TypeFlags.TypeParameter | + ts.TypeFlags.Index | + ts.TypeFlags.TemplateLiteral) + ) { + return checker.typeToString(type); + } + + if (flags & ts.TypeFlags.Union) { + let parts = (type as ts.UnionType).types; + if (options.dropUndefined) parts = parts.filter(part => (part.flags & ts.TypeFlags.Undefined) === 0); + if (parts.length === 1) return serializeType(ctx, parts[0], depth); + const hasTrue = parts.some(part => checker.typeToString(part) === "true"); + const hasFalse = parts.some(part => checker.typeToString(part) === "false"); + const serialized = parts + .filter( + part => !(hasTrue && hasFalse && (checker.typeToString(part) === "true" || checker.typeToString(part) === "false")) + ) + .map(part => serializeType(ctx, part, depth + 1)); + if (hasTrue && hasFalse) serialized.push("boolean"); + const unique = [...new Set(serialized)].sort(); + return unique.join(" | "); + } + + if (flags & ts.TypeFlags.Intersection) { + const serialized = (type as ts.IntersectionType).types.map(part => serializeType(ctx, part, depth + 1)); + return [...new Set(serialized)].sort().join(" & "); + } + + if (checker.isArrayType(type)) { + const [element] = checker.getTypeArguments(type as ts.TypeReference); + return `Array<${element ? serializeType(ctx, element, depth + 1) : "unknown"}>`; + } + + if (checker.isTupleType(type)) { + const elements = checker.getTypeArguments(type as ts.TypeReference).map(element => serializeType(ctx, element, depth + 1)); + return `${(type as ts.TupleTypeReference).target.readonly ? "readonly " : ""}[${elements.join(", ")}]`; + } + + // Named external types (lib utility types, viem/polkadot types, ...): keep the name, expand in-repo type arguments. + const referenceSymbol = type.aliasSymbol ?? type.symbol; + if (isExternalSymbol(ctx, referenceSymbol)) { + const typeArguments = + type.aliasSymbol && type.aliasTypeArguments + ? type.aliasTypeArguments + : (type as ts.TypeReference).target + ? checker.getTypeArguments(type as ts.TypeReference) + : []; + const name = referenceSymbol?.name ?? checker.typeToString(type); + if (typeArguments.length === 0) return name; + return `${name}<${typeArguments.map(argument => serializeType(ctx, argument, depth + 1)).join(", ")}>`; + } + + if (flags & ts.TypeFlags.Conditional) { + if (depth > MAX_DEPTH) return symbolDisplayName(type); + if (ctx.stack.has(type)) return ``; + ctx.stack.add(type); + try { + const conditional = type as ts.ConditionalType; + const node = conditional.root.node; + const checkText = serializeType(ctx, conditional.checkType, depth + 1); + const extendsText = serializeType(ctx, conditional.extendsType, depth + 1); + const trueText = serializeType(ctx, checker.getTypeAtLocation(node.trueType), depth + 1); + const falseText = serializeType(ctx, checker.getTypeAtLocation(node.falseType), depth + 1); + return `${checkText} extends ${extendsText} ? ${trueText} : ${falseText}`; + } finally { + ctx.stack.delete(type); + } + } + + if (flags & ts.TypeFlags.Object) { + if (depth > MAX_DEPTH) return symbolDisplayName(type); + if (ctx.stack.has(type)) return ``; + ctx.stack.add(type); + try { + const callSignatures = type.getCallSignatures(); + if (callSignatures.length > 0 && ctx.checker.getPropertiesOfType(type).length === 0) { + return callSignatures.map(signature => serializeSignature(ctx, signature, depth)).join(" & "); + } + return serializeObject(ctx, type, depth); + } finally { + ctx.stack.delete(type); + } + } + + return checker.typeToString(type); +} + +function serializeClass(ctx: SerializerContext, symbol: ts.Symbol): string { + const lines: string[] = []; + const staticType = typeOfSymbol(ctx, symbol); + for (const signature of staticType.getConstructSignatures()) { + const parameters = signature.parameters.map(parameter => { + const declaration = parameter.valueDeclaration; + const optional = + declaration && + ts.isParameter(declaration) && + (declaration.questionToken !== undefined || declaration.initializer !== undefined); + return `${parameter.name}${optional ? "?" : ""}: ${serializeType(ctx, typeOfSymbol(ctx, parameter), 1, { dropUndefined: optional === true })}`; + }); + lines.push(`constructor(${parameters.join(", ")});`); + } + const instanceType = ctx.checker.getDeclaredTypeOfSymbol(symbol); + const body = serializeObject(ctx, instanceType, 0); + const members = + body === "{}" + ? [] + : body + .slice(2, -2) + .split("\n") + .map(line => line.replace(new RegExp(`^${INDENT}`), "")); + lines.push(...members.filter(line => line.length > 0)); + if (lines.length === 0) return `class ${symbol.name} {}`; + return `class ${symbol.name} {\n${indentBlock(lines.join("\n"))}\n}`; +} + +function serializeExport(ctx: SerializerContext, symbol: ts.Symbol): string { + const resolved = symbol.flags & ts.SymbolFlags.Alias ? ctx.checker.getAliasedSymbol(symbol) : symbol; + + if (resolved.flags & ts.SymbolFlags.Enum) return serializeEnum(ctx, resolved); + if (resolved.flags & ts.SymbolFlags.Class) return serializeClass(ctx, resolved); + if (resolved.flags & (ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias)) { + const declaration = resolved.declarations?.find( + (candidate): candidate is ts.TypeAliasDeclaration | ts.InterfaceDeclaration => + ts.isTypeAliasDeclaration(candidate) || ts.isInterfaceDeclaration(candidate) + ); + const typeParameters = serializeTypeParameterNodes(ctx, declaration?.typeParameters, 0); + const body = serializeType(ctx, ctx.checker.getDeclaredTypeOfSymbol(resolved), 0); + return typeParameters ? `${typeParameters} ${body}` : body; + } + return serializeType(ctx, typeOfSymbol(ctx, resolved), 0); +} + +export function buildEntryReport(tsconfigPath: string, entryPath: string): string { + const absoluteTsconfig = isAbsolute(tsconfigPath) ? tsconfigPath : resolve(REPO_ROOT, tsconfigPath); + const absoluteEntry = isAbsolute(entryPath) ? entryPath : resolve(REPO_ROOT, entryPath); + + const configFile = ts.readConfigFile(absoluteTsconfig, ts.sys.readFile); + if (configFile.error) + throw new Error(`Failed to read ${tsconfigPath}: ${ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n")}`); + const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(absoluteTsconfig)); + const program = ts.createProgram([absoluteEntry], { ...parsed.options, noEmit: true }); + const checker = program.getTypeChecker(); + + const sourceFile = program.getSourceFile(absoluteEntry); + if (!sourceFile) throw new Error(`Entry file not found in program: ${entryPath}`); + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) throw new Error(`Entry file has no module symbol (no exports?): ${entryPath}`); + + const ctx: SerializerContext = { checker, program, stack: new Set() }; + + const exports = [...checker.getExportsOfModule(moduleSymbol)].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + ); + const sections = exports.map(exported => `${exported.name}: ${serializeExport(ctx, exported)}`); + return sections.join("\n\n"); +} + +export function buildReport(): string { + const parts: string[] = [ + "# Wire-contract snapshot", + "", + "Generated by `bun run wire-contract:update` — do not edit by hand.", + "", + "This file is a canonical, structurally expanded rendering of the typed partner-facing", + "surface: the shared endpoint request/response types and the public SDK API. CI runs", + "`bun run wire-contract:check` and fails when this snapshot is stale, so every change", + "to what integrators consume appears as an explicit, reviewable diff in this file.", + "A diff here means: check backward compatibility for live integrations, and keep", + "`docs/api/openapi/vortex.openapi.json` and the SDK error mappings in sync.", + "" + ]; + + for (const entry of ENTRIES) { + parts.push(`## ${entry.heading}`, "", "```text", buildEntryReport(entry.tsconfig, entry.entry), "```", ""); + } + + return `${parts.join("\n").trimEnd()}\n`; +} + +function firstDifference(expected: string, actual: string): string { + const expectedLines = expected.split("\n"); + const actualLines = actual.split("\n"); + const length = Math.max(expectedLines.length, actualLines.length); + for (let index = 0; index < length; index++) { + if (expectedLines[index] !== actualLines[index]) { + return [ + `First difference at line ${index + 1}:`, + ` snapshot: ${expectedLines[index] ?? ""}`, + ` generated: ${actualLines[index] ?? ""}` + ].join("\n"); + } + } + return "Files differ in trailing whitespace or length."; +} + +function main(): void { + const mode = process.argv[2] ?? "--check"; + const report = buildReport(); + + if (mode === "--update") { + writeFileSync(SNAPSHOT_FILE, report); + console.log(`Wrote ${SNAPSHOT_FILE}`); + return; + } + + if (mode !== "--check") { + console.error(`Unknown mode ${mode}. Use --check or --update.`); + process.exit(2); + } + + let existing = ""; + try { + existing = readFileSync(SNAPSHOT_FILE, "utf8"); + } catch { + console.error(`Snapshot missing at ${SNAPSHOT_FILE}. Run: bun run wire-contract:update`); + process.exit(1); + } + + if (existing === report) { + console.log("Wire-contract snapshot is up to date."); + return; + } + + console.error("Wire-contract snapshot is STALE. The partner-facing typed surface changed."); + console.error(firstDifference(existing, report)); + console.error("\nIf the change is intentional, regenerate and commit the snapshot:"); + console.error(" bun run build:shared && bun run wire-contract:update"); + console.error("Then review the snapshot diff for backward compatibility with live integrators."); + process.exit(1); +} + +if (import.meta.main) main(); diff --git a/supabase/config.toml b/supabase/config.toml index 31d89d746..91e724ccb 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -210,15 +210,20 @@ otp_length = 6 # Number of seconds before the email OTP expires (defaults to 1 hour). otp_expiry = 3600 -# Use a production-ready SMTP server +# Use a production-ready SMTP server. +# Applies to the LOCAL stack only — nothing pushes this file to hosted projects, +# so staging/production auth mail must be set in the Supabase Dashboard +# (Project Settings -> Authentication -> SMTP) with these same values. +# Uncomment once the vortexfinance.co domain is verified in Resend and +# RESEND_API_KEY is exported in the shell that runs `supabase start`. # [auth.email.smtp] # enabled = true -# host = "smtp.sendgrid.net" +# host = "smtp.resend.com" # port = 587 -# user = "apikey" -# pass = "env(SENDGRID_API_KEY)" -# admin_email = "admin@email.com" -# sender_name = "Admin" +# user = "resend" +# pass = "env(RESEND_API_KEY)" +# admin_email = "support@vortexfinance.co" +# sender_name = "Vortex Finance" # Uncomment to customize email template # [auth.email.template.invite]