diff --git a/docker-compose.yml b/docker-compose.yml index e7a14c91..b11e0552 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,26 @@ services: start_period: 15s retries: 3 + # ── Frontend ──────────────────────────────────────────────────────────────── + # NEXT_PUBLIC_* values are baked in at build time. The browser calls the API + # on the host port, so the default points at http://localhost:3001. + web: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001} + NEXT_PUBLIC_STELLAR_NETWORK: ${NEXT_PUBLIC_STELLAR_NETWORK:-testnet} + NEXT_PUBLIC_SOROBAN_RPC_URL: ${NEXT_PUBLIC_SOROBAN_RPC_URL:-https://soroban-testnet.stellar.org} + NEXT_PUBLIC_MARKET_CONTRACT_ID: ${NEXT_PUBLIC_MARKET_CONTRACT_ID:-} + NEXT_PUBLIC_ADMIN_ADDRESSES: ${NEXT_PUBLIC_ADMIN_ADDRESSES:-} + restart: unless-stopped + ports: + - "3000:3000" + depends_on: + api: + condition: service_healthy + # ── Indexer ────────────────────────────────────────────────────────────────── # B-54: Runs the Soroban event-polling loop as a managed process separate # from the API server, sharing the same compiled Docker image. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..2bc6cfe9 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +out +coverage +storybook-static +test-results +playwright-report +.env*.local +npm-debug.log* +Dockerfile +.dockerignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..74342f03 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1 +# ── BOXMEOUT frontend production image ─────────────────────────────────────── +# Multi-stage build on the Next.js "standalone" output. The final image runs as +# an unprivileged user and contains only the traced server bundle. +# +# NEXT_PUBLIC_* values are inlined into the JavaScript bundle at build time, so +# they are passed as build args rather than runtime env. The defaults below +# target the local docker-compose stack; override them for other environments: +# docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com . +# ───────────────────────────────────────────────────────────────────────────── + +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:20-alpine AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +ARG NEXT_PUBLIC_API_URL=http://localhost:3001 +ARG NEXT_PUBLIC_STELLAR_NETWORK=testnet +ARG NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +ARG NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +ARG NEXT_PUBLIC_MARKET_CONTRACT_ID= +ARG NEXT_PUBLIC_ADMIN_ADDRESSES= +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ + NEXT_PUBLIC_STELLAR_NETWORK=$NEXT_PUBLIC_STELLAR_NETWORK \ + NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE=$NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE \ + NEXT_PUBLIC_SOROBAN_RPC_URL=$NEXT_PUBLIC_SOROBAN_RPC_URL \ + NEXT_PUBLIC_MARKET_CONTRACT_ID=$NEXT_PUBLIC_MARKET_CONTRACT_ID \ + NEXT_PUBLIC_ADMIN_ADDRESSES=$NEXT_PUBLIC_ADMIN_ADDRESSES + +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN mkdir -p public && npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/ > /dev/null || exit 1 + +CMD ["node", "server.js"] diff --git a/frontend/__tests__/lib/resolution.test.ts b/frontend/__tests__/lib/resolution.test.ts new file mode 100644 index 00000000..86aac653 --- /dev/null +++ b/frontend/__tests__/lib/resolution.test.ts @@ -0,0 +1,46 @@ +import { + DISPUTE_WINDOW_MS, + getClaimBlockReason, + getResolutionStage, +} from "@/lib/resolution"; + +const RESOLVED_AT = "2026-10-01T12:00:00Z"; +const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime(); + +function market(status: "Open" | "Locked" | "Resolved" | "Disputed" | "Cancelled", resolvedAt?: string) { + return { status, scheduledAt: "2026-09-30T00:00:00Z", resolvedAt }; +} + +describe("getResolutionStage", () => { + it("maps open, locked, disputed and cancelled markets directly", () => { + expect(getResolutionStage(market("Open"))).toBe("open"); + expect(getResolutionStage(market("Locked"))).toBe("locked"); + expect(getResolutionStage(market("Disputed", RESOLVED_AT))).toBe("disputed"); + expect(getResolutionStage(market("Cancelled"))).toBe("cancelled"); + }); + + it("keeps a resolved market in the dispute window until 24 hours have passed", () => { + const m = market("Resolved", RESOLVED_AT); + expect(getResolutionStage(m, RESOLVED_AT_MS + DISPUTE_WINDOW_MS - 1)).toBe("dispute-window"); + expect(getResolutionStage(m, RESOLVED_AT_MS + DISPUTE_WINDOW_MS)).toBe("finalized"); + }); + + it("falls back to scheduledAt when resolvedAt is missing", () => { + const m = market("Resolved"); + const scheduledMs = new Date("2026-09-30T00:00:00Z").getTime(); + expect(getResolutionStage(m, scheduledMs + 1000)).toBe("dispute-window"); + expect(getResolutionStage(m, scheduledMs + DISPUTE_WINDOW_MS + 1)).toBe("finalized"); + }); +}); + +describe("getClaimBlockReason", () => { + it("explains the wait during the dispute window and on disputed markets", () => { + expect(getClaimBlockReason("dispute-window")).toMatch(/dispute window/); + expect(getClaimBlockReason("disputed")).toMatch(/admin/); + }); + + it("returns null when a claim can go ahead or the market is cancelled", () => { + expect(getClaimBlockReason("finalized")).toBeNull(); + expect(getClaimBlockReason("cancelled")).toBeNull(); + }); +}); diff --git a/frontend/__tests__/lib/security-headers.test.ts b/frontend/__tests__/lib/security-headers.test.ts new file mode 100644 index 00000000..66d4226b --- /dev/null +++ b/frontend/__tests__/lib/security-headers.test.ts @@ -0,0 +1,81 @@ +import { + buildConnectSrc, + buildSecurityHeaders, + HORIZON_URLS, +} from "@/lib/security-headers"; + +const API_URL = "http://localhost:3001"; + +function headerValue(headers: { key: string; value: string }[], key: string): string { + const header = headers.find((h) => h.key === key); + if (!header) throw new Error(`missing header ${key}`); + return header.value; +} + +function directive(csp: string, name: string): string[] { + const match = csp.split("; ").find((d) => d.startsWith(`${name} `)); + if (!match) throw new Error(`missing directive ${name}`); + return match.split(" ").slice(1); +} + +describe("buildConnectSrc", () => { + it("allows the API, Horizon for the network and the Soroban RPC", () => { + const sources = buildConnectSrc({ + apiUrl: "https://api.boxmeout.example/v1", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + network: "testnet", + }); + + expect(sources).toEqual([ + "'self'", + "https://api.boxmeout.example", + HORIZON_URLS.testnet, + "https://soroban-testnet.stellar.org", + ]); + }); + + it("omits the RPC origin when none is configured", () => { + const sources = buildConnectSrc({ apiUrl: API_URL, network: "mainnet" }); + + expect(sources).toEqual(["'self'", API_URL, HORIZON_URLS.mainnet]); + }); + + it("names the variable when the API URL is invalid", () => { + expect(() => + buildConnectSrc({ apiUrl: "not a url", network: "testnet" }) + ).toThrow("NEXT_PUBLIC_API_URL must be a valid URL"); + }); +}); + +describe("buildSecurityHeaders", () => { + it("sets the clickjacking, referrer, sniffing and permissions headers", () => { + const headers = buildSecurityHeaders({ isDev: false, apiUrl: API_URL, network: "testnet" }); + + expect(headerValue(headers, "X-Frame-Options")).toBe("DENY"); + expect(headerValue(headers, "Referrer-Policy")).toBe("strict-origin-when-cross-origin"); + expect(headerValue(headers, "X-Content-Type-Options")).toBe("nosniff"); + expect(headerValue(headers, "Permissions-Policy")).toContain("camera=()"); + }); + + it("builds connect-src from the configured API origin", () => { + const headers = buildSecurityHeaders({ isDev: false, apiUrl: API_URL, network: "testnet" }); + const csp = headerValue(headers, "Content-Security-Policy"); + + expect(directive(csp, "connect-src")).toContain("http://localhost:3001"); + expect(directive(csp, "frame-ancestors")).toEqual(["'none'"]); + }); + + it("allows eval in scripts only in development", () => { + const prod = headerValue( + buildSecurityHeaders({ isDev: false, apiUrl: API_URL, network: "testnet" }), + "Content-Security-Policy" + ); + const dev = headerValue( + buildSecurityHeaders({ isDev: true, apiUrl: API_URL, network: "testnet" }), + "Content-Security-Policy" + ); + + expect(directive(prod, "script-src")).not.toContain("'unsafe-eval'"); + expect(directive(dev, "script-src")).toContain("'unsafe-eval'"); + }); +}); diff --git a/frontend/__tests__/lib/sitemap.test.ts b/frontend/__tests__/lib/sitemap.test.ts new file mode 100644 index 00000000..7ee5e7d8 --- /dev/null +++ b/frontend/__tests__/lib/sitemap.test.ts @@ -0,0 +1,42 @@ +import { buildSitemapEntries } from "@/lib/sitemap"; + +const NOW = new Date("2026-10-19T00:00:00Z"); + +describe("buildSitemapEntries", () => { + it("lists the home page first, at the site origin", () => { + const entries = buildSitemapEntries("https://boxmeout.example/some/path", [], NOW); + + expect(entries).toEqual([ + { url: "https://boxmeout.example/", lastModified: NOW, changeFrequency: "hourly", priority: 1 }, + ]); + }); + + it("adds one absolute URL per open market", () => { + const entries = buildSitemapEntries( + "https://boxmeout.example", + [{ id: "m-1", scheduledAt: "2026-11-01T00:00:00Z" }], + NOW + ); + + expect(entries[1]).toEqual({ + url: "https://boxmeout.example/markets/m-1", + lastModified: new Date("2026-11-01T00:00:00Z"), + changeFrequency: "hourly", + priority: 0.8, + }); + }); + + it("encodes market ids so they cannot change the path", () => { + const entries = buildSitemapEntries( + "https://boxmeout.example", + [{ id: "a/b?c", scheduledAt: "2026-11-01T00:00:00Z" }], + NOW + ); + + expect(entries[1].url).toBe("https://boxmeout.example/markets/a%2Fb%3Fc"); + }); + + it("rejects a site URL that is not absolute", () => { + expect(() => buildSitemapEntries("boxmeout.example", [], NOW)).toThrow(); + }); +}); diff --git a/frontend/app/icon.svg b/frontend/app/icon.svg new file mode 100644 index 00000000..d5498235 --- /dev/null +++ b/frontend/app/icon.svg @@ -0,0 +1 @@ +B diff --git a/frontend/app/markets/[id]/opengraph-image.tsx b/frontend/app/markets/[id]/opengraph-image.tsx index c51223a3..2e524ab8 100644 --- a/frontend/app/markets/[id]/opengraph-image.tsx +++ b/frontend/app/markets/[id]/opengraph-image.tsx @@ -1,5 +1,4 @@ /* eslint-disable @next/next/no-img-element */ -// @ts-expect-error -- next/og types resolve after npm install import { ImageResponse } from "next/og"; import { fetchMarketById } from "@/lib/api"; diff --git a/frontend/components/BetForm.tsx b/frontend/components/BetForm.tsx index 90fee44d..fca4f4c4 100644 --- a/frontend/components/BetForm.tsx +++ b/frontend/components/BetForm.tsx @@ -2,7 +2,8 @@ import { useEffect, useId, useMemo, useState } from "react"; import { Bet, BetSide, Market } from "@/lib/api"; import { BetAmountInput } from "./BetAmountInput"; -import { TransactionStatusModal, TransactionStatus } from "./TransactionStatusModal"; +import { TransactionStatusModal } from "./TransactionStatusModal"; +import type { TransactionStatus } from "@/lib/stellar"; import { usePlaceBet } from "@/hooks/usePlaceBet"; const MIN_AMOUNT_XLM = 1; diff --git a/frontend/components/ClaimButton.resolution.test.tsx b/frontend/components/ClaimButton.resolution.test.tsx new file mode 100644 index 00000000..807ab8d5 --- /dev/null +++ b/frontend/components/ClaimButton.resolution.test.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { ClaimButton } from "./ClaimButton"; +import { Bet, Market, MarketStatus, Outcome } from "@/lib/api"; + +jest.mock("@/hooks/useClaimWinnings", () => ({ + useClaimWinnings: () => ({ claim: jest.fn() }), +})); +jest.mock("@/hooks/useClaimRefund", () => ({ + useClaimRefund: () => ({ claimRefund: jest.fn() }), +})); + +const RESOLVED_AT = "2026-10-01T12:00:00Z"; +const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime(); +const DAY_MS = 24 * 60 * 60 * 1000; + +function makeMarket(status: MarketStatus, outcome: Outcome | null): Market { + return { + id: "m-1", + contractAddress: "C1", + fighterA: { name: "Maya Chen", record: "10-0", nationality: "US", weightClass: "Welter" }, + fighterB: { name: "Rico Alvarez", record: "9-1", nationality: "MX", weightClass: "Welter" }, + scheduledAt: "2026-09-30T00:00:00Z", + bettingEndsAt: "2026-09-29T23:00:00Z", + status, + outcome, + poolA: "0", + poolB: "0", + totalPool: "0", + oracleAddress: "G1", + createdBy: "G2", + resolvedAt: RESOLVED_AT, + }; +} + +const winningBet: Bet = { + id: "b-1", + marketId: "m-1", + bettor: "GBETTOR", + side: "FighterA", + amount: "100", + placedAt: "2026-09-29T00:00:00Z", + claimed: false, + payout: null, +}; + +describe("ClaimButton", () => { + it("renders nothing for a losing bet on a resolved market", () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows a disabled claim with the reason during the dispute window", () => { + jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + 1000); + render(); + + expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled(); + expect(screen.getByText("Claimable after the dispute window closes.")).toBeInTheDocument(); + jest.restoreAllMocks(); + }); + + it("shows a disabled claim with the reason while the result is disputed", () => { + render(); + + expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled(); + expect(screen.getByText(/decided by an admin/)).toBeInTheDocument(); + }); + + it("enables the claim once the dispute window has passed", () => { + jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + DAY_MS + 1); + render(); + + expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeEnabled(); + expect(screen.queryByText(/dispute window/)).not.toBeInTheDocument(); + jest.restoreAllMocks(); + }); + + it("allows refunds on a cancelled market straight away", () => { + render(); + + expect(screen.getByRole("button", { name: "Claim Refund" })).toBeEnabled(); + }); + + it("shows a claimed bet as claimed", () => { + render( + + ); + + expect(screen.getByRole("button", { name: "Claimed" })).toBeDisabled(); + }); +}); diff --git a/frontend/components/ClaimButton.tsx b/frontend/components/ClaimButton.tsx index 958095b9..d15ca056 100644 --- a/frontend/components/ClaimButton.tsx +++ b/frontend/components/ClaimButton.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Bet, Market } from "@/lib/api"; import { useClaimWinnings } from "@/hooks/useClaimWinnings"; import { useClaimRefund } from "@/hooks/useClaimRefund"; +import { getClaimBlockReason, getResolutionStage } from "@/lib/resolution"; export interface ClaimReceipt { betId: string; @@ -25,10 +26,13 @@ export function ClaimButton({ bet, market, onClaimed }: ClaimButtonProps): JSX.E // Can't determine claim eligibility without market data if (!market) return null; - const isWinner = market.status === "Resolved" && market.outcome === bet.side; + // A disputed market still has an outcome; its claim is blocked by getClaimBlockReason + const hasOutcome = market.status === "Resolved" || market.status === "Disputed"; + const isWinner = hasOutcome && market.outcome === bet.side; const isRefundable = (market.status === "Cancelled" || market.outcome === "NoContest") && !bet.claimed; const showButton = (isWinner || isRefundable) && !bet.claimed; + const blockReason = showButton ? getClaimBlockReason(getResolutionStage(market)) : null; if (bet.claimed) { return ( @@ -45,6 +49,20 @@ export function ClaimButton({ bet, market, onClaimed }: ClaimButtonProps): JSX.E const label = isRefundable ? "Claim Refund" : "Claim Winnings"; + if (blockReason) { + return ( +
+ +

{blockReason}

+
+ ); + } + async function handleClaim() { if (loading || !market) return; setLoading(true); diff --git a/frontend/components/MarketDetailClient.tsx b/frontend/components/MarketDetailClient.tsx index b52ef9c7..9ecd20bb 100644 --- a/frontend/components/MarketDetailClient.tsx +++ b/frontend/components/MarketDetailClient.tsx @@ -15,6 +15,8 @@ import { CountdownTimer } from "@/components/CountdownTimer"; import { DisputeModal } from "@/components/DisputeModal"; import { useWallet } from "@/hooks/useWallet"; import { LoadingSkeleton } from "@/components/LoadingSkeleton"; +import { ResolutionTimeline } from "@/components/ResolutionTimeline"; +import { getResolutionStage } from "@/lib/resolution"; import { truncateAddress, stellarExpertContractUrl } from "@/lib/stellar"; // recharts is only needed on this page; keep it out of the shared bundle @@ -27,15 +29,6 @@ const MarketOddsChart = dynamic( { ssr: false, loading: () => } ); -const DISPUTE_WINDOW_SEC = 24 * 60 * 60; // 24 h - -function isInDisputeWindow(market: Market): boolean { - if (market.status !== "Resolved") return false; - // scheduledAt is the closest proxy for resolvedAt we have on the type - const resolvedAt = new Date(market.scheduledAt).getTime(); - return Date.now() - resolvedAt < DISPUTE_WINDOW_SEC * 1000; -} - function BetHistoryRow({ bet }: { bet: Bet }): JSX.Element { const xlm = (Number(BigInt(bet.amount)) / 1e7).toFixed(2); return ( @@ -274,6 +267,8 @@ export function MarketDetailClient({ expiredLabel="Locked" /> + + {/* Fighter cards */}
@@ -317,7 +312,7 @@ export function MarketDetailClient({ )} {/* Dispute section */} - {isInDisputeWindow(m) && !showDispute && ( + {getResolutionStage(m) === "dispute-window" && !showDispute && (