diff --git a/.gitignore b/.gitignore index a3dba4b..a2793c7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ apps/app/ apps/landing/ apps/automation-marketing/ apps/dashboard/ +apps/automation-wallet/ apps/docs/ packages/contracts/ diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 418a511..cc44898 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -191,6 +191,7 @@ export function createApp(deps: AppDeps): Hono { db, env: deps.env, predictionService, + now, ...(defaultSettleOnchain ? { settleOnchain: defaultSettleOnchain } : {}), }); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index eecdf7d..dd74b57 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -73,9 +73,14 @@ const schema = z.object({ .number() .int() .default(60 * 60), + /** Hours after kickoff at which a scoreless FINISHED match is resolved from the closing-odds + * favorite (the odds feed never delivered a final score). NO-LOSS makes this safe: settling to + * any outcome returns every staker's principal — only the yield prize follows the guessed result. */ + SETTLE_FALLBACK_HOURS: z.coerce.number().min(1).default(6), // Protocol fee on pot payouts, in basis points (250 = 2.5%). - PROTOCOL_FEE_BPS: z.coerce.number().int().min(0).max(10_000).default(250), + // Protocol fee, charged on the YIELD only (never principal). 1000 = 10%. + PROTOCOL_FEE_BPS: z.coerce.number().int().min(0).max(10_000).default(1_000), // Yield Agent — autonomous Morpho rebalancing via a WDK agent wallet (uses ORACLE_PK's MANAGER_ROLE). /** Minimum APY improvement (bps) before the agent migrates the vault's backing. */ diff --git a/apps/api/src/lib/brackets-viewer.ts b/apps/api/src/lib/brackets-viewer.ts index e291d4b..94426d7 100644 --- a/apps/api/src/lib/brackets-viewer.ts +++ b/apps/api/src/lib/brackets-viewer.ts @@ -70,7 +70,10 @@ function advancer(m: KoMatch): string | null { */ function alignRounds(rounds: BracketRound[]): BracketRound[] { const out = rounds.map((r) => ({ ...r, matches: [...r.matches] })); - for (let r = 0; r < out.length - 1; r += 1) { + // Work BACKWARD from the final: each round is ordered to feed its successor, so the successor must + // already be aligned first. Going forward would align a round to its successor's raw (date) order + // before that successor is itself reordered — leaving the connectors pointing at the wrong teams. + for (let r = out.length - 2; r >= 0; r -= 1) { const cur = out[r]; const next = out[r + 1]; if (!cur || !next || cur.matches.length !== next.matches.length * 2) continue; diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 511e108..b50ef3e 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -33,10 +33,84 @@ const Match = { homeTeam: { type: 'string' }, awayTeam: { type: 'string' }, kickoff: { type: 'integer', description: 'Kickoff, unix seconds' }, - round: { type: 'string' }, + round: { type: 'string', description: 'GROUP, R32, R16, QF, SF, F, …' }, status: { type: 'string', enum: ['SCHEDULED', 'LOCKED', 'FINISHED', 'CANCELLED'] }, homeScore: { type: ['integer', 'null'] }, awayScore: { type: ['integer', 'null'] }, + closingHomeBps: { + type: ['integer', 'null'], + description: 'Closing home-win odds ×10 000 (bps), frozen at kickoff; null until frozen', + }, + closingDrawBps: { type: ['integer', 'null'], description: 'Closing draw odds ×10 000 (bps)' }, + closingAwayBps: { + type: ['integer', 'null'], + description: 'Closing away-win odds ×10 000 (bps)', + }, + updatedAt: { type: 'integer', description: 'Last update, unix milliseconds' }, + }, +} as const; + +const TeamMeta = { + type: 'object', + description: 'Resolved team metadata (national flag or club crest). Null when unresolved.', + properties: { + name: { type: 'string' }, + code: { type: 'string', description: 'FIFA 3-letter code, e.g. "ARG"' }, + iso: { type: 'string', description: 'flagcdn ISO key, e.g. "ar" or "gb-eng" ("" for clubs)' }, + logo: { type: 'string', description: 'Flag / badge image URL' }, + }, +} as const; + +const Odds = { + type: 'object', + description: 'Average h2h decimal odds (e.g. 1.30 = favourite). Live cache, else frozen closing.', + properties: { + home: { type: 'number' }, + draw: { type: 'number' }, + away: { type: 'number' }, + }, +} as const; + +/** A match row enriched with resolved team metadata (as returned by GET /matches/all). */ +const MatchWithMeta = { + allOf: [ + Match, + { + type: 'object', + properties: { + homeTeamMeta: { anyOf: [TeamMeta, { type: 'null' }] }, + awayTeamMeta: { anyOf: [TeamMeta, { type: 'null' }] }, + }, + }, + ], +} as const; + +/** A match with team metadata AND current odds (GET /matches, GET /matches/{id}). */ +const MatchDetail = { + allOf: [ + MatchWithMeta, + { + type: 'object', + properties: { odds: { anyOf: [Odds, { type: 'null' }] } }, + }, + ], +} as const; + +const Prediction = { + type: 'object', + description: 'Off-chain mirror of an on-chain stake. `stake`/`payout` are USDT0 base units.', + properties: { + id: { type: 'string', description: 'Predict tx hash (lowercased) when on-chain, else a UUID' }, + userId: { type: 'string', description: 'Wallet address' }, + matchId: { type: 'string' }, + market: { type: 'string', enum: ['WINNER', 'EXACT_SCORE'] }, + pick: { type: 'string', description: 'JSON-encoded Pick object' }, + stake: { type: 'string', description: 'USDT0 base units', example: '10000000' }, + createdAt: { type: 'integer', description: 'unix milliseconds' }, + settled: { type: 'boolean' }, + won: { type: ['boolean', 'null'] }, + payout: { type: ['string', 'null'], description: 'USDT0 base units (once settled)' }, + match: { anyOf: [MatchWithMeta, { type: 'null' }] }, }, } as const; @@ -51,23 +125,264 @@ const LeaderboardEntry = { }, } as const; +/** On-chain market lifecycle row from the Ponder indexer. */ +const Market = { + type: 'object', + properties: { + id: { type: 'string', description: 'marketId = keccak256(matchId)' }, + status: { type: 'string', description: 'e.g. OPEN | SETTLED' }, + closeTime: { type: 'string', description: 'unix seconds (string)' }, + result: { + type: ['integer', 'null'], + description: '0 HOME / 1 DRAW / 2 AWAY, null until settled', + }, + resultLabel: { type: ['string', 'null'], enum: ['HOME', 'DRAW', 'AWAY', null] }, + winningStake: { type: ['string', 'null'], description: 'base units' }, + prize: { type: ['string', 'null'], description: 'yield-funded prize, base units' }, + createdBlock: { type: 'string' }, + settledBlock: { type: ['string', 'null'] }, + updatedTimestamp: { type: 'string' }, + }, +} as const; + +const StandingRow = { + type: 'object', + properties: { + team: { type: 'string' }, + played: { type: 'integer' }, + won: { type: 'integer' }, + drawn: { type: 'integer' }, + lost: { type: 'integer' }, + gf: { type: 'integer', description: 'Goals for' }, + ga: { type: 'integer', description: 'Goals against' }, + gd: { type: 'integer', description: 'Goal difference' }, + points: { type: 'integer' }, + teamMeta: { anyOf: [TeamMeta, { type: 'null' }] }, + }, +} as const; + +const StandingGroup = { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string', description: 'e.g. "Group A"' }, + rows: { type: 'array', items: StandingRow, description: 'Ranked by points → GD → GF → name' }, + }, +} as const; + +const BracketMatch = { + type: 'object', + properties: { + home: { type: 'string', description: 'Team name ("" when the slot is still TBD)' }, + away: { type: 'string' }, + homeScore: { type: ['integer', 'null'] }, + awayScore: { type: ['integer', 'null'] }, + homePens: { type: ['integer', 'null'], description: 'Penalty-shootout score' }, + awayPens: { type: ['integer', 'null'] }, + homeMeta: { anyOf: [TeamMeta, { type: 'null' }] }, + awayMeta: { anyOf: [TeamMeta, { type: 'null' }] }, + }, +} as const; + +const BracketRound = { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string', description: 'e.g. "Round of 16", "Final"' }, + matches: { type: 'array', items: BracketMatch }, + }, +} as const; + +/** A yield vault's live economics (Morpho MetaMorpho snapshot). */ +const VaultSnapshot = { + type: 'object', + properties: { + address: { type: 'string' }, + name: { type: 'string' }, + apy: { type: 'number', description: 'Fraction (0.0172 = 1.72%)' }, + tvlUsd: { type: 'number' }, + chainId: { type: 'integer' }, + chain: { type: 'string' }, + asset: { type: 'string', description: 'Underlying symbol, e.g. "USDT0", "USDC"' }, + }, +} as const; + +const RebalanceDecision = { + type: 'object', + properties: { + shouldRebalance: { type: 'boolean' }, + from: { anyOf: [VaultSnapshot, { type: 'null' }] }, + to: { + anyOf: [VaultSnapshot, { type: 'null' }], + description: 'Best directly-migratable vault (same chain as current backing)', + }, + globalBest: { + anyOf: [VaultSnapshot, { type: 'null' }], + description: 'Highest-APY vault anywhere — may be cross-chain / cross-token', + }, + crossVenue: { type: 'boolean', description: 'True when globalBest is on another chain/asset' }, + gainBps: { type: 'number' }, + reason: { type: 'string' }, + }, +} as const; + +const AgentStatus = { + type: 'object', + description: 'Yield-agent status. Advisory only — decisions are surfaced, not auto-executed.', + properties: { + enabled: { type: 'boolean', description: 'False (and nothing else) when the agent is off' }, + vault: { type: 'string', description: 'GoalyVault address the agent manages' }, + currentVault: { + type: ['string', 'null'], + description: 'Address of the vault currently backing', + }, + current: { anyOf: [VaultSnapshot, { type: 'null' }] }, + candidates: { type: 'array', items: VaultSnapshot, description: 'Ranked by APY desc' }, + decision: { anyOf: [RebalanceDecision, { type: 'null' }] }, + route: { + type: ['object', 'null'], + description: 'Wormhole bridge/swap route to the best cross-chain vault, when applicable', + }, + ai: { + type: ['object', 'null'], + description: 'Optional LLM rationale layer', + properties: { + reason: { type: 'string' }, + confidence: { type: 'number' }, + }, + }, + lastRunAt: { type: ['integer', 'null'], description: 'unix milliseconds' }, + lastTxHash: { type: ['string', 'null'] }, + autoExecute: { type: 'boolean' }, + canExecute: { type: 'boolean', description: 'True when an agent wallet is configured' }, + }, +} as const; + +const Notification = { + type: 'object', + properties: { + id: { type: 'string' }, + kind: { + type: 'string', + description: 'welcome | placed | won | settled | claimed | deposited | kickoff', + }, + title: { type: 'string' }, + body: { type: 'string' }, + url: { type: 'string', description: 'In-app path opened when tapped' }, + createdAt: { type: 'integer', description: 'unix milliseconds' }, + readAt: { type: ['integer', 'null'], description: 'unix milliseconds; null = unread' }, + }, +} as const; + +const TermsAcceptance = { + type: 'object', + properties: { + id: { type: 'string', description: '`${address}-${version}`' }, + address: { type: 'string', description: 'Wallet address (lowercased)' }, + version: { type: 'string' }, + signature: { type: 'string', description: 'EIP-712 signature (hex)' }, + acceptedAt: { type: 'integer', description: 'unix milliseconds' }, + }, +} as const; + +const Ok = { + type: 'object', + properties: { ok: { type: 'boolean' } }, +} as const; + export const openApiDocument = { openapi: '3.1.0', info: { title: 'Goaly API', - version: '0.2.0', + version: '0.3.0', description: - 'No-loss football predictions on Arbitrum. Players stake stablecoins directly (USDT0 / USDC) and never lose principal; winners split a yield-funded, odds-boosted prize. Amounts are USDT0 base units (6 decimals) as decimal strings. Odds + fixtures come from the Goaly odds feed; an autonomous WDK agent rebalances the pool’s Morpho yield across chains and tokens.', + 'No-loss football predictions on Arbitrum One. Players stake stablecoins directly (USDT0) and ' + + 'never lose principal; winners split a yield-funded, odds-boosted prize funded by the Morpho ' + + 'yield the pooled stakes earn. Amounts are USDT0 base units (6 decimals) as decimal strings. ' + + 'Fixtures + odds come from the Goaly feed; an autonomous WDK yield agent watches the Morpho ' + + 'landscape and recommends the best risk-adjusted vault for the protocol backing.\n\n' + + 'On-chain (Arbitrum One, chainId 42161): USDT0 `0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9`, ' + + 'GoalyMarkets `0xFAcaD2Cbc3b6320239389aD5c2F597DeE95f1fd3`.', 'x-logo': { url: '/favicon.svg', altText: 'Goaly' }, + 'x-contracts': { + chain: 'Arbitrum One', + chainId: 42161, + usdt0: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', + goalyMarkets: '0xFAcaD2Cbc3b6320239389aD5c2F597DeE95f1fd3', + }, }, - servers: [{ url: '/' }], + servers: [ + { url: '/', description: 'Same-origin (the app proxies /api → the API)' }, + { url: 'https://api.goaly.fun', description: 'Production' }, + ], + tags: [ + { name: 'System', description: 'Liveness + service index' }, + { name: 'Matches', description: 'Cached fixtures, odds, and team metadata' }, + { name: 'Predictions', description: 'Off-chain mirror of on-chain stakes' }, + { name: 'Markets', description: 'On-chain markets, leaderboard, and claims (Ponder indexer)' }, + { name: 'Standings', description: 'FIFA World Cup 2026 group tables + knockout bracket' }, + { name: 'Yield Agent', description: 'Autonomous Morpho yield rebalancing (WDK agent)' }, + { name: 'Terms', description: 'Signed Terms & Conditions acceptances' }, + { name: 'Faucet', description: 'Gas faucet for freshly-created embedded accounts' }, + { name: 'Notifications', description: 'Web Push (VAPID) + in-app inbox' }, + { name: 'Admin', description: 'Operator-only: sync, oracle results, settlement, usage' }, + ], paths: { + '/': { + get: { + tags: ['System'], + summary: 'Service index (name, status, endpoints)', + responses: { '200': { description: 'Status JSON' } }, + }, + }, '/health': { - get: { summary: 'Liveness probe', responses: { '200': { description: 'OK' } } }, + get: { + tags: ['System'], + summary: 'Liveness probe', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + provider: { type: 'string', enum: ['ready', 'none'] }, + }, + }, + }, + }, + }, + }, + }, }, '/matches': { get: { - summary: 'List cached matches', + tags: ['Matches'], + summary: + 'List bettable matches (SCHEDULED + within the live window, with odds + team meta)', + responses: { + '200': { + description: 'Matches', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { matches: { type: 'array', items: MatchDetail } }, + }, + }, + }, + }, + }, + }, + }, + '/matches/all': { + get: { + tags: ['Matches'], + summary: 'List all matches incl. finished (team meta, no odds) — newest kickoff first', + description: + 'Lets tooling map an on-chain marketId → its fixture (marketId = keccak256(matchId) is not reversible).', responses: { '200': { description: 'Matches', @@ -75,7 +390,7 @@ export const openApiDocument = { 'application/json': { schema: { type: 'object', - properties: { matches: { type: 'array', items: Match } }, + properties: { matches: { type: 'array', items: MatchWithMeta } }, }, }, }, @@ -85,18 +400,40 @@ export const openApiDocument = { }, '/matches/{id}': { get: { - summary: 'Get a match', + tags: ['Matches'], + summary: 'Get a match (with team meta + odds)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { '200': { description: 'Match' }, '404': { description: 'Not found' } }, + responses: { + '200': { + description: 'Match', + content: { 'application/json': { schema: MatchDetail } }, + }, + '404': { description: 'Not found' }, + }, }, }, '/predictions': { get: { - summary: "List a user's predictions", + tags: ['Predictions'], + summary: "List a user's predictions (each enriched with its match)", parameters: [{ name: 'userId', in: 'query', required: true, schema: { type: 'string' } }], - responses: { '200': { description: 'Predictions' } }, + responses: { + '200': { + description: 'Predictions', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { predictions: { type: 'array', items: Prediction } }, + }, + }, + }, + }, + '400': { description: 'userId query param required' }, + }, }, post: { + tags: ['Predictions'], summary: 'Record a prediction (off-chain mirror of the on-chain stake)', requestBody: { required: true, @@ -106,23 +443,39 @@ export const openApiDocument = { type: 'object', required: ['userId', 'matchId', 'pick', 'stake'], properties: { - userId: { type: 'string' }, + userId: { type: 'string', description: 'Wallet address' }, matchId: { type: 'string' }, pick: Pick, stake: { type: 'string', description: 'USDT0 base units', example: '10000000' }, + txHash: { + type: 'string', + pattern: '^0x[0-9a-f]{64}$', + description: + 'On-chain predict tx hash — used as the row id so this record dedupes with the indexed Predicted event', + }, }, }, }, }, }, responses: { - '201': { description: 'Created' }, - '409': { description: 'Predictions closed' }, + '201': { + description: 'Created', + content: { + 'application/json': { + schema: { type: 'object', properties: { id: { type: 'string' } } }, + }, + }, + }, + '400': { description: 'Invalid body / stake must be positive' }, + '404': { description: 'Match not found' }, + '409': { description: 'Predictions closed for this match' }, }, }, }, '/leaderboard': { get: { + tags: ['Markets'], summary: 'Top stakers, from the on-chain indexer (base-unit strings, counts as numbers)', parameters: [ { @@ -150,7 +503,8 @@ export const openApiDocument = { }, '/markets': { get: { - summary: 'On-chain markets from the indexer (open + settled)', + tags: ['Markets'], + summary: 'On-chain markets from the indexer (open + settled), newest update first', parameters: [ { name: 'limit', @@ -162,18 +516,462 @@ export const openApiDocument = { responses: { '200': { description: 'Markets (empty array if the indexer is unreachable).', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { markets: { type: 'array', items: Market } }, + }, + }, + }, + }, + }, + }, + }, + '/claims': { + get: { + tags: ['Markets'], + summary: 'Market ids a user has claimed on-chain (authoritative claim status)', + parameters: [{ name: 'userId', in: 'query', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Claimed market ids (empty array if the indexer is unreachable).', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { marketIds: { type: 'array', items: { type: 'string' } } }, + }, + }, + }, + }, + '400': { description: 'userId query param required' }, + }, + }, + }, + '/standings': { + get: { + tags: ['Standings'], + summary: 'FIFA World Cup 2026 group tables (cached from the FIFA data API)', + responses: { + '200': { + description: 'Group standings', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { groups: { type: 'array', items: StandingGroup } }, + }, + }, + }, + }, + }, + }, + }, + '/bracket': { + get: { + tags: ['Standings'], + summary: 'Knockout bracket (Round of 32 → Final), rounds with fixtures only', + responses: { + '200': { + description: 'Bracket rounds', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { rounds: { type: 'array', items: BracketRound } }, + }, + }, + }, + }, + }, + }, + }, + '/bracket/viewer': { + get: { + tags: ['Standings'], + summary: 'The same bracket in the brackets-viewer.js data model (for its renderer)', + responses: { + '200': { + description: 'brackets-viewer.js dataset (stage/group/round/match/participant arrays)', + }, + }, + }, + }, + '/agent': { + get: { + tags: ['Yield Agent'], + summary: 'Yield-agent status (runs a fresh read-only decision on first call)', + responses: { + '200': { + description: 'Agent status (`{ enabled: false }` when the agent is not configured)', + content: { 'application/json': { schema: AgentStatus } }, + }, + }, + }, + }, + '/agent/run': { + post: { + tags: ['Yield Agent'], + summary: 'Refresh the rebalance decision (read-only, no on-chain execution)', + responses: { + '200': { + description: 'Refreshed status', + content: { 'application/json': { schema: AgentStatus } }, + }, + '501': { description: 'Yield agent not configured' }, + }, + }, + }, + '/agent/rebalance': { + post: { + tags: ['Yield Agent'], + summary: 'Decide + execute the migration on-chain (requires an agent wallet)', + responses: { + '200': { + description: 'Status after the (attempted) migration', + content: { 'application/json': { schema: AgentStatus } }, + }, + '501': { description: 'Yield agent / agent wallet not configured' }, + }, + }, + }, + '/terms/accept': { + post: { + tags: ['Terms'], + summary: 'Record a signed Terms & Conditions acceptance (idempotent per address+version)', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['address', 'version', 'signature'], + properties: { + address: { + type: 'string', + pattern: '^0x[0-9a-f]{40}$', + description: '20-byte hex', + }, + version: { type: 'string' }, + signature: { + type: 'string', + pattern: '^0x[0-9a-f]+$', + description: 'EIP-712 signature', + }, + }, + }, + }, + }, + }, + responses: { + '201': { description: 'Accepted', content: { 'application/json': { schema: Ok } } }, + }, + }, + }, + '/terms/{address}': { + get: { + tags: ['Terms'], + summary: "A wallet's recorded Terms acceptances", + parameters: [{ name: 'address', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Acceptances', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { acceptances: { type: 'array', items: TermsAcceptance } }, + }, + }, + }, }, }, }, }, + '/faucet/gas': { + post: { + tags: ['Faucet'], + summary: 'Drip a little gas (native ETH) to a fresh embedded account', + description: + 'Guardrailed (disabled / idempotent / daily-cap / already-funded). Always returns 200 with the outcome.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['address'], + properties: { + address: { + type: 'string', + pattern: '^0x[0-9a-f]{40}$', + description: '20-byte hex', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Drip outcome', + content: { + 'application/json': { + schema: { + oneOf: [ + { + type: 'object', + required: ['funded', 'txHash'], + properties: { funded: { const: true }, txHash: { type: 'string' } }, + }, + { + type: 'object', + required: ['funded', 'reason'], + properties: { + funded: { const: false }, + reason: { + type: 'string', + enum: [ + 'faucet_disabled', + 'already_funded', + 'daily_cap', + 'already_has_gas', + 'send_failed', + ], + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + '/notifications/vapid-key': { + get: { + tags: ['Notifications'], + summary: 'VAPID public key the browser needs to subscribe (null when push is disabled)', + responses: { + '200': { + description: 'Public key', + content: { + 'application/json': { + schema: { type: 'object', properties: { key: { type: ['string', 'null'] } } }, + }, + }, + }, + }, + }, + }, + '/notifications/subscribe': { + post: { + tags: ['Notifications'], + summary: 'Register (or refresh) a browser push subscription for a user', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['userId', 'subscription'], + properties: { + userId: { type: 'string' }, + subscription: { + type: 'object', + required: ['endpoint', 'keys'], + properties: { + endpoint: { type: 'string' }, + keys: { + type: 'object', + required: ['p256dh', 'auth'], + properties: { p256dh: { type: 'string' }, auth: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Subscribed (`{ ok: false, disabled: true }` when push is not configured)', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { ok: { type: 'boolean' }, disabled: { type: 'boolean' } }, + }, + }, + }, + }, + }, + }, + }, + '/notifications/unsubscribe': { + post: { + tags: ['Notifications'], + summary: 'Remove a push subscription by its endpoint', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['endpoint'], + properties: { endpoint: { type: 'string' } }, + }, + }, + }, + }, + responses: { + '200': { description: 'OK', content: { 'application/json': { schema: Ok } } }, + }, + }, + }, + '/notifications/claimed': { + post: { + tags: ['Notifications'], + summary: + 'Notify hook: a claim tx confirmed (client-triggered — the server cannot observe it)', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['userId', 'amount'], + properties: { + userId: { type: 'string' }, + amount: { type: 'string', description: 'USDT display amount, e.g. "12.50"' }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'OK', content: { 'application/json': { schema: Ok } } }, + }, + }, + }, + '/notifications/deposited': { + post: { + tags: ['Notifications'], + summary: 'Notify hook: a deposit landed (client-triggered)', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['userId', 'amount'], + properties: { + userId: { type: 'string' }, + amount: { type: 'string', description: 'USDT display amount' }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'OK', content: { 'application/json': { schema: Ok } } }, + }, + }, + }, + '/notifications/list': { + get: { + tags: ['Notifications'], + summary: "A user's in-app inbox, newest first (works even without VAPID keys)", + parameters: [ + { name: 'userId', in: 'query', required: true, schema: { type: 'string' } }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + }, + ], + responses: { + '200': { + description: 'Inbox rows', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { notifications: { type: 'array', items: Notification } }, + }, + }, + }, + }, + '400': { description: 'userId query param required' }, + }, + }, + }, + '/notifications/read': { + post: { + tags: ['Notifications'], + summary: 'Mark inbox rows read (all unread when `ids` omitted, else only those ids)', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' }, + ids: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'How many rows were updated', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { ok: { type: 'boolean' }, updated: { type: 'integer' } }, + }, + }, + }, + }, + }, + }, + }, + '/notifications/unread': { + get: { + tags: ['Notifications'], + summary: 'Unread badge count for a user', + parameters: [{ name: 'userId', in: 'query', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Unread count', + content: { + 'application/json': { + schema: { type: 'object', properties: { count: { type: 'integer' } } }, + }, + }, + }, + '400': { description: 'userId query param required' }, + }, + }, + }, '/admin/sync': { post: { + tags: ['Admin'], summary: 'Run one sync tick (fixtures + odds + on-chain markets)', responses: { '200': { description: 'Sync counts' } }, }, }, '/admin/matches/{id}/result': { post: { + tags: ['Admin'], summary: 'Record a final result (admin oracle)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], requestBody: { @@ -196,6 +994,7 @@ export const openApiDocument = { }, '/admin/matches/{id}/settle': { post: { + tags: ['Admin'], summary: 'Settle a finished match and compute pot payouts', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { @@ -206,10 +1005,15 @@ export const openApiDocument = { }, '/admin/matches/{id}/settle-onchain': { post: { - summary: 'Settle the on-chain GoalyPool market from the finished match result', + tags: ['Admin'], + summary: 'Settle the on-chain GoalyMarkets market from the finished match result', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { - '200': { description: 'On-chain settlement tx (matchId, marketId, result, txHash)' }, + '200': { + description: + 'On-chain settlement tx (matchId, marketId, result, winningOddsBps, txHash)', + }, + '404': { description: 'Match not found' }, '409': { description: 'No result yet' }, '501': { description: 'ORACLE_PK not configured' }, }, @@ -217,20 +1021,44 @@ export const openApiDocument = { }, '/admin/reconcile': { post: { + tags: ['Admin'], summary: 'Run one settlement reconcile pass (self-healing settle retry net)', responses: { '200': { - description: 'Reconcile summary (onchainSettled, offchainSettled, skipped, errors)', + description: + 'Reconcile summary (onchainSettled, offchainSettled, skipped, estimated, errors)', }, }, }, }, '/admin/usage': { get: { + tags: ['Admin'], summary: 'Odds API credit usage + estimated remaining', responses: { '200': { description: 'Usage' } }, }, }, }, - components: { schemas: { Match, Pick, LeaderboardEntry } }, + components: { + schemas: { + Match, + MatchWithMeta, + MatchDetail, + TeamMeta, + Odds, + Pick, + Prediction, + LeaderboardEntry, + Market, + StandingGroup, + StandingRow, + BracketRound, + BracketMatch, + VaultSnapshot, + RebalanceDecision, + AgentStatus, + Notification, + TermsAcceptance, + }, + }, }; diff --git a/apps/api/src/services/reconcile.service.ts b/apps/api/src/services/reconcile.service.ts index 29b086a..7842a06 100644 --- a/apps/api/src/services/reconcile.service.ts +++ b/apps/api/src/services/reconcile.service.ts @@ -5,7 +5,7 @@ import { marketIdFor, readMarketStatus, } from '@goaly/plugin-onchain'; -import { and, eq, inArray, isNotNull } from 'drizzle-orm'; +import { and, eq, inArray, isNotNull, isNull } from 'drizzle-orm'; import type { Hex, PublicClient } from 'viem'; import type { DB } from '../db/client'; import { matches, predictions } from '../db/schema'; @@ -19,6 +19,12 @@ export interface ReconcileSummary { offchainSettled: number; /** Markets already SETTLED / NONE on-chain → nothing to retry. */ skipped: number; + /** + * Scoreless FINISHED matches we resolved from the closing-odds favorite because the feed never + * delivered a final score and the fallback deadline (SETTLE_FALLBACK_HOURS after kickoff) passed. + * Each one gets a synthetic score persisted, then settles through the same path as a real score. + */ + estimated: number; /** Per-market failures (logged, never thrown — one bad market can't stop the loop). */ errors: number; } @@ -38,6 +44,46 @@ export interface ReconcileDeps { * live read against `ARBITRUM.goaly.markets` via `createArbitrumClient(env.ARBITRUM_RPC_URL)`. */ readMarketStatus?: (marketId: Hex) => Promise; + /** Wall-clock source (ms). Injectable so the fallback deadline is deterministic in tests. */ + now?: () => number; +} + +/** The closing-odds columns the fallback estimator reads (all nullable until frozen at kickoff). */ +export interface ClosingBps { + closingHomeBps: number | null; + closingDrawBps: number | null; + closingAwayBps: number | null; +} + +/** + * Best-guess final score for a scoreless-but-finished match, from its frozen closing odds. + * + * The favorite is the outcome with the LOWEST non-null closing bps (bps = decimal-odds × 10_000, so + * shortest odds = most likely). It maps to a minimal representative score that `resolveOutcome` + * grades back to the intended outcome and that drives the off-chain payout correctly: + * HOME favorite → 1-0, AWAY favorite → 0-1, DRAW favorite (or all odds null) → 0-0. + * + * Pure + deterministic; on a tie the earliest of HOME→DRAW→AWAY wins. + */ +export function estimateScoreFromOdds(match: ClosingBps): { homeScore: number; awayScore: number } { + const candidates: { outcome: Outcome; bps: number }[] = []; + if (match.closingHomeBps !== null) + candidates.push({ outcome: 'HOME', bps: match.closingHomeBps }); + if (match.closingDrawBps !== null) + candidates.push({ outcome: 'DRAW', bps: match.closingDrawBps }); + if (match.closingAwayBps !== null) + candidates.push({ outcome: 'AWAY', bps: match.closingAwayBps }); + + let favorite: { outcome: Outcome; bps: number } | null = null; + for (const candidate of candidates) { + if (favorite === null || candidate.bps < favorite.bps) favorite = candidate; + } + + // All odds null → no favorite → DRAW (0-0). Otherwise map the shortest-odds outcome to 1-0/0-1/0-0. + if (favorite === null || favorite.outcome === 'DRAW') return { homeScore: 0, awayScore: 0 }; + return favorite.outcome === 'HOME' + ? { homeScore: 1, awayScore: 0 } + : { homeScore: 0, awayScore: 1 }; } export interface Reconciler { @@ -59,6 +105,7 @@ export interface Reconciler { */ export function createReconciler(deps: ReconcileDeps): Reconciler { const { db, env, predictionService, settleOnchain } = deps; + const now = deps.now ?? Date.now; const markets = ARBITRUM.goaly.markets as `0x${string}`; // Lazily create the RPC client only if we ever fall back to a live read (never in tests). @@ -75,6 +122,7 @@ export function createReconciler(deps: ReconcileDeps): Reconciler { onchainSettled: 0, offchainSettled: 0, skipped: 0, + estimated: 0, errors: 0, }; @@ -89,7 +137,9 @@ export function createReconciler(deps: ReconcileDeps): Reconciler { .map((r) => r.matchId); if (betMatchIds.length === 0) { - console.log('[reconcile] onchain=0 offchain=0 skipped=0 errors=0 (no staked matches)'); + console.log( + '[reconcile] onchain=0 offchain=0 skipped=0 estimated=0 errors=0 (no staked matches)', + ); return summary; } @@ -106,7 +156,53 @@ export function createReconciler(deps: ReconcileDeps): Reconciler { ) .all(); - for (const match of finished) { + // ── Deadline fallback: scoreless-but-FINISHED matches (the complement of `finished`). ── + // The odds feed sometimes finishes a match without ever delivering a final score, so those staked + // positions would stay "Active" forever. Once SETTLE_FALLBACK_HOURS have passed since kickoff a + // real score clearly isn't coming, so we resolve to the pre-match favorite from the frozen closing + // odds. NO-LOSS makes this safe: settling to any outcome returns every staker's principal — only + // the yield prize follows the guessed result. Each eligible match gets a synthetic score persisted + // here, then folds into the SAME settle loop below as if the feed had scored it (one code path). + const scoreless = db + .select() + .from(matches) + .where( + and( + eq(matches.status, 'FINISHED'), + isNull(matches.homeScore), + inArray(matches.id, betMatchIds), + ), + ) + .all(); + + const fallbackHours = env.SETTLE_FALLBACK_HOURS; + const estimatedMatches: typeof finished = []; + for (const match of scoreless) { + const deadlineMs = (match.kickoff + fallbackHours * 3600) * 1000; + // Not past the deadline yet → leave it for a later pass (a real score may still arrive). + if (now() < deadlineMs) continue; + try { + const score = estimateScoreFromOdds(match); + db.update(matches) + .set({ homeScore: score.homeScore, awayScore: score.awayScore, updatedAt: now() }) + .where(eq(matches.id, match.id)) + .run(); + summary.estimated += 1; + console.log( + `[reconcile] estimated ${match.id} → ${score.homeScore}-${score.awayScore} ` + + `(favorite; no feed score after ${fallbackHours}h)`, + ); + // Carry the synthetic score into the settle loop so it settles on this same pass. + estimatedMatches.push({ ...match, homeScore: score.homeScore, awayScore: score.awayScore }); + } catch (error) { + summary.errors += 1; + const reason = error instanceof Error ? error.message.split('\n')[0] : String(error); + console.warn(`[reconcile] estimate failed for ${match.id}: ${reason}`); + } + } + + // One settle code path for both real-score and estimated matches. + for (const match of [...finished, ...estimatedMatches]) { // Redundant with the SQL filter, but narrows the nullable columns for TS + resolveOutcome. if (match.homeScore === null || match.awayScore === null) continue; const result = resolveOutcome({ homeScore: match.homeScore, awayScore: match.awayScore }); @@ -152,7 +248,7 @@ export function createReconciler(deps: ReconcileDeps): Reconciler { // One observable line per run — no more silent failures. console.log( `[reconcile] onchain=${summary.onchainSettled} offchain=${summary.offchainSettled} ` + - `skipped=${summary.skipped} errors=${summary.errors}`, + `skipped=${summary.skipped} estimated=${summary.estimated} errors=${summary.errors}`, ); return summary; } diff --git a/apps/api/test/brackets-viewer.test.ts b/apps/api/test/brackets-viewer.test.ts index ad71bb9..5865aaf 100644 --- a/apps/api/test/brackets-viewer.test.ts +++ b/apps/api/test/brackets-viewer.test.ts @@ -110,6 +110,63 @@ describe('toBracketsViewer', () => { expect(first).toEqual(['A', 'E', 'C', 'G']); }); + test('aligns across THREE rounds — R16 pairs feed QF even when QF must itself be reordered to feed SF', () => { + const win = (home: string, away: string) => ({ + home, + away, + homeScore: 1, + awayScore: 0, + homePens: null, + awayPens: null, + }); + const tbd = (home: string, away: string) => ({ + home, + away, + homeScore: null, + awayScore: null, + homePens: null, + awayPens: null, + }); + // Feed order (dates) is deliberately NOT bracket order at every level. + const ko: BracketRound[] = [ + { + id: 'r16', + name: 'Round of 16', + matches: [ + win('E', 'e'), + win('F', 'f'), + win('G', 'g'), + win('H', 'h'), + win('A', 'a'), + win('B', 'b'), + win('C', 'c'), + win('D', 'd'), + ], + }, + { + id: 'qf', + name: 'Quarter-finals', + matches: [win('E', 'F'), win('G', 'H'), win('A', 'B'), win('C', 'D')], + }, + { + id: 'sf', + name: 'Semi-finals', + matches: [tbd('A', 'C'), tbd('E', 'G')], + }, + ]; + const out = toBracketsViewer(ko, (t) => ({ name: t, imageUrl: null })); + const name = Object.fromEntries(out.participants.map((x) => [x.id, x.name])); + const homesOf = (roundId: number) => + out.matches + .filter((m) => m.round_id === roundId) + .sort((a, b) => a.number - b.number) + .map((m) => name[(m.opponent1 as { id: number }).id]); + // QF is reordered to feed SF (A/C, E/G) → homes A, C, E, G; and R16's consecutive pairs must feed + // that reordered QF → homes A, B, C, D, E, F, G, H. A forward pass would leave R16 at E,F,G,H,… + expect(homesOf(1)).toEqual(['A', 'C', 'E', 'G']); + expect(homesOf(0)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']); + }); + test('single-elimination stage sized from the first round', () => { expect(data.stages[0]?.type).toBe('single_elimination'); expect(data.stages[0]?.settings.size).toBe(4); // 2 R32 matches × 2 diff --git a/apps/api/test/prediction.test.ts b/apps/api/test/prediction.test.ts index 727b03b..431d3a5 100644 --- a/apps/api/test/prediction.test.ts +++ b/apps/api/test/prediction.test.ts @@ -71,13 +71,14 @@ describe('prediction + settlement flow', () => { const settle = await postJson(app, '/admin/matches/m1/settle', {}); expect(settle.status).toBe(200); - // pot 20, fee 2.5% = 0.5, distributable 19.5 all to Alice (the only winner). + // No-loss: pot 20, Alice (only winner) staked 10 → yield 10 (Bob's stake). Fee 2.5% of the yield + // = 0.25; Alice gets her 10 principal back + 9.75 net yield = 19.75. Principal is never fee'd. expect(settle.json.pot).toBe('20000000'); - expect(settle.json.fee).toBe('500000'); + expect(settle.json.fee).toBe('250000'); expect(settle.json.winners).toBe(1); const payouts = settle.json.payouts as { id: string; payout: string }[]; expect(payouts).toHaveLength(1); - expect(payouts[0]?.payout).toBe('19500000'); + expect(payouts[0]?.payout).toBe('19750000'); // Bob lost his staked credit but that is repaid by yield on-chain — his row is settled, not won. const bob = (await (await app.request('/predictions?userId=bob')).json()) as { diff --git a/apps/api/test/reconcile.test.ts b/apps/api/test/reconcile.test.ts index 9e37717..3545176 100644 --- a/apps/api/test/reconcile.test.ts +++ b/apps/api/test/reconcile.test.ts @@ -9,7 +9,11 @@ import type { DB } from '../src/db/client'; import { matches, predictions } from '../src/db/schema'; import { type Env, loadEnv } from '../src/env'; import { PredictionService } from '../src/services/prediction.service'; -import { createReconciler, type Reconciler } from '../src/services/reconcile.service'; +import { + createReconciler, + estimateScoreFromOdds, + type Reconciler, +} from '../src/services/reconcile.service'; import { SyncService } from '../src/services/sync.service'; const now = () => 2_000_000; @@ -39,6 +43,32 @@ function seedFinishedMatch(db: DB, id: string, homeScore = 2, awayScore = 1): vo .run(); } +/** A FINISHED match the feed never scored (homeScore/awayScore null), with frozen closing odds. */ +function seedScorelessMatch( + db: DB, + id: string, + odds: { home: number | null; draw: number | null; away: number | null }, + kickoff = 1000, +): void { + db.insert(matches) + .values({ + id, + sportKey: 'soccer_fifa_world_cup', + homeTeam: 'Argentina', + awayTeam: 'Brazil', + kickoff, + round: 'FINAL', + status: 'FINISHED', + homeScore: null, + awayScore: null, + closingHomeBps: odds.home, + closingDrawBps: odds.draw, + closingAwayBps: odds.away, + updatedAt: now(), + }) + .run(); +} + function seedPrediction(db: DB, id: string, matchId: string, settled = false): void { db.insert(predictions) .values({ @@ -73,7 +103,13 @@ describe('settlement reconcile job', () => { }); const summary = await reconciler.reconcile(); - expect(summary).toEqual({ onchainSettled: 1, offchainSettled: 1, skipped: 0, errors: 0 }); + expect(summary).toEqual({ + onchainSettled: 1, + offchainSettled: 1, + skipped: 0, + estimated: 0, + errors: 0, + }); // The retry actually invoked the on-chain settle with the resolved outcome (2-1 → HOME). expect(settled).toEqual([{ matchId: 'm1', result: 'HOME' }]); @@ -108,7 +144,13 @@ describe('settlement reconcile job', () => { const summary = await reconciler.reconcile(); expect(settleCalls).toBe(0); - expect(summary).toEqual({ onchainSettled: 0, offchainSettled: 0, skipped: 1, errors: 0 }); + expect(summary).toEqual({ + onchainSettled: 0, + offchainSettled: 0, + skipped: 1, + estimated: 0, + errors: 0, + }); }); test('counts + logs a settle failure without throwing (one bad market cannot stall the loop)', async () => { @@ -150,7 +192,212 @@ describe('settlement reconcile job', () => { const reconciler = createReconciler({ db, env: env(), predictionService }); const summary = await reconciler.reconcile(); - expect(summary).toEqual({ onchainSettled: 0, offchainSettled: 1, skipped: 0, errors: 0 }); + expect(summary).toEqual({ + onchainSettled: 0, + offchainSettled: 1, + skipped: 0, + estimated: 0, + errors: 0, + }); + }); + + test('deadline fallback: resolves a scoreless finished match from the odds favorite, then settles', async () => { + const { db } = createDb(':memory:'); + // No feed score, HOME is the pre-match favorite (lowest closing bps). Kickoff long past the + // default 6h fallback deadline (deadline = (1000 + 6*3600)*1000 = 22_600_000 ms < now). + seedScorelessMatch( + db, + 'm-fb', + { home: 13_000, draw: 45_000, away: 90_000 }, + /* kickoff */ 1000, + ); + seedPrediction(db, 'p-fb', 'm-fb'); + const clock = () => 30_000_000; + const predictionService = new PredictionService(db, 250n, () => clock() /* ms */); + + const settled: { matchId: string; result: string }[] = []; + let marketSettled = false; + const reconciler = createReconciler({ + db, + env: env(), + predictionService, + settleOnchain: async (matchId, result) => { + settled.push({ matchId, result }); + marketSettled = true; + }, + readMarketStatus: async (): Promise => (marketSettled ? 'SETTLED' : 'OPEN'), + now: clock, + }); + + const summary = await reconciler.reconcile(); + expect(summary).toEqual({ + onchainSettled: 1, + offchainSettled: 1, + skipped: 0, + estimated: 1, + errors: 0, + }); + // HOME favorite → synthetic 1-0 → resolveOutcome HOME → on-chain settled to HOME. + expect(settled).toEqual([{ matchId: 'm-fb', result: 'HOME' }]); + + // Synthetic score persisted to the match row. + const match = db.select().from(matches).where(eq(matches.id, 'm-fb')).get(); + expect(match?.homeScore).toBe(1); + expect(match?.awayScore).toBe(0); + + // Off-chain settled the HOME staker. + const prediction = db.select().from(predictions).where(eq(predictions.id, 'p-fb')).get(); + expect(prediction?.settled).toBe(true); + expect(prediction?.won).toBe(true); + + // Idempotent: the next pass sees a normal scored match with a SETTLED market → no double settle. + const again = await reconciler.reconcile(); + expect(again).toEqual({ + onchainSettled: 0, + offchainSettled: 0, + skipped: 1, + estimated: 0, + errors: 0, + }); + expect(settled).toHaveLength(1); + }); + + test('deadline fallback: leaves a scoreless match untouched before the deadline', async () => { + const { db } = createDb(':memory:'); + // now() = 2_000_000 ms, deadline = (1000 + 6*3600)*1000 = 22_600_000 ms → not yet due. + seedScorelessMatch(db, 'm-early', { home: 13_000, draw: 45_000, away: 90_000 }); + seedPrediction(db, 'p-early', 'm-early'); + const predictionService = new PredictionService(db, 250n, now); + + let settleCalls = 0; + const reconciler = createReconciler({ + db, + env: env(), + predictionService, + settleOnchain: async () => { + settleCalls += 1; + }, + readMarketStatus: async (): Promise => 'OPEN', + now, + }); + + const summary = await reconciler.reconcile(); + expect(summary).toEqual({ + onchainSettled: 0, + offchainSettled: 0, + skipped: 0, + estimated: 0, + errors: 0, + }); + expect(settleCalls).toBe(0); + + // No synthetic score written, prediction still Active. + const match = db.select().from(matches).where(eq(matches.id, 'm-early')).get(); + expect(match?.homeScore).toBeNull(); + expect(match?.awayScore).toBeNull(); + const prediction = db.select().from(predictions).where(eq(predictions.id, 'p-early')).get(); + expect(prediction?.settled).toBe(false); + }); + + test('deadline fallback honours SETTLE_FALLBACK_HOURS (a shorter deadline makes a match due)', async () => { + const { db } = createDb(':memory:'); + // With the default 6h this match would NOT be due at now()=2_000_000; with 1h it is + // (deadline = (1000 + 3600)*1000 = 4_600_000 ms) — still not due at 2M... use a later clock. + seedScorelessMatch(db, 'm-cfg', { home: 90_000, draw: 45_000, away: 12_000 }); + seedPrediction(db, 'p-cfg', 'm-cfg'); + const clock = () => 5_000_000; // > (1000 + 1*3600)*1000 = 4_600_000 + const predictionService = new PredictionService(db, 250n, clock); + + const settled: { matchId: string; result: string }[] = []; + const reconciler = createReconciler({ + db, + env: env({ SETTLE_FALLBACK_HOURS: '1' }), + predictionService, + settleOnchain: async (matchId, result) => { + settled.push({ matchId, result }); + }, + readMarketStatus: async (): Promise => 'OPEN', + now: clock, + }); + + const summary = await reconciler.reconcile(); + expect(summary.estimated).toBe(1); + // AWAY favorite (lowest bps) → synthetic 0-1 → resolveOutcome AWAY. + expect(settled).toEqual([{ matchId: 'm-cfg', result: 'AWAY' }]); + const match = db.select().from(matches).where(eq(matches.id, 'm-cfg')).get(); + expect(match?.homeScore).toBe(0); + expect(match?.awayScore).toBe(1); + }); +}); + +describe('estimateScoreFromOdds', () => { + test('HOME favorite (lowest bps) → 1-0', () => { + expect( + estimateScoreFromOdds({ + closingHomeBps: 13_000, + closingDrawBps: 40_000, + closingAwayBps: 90_000, + }), + ).toEqual({ + homeScore: 1, + awayScore: 0, + }); + }); + + test('AWAY favorite (lowest bps) → 0-1', () => { + expect( + estimateScoreFromOdds({ + closingHomeBps: 90_000, + closingDrawBps: 40_000, + closingAwayBps: 12_000, + }), + ).toEqual({ + homeScore: 0, + awayScore: 1, + }); + }); + + test('DRAW favorite (lowest bps) → 0-0', () => { + expect( + estimateScoreFromOdds({ + closingHomeBps: 30_000, + closingDrawBps: 21_000, + closingAwayBps: 33_000, + }), + ).toEqual({ + homeScore: 0, + awayScore: 0, + }); + }); + + test('all odds null → 0-0 (DRAW)', () => { + expect( + estimateScoreFromOdds({ closingHomeBps: null, closingDrawBps: null, closingAwayBps: null }), + ).toEqual({ + homeScore: 0, + awayScore: 0, + }); + }); + + test('partial-null: picks the favorite among the non-null outcomes', () => { + // Only HOME + AWAY present; AWAY is shorter → AWAY favorite → 0-1. + expect( + estimateScoreFromOdds({ + closingHomeBps: 25_000, + closingDrawBps: null, + closingAwayBps: 14_000, + }), + ).toEqual({ + homeScore: 0, + awayScore: 1, + }); + // Only HOME present → HOME favorite → 1-0. + expect( + estimateScoreFromOdds({ closingHomeBps: 18_000, closingDrawBps: null, closingAwayBps: null }), + ).toEqual({ + homeScore: 1, + awayScore: 0, + }); }); }); @@ -173,7 +420,13 @@ describe('POST /admin/reconcile', () => { test('returns the reconcile summary as JSON (injected reconciler)', async () => { const stub: Reconciler = { - reconcile: async () => ({ onchainSettled: 1, offchainSettled: 2, skipped: 3, errors: 0 }), + reconcile: async () => ({ + onchainSettled: 1, + offchainSettled: 2, + skipped: 3, + estimated: 4, + errors: 0, + }), }; const { app } = appWith(stub); const res = await app.request('/admin/reconcile', { method: 'POST' }); @@ -182,6 +435,7 @@ describe('POST /admin/reconcile', () => { onchainSettled: 1, offchainSettled: 2, skipped: 3, + estimated: 4, errors: 0, }); }); @@ -196,6 +450,7 @@ describe('POST /admin/reconcile', () => { onchainSettled: 0, offchainSettled: 0, skipped: 0, + estimated: 0, errors: 0, }); }); diff --git a/packages/core/src/domain/pot.test.ts b/packages/core/src/domain/pot.test.ts index 479ba58..35c9d9a 100644 --- a/packages/core/src/domain/pot.test.ts +++ b/packages/core/src/domain/pot.test.ts @@ -5,23 +5,40 @@ import { distributePot } from './pot'; const USDT0 = (n: bigint) => n * 1_000_000n; describe('distributePot', () => { - test('splits pro-rata by stake with no fee', () => { + test('splits the pot pro-rata by stake with no fee', () => { + // pot 100, winner stakes 40 total → yield 60; each winner gets stake + pro-rata yield. const d = distributePot(USDT0(100n), [ { id: 'a', stake: USDT0(30n) }, { id: 'b', stake: USDT0(10n) }, ]); expect(d.fee).toBe(0n); expect(d.distributable).toBe(USDT0(100n)); - expect(d.payouts.find((p) => p.id === 'a')?.payout).toBe(USDT0(75n)); - expect(d.payouts.find((p) => p.id === 'b')?.payout).toBe(USDT0(25n)); + expect(d.payouts.find((p) => p.id === 'a')?.payout).toBe(USDT0(75n)); // 30 + 45 + expect(d.payouts.find((p) => p.id === 'b')?.payout).toBe(USDT0(25n)); // 10 + 15 expect(d.dust).toBe(0n); }); - test('takes a protocol fee off the top', () => { - const d = distributePot(USDT0(100n), [{ id: 'a', stake: USDT0(1n) }], 1_000n); // 10% - expect(d.fee).toBe(USDT0(10n)); - expect(d.distributable).toBe(USDT0(90n)); - expect(d.payouts[0]?.payout).toBe(USDT0(90n)); + test('charges the fee on the yield only, never the principal', () => { + // pot 100, one winner staking 40 → yield 60; a 20% fee = 12 off the yield; principal untouched. + const d = distributePot(USDT0(100n), [{ id: 'a', stake: USDT0(40n) }], 2_000n); + expect(d.fee).toBe(USDT0(12n)); + expect(d.payouts[0]?.payout).toBe(USDT0(88n)); // stake 40 + net yield 48 + expect(d.distributable).toBe(USDT0(88n)); + }); + + test('NO-LOSS: with no yield (all stake on the winner) each winner gets exactly their stake', () => { + // pot == combined winner stake → zero yield → zero fee → full principal back, even at a 20% rate. + const d = distributePot( + USDT0(2n), + [ + { id: 'a', stake: USDT0(1n) }, + { id: 'b', stake: USDT0(1n) }, + ], + 2_000n, + ); + expect(d.fee).toBe(0n); + expect(d.payouts.find((p) => p.id === 'a')?.payout).toBe(USDT0(1n)); + expect(d.payouts.find((p) => p.id === 'b')?.payout).toBe(USDT0(1n)); }); test('conserves value: fee + payouts + dust == pot', () => { @@ -39,8 +56,9 @@ describe('distributePot', () => { expect(d.dust).toBeGreaterThanOrEqual(0n); }); - test('no winners: everything becomes dust for rollover', () => { + test('no winners: everything becomes dust for rollover (no fee taken)', () => { const d = distributePot(USDT0(50n), []); + expect(d.fee).toBe(0n); expect(d.payouts).toHaveLength(0); expect(d.dust).toBe(USDT0(50n)); }); diff --git a/packages/core/src/domain/pot.ts b/packages/core/src/domain/pot.ts index efc979b..1ad97fe 100644 --- a/packages/core/src/domain/pot.ts +++ b/packages/core/src/domain/pot.ts @@ -15,9 +15,9 @@ export interface Payout { export interface PotDistribution { pot: bigint; - /** Protocol fee taken off the top. */ + /** Protocol fee — taken from the yield only, never from principal. */ fee: bigint; - /** Amount available to winners after the fee. */ + /** Total paid to winners after the fee (their principal + net yield). */ distributable: bigint; payouts: Payout[]; /** Rounding remainder left unallocated (caller decides: roll over / treasury). */ @@ -25,9 +25,11 @@ export interface PotDistribution { } /** - * Distribute `pot` across `winners` pro-rata by stake, after taking `feeBps`. - * If there are no winners (or zero total stake), the full distributable amount - * is returned as `dust` for the caller to roll over or refund. + * Distribute `pot` across `winners` — NO-LOSS: every winner recovers their full stake (principal), + * and only the YIELD (the surplus over the winners' combined stake) is split pro-rata among them. + * The protocol `feeBps` is charged on that yield alone, so a fee can never eat into principal — + * with no yield (e.g. everyone backed the winning outcome), each winner gets exactly their stake. + * If there are no winners, the whole pot rolls over as `dust` (losers are principal-refunded). */ export function distributePot( pot: bigint, @@ -40,20 +42,30 @@ export function distributePot( if (w.stake < 0n) throw new Error('distributePot: stakes must be non-negative'); } - const fee = applyBps(pot, feeBps); - const distributable = pot - fee; const totalStake = sum(winners.map((w) => w.stake)); + // No winners → nobody to pay; the pot rolls over (losers keep their principal, refunded elsewhere). if (totalStake === 0n) { - return { pot, fee, distributable, payouts: [], dust: distributable }; + return { pot, fee: 0n, distributable: 0n, payouts: [], dust: pot }; } + // Yield = whatever the pot holds beyond the winners' own stakes. The fee is charged on this only. + const yieldAmount = pot > totalStake ? pot - totalStake : 0n; + const fee = applyBps(yieldAmount, feeBps); + const netYield = yieldAmount - fee; + const payouts: Payout[] = winners.map((w) => ({ id: w.id, stake: w.stake, - payout: (distributable * w.stake) / totalStake, + payout: w.stake + (netYield * w.stake) / totalStake, })); - const allocated = sum(payouts.map((p) => p.payout)); + const allocatedYield = sum(payouts.map((p) => p.payout - p.stake)); - return { pot, fee, distributable, payouts, dust: distributable - allocated }; + return { + pot, + fee, + distributable: totalStake + netYield, + payouts, + dust: netYield - allocatedYield, + }; }