diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 000000000..0787c41ce --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,54 @@ +# Non-PR-blocking external API contract checks (see docs/features/contract-tests.md). +# Runs the live halves of the contract suites against the real partner APIs nightly; +# failures alert but never gate merges. The hermetic halves of the same suites run +# in the PR-blocking test job. +name: external-api-contracts + +on: + schedule: + - cron: "30 3 * * *" + workflow_dispatch: + +jobs: + contracts: + name: External API contracts (live) + runs-on: ubuntu-latest + env: + CI: true + RUN_LIVE_TESTS: "1" + # A nightly where zero live calls completed must fail, not rot as green. + CONTRACT_EXPECT_LIVE: "1" + + steps: + - name: 🛒 Checkout code + uses: actions/checkout@v3 + + - name: 🧩 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.1 + + - name: 🧩 Install dependencies + run: bun install --frozen-lockfile + + - name: 🔨 Build shared package + run: bun run build:shared + + - name: 🧪 Live contract suites + working-directory: apps/api + run: bun test src/tests/contracts/ + + # Non-blocking runs are only useful if somebody hears about failures. + # Same webhook token the nightly e2e workflow uses; skips silently when unset. + - name: 📣 Notify Slack on failure + if: failure() + env: + SLACK_WEB_HOOK_TOKEN: ${{ secrets.SLACK_WEB_HOOK_TOKEN }} + run: | + if [ -z "$SLACK_WEB_HOOK_TOKEN" ]; then + echo "SLACK_WEB_HOOK_TOKEN secret not configured; skipping notification." + exit 0 + fi + curl -sf -X POST -H 'Content-Type: application/json' \ + -d "{\"text\":\"Nightly external API contract run failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \ + "https://hooks.slack.com/services/${SLACK_WEB_HOOK_TOKEN}" diff --git a/CLAUDE.md b/CLAUDE.md index 79d7bfd4e..fd22663b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,13 @@ ARRIVAL_TEXT_BY_TOKEN, sep10 tokenMapping. ## Testing +### Test Coverage Requirements + +These apply to every agent working in this repo: + +- **Bug fixes and regressions**: if a bug or regression slipped past the existing tests, add a test that reproduces it (and fails without the fix) before/with the fix — so it can't silently come back. Write the test at the level that actually covers the gap (unit or integration). Only skip when a test genuinely can't capture it (e.g. purely cosmetic, environment/config, or third-party behavior) — and say why you skipped. +- **New features**: always ship the appropriate tests alongside the feature. Cover the core behavior and the edge cases that matter, not just the happy path. + ### Backend Integration Tests ```bash cd apps/api @@ -160,9 +167,9 @@ bun test ## Security Spec Sync -`docs/security-spec/` is the audit-facing source of truth for security-sensitive behavior. When changing auth, admin routes, quote/ramp state, signing, fees, partner pricing, integrations, migrations/schema that affect invariants, or cross-chain fund flow, do a quick targeted check for the matching spec file and update it in the same change if behavior changed. +`docs/security-spec/` is the audit-facing source of truth for security-sensitive behavior, and it must not go stale. Any change to an API feature or its business logic — auth, admin routes, quote/ramp state, signing, fees, partner pricing, integrations, migrations/schema that affect invariants, or cross-chain fund flow — must be cross-checked against the matching spec file and updated in the same change whenever the behavior it documents changed. This applies to every agent working in this repo, not just the one that first touched the code. -Keep this lightweight: grep/read only the relevant spec path from `docs/security-spec/README.md`; skip this for cosmetic refactors, test-only changes, or implementation changes that do not alter security-relevant behavior. +Keep this lightweight: grep/read only the relevant spec path from `docs/security-spec/README.md`; skip this for cosmetic refactors, test-only changes, or implementation changes that do not alter security-relevant behavior. If a change alters documented behavior but you are unsure which spec file owns it, say so rather than leaving the spec silently stale. ## Type Issues diff --git a/apps/api/src/test-utils/contract-support.ts b/apps/api/src/test-utils/contract-support.ts new file mode 100644 index 000000000..e891bd40a --- /dev/null +++ b/apps/api/src/test-utils/contract-support.ts @@ -0,0 +1,34 @@ +/** + * Helpers for the external API contract suites (docs/features/contract-tests.md). + * + * Partner sandboxes are allowed to be shaky: any error thrown by the live call + * itself (network failure, 5xx, rate limit) makes the check INCONCLUSIVE — logged + * and skipped, never failed. Only a successful response that violates a schema + * fails a contract test, so the nightly alert channel stays meaningful. + * + * The nightly workflow sets CONTRACT_EXPECT_LIVE=1: a run where zero live calls + * completed (credential rot, endpoint down all night) then fails instead of + * rotting as green-but-empty. Counters are per test file (bun isolates files), + * so every contract suite asserts its own live coverage. + */ +let liveCompleted = 0; + +export async function runLive(label: string, call: () => Promise): Promise { + try { + const result = await call(); + liveCompleted += 1; + return result; + } catch (error) { + console.warn(`[contract:live] ${label} inconclusive: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} + +export function assertLiveCoverage(): void { + if (process.env.CONTRACT_EXPECT_LIVE && liveCompleted === 0) { + throw new Error( + "CONTRACT_EXPECT_LIVE=1 but no live contract call completed in this suite — " + + "check partner endpoint availability and credentials." + ); + } +} 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 2b73995ce..ab71a8df7 100644 --- a/apps/api/src/test-utils/fake-world/fake-squidrouter.ts +++ b/apps/api/src/test-utils/fake-world/fake-squidrouter.ts @@ -37,6 +37,7 @@ export class FakeSquidRouter { toAmount: this.computeToAmount(params), toToken: { decimals: this.toTokenDecimals } }, + quoteId: "fake-squid-quote", transactionRequest: { data: this.transactionData, gasLimit: this.transactionGasLimit, diff --git a/apps/api/src/tests/contracts/squidrouter.contract.test.ts b/apps/api/src/tests/contracts/squidrouter.contract.test.ts new file mode 100644 index 000000000..8686a0a84 --- /dev/null +++ b/apps/api/src/tests/contracts/squidrouter.contract.test.ts @@ -0,0 +1,79 @@ +/** + * External API contract: SquidRouter (docs/features/contract-tests.md). + * + * The same consumed-contract schemas run against the fake (hermetic, PR-blocking) + * and against the real public API (live, nightly). The live half needs no + * credentials — the integrator id is baked into squidRouterConfigBase. + * + * The status endpoint has no live check: it requires the hash of a real, recent + * cross-chain transaction. Its consumed surface (status, isGMPTransaction) is + * covered hermetically. + */ +import { describe, expect, test } from "bun:test"; +import { + createGenericRouteParams, + EvmToken, + evmTokenConfig, + getRoute, + Networks, + squidrouterRouteResponseSchema, + squidrouterStatusResponseSchema +} from "@vortexfi/shared"; +import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; +import { FakeSquidRouter } from "../../test-utils/fake-world/fake-squidrouter"; + +const RUN_LIVE = !!process.env.RUN_LIVE_TESTS; + +// Routes are quotes, nothing is executed — but Squid screens from/to addresses and +// rejects blocklisted ones with a 403 "swaps are currently unavailable" (the well-known +// hardhat dev address is blocked, for example). Use an unremarkable placeholder. +const TEST_ADDRESS = "0x1234567890123456789012345678901234567890"; + +// Mirrors the cross-chain onramp leg (USDC on Polygon → USDT on Arbitrum) using the +// same param builder production uses, so the live request has production shape. +function buildRouteParams() { + const fromToken = evmTokenConfig[Networks.Polygon]?.[EvmToken.USDC]?.erc20AddressSourceChain; + const toToken = evmTokenConfig[Networks.Arbitrum]?.[EvmToken.USDT]?.erc20AddressSourceChain; + if (!fromToken || !toToken) { + throw new Error("Token config no longer contains the Polygon USDC / Arbitrum USDT pair"); + } + return createGenericRouteParams({ + amount: "10000000", // 10 USDC in raw units + destinationAddress: TEST_ADDRESS, + fromAddress: TEST_ADDRESS, + fromNetwork: Networks.Polygon, + fromToken, + toNetwork: Networks.Arbitrum, + toToken + }); +} + +describe("SquidRouter external API contract — hermetic (fake)", () => { + test("fake route output satisfies the consumed route contract", async () => { + const fake = new FakeSquidRouter(); + const result = await fake.getRoute(buildRouteParams()); + expect(() => squidrouterRouteResponseSchema.parse(result.data)).not.toThrow(); + }); + + test("fake status output satisfies the consumed status contract", async () => { + const fake = new FakeSquidRouter(); + const status = await fake.getStatus(); + expect(() => squidrouterStatusResponseSchema.parse(status)).not.toThrow(); + }); +}); + +describe.skipIf(!RUN_LIVE)("SquidRouter external API contract — live", () => { + test( + "POST /v2/route response satisfies the consumed route contract", + async () => { + const result = await runLive("squidrouter getRoute", () => getRoute(buildRouteParams())); + if (!result) return; // inconclusive — see test-utils/contract-support.ts + squidrouterRouteResponseSchema.parse(result.data); + }, + 60_000 + ); +}); + +test.skipIf(!RUN_LIVE)("live contract coverage actually ran", () => { + assertLiveCoverage(); +}); 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 44d445f91..69a6368fd 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,6 +1,4 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import { Keyring } from "@polkadot/api"; -import { cryptoWaitReady, mnemonicGenerate } from "@polkadot/util-crypto"; import { AveniaTicketStatus, EvmToken, @@ -150,13 +148,12 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via return (await response.json()) as { id: string; outputAmount: string }; } - // The EVM→BRL route still requires a Substrate ephemeral in signingAccounts - // (validateOfframpQuote legacy default) even though this path never uses it. - async function createSubstrateEphemeralAddress(): Promise { - await cryptoWaitReady(); - const keyring = new Keyring({ type: "sr25519" }); - return keyring.addFromUri(mnemonicGenerate()).address; - } + // The EVM→BRL route still requires a Substrate entry in signingAccounts + // (validateOfframpQuote legacy default) even though this path never uses it to + // sign — all signing here is EVM. A static well-known SS58 address keeps the test + // off the @polkadot WASM keyring, whose CJS/ESM dual-load intermittently leaves an + // uninitialized bridge under Bun and crashed this suite in CI. + const SUBSTRATE_PLACEHOLDER_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; async function registerViaApi( quoteId: string, @@ -175,7 +172,7 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via quoteId, signingAccounts: [ { address: ephemeral.address, type: "EVM" }, - { address: await createSubstrateEphemeralAddress(), type: "Substrate" } + { address: SUBSTRATE_PLACEHOLDER_ADDRESS, type: "Substrate" } ] }), headers: { 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 1d456832e..3484de44d 100644 --- a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts @@ -1,6 +1,4 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import { Keyring } from "@polkadot/api"; -import { cryptoWaitReady, mnemonicGenerate } from "@polkadot/util-crypto"; import { AveniaTicketStatus, EvmToken, @@ -142,13 +140,12 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { return (await response.json()) as { id: string; outputAmount: string }; } - // The EVM→BRL route still requires a Substrate ephemeral in signingAccounts - // (validateOfframpQuote legacy default) even though this path never uses it. - async function createSubstrateEphemeralAddress(): Promise { - await cryptoWaitReady(); - const keyring = new Keyring({ type: "sr25519" }); - return keyring.addFromUri(mnemonicGenerate()).address; - } + // The EVM→BRL route still requires a Substrate entry in signingAccounts + // (validateOfframpQuote legacy default) even though this path never uses it to + // sign — all signing here is EVM. A static well-known SS58 address keeps the test + // off the @polkadot WASM keyring, whose CJS/ESM dual-load intermittently leaves an + // uninitialized bridge under Bun and crashed this suite in CI. + const SUBSTRATE_PLACEHOLDER_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; async function registerViaApi( quoteId: string, @@ -167,7 +164,7 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { quoteId, signingAccounts: [ { address: ephemeral.address, type: "EVM" }, - { address: await createSubstrateEphemeralAddress(), type: "Substrate" } + { address: SUBSTRATE_PLACEHOLDER_ADDRESS, type: "Substrate" } ] }), headers: { diff --git a/apps/frontend/_redirects b/apps/frontend/_redirects index a8a024938..9f42a4afb 100644 --- a/apps/frontend/_redirects +++ b/apps/frontend/_redirects @@ -5,6 +5,7 @@ # Known app routes — serve SPA shell with real 200 / /index.html 200 /business /index.html 200 +/payments /index.html 200 /contact /index.html 200 /privacy-policy /index.html 200 /terms-and-conditions /index.html 200 diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 699998297..c5c9b849d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -71,7 +71,7 @@ "wagmi": "catalog:", "web3": "^4.16.0", "xstate": "^5.20.1", - "zod": "^4.3.6", + "zod": "catalog:", "zustand": "^5.0.2" }, "devDependencies": { diff --git a/apps/frontend/playwright.config.ts b/apps/frontend/playwright.config.ts index 186bf6fce..116d1dcb3 100644 --- a/apps/frontend/playwright.config.ts +++ b/apps/frontend/playwright.config.ts @@ -18,8 +18,16 @@ export default defineConfig({ webServer: { command: "bun x --bun vite --port 5173 --strictPort", // A placeholder Alchemy key so balance fetching runs at all; every Alchemy - // endpoint is intercepted per-test in e2e/support/mockBackend.ts. - env: { VITE_ALCHEMY_API_KEY: "e2e-mock-key" }, + // endpoint is intercepted per-test in e2e/support/mockBackend.ts. The dummy + // Supabase vars keep src/config/supabase.ts from throwing at import time + // (it's loaded eagerly via services/auth) — without them the app white-screens + // on CI, where the webServer spins up a fresh Vite instead of reusing `bun dev`. + // ".invalid" never resolves, so an accidental real call fails loudly. + env: { + VITE_ALCHEMY_API_KEY: "e2e-mock-key", + VITE_SUPABASE_ANON_KEY: "e2e-mock-anon-key", + VITE_SUPABASE_URL: "http://supabase.invalid" + }, reuseExistingServer: !process.env.CI, timeout: 120_000, url: "http://127.0.0.1:5173" diff --git a/apps/frontend/public/sitemap.xml b/apps/frontend/public/sitemap.xml index 1f61ae470..f7e1738bf 100644 --- a/apps/frontend/public/sitemap.xml +++ b/apps/frontend/public/sitemap.xml @@ -10,6 +10,11 @@ monthly 0.8 + + https://www.vortexfinance.co/payments + monthly + 0.8 + https://www.vortexfinance.co/contact monthly diff --git a/apps/frontend/src/assets/payments-hero-rails.png b/apps/frontend/src/assets/payments-hero-rails.png new file mode 100644 index 000000000..beadd7f86 Binary files /dev/null and b/apps/frontend/src/assets/payments-hero-rails.png differ diff --git a/apps/frontend/src/components/Navbar/DesktopNavbar.tsx b/apps/frontend/src/components/Navbar/DesktopNavbar.tsx index f9c001ec2..d616a634e 100644 --- a/apps/frontend/src/components/Navbar/DesktopNavbar.tsx +++ b/apps/frontend/src/components/Navbar/DesktopNavbar.tsx @@ -56,6 +56,22 @@ export const DesktopNavbar = () => { > {t("components.navbar.business")} + + {t("components.navbar.payments")} +
diff --git a/apps/frontend/src/components/Navbar/MobileMenu.tsx b/apps/frontend/src/components/Navbar/MobileMenu.tsx index 09ae7618f..d78ea4b16 100644 --- a/apps/frontend/src/components/Navbar/MobileMenu.tsx +++ b/apps/frontend/src/components/Navbar/MobileMenu.tsx @@ -104,6 +104,20 @@ export const MobileMenu = ({ onMenuItemClick }: MobileMenuProps) => { + + + {t("components.navbar.payments")} + + + Buy & Sell diff --git a/apps/frontend/src/pages/payments/index.test.ts b/apps/frontend/src/pages/payments/index.test.ts new file mode 100644 index 000000000..c4c35b97c --- /dev/null +++ b/apps/frontend/src/pages/payments/index.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildPaymentsInquiry, PaymentsRouteSummaryData } from "./paymentsLeadPayload"; + +describe("buildPaymentsInquiry", () => { + it("bundles route fields into the contact inquiry payload", () => { + const data: PaymentsRouteSummaryData = { + country: "Brazil", + payoutCurrency: "BRL", + receiveCurrency: "USDC", + useCase: "Service exporter", + volume: "50k to 250k" + }; + + expect(buildPaymentsInquiry(data)).toBe( + [ + "Payments route comparison request", + "", + "Monthly volume: 50k to 250k", + "Receive currency: USDC", + "Payout currency: BRL", + "Country: Brazil", + "Use case: Service exporter" + ].join("\n") + ); + }); +}); diff --git a/apps/frontend/src/pages/payments/index.tsx b/apps/frontend/src/pages/payments/index.tsx new file mode 100644 index 000000000..c024e6efc --- /dev/null +++ b/apps/frontend/src/pages/payments/index.tsx @@ -0,0 +1,731 @@ +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { useMutation } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useEffect, useId, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import PaymentsHeroRails from "../../assets/payments-hero-rails.png"; +import { Field } from "../../components/Field"; +import { cn } from "../../helpers/cn"; +import { submitContactForm } from "../../services/api/contact.service"; +import { buildPaymentsInquiry } from "./paymentsLeadPayload"; + +const FLOW_SEGMENTS = ["serviceExporters", "productExporters", "platforms"] as const; +const PROCESS_STEPS = ["kyb", "receive", "quote", "settle"] as const; +const FIT_ITEMS = ["nonCustodial", "localCoverage", "compliance", "quoteExecution"] as const; +const COMPARISON_ROWS = ["spread", "operatingCash", "vendors"] as const; +const CURRENCIES = ["EUR", "USD", "USDC", "EURC", "BRL", "MXN", "COP", "ARS"]; +const LOCAL_RAILS = ["pix", "breb", "instantSepa", "argentina", "mexico"] as const; +const CURRENCY_STRIP = ["USDC", "EURC", "USD", "EUR", "BRL", "MXN", "COP", "ARS"]; +const VOLUME_OPTIONS = ["under50k", "50kTo250k", "250kTo1m", "over1m"] as const; +const RECEIVE_CURRENCY_OPTIONS = ["USD", "EUR", "USDC", "EURC"]; +const PAYOUT_CURRENCY_OPTIONS = ["BRL", "MXN", "COP", "ARS", "EUR"]; +const USE_CASE_OPTIONS = ["serviceExporter", "productExporter", "platform", "localPayout"] as const; + +const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + +const createPaymentsLeadSchema = (t: (key: string) => string) => + z.object({ + companyEmail: z + .string() + .trim() + .min(1, t("pages.payments.form.validation.emailRequired")) + .regex(EMAIL_REGEX, t("pages.payments.form.validation.emailFormat")), + companyName: z.string().trim().min(1, t("pages.payments.form.validation.companyNameRequired")), + country: z.string().trim().min(1, t("pages.payments.form.validation.countryRequired")), + payoutCurrency: z.string().trim().min(1, t("pages.payments.form.validation.payoutCurrencyRequired")), + privacyPolicyAccepted: z + .boolean() + .refine(val => val === true, { message: t("pages.contact.validation.privacyPolicyRequired") }), + receiveCurrency: z.string().trim().min(1, t("pages.payments.form.validation.receiveCurrencyRequired")), + useCase: z.string().trim().min(1, t("pages.payments.form.validation.useCaseRequired")), + volume: z.string().trim().min(1, t("pages.payments.form.validation.volumeRequired")) + }); + +type PaymentsLeadFormData = z.infer>; + +const selectClassName = + "select select-bordered w-full rounded-lg border-neutral-300 bg-white text-gray-800 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"; +const inputClassName = "focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"; + +export function PaymentsPage() { + return ( +
+ + + + + + + + +
+ ); +} + +function HeroSection() { + const shouldReduceMotion = useReducedMotion(); + const { t } = useTranslation(); + + return ( +
+
+ ); +} + +function PaymentRouteCard() { + const { t } = useTranslation(); + + return ( + + ); +} + +function RouteNode({ + label, + value, + tags, + highlight = false +}: { + label: string; + value: string; + tags?: string[]; + highlight?: boolean; +}) { + return ( +
+

{label}

+

{value}

+ {tags && ( +
+ {tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+ ); +} + +function RouteArrow() { + return