diff --git a/.github/workflows/auto-qa-live.yml b/.github/workflows/auto-qa-live.yml
new file mode 100644
index 0000000..636b934
--- /dev/null
+++ b/.github/workflows/auto-qa-live.yml
@@ -0,0 +1,31 @@
+name: auto-qa live
+
+on:
+ workflow_dispatch:
+ inputs:
+ api_base:
+ description: 'Override api.futarchy.fi base URL for live checks'
+ required: false
+ default: 'https://api.futarchy.fi'
+ schedule:
+ - cron: '17 4 * * *'
+
+jobs:
+ live:
+ name: Live endpoint auto-qa
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Run live auto-qa tests
+ env:
+ AUTO_QA_API_BASE: ${{ inputs.api_base || 'https://api.futarchy.fi' }}
+ run: npm run auto-qa:test:live
diff --git a/.github/workflows/auto-qa.yml b/.github/workflows/auto-qa.yml
index d824698..1e13dd5 100644
--- a/.github/workflows/auto-qa.yml
+++ b/.github/workflows/auto-qa.yml
@@ -18,8 +18,8 @@ on:
workflow_dispatch:
jobs:
- proposal-lifecycle:
- name: Proposal lifecycle auto-qa
+ unit:
+ name: Deterministic auto-qa
runs-on: ubuntu-latest
timeout-minutes: 5
@@ -32,5 +32,5 @@ jobs:
with:
node-version: '22'
- - name: Run auto-qa tests
- run: npm run auto-qa:test
+ - name: Run deterministic auto-qa tests
+ run: npm run auto-qa:test:unit
diff --git a/auto-qa/README.md b/auto-qa/README.md
new file mode 100644
index 0000000..bc7ba00
--- /dev/null
+++ b/auto-qa/README.md
@@ -0,0 +1,37 @@
+# Auto-QA
+
+This directory is split into CI tiers by flakiness and external dependencies.
+
+## Deterministic Unit Tier
+
+Run:
+
+```sh
+npm run auto-qa:test:unit
+```
+
+Files live under `auto-qa/tests/`. These tests must not call live services,
+start browsers, start an Anvil fork, or submit transactions. They are allowed
+to inspect local source files and run local Node helper scripts. This tier is
+safe for pull-request CI and is also what `npm run auto-qa:test` runs.
+
+## Live Network Tier
+
+Run:
+
+```sh
+npm run auto-qa:test:live
+```
+
+Files live under `auto-qa/live/`. These tests may call public Futarchy
+endpoints and should skip when the network is unavailable. They are useful for
+catching endpoint drift, but they are intentionally excluded from pull-request
+CI. GitHub runs them only by manual dispatch or on the nightly schedule in
+`.github/workflows/auto-qa-live.yml`.
+
+## Forked Browser Harness
+
+The larger Playwright and Anvil replay harness from the experimental
+`auto-qa` branch is not part of this tier. Forked-chain reads, writes, browser
+scenarios, and catalog drift checks need their own explicit workflow once they
+are cleaned up and separated from live-write tests.
diff --git a/auto-qa/fixtures/known-graphql-failures.json b/auto-qa/fixtures/known-graphql-failures.json
new file mode 100644
index 0000000..5312ba0
--- /dev/null
+++ b/auto-qa/fixtures/known-graphql-failures.json
@@ -0,0 +1,86 @@
+{
+ "knownFailureCount": 16,
+ "notes": "Baseline of GraphQL compat failures captured by auto-qa/tools/probe-graphql.mjs. These are production queries that fail validation against the live Checkpoint schema. Per the /loop directive: failures are documented but NOT fixed in this branch. When a production fix lands on main, regenerate with: npm run auto-qa:probe-graphql -- --baseline > auto-qa/fixtures/known-graphql-failures.json",
+ "failures": [
+ {
+ "file": "src/components/debug/EditCompanyModal.jsx",
+ "line": 23,
+ "errorSignature": "Cannot query field \"organizationEntity\" on type \"Query\"."
+ },
+ {
+ "file": "src/components/debug/EditProposalModal.jsx",
+ "line": 24,
+ "errorSignature": "Cannot query field \"proposalEntity\" on type \"Query\". Did you mean \"proposalentity\" or \"proposalentities\"?"
+ },
+ {
+ "file": "src/components/debug/OrganizationManagerModal.jsx",
+ "line": 64,
+ "errorSignature": "Cannot query field \"organizations\" on type \"Aggregator\"."
+ },
+ {
+ "file": "src/components/debug/OrganizationManagerModal.jsx",
+ "line": 65,
+ "errorSignature": "Cannot query field \"proposals\" on type \"Organization\"."
+ },
+ {
+ "file": "src/components/futarchyFi/marketPage/MarketPageShowcase.jsx",
+ "line": 2028,
+ "errorSignature": "Syntax Error: Unexpected \"}\"."
+ },
+ {
+ "file": "src/components/futarchyFi/proposalsList/page/proposalsPage/ProposalsPage.jsx",
+ "line": 111,
+ "errorSignature": "Cannot query field \"pools\" on type \"Proposal\"."
+ },
+ {
+ "file": "src/hooks/useOrganization.js",
+ "line": 20,
+ "errorSignature": "Cannot query field \"proposals\" on type \"Organization\"."
+ },
+ {
+ "file": "src/hooks/useSearchProposals.js",
+ "line": 20,
+ "errorSignature": "Cannot query field \"pools\" on type \"Proposal\"."
+ },
+ {
+ "file": "src/hooks/useSearchProposals.js",
+ "line": 40,
+ "errorSignature": "Cannot query field \"pools\" on type \"Proposal\"."
+ },
+ {
+ "file": "src/services/subgraphClient.js",
+ "line": 26,
+ "errorSignature": "Field \"proposal\" must not have a selection since type \"String\" has no subfields."
+ },
+ {
+ "file": "src/services/subgraphClient.js",
+ "line": 46,
+ "errorSignature": "Field \"proposal\" must not have a selection since type \"String\" has no subfields."
+ },
+ {
+ "file": "src/services/subgraphClient.js",
+ "line": 67,
+ "errorSignature": "Unknown type \"BigInt\". Did you mean \"Int\"?"
+ },
+ {
+ "file": "src/services/subgraphClient.js",
+ "line": 89,
+ "errorSignature": "Cannot query field \"pools\" on type \"Proposal\"."
+ },
+ {
+ "file": "src/utils/SubgraphBulkPriceFetcher.js",
+ "line": 36,
+ "errorSignature": "Syntax Error: Expected Name, found \"{\"."
+ },
+ {
+ "file": "src/utils/SubgraphPoolFetcher.js",
+ "line": 132,
+ "errorSignature": "Syntax Error: Expected Name, found \"{\"."
+ },
+ {
+ "file": "src/utils/subgraphTradesClient.js",
+ "line": 133,
+ "errorSignature": "String cannot represent a non string value: 0"
+ }
+ ]
+}
diff --git a/auto-qa/live/endpoint-liveness.test.mjs b/auto-qa/live/endpoint-liveness.test.mjs
new file mode 100644
index 0000000..03d3bdf
--- /dev/null
+++ b/auto-qa/live/endpoint-liveness.test.mjs
@@ -0,0 +1,144 @@
+/**
+ * Endpoint-liveness invariant (auto-qa).
+ *
+ * For every subgraph endpoint URL hardcoded in
+ * `src/config/subgraphEndpoints.js`, send a trivial GraphQL introspection
+ * query and assert:
+ * - HTTP 2xx
+ * - response is valid JSON with a `data.__schema` envelope (i.e. it's
+ * actually a working GraphQL endpoint, not a 200-OK landing page)
+ *
+ * Why: PRs #47, #49, #50, #60 all stemmed from the AWS → GCP migration
+ * leaving the frontend pointing at dead URLs. The dead endpoint returned
+ * a `database unavailable` body (so the page rendered empty silently).
+ * If any future endpoint drift, dead deploy, or URL typo lands on main,
+ * this test fails immediately with a clear message.
+ *
+ * The test reads the URLs out of the live source file (no copy-paste),
+ * so adding a new endpoint to subgraphEndpoints.js automatically extends
+ * the coverage.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ENDPOINTS_FILE = resolve(__dirname, '../../src/config/subgraphEndpoints.js');
+// PR #42: futarchy-api base URL referenced from usePoolData.js — extracted
+// here so a regression in the env-var default URL is caught.
+const API_BASE_URL_FILE = resolve(__dirname, '../../src/hooks/usePoolData.js');
+
+/**
+ * Pull every `https://…/graphql` URL out of the endpoints config.
+ * Deliberately permissive: any string literal that ends in `/graphql`
+ * counts. Keeps the test resilient to refactors that rename the
+ * exported constants.
+ */
+function loadEndpointUrls() {
+ const text = readFileSync(ENDPOINTS_FILE, 'utf8');
+ const matches = text.matchAll(/['"`](https?:\/\/[^'"`]+\/graphql)['"`]/g);
+ return [...new Set([...matches].map(m => m[1]))];
+}
+
+/**
+ * Pull the futarchy-api base URL out of usePoolData.js. The string
+ * literal we look for is the `||` fallback after the env-var read:
+ * process.env.NEXT_PUBLIC_POOL_API_URL || 'https://api.futarchy.fi'
+ * If the file is refactored to use a different default URL we want to
+ * notice. Returns null if not found (test then skips that case).
+ */
+function loadApiBaseUrl() {
+ let text;
+ try { text = readFileSync(API_BASE_URL_FILE, 'utf8'); }
+ catch { return null; }
+ const m = text.match(/NEXT_PUBLIC_POOL_API_URL\s*\|\|\s*['"`](https?:\/\/[^'"`]+)['"`]/);
+ return m ? m[1] : null;
+}
+
+const INTROSPECTION = `{ __schema { queryType { name } } }`;
+
+async function probeEndpoint(url) {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: INTROSPECTION }),
+ signal: AbortSignal.timeout(10000),
+ });
+ let body = null;
+ try { body = await res.json(); } catch { /* non-JSON */ }
+ return { status: res.status, body };
+}
+
+const urls = loadEndpointUrls();
+
+test('subgraphEndpoints.js contains at least one URL', () => {
+ assert.ok(urls.length > 0,
+ `expected to find at least one /graphql URL in ${ENDPOINTS_FILE}`);
+});
+
+for (const url of urls) {
+ test(`endpoint is live: ${url}`, async (t) => {
+ // Quick probe — if the URL is unreachable at all, skip rather than fail
+ // so we don't fail when the user is offline. But once we GET a response,
+ // demand it satisfies the schema-introspection invariant.
+ let result;
+ try {
+ result = await probeEndpoint(url);
+ } catch (err) {
+ t.skip(`network unreachable for ${url}: ${err.message}`);
+ return;
+ }
+ assert.ok(result.status >= 200 && result.status < 300,
+ `${url} returned HTTP ${result.status} (expected 2xx)`);
+ assert.ok(result.body && typeof result.body === 'object',
+ `${url} did not return JSON`);
+ assert.ok(
+ result.body.data?.__schema?.queryType?.name,
+ `${url} did not return a valid GraphQL introspection envelope. ` +
+ `Got: ${JSON.stringify(result.body).slice(0, 200)}…`
+ );
+ });
+}
+
+// ────────────────────────────────────────────────────────────────────────
+// PR #42 — futarchy-api base URL (Express, not GraphQL)
+// ────────────────────────────────────────────────────────────────────────
+const apiBaseUrl = loadApiBaseUrl();
+
+test('PR #42 — usePoolData.js declares a futarchy-api base URL', () => {
+ assert.ok(
+ apiBaseUrl && /^https?:\/\//.test(apiBaseUrl),
+ `Expected to find a NEXT_PUBLIC_POOL_API_URL fallback URL in ` +
+ `${API_BASE_URL_FILE}. Was the constant renamed or removed?`
+ );
+});
+
+test(`PR #42 — futarchy-api base URL is live: ${apiBaseUrl || '(not found)'}`,
+async (t) => {
+ if (!apiBaseUrl) { t.skip('no api base URL discovered'); return; }
+ let res;
+ try {
+ res = await fetch(`${apiBaseUrl}/health`, {
+ signal: AbortSignal.timeout(10000),
+ });
+ } catch (err) {
+ t.skip(`network unreachable for ${apiBaseUrl}: ${err.message}`);
+ return;
+ }
+ assert.ok(res.status >= 200 && res.status < 300,
+ `${apiBaseUrl}/health returned HTTP ${res.status} (expected 2xx)`);
+
+ let body = null;
+ try { body = await res.json(); } catch { /* not JSON */ }
+ // /health on futarchy-api returns { status: 'ok', timestamp }; assert
+ // either that or simply that the body is non-empty so this test still
+ // passes if the health endpoint changes its shape harmlessly.
+ if (body && typeof body === 'object') {
+ assert.ok(body.status === 'ok' || body.timestamp || Object.keys(body).length > 0,
+ `${apiBaseUrl}/health body looks empty: ${JSON.stringify(body)}`);
+ }
+});
+
diff --git a/auto-qa/live/graphql-compat.test.mjs b/auto-qa/live/graphql-compat.test.mjs
new file mode 100644
index 0000000..1f5142a
--- /dev/null
+++ b/auto-qa/live/graphql-compat.test.mjs
@@ -0,0 +1,94 @@
+/**
+ * GraphQL schema-compat test (auto-qa).
+ *
+ * Runs the schema-compat probe against the live api.futarchy.fi and asserts
+ * the set of failing queries matches the baseline at
+ * auto-qa/fixtures/known-graphql-failures.json.
+ *
+ * Why a baseline rather than a hard "zero failures" assertion:
+ * The /loop directive forbids modifying production code on this branch,
+ * so we can't fix the broken queries we found. We instead pin the
+ * current state — any NEW failure (regression) trips the test, and any
+ * FIXED failure (production PR cleaned it up) also trips the test
+ * (forces baseline update). Both are useful signals.
+ *
+ * Skip behavior: if the live API is unreachable, skip with a clear message.
+ * The probe makes ~32 HTTP calls; this test is heavier than the others.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const PROBE = resolve(__dirname, '../tools/probe-graphql.mjs');
+const BASELINE = resolve(__dirname, '../fixtures/known-graphql-failures.json');
+const REPO_ROOT = resolve(__dirname, '../..');
+const API_BASE = process.env.AUTO_QA_API_BASE || 'https://api.futarchy.fi';
+
+async function isApiReachable() {
+ try {
+ const resp = await fetch(`${API_BASE}/health`, {
+ method: 'GET',
+ signal: AbortSignal.timeout(5000),
+ });
+ return resp.ok;
+ } catch {
+ return false;
+ }
+}
+
+function runProbe() {
+ const out = execFileSync('node', [PROBE], {
+ encoding: 'utf8',
+ cwd: REPO_ROOT,
+ maxBuffer: 8 * 1024 * 1024,
+ });
+ return JSON.parse(out);
+}
+
+function loadBaseline() {
+ return JSON.parse(readFileSync(BASELINE, 'utf8'));
+}
+
+test('GraphQL probe — failure count matches baseline', async (t) => {
+ if (!(await isApiReachable())) {
+ t.skip(`API at ${API_BASE} not reachable; skipping schema-compat probe`);
+ return;
+ }
+ const probe = runProbe();
+ const baseline = loadBaseline();
+ const actual = probe.results.filter(r => !r.ok);
+
+ assert.equal(
+ actual.length,
+ baseline.knownFailureCount,
+ `Expected exactly ${baseline.knownFailureCount} failing queries (the known baseline); ` +
+ `got ${actual.length}. ` +
+ (actual.length > baseline.knownFailureCount
+ ? 'NEW FAILURES — likely a regression. Inspect with `npm run auto-qa:probe-graphql -- --summary`.'
+ : 'FEWER failures than baseline — a production fix likely landed. Regenerate the baseline file.')
+ );
+});
+
+test('GraphQL probe — every failure is in the baseline (no surprise new ones)', async (t) => {
+ if (!(await isApiReachable())) {
+ t.skip(`API at ${API_BASE} not reachable; skipping`);
+ return;
+ }
+ const probe = runProbe();
+ const baseline = loadBaseline();
+ const actual = probe.results.filter(r => !r.ok);
+ const baselineKey = new Set(baseline.failures.map(f => `${f.file}:${f.line}`));
+
+ const surprises = actual.filter(a => !baselineKey.has(`${a.file}:${a.line}`));
+ assert.equal(
+ surprises.length, 0,
+ `New failing queries not in baseline:\n${
+ surprises.map(s => ` ${s.file}:${s.line} → ${s.graphqlErrors.join('; ')}`).join('\n')
+ }`
+ );
+});
diff --git a/auto-qa/tests/algebra-pool-price.test.mjs b/auto-qa/tests/algebra-pool-price.test.mjs
new file mode 100644
index 0000000..0f2f715
--- /dev/null
+++ b/auto-qa/tests/algebra-pool-price.test.mjs
@@ -0,0 +1,344 @@
+/**
+ * getAlgebraPoolPrice spec mirror (auto-qa).
+ *
+ * Pins src/utils/getAlgebraPoolPrice.js — the RPC-rotating Algebra
+ * pool price reader used to fetch on-chain prices for non-Quoter paths
+ * (e.g. background spot watchers, dashboards). Has its own RPC-fallback
+ * + cooldown machinery distinct from getBestRpc.js.
+ *
+ * Five concerns:
+ *
+ * 1. POOL_ABI globalState DIVERGENCE — pinned that this file's
+ * 6-field tuple (uint160 price, int24 tick, uint16 lastFee,
+ * uint8 pluginConfig, uint16 communityFee, bool unlocked) is
+ * DIFFERENT from algebraQuoter.js's 7-field tuple (the older
+ * Algebra V3 shape with timepointIndex + communityFeeToken0/1).
+ * This file targets the newer Algebra Integral shape. A regression
+ * that flips them silently mis-decodes globalState in production.
+ *
+ * 2. GNOSIS_RPCS — 5 endpoints, HTTPS-only, deduplicated. Cross-pin
+ * vs the canonical lists in getRpcUrl.js / providers.jsx (already
+ * covered in rpc-config.test.mjs but here as a third occurrence
+ * worth pinning so the duplication is visible).
+ *
+ * 3. Constants — CACHE_DURATION = 30s, BASE_RETRY_DELAY = 30s,
+ * RANDOM_RETRY_RANGE = 10s, MOCK_MODE = false.
+ *
+ * 4. isRateLimitError — pure classifier across 7 indicators
+ * (429 code, "429" string, "rate limit", "cors", "too many
+ * requests", "network error", "fetch"). A regression that drops
+ * one means the corresponding error class slips past cooldown.
+ *
+ * 5. Price formula — `(Number(sqrtPriceX96) ** 2) / 2 ** 192`.
+ * Same Algebra V3 standard as sqrt-price-x96.test.mjs and
+ * algebra-quoter.test.mjs but written using the `**` operator.
+ * Pinned for cross-file consistency.
+ *
+ * 6. Module-level mutable state pattern — pinned via shape (count
+ * of Maps + the `let currentRpcIndex` declaration). A refactor
+ * to functional / class-based would surface here so cooldown
+ * semantics get re-evaluated.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const SRC = readFileSync(
+ new URL('../../src/utils/getAlgebraPoolPrice.js', import.meta.url),
+ 'utf8',
+);
+const ALGEBRA_QUOTER_SRC = readFileSync(
+ new URL('../../src/utils/algebraQuoter.js', import.meta.url),
+ 'utf8',
+);
+
+// --- spec mirror of isRateLimitError (pure classifier) ---
+function isRateLimitError(error) {
+ const msg = error.message?.toLowerCase() || '';
+ const code = error.code;
+ return (
+ code === 429 ||
+ msg.includes('429') ||
+ msg.includes('rate limit') ||
+ msg.includes('cors') ||
+ msg.includes('too many requests') ||
+ msg.includes('network error') ||
+ msg.includes('fetch')
+ );
+}
+
+// --- spec mirror of price formula ---
+function priceFromSqrtX96(sqrtPriceX96) {
+ return (Number(sqrtPriceX96) ** 2) / 2 ** 192;
+}
+
+// ---------------------------------------------------------------------------
+// POOL_ABI — divergence from algebraQuoter.js (Integral vs V3 shape)
+// ---------------------------------------------------------------------------
+
+test('POOL_ABI — globalState declares 6 fields (Integral shape: lastFee + pluginConfig)', () => {
+ // Pinned: this file's globalState struct expects the newer Algebra
+ // Integral shape:
+ // uint160 price, int24 tick, uint16 lastFee, uint8 pluginConfig,
+ // uint16 communityFee, bool unlocked
+ // (6 fields). A regression that flips this back to the V3 shape
+ // would mis-decode the 4th/5th fields silently.
+ // Source uses { type: ..., name: ... } order — pin name only.
+ assert.match(SRC, /name:\s*["']price["']/,
+ `POOL_ABI globalState first field name must be "price"`);
+ assert.match(SRC, /type:\s*["']uint160["'],\s*name:\s*["']price["']/,
+ `POOL_ABI globalState price field must be uint160`);
+ assert.match(SRC, /name:\s*["']lastFee["']/,
+ `POOL_ABI globalState must include lastFee (Integral shape)`);
+ assert.match(SRC, /name:\s*["']pluginConfig["']/,
+ `POOL_ABI globalState must include pluginConfig (Integral-only field)`);
+ assert.match(SRC, /name:\s*["']communityFee["']/,
+ `POOL_ABI globalState must include communityFee (single field, NOT split into Token0/Token1)`);
+});
+
+test('POOL_ABI — does NOT include timepointIndex (the older V3 field)', () => {
+ // Pinned the divergence. timepointIndex = old V3; pluginConfig = Integral.
+ assert.doesNotMatch(SRC, /timepointIndex/,
+ `POOL_ABI globalState must NOT include timepointIndex — that's the older V3 shape, ` +
+ `which is in algebraQuoter.js. These two files target DIFFERENT Algebra versions.`);
+});
+
+test('POOL_ABI — does NOT split communityFee into Token0/Token1 (V3-style)', () => {
+ // Pinned that this file uses the unified communityFee (Integral).
+ assert.doesNotMatch(SRC, /communityFeeToken[01]/,
+ `POOL_ABI must use single 'communityFee' (Integral), NOT 'communityFeeToken0/1' (V3)`);
+});
+
+test('cross-file divergence — algebraQuoter.js POOL_ABI uses the OLDER V3 7-field shape', () => {
+ // Sanity-pin: algebraQuoter still uses the V3 shape (with
+ // timepointIndex + Token0/1 community fees). If both files ever
+ // converge, that's a deliberate refactor — re-check both call sites.
+ assert.match(ALGEBRA_QUOTER_SRC, /timepointIndex/,
+ `algebraQuoter.js POOL_ABI must still include timepointIndex (V3 shape)`);
+ assert.match(ALGEBRA_QUOTER_SRC, /communityFeeToken[01]/,
+ `algebraQuoter.js POOL_ABI must still split communityFee into Token0/Token1 (V3 shape)`);
+});
+
+// ---------------------------------------------------------------------------
+// GNOSIS_RPCS — 5 endpoints, HTTPS-only, deduplicated
+// ---------------------------------------------------------------------------
+
+test('GNOSIS_RPCS — has exactly 5 entries (drift surfaces as more/fewer fallback options)', () => {
+ const m = SRC.match(/GNOSIS_RPCS\s*=\s*\[([\s\S]*?)\]/);
+ assert.ok(m);
+ const urls = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1]);
+ assert.equal(urls.length, 5,
+ `GNOSIS_RPCS drifted from 5 entries; got ${urls.length}. ` +
+ `Compare against rpc-config.test.mjs canonical list.`);
+});
+
+test('GNOSIS_RPCS — all entries are HTTPS', () => {
+ const m = SRC.match(/GNOSIS_RPCS\s*=\s*\[([\s\S]*?)\]/);
+ const urls = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1]);
+ for (const url of urls) {
+ assert.match(url, /^https:\/\//,
+ `GNOSIS_RPCS entry ${url} is not HTTPS — would leak request headers`);
+ }
+});
+
+test('GNOSIS_RPCS — entries are deduplicated', () => {
+ const m = SRC.match(/GNOSIS_RPCS\s*=\s*\[([\s\S]*?)\]/);
+ const urls = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1]);
+ assert.equal(new Set(urls).size, urls.length,
+ `GNOSIS_RPCS contains duplicates`);
+});
+
+// ---------------------------------------------------------------------------
+// Constants — CACHE_DURATION, BASE_RETRY_DELAY, RANDOM_RETRY_RANGE, MOCK_MODE
+// ---------------------------------------------------------------------------
+
+test('CACHE_DURATION — pinned at 30s (different from getBestRpc which is 5min)', () => {
+ // Pinned: 30s makes sense for pool prices (rapid change). Drift
+ // would either over-fetch (sub-30s) or show stale pool prices
+ // (longer than 30s).
+ assert.match(SRC, /CACHE_DURATION\s*=\s*30\s*\*\s*1000/,
+ `CACHE_DURATION drifted from 30 * 1000 (30s)`);
+});
+
+test('BASE_RETRY_DELAY — pinned at 30s', () => {
+ // Pinned: this is the cooldown window after a rate-limit error.
+ // Too short = thrashing; too long = inflated UX latency on flaky RPCs.
+ assert.match(SRC, /BASE_RETRY_DELAY\s*=\s*30\s*\*\s*1000/,
+ `BASE_RETRY_DELAY drifted from 30 * 1000 (30s base cooldown)`);
+});
+
+test('RANDOM_RETRY_RANGE — pinned at 10s (jitter window)', () => {
+ // Pinned: a 1-10s additional random delay smooths thundering-herd
+ // behavior on rate-limit recovery.
+ assert.match(SRC, /RANDOM_RETRY_RANGE\s*=\s*10\s*\*\s*1000/,
+ `RANDOM_RETRY_RANGE drifted from 10 * 1000 (10s jitter)`);
+});
+
+test('MOCK_MODE — pinned to FALSE in production source', () => {
+ // CRITICAL pin: MOCK_MODE=true returns deterministic mock data
+ // (0.98765 + random*0.02). Shipping with MOCK_MODE=true would
+ // serve fake prices to every consumer — silent catastrophe.
+ assert.match(SRC, /const MOCK_MODE\s*=\s*false/,
+ `MOCK_MODE must be false in source. Setting to true ships fake prices to every consumer.`);
+});
+
+// ---------------------------------------------------------------------------
+// isRateLimitError — 7-indicator classifier (each indicator pinned)
+// ---------------------------------------------------------------------------
+
+test('isRateLimitError — error.code === 429 → true', () => {
+ assert.equal(isRateLimitError({ code: 429 }), true);
+ assert.equal(isRateLimitError({ code: 429, message: 'whatever' }), true);
+});
+
+test('isRateLimitError — message contains "429" → true', () => {
+ assert.equal(isRateLimitError({ message: 'HTTP 429: Too Many Requests' }), true);
+});
+
+test('isRateLimitError — message contains "rate limit" → true (case-insensitive via toLowerCase)', () => {
+ assert.equal(isRateLimitError({ message: 'Rate Limit exceeded' }), true);
+ assert.equal(isRateLimitError({ message: 'rate limit ok' }), true);
+});
+
+test('isRateLimitError — message contains "cors" → true (CORS counts as rate-limit-like)', () => {
+ // Pinned: CORS errors are common when an RPC is misconfigured. The
+ // file lumps them in with rate-limits so the same cooldown
+ // applies.
+ assert.equal(isRateLimitError({ message: 'CORS blocked' }), true);
+ assert.equal(isRateLimitError({ message: 'cors policy' }), true);
+});
+
+test('isRateLimitError — message contains "too many requests" → true', () => {
+ assert.equal(isRateLimitError({ message: 'Too Many Requests' }), true);
+});
+
+test('isRateLimitError — message contains "network error" → true', () => {
+ assert.equal(isRateLimitError({ message: 'Network Error' }), true);
+});
+
+test('isRateLimitError — message contains "fetch" → true', () => {
+ // Pinned: covers fetch-related failures (e.g. "TypeError: fetch failed").
+ assert.equal(isRateLimitError({ message: 'fetch failed' }), true);
+});
+
+test('isRateLimitError — non-matching error → false', () => {
+ assert.equal(isRateLimitError({ message: 'Internal server error', code: 500 }), false);
+ assert.equal(isRateLimitError({ message: 'Invalid params', code: -32602 }), false);
+});
+
+test('isRateLimitError — empty/missing message + non-429 code → false', () => {
+ assert.equal(isRateLimitError({}), false);
+ assert.equal(isRateLimitError({ message: null }), false);
+});
+
+// ---------------------------------------------------------------------------
+// Price formula — sqrt² / 2^192 (Algebra V3 standard)
+// ---------------------------------------------------------------------------
+
+test('priceFromSqrtX96 — sqrt = 2^96 → price = 1', () => {
+ // Cross-pin against canonical sqrt-price-x96 / algebra-quoter math.
+ // Note: this file uses Number(sqrt) ** 2 (NOT BigInt mul), losing
+ // precision at extreme prices. Pinned-as-is per /loop directive.
+ const r = priceFromSqrtX96((2n ** 96n).toString());
+ assert.equal(r, 1);
+});
+
+test('priceFromSqrtX96 — non-negative for any non-negative sqrt (square invariant)', () => {
+ for (const exp of [0, 48, 96, 144, 160]) {
+ const r = priceFromSqrtX96((2n ** BigInt(exp)).toString());
+ assert.ok(r >= 0, `sqrt=2^${exp} produced negative price ${r}`);
+ }
+});
+
+test('priceFromSqrtX96 — monotonic in sqrtPrice', () => {
+ const a = priceFromSqrtX96((1n << 96n).toString());
+ const b = priceFromSqrtX96((2n << 96n).toString());
+ const c = priceFromSqrtX96((10n << 96n).toString());
+ assert.ok(a < b && b < c);
+});
+
+test('source — price formula uses ** operator (Number coercion path)', () => {
+ // Pinned: this file uses `(Number(sqrtPriceX96) ** 2) / 2 ** 192`,
+ // distinct stylistically from algebra-quoter.js's BigNumber.mul().
+ // Both should produce the same value; pin the syntactic form so a
+ // refactor to BigInt would be deliberate (changes precision).
+ assert.match(SRC,
+ /\(Number\(sqrtPriceX96\)\s*\*\*\s*2\)\s*\/\s*2\s*\*\*\s*192/,
+ `price formula drifted from (Number(sqrt) ** 2) / 2 ** 192`);
+});
+
+// ---------------------------------------------------------------------------
+// Cooldown / dedup machinery — source-text shape pins
+// ---------------------------------------------------------------------------
+
+test('source — module-level state: 5 distinct Maps for caches + a single mutable index', () => {
+ // Pinned: providers, poolContracts, loadingStates, retryStates,
+ // rateLimitCooldowns. A refactor that consolidates these silently
+ // changes cache scoping.
+ const mapDecls = [...SRC.matchAll(/const\s+(\w+)\s*=\s*new Map\(\)/g)].map(m => m[1]);
+ assert.equal(mapDecls.length, 5,
+ `expected 5 module-level Map() declarations; got ${mapDecls.length}: ${mapDecls.join(', ')}`);
+ for (const name of ['providers', 'poolContracts', 'loadingStates', 'retryStates', 'rateLimitCooldowns']) {
+ assert.ok(mapDecls.includes(name),
+ `expected Map "${name}" not declared`);
+ }
+});
+
+test('source — currentRpcIndex is `let` (mutable round-robin counter)', () => {
+ // Pinned: `let` not `const` — getNextRpc / rotateRpc reassign it.
+ // A refactor to const would either stop rotating (always RPC 0)
+ // or throw on assignment.
+ assert.match(SRC,
+ /let\s+currentRpcIndex\s*=\s*0/,
+ `currentRpcIndex must be \`let\` initialized to 0 (mutable round-robin counter)`);
+});
+
+test('source — getNextRpc rotates round-robin via modulo (currentRpcIndex + 1) % len', () => {
+ // Pinned the rotation primitive. A regression to `currentRpcIndex++`
+ // (no modulo) would walk off the end into undefined.
+ const matches = [...SRC.matchAll(/currentRpcIndex\s*=\s*\(currentRpcIndex\s*\+\s*1\)\s*%\s*GNOSIS_RPCS\.length/g)];
+ assert.ok(matches.length >= 2,
+ `expected >=2 round-robin rotation expressions; got ${matches.length}`);
+});
+
+test('source — callWithRpcFallback default maxRetries = 3', () => {
+ // Pinned: a regression that lowers to 1 means a single transient
+ // RPC error fails the entire call — UX cascade.
+ assert.match(SRC,
+ /callWithRpcFallback\(address,\s*maxRetries\s*=\s*3\)/,
+ `callWithRpcFallback default maxRetries drifted from 3`);
+});
+
+test('source — getAlgebraPoolPrice IMMEDIATE deduplication checks loadingStates BEFORE Date.now()', () => {
+ // Pinned: the comment says "IMMEDIATE deduplication - check all
+ // states first before doing ANYTHING". A regression that moves
+ // Date.now() ahead of the dedup check would re-enter the function
+ // for in-flight requests.
+ const fn = SRC.match(/export\s+async\s+function\s+getAlgebraPoolPrice\(poolConfig\)\s*\{([\s\S]*?)^\}/m);
+ assert.ok(fn);
+ const dedupIdx = fn[1].indexOf('loadingStates.has(address)');
+ const nowIdx = fn[1].indexOf('Date.now()');
+ assert.ok(dedupIdx > -1 && nowIdx > -1);
+ assert.ok(dedupIdx < nowIdx,
+ `loadingStates dedup check must come BEFORE Date.now() — IMMEDIATE dedup invariant`);
+});
+
+test('source — successful RPC call clears address-level cooldown', () => {
+ // Pinned: rateLimitCooldowns.delete(address) on success ensures a
+ // recovered RPC restores normal behavior immediately. A regression
+ // that drops this leaves stale cooldowns blocking valid responses.
+ assert.match(SRC,
+ /\/\/.*Clear any rate limit cooldown on success[\s\S]*rateLimitCooldowns\.delete\(address\)/,
+ `must clear rateLimitCooldowns on success — stale cooldowns block recovered RPCs`);
+});
+
+test('source — finally block ALWAYS removes loading state (no leak path)', () => {
+ // Pinned: loadingStates.delete(address) lives in `finally`, not
+ // after the try. A regression that moves it to the success path
+ // leaks the in-flight promise on errors → all subsequent calls
+ // wait on a rejected promise.
+ assert.match(SRC,
+ /\}\s*finally\s*\{[\s\S]*?loadingStates\.delete\(address\)/,
+ `loadingStates.delete must be in finally — error-path leak otherwise`);
+});
diff --git a/auto-qa/tests/algebra-quoter.test.mjs b/auto-qa/tests/algebra-quoter.test.mjs
new file mode 100644
index 0000000..872e186
--- /dev/null
+++ b/auto-qa/tests/algebra-quoter.test.mjs
@@ -0,0 +1,362 @@
+/**
+ * algebraQuoter spec mirror (auto-qa).
+ *
+ * Pins src/utils/algebraQuoter.js — the direct on-chain Algebra Quoter
+ * wrapper that replaced @swapr/sdk to drop quote latency from "420+ RPC
+ * calls per quote" to "1-4 RPC calls per quote". Powers swap-modal
+ * quotes for non-Futarchy pools.
+ *
+ * Six concerns pinned:
+ *
+ * 1. ALGEBRA_QUOTER contract address — canonical Gnosis address.
+ * Drift = quotes route to wrong/non-existent contract.
+ * 2. QUOTER_ABI / POOL_ABI / ERC20_ABI shapes — drift = ethers
+ * decodes garbage from callStatic returns.
+ * 3. getAlgebraQuote — callStatic with sqrtPriceLimitX96=0, error
+ * remapping (LOK / IIA / AS to human-readable strings).
+ * 4. sqrtPriceX96ToPrice (exported helper) — Algebra V3 standard
+ * formula. Cross-pinned against the canonical sqrt-price-x96.test.mjs.
+ * 5. Slippage math (default 50 bps = 0.5%) — same algorithm as the
+ * canonical helper. Cross-pin so divergence is visible.
+ * 6. Direction-detection (isToken0ToToken1 / shouldInvertPrice /
+ * executionPrice direction) — silent regression here = wrong
+ * price displayed in UI for one half of every pool.
+ *
+ * Async getAlgebraQuoteWithSlippage NOT mirrored (network-bound) —
+ * only its source-text branches and pure helpers.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const SRC = readFileSync(
+ new URL('../../src/utils/algebraQuoter.js', import.meta.url),
+ 'utf8',
+);
+
+// --- spec mirror of sqrtPriceX96ToPrice (BigInt math) ---
+function sqrtPriceX96ToPrice(sqrtPriceX96String) {
+ try {
+ const sqrt = BigInt(sqrtPriceX96String);
+ const Q96 = 2n ** 96n;
+ const sqrtSquared = sqrt * sqrt;
+ const Q192 = Q96 * Q96;
+ return Number(sqrtSquared) / Number(Q192);
+ } catch {
+ return null;
+ }
+}
+
+// --- spec mirror of slippage math (BigInt-safe) ---
+function applySlippageBps(amountOutWei, slippageBps) {
+ const factor = BigInt(10000 - slippageBps);
+ return (BigInt(amountOutWei) * factor) / 10000n;
+}
+
+// --- spec mirror of token-direction detection ---
+function detectDirection(tokenIn, tokenOut, token0, token1) {
+ const tInL = tokenIn.toLowerCase();
+ const tOutL = tokenOut.toLowerCase();
+ const t0L = token0.toLowerCase();
+ const t1L = token1.toLowerCase();
+ return {
+ isToken0ToToken1: tInL === t0L && tOutL === t1L,
+ isToken1ToToken0: tInL === t1L && tOutL === t0L,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// ALGEBRA_QUOTER — canonical Gnosis address
+// ---------------------------------------------------------------------------
+
+test('ALGEBRA_QUOTER — pinned to canonical 0xcBaD9FDf...0F7 Gnosis Algebra Quoter', () => {
+ const m = SRC.match(/ALGEBRA_QUOTER\s*=\s*['"]([^'"]+)['"]/);
+ assert.ok(m, 'ALGEBRA_QUOTER not found');
+ assert.equal(m[1], '0xcBaD9FDf0D2814659Eb26f600EFDeAF005Eda0F7',
+ `ALGEBRA_QUOTER drifted from canonical Gnosis Quoter — every non-Futarchy ` +
+ `quote calls this contract; drift means wrong contract / wrong quotes / silent revert.`);
+});
+
+test('ALGEBRA_QUOTER — valid 0x + 40 hex chars', () => {
+ const m = SRC.match(/ALGEBRA_QUOTER\s*=\s*['"]([^'"]+)['"]/);
+ assert.match(m[1], /^0x[0-9a-fA-F]{40}$/);
+});
+
+test('ALGEBRA_QUOTER — preserves EIP-55 mixed-case checksum form', () => {
+ const m = SRC.match(/ALGEBRA_QUOTER\s*=\s*['"]([^'"]+)['"]/);
+ assert.notEqual(m[1], m[1].toLowerCase(),
+ `ALGEBRA_QUOTER must preserve EIP-55 checksum case`);
+});
+
+// ---------------------------------------------------------------------------
+// ABIs — exact shapes
+// ---------------------------------------------------------------------------
+
+test('QUOTER_ABI — exact quoteExactInputSingle signature', () => {
+ // Pinned: (address tokenIn, address tokenOut, uint256 amountIn,
+ // uint160 sqrtPriceLimitX96) returns (uint256 amountOut).
+ // Drift means the call ABI differs from the on-chain contract →
+ // ethers decodes garbage from callStatic.
+ assert.match(SRC,
+ /function quoteExactInputSingle\(address tokenIn, address tokenOut, uint256 amountIn, uint160 sqrtPriceLimitX96\) external returns \(uint256 amountOut\)/,
+ `QUOTER_ABI signature drifted`);
+});
+
+test('POOL_ABI — globalState 7-tuple shape (price, tick, fee, ...)', () => {
+ // Pinned: globalState returns (uint160 price, int24 tick, uint16 fee,
+ // uint16 timepointIndex, uint16 communityFeeToken0, uint16 communityFeeToken1, bool unlocked).
+ // Caller reads .price, .tick, .fee, etc. Drift means undefined
+ // properties or off-by-one in tuple index.
+ assert.match(SRC,
+ /function globalState\(\) external view returns \(uint160 price, int24 tick, uint16 fee, uint16 timepointIndex, uint16 communityFeeToken0, uint16 communityFeeToken1, bool unlocked\)/,
+ `POOL_ABI globalState shape drifted`);
+});
+
+test('POOL_ABI — has liquidity(), token0(), token1() getters', () => {
+ assert.match(SRC, /function liquidity\(\) external view returns \(uint128\)/);
+ assert.match(SRC, /function token0\(\) external view returns \(address\)/);
+ assert.match(SRC, /function token1\(\) external view returns \(address\)/);
+});
+
+test('ERC20_ABI — decimals + symbol getters', () => {
+ assert.match(SRC, /function decimals\(\) external view returns \(uint8\)/);
+ assert.match(SRC, /function symbol\(\) external view returns \(string\)/);
+});
+
+// ---------------------------------------------------------------------------
+// getAlgebraQuote — callStatic + sqrtPriceLimitX96=0 + error parsing
+// ---------------------------------------------------------------------------
+
+test('source — getAlgebraQuote uses callStatic (NOT direct call)', () => {
+ // Pinned: a regression to direct call would attempt a real signed
+ // transaction — burns gas and requires a signer.
+ assert.match(SRC,
+ /quoterContract\.callStatic\.quoteExactInputSingle\(/,
+ `must use callStatic — direct call burns gas and requires signer`);
+});
+
+test('source — getAlgebraQuote passes sqrtPriceLimitX96 = 0 (no price limit)', () => {
+ // Pinned: 0 means "no price limit" in Algebra. A regression to
+ // some non-zero literal would silently cap the swap or hit "AS".
+ assert.match(SRC,
+ /amountIn,\s*\n?\s*0\s*\/\/.*sqrtPriceLimitX96/,
+ `sqrtPriceLimitX96 must be 0 (no price limit) with the explanatory comment`);
+});
+
+test('source — error remapping: LOK → "Pool is locked"', () => {
+ assert.match(SRC,
+ /error\.message\?\.includes\(['"]LOK['"]\)[\s\S]*?throw new Error\(['"]Pool is locked['"]\)/,
+ `LOK → "Pool is locked" remap drifted`);
+});
+
+test('source — error remapping: IIA → "Insufficient input amount"', () => {
+ assert.match(SRC,
+ /error\.message\?\.includes\(['"]IIA['"]\)[\s\S]*?throw new Error\(['"]Insufficient input amount['"]\)/,
+ `IIA → "Insufficient input amount" remap drifted`);
+});
+
+test('source — error remapping: AS → "Price limit reached"', () => {
+ assert.match(SRC,
+ /error\.message\?\.includes\(['"]AS['"]\)[\s\S]*?throw new Error\(['"]Price limit reached['"]\)/,
+ `AS → "Price limit reached" remap drifted`);
+});
+
+test('source — unknown errors re-thrown unchanged (no swallow)', () => {
+ // Pinned: a `throw error;` at the bottom of the catch ensures
+ // unknown errors propagate. A regression that returns null/0
+ // would silently produce wrong quotes.
+ assert.match(SRC,
+ /throw\s+error\s*;?\s*\}\s*\}/,
+ `unknown errors must be re-thrown (not swallowed/returned)`);
+});
+
+// ---------------------------------------------------------------------------
+// sqrtPriceX96ToPrice — Algebra V3 standard formula
+// ---------------------------------------------------------------------------
+
+test('sqrtPriceX96ToPrice — sqrt = 2^96 → price = 1', () => {
+ const r = sqrtPriceX96ToPrice((2n ** 96n).toString());
+ assert.equal(r, 1, `sqrt=2^96 must yield price=1 (sqrt²/Q192 = 1)`);
+});
+
+test('sqrtPriceX96ToPrice — sqrt = 2 * 2^96 → price = 4', () => {
+ const r = sqrtPriceX96ToPrice((2n * (2n ** 96n)).toString());
+ assert.equal(r, 4);
+});
+
+test('sqrtPriceX96ToPrice — null/invalid input returns null (not throw)', () => {
+ // Pinned: callers may pass undefined when pool data not yet loaded.
+ // try/catch returns null — a regression to throw would crash UI.
+ assert.equal(sqrtPriceX96ToPrice(null), null);
+ assert.equal(sqrtPriceX96ToPrice('not a number'), null);
+});
+
+test('sqrtPriceX96ToPrice — non-negative for any valid sqrt (square invariant)', () => {
+ for (const exp of [0, 48, 96, 144, 160]) {
+ const r = sqrtPriceX96ToPrice((2n ** BigInt(exp)).toString());
+ assert.ok(r >= 0, `sqrt=2^${exp} produced negative price ${r}`);
+ }
+});
+
+test('sqrtPriceX96ToPrice — monotonic in sqrtPrice', () => {
+ const a = sqrtPriceX96ToPrice((1n << 96n).toString());
+ const b = sqrtPriceX96ToPrice((2n << 96n).toString());
+ const c = sqrtPriceX96ToPrice((10n << 96n).toString());
+ assert.ok(a < b && b < c);
+});
+
+test('source — sqrtPriceX96ToPrice formula matches canonical sqrt²/Q192', () => {
+ // Cross-pin: the inline math in getAlgebraQuoteWithSlippage uses
+ // the same formula. Drift between the two would silently differ
+ // from the canonical sqrt-price-x96.test.mjs pin.
+ assert.match(SRC,
+ /sqrtPriceSquared\s*=\s*sqrtPriceX96\.mul\(sqrtPriceX96\)/,
+ `inline rawPoolPrice formula must use sqrt.mul(sqrt) (NOT sqrt.pow(2) — different gas semantics)`);
+ assert.match(SRC,
+ /Q192\s*=\s*Q96\.mul\(Q96\)/,
+ `Q192 must be Q96.mul(Q96)`);
+});
+
+// ---------------------------------------------------------------------------
+// Slippage math — default 50 bps + (10000 - bps) / 10000
+// ---------------------------------------------------------------------------
+
+test('source — getAlgebraQuoteWithSlippage default slippageBps = 50 (0.5%)', () => {
+ assert.match(SRC,
+ /slippageBps\s*=\s*50,?\s*\n/,
+ `default slippageBps drifted from 50 (0.5%) — too low → quote rejected; too high → user gets less`);
+});
+
+test('source — slippage formula: amountOut * (10000 - bps) / 10000', () => {
+ // Cross-pin against canonical slippage-math.test.mjs. Same algorithm,
+ // different module — drift here means swap quotes diverge from
+ // canonical UI math.
+ assert.match(SRC,
+ /slippageFactor\s*=\s*ethers\.BigNumber\.from\(10000\s*-\s*slippageBps\)/,
+ `slippageFactor formula drifted from BigNumber.from(10000 - bps)`);
+ assert.match(SRC,
+ /amountOutWei\.mul\(slippageFactor\)\.div\(10000\)/,
+ `minAmountOut formula drifted from amountOut.mul(factor).div(10000)`);
+});
+
+test('slippage — applySlippageBps spec mirror: 0 bps → identity', () => {
+ const out = '1000000000000000000';
+ assert.equal(applySlippageBps(out, 0), BigInt(out));
+});
+
+test('slippage — applySlippageBps: 50 bps (0.5%) on 1e18 → 0.995e18', () => {
+ const r = applySlippageBps((10n ** 18n).toString(), 50);
+ const expected = (10n ** 18n) * 9950n / 10000n;
+ assert.equal(r, expected);
+});
+
+test('slippage — applySlippageBps non-increasing as bps grows', () => {
+ const out = '1000000000000000000';
+ let prev = BigInt(out) + 1n;
+ for (const bps of [0, 10, 50, 100, 500]) {
+ const r = applySlippageBps(out, bps);
+ assert.ok(r < prev,
+ `monotonicity broken: bps=${bps} produced minOut=${r} ≥ prev=${prev}`);
+ prev = r;
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Direction detection — toLowerCase comparison
+// ---------------------------------------------------------------------------
+
+test('detectDirection — token0→token1 swap detected', () => {
+ const r = detectDirection('0xAA', '0xBB', '0xAA', '0xBB');
+ assert.equal(r.isToken0ToToken1, true);
+ assert.equal(r.isToken1ToToken0, false);
+});
+
+test('detectDirection — token1→token0 swap detected (reverse)', () => {
+ const r = detectDirection('0xBB', '0xAA', '0xAA', '0xBB');
+ assert.equal(r.isToken0ToToken1, false);
+ assert.equal(r.isToken1ToToken0, true);
+});
+
+test('detectDirection — case-insensitive (lowercase comparison)', () => {
+ // Pinned: ethers returns checksum addresses, but the input may be
+ // lower or mixed case. A regression to strict equality would
+ // silently mis-detect direction → wrong price inversion.
+ const r = detectDirection('0xaaaaaa', '0xBBBBBB', '0xAAAAAA', '0xbbbbbb');
+ assert.equal(r.isToken0ToToken1, true,
+ `direction detection must be case-insensitive`);
+});
+
+test('source — token addresses lowercased BEFORE comparison', () => {
+ // Pinned all four lowercases.
+ assert.match(SRC, /token0Lower\s*=\s*token0Address\.toLowerCase\(\)/);
+ assert.match(SRC, /token1Lower\s*=\s*token1Address\.toLowerCase\(\)/);
+ assert.match(SRC, /tokenInLower\s*=\s*tokenIn\.toLowerCase\(\)/);
+ assert.match(SRC, /tokenOutLower\s*=\s*tokenOut\.toLowerCase\(\)/);
+});
+
+// ---------------------------------------------------------------------------
+// Price inversion logic — currency/company orientation
+// ---------------------------------------------------------------------------
+
+test('source — shouldInvertPrice = true when token0 is currency', () => {
+ // Pinned: rawPoolPrice = token1/token0. We want to display
+ // currency/company. So:
+ // token0=currency, token1=company → raw is company/currency → INVERT.
+ // token0=company, token1=currency → raw is currency/company → KEEP.
+ assert.match(SRC,
+ /if\s*\(token0IsCurrency\)\s*\{[\s\S]*?shouldInvertPrice\s*=\s*true[\s\S]*?\}\s*else\s*\{[\s\S]*?shouldInvertPrice\s*=\s*false/,
+ `inversion-by-token-orientation logic drifted`);
+});
+
+test('source — currentPrice = shouldInvertPrice ? 1/raw : raw', () => {
+ assert.match(SRC,
+ /currentPrice\s*=\s*shouldInvertPrice\s*\?\s*\(1\s*\/\s*rawPoolPrice\)\s*:\s*rawPoolPrice/,
+ `currentPrice inversion shape drifted`);
+});
+
+test('source — executionPrice for currency→company swap is INVERTED', () => {
+ // Pinned: when buying company with currency, amountOut/amountIn =
+ // company/currency. We want to display currency/company → invert.
+ // A regression here = wrong execution price displayed.
+ assert.match(SRC,
+ /!tokenInIsCompany\s*&&\s*tokenOutIsCompany[\s\S]*?executionPrice\s*=\s*1\s*\/\s*rawExecutionPrice/,
+ `currency→company execution price must be 1/raw`);
+});
+
+test('source — executionPrice for company→currency swap is DIRECT', () => {
+ assert.match(SRC,
+ /tokenInIsCompany\s*&&\s*!tokenOutIsCompany[\s\S]*?executionPrice\s*=\s*rawExecutionPrice/,
+ `company→currency execution price must be raw (not inverted)`);
+});
+
+// ---------------------------------------------------------------------------
+// Performance / gas contract pins
+// ---------------------------------------------------------------------------
+
+test('source — gasEstimate hardcoded at "400000" with comment about ~300k typical', () => {
+ // Pinned: comment says Algebra swaps typically use ~300k gas;
+ // 400k provides a buffer. A regression to a much higher value
+ // surfaces as confusing wallet-prompt UX.
+ assert.match(SRC,
+ /gasEstimate:\s*['"]400000['"]/,
+ `gasEstimate drifted from "400000"`);
+});
+
+test('source — rpcCalls: 4 performance contract pin (the file\'s reason for existing)', () => {
+ // The whole point of this file is "1-4 RPC calls per quote"
+ // (vs 420+ from @swapr/sdk). Pinned because a regression that
+ // re-introduces a loop or extra fetch would silently re-introduce
+ // the latency this file was created to fix.
+ assert.match(SRC,
+ /rpcCalls:\s*4/,
+ `rpcCalls drifted from 4 — may indicate extra RPC calls slipped in`);
+});
+
+test('source — opening docstring references the "420+ RPC calls" optimization narrative', () => {
+ // Pinned the historical context. If the comment is removed, future
+ // contributors lose the WHY behind the file's architecture.
+ assert.match(SRC,
+ /420\+/,
+ `module docstring no longer mentions the 420+ RPC call problem this file fixed`);
+});
diff --git a/auto-qa/tests/asset-refs.test.mjs b/auto-qa/tests/asset-refs.test.mjs
new file mode 100644
index 0000000..35ce83d
--- /dev/null
+++ b/auto-qa/tests/asset-refs.test.mjs
@@ -0,0 +1,131 @@
+/**
+ * Asset reference baseline test (auto-qa).
+ *
+ * Walks src/ for `/assets/...` references and asserts each one
+ * resolves to a file in public/. Catches a class of "broken image"
+ * deploys where someone renames an asset without updating callers.
+ *
+ * Per the auto-qa directive (do not fix production bugs in this loop),
+ * the 6 currently-broken refs are pinned in BASELINE_BROKEN_REFS.
+ * Any NEW broken ref fails the test loudly. Any time someone fixes
+ * one from the baseline, the test prompts them to remove it from the
+ * baseline (so the count keeps ratcheting down).
+ *
+ * Excluded callers (Storybook / docs / template):
+ * - src/stories/Configure.mdx
+ * - src/config/README.md
+ * - src/config/markets-example.js
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const REPO_ROOT = new URL('../../', import.meta.url);
+const SRC_DIR = fileURLToPath(new URL('src', REPO_ROOT));
+const PUBLIC_DIR = fileURLToPath(new URL('public', REPO_ROOT));
+
+const EXCLUDED_CALLERS = new Set([
+ 'src/stories/Configure.mdx', // Storybook welcome page (not shipped)
+ 'src/config/README.md', // documentation
+ 'src/config/markets-example.js', // example template, not imported
+]);
+
+// Refs that are broken today but cannot be fixed in this loop (production
+// is off-limits). When a fix lands and one of these refs starts resolving,
+// remove it from the baseline so the count keeps ratcheting down.
+const BASELINE_BROKEN_REFS = new Set([
+ '/assets/default-company-logo.png', // src/utils/imageUtils.js
+ '/assets/default-logo.png', // EventHighlightCard.jsx
+ '/assets/fallback-company.png', // ResolvedEventsDataTransformer.jsx + 2 more
+ '/assets/kleros-proposal-1.png', // src/config/mapped-seo.json
+ '/assets/market-logo.svg', // MarketPage.jsx
+ '/assets/starbucks-market-card-1.png', // src/config/mapped-seo.json
+]);
+
+function walk(dir, results = []) {
+ for (const name of readdirSync(dir)) {
+ const full = join(dir, name);
+ const st = statSync(full);
+ if (st.isDirectory()) walk(full, results);
+ else if (/\.(jsx?|tsx?|mdx?|json)$/.test(name)) results.push(full);
+ }
+ return results;
+}
+
+function extractAssetRefs() {
+ const refs = new Map(); // ref -> Set
+ const re = /\/assets\/[a-zA-Z0-9._\-/]+\.(png|jpg|jpeg|webp|svg|gif|ico)/g;
+ for (const file of walk(SRC_DIR)) {
+ const rel = relative(fileURLToPath(REPO_ROOT), file);
+ if (EXCLUDED_CALLERS.has(rel)) continue;
+ const src = readFileSync(file, 'utf8');
+ const matches = src.match(re) || [];
+ for (const m of matches) {
+ if (!refs.has(m)) refs.set(m, new Set());
+ refs.get(m).add(rel);
+ }
+ }
+ return refs;
+}
+
+function assetExists(ref) {
+ // ref looks like "/assets/foo/bar.png"; strip leading slash.
+ const rel = ref.replace(/^\//, '');
+ try {
+ statSync(join(fileURLToPath(REPO_ROOT), 'public', rel));
+ return true;
+ } catch { return false; }
+}
+
+const ALL_REFS = extractAssetRefs();
+const BROKEN_REFS = [...ALL_REFS.keys()].filter(r => !assetExists(r));
+const NEW_BROKEN = BROKEN_REFS.filter(r => !BASELINE_BROKEN_REFS.has(r));
+const FIXED_FROM_BASELINE = [...BASELINE_BROKEN_REFS].filter(r => assetExists(r));
+
+test('asset-refs — extractor finds at least 30 asset refs in production code', () => {
+ // Sanity check: if this number drops to 0 the regex is broken.
+ assert.ok(ALL_REFS.size >= 30,
+ `extractor found only ${ALL_REFS.size} refs — regex / walker likely broken`);
+});
+
+test('asset-refs — public/assets directory exists and is populated', () => {
+ const items = readdirSync(PUBLIC_DIR + '/assets');
+ assert.ok(items.length > 50,
+ `public/assets has ${items.length} items — directory likely truncated`);
+});
+
+test('asset-refs — no NEW broken /assets/ refs since baseline', () => {
+ if (NEW_BROKEN.length === 0) return;
+ const lines = NEW_BROKEN.map(r => {
+ const callers = [...ALL_REFS.get(r)].slice(0, 3).join(', ');
+ return ` ${r} ← ${callers}`;
+ }).join('\n');
+ assert.fail(
+ `${NEW_BROKEN.length} new broken /assets/ ref(s) found beyond the baseline:\n${lines}\n\n` +
+ `Either add the missing file under public/ OR update the calling code.\n` +
+ `If genuinely intentional, add the ref to BASELINE_BROKEN_REFS in this test ` +
+ `(but please leave a comment explaining why).`
+ );
+});
+
+test('asset-refs — every BASELINE_BROKEN_REFS entry is still actually broken', () => {
+ if (FIXED_FROM_BASELINE.length === 0) return;
+ assert.fail(
+ `${FIXED_FROM_BASELINE.length} BASELINE_BROKEN_REFS entries now resolve in public/:\n` +
+ FIXED_FROM_BASELINE.map(r => ` ${r}`).join('\n') +
+ `\n\nNice — please REMOVE these from BASELINE_BROKEN_REFS in this test ` +
+ `so the ratchet keeps tightening.`
+ );
+});
+
+test('asset-refs — baseline broken count is exactly what we expect', () => {
+ // Snapshot ratchet — surfaces if the baseline gets edited without
+ // updating this number (the dual-test above would catch removals,
+ // but this catches manual baseline-list additions).
+ assert.equal(BASELINE_BROKEN_REFS.size, 6,
+ `BASELINE_BROKEN_REFS size changed from 6 to ${BASELINE_BROKEN_REFS.size}. ` +
+ `If you added entries, also bump this number; if you removed entries (a fix landed), bump down.`);
+});
diff --git a/auto-qa/tests/best-rpc-cache.test.mjs b/auto-qa/tests/best-rpc-cache.test.mjs
new file mode 100644
index 0000000..b064972
--- /dev/null
+++ b/auto-qa/tests/best-rpc-cache.test.mjs
@@ -0,0 +1,282 @@
+/**
+ * getBestRpc cache helpers spec mirror (auto-qa).
+ *
+ * Pins src/utils/getBestRpc.js — the chain-aware RPC selector that
+ * powers any code path needing a fast working RPC. Three layers:
+ *
+ * 1. RPC_LISTS — hardcoded URL lists per chain. HTTPS-only, dedup,
+ * non-empty. A regression that empties one would fall through to
+ * `throw new Error("No RPC endpoints configured for chain ...")`.
+ *
+ * 2. Constants — RPC_TIMEOUT_MS (5s), CACHE_DURATION_MS (5min),
+ * MAX_CACHED_RPC_COUNT (3). Drift here changes user-visible
+ * latency / staleness silently.
+ *
+ * 3. normalizeCacheEntry — back-compat shim. Older builds wrote
+ * { url, timestamp } (single-url shape); the new code reads
+ * { urls, timestamp } (array shape). The shim must preserve the
+ * old entries so warm caches survive a rolling deploy.
+ *
+ * 4. The LRU rotation inside tryCachedRpcs — when a cached candidate
+ * succeeds, it must move to FRONT, dedupe, and clip to
+ * MAX_CACHED_RPC_COUNT. A regression that drops the dedupe would
+ * let the same URL accumulate (and crowd out the others).
+ *
+ * The async path (testRpc, getBestRpc) is NOT mirrored — it does
+ * fetch() + console.log + setTimeout. That's an integration concern.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const SRC = readFileSync(
+ new URL('../../src/utils/getBestRpc.js', import.meta.url),
+ 'utf8',
+);
+
+// --- spec mirror of normalizeCacheEntry (pure) ---
+function normalizeCacheEntry(entry) {
+ if (!entry) return null;
+ if (Array.isArray(entry.urls)) {
+ return entry;
+ }
+ if (entry.url) {
+ return {
+ urls: [entry.url],
+ timestamp: entry.timestamp || Date.now(),
+ };
+ }
+ return null;
+}
+
+// --- spec mirror of the LRU rotation inside tryCachedRpcs ---
+// When candidateUrl succeeds: move to front, dedupe, clip to max.
+function rotateOnHit(urls, candidateUrl, maxCount) {
+ const deduped = urls.filter(url => url !== candidateUrl);
+ return [candidateUrl, ...deduped].slice(0, maxCount);
+}
+
+// ---------------------------------------------------------------------------
+// RPC_LISTS — pinned from source text
+// ---------------------------------------------------------------------------
+
+function extractRpcList(chainId) {
+ // Find the RPC_LISTS object body, then find the chainId block within.
+ const block = SRC.match(/RPC_LISTS\s*=\s*\{([\s\S]*?)\n\};/);
+ assert.ok(block, 'RPC_LISTS object not found');
+ // Lines like ` 100: [ ... ]` — pull the array following `:`.
+ const re = new RegExp(`${chainId}\\s*:\\s*\\[([\\s\\S]*?)\\]`);
+ const m = block[1].match(re);
+ if (!m) return null;
+ return [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1]);
+}
+
+const ETH_RPCS = extractRpcList(1);
+const GNOSIS_RPCS = extractRpcList(100);
+
+test('RPC_LISTS — chain 1 (Ethereum) has >= 3 entries, all HTTPS, deduped', () => {
+ assert.ok(ETH_RPCS, 'chain 1 RPC list not extractable');
+ assert.ok(ETH_RPCS.length >= 3,
+ `chain 1 has only ${ETH_RPCS.length} RPCs — fallback loses meaning below 3`);
+ for (const url of ETH_RPCS) {
+ assert.match(url, /^https:\/\//,
+ `chain 1 contains non-HTTPS RPC: ${url}`);
+ }
+ assert.equal(new Set(ETH_RPCS).size, ETH_RPCS.length,
+ `chain 1 RPC list contains duplicates`);
+});
+
+test('RPC_LISTS — chain 100 (Gnosis) has >= 3 entries, all HTTPS, deduped', () => {
+ assert.ok(GNOSIS_RPCS, 'chain 100 RPC list not extractable');
+ assert.ok(GNOSIS_RPCS.length >= 3,
+ `chain 100 has only ${GNOSIS_RPCS.length} RPCs`);
+ for (const url of GNOSIS_RPCS) {
+ assert.match(url, /^https:\/\//,
+ `chain 100 contains non-HTTPS RPC: ${url}`);
+ }
+ assert.equal(new Set(GNOSIS_RPCS).size, GNOSIS_RPCS.length,
+ `chain 100 RPC list contains duplicates`);
+});
+
+test('RPC_LISTS — chain 100 includes the canonical rpc.gnosischain.com', () => {
+ // Pinned because it's the chain's own official endpoint and survives
+ // any third-party outage.
+ assert.ok(GNOSIS_RPCS.includes('https://rpc.gnosischain.com'),
+ `chain 100 RPC list missing canonical https://rpc.gnosischain.com`);
+});
+
+// ---------------------------------------------------------------------------
+// Constants — pinned from source text
+// ---------------------------------------------------------------------------
+
+test('RPC_TIMEOUT_MS — pinned at 5000ms (5 seconds)', () => {
+ const m = SRC.match(/RPC_TIMEOUT_MS\s*=\s*(\d+)/);
+ assert.ok(m, 'RPC_TIMEOUT_MS not found');
+ assert.equal(parseInt(m[1]), 5000,
+ `RPC_TIMEOUT_MS drifted from 5000ms — too low fails fast on slow networks; ` +
+ `too high stalls the user before fallback kicks in.`);
+});
+
+test('CACHE_DURATION_MS — pinned at 5 minutes', () => {
+ // The expression is `5 * 60 * 1000` — match the parts.
+ const m = SRC.match(/CACHE_DURATION_MS\s*=\s*5\s*\*\s*60\s*\*\s*1000/);
+ assert.ok(m,
+ `CACHE_DURATION_MS drifted from 5*60*1000 (5 min). ` +
+ `Shortening means more cold probes; lengthening means stale cached RPCs ` +
+ `that may have gone down.`);
+});
+
+test('MAX_CACHED_RPC_COUNT — pinned at 3 (top-3 fastest kept warm)', () => {
+ const m = SRC.match(/MAX_CACHED_RPC_COUNT\s*=\s*(\d+)/);
+ assert.ok(m, 'MAX_CACHED_RPC_COUNT not found');
+ assert.equal(parseInt(m[1]), 3,
+ `MAX_CACHED_RPC_COUNT changed — affects fallback breadth in cached path. ` +
+ `1 = single-point-of-failure; >5 = cache stale URLs longer than helpful.`);
+});
+
+// ---------------------------------------------------------------------------
+// normalizeCacheEntry — null/empty inputs
+// ---------------------------------------------------------------------------
+
+test('normalizeCacheEntry — null entry returns null', () => {
+ assert.equal(normalizeCacheEntry(null), null);
+});
+
+test('normalizeCacheEntry — undefined entry returns null', () => {
+ assert.equal(normalizeCacheEntry(undefined), null);
+});
+
+test('normalizeCacheEntry — empty object returns null (no urls, no url)', () => {
+ assert.equal(normalizeCacheEntry({}), null);
+});
+
+// ---------------------------------------------------------------------------
+// normalizeCacheEntry — modern shape (urls array) is returned unchanged
+// ---------------------------------------------------------------------------
+
+test('normalizeCacheEntry — modern shape returned by identity (not copy)', () => {
+ // Identity matters: callers mutate cacheEntry in-place to update
+ // urls/timestamp on hit. A defensive copy here would silently
+ // discard those mutations.
+ const entry = { urls: ['https://a'], timestamp: 12345 };
+ assert.equal(normalizeCacheEntry(entry), entry,
+ `modern shape MUST be returned by identity (callers mutate in place)`);
+});
+
+test('normalizeCacheEntry — modern shape with empty urls array still returned', () => {
+ // Empty-but-array means "we tried and nothing worked" — distinct
+ // from missing. Caller checks `urls.length` separately.
+ const entry = { urls: [], timestamp: 12345 };
+ assert.equal(normalizeCacheEntry(entry), entry);
+});
+
+// ---------------------------------------------------------------------------
+// normalizeCacheEntry — legacy shape (single url) gets upgraded
+// ---------------------------------------------------------------------------
+
+test('normalizeCacheEntry — legacy { url, timestamp } upgraded to { urls: [url], timestamp }', () => {
+ // Pinned: this back-compat shim is what lets a rolling deploy keep
+ // the warm cache. Drop it and every user pays a cold-probe penalty
+ // on the deploy boundary.
+ const legacy = { url: 'https://a', timestamp: 12345 };
+ const r = normalizeCacheEntry(legacy);
+ assert.deepEqual(r, { urls: ['https://a'], timestamp: 12345 });
+});
+
+test('normalizeCacheEntry — legacy without timestamp gets a fresh Date.now()', () => {
+ // Defensive default: if a legacy entry somehow lost its timestamp,
+ // we treat it as fresh rather than letting it expire instantly.
+ const before = Date.now();
+ const r = normalizeCacheEntry({ url: 'https://a' });
+ const after = Date.now();
+ assert.deepEqual(r.urls, ['https://a']);
+ assert.ok(r.timestamp >= before && r.timestamp <= after,
+ `timestamp must be set to ~Date.now() when legacy entry lacks one`);
+});
+
+// ---------------------------------------------------------------------------
+// LRU rotation — successful URL moves to front, dedupes, clips to max
+// ---------------------------------------------------------------------------
+
+test('rotateOnHit — candidate already at front: stays at front, no duplicates', () => {
+ const r = rotateOnHit(['a', 'b', 'c'], 'a', 3);
+ assert.deepEqual(r, ['a', 'b', 'c']);
+});
+
+test('rotateOnHit — candidate in middle: moves to front, others preserve order', () => {
+ const r = rotateOnHit(['a', 'b', 'c'], 'b', 3);
+ assert.deepEqual(r, ['b', 'a', 'c']);
+});
+
+test('rotateOnHit — candidate at end: moves to front', () => {
+ const r = rotateOnHit(['a', 'b', 'c'], 'c', 3);
+ assert.deepEqual(r, ['c', 'a', 'b']);
+});
+
+test('rotateOnHit — candidate not in list: prepended, list grows up to max', () => {
+ // This case can happen after an entry ages out of the array but
+ // the caller passes it back in (shouldn't really happen given the
+ // current code, but the function handles it).
+ const r = rotateOnHit(['a', 'b'], 'c', 3);
+ assert.deepEqual(r, ['c', 'a', 'b']);
+});
+
+test('rotateOnHit — clips to MAX_CACHED_RPC_COUNT when over capacity', () => {
+ // Pinned: bug would be "we keep 4 cached RPCs instead of 3" — silent
+ // regression that weakens the eviction guarantee.
+ const r = rotateOnHit(['a', 'b', 'c', 'd'], 'e', 3);
+ assert.deepEqual(r, ['e', 'a', 'b']);
+});
+
+test('rotateOnHit — dedupe does NOT add a duplicate when candidate appears twice in input', () => {
+ // Defensive: even if upstream wrote duplicates, a single rotation
+ // should de-dupe them.
+ const r = rotateOnHit(['a', 'b', 'a'], 'a', 3);
+ assert.deepEqual(r, ['a', 'b']);
+});
+
+// ---------------------------------------------------------------------------
+// Source-text shape pins (catch silent refactors)
+// ---------------------------------------------------------------------------
+
+test('getBestRpc — uses POST + eth_blockNumber for the probe (not GET / not chain_id)', () => {
+ // Pinned: an RPC test that uses GET would always fail on
+ // standards-compliant JSON-RPC endpoints. A test that uses
+ // `eth_chainId` would fail on chains that haven't replied to that
+ // method (rare but possible). `eth_blockNumber` is the canonical
+ // liveness probe.
+ assert.match(SRC, /method:\s*['"]POST['"]/, 'probe method must be POST');
+ assert.match(SRC, /method:\s*['"]eth_blockNumber['"]/,
+ 'probe RPC method must be eth_blockNumber');
+});
+
+test('getBestRpc — sets jsonrpc: "2.0" in the probe body', () => {
+ // Some RPC endpoints reject calls without the proper jsonrpc version.
+ assert.match(SRC, /jsonrpc:\s*['"]2\.0['"]/);
+});
+
+test('getBestRpc — uses AbortController + setTimeout for the timeout (not setTimeout-only)', () => {
+ // Pinned: a setTimeout-only timeout would race the request but not
+ // actually cancel it — fetch keeps the socket open. AbortController
+ // is the only way to actually free the resource on timeout.
+ assert.match(SRC, /AbortController/);
+ assert.match(SRC, /controller\.abort\(\)/);
+ assert.match(SRC, /signal:\s*controller\.signal/);
+});
+
+test('getBestRpc — sorts working RPCs by ascending latency', () => {
+ // Pinned the sort direction: descending would pick the SLOWEST
+ // working RPC — silent UX regression.
+ assert.match(SRC, /\.sort\(\(a,\s*b\)\s*=>\s*a\.latency\s*-\s*b\.latency\)/,
+ `working RPCs must be sorted ASC by latency (a.latency - b.latency)`);
+});
+
+test('getBestRpc — exports clearRpcCache, diagnoseRpcs, getRpcCacheStatus', () => {
+ // These are utilities used by debug pages / diagnostics. A refactor
+ // that drops the export breaks those callers silently (next build
+ // surfaces the error but only if the caller is in a tested path).
+ assert.match(SRC, /export\s+function\s+clearRpcCache/);
+ assert.match(SRC, /export\s+async\s+function\s+diagnoseRpcs/);
+ assert.match(SRC, /export\s+function\s+getRpcCacheStatus/);
+});
diff --git a/auto-qa/tests/contract-addresses.test.mjs b/auto-qa/tests/contract-addresses.test.mjs
new file mode 100644
index 0000000..5a738a3
--- /dev/null
+++ b/auto-qa/tests/contract-addresses.test.mjs
@@ -0,0 +1,175 @@
+/**
+ * Contract addresses lint (auto-qa).
+ *
+ * Walks src/components/futarchyFi/marketPage/constants/contracts.js and
+ * asserts every 0x-prefixed string follows shape rules. Catches a
+ * class of catastrophic bugs:
+ *
+ * - An address typo (truncated, extra digit, wrong case) silently
+ * breaks every transaction targeting that contract.
+ * - A merge accident concatenates two addresses into one.
+ * - A refactor swaps two addresses by name.
+ *
+ * Pinned shape invariants:
+ * - Address: 0x + exactly 40 hex chars
+ * - Hash / condition id: 0x + 64 hex chars (32 bytes)
+ * - Chain id / small constant: 0x + 1-8 hex chars
+ * - Everything else is suspicious
+ *
+ * Pinned specific values that are "external constants" — well-known
+ * addresses that must never drift:
+ * - REQUIRED_CHAIN_ID = '0x64' (Gnosis Chain = 100 in hex)
+ * - COW_SETTLEMENT_ADDRESS = canonical CoW Protocol settlement
+ * - WXDAI_ADDRESS = canonical wrapped xDAI on Gnosis
+ *
+ * Plus a count baseline so additions/removals are intentional.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const FILE = new URL(
+ '../../src/components/futarchyFi/marketPage/constants/contracts.js',
+ import.meta.url,
+);
+
+const SRC = readFileSync(FILE, 'utf8');
+
+// Extract every quoted "0x..." literal. Match in single-quoted strings only
+// to avoid pulling 0x out of comments. The constants file uses single
+// quotes consistently for these literals.
+const HEX_LITERAL_RE = /'(0x[0-9a-fA-F]+)'/g;
+const all = [];
+let m;
+while ((m = HEX_LITERAL_RE.exec(SRC)) !== null) all.push(m[1]);
+
+const addresses = all.filter(s => s.length === 42);
+const non42 = all.filter(s => s.length !== 42);
+
+// ---------------------------------------------------------------------------
+// Sanity / extractor checks
+// ---------------------------------------------------------------------------
+
+test('contracts — extractor finds at least 10 0x-literals', () => {
+ assert.ok(all.length >= 10,
+ `extractor found only ${all.length} 0x-literals — regex broken or file shrunk dramatically`);
+});
+
+// ---------------------------------------------------------------------------
+// Address shape — every 42-char 0x-literal must be all-hex
+// ---------------------------------------------------------------------------
+
+test('contracts — every 42-char 0x-literal is valid hex (40 chars after 0x)', () => {
+ for (const a of addresses) {
+ assert.match(a, /^0x[0-9a-fA-F]{40}$/,
+ `address fails shape check: "${a}"`);
+ }
+});
+
+test('contracts — no address is the zero address (0x000…000)', () => {
+ // A literal zero-address constant is almost always a copy-paste bug.
+ const ZERO = '0x' + '0'.repeat(40);
+ for (const a of addresses) {
+ assert.notEqual(a.toLowerCase(), ZERO,
+ `zero address found in contracts.js — likely a placeholder left in by mistake`);
+ }
+});
+
+test('contracts — addresses do not contain repeated characters past plausibility', () => {
+ // Catches "0xaaaaaaa..." or "0xfffff..." style placeholders.
+ for (const a of addresses) {
+ const body = a.slice(2).toLowerCase();
+ const allSameChar = /^(.)\1+$/.test(body);
+ assert.ok(!allSameChar,
+ `address "${a}" is all the same character — likely a placeholder`);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Non-address 0x-literals (chain ids, etc.) — must be 1-8 hex chars
+// ---------------------------------------------------------------------------
+
+test('contracts — non-address 0x-literals are recognized shapes (chain id / 32-byte hash)', () => {
+ // Allowed shapes:
+ // - chain id / small constant: 1-8 hex chars (e.g. '0x64' for Gnosis)
+ // - 32-byte hash / condition id: exactly 64 hex chars
+ // Anything else is suspicious — possibly an address that lost or gained chars.
+ for (const s of non42) {
+ const body = s.slice(2);
+ assert.match(body, /^[0-9a-fA-F]+$/, `non-address literal "${s}" has non-hex chars`);
+ const isSmall = body.length >= 1 && body.length <= 8;
+ const isHash = body.length === 64;
+ assert.ok(isSmall || isHash,
+ `0x literal "${s}" has ${body.length} hex chars — not 40 (address), 64 (hash), or 1-8 (chain id). ` +
+ `Possibly a typo where an address lost or gained chars.`);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Pinned external constants — well-known values that MUST NOT drift
+// ---------------------------------------------------------------------------
+
+test('contracts — REQUIRED_CHAIN_ID is exactly 0x64 (Gnosis = 100)', () => {
+ const m = SRC.match(/REQUIRED_CHAIN_ID\s*=\s*'(0x[0-9a-fA-F]+)'/);
+ assert.ok(m, 'REQUIRED_CHAIN_ID literal not found in source');
+ assert.equal(m[1], '0x64',
+ `REQUIRED_CHAIN_ID drifted: got "${m[1]}". 0x64 = chain id 100 (Gnosis Chain). ` +
+ `If we intentionally moved off Gnosis, this is a major rollout — update the test.`);
+});
+
+test('contracts — COW_SETTLEMENT_ADDRESS is the canonical CoW Protocol address', () => {
+ // CoW's settlement contract is the same address on every chain it's deployed on.
+ const m = SRC.match(/COW_SETTLEMENT_ADDRESS\s*=\s*'(0x[0-9a-fA-F]+)'/);
+ assert.ok(m, 'COW_SETTLEMENT_ADDRESS not found');
+ assert.equal(m[1], '0x9008D19f58AAbD9eD0D60971565AA8510560ab41',
+ `COW_SETTLEMENT_ADDRESS drifted from canonical CoW Protocol settlement`);
+});
+
+test('contracts — WXDAI_ADDRESS is the canonical wXDAI on Gnosis', () => {
+ const m = SRC.match(/WXDAI_ADDRESS\s*=\s*'(0x[0-9a-fA-F]+)'/);
+ assert.ok(m, 'WXDAI_ADDRESS not found');
+ assert.equal(m[1], '0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d',
+ `WXDAI_ADDRESS drifted — wXDAI on Gnosis is fixed`);
+});
+
+test('contracts — VAULT_RELAYER_ADDRESS is the canonical CoW vault relayer', () => {
+ const m = SRC.match(/VAULT_RELAYER_ADDRESS\s*=\s*'(0x[0-9a-fA-F]+)'/);
+ assert.ok(m, 'VAULT_RELAYER_ADDRESS not found');
+ assert.equal(m[1], '0xC92E8bdf79f0507f65a392b0ab4667716BFE0110',
+ `VAULT_RELAYER_ADDRESS drifted from canonical CoW Protocol vault relayer`);
+});
+
+// ---------------------------------------------------------------------------
+// Counts — baseline so additions/removals are intentional
+// ---------------------------------------------------------------------------
+
+test('contracts — address count is within expected range', () => {
+ // Count includes addresses inside the ABI strings (those are mostly
+ // event topics that look like 0x... but happen to be 64 chars — they
+ // get filtered out of `addresses`). Just pin the order of magnitude.
+ assert.ok(addresses.length >= 8 && addresses.length <= 50,
+ `address count is ${addresses.length} — outside expected range [8, 50]. ` +
+ `If you added/removed addresses intentionally, bump this range.`);
+});
+
+test('contracts — extractor finds the expected NAMED addresses', () => {
+ // Spot-check a few names that must always exist. If any of these
+ // disappears, the surrounding components are likely broken.
+ const REQUIRED_NAMES = [
+ 'CONDITIONAL_TOKENS_ADDRESS',
+ 'WRAPPER_SERVICE_ADDRESS',
+ 'VAULT_RELAYER_ADDRESS',
+ 'COW_SETTLEMENT_ADDRESS',
+ 'FUTARCHY_ROUTER_ADDRESS',
+ 'BASE_CURRENCY_TOKEN_ADDRESS',
+ 'BASE_COMPANY_TOKEN_ADDRESS',
+ 'MARKET_ADDRESS',
+ 'WXDAI_ADDRESS',
+ 'REQUIRED_CHAIN_ID',
+ ];
+ for (const name of REQUIRED_NAMES) {
+ assert.match(SRC, new RegExp(`export const ${name}\\s*=`),
+ `required export "${name}" missing from contracts.js`);
+ }
+});
diff --git a/auto-qa/tests/dead-references.test.mjs b/auto-qa/tests/dead-references.test.mjs
new file mode 100644
index 0000000..9079815
--- /dev/null
+++ b/auto-qa/tests/dead-references.test.mjs
@@ -0,0 +1,90 @@
+/**
+ * Dead-references lint test (auto-qa).
+ *
+ * Pins references that should not appear (tickspread.com, PR #43) and
+ * tracks the count of legacy references that DO still appear (supabase
+ * imports, PR #47's cleanup was partial). Same baseline pattern as
+ * the graphql-compat test.
+ *
+ * Why count rather than zero:
+ * PR #43 cleanup is complete — assert exact zero.
+ * PR #47 cleanup was partial — 5 files still import @supabase/supabase-js
+ * even though most should be using the subgraph fetcher. Per /loop
+ * directive we don't fix production; we record the baseline so any
+ * regression (more imports added) AND any progress (imports removed)
+ * trips the test.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join, relative } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = join(__dirname, '..', '..');
+const SRC = join(REPO_ROOT, 'src');
+const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', 'coverage']);
+const EXTS = new Set(['.js', '.jsx', '.mjs', '.ts', '.tsx', '.css']);
+
+function* walk(dir) {
+ let entries;
+ try { entries = readdirSync(dir); } catch { return; }
+ for (const name of entries) {
+ if (SKIP_DIRS.has(name)) continue;
+ const full = join(dir, name);
+ let st; try { st = statSync(full); } catch { continue; }
+ if (st.isDirectory()) yield* walk(full);
+ else if (st.isFile()) yield full;
+ }
+}
+
+function findHits(pattern) {
+ const re = new RegExp(pattern);
+ const hits = [];
+ for (const file of walk(SRC)) {
+ const ext = '.' + file.split('.').pop();
+ if (!EXTS.has(ext)) continue;
+ let text; try { text = readFileSync(file, 'utf8'); } catch { continue; }
+ const lines = text.split('\n');
+ for (let i = 0; i < lines.length; i++) {
+ if (re.test(lines[i])) {
+ hits.push({ file: relative(REPO_ROOT, file), line: i + 1, text: lines[i].trim() });
+ }
+ }
+ }
+ return hits;
+}
+
+// ────────────────────────────────────────────────────────────────────────
+// PR #43 — Remove tickspread.com URL references (cleanup is complete)
+// ────────────────────────────────────────────────────────────────────────
+test('PR #43 — no tickspread.com URLs remain in src/', () => {
+ const hits = findHits('tickspread\\.com');
+ assert.equal(hits.length, 0,
+ `Found ${hits.length} tickspread.com references that shouldn't exist:\n${
+ hits.map(h => ` ${h.file}:${h.line} ${h.text}`).join('\n')
+ }`);
+});
+
+// ────────────────────────────────────────────────────────────────────────
+// PR #47 — Remove dead Supabase code (cleanup is PARTIAL)
+//
+// Baseline = the count of imports as of this iteration. If new ones get
+// added: test fails (regression). If real-fix work removes some: test
+// fails (forces the baseline to be lowered, ratcheting the cleanup).
+// ────────────────────────────────────────────────────────────────────────
+const SUPABASE_IMPORT_BASELINE = 10;
+
+test('PR #47 — supabase import count matches baseline', () => {
+ const hits = findHits("from\\s+['\"]@supabase/supabase-js['\"]");
+ const msg = `Supabase imports remaining (${hits.length}):\n${
+ hits.map(h => ` ${h.file}:${h.line}`).join('\n')
+ }`;
+ if (hits.length > SUPABASE_IMPORT_BASELINE) {
+ assert.fail(`REGRESSION: count rose from ${SUPABASE_IMPORT_BASELINE} to ${hits.length}.\n${msg}`);
+ } else if (hits.length < SUPABASE_IMPORT_BASELINE) {
+ assert.fail(`PROGRESS: count fell from ${SUPABASE_IMPORT_BASELINE} to ${hits.length} — update the baseline in this test (lower the constant) and the entry in PROGRESS.md.\n${msg}`);
+ }
+ assert.equal(hits.length, SUPABASE_IMPORT_BASELINE);
+});
diff --git a/auto-qa/tests/extractor-sanity.test.mjs b/auto-qa/tests/extractor-sanity.test.mjs
new file mode 100644
index 0000000..bd3e69c
--- /dev/null
+++ b/auto-qa/tests/extractor-sanity.test.mjs
@@ -0,0 +1,81 @@
+/**
+ * Sanity test for auto-qa/tools/extract-graphql.mjs (auto-qa).
+ *
+ * This is the foundation for the schema-compat checker (highest-leverage
+ * tool in our backlog). The extractor will only be useful if it actually
+ * pulls every shipped GraphQL query out of src/. This test asserts that:
+ *
+ * 1. The extractor runs without crashing.
+ * 2. It finds at least N queries (we know we ship many).
+ * 3. It picks up *known* queries we authored in this session — these are
+ * checkpoints against accidentally narrowing the heuristic later.
+ *
+ * If the extractor regresses (e.g. someone tightens looksLikeGraphQL and
+ * loses entries), this test fails clearly.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const EXTRACTOR = resolve(__dirname, '../tools/extract-graphql.mjs');
+
+function runExtractor() {
+ const out = execFileSync('node', [EXTRACTOR], {
+ encoding: 'utf8',
+ cwd: resolve(__dirname, '../..'),
+ });
+ return JSON.parse(out);
+}
+
+test('extractor runs and emits an array of query records', () => {
+ const queries = runExtractor();
+ assert.ok(Array.isArray(queries), 'output should be an array');
+ assert.ok(queries.length > 10,
+ `expected >10 queries (we ship many); got ${queries.length}`);
+});
+
+test('every record has the expected shape', () => {
+ const queries = runExtractor();
+ for (const q of queries) {
+ assert.equal(typeof q.file, 'string', 'file is string');
+ assert.equal(typeof q.line, 'number', 'line is number');
+ assert.ok(q.line >= 1, 'line is 1-indexed');
+ assert.equal(typeof q.query, 'string', 'query is string');
+ assert.ok(q.query.length > 0, 'query is non-empty');
+ }
+});
+
+test('finds known queries authored in this session', () => {
+ const queries = runExtractor();
+ const files = new Set(queries.map(q => q.file));
+
+ // Files we know contain GraphQL strings (rewritten in PRs #62, #63, #65).
+ const expected = [
+ 'src/hooks/useSubgraphData.js', // PR #65
+ 'src/hooks/usePoolData.js', // PR #65
+ 'src/utils/subgraphTradesClient.js', // PR #63
+ 'src/adapters/subgraphConfigAdapter.js',// PR #62
+ 'src/hooks/useAggregatorProposals.js', // PR #64 area
+ 'src/utils/SubgraphBulkPriceFetcher.js',// PR #64 area
+ ];
+
+ for (const f of expected) {
+ assert.ok(files.has(f),
+ `extractor missed ${f} — heuristic may have regressed`);
+ }
+});
+
+test('finds at least one anonymous-shorthand query (no "query" keyword)', () => {
+ const queries = runExtractor();
+ // Many queries are written as `{ pools(where: …) { … } }` — no keyword.
+ // The looksLikeGraphQL heuristic must keep handling this shape.
+ const anonymous = queries.filter(q =>
+ q.query.startsWith('{') && !/^\{\s*query\b/.test(q.query)
+ );
+ assert.ok(anonymous.length > 0,
+ 'expected at least one anonymous-shorthand query like `{ pools(...) { ... } }`');
+});
diff --git a/auto-qa/tests/footer-links.test.mjs b/auto-qa/tests/footer-links.test.mjs
new file mode 100644
index 0000000..4f830b8
--- /dev/null
+++ b/auto-qa/tests/footer-links.test.mjs
@@ -0,0 +1,115 @@
+/**
+ * Footer NAV_LINKS structural lint (auto-qa).
+ *
+ * Pins the structural invariants of the Footer's NAV_LINKS array. Two
+ * concerns it catches:
+ *
+ * (a) Mis-shaped entries (missing label/href, or `external: true` on
+ * a path-relative href, which would render as a broken absolute
+ * link)
+ * (b) Documentation link target — currently the unstable external
+ * `https://docs.futarchy.fi`. PR #41 (open) replaces this with
+ * in-app `/documents` (alias `/docs`). The test accepts EITHER
+ * so it stays green across the transition, and pins which states
+ * are valid so a typo or accidental third value (`/doc`,
+ * `https://github.com/...`) surfaces immediately.
+ *
+ * Static-grep style — no import, since the Footer is a Next.js JSX file
+ * and node:test's strict ESM doesn't resolve extension-less relative
+ * imports the way Next does.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const FOOTER_PATH = new URL('../../src/components/common/Footer.jsx', import.meta.url);
+
+function parseNavLinks() {
+ const src = readFileSync(FOOTER_PATH, 'utf8');
+ const m = src.match(/const NAV_LINKS = \[([\s\S]*?)\];/);
+ assert.ok(m, 'NAV_LINKS array not found in Footer.jsx — has the file been refactored?');
+
+ // Pull each `{ ... }` object literal out of the array body. Avoids
+ // pulling in a JS parser dep — the array shape is small and stable.
+ const entries = [];
+ const re = /\{\s*([^{}]+?)\s*\}/g;
+ let item;
+ while ((item = re.exec(m[1])) !== null) {
+ const obj = {};
+ // Match key: value pairs. Handles single-quoted strings and bare
+ // booleans.
+ const pairRe = /(\w+)\s*:\s*('([^']*)'|true|false)/g;
+ let p;
+ while ((p = pairRe.exec(item[1])) !== null) {
+ obj[p[1]] = p[3] !== undefined ? p[3]
+ : p[2] === 'true' ? true
+ : p[2] === 'false' ? false : p[2];
+ }
+ entries.push(obj);
+ }
+ return entries;
+}
+
+const NAV_LINKS = parseNavLinks();
+
+test('Footer parser — extracts at least one NAV_LINKS entry', () => {
+ assert.ok(NAV_LINKS.length > 0,
+ 'NAV_LINKS parsed as empty — the regex extractor is broken or the array is empty.');
+});
+
+test('Footer — every NAV_LINKS entry has a label and an href', () => {
+ for (const e of NAV_LINKS) {
+ assert.ok(typeof e.label === 'string' && e.label.length > 0,
+ `entry missing label: ${JSON.stringify(e)}`);
+ assert.ok(typeof e.href === 'string' && e.href.length > 0,
+ `entry missing href: ${JSON.stringify(e)}`);
+ }
+});
+
+test('Footer — `external: true` entries have an absolute http(s) href', () => {
+ for (const e of NAV_LINKS) {
+ if (e.external === true) {
+ assert.ok(/^https?:\/\//.test(e.href),
+ `entry "${e.label}" is marked external but href is not absolute: ${e.href}`);
+ }
+ }
+});
+
+test('Footer — non-external entries have a path-relative href', () => {
+ for (const e of NAV_LINKS) {
+ if (e.external !== true) {
+ assert.ok(e.href.startsWith('/'),
+ `entry "${e.label}" is not marked external but href is not path-relative: ${e.href}. ` +
+ `If the link is external, set external: true.`);
+ }
+ }
+});
+
+test('PR #41 — Documentation link target is one of the accepted values', () => {
+ // Accepted set spans the pre- and post-PR-#41 worlds. If the link
+ // gets pointed at a third value (typo, accidental redirect to a
+ // GitHub URL, etc.) this test surfaces it.
+ const ACCEPTED_DOCS_HREFS = new Set([
+ 'https://docs.futarchy.fi', // pre-PR-#41 (current)
+ '/documents', // post-PR-#41
+ '/docs', // post-PR-#41 alias
+ ]);
+ const docs = NAV_LINKS.find(e => e.label === 'Documentation');
+ assert.ok(docs, 'no NAV_LINKS entry with label "Documentation"');
+ assert.ok(ACCEPTED_DOCS_HREFS.has(docs.href),
+ `Documentation href "${docs.href}" is not in the accepted set ` +
+ `[${[...ACCEPTED_DOCS_HREFS].join(', ')}]. ` +
+ `Either fix the link or add the new value to ACCEPTED_DOCS_HREFS in this test.`);
+});
+
+test('PR #41 — Status link is the canonical status URL', () => {
+ // Adjacent invariant — the same NAV_LINKS array also holds Status,
+ // which has historically been a deployment regression target. Pin it.
+ const status = NAV_LINKS.find(e => e.label === 'Status');
+ assert.ok(status, 'no NAV_LINKS entry with label "Status"');
+ assert.equal(status.href, 'https://status.futarchy.fi',
+ `Status link drifted from canonical URL; got "${status.href}"`);
+ assert.equal(status.external, true,
+ `Status link must be marked external: true`);
+});
diff --git a/auto-qa/tests/format-number.test.mjs b/auto-qa/tests/format-number.test.mjs
new file mode 100644
index 0000000..bfccb2f
--- /dev/null
+++ b/auto-qa/tests/format-number.test.mjs
@@ -0,0 +1,135 @@
+/**
+ * formatNumber utility tests (auto-qa).
+ *
+ * Pins the three functions in src/utils/formatNumber.js. They format
+ * counts and percentages on the Companies card (Snapshot-style display).
+ * Not tied to a single PR, but defensive against subtle drift in the
+ * suffix rules — a regression that bumps "999" to "0.999k" or strips
+ * the % sign would slip past code review and only surface as an
+ * unreadable card.
+ *
+ * Spec mirrors src/utils/formatNumber.js. If that file is refactored,
+ * sync the helpers below.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+// --- spec-mirror of src/utils/formatNumber.js ---
+
+function formatSnapshotNumber(num, decimals = 1) {
+ if (num >= 1000000) return (num / 1000000).toFixed(decimals) + 'M';
+ if (num >= 1000) return (num / 1000).toFixed(decimals) + 'k';
+ if (Number.isInteger(num)) return num.toString();
+ return num.toFixed(decimals);
+}
+
+function formatSnapshotPercentage(percentage) {
+ return percentage.toFixed(2) + '%';
+}
+
+function formatCount(count) {
+ if (count >= 1000) return formatSnapshotNumber(count, 1);
+ if (count < 1) return count.toFixed(3);
+ if (count < 100) return count.toFixed(2);
+ return count.toFixed(1);
+}
+
+// ---------------------------------------------------------------------------
+// formatSnapshotNumber — suffix selection rules
+// ---------------------------------------------------------------------------
+
+test('formatSnapshotNumber — exact 1_000_000 uses M suffix', () => {
+ assert.equal(formatSnapshotNumber(1_000_000), '1.0M');
+});
+
+test('formatSnapshotNumber — exact 1000 uses k suffix', () => {
+ assert.equal(formatSnapshotNumber(1000), '1.0k');
+});
+
+test('formatSnapshotNumber — 999 stays as raw integer (no suffix)', () => {
+ // Boundary catch: a regression that flipped >= to > would yield "0.999k".
+ assert.equal(formatSnapshotNumber(999), '999');
+});
+
+test('formatSnapshotNumber — 999_999 falls in the k range, not M', () => {
+ assert.equal(formatSnapshotNumber(999_999), '1000.0k');
+});
+
+test('formatSnapshotNumber — integer < 1000 returns plain string with no decimals', () => {
+ assert.equal(formatSnapshotNumber(0), '0');
+ assert.equal(formatSnapshotNumber(1), '1');
+ assert.equal(formatSnapshotNumber(42), '42');
+});
+
+test('formatSnapshotNumber — non-integer < 1000 uses default decimals=1', () => {
+ assert.equal(formatSnapshotNumber(0.5), '0.5');
+ assert.equal(formatSnapshotNumber(3.14), '3.1');
+});
+
+test('formatSnapshotNumber — decimals param overrides default precision', () => {
+ assert.equal(formatSnapshotNumber(1234, 0), '1k');
+ assert.equal(formatSnapshotNumber(1234, 2), '1.23k');
+ assert.equal(formatSnapshotNumber(2_500_000, 2), '2.50M');
+});
+
+// ---------------------------------------------------------------------------
+// formatSnapshotPercentage — fixed 2-decimal % format
+// ---------------------------------------------------------------------------
+
+test('formatSnapshotPercentage — always 2 decimals + % sign', () => {
+ assert.equal(formatSnapshotPercentage(0), '0.00%');
+ assert.equal(formatSnapshotPercentage(50), '50.00%');
+ assert.equal(formatSnapshotPercentage(100), '100.00%');
+ assert.equal(formatSnapshotPercentage(33.33333), '33.33%');
+});
+
+test('formatSnapshotPercentage — preserves negative sign', () => {
+ assert.equal(formatSnapshotPercentage(-12.5), '-12.50%');
+});
+
+test('formatSnapshotPercentage — rounds half-to-even per JS toFixed', () => {
+ // Pin actual JS behavior so a future re-implementation that switches
+ // rounding mode (e.g. to Math.round-based) surfaces immediately.
+ assert.equal(formatSnapshotPercentage(0.005), '0.01%'); // 0.005 -> "0.01" in V8
+});
+
+// ---------------------------------------------------------------------------
+// formatCount — three-zone variable precision (>=1000, <1, in-between)
+// ---------------------------------------------------------------------------
+
+test('formatCount — count >= 1000 delegates to formatSnapshotNumber', () => {
+ assert.equal(formatCount(1500), '1.5k');
+ assert.equal(formatCount(2_000_000), '2.0M');
+});
+
+test('formatCount — count < 1 uses 3 decimals', () => {
+ assert.equal(formatCount(0), '0.000');
+ assert.equal(formatCount(0.1), '0.100');
+ assert.equal(formatCount(0.123456), '0.123');
+});
+
+test('formatCount — count in [1, 100) uses 2 decimals', () => {
+ assert.equal(formatCount(1), '1.00');
+ assert.equal(formatCount(42.567), '42.57');
+ assert.equal(formatCount(99.999), '100.00'); // toFixed rounds — pin this
+});
+
+test('formatCount — count in [100, 1000) uses 1 decimal', () => {
+ assert.equal(formatCount(100), '100.0');
+ assert.equal(formatCount(999.99), '1000.0'); // toFixed rounds even at boundary
+ assert.equal(formatCount(500.55), '500.6');
+});
+
+// ---------------------------------------------------------------------------
+// Boundary continuity — adjacent zones must produce sane transitions
+// ---------------------------------------------------------------------------
+
+test('formatCount — 999.4 displays in the [100,1000) zone, not k zone', () => {
+ // 999.4 < 1000, so .toFixed(1) → "999.4" (not "999.4k")
+ assert.equal(formatCount(999.4), '999.4');
+});
+
+test('formatCount — 1000 crosses into k zone (suffix appears)', () => {
+ assert.equal(formatCount(1000), '1.0k');
+});
diff --git a/auto-qa/tests/futarchy-quote-helper.test.mjs b/auto-qa/tests/futarchy-quote-helper.test.mjs
new file mode 100644
index 0000000..e70d28d
--- /dev/null
+++ b/auto-qa/tests/futarchy-quote-helper.test.mjs
@@ -0,0 +1,337 @@
+/**
+ * FutarchyQuoteHelper spec mirror (auto-qa).
+ *
+ * Pins src/utils/FutarchyQuoteHelper.js — the swap-quote helper that
+ * wraps the on-chain FutarchyArbitrageHelper contract via callStatic
+ * (eth_call simulation, no signature). Powers swap-modal price quotes
+ * everywhere in the UI.
+ *
+ * Five concerns:
+ *
+ * 1. HELPER_ADDRESS — pinned canonical Gnosis address. A typo
+ * silently routes every quote to a non-existent / wrong contract.
+ * 2. HELPER_ABI — exact tuple shape (6 fields). Drift in ordering
+ * or types means callStatic returns garbage that this code then
+ * "interprets" — silent quote corruption.
+ * 3. calculatePriceFromSqrt — Algebra V3 sqrtPriceX96 → price math,
+ * duplicated from utils/algebraQuoter.js style. Cross-checked
+ * against the canonical sqrtPriceX96ToPrice in sqrt-price-x96.test.mjs.
+ * 4. Slippage math — `slippageBps = round(slippage * 10000)`,
+ * `minReceive = out * (10000 - bps) / 10000`. Same algorithm as
+ * the canonical getMinReceive (slippage-math.test.mjs). Cross-pin
+ * so a divergent change here is visible.
+ * 5. Gas limit override (12_000_000) — pinned safety margin below
+ * Gnosis block gas limit (~17M). A regression to a higher value
+ * would cause public RPCs to reject the eth_call.
+ *
+ * The async getSwapQuote() function itself (network-bound) is NOT
+ * unit-tested — only its source-text branches.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const SRC = readFileSync(
+ new URL('../../src/utils/FutarchyQuoteHelper.js', import.meta.url),
+ 'utf8',
+);
+
+// --- spec mirror of calculatePriceFromSqrt (pure number math) ---
+function calculatePriceFromSqrt(sqrtPriceX96Str) {
+ const curr = Number(sqrtPriceX96Str) / (2 ** 96);
+ return curr * curr;
+}
+
+// --- spec mirror of slippage math ---
+function slippageMath(amountOutBigStr, slippagePercentage) {
+ // BigInt-safe mirror; the source uses ethers BigNumber but the math
+ // is integer / 10000.
+ const amountOut = BigInt(amountOutBigStr);
+ const slippageBps = Math.round(slippagePercentage * 10000);
+ const minReceive = (amountOut * BigInt(10000 - slippageBps)) / 10000n;
+ return { slippageBps, minReceive };
+}
+
+// ---------------------------------------------------------------------------
+// HELPER_ADDRESS — canonical Gnosis FutarchyArbitrageHelper
+// ---------------------------------------------------------------------------
+
+test('HELPER_ADDRESS — pinned to the canonical 0xe32bfb3 verified Gnosis contract', () => {
+ const m = SRC.match(/HELPER_ADDRESS\s*=\s*['"]([^'"]+)['"]/);
+ assert.ok(m, 'HELPER_ADDRESS not found');
+ assert.equal(m[1], '0xe32bfb3DD8bA4c7F82dADc4982c04Afa90027EFb',
+ `HELPER_ADDRESS drifted from canonical Gnosis FutarchyArbitrageHelper. ` +
+ `Every swap quote in the UI calls this contract — drift = wrong contract = wrong quotes.`);
+});
+
+test('HELPER_ADDRESS — valid 0x + 40 hex chars (EVM address shape)', () => {
+ const m = SRC.match(/HELPER_ADDRESS\s*=\s*['"]([^'"]+)['"]/);
+ assert.match(m[1], /^0x[0-9a-fA-F]{40}$/,
+ `HELPER_ADDRESS shape invalid`);
+});
+
+test('HELPER_ADDRESS — preserves EIP-55 mixed-case checksum form', () => {
+ // Pinned: keeping the checksummed form (lowercase + uppercase mix)
+ // means a future programmatic check (ethers.utils.getAddress) won't
+ // throw. A regression to all-lowercase would still validate but
+ // loses the visual hint about checksum integrity.
+ const m = SRC.match(/HELPER_ADDRESS\s*=\s*['"]([^'"]+)['"]/);
+ const addr = m[1];
+ assert.notEqual(addr, addr.toLowerCase(),
+ `HELPER_ADDRESS must preserve EIP-55 checksum case (mixed case)`);
+});
+
+// ---------------------------------------------------------------------------
+// HELPER_ABI — exact tuple shape
+// ---------------------------------------------------------------------------
+
+test('HELPER_ABI — declares simulateQuote with EXACT param signature', () => {
+ // Pinned: (address proposal, bool isYesPool, uint8 inputType, uint256 amountIn).
+ // Drift in arg order or types changes the call ABI and either
+ // silently mis-decodes data or reverts. callStatic surfaces neither
+ // cleanly — it's a tough class of bug.
+ assert.match(SRC,
+ /function simulateQuote\(address proposal, bool isYesPool, uint8 inputType, uint256 amountIn\)/,
+ `simulateQuote arg signature drift`);
+});
+
+test('HELPER_ABI — return tuple has exactly 6 fields in canonical order', () => {
+ // Pinned: (int256 amount0Delta, int256 amount1Delta, uint160 startSqrtPrice,
+ // uint160 endSqrtPrice, bytes debugReason, bool isToken0Outcome).
+ // The handler reads result.amount0Delta, .amount1Delta, .startSqrtPrice,
+ // .endSqrtPrice, .isToken0Outcome — all six fields are wired in.
+ assert.match(SRC,
+ /tuple\(int256 amount0Delta, int256 amount1Delta, uint160 startSqrtPrice, uint160 endSqrtPrice, bytes debugReason, bool isToken0Outcome\)/,
+ `simulateQuote return tuple shape drift`);
+});
+
+// ---------------------------------------------------------------------------
+// Empty / invalid amount → null
+// ---------------------------------------------------------------------------
+
+test('source — getSwapQuote returns null for empty/NaN/<=0 amount', () => {
+ // Pinned: the guard `if (!amount || isNaN(parseFloat(amount)) ||
+ // parseFloat(amount) <= 0) return null;` short-circuits BEFORE the
+ // network call. A regression that drops any branch wastes an
+ // eth_call and surfaces a wrong-shaped rejection to the UI.
+ assert.match(SRC,
+ /if\s*\(\s*!amount\s*\|\|\s*isNaN\(parseFloat\(amount\)\)\s*\|\|\s*parseFloat\(amount\)\s*<=\s*0\s*\)\s*\{\s*return null\s*;?\s*\}/,
+ `empty/invalid-amount guard shape drifted`);
+});
+
+// ---------------------------------------------------------------------------
+// inputType encoding — company → 0, currency → 1
+// ---------------------------------------------------------------------------
+
+test('source — inputType: isInputCompanyToken ? 0 : 1 (company=0, currency=1)', () => {
+ // Pinned: the contract enum encoding. Flipping these silently
+ // routes EVERY swap to the OTHER token side — buy becomes sell,
+ // sell becomes buy. Catastrophic and hard to detect from outside.
+ assert.match(SRC,
+ /inputType\s*=\s*isInputCompanyToken\s*\?\s*0\s*:\s*1/,
+ `inputType encoding drifted from "company=0, currency=1"`);
+});
+
+// ---------------------------------------------------------------------------
+// Gas limit override — 12M (safe under Gnosis 17M block limit)
+// ---------------------------------------------------------------------------
+
+test('source — gasLimit override pinned at 12_000_000 (safe under Gnosis ~17M block limit)', () => {
+ // Pinned: the comment in the source explains this — public RPCs
+ // reject eth_call with gas above the block limit. 12M leaves room.
+ // A regression to 20M would silently break quotes on public RPCs
+ // (works on local fork, fails in prod).
+ assert.match(SRC,
+ /gasLimit:\s*12_000_000/,
+ `gasLimit drifted from 12_000_000 — too high → public RPC rejects ` +
+ `("Block gas limit exceeded"); too low → simulation runs out of gas.`);
+});
+
+// ---------------------------------------------------------------------------
+// calculatePriceFromSqrt — spec mirror + cross-pin
+// ---------------------------------------------------------------------------
+
+test('calcPriceFromSqrt — square root invariant: price = (sqrt / 2^96)^2', () => {
+ // Pinned the formula. A regression to /2^192 (full Q-format) would
+ // be off by a factor of 2^96 — silent.
+ const sqrt = 2n ** 96n; // sqrt = 1 in raw price terms
+ const price = calculatePriceFromSqrt(sqrt.toString());
+ // price = (2^96 / 2^96)^2 = 1
+ assert.equal(price, 1);
+});
+
+test('calcPriceFromSqrt — sqrt = 2 * 2^96 → price = 4', () => {
+ const sqrt = 2n * (2n ** 96n);
+ const price = calculatePriceFromSqrt(sqrt.toString());
+ assert.equal(price, 4);
+});
+
+test('calcPriceFromSqrt — output is non-negative for any input (square invariant)', () => {
+ // Probe a few sample sqrt values; price = (x/2^96)^2 ≥ 0 always.
+ for (const exp of [0, 48, 96, 144, 192]) {
+ const sqrt = 2n ** BigInt(exp);
+ const price = calculatePriceFromSqrt(sqrt.toString());
+ assert.ok(price >= 0, `price for sqrt=2^${exp} was negative: ${price}`);
+ }
+});
+
+test('calcPriceFromSqrt — monotonic in sqrtPrice (larger sqrt → larger price)', () => {
+ const a = calculatePriceFromSqrt(((1n << 96n)).toString());
+ const b = calculatePriceFromSqrt(((2n << 96n)).toString());
+ const c = calculatePriceFromSqrt(((10n << 96n)).toString());
+ assert.ok(a < b && b < c, `monotonicity broken: a=${a}, b=${b}, c=${c}`);
+});
+
+test('calcPriceFromSqrt — source uses Number() not BigInt for the math (precision tradeoff pinned)', () => {
+ // Pinned: the source comment acknowledges JS Number is double-precision
+ // (15-17 digits). For prices in normal ranges this is enough; for
+ // extreme prices this is a known precision tradeoff. A regression
+ // to BigInt-only would lose the fractional portion entirely.
+ assert.match(SRC,
+ /Number\(sqrtPriceStr\)\s*\/\s*\(\s*2\s*\*\*\s*96\s*\)/,
+ `calculatePriceFromSqrt formula drifted — must be Number(sqrt) / (2**96)`);
+});
+
+// ---------------------------------------------------------------------------
+// Slippage math — same algorithm as canonical getMinReceive (cross-pin)
+// ---------------------------------------------------------------------------
+
+test('slippage — slippageBps = round(slippagePercentage * 10000)', () => {
+ // 3% → 300 bps. Pinned as the universal slippage encoding in this
+ // codebase (matches slippage-math.test.mjs canonical mirror).
+ assert.equal(slippageMath('1000', 0.03).slippageBps, 300);
+ assert.equal(slippageMath('1000', 0.005).slippageBps, 50);
+});
+
+test('slippage — slippageBps rounds (e.g. 0.0001 → 1, 0.00005 → 1, 0.00004 → 0)', () => {
+ // Pinned because Math.round(0.5) = 1 (banker's rounding behavior).
+ // 0.00004 * 10000 = 0.4 → 0; 0.00005 * 10000 = 0.5 → 1.
+ assert.equal(slippageMath('1', 0.00005).slippageBps, 1);
+ assert.equal(slippageMath('1', 0.00004).slippageBps, 0);
+});
+
+test('slippage — minReceive = amountOut * (10000 - bps) / 10000 (BigInt-safe)', () => {
+ // Probed amount: 1e18 (1 ether scale); 3% slippage → 0.97 * 1e18.
+ const r = slippageMath((10n ** 18n).toString(), 0.03);
+ const expected = (10n ** 18n) * 9700n / 10000n;
+ assert.equal(r.minReceive, expected);
+});
+
+test('slippage — zero slippage returns amountOut unchanged', () => {
+ const out = '12345678901234567890';
+ const r = slippageMath(out, 0);
+ assert.equal(r.minReceive, BigInt(out),
+ `zero slippage must produce identity (got ${r.minReceive})`);
+});
+
+test('slippage — minReceive is non-increasing as slippage grows', () => {
+ const out = '1000000000000000000';
+ let prev = BigInt(out) + 1n; // sentinel
+ for (const slip of [0, 0.001, 0.01, 0.05, 0.1]) {
+ const r = slippageMath(out, slip);
+ assert.ok(r.minReceive < prev,
+ `monotonicity broken: slip=${slip} produced minReceive=${r.minReceive} ≥ prev=${prev}`);
+ prev = r.minReceive;
+ }
+});
+
+test('source — slippage math expression matches canonical getMinReceive shape', () => {
+ // Cross-pin: slippage-math.test.mjs covers the canonical helper.
+ // This file inlines the same formula. Drift here would diverge
+ // from the canonical UI math — silent quote-vs-execution mismatch.
+ assert.match(SRC,
+ /slippageBps\s*=\s*Math\.round\(slippagePercentage\s*\*\s*10000\)/,
+ `inline slippageBps formula drifted from canonical`);
+ assert.match(SRC,
+ /\.mul\(10000\s*-\s*slippageBps\)\.div\(10000\)/,
+ `inline minReceive formula drifted from canonical (out * (10000-bps) / 10000)`);
+});
+
+// ---------------------------------------------------------------------------
+// Inversion logic — !isToken0Outcome → invert price (1/p)
+// ---------------------------------------------------------------------------
+
+test('source — inverts BOTH currentPoolPrice AND priceAfterNum when !isToken0Outcome', () => {
+ // Pinned: a regression that inverts only one side would display
+ // the start/end prices in different units — silent UX corruption.
+ assert.match(SRC,
+ /currentPoolPrice\s*=\s*\(currentPoolPrice\s*>\s*0\)\s*\?\s*1\s*\/\s*currentPoolPrice\s*:\s*0/,
+ `currentPoolPrice inversion shape drifted`);
+ assert.match(SRC,
+ /priceAfterNum\s*=\s*\(priceAfterNum\s*>\s*0\)\s*\?\s*1\s*\/\s*priceAfterNum\s*:\s*0/,
+ `priceAfterNum inversion shape drifted`);
+});
+
+test('source — division-by-zero guard: invert only when current/price > 0', () => {
+ // Pinned because 1/0 = Infinity in JS, not throw. A regression
+ // that drops the guard returns Infinity to UI rendering code.
+ // Source uses `(x > 0) ? 1/x : 0`.
+ const inversionGuards = [...SRC.matchAll(/\(\s*\w+(?:PoolPrice|AfterNum)\s*>\s*0\s*\)\s*\?\s*1\s*\/\s*\w+/g)];
+ assert.ok(inversionGuards.length >= 2,
+ `expected >=2 div-by-zero guards in inversion path; got ${inversionGuards.length}`);
+});
+
+// ---------------------------------------------------------------------------
+// Output match logic — find the side that is NOT the input
+// ---------------------------------------------------------------------------
+
+test('source — amountOut detection: matches input side, returns the OTHER', () => {
+ // Pinned: the contract returns two deltas (one positive, one
+ // negative). The input matches one. amountOut = the other.
+ // A regression that reverses this returns the input as output —
+ // catastrophic display bug.
+ assert.match(SRC,
+ /if\s*\(absD0\.eq\(amountBig\)\)\s*\{\s*amountOutBig\s*=\s*absD1[\s\S]*?\}\s*else\s*\{\s*amountOutBig\s*=\s*absD0/,
+ `amountOut detection logic drifted — must be: if absD0==input then amountOut=absD1 else amountOut=absD0`);
+});
+
+test('source — uses callStatic (eth_call simulation, NOT a real tx)', () => {
+ // Pinned: a regression to `await helper.simulateQuote(...)` (no
+ // callStatic) would attempt a real signed transaction — would
+ // require gas, fail without a signer, and on a signer would
+ // BURN GAS for a query.
+ assert.match(SRC,
+ /helper\.callStatic\.simulateQuote\(/,
+ `must use callStatic (NOT direct call — direct call would burn gas)`);
+});
+
+test('source — empty-/null-provider guard throws BEFORE constructing the contract', () => {
+ // Pinned: the `if (!provider) throw` runs at the top of the
+ // function. A regression that lets it through would surface as
+ // a confusing ethers error deeper in the stack.
+ assert.match(SRC,
+ /if\s*\(!provider\)\s*\{\s*throw\s+new\s+Error\(["']Provider required for getSwapQuote["']\)/,
+ `provider guard shape drifted`);
+});
+
+// ---------------------------------------------------------------------------
+// Output shape — the returned object has all expected fields
+// ---------------------------------------------------------------------------
+
+test('source — returned object includes all 9 documented fields', () => {
+ // Pinned: callers destructure these. A field rename here breaks
+ // the consumer silently (undefined vs missing key).
+ const fields = [
+ 'expectedReceive', 'minReceive', 'slippagePct',
+ 'currentPoolPrice', 'priceAfter', 'executionPrice',
+ 'startSqrtPrice', 'endSqrtPrice', 'isInverted',
+ ];
+ for (const f of fields) {
+ // Each field must appear as a key in the returned object literal.
+ assert.match(SRC, new RegExp(`${f}\\s*:`),
+ `returned object missing field "${f}"`);
+ }
+});
+
+test('source — raw.amountIn / raw.amountOut are STRINGS (not BigNumbers) for JSON safety', () => {
+ // Pinned: BigNumbers don't JSON-serialize cleanly. Returning them
+ // as strings ensures callers can safely round-trip through
+ // JSON.stringify (e.g. analytics, logs).
+ assert.match(SRC,
+ /amountIn:\s*amountBig\.toString\(\)/,
+ `raw.amountIn must be .toString() (string for JSON safety)`);
+ assert.match(SRC,
+ /amountOut:\s*amountOutBig\.toString\(\)/,
+ `raw.amountOut must be .toString() (string for JSON safety)`);
+});
diff --git a/auto-qa/tests/image-utils.test.mjs b/auto-qa/tests/image-utils.test.mjs
new file mode 100644
index 0000000..ddc06cb
--- /dev/null
+++ b/auto-qa/tests/image-utils.test.mjs
@@ -0,0 +1,213 @@
+/**
+ * imageUtils tests (auto-qa).
+ *
+ * Pins src/utils/imageUtils.js — the company-logo fallback chain used
+ * across the Companies card, ResolvedEvents data transformer, and
+ * useAggregatorCompanies hook. The priority chain (image → logo →
+ * logo_url → generated avatar) is subtle enough that a refactor that
+ * reorders or short-circuits would silently degrade thousands of
+ * cards to UI-Avatar placeholders.
+ *
+ * Spec mirrors src/utils/imageUtils.js for the pure functions
+ * (getCompanyImage, generateFallbackImage, getInitials,
+ * generateColorFromId, generateColorFromString, getDefaultFallbackImage).
+ * The async DOM-dependent helpers (verifyImageUrl, getVerifiedCompanyImage,
+ * preloadImage) are skipped — they need a browser-like Image global.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+// --- spec-mirror of src/utils/imageUtils.js (pure functions only) ---
+
+function getInitials(name) {
+ if (!name || typeof name !== 'string') return 'CO';
+ return name.split(' ').filter(w => w.length > 0).map(w => w[0].toUpperCase()).join('').slice(0, 2);
+}
+
+function generateColorFromId(id) {
+ const colors = ['4F46E5','7C3AED','DB2777','DC2626','059669','2563EB','EA580C','16A34A','9333EA','CA8A04'];
+ const numericId = typeof id === 'string' ? parseInt(id) || 0 : id;
+ return colors[numericId % colors.length];
+}
+
+function generateColorFromString(str) {
+ if (!str || typeof str !== 'string') return '6B7280';
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ hash = hash & hash;
+ }
+ return Math.abs(hash).toString(16).slice(0, 6).padStart(6, '0');
+}
+
+function generateFallbackImage(name, id) {
+ const initials = getInitials(name);
+ const bgColor = generateColorFromId(id);
+ return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&size=200&background=${bgColor}&color=fff&bold=true&rounded=true`;
+}
+
+function getDefaultFallbackImage() {
+ return 'https://ui-avatars.com/api/?name=CO&size=200&background=6B7280&color=fff&bold=true&rounded=true';
+}
+
+function getCompanyImage(companyData) {
+ if (!companyData) return getDefaultFallbackImage();
+ if (companyData.image && companyData.image.trim() !== '') return companyData.image;
+ if (companyData.logo && companyData.logo.trim() !== '') return companyData.logo;
+ if (companyData.logo_url && companyData.logo_url.trim() !== '') return companyData.logo_url;
+ return generateFallbackImage(companyData.name || 'Unknown', companyData.id || 0);
+}
+
+// ---------------------------------------------------------------------------
+// getCompanyImage — priority chain
+// ---------------------------------------------------------------------------
+
+test('getCompanyImage — null/undefined returns default UI-Avatars URL', () => {
+ assert.equal(getCompanyImage(null), getDefaultFallbackImage());
+ assert.equal(getCompanyImage(undefined), getDefaultFallbackImage());
+});
+
+test('getCompanyImage — image field wins over logo and logo_url', () => {
+ const got = getCompanyImage({
+ image: 'https://example.com/img.png',
+ logo: 'https://example.com/logo.png',
+ logo_url: 'https://example.com/logo_url.png',
+ });
+ assert.equal(got, 'https://example.com/img.png');
+});
+
+test('getCompanyImage — logo wins over logo_url when image is absent', () => {
+ const got = getCompanyImage({
+ logo: 'https://example.com/logo.png',
+ logo_url: 'https://example.com/logo_url.png',
+ });
+ assert.equal(got, 'https://example.com/logo.png');
+});
+
+test('getCompanyImage — logo_url is the last URL fallback before generated avatar', () => {
+ const got = getCompanyImage({ logo_url: 'https://example.com/lu.png' });
+ assert.equal(got, 'https://example.com/lu.png');
+});
+
+test('getCompanyImage — empty/whitespace strings DO fall through to next fallback', () => {
+ // Subtle: empty string and " " count as missing per the .trim() check.
+ // A future refactor that drops the trim would silently cause "" or " " to
+ // be returned as the image src, breaking the .
+ const got = getCompanyImage({
+ image: ' ',
+ logo: '',
+ logo_url: 'https://example.com/lu.png',
+ });
+ assert.equal(got, 'https://example.com/lu.png');
+});
+
+test('getCompanyImage — falls through to generateFallbackImage when all URLs missing', () => {
+ const got = getCompanyImage({ name: 'Acme Corp', id: 5 });
+ assert.match(got, /^https:\/\/ui-avatars\.com\/api\//,
+ `expected UI-Avatars URL; got "${got}"`);
+ assert.match(got, /name=AC/, `initials must be "AC" for "Acme Corp"`);
+});
+
+test('getCompanyImage — missing name uses "Unknown" → initials "U"', () => {
+ const got = getCompanyImage({ id: 1 });
+ assert.match(got, /name=U/, `expected name=U from "Unknown"; got "${got}"`);
+});
+
+// ---------------------------------------------------------------------------
+// getInitials
+// ---------------------------------------------------------------------------
+
+test('getInitials — empty/null returns "CO"', () => {
+ assert.equal(getInitials(null), 'CO');
+ assert.equal(getInitials(undefined), 'CO');
+ assert.equal(getInitials(''), 'CO');
+ assert.equal(getInitials(123), 'CO');
+});
+
+test('getInitials — single word: first letter only', () => {
+ assert.equal(getInitials('Acme'), 'A');
+ assert.equal(getInitials('gnosis'), 'G');
+});
+
+test('getInitials — multi-word: first letter of first two words', () => {
+ assert.equal(getInitials('Acme Corp'), 'AC');
+ assert.equal(getInitials('Curve Decentralized Exchange'), 'CD',
+ 'must take only first 2 letters; ignores third word');
+});
+
+test('getInitials — collapses extra spaces (filters empty splits)', () => {
+ assert.equal(getInitials('Acme Corp'), 'AC',
+ 'multi-space gap should not introduce empty initials');
+ assert.equal(getInitials(' Acme '), 'A',
+ 'leading/trailing space should not count as words');
+});
+
+// ---------------------------------------------------------------------------
+// generateColorFromId — palette modulo
+// ---------------------------------------------------------------------------
+
+test('generateColorFromId — id=0 returns first color', () => {
+ assert.equal(generateColorFromId(0), '4F46E5');
+});
+
+test('generateColorFromId — wraps around palette at id=10', () => {
+ // 10 colors; id 10 → idx 0
+ assert.equal(generateColorFromId(10), generateColorFromId(0));
+ assert.equal(generateColorFromId(11), generateColorFromId(1));
+});
+
+test('generateColorFromId — string id parsed to integer', () => {
+ assert.equal(generateColorFromId('5'), generateColorFromId(5));
+ // Non-numeric string → parseInt returns NaN → || 0 → color[0]
+ assert.equal(generateColorFromId('not-a-number'), generateColorFromId(0));
+});
+
+// ---------------------------------------------------------------------------
+// generateColorFromString — deterministic hash
+// ---------------------------------------------------------------------------
+
+test('generateColorFromString — same input → same color (deterministic)', () => {
+ assert.equal(generateColorFromString('Acme'), generateColorFromString('Acme'));
+});
+
+test('generateColorFromString — different input → typically different color', () => {
+ assert.notEqual(generateColorFromString('Acme'), generateColorFromString('Globex'),
+ 'collisions exist but two distinct simple names should not collide');
+});
+
+test('generateColorFromString — empty/non-string returns gray-500 default', () => {
+ assert.equal(generateColorFromString(''), '6B7280');
+ assert.equal(generateColorFromString(null), '6B7280');
+ assert.equal(generateColorFromString(123), '6B7280');
+});
+
+test('generateColorFromString — output is always a 6-char hex (zero-padded)', () => {
+ for (const s of ['a', 'A', 'Acme', '🦊', 'long-string-of-text']) {
+ const c = generateColorFromString(s);
+ assert.equal(c.length, 6, `color for "${s}" not 6 chars: "${c}"`);
+ assert.match(c, /^[0-9a-f]{6}$/, `color for "${s}" not lowercase hex: "${c}"`);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// generateFallbackImage — UI Avatars URL structure
+// ---------------------------------------------------------------------------
+
+test('generateFallbackImage — produces a valid UI Avatars URL with initials and bg color', () => {
+ const url = generateFallbackImage('Acme Corp', 3);
+ assert.match(url, /^https:\/\/ui-avatars\.com\/api\/\?/);
+ assert.match(url, /name=AC/);
+ assert.match(url, new RegExp(`background=${generateColorFromId(3)}`));
+ assert.match(url, /size=200/);
+ assert.match(url, /rounded=true/);
+});
+
+test('getDefaultFallbackImage — pinned URL', () => {
+ // Pin the exact URL so a refactor that tweaks it (e.g. switches to
+ // /assets/default-company-logo.png instead) surfaces immediately.
+ assert.equal(
+ getDefaultFallbackImage(),
+ 'https://ui-avatars.com/api/?name=CO&size=200&background=6B7280&color=fff&bold=true&rounded=true'
+ );
+});
diff --git a/auto-qa/tests/impact-formula.test.mjs b/auto-qa/tests/impact-formula.test.mjs
new file mode 100644
index 0000000..01f98ab
--- /dev/null
+++ b/auto-qa/tests/impact-formula.test.mjs
@@ -0,0 +1,201 @@
+/**
+ * Impact formula test (auto-qa).
+ *
+ * Pins PR #31: "Fix Impact showing 0% by using candle close prices".
+ *
+ * Background: the subgraph `tick` field on YES and NO CONDITIONAL pools
+ * was sometimes stale/identical (both showed -50491 on the AAVE market)
+ * even when the pools' real prices had diverged. Tick-derived prices
+ * came out equal → impact = (yes - no)/spot * 100 = 0%, the wrong
+ * answer. PR #31 swaps tick-derived prices for candle close prices so
+ * YES and NO actually differ.
+ *
+ * The impact *formula* is unchanged by PR #31 — what changed is which
+ * inputs feed into it. So the test pins the formula AND demonstrates
+ * the bug condition: when YES and NO prices are equal, impact is 0
+ * regardless of formula path; when they diverge (post-PR-#31), the
+ * formula yields the displayed impact percentage.
+ *
+ * Spec mirrors:
+ * src/components/chart/SubgraphChart.jsx:515-526 (single-point card)
+ * src/components/chart/TripleChart.jsx:120-131 (chart series)
+ *
+ * If those line ranges change, this test may need a synchronized update.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+/**
+ * Mirror of SubgraphChart.jsx:515-526. Computes the Impact % shown on
+ * the SubgraphChart card. Two paths:
+ * 1. Spot price available → (yes - no)/spot * 100 (preferred)
+ * 2. No spot → (yes - no)/max(yes, no) * 100 (fallback)
+ *
+ * @param {number|null} yesPrice
+ * @param {number|null} noPrice
+ * @param {number|null} spotPrice
+ * @param {boolean} showSpot
+ * @returns {number} impact percentage
+ */
+function impactPercent(yesPrice, noPrice, spotPrice, showSpot) {
+ if (yesPrice === null || noPrice === null) return 0;
+ if (showSpot && spotPrice && spotPrice > 0) {
+ return ((yesPrice - noPrice) / spotPrice) * 100;
+ }
+ const denominator = Math.max(yesPrice, noPrice);
+ return denominator > 0 ? ((yesPrice - noPrice) / denominator) * 100 : 0;
+}
+
+/**
+ * Mirror of TripleChart.jsx:120-131 — the per-timestamp impact series
+ * used by the chart line. Always uses the spot-based formula; skips any
+ * timestamp where any of the three values is missing or spot <= 0.
+ */
+function impactSeries(yesData, noData, spotData) {
+ const map = new Map();
+ yesData.forEach(d => { if (!map.has(d.time)) map.set(d.time, {}); map.get(d.time).yes = d.value; });
+ noData.forEach(d => { if (!map.has(d.time)) map.set(d.time, {}); map.get(d.time).no = d.value; });
+ spotData.forEach(d => { if (!map.has(d.time)) map.set(d.time, {}); map.get(d.time).spot = d.value; });
+
+ const out = [];
+ map.forEach((v, time) => {
+ if (v.yes !== undefined && v.no !== undefined && v.spot !== undefined && v.spot > 0) {
+ out.push({ time, value: ((v.yes - v.no) / v.spot) * 100 });
+ }
+ });
+ out.sort((a, b) => a.time - b.time);
+ return out;
+}
+
+const EPS = 1e-9;
+const close = (a, b) => Math.abs(a - b) < EPS;
+
+// ---------------------------------------------------------------------------
+// PR #31 — bug condition: identical YES & NO prices → 0% impact
+// ---------------------------------------------------------------------------
+
+test('PR #31 — bug repro: equal YES/NO prices yield 0% impact (spot path)', () => {
+ // Pre-PR #31, both pools' tick-derived prices came back equal (subgraph
+ // staleness). The formula isn't broken — but the inputs were degenerate.
+ const yes = 120, no = 120, spot = 100;
+ assert.equal(impactPercent(yes, no, spot, true), 0,
+ 'when YES == NO the formula yields exactly 0 — explains the user-visible "Impact 0.00%" bug');
+});
+
+test('PR #31 — bug repro: equal YES/NO prices yield 0% impact (fallback path)', () => {
+ const yes = 134.45, no = 134.45;
+ assert.equal(impactPercent(yes, no, null, false), 0,
+ 'fallback path: equal prices also yield 0');
+});
+
+// ---------------------------------------------------------------------------
+// PR #31 — fixed condition: candle-close prices diverge → meaningful impact
+// ---------------------------------------------------------------------------
+
+test('PR #31 — AAVE example: divergent candle prices give ~20% impact (spot path)', () => {
+ // From the PR body: "YES: 134.45, NO: 106.68 GHO/AAVE for AAVE market"
+ // and the description says Impact should show "~20% (not 0.00%)".
+ // We don't know the exact spot price the PR author used; pick one that
+ // brackets the documented "~20%" expectation.
+ const yes = 134.45, no = 106.68;
+ const spot = 134.45; // an at-the-money spot ≈ YES gives the upper bound
+ const impact = impactPercent(yes, no, spot, true);
+ // (134.45 - 106.68)/134.45 * 100 ≈ 20.65%
+ assert.ok(impact > 19 && impact < 22,
+ `expected ~20% impact for AAVE-style spread; got ${impact.toFixed(2)}%`);
+});
+
+test('PR #31 — fallback path: divergent prices yield non-zero impact', () => {
+ // No spot available (showSpot=false or spot=0): falls back to
+ // (yes - no)/max(yes, no) * 100. With yes > no this is the same
+ // upper-bound formula.
+ const impact = impactPercent(134.45, 106.68, null, false);
+ assert.ok(close(impact, ((134.45 - 106.68) / 134.45) * 100),
+ `fallback should equal (yes-no)/max(yes,no)*100; got ${impact}`);
+});
+
+// ---------------------------------------------------------------------------
+// Sign / direction invariants
+// ---------------------------------------------------------------------------
+
+test('formula — YES > NO yields positive impact', () => {
+ const i = impactPercent(110, 100, 105, true);
+ assert.ok(i > 0, `YES > NO should be positive impact; got ${i}`);
+});
+
+test('formula — NO > YES yields negative impact', () => {
+ const i = impactPercent(100, 110, 105, true);
+ assert.ok(i < 0, `NO > YES should be negative impact; got ${i}`);
+});
+
+test('formula — symmetric: swapping YES and NO flips the sign', () => {
+ const a = impactPercent(120, 100, 110, true);
+ const b = impactPercent(100, 120, 110, true);
+ assert.ok(close(a, -b), `expected ${a} === -${b}`);
+});
+
+// ---------------------------------------------------------------------------
+// Defensive guards
+// ---------------------------------------------------------------------------
+
+test('formula — null YES or NO yields 0% (guard)', () => {
+ assert.equal(impactPercent(null, 100, 100, true), 0);
+ assert.equal(impactPercent(100, null, 100, true), 0);
+});
+
+test('formula — spot=0 falls back to denominator path (no div-by-zero)', () => {
+ // spotPrice > 0 guard sends us into the fallback branch.
+ const i = impactPercent(120, 100, 0, true);
+ // Fallback: (120-100)/max(120,100)*100 = 16.67
+ assert.ok(close(i, ((120 - 100) / 120) * 100), `got ${i}`);
+});
+
+test('formula — spot=0 with YES==NO==0 fallback yields 0 (no div-by-zero)', () => {
+ const i = impactPercent(0, 0, 0, true);
+ assert.equal(i, 0, 'denominator=0 must short-circuit to 0');
+});
+
+// ---------------------------------------------------------------------------
+// Series-builder invariants (TripleChart.jsx)
+// ---------------------------------------------------------------------------
+
+test('series — drops timestamps missing any of yes/no/spot', () => {
+ const yes = [{ time: 1, value: 110 }, { time: 2, value: 115 }, { time: 3, value: 120 }];
+ const no = [{ time: 1, value: 100 }, { time: 2, value: 102 }]; // missing t=3
+ const spot = [{ time: 1, value: 105 }, { time: 2, value: 108 }, { time: 3, value: 112 }];
+ const s = impactSeries(yes, no, spot);
+ assert.equal(s.length, 2, `expected 2 series points (t=1,2 only); got ${s.length}`);
+ assert.deepEqual(s.map(p => p.time), [1, 2]);
+});
+
+test('series — drops timestamps where spot <= 0 (guards div-by-zero)', () => {
+ const yes = [{ time: 1, value: 110 }, { time: 2, value: 115 }];
+ const no = [{ time: 1, value: 100 }, { time: 2, value: 102 }];
+ const spot = [{ time: 1, value: 0 }, { time: 2, value: 108 }]; // t=1 has spot=0
+ const s = impactSeries(yes, no, spot);
+ assert.equal(s.length, 1, `t=1 must be skipped (spot=0); got ${s.length}`);
+ assert.equal(s[0].time, 2);
+});
+
+test('series — output sorted by time ascending', () => {
+ const yes = [{ time: 3, value: 120 }, { time: 1, value: 110 }, { time: 2, value: 115 }];
+ const no = [{ time: 3, value: 102 }, { time: 1, value: 100 }, { time: 2, value: 101 }];
+ const spot = [{ time: 3, value: 112 }, { time: 1, value: 105 }, { time: 2, value: 108 }];
+ const s = impactSeries(yes, no, spot);
+ const times = s.map(p => p.time);
+ assert.deepEqual(times, [...times].sort((a, b) => a - b), 'series must be sorted by time');
+});
+
+test('series — degenerate yes==no case yields 0 at every point (PR #31 bug)', () => {
+ // Pre-PR #31: every series point had yes == no (tick collapse) → impact
+ // line was a flat 0%. This is the chart-level companion to the card-level
+ // bug repro test above.
+ const yes = [{ time: 1, value: 120 }, { time: 2, value: 121 }];
+ const no = [{ time: 1, value: 120 }, { time: 2, value: 121 }];
+ const spot = [{ time: 1, value: 100 }, { time: 2, value: 100 }];
+ const s = impactSeries(yes, no, spot);
+ assert.equal(s.length, 2);
+ assert.ok(s.every(p => p.value === 0),
+ `equal yes/no must produce a flat 0 series — pre-PR-#31 chart symptom`);
+});
diff --git a/auto-qa/tests/is-safe-wallet.test.mjs b/auto-qa/tests/is-safe-wallet.test.mjs
new file mode 100644
index 0000000..b8a9478
--- /dev/null
+++ b/auto-qa/tests/is-safe-wallet.test.mjs
@@ -0,0 +1,220 @@
+/**
+ * isSafeWallet detection spec mirror (auto-qa).
+ *
+ * Pins src/utils/ethersAdapters.js:isSafeWallet — used by
+ * AddLiquidityModal and ConfirmSwapModal to gate Safe-specific tx
+ * flows. The function has THREE independent detection paths:
+ *
+ * 1. Wagmi connector: name OR id contains "safe" OR name contains "gnosis"
+ * 2. window.ethereum: isSafe===true OR isSafeApp===true
+ * 3. document.referrer: contains "safe.global"
+ *
+ * If ANY path matches → returns true. The detection is intentionally
+ * permissive because false positives are harmless (Safe flow gracefully
+ * degrades) but false negatives skip the Safe-specific waitForSafeTxReceipt
+ * flow and the user sees a tx hash that never confirms.
+ *
+ * Spec mirrors the function. Globals (window, document) are stubbed
+ * via globalThis assignments per test (cleanup after each).
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+// --- spec mirror ---
+
+function isSafeWallet(walletClient) {
+ const connectorName = walletClient?.connector?.name?.toLowerCase() || '';
+ const connectorId = walletClient?.connector?.id?.toLowerCase() || '';
+
+ if (connectorName.includes('safe') || connectorId.includes('safe') || connectorName.includes('gnosis')) {
+ return true;
+ }
+ if (typeof window !== 'undefined' && window.ethereum) {
+ if (window.ethereum.isSafe === true) return true;
+ if (window.ethereum.isSafeApp === true) return true;
+ }
+ if (typeof document !== 'undefined' && document.referrer?.includes('safe.global')) {
+ return true;
+ }
+ return false;
+}
+
+// Test isolation helpers — set/reset globalThis.window and globalThis.document
+function withGlobals({ ethereum, referrer }, fn) {
+ const origWindow = globalThis.window;
+ const origDocument = globalThis.document;
+ try {
+ if (ethereum !== undefined) globalThis.window = { ethereum };
+ if (referrer !== undefined) globalThis.document = { referrer };
+ return fn();
+ } finally {
+ globalThis.window = origWindow;
+ globalThis.document = origDocument;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Path 1: Wagmi connector
+// ---------------------------------------------------------------------------
+
+test('isSafeWallet — connector.name contains "safe" → true', () => {
+ assert.equal(isSafeWallet({ connector: { name: 'Safe Wallet' } }), true);
+ assert.equal(isSafeWallet({ connector: { name: 'safe' } }), true);
+});
+
+test('isSafeWallet — connector.name case-insensitive (SAFE, sAfE)', () => {
+ // .toLowerCase() applied before .includes() — regression that
+ // drops the lowercase would miss connectors named e.g. "SAFE Wallet".
+ assert.equal(isSafeWallet({ connector: { name: 'SAFE Wallet' } }), true);
+ assert.equal(isSafeWallet({ connector: { name: 'sAfE' } }), true);
+});
+
+test('isSafeWallet — connector.id contains "safe" → true', () => {
+ assert.equal(isSafeWallet({ connector: { id: 'safe-connector' } }), true);
+ assert.equal(isSafeWallet({ connector: { id: 'SAFE_APPS' } }), true);
+});
+
+test('isSafeWallet — connector.name contains "gnosis" → true (Safe ↔ Gnosis Safe historic)', () => {
+ // Pinned: "gnosis" matches in name only (not id). This is because
+ // historically Safe was called "Gnosis Safe" and some connectors
+ // still use the old name.
+ assert.equal(isSafeWallet({ connector: { name: 'Gnosis Safe Wallet' } }), true);
+ assert.equal(isSafeWallet({ connector: { name: 'gnosis' } }), true);
+});
+
+test('isSafeWallet — connector.id contains "gnosis" alone does NOT trigger (name-only check)', () => {
+ // Pinned current behavior: the "gnosis" check is only on name, not id.
+ // This is asymmetric with the "safe" check. Documenting via test.
+ const r = isSafeWallet({ connector: { id: 'gnosis-chain-rpc', name: 'Some Wallet' } });
+ assert.equal(r, false,
+ `current behavior: "gnosis" check only on name, not id. ` +
+ `If we ever add it to id check too, this test pin guides the deliberate update.`);
+});
+
+// ---------------------------------------------------------------------------
+// Defensive guards
+// ---------------------------------------------------------------------------
+
+test('isSafeWallet — null/undefined walletClient does NOT throw', () => {
+ // Optional chaining defends both .connector?.name and .connector?.id.
+ assert.doesNotThrow(() => isSafeWallet(null));
+ assert.doesNotThrow(() => isSafeWallet(undefined));
+});
+
+test('isSafeWallet — walletClient with no connector → falls through to global checks', () => {
+ // No throw, falls through to window/document checks (which return false in default env).
+ const r = withGlobals({}, () => isSafeWallet({}));
+ assert.equal(r, false);
+});
+
+test('isSafeWallet — walletClient with empty connector → falls through to false', () => {
+ const r = withGlobals({}, () => isSafeWallet({ connector: {} }));
+ assert.equal(r, false);
+});
+
+// ---------------------------------------------------------------------------
+// Path 2: window.ethereum.isSafe / isSafeApp
+// ---------------------------------------------------------------------------
+
+test('isSafeWallet — window.ethereum.isSafe === true → true', () => {
+ const r = withGlobals(
+ { ethereum: { isSafe: true } },
+ () => isSafeWallet({})
+ );
+ assert.equal(r, true);
+});
+
+test('isSafeWallet — window.ethereum.isSafeApp === true → true', () => {
+ const r = withGlobals(
+ { ethereum: { isSafeApp: true } },
+ () => isSafeWallet({})
+ );
+ assert.equal(r, true);
+});
+
+test('isSafeWallet — window.ethereum.isSafe must be STRICTLY === true (truthy is not enough)', () => {
+ // Pinned: the check is `=== true`, not just truthy. A "safe" string
+ // or 1 wouldn't trigger. Defensive against frame providers that
+ // return non-boolean values.
+ const r1 = withGlobals(
+ { ethereum: { isSafe: 'true' } },
+ () => isSafeWallet({})
+ );
+ assert.equal(r1, false, `string "true" must NOT match strict === true`);
+ const r2 = withGlobals(
+ { ethereum: { isSafe: 1 } },
+ () => isSafeWallet({})
+ );
+ assert.equal(r2, false, `number 1 must NOT match strict === true`);
+});
+
+test('isSafeWallet — window.ethereum without isSafe/isSafeApp → false (in absence of other paths)', () => {
+ const r = withGlobals(
+ { ethereum: { isMetaMask: true } },
+ () => isSafeWallet({})
+ );
+ assert.equal(r, false);
+});
+
+// ---------------------------------------------------------------------------
+// Path 3: document.referrer
+// ---------------------------------------------------------------------------
+
+test('isSafeWallet — document.referrer contains "safe.global" → true', () => {
+ const r = withGlobals(
+ { referrer: 'https://app.safe.global/foo' },
+ () => isSafeWallet({})
+ );
+ assert.equal(r, true);
+});
+
+test('isSafeWallet — document.referrer is empty → false (no path 3 match)', () => {
+ const r = withGlobals(
+ { referrer: '' },
+ () => isSafeWallet({})
+ );
+ assert.equal(r, false);
+});
+
+test('isSafeWallet — document.referrer is unrelated → false', () => {
+ const r = withGlobals(
+ { referrer: 'https://twitter.com/somewhere' },
+ () => isSafeWallet({})
+ );
+ assert.equal(r, false);
+});
+
+test('isSafeWallet — document undefined does NOT throw (typeof guard)', () => {
+ // Path 3 only fires if document exists. SSR / non-browser env should
+ // not crash on the function call.
+ const origDoc = globalThis.document;
+ delete globalThis.document;
+ try {
+ assert.doesNotThrow(() => isSafeWallet({}));
+ assert.equal(isSafeWallet({}), false);
+ } finally {
+ globalThis.document = origDoc;
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Combined: any path matches → true (OR semantics)
+// ---------------------------------------------------------------------------
+
+test('isSafeWallet — connector path wins even if other paths would also match', () => {
+ // Doesn't matter which short-circuits — result is still true.
+ const r = withGlobals(
+ { ethereum: { isSafe: true }, referrer: 'https://safe.global/' },
+ () => isSafeWallet({ connector: { name: 'Safe' } })
+ );
+ assert.equal(r, true);
+});
+
+test('isSafeWallet — all three paths false → returns false', () => {
+ const r = withGlobals(
+ { ethereum: { isMetaMask: true }, referrer: 'https://other.com/' },
+ () => isSafeWallet({ connector: { name: 'MetaMask', id: 'metamask' } })
+ );
+ assert.equal(r, false);
+});
diff --git a/auto-qa/tests/json-config-validity.test.mjs b/auto-qa/tests/json-config-validity.test.mjs
new file mode 100644
index 0000000..275049b
--- /dev/null
+++ b/auto-qa/tests/json-config-validity.test.mjs
@@ -0,0 +1,146 @@
+/**
+ * JSON config validity test (auto-qa).
+ *
+ * Walks every JSON file in src/, public/, and the repo root (excluding
+ * package-lock.json + node_modules) and asserts:
+ *
+ * 1. The file parses cleanly as JSON (catches trailing commas,
+ * missing braces, BOM markers, accidental Markdown fences)
+ * 2. For known structural files (manifest.json, package.json,
+ * mapped-seo.json), specific shape invariants hold
+ *
+ * A bad JSON file in src/ or public/ silently breaks SSR or static
+ * asset serving — Next.js may throw a vague "Unexpected token" with
+ * no file path. This test surfaces it with a clear file:line message.
+ *
+ * The mapped-seo.json key-shape check pins that every proposal-id
+ * key is a valid 0x-prefixed 42-char address — guards against a
+ * search/replace that mangles addresses.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
+
+function walk(dir, results = []) {
+ for (const name of readdirSync(dir)) {
+ if (name === 'node_modules' || name === '.next' || name.startsWith('.')) continue;
+ const full = join(dir, name);
+ const st = statSync(full);
+ if (st.isDirectory()) walk(full, results);
+ else if (name.endsWith('.json') && name !== 'package-lock.json') results.push(full);
+ }
+ return results;
+}
+
+// Top-level files only (no walk into directories) — repo root has many
+// scratch JSON files that aren't shipped.
+function topLevelJson(dir) {
+ return readdirSync(dir)
+ .filter(n => n.endsWith('.json') && n !== 'package-lock.json')
+ .map(n => join(dir, n));
+}
+
+const SRC_FILES = walk(join(REPO_ROOT, 'src'));
+const PUBLIC_FILES = walk(join(REPO_ROOT, 'public'));
+const ROOT_FILES = topLevelJson(REPO_ROOT);
+
+// ---------------------------------------------------------------------------
+// Universal: every JSON file must parse
+// ---------------------------------------------------------------------------
+
+test('JSON validity — extractor finds non-trivial number of files', () => {
+ const total = SRC_FILES.length + PUBLIC_FILES.length + ROOT_FILES.length;
+ assert.ok(total >= 5,
+ `extractor found only ${total} JSON files — walker / filter likely broken`);
+});
+
+const allFiles = [...SRC_FILES, ...PUBLIC_FILES, ...ROOT_FILES];
+for (const file of allFiles) {
+ const rel = relative(REPO_ROOT, file);
+ test(`JSON validity — ${rel} parses`, () => {
+ const raw = readFileSync(file, 'utf8');
+ // Detect BOM (sometimes added by Windows editors → JSON.parse throws).
+ assert.ok(raw.charCodeAt(0) !== 0xFEFF,
+ `${rel} starts with a UTF-8 BOM marker — strip it`);
+ // Try to parse; surface a rich error if it fails.
+ try {
+ JSON.parse(raw);
+ } catch (e) {
+ assert.fail(`${rel} failed to parse as JSON: ${e.message}`);
+ }
+ });
+}
+
+// ---------------------------------------------------------------------------
+// public/manifest.json — Web App Manifest minimal shape
+// ---------------------------------------------------------------------------
+
+test('manifest.json — has name + description fields', () => {
+ const m = JSON.parse(readFileSync(join(REPO_ROOT, 'public/manifest.json'), 'utf8'));
+ assert.ok(typeof m.name === 'string' && m.name.length > 0,
+ `manifest.json must have a name field`);
+ assert.ok(typeof m.description === 'string' && m.description.length > 0,
+ `manifest.json must have a description field`);
+});
+
+test('safe-app-manifest.json — has name + iconPath fields', () => {
+ const m = JSON.parse(readFileSync(join(REPO_ROOT, 'public/safe-app-manifest.json'), 'utf8'));
+ // Safe (Gnosis Safe) app manifest spec: name, description, iconPath.
+ for (const k of ['name', 'description', 'iconPath']) {
+ assert.ok(typeof m[k] === 'string' && m[k].length > 0,
+ `safe-app-manifest.json must have a non-empty "${k}" field`);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// src/config/mapped-seo.json — every key is a 0x-address
+// ---------------------------------------------------------------------------
+
+test('mapped-seo.json — every key is a valid 0x + 40 hex chars address', () => {
+ const m = JSON.parse(readFileSync(join(REPO_ROOT, 'src/config/mapped-seo.json'), 'utf8'));
+ const ADDR = /^0x[a-fA-F0-9]{40}$/;
+ const bad = Object.keys(m).filter(k => !ADDR.test(k));
+ assert.equal(bad.length, 0,
+ `${bad.length} mapped-seo.json key(s) failed address shape check:\n${bad.map(k => ' ' + k).join('\n')}`);
+});
+
+test('mapped-seo.json — every value is a path-relative string', () => {
+ const m = JSON.parse(readFileSync(join(REPO_ROOT, 'src/config/mapped-seo.json'), 'utf8'));
+ const bad = [];
+ for (const [k, v] of Object.entries(m)) {
+ if (typeof v !== 'string') { bad.push(`${k}: not string (${typeof v})`); continue; }
+ if (!v.startsWith('/')) { bad.push(`${k}: not path-relative ("${v}")`); }
+ }
+ assert.equal(bad.length, 0,
+ `${bad.length} mapped-seo.json value(s) failed shape check:\n${bad.slice(0, 10).map(b => ' ' + b).join('\n')}`);
+});
+
+test('mapped-seo.json — non-trivial number of entries', () => {
+ const m = JSON.parse(readFileSync(join(REPO_ROOT, 'src/config/mapped-seo.json'), 'utf8'));
+ const n = Object.keys(m).length;
+ assert.ok(n >= 10, `mapped-seo.json has ${n} entries — likely truncated`);
+});
+
+// ---------------------------------------------------------------------------
+// Root package.json — required dev/runtime dep names + scripts
+// ---------------------------------------------------------------------------
+
+test('package.json — has required scripts (dev, build, lint, auto-qa:test)', () => {
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'));
+ for (const s of ['dev', 'build', 'lint', 'auto-qa:test']) {
+ assert.ok(pkg.scripts?.[s], `package.json missing script "${s}"`);
+ }
+});
+
+test('package.json — declares ethers and next as dependencies', () => {
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'));
+ const all = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
+ for (const dep of ['ethers', 'next']) {
+ assert.ok(all[dep], `package.json missing dep "${dep}"`);
+ }
+});
diff --git a/auto-qa/tests/liquidity-math.test.mjs b/auto-qa/tests/liquidity-math.test.mjs
new file mode 100644
index 0000000..5888867
--- /dev/null
+++ b/auto-qa/tests/liquidity-math.test.mjs
@@ -0,0 +1,106 @@
+/**
+ * Liquidity math unit-bound test (auto-qa).
+ *
+ * Pins the math fix from PR #51 — converting Algebra V3's raw `liquidity`
+ * field (1e18-scaled) to a currency-denominated TVL via:
+ * TVL_currency = (2 × L × sqrtPrice) / 1e18 (when currency is token1)
+ * TVL_currency = (2 × L / sqrtPrice) / 1e18 (when currency is token0)
+ * where sqrtPrice = 1.0001^(tick/2).
+ *
+ * Pre-PR-#51, the widget displayed the raw L value as "sDAI" — values
+ * like "4.9e21 sDAI" — nonsense.
+ *
+ * This is a SPEC test: the math here mirrors the production code at
+ * `src/hooks/usePoolData.js:141-153`. If those lines change, this test
+ * may need a synchronized update — that's the contract.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+/**
+ * Mirrors the production calculation at src/hooks/usePoolData.js:141-153.
+ * @param {string|number} rawL Algebra V3 `liquidity` field (1e18-scaled BigInt).
+ * @param {number} tick Current pool tick.
+ * @param {boolean} currencyIsToken0
+ * @returns {number} TVL in currency token units (NOT wei), or 0 if tick missing.
+ */
+function tvlFromTick(rawL, tick, currencyIsToken0) {
+ let adjustedLiquidity = parseFloat(rawL || 0);
+ if (tick === undefined || tick === null) return adjustedLiquidity / 1e18;
+ const sqrtPrice = Math.pow(1.0001, tick / 2);
+ if (!(sqrtPrice > 0)) return 0;
+ const liquidityScaled = currencyIsToken0
+ ? adjustedLiquidity / sqrtPrice
+ : adjustedLiquidity * sqrtPrice;
+ return (liquidityScaled * 2) / 1e18;
+}
+
+// ────────────────────────────────────────────────────────────────────────
+// Fixture: GIP-150 v2 CONDITIONAL pool data captured from live API.
+// ────────────────────────────────────────────────────────────────────────
+const YES_POOL = {
+ L: '4900580174367112592095',
+ tick: 47116,
+ currencyIsToken0: false, // token0=YES_GNO, token1=YES_sDAI
+};
+const NO_POOL = {
+ L: '4900580174367112321476',
+ tick: -46766,
+ currencyIsToken0: true, // token0=NO_sDAI, token1=NO_GNO
+};
+
+test('PR #51 — YES pool TVL is in plausible currency-units range', () => {
+ const tvl = tvlFromTick(YES_POOL.L, YES_POOL.tick, YES_POOL.currencyIsToken0);
+ assert.ok(tvl > 1,
+ `YES pool TVL must be > 1 sDAI; got ${tvl}`);
+ assert.ok(tvl < 1e9,
+ `YES pool TVL must be < 1e9 sDAI (raw 1e18 leak guard); got ${tvl}`);
+ // Sanity: GIP-150's CONDITIONAL pools sit around 100K sDAI TVL.
+ assert.ok(tvl > 1e4 && tvl < 1e7,
+ `YES pool TVL should be in 10K..10M range for GIP-150; got ${tvl}`);
+});
+
+test('PR #51 — NO pool TVL is in plausible currency-units range', () => {
+ const tvl = tvlFromTick(NO_POOL.L, NO_POOL.tick, NO_POOL.currencyIsToken0);
+ assert.ok(tvl > 1, `NO pool TVL must be > 1 sDAI; got ${tvl}`);
+ assert.ok(tvl < 1e9, `NO pool TVL must be < 1e9 sDAI; got ${tvl}`);
+ assert.ok(tvl > 1e4 && tvl < 1e7,
+ `NO pool TVL should be in 10K..10M range for GIP-150; got ${tvl}`);
+});
+
+test('PR #51 — YES and NO TVL are within 50% of each other (futarchy invariant)', () => {
+ // For a healthy futarchy market the YES and NO conditional pools
+ // start with the same liquidity. They drift but should stay
+ // within an order of magnitude. If one is 100× the other, the
+ // tick-based formula is broken on one side.
+ const yesTvl = tvlFromTick(YES_POOL.L, YES_POOL.tick, YES_POOL.currencyIsToken0);
+ const noTvl = tvlFromTick(NO_POOL.L, NO_POOL.tick, NO_POOL.currencyIsToken0);
+ const ratio = Math.max(yesTvl, noTvl) / Math.min(yesTvl, noTvl);
+ assert.ok(ratio < 2,
+ `YES and NO TVL should be within 2x of each other; got ratio ${ratio.toFixed(2)} (YES=${yesTvl.toFixed(0)} NO=${noTvl.toFixed(0)})`);
+});
+
+test('PR #51 — fallback /1e18 when tick missing yields plausible value', () => {
+ const tvl = tvlFromTick(YES_POOL.L, null, false);
+ // Without tick, formula falls back to L / 1e18 ≈ 4900 (raw L is
+ // a 1e18-scaled BigInt). This is a degraded fallback but still
+ // bounded — must not leak the raw 1e21 value.
+ assert.ok(tvl > 1 && tvl < 1e9,
+ `fallback TVL out of bounds: ${tvl}`);
+});
+
+test('PR #51 — degenerate tick (sqrtPrice=0) returns 0, not Infinity/NaN', () => {
+ // Algebra V3 ticks are bounded in [-887272, 887272]. At extreme
+ // negative ticks Math.pow(1.0001, tick/2) underflows to 0 — the
+ // production guard returns 0 rather than dividing by zero.
+ const tvl = tvlFromTick('1000000000000000000', -1_000_000, true);
+ assert.ok(tvl === 0 || (Number.isFinite(tvl) && tvl >= 0),
+ `extreme negative tick should not yield Infinity/NaN; got ${tvl}`);
+});
+
+test('PR #51 — null/zero L returns 0, not NaN', () => {
+ assert.equal(tvlFromTick(null, 0, false), 0);
+ assert.equal(tvlFromTick(undefined, 0, false), 0);
+ assert.equal(tvlFromTick('0', 0, false), 0);
+});
diff --git a/auto-qa/tests/pagination-first-cap.test.mjs b/auto-qa/tests/pagination-first-cap.test.mjs
new file mode 100644
index 0000000..e5475c7
--- /dev/null
+++ b/auto-qa/tests/pagination-first-cap.test.mjs
@@ -0,0 +1,137 @@
+/**
+ * Pagination `first:` cap lint (auto-qa).
+ *
+ * Pins the family of bugs that landed as PR #44 — the Companies admin
+ * UI silently dropped GIP-150 because `getLinkableProposals` queried
+ * with `first: 50` against a registry that already had 229 proposals.
+ *
+ * General class of bug: a hardcoded `first: ` on an entity
+ * type whose population can grow past N silently truncates results.
+ *
+ * What this test does:
+ * Walks every shipped GraphQL query (via the same extractor used by
+ * the schema-compat probe) and flags entity-listing queries that use
+ * `first: ` for entities likely to grow past 100. Today's risk
+ * list: organizations, proposals, proposalentities, pools.
+ *
+ * Per /loop directive: failures here are documented as a baseline of
+ * "known small-first usages we accept" — adding a NEW one trips the
+ * test, so this is a ratchet against silent-truncation regressions.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const EXTRACTOR = resolve(__dirname, '../tools/extract-graphql.mjs');
+
+// Listing-style queries we audit. Format: `(... first: )` with N < threshold
+const RISKY_ENTITIES = ['proposals', 'proposalentities', 'organizations', 'pools'];
+
+// Threshold: anything under this is suspicious for entities that grow.
+// 100 captures the post-PR-#44 change (50 → 1000) — anything below is
+// either intentionally narrow or a future PR-#44-style bug.
+const FIRST_THRESHOLD = 100;
+
+function loadQueries() {
+ const out = execFileSync('node', [EXTRACTOR], {
+ encoding: 'utf8',
+ cwd: resolve(__dirname, '../..'),
+ });
+ return JSON.parse(out);
+}
+
+function findRiskyFirstUsages(queries) {
+ const findings = [];
+ for (const q of queries) {
+ // Match `()` where args contain `first: `.
+ // Handle `entity(first: N, …)`, `entity(…, first: N)`, etc.
+ for (const entity of RISKY_ENTITIES) {
+ const re = new RegExp(
+ `\\b${entity}\\s*\\([^)]*first\\s*:\\s*(\\d+)`,
+ 'gi'
+ );
+ let m;
+ while ((m = re.exec(q.query)) !== null) {
+ const n = parseInt(m[1], 10);
+ if (n < FIRST_THRESHOLD) {
+ findings.push({
+ file: q.file,
+ line: q.line,
+ entity,
+ first: n,
+ snippet: m[0].slice(0, 80),
+ });
+ }
+ }
+ }
+ }
+ return findings;
+}
+
+// Baseline: known small-first usages we accept today. Exhaustive list as
+// of this iteration. If a new entry appears, it's flagged for review.
+const ACCEPTED_SMALL_FIRST = [
+ // useSearchProposals: search box deliberately shows only top-20.
+ { file: 'src/hooks/useSearchProposals.js', entity: 'proposals', first: 20 },
+ // usePoolData: single-pool lookup by ID (`pools(where: { id: "0xabc" }, first: 1)`)
+ // is intentionally narrow — we want exactly one pool, not many.
+ { file: 'src/hooks/usePoolData.js', entity: 'pools', first: 1 },
+];
+
+function isAccepted(finding) {
+ return ACCEPTED_SMALL_FIRST.some(
+ a => a.file === finding.file && a.entity === finding.entity && a.first === finding.first
+ );
+}
+
+test('PR #44 — no NEW small-first usage on listing entities', () => {
+ const queries = loadQueries();
+ const findings = findRiskyFirstUsages(queries);
+ const surprises = findings.filter(f => !isAccepted(f));
+
+ if (surprises.length > 0) {
+ const lines = surprises.map(s =>
+ ` ${s.file}:${s.line} ${s.entity}(... first: ${s.first} ...) — ${s.snippet}`
+ ).join('\n');
+ assert.fail(
+ `Found ${surprises.length} new small-first usage(s) on growing entities:\n${lines}\n` +
+ `Either bump the limit (PR #44-style fix) or, if intentionally narrow, ` +
+ `add to ACCEPTED_SMALL_FIRST in this test file.`
+ );
+ }
+});
+
+test('PR #44 — accepted small-first list still matches reality', () => {
+ // Inverse direction: if an accepted entry was removed (e.g. the
+ // useSearchProposals query was bumped/reworked), we want to know
+ // so the accepted list doesn't grow stale.
+ const queries = loadQueries();
+ const findings = findRiskyFirstUsages(queries);
+
+ for (const accepted of ACCEPTED_SMALL_FIRST) {
+ const stillThere = findings.some(f =>
+ f.file === accepted.file &&
+ f.entity === accepted.entity &&
+ f.first === accepted.first
+ );
+ assert.ok(stillThere,
+ `Accepted small-first usage no longer found in source: ${JSON.stringify(accepted)}. ` +
+ `Remove it from ACCEPTED_SMALL_FIRST in this test file.`);
+ }
+});
+
+test('PR #44 — diagnostic: report all listing-entity first: values', (t) => {
+ // Diagnostic-only — emits a count by file:entity:first triple so we
+ // can see at a glance what pagination defaults the codebase uses.
+ const queries = loadQueries();
+ const findings = findRiskyFirstUsages(queries);
+ t.diagnostic(`small-first usages found: ${findings.length} (threshold: <${FIRST_THRESHOLD})`);
+ for (const f of findings) {
+ t.diagnostic(` ${f.file}:${f.line} ${f.entity} first=${f.first}`);
+ }
+ assert.ok(true);
+});
diff --git a/auto-qa/tests/precision-formatter.test.mjs b/auto-qa/tests/precision-formatter.test.mjs
new file mode 100644
index 0000000..86a9ead
--- /dev/null
+++ b/auto-qa/tests/precision-formatter.test.mjs
@@ -0,0 +1,221 @@
+/**
+ * precisionFormatter utility tests (auto-qa).
+ *
+ * Pins src/utils/precisionFormatter.js — used in 8 production files
+ * including SubgraphChart, MarketBalancePanel, PositionsTable,
+ * ConfirmSwapModal, and ChartParameters. The "smart precision" logic
+ * (increase precision until value doesn't round to 0) and the
+ * type-specific trailing-zero handling are subtle enough that subtle
+ * regressions would slip past code review and only show up as wrong
+ * numbers on the trading screen.
+ *
+ * Spec mirrors src/utils/precisionFormatter.js with the production
+ * default PRECISION_CONFIG inlined. The mirror omits the production
+ * console.log spam.
+ *
+ * Notable quirk pinned: formatPercentage MULTIPLIES the input by 100,
+ * so formatPercentage(0.5) → "50%" (not "0.5%"). This is the opposite
+ * convention from formatNumber.formatSnapshotPercentage which assumes
+ * the input is already in percent form.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+// Production default config — copy of PRECISION_CONFIG.display in
+// src/components/futarchyFi/marketPage/constants/contracts.js:358.
+const DEFAULT_CONFIG = {
+ display: {
+ main: 1,
+ default: 2,
+ price: 2,
+ swapPrice: 1,
+ amount: 6,
+ balance: 4,
+ percentage: 1,
+ smallNumbers: 8,
+ },
+};
+
+function formatWith(value, type = 'default', config = null) {
+ const precisionConfig = config || DEFAULT_CONFIG;
+ if (value === null || value === undefined || value === '' || isNaN(value)) return 'N/A';
+ const num = typeof value === 'string' ? parseFloat(value) : value;
+ if (!isFinite(num)) return 'N/A';
+ if (num === 0) return '0';
+
+ let precision = precisionConfig?.display?.[type] ?? precisionConfig?.display?.default ?? 2;
+
+ // Very-small handling
+ if (Math.abs(num) < 0.0001 && Math.abs(num) > 0) {
+ const smallPrecision = precisionConfig?.display?.smallNumbers ?? 20;
+ return num.toFixed(smallPrecision).replace(/\.?0+$/, '');
+ }
+
+ let formatted = num.toFixed(precision);
+ const originalPrecision = precision;
+ const maxPrecision = precisionConfig?.display?.smallNumbers ?? 20;
+
+ while (parseFloat(formatted) === 0 && precision < maxPrecision && num !== 0) {
+ precision++;
+ formatted = num.toFixed(precision);
+ }
+
+ if (parseFloat(formatted) === 0 && num !== 0) {
+ return num.toFixed(maxPrecision).replace(/\.?0+$/, '');
+ }
+ if (precision > originalPrecision) {
+ return formatted.replace(/\.?0+$/, '');
+ }
+ if (type === 'balance') {
+ return formatted.replace(/\.?0+$/, '');
+ }
+ return formatted;
+}
+
+function formatPercentage(value, config = null) {
+ if (value === null || value === undefined || value === '' || isNaN(value)) return 'N/A';
+ const num = typeof value === 'string' ? parseFloat(value) : value;
+ return `${formatWith(num * 100, 'percentage', config)}%`;
+}
+
+function getPrecision(type = 'default', config = null) {
+ const precisionConfig = config || DEFAULT_CONFIG;
+ return precisionConfig?.display?.[type] ?? precisionConfig?.display?.default ?? 2;
+}
+
+// ---------------------------------------------------------------------------
+// formatWith — invalid input handling
+// ---------------------------------------------------------------------------
+
+test('formatWith — invalid inputs return "N/A"', () => {
+ for (const v of [null, undefined, '', NaN, 'not a number']) {
+ assert.equal(formatWith(v, 'price'), 'N/A',
+ `${JSON.stringify(v)} should yield "N/A"`);
+ }
+});
+
+test('formatWith — Infinity returns "N/A"', () => {
+ assert.equal(formatWith(Infinity, 'price'), 'N/A');
+ assert.equal(formatWith(-Infinity, 'price'), 'N/A');
+});
+
+test('formatWith — exact zero returns "0" (no decimals, no precision)', () => {
+ assert.equal(formatWith(0, 'price'), '0');
+ assert.equal(formatWith('0', 'price'), '0');
+ assert.equal(formatWith(0.0, 'balance'), '0');
+});
+
+// ---------------------------------------------------------------------------
+// formatWith — type-specific precision lookup
+// ---------------------------------------------------------------------------
+
+test('formatWith — price type uses 2-decimal precision', () => {
+ assert.equal(formatWith(3.23456, 'price'), '3.23');
+});
+
+test('formatWith — amount type uses 6-decimal precision', () => {
+ assert.equal(formatWith(1234.5, 'amount'), '1234.500000');
+});
+
+test('formatWith — balance type strips trailing zeros (display contract)', () => {
+ // From the function comment: "but keep at least original precision for
+ // display consistency when non-zero". Trailing zeros are stripped.
+ assert.equal(formatWith(1.0, 'balance'), '1');
+ assert.equal(formatWith(1.2300, 'balance'), '1.23');
+});
+
+test('formatWith — non-balance types KEEP trailing zeros', () => {
+ // Pinned: keep trailing zeros for stable column-aligned display.
+ assert.equal(formatWith(1.5, 'amount'), '1.500000');
+ assert.equal(formatWith(1.5, 'price'), '1.50');
+});
+
+test('formatWith — unknown type falls back to default (2 decimals)', () => {
+ assert.equal(formatWith(3.14159, 'totally-unknown-type'), '3.14');
+});
+
+// ---------------------------------------------------------------------------
+// formatWith — small-number branching (< 0.0001)
+// ---------------------------------------------------------------------------
+
+test('formatWith — values < 0.0001 use smallNumbers precision (8)', () => {
+ // toFixed(8) on 0.00001234 = "0.00001234" (no trailing zeros to strip).
+ assert.equal(formatWith(0.00001234, 'price'), '0.00001234');
+});
+
+test('formatWith — small-number path strips trailing zeros', () => {
+ assert.equal(formatWith(0.00001, 'price'), '0.00001');
+});
+
+test('formatWith — negative small numbers use the same path', () => {
+ assert.equal(formatWith(-0.00001234, 'price'), '-0.00001234');
+});
+
+// ---------------------------------------------------------------------------
+// formatWith — smart-precision: never display non-zero value as "0"
+// ---------------------------------------------------------------------------
+
+test('formatWith — value rounding to 0 at default precision auto-bumps precision', () => {
+ // 0.001 with type 'price' (precision=2) would round to "0.00" — the
+ // smart-precision loop bumps until the displayed value is non-zero.
+ const out = formatWith(0.001, 'price');
+ assert.notEqual(parseFloat(out), 0,
+ `0.001 must NOT display as zero; got "${out}"`);
+});
+
+test('formatWith — smart-precision strips trailing zeros after bump', () => {
+ // After the precision bump, the strip-zeros branch fires.
+ assert.equal(formatWith(0.001, 'price'), '0.001');
+});
+
+// ---------------------------------------------------------------------------
+// formatWith — string vs number input parity
+// ---------------------------------------------------------------------------
+
+test('formatWith — accepts string and number forms equivalently', () => {
+ assert.equal(formatWith(1.5, 'price'), formatWith('1.5', 'price'));
+ assert.equal(formatWith(0.001, 'amount'), formatWith('0.001', 'amount'));
+});
+
+// ---------------------------------------------------------------------------
+// formatPercentage — input is a *fraction*, not a percent
+// ---------------------------------------------------------------------------
+
+test('formatPercentage — input is multiplied by 100', () => {
+ // The convention pin: 0.5 → "50.0%", NOT "0.5%". This is opposite
+ // to formatSnapshotPercentage. If a future refactor "fixes" this
+ // by removing the *100, every percentage in the app silently
+ // shrinks by 100×.
+ assert.equal(formatPercentage(0.5), '50.0%');
+ assert.equal(formatPercentage(0.1234), '12.3%');
+ assert.equal(formatPercentage(1), '100.0%');
+});
+
+test('formatPercentage — invalid → "N/A" (not "N/A%")', () => {
+ // Invalid input MUST short-circuit before adding the % suffix —
+ // otherwise users see the literal "N/A%" which looks like a value.
+ assert.equal(formatPercentage(null), 'N/A');
+ assert.equal(formatPercentage(undefined), 'N/A');
+ assert.equal(formatPercentage('not-a-num'), 'N/A');
+});
+
+// ---------------------------------------------------------------------------
+// getPrecision — config lookup
+// ---------------------------------------------------------------------------
+
+test('getPrecision — returns the per-type precision from config', () => {
+ assert.equal(getPrecision('price'), 2);
+ assert.equal(getPrecision('amount'), 6);
+ assert.equal(getPrecision('smallNumbers'), 8);
+});
+
+test('getPrecision — unknown type falls back to default (2)', () => {
+ assert.equal(getPrecision('made-up-type'), 2);
+});
+
+test('getPrecision — accepts custom config and ignores defaults', () => {
+ const custom = { display: { price: 7, default: 5 } };
+ assert.equal(getPrecision('price', custom), 7);
+ assert.equal(getPrecision('made-up-type', custom), 5);
+});
diff --git a/auto-qa/tests/proposal-doc-store.test.mjs b/auto-qa/tests/proposal-doc-store.test.mjs
new file mode 100644
index 0000000..220a6f1
--- /dev/null
+++ b/auto-qa/tests/proposal-doc-store.test.mjs
@@ -0,0 +1,267 @@
+/**
+ * proposalDocumentationStore spec mirror (auto-qa).
+ *
+ * Pins src/utils/proposalDocumentationStore.js — a singleton
+ * module-level store for "user-created proposal" demo data with an
+ * HTML→markdown converter. Currently has ZERO importers in the
+ * codebase (dead module). Pinned because:
+ *
+ * 1. Each call to setProposalDocumentationData triggers SEVEN
+ * module-level console.log lines (raw topics + per-topic before/
+ * after + final combined + final stored). If anyone ever imports
+ * and uses this in production, the logs flood. Pinned as a
+ * hazard ratchet.
+ * 2. The convertToMarkdown rules are non-obvious (strips /,
+ * replaces - with "- ", with space, etc.). A regression
+ * that drops a rule would silently produce malformed markdown.
+ * 3. Hardcoded asset paths (/assets/futarchy-logo-black.svg,
+ * /assets/protocol-upgrade-banner.png) — must exist in public/
+ * or any rendering caller would 404 on those images.
+ * 4. Module-level mutable state pattern (`let proposalDocumentationData = null`).
+ * Initial getter call returns null. Singleton pinned.
+ *
+ * Per the /loop directive: dead module is left as-is. The test
+ * documents the hazards so a future cleanup is deliberate.
+ */
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const SRC = readFileSync(
+ new URL('../../src/utils/proposalDocumentationStore.js', import.meta.url),
+ 'utf8',
+);
+
+const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+
+// --- spec mirror of the inner convertToMarkdown function ---
+function convertToMarkdown(htmlContent, topicNumber) {
+ const sectionHeader = `### Section ${topicNumber}\n`;
+ const converted = htmlContent
+ .replace(//g, '')
+ .replace(/<\/h[1-6]>/g, '\n')
+ .replace(/
/g, '')
+ .replace(/<\/ul>/g, '')
+ .replace(//g, '')
+ .replace(/<\/ol>/g, '')
+ .replace(/- /g, '- ')
+ .replace(/<\/li>/g, '\n')
+ .replace(/
/g, '')
+ .replace(/<\/p>/g, '\n\n')
+ .replace(/ /g, ' ')
+ .trim();
+ return `${sectionHeader}${converted}`;
+}
+
+// ---------------------------------------------------------------------------
+// convertToMarkdown — section header is always present
+// ---------------------------------------------------------------------------
+
+test('convertToMarkdown — emits "### Section N" header before content', () => {
+ const r = convertToMarkdown('
Hello
', 3);
+ assert.match(r, /^### Section 3\n/,
+ `each topic must be prefixed with "### Section \\n"`);
+});
+
+test('convertToMarkdown — empty input still emits the section header', () => {
+ const r = convertToMarkdown('', 1);
+ assert.equal(r, '### Section 1\n',
+ `empty content must still produce a header (no orphan content lines)`);
+});
+
+// ---------------------------------------------------------------------------
+// convertToMarkdown — HTML tag stripping rules
+// ---------------------------------------------------------------------------
+
+test('convertToMarkdown — .. open tags stripped, close → newline', () => {
+ for (const level of [1, 2, 3, 4, 5, 6]) {
+ // Use hTitle
>x
so the \n after
+ // sits BEFORE non-whitespace and survives trim().
+ const r = convertToMarkdown(`Titlex
`, 1);
+ assert.match(r, /Title\n/,
+ `h${level} close must emit "Title\\n" before subsequent content`);
+ assert.doesNotMatch(r, new RegExp(``),
+ `h${level} open tag must be stripped`);
+ }
+});
+
+test('convertToMarkdown — / wrappers stripped entirely (open + close)', () => {
+ const r = convertToMarkdown('', 1);
+ assert.doesNotMatch(r, /<\/?ul>/,
+ ` wrappers must be stripped`);
+ const r2 = convertToMarkdown('- One
', 1);
+ assert.doesNotMatch(r2, /<\/?ol>/,
+ `
wrappers must be stripped`);
+});
+
+test('convertToMarkdown — - opens with "- ", closes with newline', () => {
+ const r = convertToMarkdown('
- Item A
- Item B
', 1);
+ assert.match(r, /- Item A\n/);
+ // Last
-> "\n" but trim() strips it. Match end-of-string.
+ assert.match(r, /- Item B$/);
+ assert.doesNotMatch(r, /<\/?li>/);
+});
+
+test('convertToMarkdown — stripped on open,
→ "\\n\\n"', () => {
+ const r = convertToMarkdown('First
Second
', 1);
+ assert.match(r, /First\n\n/);
+ // After trim, trailing \n\n on the LAST
is consumed.
+ assert.doesNotMatch(r, /<\/?p>/);
+});
+
+test('convertToMarkdown — replaced with single space', () => {
+ const r = convertToMarkdown('foo bar baz
', 1);
+ assert.match(r, /foo bar baz/);
+ assert.doesNotMatch(r, / /);
+});
+
+test('convertToMarkdown — output is trimmed (no leading/trailing whitespace on body)', () => {
+ // The trim() applies to `converted`, BEFORE the section header is
+ // prepended. So the section header may sit alone, but no surrounding
+ // whitespace on the body itself.
+ const r = convertToMarkdown(' X
', 1);
+ // Header is always "### Section 1\n", then the trimmed body.
+ // Body after replacements: " X\n\n " → trim → "X"
+ assert.equal(r, '### Section 1\nX',
+ `whitespace must be trimmed AFTER tag conversion`);
+});
+
+// ---------------------------------------------------------------------------
+// convertToMarkdown — what it does NOT handle (silent corruption surface)
+// ---------------------------------------------------------------------------
+
+test('convertToMarkdown — DOES NOT escape ', 1);
+ assert.match(r, /