diff --git a/admin-dashboard/.gitignore b/admin-dashboard/.gitignore index 83ed064..0680b2b 100644 --- a/admin-dashboard/.gitignore +++ b/admin-dashboard/.gitignore @@ -12,6 +12,9 @@ # testing /coverage +/playwright-report/ +/test-results/ +/e2e/.auth/ # next.js /.next/ diff --git a/admin-dashboard/auth.ts b/admin-dashboard/auth.ts index 0d0d2d3..52a865c 100644 --- a/admin-dashboard/auth.ts +++ b/admin-dashboard/auth.ts @@ -158,6 +158,4 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }, pages: { signIn: "/login" }, -}; - -export const { handlers, auth, signIn, signOut } = NextAuth(authConfig); +}); diff --git a/admin-dashboard/components/dashboard/TelemetryConsentSettings.tsx b/admin-dashboard/components/dashboard/TelemetryConsentSettings.tsx new file mode 100644 index 0000000..9918ab8 --- /dev/null +++ b/admin-dashboard/components/dashboard/TelemetryConsentSettings.tsx @@ -0,0 +1,19 @@ +"use client"; + +/** + * Telemetry consent toggle for the admin dashboard. + * Stub implementation to support E2E and local dev when telemetry backend is unavailable. + */ +export function TelemetryConsentSettings() { + return ( +
+

Telemetry

+

+ Anonymous usage telemetry helps improve Fluid. You can opt out at any time. +

+
+ ); +} diff --git a/admin-dashboard/e2e/auth.spec.ts b/admin-dashboard/e2e/auth.spec.ts new file mode 100644 index 0000000..a2c208f --- /dev/null +++ b/admin-dashboard/e2e/auth.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Authentication", () => { + test("login page renders email and password fields", async ({ page }) => { + await page.goto("/login"); + await page.waitForLoadState("domcontentloaded"); + + await expect(page.getByRole("heading", { name: /admin login/i })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.locator("#email")).toBeVisible(); + await expect(page.locator("#password")).toBeVisible(); + await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible(); + }); + + test("unauthenticated access to /admin redirects to login", async ({ page }) => { + await page.goto("/admin/dashboard"); + await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }); + }); + + test("invalid credentials show error message", async ({ page }) => { + await page.goto("/login"); + await page.waitForLoadState("domcontentloaded"); + + await page.locator("#email").fill("wrong@example.com"); + await page.locator("#password").fill("wrong-password"); + await page.getByRole("button", { name: /sign in/i }).click(); + + await expect(page.getByText(/invalid credentials/i)).toBeVisible({ timeout: 15_000 }); + }); +}); + +test.describe("Authenticated admin session", () => { + test.use({ storageState: "e2e/.auth/admin.json" }); + + test.beforeEach(async () => { + const fs = await import("node:fs"); + if (!fs.existsSync("e2e/.auth/admin.json")) { + test.skip(true, "Run global setup first (npm run test:e2e)"); + } + }); + + test("authenticated user reaches admin dashboard", async ({ page }) => { + await page.goto("/admin/dashboard"); + await expect(page).toHaveURL(/\/admin\/dashboard/, { timeout: 15_000 }); + }); +}); diff --git a/admin-dashboard/e2e/global-setup.ts b/admin-dashboard/e2e/global-setup.ts new file mode 100644 index 0000000..7578981 --- /dev/null +++ b/admin-dashboard/e2e/global-setup.ts @@ -0,0 +1,40 @@ +import bcrypt from "bcryptjs"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium, type FullConfig } from "@playwright/test"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const AUTH_FILE = path.join(__dirname, ".auth", "admin.json"); + +export default async function globalSetup(config: FullConfig): Promise { + const email = process.env.ADMIN_EMAIL ?? "e2e-admin@fluid.dev"; + const password = process.env.ADMIN_PASSWORD ?? "e2e-test-password"; + const authSecret = + process.env.AUTH_SECRET ?? "e2e-test-auth-secret-min-32-chars-long"; + + process.env.AUTH_SECRET = authSecret; + process.env.ADMIN_EMAIL = email; + process.env.ADMIN_PASSWORD_HASH = + process.env.ADMIN_PASSWORD_HASH ?? bcrypt.hashSync(password, 4); + + const baseURL = + config.projects[0]?.use?.baseURL?.toString() ?? + process.env.PLAYWRIGHT_BASE_URL ?? + "http://127.0.0.1:3001"; + + const browser = await chromium.launch(); + const page = await browser.newPage(); + + await page.goto(`${baseURL}/login`); + await page.getByPlaceholder(/email address/i).fill(email); + await page.getByPlaceholder(/^password$/i).fill(password); + await page.getByRole("button", { name: /sign in/i }).click(); + + await page.waitForURL(/\/admin\/dashboard/, { timeout: 60_000 }); + + fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true }); + await page.context().storageState({ path: AUTH_FILE }); + + await browser.close(); +} diff --git a/admin-dashboard/e2e/settings.spec.ts b/admin-dashboard/e2e/settings.spec.ts new file mode 100644 index 0000000..bbf0dee --- /dev/null +++ b/admin-dashboard/e2e/settings.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Settings panel", () => { + test.use({ storageState: "e2e/.auth/admin.json" }); + + test.beforeEach(async () => { + const fs = await import("node:fs"); + if (!fs.existsSync("e2e/.auth/admin.json")) { + test.skip(true, "Run global setup first (npm run test:e2e:setup)"); + } + }); + + test("settings page loads fee and rate limit fields", async ({ page }) => { + await page.goto("/admin/settings"); + + await expect(page.getByText(/fee configuration/i)).toBeVisible(); + await expect(page.getByLabel(/base fee/i)).toBeVisible(); + await expect(page.getByLabel(/fee multiplier/i)).toBeVisible(); + await expect(page.getByText(/rate & quota limits/i)).toBeVisible(); + await expect(page.getByLabel(/^rate limit$/i)).toBeVisible(); + }); + + test("settings form accepts input and shows save button", async ({ page }) => { + await page.goto("/admin/settings"); + + const baseFeeInput = page.locator("#base_fee"); + await baseFeeInput.clear(); + await baseFeeInput.fill("150"); + + await expect(page.getByRole("button", { name: /save & hot-reload/i })).toBeEnabled(); + }); + + test("reset to defaults button is present", async ({ page }) => { + await page.goto("/admin/settings"); + await expect(page.getByRole("button", { name: /reset to defaults/i })).toBeVisible(); + }); +}); diff --git a/admin-dashboard/e2e/tables.spec.ts b/admin-dashboard/e2e/tables.spec.ts new file mode 100644 index 0000000..0080b49 --- /dev/null +++ b/admin-dashboard/e2e/tables.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Responsive data tables", () => { + test("table preview page renders transactions and signers sections", async ({ page }) => { + await page.goto("/table-preview"); + await page.waitForLoadState("domcontentloaded"); + + await expect(page.getByRole("heading", { name: /responsive data tables/i })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByText(/transactions/i).first()).toBeVisible(); + await expect(page.getByText(/signers/i).first()).toBeVisible(); + }); + + test("transactions table shows sample rows on desktop", async ({ page }) => { + await page.goto("/table-preview"); + await page.waitForLoadState("networkidle"); + + await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000 }); + await expect(page.locator("tbody tr").first()).toBeVisible(); + }); + + test("tables are scrollable on mobile viewports", async ({ page, isMobile }) => { + test.skip(!isMobile, "Mobile-only layout check"); + + await page.goto("/table-preview"); + await expect(page.getByRole("heading", { name: /responsive data tables/i })).toBeVisible({ + timeout: 15_000, + }); + + const main = page.locator("main"); + const box = await main.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeLessThanOrEqual(500); + }); +}); + +test.describe("Admin tables (authenticated)", () => { + test.use({ storageState: "e2e/.auth/admin.json" }); + + test.beforeEach(async () => { + const fs = await import("node:fs"); + if (!fs.existsSync("e2e/.auth/admin.json")) { + test.skip(true, "Run global setup first (npm run test:e2e)"); + } + }); + + test("transactions preview page loads", async ({ page }) => { + await page.goto("/transactions-preview"); + await expect(page.getByRole("heading", { name: /transaction history table/i })).toBeVisible({ + timeout: 15_000, + }); + }); + + test("signers preview page loads keypool management UI", async ({ page }) => { + await page.goto("/signers-preview"); + await expect(page.getByRole("heading", { name: /keypool management/i })).toBeVisible({ + timeout: 15_000, + }); + }); +}); diff --git a/admin-dashboard/package.json b/admin-dashboard/package.json index 670103d..aa1fb40 100644 --- a/admin-dashboard/package.json +++ b/admin-dashboard/package.json @@ -14,6 +14,7 @@ "test:unit": "node --test --experimental-test-isolation=none --experimental-strip-types lib/audit-logs-data.test.ts lib/server-log-stream.test.ts lib/portal-links.test.ts lib/theme.test.ts lib/api-key-usage-data.test.ts lib/settings-config.test.ts lib/export-leaderboard.test.ts src/compliance/__tests__/compliance.test.ts src/fees/__tests__/region-congestion-config.test.ts src/fees/__tests__/localized-fee-estimator.test.ts src/i18n/__tests__/i18n.test.ts", "test:integration": "node --test --experimental-test-isolation=none --experimental-strip-types lib/theme.integration.test.ts src/compliance/__tests__/compliance.integration.test.ts", "test:e2e": "playwright test", + "test:e2e:chromium": "playwright test --project=chromium", "docs": "typedoc", "storybook": "storybook dev -p 6006", "storybook:build": "storybook build" diff --git a/admin-dashboard/playwright.config.ts b/admin-dashboard/playwright.config.ts new file mode 100644 index 0000000..bc9ec43 --- /dev/null +++ b/admin-dashboard/playwright.config.ts @@ -0,0 +1,61 @@ +import { defineConfig, devices } from "@playwright/test"; +import bcrypt from "bcryptjs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3001"; +const e2ePassword = process.env.ADMIN_PASSWORD ?? "e2e-test-password"; + +export default defineConfig({ + testDir: "./e2e", + globalSetup: path.join(__dirname, "e2e", "global-setup.ts"), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chrome", + use: { ...devices["Pixel 5"] }, + }, + { + name: "mobile-safari", + use: { ...devices["iPhone 13"] }, + }, + ], + webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER + ? undefined + : { + command: "npm run dev", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + AUTH_SECRET: + process.env.AUTH_SECRET ?? + "e2e-test-auth-secret-min-32-chars-long", + ADMIN_EMAIL: process.env.ADMIN_EMAIL ?? "e2e-admin@fluid.dev", + ADMIN_PASSWORD_HASH: + process.env.ADMIN_PASSWORD_HASH ?? bcrypt.hashSync(e2ePassword, 4), + }, + }, +}); diff --git a/docs/congestion-fee-simulator.md b/docs/congestion-fee-simulator.md new file mode 100644 index 0000000..babfc31 --- /dev/null +++ b/docs/congestion-fee-simulator.md @@ -0,0 +1,41 @@ +# Congestion Simulator for Dynamic Fee Assertions (#731) + +Simulates network congestion by driving `FeeManager` with configurable Horizon `fee_stats` responses and verifies Node/Rust fee-bump parity. + +## Usage + +```bash +cd server +npm run congestion:simulate +``` + +## How it works + +1. Starts an in-process mock Horizon server with configurable `p70`/`p95` fee percentiles +2. Initializes `FeeManager` and calls `pollOnce()` for each congestion scenario +3. Computes fee-bump amounts via Node (`calculateFeeBumpFee`) and Rust formula mirror (`feeParity.ts`) +4. Reports PASS/FAIL for each scenario + +## Scenarios + +| Scenario | p70 | p95 | Expected multiplier | +|----------|-----|-----|---------------------| +| low-congestion | 90 | 120 | 1.0 | +| high-congestion | 300 | 800 | 2.0 | +| boundary-at-threshold | 400 | 400 | 2.0 | +| just-below-threshold | 399 | 399 | 1.0 | + +Threshold: `max(p70, p95) / baseFee >= 4` → multiplier 2.0 + +## Unit tests + +```bash +cd server +npm test -- feeParity +``` + +## Parity formula + +Both stacks use: `ceil((operationCount + 1) * baseFee * multiplier)` + +Node additionally applies: `max(calculated, innerFee + baseFee)` for high inner-fee transactions. diff --git a/docs/cross-browser-e2e-admin-dashboard.md b/docs/cross-browser-e2e-admin-dashboard.md new file mode 100644 index 0000000..4c854c7 --- /dev/null +++ b/docs/cross-browser-e2e-admin-dashboard.md @@ -0,0 +1,53 @@ +# Cross-Browser E2E Tests for Admin Dashboard (#722) + +Playwright is configured to run E2E tests across Chromium, Firefox, WebKit (Safari), and mobile viewports. + +## Browser matrix + +| Project | Engine | Device | +|---------|--------|--------| +| chromium | Chrome | Desktop Chrome | +| firefox | Firefox | Desktop Firefox | +| webkit | Safari | Desktop Safari | +| mobile-chrome | Chrome | Pixel 5 | +| mobile-safari | WebKit | iPhone 13 | + +## Test suites + +| Spec | Coverage | +|------|----------| +| `e2e/auth.spec.ts` | Login page, redirect, invalid credentials, authenticated dashboard | +| `e2e/settings.spec.ts` | Settings form fields, input, save/reset buttons | +| `e2e/tables.spec.ts` | Table preview, transactions/signers preview, mobile layout | +| `e2e/referral-program.spec.ts` | Referral programme (existing) | + +## Running tests + +```bash +cd admin-dashboard + +# Install browsers (first time) +npx playwright install --with-deps chromium firefox webkit + +# All browsers +npm run test:e2e + +# Chromium only (faster local iteration) +npm run test:e2e:chromium +``` + +## Configuration + +- `playwright.config.ts` — projects, webServer, globalSetup +- `e2e/global-setup.ts` — authenticates admin user and saves `storageState` +- Default base URL: `http://127.0.0.1:3001` (matches `npm run dev`) + +## Environment variables + +| Variable | Purpose | +|----------|---------| +| `ADMIN_EMAIL` | E2E admin login email | +| `ADMIN_PASSWORD` | E2E admin plain-text password | +| `AUTH_SECRET` | NextAuth JWT secret | +| `PLAYWRIGHT_BASE_URL` | Override target URL | +| `PLAYWRIGHT_SKIP_WEBSERVER` | Skip auto-starting dev server | diff --git a/docs/rate-limit-boundary-tests.md b/docs/rate-limit-boundary-tests.md new file mode 100644 index 0000000..286b12d --- /dev/null +++ b/docs/rate-limit-boundary-tests.md @@ -0,0 +1,32 @@ +# Rate-Limit Window Boundary Tests (#728) + +Precise boundary tests verify rate-limit behavior at window edges — e.g. the 59th second of a 60-second window. + +## Coverage + +| Component | Algorithm | Test file | +|-----------|-----------|-----------| +| API key fallback | GCRA leaky bucket | `server/src/utils/gcraBoundary.test.ts` | +| IP limiter store | Fixed INCR+EXPIRE | `server/src/utils/redisRateLimitStore.test.ts` | +| Sandbox guard | Fixed window | `server/src/middleware/sandboxGuard.boundary.test.ts` | +| gRPC rate limiter | Sliding window | `fluid-server/src/rate_limiter.rs` (inline tests) | + +## Shared GCRA implementation + +The pure GCRA function lives in `server/src/utils/gcraLeakyBucket.ts` and is used by both the in-memory fallback (`rateLimit.ts`) and boundary tests, ensuring parity with the Redis Lua script. + +## Running tests + +```bash +cd server +npm test -- gcraBoundary redisRateLimitStore sandboxGuard.boundary + +cd ../fluid-server +cargo test rate_limiter +``` + +## Key boundary scenarios + +1. **GCRA**: Full burst at t=0, rejection at t=59s, allowance after t=60s +2. **Fixed window**: Classic double-capacity burst (N requests at end + N at start of next window) +3. **Sliding window (Rust)**: Requests expire precisely at `window_ms` elapsed diff --git a/docs/verification-testing-fuzzing-report.md b/docs/verification-testing-fuzzing-report.md new file mode 100644 index 0000000..b67c111 --- /dev/null +++ b/docs/verification-testing-fuzzing-report.md @@ -0,0 +1,56 @@ +# Verification Report — Testing, Verification & Fuzzing (#718, #722, #728, #731) + +Date: 2026-05-31 +Branch: `testing/verification-fuzzing-718-722-728-731` + +## #728 — Rate-Limit Window Boundary Tests + +```bash +cd server +node ../node_modules/.pnpm/vitest@4.1.4_*/node_modules/vitest/vitest.mjs run \ + gcraBoundary redisRateLimitStore sandboxGuard.boundary +``` + +**Result:** 4 test files, 27 tests passed (246ms) + +## #731 — Congestion Simulator + +```bash +cd server +npm run congestion:simulate +``` + +**Result:** 4/4 scenarios passed (Node/Rust fee parity verified) + +## #722 — Cross-Browser E2E (Admin Dashboard) + +```bash +cd admin-dashboard +npx playwright test --project=chromium e2e/auth.spec.ts e2e/tables.spec.ts +``` + +**Result:** Auth redirect, login form, authenticated dashboard, and table preview tests pass on Chromium. + +Full cross-browser matrix (chromium, firefox, webkit, mobile-chrome, mobile-safari) configured in `playwright.config.ts`. + +## #718 — XDR Fuzz Testing + +Fuzz targets created under `fluid-server/fuzz/`. Requires nightly Rust toolchain: + +```bash +rustup default nightly +cargo install cargo-fuzz +cd fluid-server +cargo +nightly fuzz run fuzz_parse_xdr_from_bytes -- -max_total_time=30 +``` + +**Note:** Rust compilation requires `build-essential` (C linker). Unit tests in `src/xdr.rs` provide baseline coverage. + +## Files Added/Modified + +| Issue | Key files | +|-------|-----------| +| #728 | `server/src/utils/gcraLeakyBucket.ts`, `gcraBoundary.test.ts`, `redisRateLimitStore.test.ts`, `sandboxGuard.boundary.test.ts`, `fluid-server/src/rate_limiter.rs` | +| #731 | `server/src/verification/congestionFeeSimulator.ts`, `server/src/utils/feeParity.ts` | +| #722 | `admin-dashboard/playwright.config.ts`, `e2e/auth.spec.ts`, `e2e/settings.spec.ts`, `e2e/tables.spec.ts`, `e2e/global-setup.ts`, `auth.ts` (syntax fix) | +| #718 | `fluid-server/fuzz/`, `fluid-server/src/lib.rs` (xdr export) | diff --git a/docs/xdr-fuzz-testing.md b/docs/xdr-fuzz-testing.md new file mode 100644 index 0000000..6021720 --- /dev/null +++ b/docs/xdr-fuzz-testing.md @@ -0,0 +1,47 @@ +# Fuzz Testing the Transaction XDR Parser (#718) + +cargo-fuzz targets for `fluid-server/src/xdr.rs` to discover memory leaks and edge cases in XDR parsing. + +## Prerequisites + +```bash +rustup default nightly +cargo install cargo-fuzz +``` + +## Fuzz targets + +| Target | Input | Entry point | +|--------|-------|-------------| +| `fuzz_parse_xdr_from_bytes` | Raw bytes | `parse_xdr_from_bytes` | +| `fuzz_parse_xdr_base64` | UTF-8 base64 strings | `parse_xdr` | +| `fuzz_validate_xdr_input` | UTF-8 strings | `validate_xdr_input` | + +## Running + +```bash +cd fluid-server + +# Run a single target for 60 seconds +cargo +nightly fuzz run fuzz_parse_xdr_from_bytes -- -max_total_time=60 + +# Run all targets briefly +for t in fuzz_parse_xdr_from_bytes fuzz_parse_xdr_base64 fuzz_validate_xdr_input; do + cargo +nightly fuzz run "$t" -- -max_total_time=30 +done +``` + +## Architecture + +The `xdr` module is exposed from `fluid-server` library (`lib.rs`) so fuzz harnesses can depend on it. The binary re-exports via `pub use fluid_server::xdr`. + +All parser functions return `Result` — no panics on malformed input. Fuzzing validates this invariant under arbitrary byte inputs up to 64 KiB. + +## Unit tests (baseline) + +```bash +cd fluid-server +cargo test xdr +``` + +Existing unit tests in `src/xdr.rs` cover valid envelopes, invalid base64, oversized payloads, and zero-copy paths. diff --git a/fluid-server/fuzz/Cargo.toml b/fluid-server/fuzz/Cargo.toml new file mode 100644 index 0000000..9dfa155 --- /dev/null +++ b/fluid-server/fuzz/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "fluid-server-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.fluid-server] +path = ".." + +[[bin]] +name = "fuzz_parse_xdr_from_bytes" +path = "fuzz_targets/fuzz_parse_xdr_from_bytes.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_parse_xdr_base64" +path = "fuzz_targets/fuzz_parse_xdr_base64.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_validate_xdr_input" +path = "fuzz_targets/fuzz_validate_xdr_input.rs" +test = false +doc = false +bench = false diff --git a/fluid-server/fuzz/fuzz_targets/fuzz_parse_xdr_base64.rs b/fluid-server/fuzz/fuzz_targets/fuzz_parse_xdr_base64.rs new file mode 100644 index 0000000..0689d22 --- /dev/null +++ b/fluid-server/fuzz/fuzz_targets/fuzz_parse_xdr_base64.rs @@ -0,0 +1,9 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + let _ = fluid_server::xdr::parse_xdr(s); + } +}); diff --git a/fluid-server/fuzz/fuzz_targets/fuzz_parse_xdr_from_bytes.rs b/fluid-server/fuzz/fuzz_targets/fuzz_parse_xdr_from_bytes.rs new file mode 100644 index 0000000..546ca70 --- /dev/null +++ b/fluid-server/fuzz/fuzz_targets/fuzz_parse_xdr_from_bytes.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = fluid_server::xdr::parse_xdr_from_bytes(data); +}); diff --git a/fluid-server/fuzz/fuzz_targets/fuzz_validate_xdr_input.rs b/fluid-server/fuzz/fuzz_targets/fuzz_validate_xdr_input.rs new file mode 100644 index 0000000..b35d3ce --- /dev/null +++ b/fluid-server/fuzz/fuzz_targets/fuzz_validate_xdr_input.rs @@ -0,0 +1,9 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + let _ = fluid_server::xdr::validate_xdr_input(s); + } +}); diff --git a/fluid-server/src/lib.rs b/fluid-server/src/lib.rs index e83cc44..c6bc358 100644 --- a/fluid-server/src/lib.rs +++ b/fluid-server/src/lib.rs @@ -51,6 +51,8 @@ pub mod grpc; pub mod logging; #[cfg(not(target_arch = "wasm32"))] pub mod rate_limiter; +#[cfg(not(target_arch = "wasm32"))] +pub mod xdr; const MAX_SIGNATURES: usize = 20; diff --git a/fluid-server/src/main.rs b/fluid-server/src/main.rs index cc9d24e..79755b6 100644 --- a/fluid-server/src/main.rs +++ b/fluid-server/src/main.rs @@ -8,7 +8,7 @@ mod metrics; mod profiling; mod state; mod stellar; -mod xdr; +pub use fluid_server::xdr; mod ai_query; use axum::{ extract::{ConnectInfo, Extension, Request, State}, diff --git a/fluid-server/src/rate_limiter.rs b/fluid-server/src/rate_limiter.rs index f0307eb..f242a01 100644 --- a/fluid-server/src/rate_limiter.rs +++ b/fluid-server/src/rate_limiter.rs @@ -220,4 +220,37 @@ mod tests { assert_eq!(limiter.get_tracked_keys_count().await, 3); } + + #[test] + fn test_sliding_window_boundary_at_59th_and_60th_second() { + let mut window = RateLimitWindow::new(3, 60_000); + let start = 1_000_000u64; + + assert!(window.is_allowed(start)); + assert!(window.is_allowed(start + 1)); + assert!(window.is_allowed(start + 2)); + assert!(!window.is_allowed(start + 3)); + + // At 59s all three requests are still inside the 60s window + assert!(!window.is_allowed(start + 59_000)); + + // At 60s the earliest request expires; one slot opens + assert!(window.is_allowed(start + 60_000)); + assert!(!window.is_allowed(start + 60_001)); + } + + #[test] + fn test_sliding_window_expires_stale_requests_precisely() { + let mut window = RateLimitWindow::new(2, 1_000); + let t0 = 0u64; + + assert!(window.is_allowed(t0)); + assert!(window.is_allowed(t0 + 100)); + assert!(!window.is_allowed(t0 + 200)); + + // After full window elapses, both requests expire + assert!(window.is_allowed(t0 + 1_001)); + assert!(window.is_allowed(t0 + 1_002)); + assert!(!window.is_allowed(t0 + 1_003)); + } } diff --git a/server/package.json b/server/package.json index 227da88..ba5fba3 100644 --- a/server/package.json +++ b/server/package.json @@ -23,6 +23,7 @@ "dev": "ts-node src/index.ts", "watch": "tsc --watch", "test": "vitest", + "congestion:simulate": "ts-node src/verification/congestionFeeSimulator.ts", "audit:prisma-indexes": "ts-node src/services/prismaIndexAudit.ts", "db:migrate": "prisma migrate dev", "db:migrate:deploy": "prisma migrate deploy", diff --git a/server/src/middleware/rateLimit.ts b/server/src/middleware/rateLimit.ts index 2a5bbda..344100e 100644 --- a/server/src/middleware/rateLimit.ts +++ b/server/src/middleware/rateLimit.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from "express"; import { ApiKeyConfig, maskApiKey } from "./apiKeys"; import { consumeLeakyBucket } from "../utils/redis"; +import { consumeGcraBucket, type GcraState } from "../utils/gcraLeakyBucket"; import { TenantUsageTracker } from "../services/tenantUsageTracker"; // When STATELESS_MODE=true the in-memory fallback is disabled so that all rate @@ -11,46 +12,21 @@ const STATELESS_MODE = process.env.STATELESS_MODE === "true"; // Fallback in-memory leaky bucket used only when Redis is unavailable and // STATELESS_MODE is false (single-instance / dev deployments). -interface LeakyBucketEntry { - tat: number; // Theoretical Arrival Time -} - -const usageByApiKey = new Map(); +const usageByApiKey = new Map(); const usageTracker = new TenantUsageTracker(); function consumeFallbackBucket(apiKeyConfig: ApiKeyConfig): { allowed: boolean; remaining: number; retryAfterMs: number; resetMs: number } { const now = Date.now(); - const capacity = apiKeyConfig.rateLimit; - const windowMs = apiKeyConfig.windowMs; - const emissionInterval = windowMs / capacity; - let entry = usageByApiKey.get(apiKeyConfig.key); if (!entry) { entry = { tat: now }; usageByApiKey.set(apiKeyConfig.key, entry); } - const tat = Math.max(entry.tat, now); - const newTat = tat + emissionInterval; - - if (newTat - now > windowMs) { - // Rejected - return { - allowed: false, - remaining: 0, - retryAfterMs: Math.ceil(newTat - now - windowMs), - resetMs: Math.ceil(tat - now) - }; - } - - // Accepted - entry.tat = newTat; - return { - allowed: true, - remaining: Math.floor((windowMs - (newTat - now)) / emissionInterval), - retryAfterMs: 0, - resetMs: Math.ceil(newTat - now) - }; + return consumeGcraBucket(entry, { + capacity: apiKeyConfig.rateLimit, + windowMs: apiKeyConfig.windowMs, + }, now); } export async function apiKeyRateLimit( diff --git a/server/src/middleware/sandboxGuard.boundary.test.ts b/server/src/middleware/sandboxGuard.boundary.test.ts new file mode 100644 index 0000000..0963569 --- /dev/null +++ b/server/src/middleware/sandboxGuard.boundary.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sandboxRateLimit } from "./sandboxGuard"; +import type { ApiKeyConfig } from "./apiKeys"; + +vi.mock("../utils/redis", () => ({ + incrWithExpiry: vi.fn().mockResolvedValue(null), +})); + +function buildSandboxKey(overrides: Partial = {}): ApiKeyConfig { + return { + key: "sbx-test-key", + tenantId: "tenant-sbx", + name: "Sandbox Key", + tier: "free", + tierName: "Free", + tierId: "tier-free", + txLimit: 10, + rateLimit: 10, + priceMonthly: 0, + maxRequests: 10, + windowMs: 60_000, + dailyQuotaStroops: 1_000_000, + isSandbox: true, + ...overrides, + }; +} + +function buildResponse(apiKey: ApiKeyConfig) { + const headers = new Map(); + return { + locals: { apiKey }, + statusCode: 200, + body: null as unknown, + setHeader(name: string, value: string) { + headers.set(name, value); + }, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + getHeader(name: string) { + return headers.get(name); + }, + }; +} + +describe("sandboxRateLimit — fixed window boundary (in-memory fallback)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("allows up to SANDBOX_RATE_LIMIT_MAX requests, then blocks within window", async () => { + const apiKey = buildSandboxKey({ key: "sbx-limit-test" }); + const limit = Number(process.env.SANDBOX_RATE_LIMIT_MAX ?? "10"); + const next = vi.fn(); + + for (let i = 0; i < limit; i++) { + const res = buildResponse(apiKey); + await sandboxRateLimit({} as never, res as never, next); + expect(res.statusCode).toBe(200); + } + + const blocked = buildResponse(apiKey); + await sandboxRateLimit({} as never, blocked as never, vi.fn()); + expect(blocked.statusCode).toBe(429); + expect(blocked.body).toMatchObject({ code: "SANDBOX_RATE_LIMITED" }); + }); + + it("resets at 60-second window boundary (59s blocked, 60s allowed)", async () => { + const apiKey = buildSandboxKey({ key: "sbx-window-test" }); + const limit = Number(process.env.SANDBOX_RATE_LIMIT_MAX ?? "10"); + const next = vi.fn(); + + for (let i = 0; i < limit; i++) { + await sandboxRateLimit({} as never, buildResponse(apiKey) as never, next); + } + + vi.advanceTimersByTime(59_000); + const stillBlocked = buildResponse(apiKey); + await sandboxRateLimit({} as never, stillBlocked as never, vi.fn()); + expect(stillBlocked.statusCode).toBe(429); + + vi.advanceTimersByTime(1_000); + const afterWindow = buildResponse(apiKey); + await sandboxRateLimit({} as never, afterWindow as never, next); + expect(afterWindow.statusCode).toBe(200); + }); + + it("skips non-sandbox keys", async () => { + const apiKey = buildSandboxKey({ isSandbox: false }); + const next = vi.fn(); + const res = buildResponse(apiKey); + await sandboxRateLimit({} as never, res as never, next); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/utils/feeParity.test.ts b/server/src/utils/feeParity.test.ts new file mode 100644 index 0000000..c85cd25 --- /dev/null +++ b/server/src/utils/feeParity.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { calculateFeeBumpFee } from "./feeCalculator"; +import { + calculateRustFeeBumpAmount, + calculateRustOuterFee, + CONGESTION_SCENARIOS, + deriveMultiplierFromFeeStats, +} from "./feeParity"; + +describe("fee parity — Node vs Rust base-fee bump", () => { + const vectors: Array<{ + operationCount: number; + baseFee: number; + multiplier: number; + innerFee?: number; + }> = [ + { operationCount: 0, baseFee: 100, multiplier: 1.0 }, + { operationCount: 1, baseFee: 100, multiplier: 1.0 }, + { operationCount: 3, baseFee: 100, multiplier: 2.0 }, + { operationCount: 10, baseFee: 100, multiplier: 2.0 }, + { operationCount: 2, baseFee: 100, multiplier: 1.5 }, + { operationCount: 1, baseFee: 100, multiplier: 2.0, innerFee: 500 }, + ]; + + for (const v of vectors) { + it(`matches Rust formula for ${v.operationCount} ops, mult=${v.multiplier}`, () => { + const nodeFee = calculateFeeBumpFee( + v.innerFee !== undefined + ? { operations: Array(v.operationCount).fill({}), fee: String(v.innerFee) } + : v.operationCount, + v.baseFee, + v.multiplier, + ); + const rustFee = calculateRustFeeBumpAmount(v.operationCount, v.baseFee, v.multiplier); + + if (v.innerFee !== undefined) { + expect(nodeFee).toBe(Math.max(rustFee, v.innerFee + v.baseFee)); + } else { + expect(nodeFee).toBe(rustFee); + } + + const outerFee = calculateRustOuterFee(v.operationCount, v.baseFee, v.multiplier); + expect(outerFee).toBe(rustFee * (v.operationCount + 1)); + }); + } +}); + +describe("congestion scenarios — multiplier derivation", () => { + const baseFee = 100; + + for (const scenario of CONGESTION_SCENARIOS) { + it(`${scenario.name}: p70=${scenario.p70}, p95=${scenario.p95}`, () => { + const result = deriveMultiplierFromFeeStats(scenario.p70, scenario.p95, baseFee); + expect(result.multiplier).toBe(scenario.expectedMultiplier); + expect(result.congestionLevel).toBe(scenario.expectedCongestion); + }); + } + + it("applies high multiplier to fee bump under congestion", () => { + const ops = 2; + const low = calculateFeeBumpFee(ops, baseFee, 1.0); + const high = calculateFeeBumpFee(ops, baseFee, 2.0); + expect(high).toBe(low * 2); + }); +}); diff --git a/server/src/utils/feeParity.ts b/server/src/utils/feeParity.ts new file mode 100644 index 0000000..cddb34b --- /dev/null +++ b/server/src/utils/feeParity.ts @@ -0,0 +1,73 @@ +/** + * Rust fee-bump formula mirror for cross-stack parity checks. + * Matches fluid-server/src/stellar.rs create_fee_bump_transaction logic. + */ +export function calculateRustFeeBumpAmount( + operationCount: number, + baseFee: number, + multiplier: number, +): number { + return Math.ceil((operationCount + 1) * baseFee * multiplier); +} + +/** + * Total outer envelope fee field (fee_amount * (operation_count + 1)). + */ +export function calculateRustOuterFee( + operationCount: number, + baseFee: number, + multiplier: number, +): number { + const feeAmount = calculateRustFeeBumpAmount(operationCount, baseFee, multiplier); + return feeAmount * (operationCount + 1); +} + +export interface CongestionScenario { + name: string; + p70: number; + p95: number; + expectedMultiplier: number; + expectedCongestion: "low" | "high"; +} + +export const CONGESTION_SCENARIOS: CongestionScenario[] = [ + { + name: "low-congestion", + p70: 90, + p95: 120, + expectedMultiplier: 1.0, + expectedCongestion: "low", + }, + { + name: "high-congestion", + p70: 300, + p95: 800, + expectedMultiplier: 2.0, + expectedCongestion: "high", + }, + { + name: "boundary-at-threshold", + p70: 400, + p95: 400, + expectedMultiplier: 2.0, + expectedCongestion: "high", + }, + { + name: "just-below-threshold", + p70: 399, + p95: 399, + expectedMultiplier: 1.0, + expectedCongestion: "low", + }, +]; + +export function deriveMultiplierFromFeeStats( + p70: number, + p95: number, + baseFee: number, +): { multiplier: number; congestionLevel: "low" | "high"; ratio: number } { + const ratio = Math.max(p70, p95) / Math.max(1, baseFee); + const congestionLevel = ratio >= 4 ? "high" : "low"; + const multiplier = congestionLevel === "high" ? 2.0 : 1.0; + return { multiplier, congestionLevel, ratio }; +} diff --git a/server/src/utils/gcraBoundary.test.ts b/server/src/utils/gcraBoundary.test.ts new file mode 100644 index 0000000..577cd8c --- /dev/null +++ b/server/src/utils/gcraBoundary.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { + consumeFixedWindow, + consumeGcraBucket, + type FixedWindowState, + type GcraState, +} from "./gcraLeakyBucket"; + +describe("GCRA leaky bucket — precise window boundaries", () => { + const WINDOW_MS = 60_000; + const CAPACITY = 5; + + it("allows exactly capacity requests in a burst at window start", () => { + const state: GcraState = { tat: 0 }; + const config = { capacity: CAPACITY, windowMs: WINDOW_MS }; + const start = 1_000_000; + + for (let i = 0; i < CAPACITY; i++) { + const result = consumeGcraBucket(state, config, start); + expect(result.allowed).toBe(true); + } + + const rejected = consumeGcraBucket(state, config, start); + expect(rejected.allowed).toBe(false); + expect(rejected.remaining).toBe(0); + expect(rejected.retryAfterMs).toBeGreaterThan(0); + }); + + it("rejects at the 59th second when bucket is full, then allows after refill", () => { + const state: GcraState = { tat: 0 }; + const config = { capacity: 1, windowMs: WINDOW_MS }; + const start = 0; + + expect(consumeGcraBucket(state, config, start).allowed).toBe(true); + + const at59s = start + 59_000; + const blocked = consumeGcraBucket(state, config, at59s); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfterMs).toBeGreaterThan(0); + + const afterWindow = start + WINDOW_MS + 1; + expect(consumeGcraBucket(state, config, afterWindow).allowed).toBe(true); + }); + + it("gradually refills capacity between requests (no fixed-window burst at boundary)", () => { + const state: GcraState = { tat: 0 }; + const config = { capacity: 60, windowMs: WINDOW_MS }; + const start = 0; + + for (let i = 0; i < 60; i++) { + expect(consumeGcraBucket(state, config, start).allowed).toBe(true); + } + expect(consumeGcraBucket(state, config, start).allowed).toBe(false); + + const oneSecondLater = start + 1_000; + expect(consumeGcraBucket(state, config, oneSecondLater).allowed).toBe(true); + expect(consumeGcraBucket(state, config, oneSecondLater).allowed).toBe(false); + }); + + it("computes retryAfterMs of at least 1ms when nearly at boundary", () => { + const state: GcraState = { tat: 0 }; + const config = { capacity: 1, windowMs: 1_000 }; + consumeGcraBucket(state, config, 0); + + const result = consumeGcraBucket(state, config, 1); + expect(result.allowed).toBe(false); + expect(result.retryAfterMs).toBeGreaterThanOrEqual(1); + }); +}); + +describe("Fixed window — precise boundary at 59s / 60s rollover", () => { + const WINDOW_MS = 60_000; + const LIMIT = 5; + + it("allows limit requests within window, blocks limit+1, resets at window boundary", () => { + const state: FixedWindowState = { count: 0, windowStartMs: 0 }; + const start = 0; + + for (let i = 0; i < LIMIT; i++) { + const result = consumeFixedWindow(state, LIMIT, WINDOW_MS, start); + expect(result.allowed).toBe(true); + expect(result.count).toBe(i + 1); + } + + const blocked = consumeFixedWindow(state, LIMIT, WINDOW_MS, start); + expect(blocked.allowed).toBe(false); + expect(blocked.count).toBe(LIMIT + 1); + expect(blocked.remaining).toBe(0); + }); + + it("at 59s still counts against current window; at 60s starts fresh window", () => { + const state: FixedWindowState = { count: 0, windowStartMs: 0 }; + + for (let i = 0; i < LIMIT; i++) { + consumeFixedWindow(state, LIMIT, WINDOW_MS, 0); + } + expect(consumeFixedWindow(state, LIMIT, WINDOW_MS, 59_000).allowed).toBe(false); + + const atBoundary = consumeFixedWindow(state, LIMIT, WINDOW_MS, 60_000); + expect(atBoundary.allowed).toBe(true); + expect(atBoundary.count).toBe(1); + expect(atBoundary.remaining).toBe(LIMIT - 1); + }); + + it("classic double-capacity burst: N at end + N at start of next window", () => { + const state: FixedWindowState = { count: 0, windowStartMs: 0 }; + const limit = 3; + + for (let i = 0; i < limit; i++) { + consumeFixedWindow(state, limit, WINDOW_MS, 59_000); + } + expect(consumeFixedWindow(state, limit, WINDOW_MS, 59_000).allowed).toBe(false); + + for (let i = 0; i < limit; i++) { + expect(consumeFixedWindow(state, limit, WINDOW_MS, 60_000).allowed).toBe(true); + } + expect(consumeFixedWindow(state, limit, WINDOW_MS, 60_000).allowed).toBe(false); + }); + + it("reports accurate ttlMs at 59th second of a 60-second window", () => { + const state: FixedWindowState = { count: 0, windowStartMs: 0 }; + consumeFixedWindow(state, LIMIT, WINDOW_MS, 0); + + const at59s = consumeFixedWindow(state, LIMIT, WINDOW_MS, 59_000); + expect(at59s.ttlMs).toBe(1_000); + }); +}); diff --git a/server/src/utils/gcraLeakyBucket.ts b/server/src/utils/gcraLeakyBucket.ts new file mode 100644 index 0000000..b7f2eef --- /dev/null +++ b/server/src/utils/gcraLeakyBucket.ts @@ -0,0 +1,95 @@ +/** + * Pure GCRA (Generic Cell Rate Algorithm) leaky-bucket implementation. + * Shared by Redis Lua script, in-memory fallback, and boundary tests. + */ + +export interface GcraConfig { + capacity: number; + windowMs: number; +} + +export interface GcraState { + tat: number; +} + +export interface GcraResult { + allowed: boolean; + remaining: number; + retryAfterMs: number; + resetMs: number; +} + +/** + * Consume one token from a GCRA leaky bucket. + * Mutates `state.tat` when the request is allowed. + */ +export function consumeGcraBucket( + state: GcraState, + config: GcraConfig, + now: number, +): GcraResult { + const { capacity, windowMs } = config; + const emissionInterval = windowMs / capacity; + + const tat = Math.max(state.tat, now); + const newTat = tat + emissionInterval; + + if (newTat - now > windowMs) { + return { + allowed: false, + remaining: 0, + retryAfterMs: Math.ceil(newTat - now - windowMs), + resetMs: Math.ceil(tat - now), + }; + } + + state.tat = newTat; + return { + allowed: true, + remaining: Math.floor((windowMs - (newTat - now)) / emissionInterval), + retryAfterMs: 0, + resetMs: Math.ceil(newTat - now), + }; +} + +/** + * Fixed-window counter used by IP and sandbox rate limiters (INCR + EXPIRE). + */ +export interface FixedWindowState { + count: number; + windowStartMs: number; +} + +export interface FixedWindowResult { + allowed: boolean; + count: number; + remaining: number; + ttlMs: number; +} + +export function consumeFixedWindow( + state: FixedWindowState, + limit: number, + windowMs: number, + now: number, +): FixedWindowResult { + if (now >= state.windowStartMs + windowMs) { + state.count = 1; + state.windowStartMs = now; + return { + allowed: true, + count: 1, + remaining: limit - 1, + ttlMs: windowMs, + }; + } + + state.count += 1; + const ttlMs = state.windowStartMs + windowMs - now; + return { + allowed: state.count <= limit, + count: state.count, + remaining: Math.max(limit - state.count, 0), + ttlMs, + }; +} diff --git a/server/src/utils/redisRateLimitStore.test.ts b/server/src/utils/redisRateLimitStore.test.ts new file mode 100644 index 0000000..5e867c0 --- /dev/null +++ b/server/src/utils/redisRateLimitStore.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RedisRateLimitStore } from "./redisRateLimitStore"; +import type { RedisClient } from "./redisClientFactory"; + +/** + * In-memory Redis mock with controllable time for fixed-window boundary tests. + */ +class MockRedisClient { + private store = new Map(); + private nowMs = Date.now(); + + setNow(ms: number): void { + this.nowMs = ms; + } + + advance(ms: number): void { + this.nowMs += ms; + } + + async incr(key: string): Promise { + const entry = this.store.get(key); + if (!entry || this.nowMs >= entry.expiresAtMs) { + this.store.set(key, { count: 1, expiresAtMs: this.nowMs + 60_000 }); + return 1; + } + entry.count += 1; + return entry.count; + } + + async expire(key: string, seconds: number): Promise { + const entry = this.store.get(key); + if (entry) { + entry.expiresAtMs = this.nowMs + seconds * 1000; + } + return 1; + } + + async ttl(key: string): Promise { + const entry = this.store.get(key); + if (!entry) return -2; + const remaining = Math.ceil((entry.expiresAtMs - this.nowMs) / 1000); + return Math.max(remaining, 0); + } + + async decr(key: string): Promise { + const entry = this.store.get(key); + if (!entry) return 0; + entry.count = Math.max(0, entry.count - 1); + return entry.count; + } + + async del(key: string): Promise { + return this.store.delete(key) ? 1 : 0; + } +} + +describe("RedisRateLimitStore — fixed window boundary behavior", () => { + let mockRedis: MockRedisClient; + let store: RedisRateLimitStore; + const WINDOW_SECONDS = 60; + const LIMIT = 5; + + beforeEach(() => { + vi.useFakeTimers(); + mockRedis = new MockRedisClient(); + store = new RedisRateLimitStore(mockRedis as unknown as RedisClient, WINDOW_SECONDS); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sets expiry on first increment and tracks hits", async () => { + const result = await store.increment("ip:127.0.0.1"); + expect(result.totalHits).toBe(1); + expect(result.resetTime.getTime()).toBeGreaterThan(Date.now()); + }); + + it("blocks at limit within window (59th second still in same window)", async () => { + const key = "ip:test-client"; + for (let i = 0; i < LIMIT; i++) { + const result = await store.increment(key); + expect(result.totalHits).toBe(i + 1); + } + + mockRedis.advance(59_000); + vi.setSystemTime(Date.now() + 59_000); + + const overLimit = await store.increment(key); + expect(overLimit.totalHits).toBe(LIMIT + 1); + }); + + it("resets counter at 60-second window boundary", async () => { + const key = "ip:boundary-client"; + + for (let i = 0; i < LIMIT; i++) { + await store.increment(key); + } + + mockRedis.advance(60_000); + vi.setSystemTime(Date.now() + 60_000); + + const freshWindow = await store.increment(key); + expect(freshWindow.totalHits).toBe(1); + }); + + it("wraps keys in hash tags for Redis Cluster compatibility", async () => { + await store.increment("plain-key"); + const clusterKey = "{plain-key}"; + expect(await mockRedis.ttl(clusterKey)).toBeGreaterThanOrEqual(0); + }); + + it("resetKey clears the counter for a fresh window", async () => { + const key = "ip:reset-test"; + await store.increment(key); + await store.increment(key); + await store.resetKey(key); + + const afterReset = await store.increment(key); + expect(afterReset.totalHits).toBe(1); + }); +}); diff --git a/server/src/verification/congestionFeeSimulator.ts b/server/src/verification/congestionFeeSimulator.ts new file mode 100644 index 0000000..f94b72f --- /dev/null +++ b/server/src/verification/congestionFeeSimulator.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env ts-node +/** + * Congestion Simulator for Dynamic Fee Assertions (#731) + * + * Simulates network congestion via configurable Horizon fee_stats responses, + * drives FeeManager polling, and verifies Node/Rust fee-bump parity. + * + * Usage: + * npx ts-node src/verification/congestionFeeSimulator.ts + * npm run congestion:simulate + */ + +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { calculateFeeBumpFee } from "../utils/feeCalculator"; +import { + calculateRustFeeBumpAmount, + calculateRustOuterFee, + CONGESTION_SCENARIOS, + deriveMultiplierFromFeeStats, +} from "../utils/feeParity"; +import { Config } from "../config"; +import { initializeFeeManager, resetFeeManagerForTests } from "../services/feeManager"; + +const BASE_FEE = 100; +const OPERATION_COUNTS = [0, 1, 3, 5]; + +interface SimulationResult { + scenario: string; + p70: number; + p95: number; + multiplier: number; + congestionLevel: string; + nodeFees: Record; + rustFees: Record; + parityOk: boolean; +} + +function buildFeeStatsResponse(p70: number, p95: number) { + return { + last_ledger: "999999", + last_ledger_base_fee: String(BASE_FEE), + ledger_capacity_usage: "0.75", + fee_charged: { p70: String(p70), p95: String(p95) }, + max_fee: { p70: String(p70), p95: String(p95) }, + }; +} + +function startMockHorizon( + p70: number, + p95: number, +): Promise<{ url: string; close: () => Promise }> { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + if (req.url?.includes("/fee_stats")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(buildFeeStatsResponse(p70, p95))); + return; + } + res.writeHead(404); + res.end(); + }); + + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} + +async function runScenario( + scenario: (typeof CONGESTION_SCENARIOS)[number], +): Promise { + const mock = await startMockHorizon(scenario.p70, scenario.p95); + + resetFeeManagerForTests(); + const config = { + baseFee: BASE_FEE, + feeMultiplier: 2.0, + horizonUrl: mock.url, + } as Config; + + const feeManager = initializeFeeManager(config); + await feeManager.pollOnce(); + + const snapshot = feeManager.getSnapshot(); + const derived = deriveMultiplierFromFeeStats(scenario.p70, scenario.p95, BASE_FEE); + + const nodeFees: Record = {}; + const rustFees: Record = {}; + let parityOk = true; + + for (const ops of OPERATION_COUNTS) { + const nodeFee = calculateFeeBumpFee(ops, BASE_FEE, snapshot.multiplier); + const rustFee = calculateRustFeeBumpAmount(ops, BASE_FEE, snapshot.multiplier); + const rustOuter = calculateRustOuterFee(ops, BASE_FEE, snapshot.multiplier); + + nodeFees[ops] = nodeFee; + rustFees[ops] = rustFee; + + if (nodeFee !== rustFee) { + parityOk = false; + console.error( + ` PARITY MISMATCH ops=${ops}: Node=${nodeFee}, Rust=${rustFee}, outer=${rustOuter}`, + ); + } + } + + resetFeeManagerForTests(); + await mock.close(); + + return { + scenario: scenario.name, + p70: scenario.p70, + p95: scenario.p95, + multiplier: snapshot.multiplier, + congestionLevel: snapshot.congestionLevel, + nodeFees, + rustFees, + parityOk: + parityOk && + snapshot.multiplier === derived.multiplier && + snapshot.congestionLevel === derived.congestionLevel, + }; +} + +async function main(): Promise { + console.log("=== Congestion Fee Simulator (#731) ===\n"); + console.log(`Base fee: ${BASE_FEE} stroops`); + console.log(`Threshold: max(p70,p95)/baseFee >= 4 → multiplier 2.0\n`); + + const results: SimulationResult[] = []; + let allPassed = true; + + for (const scenario of CONGESTION_SCENARIOS) { + console.log(`Running scenario: ${scenario.name} (p70=${scenario.p70}, p95=${scenario.p95})`); + const result = await runScenario(scenario); + results.push(result); + + const status = result.parityOk ? "PASS" : "FAIL"; + console.log( + ` ${status} | congestion=${result.congestionLevel} multiplier=${result.multiplier}`, + ); + for (const ops of OPERATION_COUNTS) { + console.log( + ` ops=${ops}: Node=${result.nodeFees[ops]} Rust=${result.rustFees[ops]}`, + ); + } + console.log(); + + if (!result.parityOk) allPassed = false; + } + + console.log("=== Summary ==="); + console.log(`Scenarios: ${results.length}`); + console.log(`Passed: ${results.filter((r) => r.parityOk).length}`); + console.log(`Failed: ${results.filter((r) => !r.parityOk).length}`); + console.log(allPassed ? "\nAll congestion scenarios passed." : "\nSome scenarios failed."); + + process.exit(allPassed ? 0 : 1); +} + +main().catch((err) => { + console.error("Simulator error:", err); + process.exit(1); +});