From a5a20fea3d22855ceabdc6e160b79eba836b348a Mon Sep 17 00:00:00 2001 From: kaivegascod-a11y Date: Mon, 19 Oct 2026 03:18:37 +0000 Subject: [PATCH 1/4] fix: #1427 F-89: Handle all sendTransaction statuses and RPC errors while polling - Re-send on TRY_AGAIN_LATER with exponential backoff, bounded by the timeout - Treat DUPLICATE as already submitted and poll by hash - Decode the result code from errorResult on ERROR - Tolerate transient getTransaction errors until the timeout - Restore the contract error helpers imported by lib/stellar.ts Closes #1427 --- .../__tests__/lib/submitTransaction.test.ts | 100 +++++++++++ frontend/lib/errors.ts | 155 ++++++++++++++++++ frontend/lib/stellar.ts | 85 ++++++++-- 3 files changed, 323 insertions(+), 17 deletions(-) create mode 100644 frontend/__tests__/lib/submitTransaction.test.ts diff --git a/frontend/__tests__/lib/submitTransaction.test.ts b/frontend/__tests__/lib/submitTransaction.test.ts new file mode 100644 index 000000000..278499f3b --- /dev/null +++ b/frontend/__tests__/lib/submitTransaction.test.ts @@ -0,0 +1,100 @@ +import { submitTransaction, TransactionTimeoutError } from "@/lib/stellar"; + +const mockSendTransaction = jest.fn(); +const mockGetTransaction = jest.fn(); + +jest.mock("@stellar/stellar-sdk", () => ({ + SorobanRpc: { + Server: jest.fn(() => ({ + sendTransaction: (...args: unknown[]) => mockSendTransaction(...args), + getTransaction: (...args: unknown[]) => mockGetTransaction(...args), + })), + }, + TransactionBuilder: { + fromXDR: jest.fn(() => ({ hash: () => Buffer.from("ab".repeat(32), "hex") })), + }, +})); + +const HASH = "ab".repeat(32); +const SUCCESS = { status: "SUCCESS", ledger: 42, returnValue: "ok" }; + +describe("submitTransaction", () => { + beforeEach(() => { + mockSendTransaction.mockReset(); + mockGetTransaction.mockReset(); + // Run backoff sleeps immediately so the tests stay fast. + jest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void) => { + fn(); + return 0; + }) as unknown as typeof setTimeout); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("re-sends the transaction when the RPC answers TRY_AGAIN_LATER", async () => { + mockSendTransaction + .mockResolvedValueOnce({ status: "TRY_AGAIN_LATER", hash: HASH }) + .mockResolvedValueOnce({ status: "TRY_AGAIN_LATER", hash: HASH }) + .mockResolvedValueOnce({ status: "PENDING", hash: HASH }); + mockGetTransaction.mockResolvedValueOnce(SUCCESS); + + const result = await submitTransaction("xdr"); + + expect(mockSendTransaction).toHaveBeenCalledTimes(3); + expect(result).toEqual({ txHash: HASH, ledger: 42, returnValue: "ok" }); + }); + + it("polls by hash without failing when the RPC answers DUPLICATE", async () => { + mockSendTransaction.mockResolvedValueOnce({ status: "DUPLICATE", hash: HASH }); + mockGetTransaction.mockResolvedValueOnce(SUCCESS); + const onStatus = jest.fn(); + + const result = await submitTransaction("xdr", onStatus); + + expect(result.txHash).toBe(HASH); + expect(mockGetTransaction).toHaveBeenCalledTimes(1); + expect(onStatus).not.toHaveBeenCalledWith(expect.objectContaining({ status: "failed" })); + }); + + it("decodes the result code from errorResult when the RPC answers ERROR", async () => { + mockSendTransaction.mockResolvedValueOnce({ + status: "ERROR", + hash: HASH, + errorResult: { result: () => ({ switch: () => ({ name: "txBAD_SEQ" }) }) }, + }); + const onStatus = jest.fn(); + + await expect(submitTransaction("xdr", onStatus)).rejects.toThrow("txBAD_SEQ"); + expect(onStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: "failed", txHash: HASH }) + ); + expect(mockGetTransaction).not.toHaveBeenCalled(); + }); + + it("tolerates transient getTransaction errors until the transaction confirms", async () => { + mockSendTransaction.mockResolvedValueOnce({ status: "PENDING", hash: HASH }); + mockGetTransaction + .mockRejectedValueOnce(new Error("socket hang up")) + .mockRejectedValueOnce(new Error("503 Service Unavailable")) + .mockResolvedValueOnce(SUCCESS); + + const result = await submitTransaction("xdr"); + + expect(mockGetTransaction).toHaveBeenCalledTimes(3); + expect(result.ledger).toBe(42); + }); + + it("times out instead of aborting when every poll fails", async () => { + mockSendTransaction.mockResolvedValueOnce({ status: "PENDING", hash: HASH }); + mockGetTransaction.mockRejectedValue(new Error("network down")); + + // Each Date.now() call advances the clock by 10s, so the 30s window closes quickly. + let now = 0; + jest.spyOn(Date, "now").mockImplementation(() => (now += 10_000)); + + await expect(submitTransaction("xdr")).rejects.toBeInstanceOf(TransactionTimeoutError); + expect(mockGetTransaction).toHaveBeenCalled(); + }); +}); diff --git a/frontend/lib/errors.ts b/frontend/lib/errors.ts index daa7e731a..95fbe6da6 100644 --- a/frontend/lib/errors.ts +++ b/frontend/lib/errors.ts @@ -13,3 +13,158 @@ export class UserRejectedError extends Error { export function isUserRejectedError(error: unknown): error is UserRejectedError { return error instanceof UserRejectedError; } + +/** + * F-52: Map contract error codes to user-facing messages. + * + * Contracts (see C-48/C-49) return numeric `ContractError` codes on failed + * simulations. This module decodes those codes (and the raw host error + * strings that wrap them) into friendly copy suitable for toasts. + */ + +/** Numeric `ContractError` variants returned by the contracts. */ +export enum ContractError { + Unknown = 0, + Unauthorized = 1, + NotFound = 2, + AlreadyExists = 3, + InvalidInput = 4, + InsufficientFunds = 5, + InsufficientAllowance = 6, + DeadlineExpired = 7, + Paused = 8, + Overflow = 9, + NotInitialized = 10, + AlreadyInitialized = 11, + InvalidState = 12, + Unsupported = 13, +} + +/** Friendly, user-facing copy for every `ContractError` variant. */ +export const CONTRACT_ERROR_MESSAGES: Record = { + [ContractError.Unknown]: + "Something went wrong while processing the transaction. Please try again.", + [ContractError.Unauthorized]: + "You are not authorized to perform this action.", + [ContractError.NotFound]: + "We couldn't find the requested item. It may have been removed.", + [ContractError.AlreadyExists]: + "This item already exists.", + [ContractError.InvalidInput]: + "Some of the information provided is invalid. Please review and try again.", + [ContractError.InsufficientFunds]: + "You don't have enough funds to complete this transaction.", + [ContractError.InsufficientAllowance]: + "You haven't approved enough allowance for this transaction.", + [ContractError.DeadlineExpired]: + "This transaction has expired. Please try again.", + [ContractError.Paused]: + "This feature is temporarily paused. Please try again later.", + [ContractError.Overflow]: + "The amount is too large to process.", + [ContractError.NotInitialized]: + "This contract hasn't been set up yet. Please try again later.", + [ContractError.AlreadyInitialized]: + "This contract has already been set up.", + [ContractError.InvalidState]: + "This action can't be performed in the current state.", + [ContractError.Unsupported]: + "This operation isn't supported.", +}; + +/** Fallback copy when no specific contract error can be decoded. */ +export const DEFAULT_ERROR_MESSAGE = + "Something went wrong. Please try again."; + +/** + * Extract a numeric `ContractError` code from a raw host/simulation error. + * + * Host errors surface the code in a few shapes, e.g.: + * - `Error(Contract, #5)` + * - `HostError: Error(Contract, #5)` + * - `contract error: 5` + * - a bare numeric code + */ +export function decodeContractError(error: unknown): ContractError | null { + if (error == null) return null; + + if (typeof error === "number") { + return isContractError(error) ? error : null; + } + + const message = + typeof error === "string" + ? error + : error instanceof Error + ? error.message + : typeof (error as { message?: unknown }).message === "string" + ? (error as { message: string }).message + : null; + + if (!message) return null; + + // Matches `Error(Contract, #5)` and similar host error wrappers. + const contractMatch = message.match(/Error\(\s*Contract\s*,\s*#?(\d+)\s*\)/i); + if (contractMatch) { + const code = Number(contractMatch[1]); + return isContractError(code) ? code : null; + } + + // Matches `contract error: 5` / `contracterror 5` style messages. + const labelledMatch = message.match(/contract\s*error[:\s#]*(\d+)/i); + if (labelledMatch) { + const code = Number(labelledMatch[1]); + return isContractError(code) ? code : null; + } + + return null; +} + +/** + * Returns the `ContractError` code embedded in a raw error string, or `null` + * when the string does not describe a contract error. + */ +export function parseContractErrorCode(raw: string): ContractError | null { + return decodeContractError(raw); +} + +/** + * Error thrown for a decoded contract error. The message is the user-facing copy, + * and `code` keeps the numeric variant for callers that branch on it. + */ +export class ContractErrorException extends Error { + readonly code: ContractError; + + constructor(code: ContractError) { + super(CONTRACT_ERROR_MESSAGES[code] ?? DEFAULT_ERROR_MESSAGE); + this.name = "ContractErrorException"; + this.code = code; + Object.setPrototypeOf(this, ContractErrorException.prototype); + } +} + +/** + * Map any thrown/simulated error to a friendly, user-facing message. + * Falls back to the raw message when it is already human-readable, and to + * {@link DEFAULT_ERROR_MESSAGE} otherwise. + */ +export function getErrorMessage(error: unknown): string { + const code = decodeContractError(error); + if (code !== null) { + return CONTRACT_ERROR_MESSAGES[code] ?? DEFAULT_ERROR_MESSAGE; + } + + if (typeof error === "string" && error.trim()) { + return error; + } + + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + return DEFAULT_ERROR_MESSAGE; +} + +function isContractError(value: number): value is ContractError { + return Number.isInteger(value) && value in CONTRACT_ERROR_MESSAGES; +} diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index ec0d8f2f4..df41549de 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -218,10 +218,34 @@ export async function buildSorobanInvocation( throw decodeContractError(new Error(simulationErrorText(simResult))); } +const TX_TIMEOUT_MS = 30_000; +const TX_INITIAL_DELAY_MS = 1_000; +const TX_MAX_DELAY_MS = 5_000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Extracts the result code (e.g. `txBAD_SEQ`) from the TransactionResult XDR + * returned by a rejected `sendTransaction` call. + */ +function decodeSendErrorResult(errorResult: unknown): string { + try { + const result = errorResult as { result: () => { switch: () => { name: string } } }; + + return result.result().switch().name; + } catch { + return "unknown"; + } +} + /** * Submits a signed XDR transaction to Soroban RPC and waits for ledger confirmation. - * Polls `getTransaction` with exponential backoff, bounded to a 30s timeout, and - * reports each state transition (pending/success/failed/timeout) via `onStatus`. + * - `TRY_AGAIN_LATER` is re-sent with exponential backoff until the timeout. + * - `DUPLICATE` means the network already has the transaction, so it goes straight to polling. + * - `ERROR` is decoded from `errorResult` and reported as a failure. + * Polls `getTransaction` with exponential backoff, bounded to a 30s timeout. Transient + * RPC errors during polling are tolerated until the timeout. Reports each state + * transition (pending/success/failed/timeout) via `onStatus`. * Returns the TransactionResult containing txHash, ledger, and return value. */ export async function submitTransaction( @@ -241,9 +265,30 @@ export async function submitTransaction( const hash = transaction.hash(); const txHash = hash.toString("hex"); - const response = await server.sendTransaction(transaction); + const submitStartedAt = Date.now(); + let delay = TX_INITIAL_DELAY_MS; + let response = await server.sendTransaction(transaction); + + while (response.status === "TRY_AGAIN_LATER") { + if (Date.now() - submitStartedAt >= TX_TIMEOUT_MS) { + const error = "Transaction submission failed: RPC still busy (TRY_AGAIN_LATER)"; + onStatus?.({ status: "failed", txHash, error }); + throw decodeContractError(new Error(error)); + } + + await sleep(delay); + delay = Math.min(delay * 2, TX_MAX_DELAY_MS); - if (response.status !== "PENDING") { + response = await server.sendTransaction(transaction); + } + + if (response.status === "ERROR") { + const error = `Transaction submission failed: ${decodeSendErrorResult(response.errorResult)}`; + onStatus?.({ status: "failed", txHash, error }); + throw decodeContractError(new Error(error)); + } + + if (response.status !== "PENDING" && response.status !== "DUPLICATE") { const error = `Transaction submission failed with status: ${response.status}`; onStatus?.({ status: "failed", txHash, error }); throw decodeContractError(new Error(error)); @@ -251,24 +296,30 @@ export async function submitTransaction( onStatus?.({ status: "pending", txHash }); - const TIMEOUT_MS = 30_000; - const INITIAL_DELAY_MS = 1_000; - const MAX_DELAY_MS = 5_000; - const startedAt = Date.now(); - let delay = INITIAL_DELAY_MS; + const pollStartedAt = Date.now(); + delay = TX_INITIAL_DELAY_MS; - let result = await server.getTransaction(hash); + let result; - while (result.status === "PENDING" || result.status === "NOT_FOUND") { - if (Date.now() - startedAt >= TIMEOUT_MS) { - onStatus?.({ status: "timeout", txHash }); - throw new TransactionTimeoutError(txHash, TIMEOUT_MS); + for (;;) { + try { + result = await server.getTransaction(hash); + } catch { + // A transient RPC error (network blip) must not abort the wait; keep polling until the timeout. + result = undefined; + } + + if (result && result.status !== "PENDING" && result.status !== "NOT_FOUND") { + break; } - await new Promise((resolve) => setTimeout(resolve, delay)); - delay = Math.min(delay * 2, MAX_DELAY_MS); + if (Date.now() - pollStartedAt >= TX_TIMEOUT_MS) { + onStatus?.({ status: "timeout", txHash }); + throw new TransactionTimeoutError(txHash, TX_TIMEOUT_MS); + } - result = await server.getTransaction(hash); + await sleep(delay); + delay = Math.min(delay * 2, TX_MAX_DELAY_MS); } if (result.status !== "SUCCESS") { From 843989478fb5dc466fc232c29c3f075c26604cce Mon Sep 17 00:00:00 2001 From: kaivegascod-a11y Date: Mon, 19 Oct 2026 03:20:33 +0000 Subject: [PATCH 2/4] fix: #1428 F-90: Route all env access through lib/config.ts and fix the contract-id variable mismatch - Declare every NEXT_PUBLIC_* variable in the zod schema and read it only via config - Rename the factory contract id to NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID, matching .env.example and the README - Require the mainnet passphrase and an HTTPS RPC URL when the network is mainnet - Add scripts/check-env-example.mjs and run it in frontend CI to keep .env.example and the schema in sync Closes #1428 --- .github/workflows/frontend-ci.yml | 4 ++ frontend/.env.example | 11 ++++ frontend/README.md | 5 +- frontend/__tests__/lib/config.test.ts | 70 ++++++++++++++++++++++ frontend/app/admin/page.tsx | 15 ++--- frontend/hooks/useMarketEvents.ts | 3 +- frontend/jest.setup.ts | 3 + frontend/lib/config.ts | 83 +++++++++++++++++++++----- frontend/lib/stellar.ts | 28 +++------ frontend/package.json | 3 +- frontend/scripts/check-env-example.mjs | 32 ++++++++++ 11 files changed, 210 insertions(+), 47 deletions(-) create mode 100644 frontend/__tests__/lib/config.test.ts create mode 100644 frontend/scripts/check-env-example.mjs diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 860f33411..3a546ddff 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -39,6 +39,10 @@ jobs: - name: Install dependencies run: npm ci + # ── Env contract ──────────────────────────────────────────────────────── + - name: Check .env.example matches the config schema + run: node scripts/check-env-example.mjs + # ── Lint ──────────────────────────────────────────────────────────────── - name: Lint run: npm run lint diff --git a/frontend/.env.example b/frontend/.env.example index 1fb4fcac4..6a605a6ba 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -26,3 +26,14 @@ NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org # Contract ID of the deployed MarketFactory contract. # Replace with the ID returned when deploying the contract to your target network. NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID=CDXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# ── Optional ───────────────────────────────────────────────────────────────── + +# Comma-separated Stellar addresses with access to the /admin page. +#NEXT_PUBLIC_ADMIN_ADDRESSES=GADMIN1...,GADMIN2... + +# Admin key sent with admin requests. Defaults to the connected wallet address. +#NEXT_PUBLIC_ADMIN_API_KEY= + +# Set to "true" to build with wallet and chain mocks (used by Playwright e2e tests). +#NEXT_PUBLIC_E2E=false diff --git a/frontend/README.md b/frontend/README.md index 60f2a8935..642c8af4a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -57,9 +57,12 @@ Create a `.env.local` file in this directory (never commit it — it is in `.git |---|---|---|---| | `NEXT_PUBLIC_API_URL` | Yes | Base URL of the backend REST API. No trailing slash. | `http://localhost:3001` | | `NEXT_PUBLIC_STELLAR_NETWORK` | Yes | Stellar network to connect to. `testnet` or `mainnet`. | `testnet` | -| `NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE` | Yes | Network passphrase matching `NEXT_PUBLIC_STELLAR_NETWORK`. | `Test SDF Network ; September 2015` | +| `NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE` | No | Network passphrase matching `NEXT_PUBLIC_STELLAR_NETWORK`. Derived from the network when unset. Must be the mainnet passphrase on `mainnet`. | `Test SDF Network ; September 2015` | | `NEXT_PUBLIC_SOROBAN_RPC_URL` | Yes | Soroban RPC endpoint for building and simulating transactions. | `https://soroban-testnet.stellar.org` | | `NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID` | Yes | Contract ID of the deployed `MarketFactory` contract. | `CDXXX...` | +| `NEXT_PUBLIC_ADMIN_ADDRESSES` | No | Comma-separated Stellar addresses with access to `/admin`. | `GADMIN1...,GADMIN2...` | +| `NEXT_PUBLIC_ADMIN_API_KEY` | No | Admin key sent with admin requests. Defaults to the connected wallet address. | | +| `NEXT_PUBLIC_E2E` | No | Set to `true` to build with wallet and chain mocks (Playwright). | `false` | ### Example `.env.local` for Testnet diff --git a/frontend/__tests__/lib/config.test.ts b/frontend/__tests__/lib/config.test.ts new file mode 100644 index 000000000..b499160a4 --- /dev/null +++ b/frontend/__tests__/lib/config.test.ts @@ -0,0 +1,70 @@ +const MAINNET_PASSPHRASE = "Public Global Stellar Network ; September 2015"; +const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015"; + +const ORIGINAL_ENV = process.env; + +function loadConfig(env: Record) { + process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_API_URL: "http://localhost:3001", ...env }; + + let loaded: typeof import("@/lib/config") | undefined; + jest.isolateModules(() => { + loaded = require("@/lib/config"); + }); + + return loaded as typeof import("@/lib/config"); +} + +describe("lib/config", () => { + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + it("exposes the factory contract ID under the name used in .env.example", () => { + const config = loadConfig({ NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID: "CFACTORY" }); + + expect(config.MARKET_FACTORY_CONTRACT_ID).toBe("CFACTORY"); + }); + + it("derives the passphrase from the network when none is set", () => { + const config = loadConfig({ NEXT_PUBLIC_STELLAR_NETWORK: "testnet" }); + + expect(config.NETWORK_PASSPHRASE).toBe(TESTNET_PASSPHRASE); + }); + + it("rejects mainnet with a non-mainnet passphrase", () => { + expect(() => + loadConfig({ + NEXT_PUBLIC_STELLAR_NETWORK: "mainnet", + NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE: TESTNET_PASSPHRASE, + NEXT_PUBLIC_SOROBAN_RPC_URL: "https://rpc.example.org", + }) + ).toThrow("NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE"); + }); + + it("rejects mainnet with a non-HTTPS RPC URL", () => { + expect(() => + loadConfig({ + NEXT_PUBLIC_STELLAR_NETWORK: "mainnet", + NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE: MAINNET_PASSPHRASE, + NEXT_PUBLIC_SOROBAN_RPC_URL: "http://rpc.example.org", + }) + ).toThrow("NEXT_PUBLIC_SOROBAN_RPC_URL"); + }); + + it("accepts mainnet with the mainnet passphrase and an HTTPS RPC URL", () => { + const config = loadConfig({ + NEXT_PUBLIC_STELLAR_NETWORK: "mainnet", + NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE: MAINNET_PASSPHRASE, + NEXT_PUBLIC_SOROBAN_RPC_URL: "https://rpc.example.org", + }); + + expect(config.NETWORK_PASSPHRASE).toBe(MAINNET_PASSPHRASE); + expect(config.SOROBAN_RPC_URL).toBe("https://rpc.example.org"); + }); + + it("parses the comma-separated admin address list", () => { + const config = loadConfig({ NEXT_PUBLIC_ADMIN_ADDRESSES: "GADMIN1, GADMIN2," }); + + expect(config.ADMIN_ADDRESSES).toEqual(["GADMIN1", "GADMIN2"]); + }); +}); diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 21584883b..5cc26a20a 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -15,19 +15,12 @@ import { updateOracle, } from "@/lib/api"; import { truncateAddress } from "@/lib/stellar"; +import { ADMIN_ADDRESSES, ADMIN_API_KEY } from "@/lib/config"; // ─── Admin access list ──────────────────────────────────────────────────────── -/** - * Comma-separated Stellar addresses that have admin access. - * Set NEXT_PUBLIC_ADMIN_ADDRESSES in your .env.local file. - */ -const ADMIN_ADDRESSES: string[] = ( - process.env.NEXT_PUBLIC_ADMIN_ADDRESSES ?? "" -) - .split(",") - .map((a) => a.trim()) - .filter(Boolean); +// `ADMIN_ADDRESSES` comes from NEXT_PUBLIC_ADMIN_ADDRESSES in `lib/config.ts` +// (set it in your .env.local file). function isAdmin(address: string | null): boolean { if (!address) return false; @@ -507,7 +500,7 @@ export default function AdminPage(): JSX.Element { * use a signed challenge or a proper JWT. The env var allows overriding * for development/testing. */ - const adminKey = process.env.NEXT_PUBLIC_ADMIN_API_KEY ?? address ?? ""; + const adminKey = ADMIN_API_KEY ?? address ?? ""; if (!isConnected || !address) { return ( diff --git a/frontend/hooks/useMarketEvents.ts b/frontend/hooks/useMarketEvents.ts index 6158a540e..2d23ef1c4 100644 --- a/frontend/hooks/useMarketEvents.ts +++ b/frontend/hooks/useMarketEvents.ts @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { fetchMarketStats } from "@/lib/api"; import { MarketStats } from "@/lib/api"; +import { API_BASE_URL } from "@/lib/config"; export interface UseMarketEventsResult { poolA: bigint | null; @@ -22,7 +23,7 @@ const SSE_RECONNECT_MAX_MS = 30000; * Falls back to a relative path when no API base URL is configured. */ function buildMarketEventsUrl(market_id: string): string { - const base = process.env.NEXT_PUBLIC_API_URL ?? ""; + const base = API_BASE_URL; return `${base}/markets/${encodeURIComponent(market_id)}/events`; } diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index d0de870dc..8deca69d7 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -1 +1,4 @@ import "@testing-library/jest-dom"; + +// lib/config.ts validates the environment when it loads, so tests need a valid API URL. +process.env.NEXT_PUBLIC_API_URL ??= "http://localhost:3001"; diff --git a/frontend/lib/config.ts b/frontend/lib/config.ts index 7af82ca77..8dd185773 100644 --- a/frontend/lib/config.ts +++ b/frontend/lib/config.ts @@ -6,20 +6,62 @@ import { z } from "zod"; * All `NEXT_PUBLIC_*` reads should go through this module instead of reading * `process.env` ad hoc. Validation runs once at module load (startup) so a * missing/invalid required variable fails fast with a clear message. + * + * Every variable must be declared in `envSchema` and listed in `.env.example`. + * `scripts/check-env-example.mjs` enforces both in CI. */ const isDev = process.env.NODE_ENV !== "production"; -const envSchema = z.object({ - NEXT_PUBLIC_API_URL: z.string().url({ - message: "NEXT_PUBLIC_API_URL must be a valid URL (e.g. http://localhost:3001)", - }), - NEXT_PUBLIC_STELLAR_NETWORK: z - .enum(["testnet", "mainnet", "futurenet"]) - .default("testnet"), - NEXT_PUBLIC_SOROBAN_RPC_URL: z.string().url().optional(), - NEXT_PUBLIC_MARKET_CONTRACT_ID: z.string().optional(), -}); +/** Canonical network passphrases keyed by network name. */ +const NETWORK_PASSPHRASES = { + testnet: "Test SDF Network ; September 2015", + mainnet: "Public Global Stellar Network ; September 2015", + futurenet: "Test SDF Future Network ; October 2022", +} as const; + +const DEFAULT_SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; + +const envSchema = z + .object({ + NEXT_PUBLIC_API_URL: z.string().url({ + message: "NEXT_PUBLIC_API_URL must be a valid URL (e.g. http://localhost:3001)", + }), + NEXT_PUBLIC_STELLAR_NETWORK: z + .enum(["testnet", "mainnet", "futurenet"]) + .default("testnet"), + NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE: z.string().min(1).optional(), + NEXT_PUBLIC_SOROBAN_RPC_URL: z.string().url().optional(), + NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID: z.string().optional(), + /** Comma-separated Stellar addresses with admin access. */ + NEXT_PUBLIC_ADMIN_ADDRESSES: z.string().optional(), + /** Overrides the admin key sent with requests (development/testing only). */ + NEXT_PUBLIC_ADMIN_API_KEY: z.string().optional(), + /** Set to "true" by the Playwright build to swap in wallet and chain mocks. */ + NEXT_PUBLIC_E2E: z.enum(["true", "false"]).optional(), + }) + .superRefine((env, ctx) => { + if (env.NEXT_PUBLIC_STELLAR_NETWORK !== "mainnet") return; + + const passphrase = + env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? NETWORK_PASSPHRASES.mainnet; + + if (passphrase !== NETWORK_PASSPHRASES.mainnet) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE"], + message: `must be "${NETWORK_PASSPHRASES.mainnet}" when NEXT_PUBLIC_STELLAR_NETWORK=mainnet`, + }); + } + + if (!env.NEXT_PUBLIC_SOROBAN_RPC_URL?.startsWith("https://")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["NEXT_PUBLIC_SOROBAN_RPC_URL"], + message: "must be set to an https:// URL when NEXT_PUBLIC_STELLAR_NETWORK=mainnet", + }); + } + }); export type EnvConfig = z.infer; @@ -27,8 +69,12 @@ function loadConfig(): EnvConfig { const parsed = envSchema.safeParse({ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_STELLAR_NETWORK: process.env.NEXT_PUBLIC_STELLAR_NETWORK, + NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE: process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE, NEXT_PUBLIC_SOROBAN_RPC_URL: process.env.NEXT_PUBLIC_SOROBAN_RPC_URL, - NEXT_PUBLIC_MARKET_CONTRACT_ID: process.env.NEXT_PUBLIC_MARKET_CONTRACT_ID, + NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID: process.env.NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID, + NEXT_PUBLIC_ADMIN_ADDRESSES: process.env.NEXT_PUBLIC_ADMIN_ADDRESSES, + NEXT_PUBLIC_ADMIN_API_KEY: process.env.NEXT_PUBLIC_ADMIN_API_KEY, + NEXT_PUBLIC_E2E: process.env.NEXT_PUBLIC_E2E, }); if (!parsed.success) { @@ -57,6 +103,15 @@ export const config: EnvConfig = loadConfig(); export const API_BASE_URL: string = config.NEXT_PUBLIC_API_URL; export const STELLAR_NETWORK: EnvConfig["NEXT_PUBLIC_STELLAR_NETWORK"] = config.NEXT_PUBLIC_STELLAR_NETWORK; -export const SOROBAN_RPC_URL: string | undefined = config.NEXT_PUBLIC_SOROBAN_RPC_URL; -export const MARKET_CONTRACT_ID: string | undefined = - config.NEXT_PUBLIC_MARKET_CONTRACT_ID; +export const NETWORK_PASSPHRASE: string = + config.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? NETWORK_PASSPHRASES[STELLAR_NETWORK]; +export const SOROBAN_RPC_URL: string = + config.NEXT_PUBLIC_SOROBAN_RPC_URL ?? DEFAULT_SOROBAN_RPC_URL; +export const MARKET_FACTORY_CONTRACT_ID: string | undefined = + config.NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID; +export const ADMIN_ADDRESSES: string[] = (config.NEXT_PUBLIC_ADMIN_ADDRESSES ?? "") + .split(",") + .map((address) => address.trim()) + .filter(Boolean); +export const ADMIN_API_KEY: string | undefined = config.NEXT_PUBLIC_ADMIN_API_KEY; +export const E2E_ENABLED: boolean = config.NEXT_PUBLIC_E2E === "true"; diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index df41549de..a0aefa59e 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -1,5 +1,10 @@ import type { Transaction } from "@stellar/stellar-sdk"; import { ContractError, ContractErrorException, parseContractErrorCode } from "./errors"; +import { + NETWORK_PASSPHRASE, + SOROBAN_RPC_URL, + STELLAR_NETWORK, +} from "./config"; // The SDK is large; load it only when a transaction is actually built or decoded // so pages that just read the network constants below don't ship it. @@ -13,28 +18,14 @@ const loadSdk = (): Promise => import("@s * lowercase so case-sensitive comparisons (e.g. against Freighter's network * string) don't trigger a false mismatch banner. */ -export const NETWORK = (process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? "testnet").toLowerCase(); - -/** - * Canonical network passphrases keyed by normalized network name. - * Used to derive the passphrase from the network name when an explicit - * NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE is not provided. - */ -const NETWORK_PASSPHRASES: Record = { - testnet: "Test SDF Network ; September 2015", - mainnet: "Public Global Stellar Network ; September 2015", - futurenet: "Test SDF Future Network ; October 2022", -}; +export const NETWORK = STELLAR_NETWORK.toLowerCase(); /** * The Stellar network passphrase this app is configured to operate against. * Wallet network mismatches are detected by comparing against this passphrase. - * Falls back to the passphrase derived from NETWORK when not explicitly set. + * Read from `lib/config.ts`, which derives it from the network when not set. */ -export const NETWORK_PASSPHRASE = - process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? - NETWORK_PASSPHRASES[NETWORK] ?? - NETWORK_PASSPHRASES.testnet; +export { NETWORK_PASSPHRASE }; export const NETWORK_NAME = NETWORK; @@ -50,8 +41,7 @@ export function isSameNetwork(a: string | null | undefined, b: string | null | u return a.toLowerCase() === b.toLowerCase(); } -export const SOROBAN_RPC_URL = - process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org"; +export { SOROBAN_RPC_URL }; /** * Base URL for the stellar.expert explorer, derived from the configured network. diff --git a/frontend/package.json b/frontend/package.json index 70d026abc..3c8f5fe40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,8 @@ "next": "14.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", - "recharts": "^2.12.0" + "recharts": "^2.12.0", + "zod": "^3.23.8" }, "devDependencies": { "@next/bundle-analyzer": "14.2.0", diff --git a/frontend/scripts/check-env-example.mjs b/frontend/scripts/check-env-example.mjs new file mode 100644 index 000000000..195931a7a --- /dev/null +++ b/frontend/scripts/check-env-example.mjs @@ -0,0 +1,32 @@ +// Fails when `.env.example` and the zod schema in `lib/config.ts` disagree on +// which NEXT_PUBLIC_* variables exist. Run from the frontend directory. +import { readFileSync } from "node:fs"; + +const exampleKeys = new Set( + readFileSync(".env.example", "utf8") + .split("\n") + .map((line) => line.match(/^#?\s*(NEXT_PUBLIC_[A-Z0-9_]+)=/)?.[1]) + .filter(Boolean) +); + +// Schema and parse-input keys are the only lines that start with `NEXT_PUBLIC_X:`. +const schemaKeys = new Set( + [...readFileSync("lib/config.ts", "utf8").matchAll(/^\s+(NEXT_PUBLIC_[A-Z0-9_]+):/gm)].map( + (match) => match[1] + ) +); + +const undocumented = [...schemaKeys].filter((key) => !exampleKeys.has(key)); +const undeclared = [...exampleKeys].filter((key) => !schemaKeys.has(key)); + +if (undocumented.length > 0 || undeclared.length > 0) { + if (undocumented.length > 0) { + console.error(`Declared in lib/config.ts but missing from .env.example: ${undocumented.join(", ")}`); + } + if (undeclared.length > 0) { + console.error(`Listed in .env.example but not declared in lib/config.ts: ${undeclared.join(", ")}`); + } + process.exit(1); +} + +console.log(`.env.example and lib/config.ts agree on ${schemaKeys.size} variables.`); From 814eff5174c5bffe9c7509c15804928aa67f5e07 Mon Sep 17 00:00:00 2001 From: kaivegascod-a11y Date: Mon, 19 Oct 2026 03:21:57 +0000 Subject: [PATCH 3/4] fix: #1429 F-91: Unwrap the backend response envelope in lib/api.ts and validate responses with zod - Unwrap `data` from the backend envelope and expose page, limit and nextCursor on list responses (fetchMarkets now resolves to Paginated) - Validate every response with a zod schema; a mismatch throws ApiResponseError - Read `estimatedPayout` from the payout estimate, matching the backend - Serve the real envelope from the Playwright mock API Closes #1429 --- frontend/__tests__/api.test.ts | 94 +++++++++--- frontend/e2e/mock-api.mjs | 11 +- frontend/hooks/useMarkets.ts | 2 +- frontend/lib/api.ts | 258 ++++++++++++++++++++++++++------- 4 files changed, 288 insertions(+), 77 deletions(-) diff --git a/frontend/__tests__/api.test.ts b/frontend/__tests__/api.test.ts index a06ef78a0..7a58c8302 100644 --- a/frontend/__tests__/api.test.ts +++ b/frontend/__tests__/api.test.ts @@ -10,6 +10,7 @@ import { fetchUserPositions, createMarketMeta, ApiError, + ApiResponseError, Market, Bet, MarketStats, @@ -17,6 +18,9 @@ import { PortfolioSummary, } from "@/lib/api"; +// Response bodies below follow the envelope the backend controllers send: +// `{ data, page, limit }` for lists and `{ data }` for single values. + const MARKET: Market = { id: "mkt-1", contractAddress: "CA1", @@ -71,12 +75,12 @@ const SUMMARY: PortfolioSummary = { roi: 0, }; -function mockOk(body: unknown) { +function mockJson(body: unknown, status = 200) { return jest.fn().mockResolvedValue({ - ok: true, - status: 200, + ok: status >= 200 && status < 300, + status, json: () => Promise.resolve(body), - text: () => Promise.resolve(""), + text: () => Promise.resolve(JSON.stringify(body)), }); } @@ -84,19 +88,63 @@ function mockError(status: number, message = "Server error") { return jest.fn().mockResolvedValue({ ok: false, status, - json: () => Promise.resolve({ message }), + json: () => Promise.resolve({ error: message }), text: () => Promise.resolve(message), }); } afterEach(() => jest.restoreAllMocks()); -// ─── fetchMarkets ───────────────────────────────────────────────────────────── +// ─── envelope handling ──────────────────────────────────────────────────────── + +test("fetchMarkets: unwraps data and exposes pagination metadata", async () => { + global.fetch = mockJson({ data: [MARKET], page: 2, limit: 10 }); + + const result = await fetchMarkets({ page: 2, limit: 10 }); + + expect(result.data).toEqual([MARKET]); + expect(result.page).toBe(2); + expect(result.limit).toBe(10); +}); + +test("fetchMarkets: exposes nextCursor for cursor-paginated responses", async () => { + global.fetch = mockJson({ data: [MARKET], nextCursor: "abc", limit: 20 }); -test("fetchMarkets: returns market array", async () => { - global.fetch = mockOk([MARKET]); const result = await fetchMarkets(); - expect(result).toEqual([MARKET]); + + expect(result.nextCursor).toBe("abc"); + expect(result.page).toBeUndefined(); +}); + +test("fetchMarketById: unwraps a single-value envelope", async () => { + global.fetch = mockJson({ data: MARKET }); + await expect(fetchMarketById("mkt-1")).resolves.toEqual(MARKET); +}); + +test("a body without an envelope is validated as-is", async () => { + global.fetch = mockJson(MARKET); + await expect(fetchMarketById("mkt-1")).resolves.toEqual(MARKET); +}); + +test("throws ApiResponseError when the payload does not match the schema", async () => { + global.fetch = mockJson({ data: { ...MARKET, poolA: 1000 } }); + + const err = await fetchMarketById("mkt-1").catch((e) => e); + + expect(err).toBeInstanceOf(ApiResponseError); + expect(err.message).toContain("poolA"); +}); + +test("rejects BigInt fields that are not decimal strings", async () => { + global.fetch = mockJson({ data: { ...MARKET, totalPool: "1.5e9" } }); + await expect(fetchMarketById("mkt-1")).rejects.toBeInstanceOf(ApiResponseError); +}); + +// ─── fetchMarkets ───────────────────────────────────────────────────────────── + +test("fetchMarkets: requests the market list", async () => { + global.fetch = mockJson({ data: [MARKET] }); + await fetchMarkets(); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining("/api/markets"), expect.any(Object) @@ -104,7 +152,7 @@ test("fetchMarkets: returns market array", async () => { }); test("fetchMarkets: passes query params", async () => { - global.fetch = mockOk([MARKET]); + global.fetch = mockJson({ data: [MARKET] }); await fetchMarkets({ status: "Open", weightClass: "Heavyweight", page: 2, limit: 10 }); const url = (global.fetch as jest.Mock).mock.calls[0][0] as string; expect(url).toContain("status=Open"); @@ -122,7 +170,7 @@ test("fetchMarkets: throws ApiError on 500", async () => { // ─── fetchMarketById ────────────────────────────────────────────────────────── test("fetchMarketById: returns market", async () => { - global.fetch = mockOk(MARKET); + global.fetch = mockJson({ data: MARKET }); const result = await fetchMarketById("mkt-1"); expect(result).toEqual(MARKET); expect(global.fetch).toHaveBeenCalledWith( @@ -142,7 +190,7 @@ test("fetchMarketById: throws ApiError with status 404", async () => { // ─── fetchMarketStats ───────────────────────────────────────────────────────── test("fetchMarketStats: returns stats", async () => { - global.fetch = mockOk(STATS); + global.fetch = mockJson({ data: STATS }); const result = await fetchMarketStats("mkt-1"); expect(result).toEqual(STATS); expect(global.fetch).toHaveBeenCalledWith( @@ -153,10 +201,8 @@ test("fetchMarketStats: returns stats", async () => { // ─── fetchMarketBets ────────────────────────────────────────────────────────── -// ─── fetchMarketBets ────────────────────────────────────────────────────────── - test("fetchMarketBets: returns bets array", async () => { - global.fetch = mockOk([BET]); + global.fetch = mockJson({ data: [BET], page: 1, limit: 20 }); const result = await fetchMarketBets("mkt-1"); expect(result).toEqual([BET]); expect(global.fetch).toHaveBeenCalledWith( @@ -166,7 +212,7 @@ test("fetchMarketBets: returns bets array", async () => { }); test("fetchMarketBets: passes outcome filter query param", async () => { - global.fetch = mockOk([BET]); + global.fetch = mockJson({ data: [BET] }); await fetchMarketBets("mkt-1", { outcome: "FighterA" }); const url = (global.fetch as jest.Mock).mock.calls[0][0] as string; expect(url).toContain("outcome=FighterA"); @@ -175,7 +221,7 @@ test("fetchMarketBets: passes outcome filter query param", async () => { // ─── fetchBetsByAddress ─────────────────────────────────────────────────────── test("fetchBetsByAddress: returns bets for address", async () => { - global.fetch = mockOk([BET]); + global.fetch = mockJson({ data: [BET] }); const result = await fetchBetsByAddress("GADDR1"); expect(result).toEqual([BET]); expect(global.fetch).toHaveBeenCalledWith( @@ -187,7 +233,7 @@ test("fetchBetsByAddress: returns bets for address", async () => { // ─── fetchPortfolioSummary ──────────────────────────────────────────────────── test("fetchPortfolioSummary: returns summary", async () => { - global.fetch = mockOk(SUMMARY); + global.fetch = mockJson({ data: SUMMARY }); const result = await fetchPortfolioSummary("GADDR1"); expect(result).toEqual(SUMMARY); expect(global.fetch).toHaveBeenCalledWith( @@ -198,15 +244,15 @@ test("fetchPortfolioSummary: returns summary", async () => { // ─── fetchPayoutEstimate ────────────────────────────────────────────────────── -test("fetchPayoutEstimate: returns BigInt from string estimate", async () => { - global.fetch = mockOk({ estimate: "150000000" }); +test("fetchPayoutEstimate: reads estimatedPayout and returns a BigInt", async () => { + global.fetch = mockJson({ data: { estimatedPayout: "150000000" } }); const result = await fetchPayoutEstimate("mkt-1", "FighterA", BigInt(100000000)); expect(result).toBe(BigInt(150000000)); expect(typeof result).toBe("bigint"); }); test("fetchPayoutEstimate: passes correct query params", async () => { - global.fetch = mockOk({ estimate: "0" }); + global.fetch = mockJson({ data: { estimatedPayout: "0" } }); await fetchPayoutEstimate("mkt-1", "FighterB", BigInt(50000000)); const url = (global.fetch as jest.Mock).mock.calls[0][0] as string; expect(url).toContain("market_id=mkt-1"); @@ -222,7 +268,7 @@ test("fetchPayoutEstimate: throws ApiError on non-2xx", async () => { // ─── fetchOddsHistory ───────────────────────────────────────────────────────── test("fetchOddsHistory: returns snapshots array", async () => { - global.fetch = mockOk([SNAPSHOT]); + global.fetch = mockJson({ data: [SNAPSHOT] }); const result = await fetchOddsHistory("mkt-1"); expect(result).toEqual([SNAPSHOT]); expect(global.fetch).toHaveBeenCalledWith( @@ -251,7 +297,7 @@ test("fetchUserPositions: returns positions array", async () => { avgOdds: 1.5, open: true, }; - global.fetch = mockOk([position]); + global.fetch = mockJson({ data: [position] }); const result = await fetchUserPositions("GADDR1"); expect(result).toEqual([position]); expect(global.fetch).toHaveBeenCalledWith( @@ -277,7 +323,7 @@ test("createMarketMeta: sends POST request with market metadata", async () => { bettingEndsAt: "2026-07-01T19:00:00Z", oracleAddress: "GORACLE", }; - global.fetch = mockOk({ success: true, marketId: "mkt-1" }); + global.fetch = mockJson({ data: { success: true, marketId: "mkt-1" } }); const result = await createMarketMeta(input); expect(result).toEqual({ success: true, marketId: "mkt-1" }); expect(global.fetch).toHaveBeenCalledWith( diff --git a/frontend/e2e/mock-api.mjs b/frontend/e2e/mock-api.mjs index 80dff88db..3986076bd 100644 --- a/frontend/e2e/mock-api.mjs +++ b/frontend/e2e/mock-api.mjs @@ -83,7 +83,7 @@ function route(pathname) { if ((m = pathname.match(/^\/api\/markets\/([^/]+)\/stats$/))) return markets[m[1]] && stats(markets[m[1]]); if ((m = pathname.match(/^\/api\/markets\/([^/]+)\/odds-history$/))) return markets[m[1]] && oddsHistory(markets[m[1]]); - if (pathname === "/api/bets/payout-estimate") return { estimate: "0" }; + if (pathname === "/api/bets/payout-estimate") return { estimatedPayout: "0" }; if (/^\/api\/bets\/[^/]+\/portfolio$/.test(pathname)) return { totalStaked: "100000000", @@ -109,13 +109,18 @@ createServer((req, res) => { } const { pathname } = new URL(req.url ?? "/", `http://localhost:${PORT}`); - const body = route(pathname); + const payload = route(pathname); - if (body === undefined) { + if (payload === undefined) { res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "Not found" })); return; } + // Mirror the backend envelope: lists carry page metadata, single values do not. + const body = Array.isArray(payload) + ? { data: payload, page: 1, limit: 20 } + : { data: payload }; + res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body)); }).listen(PORT, () => { console.log(`E2E mock API listening on http://localhost:${PORT}`); diff --git a/frontend/hooks/useMarkets.ts b/frontend/hooks/useMarkets.ts index f81d227e4..91e2b45c9 100644 --- a/frontend/hooks/useMarkets.ts +++ b/frontend/hooks/useMarkets.ts @@ -52,7 +52,7 @@ export function useMarkets( setIsLoading(true); setError(null); try { - const data = await fetchMarkets(filtersRef.current); + const { data } = await fetchMarkets(filtersRef.current); setMarkets(data); } catch (err) { setError(err instanceof Error ? err : new Error('Unknown error')); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 89470b748..7ac926919 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,3 +1,6 @@ +import { z } from "zod"; +import { API_BASE_URL } from "./config"; + // ─── TYPES ──────────────────────────────────────────────────────────────────── export type MarketStatus = "Open" | "Locked" | "Resolved" | "Cancelled" | "Disputed"; @@ -104,7 +107,7 @@ export interface CreateMarketMetaInput { oracleAddress: string; } -// ─── ERROR ──────────────────────────────────────────────────────────────────── +// ─── ERRORS ─────────────────────────────────────────────────────────────────── export class ApiError extends Error { constructor(public status: number, message: string) { @@ -113,13 +116,164 @@ export class ApiError extends Error { } } -// ─── HELPERS ───────────────────────────────────────────────────────────────── +/** Thrown when a 2xx response does not match the schema for its endpoint. */ +export class ApiResponseError extends Error { + constructor(public path: string, public issues: z.ZodIssue[]) { + const details = issues + .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + .join("; "); + super(`Unexpected response from ${path}: ${details}`); + this.name = "ApiResponseError"; + } +} -import { API_BASE_URL } from "./config"; +// ─── RESPONSE SCHEMAS ───────────────────────────────────────────────────────── + +/** BigInt values are serialized as non-negative decimal strings by the backend. */ +const bigintString = z.string().regex(/^\d+$/, "expected a non-negative integer string"); + +const fighterSchema = z.object({ + name: z.string(), + record: z.string(), + nationality: z.string(), + weightClass: z.string(), +}); + +const marketStatusSchema = z.enum(["Open", "Locked", "Resolved", "Cancelled", "Disputed"]); +const outcomeSchema = z.enum(["FighterA", "FighterB", "Draw", "NoContest"]); +const betSideSchema = z.enum(["FighterA", "FighterB"]); + +const marketSchema = z.object({ + id: z.string(), + contractAddress: z.string(), + fighterA: fighterSchema, + fighterB: fighterSchema, + scheduledAt: z.string(), + bettingEndsAt: z.string(), + status: marketStatusSchema, + outcome: outcomeSchema.nullable(), + poolA: bigintString, + poolB: bigintString, + totalPool: bigintString, + oracleAddress: z.string(), + createdBy: z.string(), +}) satisfies z.ZodType; + +const betSchema = z.object({ + id: z.string(), + marketId: z.string(), + bettor: z.string(), + side: betSideSchema, + amount: bigintString, + placedAt: z.string(), + claimed: z.boolean(), + payout: bigintString.nullable(), +}) satisfies z.ZodType; + +const marketStatsSchema = z.object({ + totalBets: z.number().int().nonnegative(), + uniqueBettors: z.number().int().nonnegative(), + poolA: bigintString, + poolB: bigintString, + totalVolume: bigintString, + impliedOddsA: z.number(), + impliedOddsB: z.number(), +}) satisfies z.ZodType; + +const oddsSnapshotSchema = z.object({ + timestamp: z.string(), + poolA: bigintString, + poolB: bigintString, + oddsA: z.number(), + oddsB: z.number(), +}) satisfies z.ZodType; + +const portfolioSummarySchema = z.object({ + totalStaked: bigintString, + totalWinnings: bigintString, + pendingClaims: bigintString, + activeBets: z.number().int().nonnegative(), + completedBets: z.number().int().nonnegative(), + roi: z.number(), +}) satisfies z.ZodType; + +const userPositionSchema = z.object({ + marketId: z.string(), + market: marketSchema.optional(), + side: betSideSchema, + amount: bigintString, + avgOdds: z.number(), + open: z.boolean(), + placedAt: z.string().optional(), +}) satisfies z.ZodType; + +const payoutEstimateSchema = z.object({ estimatedPayout: bigintString }); + +const successSchema = z.object({ success: z.boolean() }); + +const createMarketMetaSchema = z.object({ + success: z.boolean(), + marketId: z.string().optional(), +}); + +const leaderboardEntrySchema = z.object({ + rank: z.number().int().positive(), + address: z.string(), + totalWinnings: bigintString, + totalBets: z.number().int().nonnegative(), + winCount: z.number().int().nonnegative(), + roi: z.number(), +}); + +const adminMarketResolutionSchema = z.object({ + marketId: z.string(), + contractAddress: z.string(), + fighterA: fighterSchema, + fighterB: fighterSchema, + scheduledAt: z.string(), + status: marketStatusSchema, +}); + +const oracleConfigSchema = z.object({ + id: z.string(), + address: z.string(), + name: z.string(), + active: z.boolean(), + createdAt: z.string(), +}); + +// ─── HELPERS ───────────────────────────────────────────────────────────────── const BASE = API_BASE_URL; -async function apiFetch(path: string, options?: RequestInit): Promise { +/** Pagination metadata the backend sends alongside `data` on list endpoints. */ +export interface PageMeta { + page?: number; + limit?: number; + nextCursor?: string | null; +} + +export interface Paginated extends PageMeta { + data: T; +} + +type Envelope = PageMeta & { data: unknown }; + +function isEnvelope(body: unknown): body is Envelope { + return typeof body === "object" && body !== null && "data" in body; +} + +/** + * Fetches `path`, unwraps the `{ data, ...meta }` envelope, and validates `data` + * against `schema`. Bodies without an envelope are validated as-is. Any mismatch + * throws `ApiResponseError` so a broken contract fails loudly instead of rendering + * garbage. + */ +async function request( + path: string, + schema: z.ZodType, + options?: RequestInit +): Promise> { const res = await fetch(`${BASE}${path}`, { headers: { "Content-Type": "application/json" }, ...options, @@ -128,7 +282,25 @@ async function apiFetch(path: string, options?: RequestInit): Promise { const text = await res.text().catch(() => res.statusText); throw new ApiError(res.status, text); } - return res.json() as Promise; + + const body: unknown = await res.json(); + const envelope: Envelope = isEnvelope(body) ? body : { data: body }; + const parsed = schema.safeParse(envelope.data); + + if (!parsed.success) { + throw new ApiResponseError(path, parsed.error.issues); + } + + return { + data: parsed.data, + page: envelope.page, + limit: envelope.limit, + nextCursor: envelope.nextCursor, + }; +} + +async function apiFetch(path: string, schema: z.ZodType, options?: RequestInit): Promise { + return (await request(path, schema, options)).data; } function toQueryString(params: Record): string { @@ -140,15 +312,21 @@ function toQueryString(params: Record): str return s ? `?${s}` : ""; } +const jsonHeaders = (adminKey: string) => ({ + "Content-Type": "application/json", + "x-admin-key": adminKey, +}); + // ─── API FUNCTIONS ──────────────────────────────────────────────────────────── /** * GET /api/markets - * Fetches the market list with optional filters and pagination. + * Fetches the market list with optional filters and pagination. Resolves to the + * markets together with the page metadata returned by the backend. */ -export async function fetchMarkets(params?: MarketQueryParams): Promise { +export async function fetchMarkets(params?: MarketQueryParams): Promise> { const qs = toQueryString(params as Record ?? {}); - return apiFetch(`/api/markets${qs}`); + return request(`/api/markets${qs}`, z.array(marketSchema)); } /** @@ -156,7 +334,7 @@ export async function fetchMarkets(params?: MarketQueryParams): Promise { - return apiFetch(`/api/markets/${market_id}`); + return apiFetch(`/api/markets/${market_id}`, marketSchema); } /** @@ -164,7 +342,7 @@ export async function fetchMarketById(market_id: string): Promise { * Fetches aggregated stats for a market. */ export async function fetchMarketStats(market_id: string): Promise { - return apiFetch(`/api/markets/${market_id}/stats`); + return apiFetch(`/api/markets/${market_id}/stats`, marketStatsSchema); } /** @@ -173,7 +351,7 @@ export async function fetchMarketStats(market_id: string): Promise */ export async function fetchMarketBets(market_id: string, filters?: MarketBetsFilters): Promise { const qs = toQueryString(filters as Record ?? {}); - return apiFetch(`/api/markets/${market_id}/bets${qs}`); + return apiFetch(`/api/markets/${market_id}/bets${qs}`, z.array(betSchema)); } /** @@ -181,7 +359,7 @@ export async function fetchMarketBets(market_id: string, filters?: MarketBetsFil * Fetches a user's full bet history. */ export async function fetchBetsByAddress(address: string): Promise { - return apiFetch(`/api/bets/${address}`); + return apiFetch(`/api/bets/${address}`, z.array(betSchema)); } /** @@ -189,7 +367,7 @@ export async function fetchBetsByAddress(address: string): Promise { * Fetches portfolio summary stats for a wallet address. */ export async function fetchPortfolioSummary(address: string): Promise { - return apiFetch(`/api/bets/${address}/portfolio`); + return apiFetch(`/api/bets/${address}/portfolio`, portfolioSummarySchema); } /** @@ -203,8 +381,8 @@ export async function fetchPayoutEstimate( amount: bigint ): Promise { const qs = toQueryString({ market_id, side, amount: amount.toString() }); - const data = await apiFetch<{ estimate: string }>(`/api/bets/payout-estimate${qs}`); - return BigInt(data.estimate); + const { estimatedPayout } = await apiFetch(`/api/bets/payout-estimate${qs}`, payoutEstimateSchema); + return BigInt(estimatedPayout); } /** @@ -212,7 +390,7 @@ export async function fetchPayoutEstimate( * Fetches historical odds snapshots for the market odds chart. */ export async function fetchOddsHistory(market_id: string): Promise { - return apiFetch(`/api/markets/${market_id}/odds-history`); + return apiFetch(`/api/markets/${market_id}/odds-history`, z.array(oddsSnapshotSchema)); } /** @@ -220,7 +398,7 @@ export async function fetchOddsHistory(market_id: string): Promise { - return apiFetch(`/api/users/${address}/positions`); + return apiFetch(`/api/users/${address}/positions`, z.array(userPositionSchema)); } /** @@ -228,7 +406,7 @@ export async function fetchUserPositions(address: string): Promise { - return apiFetch<{ success: boolean; marketId?: string }>(`/api/markets`, { + return apiFetch(`/api/markets`, createMarketMetaSchema, { method: "POST", body: JSON.stringify(data), }); @@ -252,7 +430,7 @@ export interface LeaderboardEntry { * Returns the top bettors ranked by total winnings for the given period. */ export async function fetchLeaderboard(period: LeaderboardPeriod): Promise { - return apiFetch(`/api/leaderboard?period=${period}`); + return apiFetch(`/api/leaderboard?period=${period}`, z.array(leaderboardEntrySchema)); } // ─── ADMIN ──────────────────────────────────────────────────────────────────── @@ -284,11 +462,8 @@ export interface ResolveMarketInput { * Returns markets awaiting resolution. Requires admin auth header. */ export async function fetchPendingResolutions(adminKey: string): Promise { - return apiFetch(`/api/admin/markets/pending`, { - headers: { - "Content-Type": "application/json", - "x-admin-key": adminKey, - }, + return apiFetch(`/api/admin/markets/pending`, z.array(adminMarketResolutionSchema), { + headers: jsonHeaders(adminKey), }); } @@ -301,12 +476,9 @@ export async function resolveMarket( outcome: Outcome, adminKey: string, ): Promise<{ success: boolean }> { - return apiFetch<{ success: boolean }>(`/api/admin/markets/${marketId}/resolve`, { + return apiFetch(`/api/admin/markets/${marketId}/resolve`, successSchema, { method: "POST", - headers: { - "Content-Type": "application/json", - "x-admin-key": adminKey, - }, + headers: jsonHeaders(adminKey), body: JSON.stringify({ outcome }), }); } @@ -316,11 +488,8 @@ export async function resolveMarket( * Returns the list of configured oracle addresses. Requires admin auth header. */ export async function fetchOracles(adminKey: string): Promise { - return apiFetch(`/api/admin/oracles`, { - headers: { - "Content-Type": "application/json", - "x-admin-key": adminKey, - }, + return apiFetch(`/api/admin/oracles`, z.array(oracleConfigSchema), { + headers: jsonHeaders(adminKey), }); } @@ -332,12 +501,9 @@ export async function createOracle( data: { address: string; name: string }, adminKey: string, ): Promise { - return apiFetch(`/api/admin/oracles`, { + return apiFetch(`/api/admin/oracles`, oracleConfigSchema, { method: "POST", - headers: { - "Content-Type": "application/json", - "x-admin-key": adminKey, - }, + headers: jsonHeaders(adminKey), body: JSON.stringify(data), }); } @@ -347,12 +513,9 @@ export async function createOracle( * Removes an oracle config entry. Requires admin auth header. */ export async function deleteOracle(id: string, adminKey: string): Promise<{ success: boolean }> { - return apiFetch<{ success: boolean }>(`/api/admin/oracles/${id}`, { + return apiFetch(`/api/admin/oracles/${id}`, successSchema, { method: "DELETE", - headers: { - "Content-Type": "application/json", - "x-admin-key": adminKey, - }, + headers: jsonHeaders(adminKey), }); } @@ -365,12 +528,9 @@ export async function updateOracle( patch: Partial>, adminKey: string, ): Promise { - return apiFetch(`/api/admin/oracles/${id}`, { + return apiFetch(`/api/admin/oracles/${id}`, oracleConfigSchema, { method: "PATCH", - headers: { - "Content-Type": "application/json", - "x-admin-key": adminKey, - }, + headers: jsonHeaders(adminKey), body: JSON.stringify(patch), }); } From f0895946ccd62680c30f7100e4ab5254ab59bb18 Mon Sep 17 00:00:00 2001 From: kaivegascod-a11y Date: Mon, 19 Oct 2026 03:22:25 +0000 Subject: [PATCH 4/4] fix: #1430 F-92: Fix API paths that do not exist on the backend (admin resolve, leaderboard) - POST /api/admin/markets/resolve with marketId and outcome in the body, matching the backend route instead of /api/admin/markets/:id/resolve - Contract tests pin the method, path and body for resolveMarket and the /api/leaderboard request. The admin and leaderboard pages already render an error state when these calls fail. Closes #1430 --- frontend/__tests__/api.test.ts | 46 ++++++++++++++++++++++++++++++++++ frontend/lib/api.ts | 9 ++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/frontend/__tests__/api.test.ts b/frontend/__tests__/api.test.ts index 7a58c8302..6a165075f 100644 --- a/frontend/__tests__/api.test.ts +++ b/frontend/__tests__/api.test.ts @@ -9,6 +9,8 @@ import { fetchOddsHistory, fetchUserPositions, createMarketMeta, + fetchLeaderboard, + resolveMarket, ApiError, ApiResponseError, Market, @@ -334,3 +336,47 @@ test("createMarketMeta: sends POST request with market metadata", async () => { }) ); }); + +// ─── contract: paths and bodies the backend routes expect ───────────────────── + +test("resolveMarket: POSTs the market id and outcome to /api/admin/markets/resolve", async () => { + global.fetch = mockJson({ data: { success: true } }); + + await expect(resolveMarket("mkt-1", "FighterA", "admin-key")).resolves.toEqual({ success: true }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/admin\/markets\/resolve$/), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "x-admin-key": "admin-key" }), + body: JSON.stringify({ marketId: "mkt-1", outcome: "FighterA" }), + }) + ); +}); + +test("fetchLeaderboard: GETs /api/leaderboard with the period and validates entries", async () => { + const entry = { + rank: 1, + address: "GTOP", + totalWinnings: "500000000", + totalBets: 4, + winCount: 3, + roi: 0.25, + }; + global.fetch = mockJson({ data: [entry] }); + + await expect(fetchLeaderboard("30d")).resolves.toEqual([entry]); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/leaderboard?period=30d"), + expect.any(Object) + ); +}); + +test("fetchLeaderboard: rejects entries with non-string winnings", async () => { + global.fetch = mockJson({ + data: [{ rank: 1, address: "GTOP", totalWinnings: 500, totalBets: 4, winCount: 3, roi: 0 }], + }); + + await expect(fetchLeaderboard("all")).rejects.toBeInstanceOf(ApiResponseError); +}); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 7ac926919..7971a5ccc 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -468,18 +468,19 @@ export async function fetchPendingResolutions(adminKey: string): Promise { - return apiFetch(`/api/admin/markets/${marketId}/resolve`, successSchema, { + return apiFetch(`/api/admin/markets/resolve`, successSchema, { method: "POST", headers: jsonHeaders(adminKey), - body: JSON.stringify({ outcome }), + body: JSON.stringify({ marketId, outcome }), }); }