Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/contracts.yml
Original file line number Diff line number Diff line change
@@ -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}"
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/test-utils/contract-support.ts
Original file line number Diff line number Diff line change
@@ -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<T>(label: string, call: () => Promise<T>): Promise<T | null> {
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."
);
}
}
1 change: 1 addition & 0 deletions apps/api/src/test-utils/fake-world/fake-squidrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions apps/api/src/tests/contracts/squidrouter.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string> {
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,
Expand All @@ -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: {
Expand Down
17 changes: 7 additions & 10 deletions apps/api/src/tests/corridors/brl-offramp.scenario.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string> {
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,
Expand All @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/_redirects
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 10 additions & 2 deletions apps/frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/public/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://www.vortexfinance.co/payments</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://www.vortexfinance.co/contact</loc>
<changefreq>monthly</changefreq>
Expand Down
Binary file added apps/frontend/src/assets/payments-hero-rails.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions apps/frontend/src/components/Navbar/DesktopNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ export const DesktopNavbar = () => {
>
{t("components.navbar.business")}
</Link>
<Link
activeProps={{
className: cn(
"transition-colors group-hover:[&:not(:hover)]:text-gray-400",
isBusinessPage ? "text-blue-950 hover:text-blue-950" : "text-white hover:text-white"
)
}}
className={cn(
"text-xl transition-colors",
isBusinessPage ? "text-gray-600 hover:text-blue-950" : "text-gray-400 hover:text-white"
)}
params={params}
to="/{-$locale}/payments"
>
{t("components.navbar.payments")}
</Link>
</div>

<div className="flex items-center">
Expand Down
14 changes: 14 additions & 0 deletions apps/frontend/src/components/Navbar/MobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ export const MobileMenu = ({ onMenuItemClick }: MobileMenuProps) => {
</Link>
</motion.div>

<motion.div variants={menuItemVariants}>
<Link
activeProps={{
className: "text-white group-hover:[&:not(:hover)]:text-gray-400"
}}
className="block w-full px-2 py-3 text-left text-gray-400 text-xl transition-colors hover:text-white"
onClick={onMenuItemClick}
params={params}
to="/{-$locale}/payments"
>
{t("components.navbar.payments")}
</Link>
</motion.div>

<motion.div className="mt-6 mb-4" variants={buttonVariants}>
<Link className="btn btn-vortex-secondary w-full rounded-md" onClick={onMenuItemClick} to="/{-$locale}/widget">
Buy & Sell
Expand Down
26 changes: 26 additions & 0 deletions apps/frontend/src/pages/payments/index.test.ts
Original file line number Diff line number Diff line change
@@ -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")
);
});
});
Loading
Loading