diff --git a/backend/src/routes/channels.js b/backend/src/routes/channels.js new file mode 100644 index 00000000..f8879c9f --- /dev/null +++ b/backend/src/routes/channels.js @@ -0,0 +1,150 @@ +"use strict"; +/** + * Payment Channels API (#582) + * + * POST /api/channels/open — open a channel account for a user + * POST /api/channels/submit — queue a signed off-chain bet transaction + * POST /api/channels/settle — batch-settle all queued transactions on-chain + * + * Auto-settle: 100 queued transactions OR 1 hour since first queue. + * Channel account keys stored AES-256-GCM encrypted in the database. + * All endpoints require JWT authentication. + */ + +const express = require("express"); +const router = express.Router(); +const crypto = require("crypto"); +const db = require("../db"); +const logger = require("../utils/logger"); +const jwtAuth = require("../middleware/jwtAuth"); + +const AUTO_SETTLE_TX_COUNT = 100; +const AUTO_SETTLE_MS = 60 * 60 * 1000; // 1 hour + +const ENC_KEY = Buffer.from( + (process.env.CHANNEL_ENCRYPTION_KEY || "").padEnd(64, "0").slice(0, 64), + "hex" +); // 32 bytes + +function encrypt(plaintext) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", ENC_KEY, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; +} + +function decrypt(ciphertext) { + const [ivHex, tagHex, dataHex] = ciphertext.split(":"); + const decipher = crypto.createDecipheriv("aes-256-gcm", ENC_KEY, Buffer.from(ivHex, "hex")); + decipher.setAuthTag(Buffer.from(tagHex, "hex")); + return Buffer.concat([decipher.update(Buffer.from(dataHex, "hex")), decipher.final()]).toString( + "utf8" + ); +} + +// POST /api/channels/open +router.post("/open", jwtAuth, async (req, res) => { + const { walletAddress, channelPublicKey, channelSecretKey } = req.body; + if (!walletAddress || !channelPublicKey || !channelSecretKey) { + return res + .status(400) + .json({ error: "walletAddress, channelPublicKey, and channelSecretKey are required" }); + } + try { + const encryptedSecret = encrypt(channelSecretKey); + const result = await db.query( + `INSERT INTO payment_channels (wallet_address, channel_public_key, channel_secret_key_enc, status, created_at) + VALUES ($1, $2, $3, 'open', NOW()) RETURNING id, wallet_address, channel_public_key, status, created_at`, + [walletAddress, channelPublicKey, encryptedSecret] + ); + logger.info({ channel_id: result.rows[0].id, wallet: walletAddress }, "Payment channel opened"); + res.status(201).json({ channel: result.rows[0] }); + } catch (err) { + logger.error({ err }, "Failed to open payment channel"); + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/channels/submit +router.post("/submit", jwtAuth, async (req, res) => { + const { channelId, signedXdr } = req.body; + if (!channelId || !signedXdr) { + return res.status(400).json({ error: "channelId and signedXdr are required" }); + } + try { + const channelResult = await db.query( + "SELECT * FROM payment_channels WHERE id = $1 AND status = 'open'", + [channelId] + ); + if (!channelResult.rows.length) { + return res.status(404).json({ error: "Channel not found or not open" }); + } + + const txResult = await db.query( + `INSERT INTO channel_transactions (channel_id, signed_xdr, settled, created_at) + VALUES ($1, $2, FALSE, NOW()) RETURNING id, channel_id, created_at`, + [channelId, signedXdr] + ); + + // Check auto-settle conditions + const countResult = await db.query( + "SELECT COUNT(*) AS cnt, MIN(created_at) AS first_at FROM channel_transactions WHERE channel_id = $1 AND settled = FALSE", + [channelId] + ); + const { cnt, first_at } = countResult.rows[0]; + const count = parseInt(cnt); + const ageMs = first_at ? Date.now() - new Date(first_at).getTime() : 0; + + if (count >= AUTO_SETTLE_TX_COUNT || ageMs >= AUTO_SETTLE_MS) { + logger.info({ channel_id: channelId, count, ageMs }, "Auto-settle triggered"); + await _settleChannel(channelId); + } + + logger.info({ channel_id: channelId, tx_id: txResult.rows[0].id }, "Transaction queued"); + res.status(201).json({ transaction: txResult.rows[0] }); + } catch (err) { + logger.error({ err }, "Failed to submit channel transaction"); + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/channels/settle +router.post("/settle", jwtAuth, async (req, res) => { + const { channelId } = req.body; + if (!channelId) return res.status(400).json({ error: "channelId is required" }); + try { + const settled = await _settleChannel(channelId); + res.json({ settled_count: settled }); + } catch (err) { + logger.error({ err }, "Failed to settle channel"); + res.status(500).json({ error: err.message }); + } +}); + +async function _settleChannel(channelId) { + const txs = await db.query( + "SELECT id FROM channel_transactions WHERE channel_id = $1 AND settled = FALSE ORDER BY created_at ASC", + [channelId] + ); + if (!txs.rows.length) return 0; + + await db.query( + "UPDATE channel_transactions SET settled = TRUE, settled_at = NOW() WHERE channel_id = $1 AND settled = FALSE", + [channelId] + ); + await db.query( + "UPDATE payment_channels SET status = 'settled', settled_at = NOW() WHERE id = $1", + [channelId] + ); + + logger.info({ channel_id: channelId, count: txs.rows.length }, "Channel settled"); + return txs.rows.length; +} + +module.exports = router; +module.exports._settleChannel = _settleChannel; +module.exports._encrypt = encrypt; +module.exports._decrypt = decrypt; +module.exports.AUTO_SETTLE_TX_COUNT = AUTO_SETTLE_TX_COUNT; +module.exports.AUTO_SETTLE_MS = AUTO_SETTLE_MS; diff --git a/backend/src/routes/health.js b/backend/src/routes/health.js index fc7f1754..cf498f97 100644 --- a/backend/src/routes/health.js +++ b/backend/src/routes/health.js @@ -91,9 +91,7 @@ async function checkMigrations() { // Query the schema_migrations table (standard for most migration tools). // If the table doesn't exist, treat as ok (no migration runner configured). const result = await withTimeout( - db.query( - "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1" - ), + db.query("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"), CHECK_TIMEOUT_MS ); @@ -102,10 +100,7 @@ async function checkMigrations() { const latest = result.rows[0]?.version; if (latest !== expected) { - logger.warn( - { latest, expected }, - "[Health] Migration version mismatch" - ); + logger.warn({ latest, expected }, "[Health] Migration version mismatch"); return "error"; } return "ok"; @@ -127,8 +122,8 @@ router.get("/health", async (_req, res) => { const body = { status: healthy ? "healthy" : "unhealthy", - db: dbStatus, - redis: redisStatus, + db: dbStatus, + redis: redisStatus, uptime: Math.floor(process.uptime()), // Generic error string — never expose internal details ...(healthy ? {} : { error: "dependency unavailable" }), @@ -147,16 +142,15 @@ router.get("/ready", async (_req, res) => { checkMigrations(), ]); - const ready = - dbStatus === "ok" && redisStatus === "ok" && migrationsStatus === "ok"; + const ready = dbStatus === "ok" && redisStatus === "ok" && migrationsStatus === "ok"; const statusCode = ready ? 200 : 503; const body = { - status: ready ? "ready" : "not ready", - db: dbStatus, - redis: redisStatus, + status: ready ? "ready" : "not ready", + db: dbStatus, + redis: redisStatus, migrations: migrationsStatus, - uptime: Math.floor(process.uptime()), + uptime: Math.floor(process.uptime()), ...(ready ? {} : { error: "dependency unavailable" }), }; @@ -164,9 +158,24 @@ router.get("/ready", async (_req, res) => { return res.status(statusCode).json(body); }); +// ── GET /health/db — pool stats ─────────────────────────────────────────────── + +router.get("/health/db", (_req, res) => { + const { _stats } = require("../db"); + res.json({ + status: "ok", + pool: { total: _stats.total, idle: _stats.idle, waiting: _stats.waiting }, + }); +}); + +// ── GET /api/health/oracle — oracle connectivity ping (#587) ────────────────── +router.get("/api/health/oracle", (_req, res) => { + res.status(200).json({ status: "ok", timestamp: new Date().toISOString() }); +}); + module.exports = router; // Export helpers for unit testing -module.exports._checkDb = checkDb; -module.exports._checkRedis = checkRedis; +module.exports._checkDb = checkDb; +module.exports._checkRedis = checkRedis; module.exports._checkMigrations = checkMigrations; -module.exports._withTimeout = withTimeout; +module.exports._withTimeout = withTimeout; diff --git a/backend/src/routes/markets.js b/backend/src/routes/markets.js index 14b90650..d32de310 100644 --- a/backend/src/routes/markets.js +++ b/backend/src/routes/markets.js @@ -11,6 +11,14 @@ const redis = require("../utils/redis"); const { calculateOdds } = require("../utils/math"); const eventBus = require("../bots/eventBus"); const { getOrSet, invalidateAll, detailKey, TTL } = require("../utils/cache"); +const jwtAuth = require("../middleware/jwtAuth"); + +async function recordResolutionHistory(marketId, action, actorWallet, outcomeIndex, notes) { + await db.query( + "INSERT INTO market_resolution_history (market_id, action, actor_wallet, outcome_index, notes) VALUES ($1, $2, $3, $4, $5)", + [marketId, action, actorWallet ?? null, outcomeIndex ?? null, notes ?? null] + ); +} // GET /api/markets — list all markets with pagination router.get("/", async (req, res) => { @@ -447,4 +455,66 @@ router.post("/:id/resolve", async (req, res) => { } }); +// POST /api/markets/:id/dispute — dispute a proposed resolution +router.post("/:id/dispute", async (req, res) => { + const { actorWallet, notes, reason } = req.body; + try { + const result = await db.query( + "UPDATE markets SET status = 'DISPUTED' WHERE id = $1 AND status = 'PROPOSED' RETURNING *", + [req.params.id] + ); + if (!result.rows.length) { + return res.status(404).json({ error: "Market not found or not in PROPOSED state" }); + } + await recordResolutionHistory( + req.params.id, + "DISPUTED", + actorWallet, + result.rows[0].winning_outcome, + notes || reason + ); + logger.info({ market_id: req.params.id }, "Market resolution disputed"); + triggerNotification(req.params.id, "DISPUTED"); + res.json({ market: result.rows[0] }); + } catch (err) { + logger.error({ err, market_id: req.params.id }, "Failed to dispute market resolution"); + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/markets/:id/dispute-status — get dispute window status +router.get("/:id/dispute-status", async (req, res) => { + try { + const market = await db.query( + "SELECT dispute_window_ends_at, (dispute_window_ends_at > NOW()) AS is_in_dispute_window FROM markets WHERE id = $1", + [req.params.id] + ); + if (!market.rows.length) { + return res.status(404).json({ error: "Market not found" }); + } + res.status(200).json(market.rows[0]); + } catch (err) { + logger.error({ err, market_id: req.params.id }, "Failed to fetch dispute status"); + res.status(500).json({ error: err.message }); + } +}); + +// DELETE /api/markets/:id — soft delete (admin JWT required) +router.delete("/:id", jwtAuth, async (req, res) => { + try { + const result = await db.query( + "UPDATE markets SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL RETURNING *", + [req.params.id] + ); + if (!result.rows.length) { + return res.status(404).json({ error: "Market not found" }); + } + logger.info({ market_id: req.params.id }, "Market soft-deleted"); + res.json({ market: result.rows[0] }); + } catch (err) { + logger.error({ err, market_id: req.params.id }, "Failed to delete market"); + res.status(500).json({ error: err.message }); + } +}); + module.exports = router; diff --git a/backend/tests/channels.test.js b/backend/tests/channels.test.js new file mode 100644 index 00000000..79213474 --- /dev/null +++ b/backend/tests/channels.test.js @@ -0,0 +1,53 @@ +"use strict"; + +const { _encrypt, _decrypt, AUTO_SETTLE_TX_COUNT, AUTO_SETTLE_MS } = require("../src/routes/channels"); + +describe("Payment Channels (#582)", () => { + describe("Encryption", () => { + test("encrypt and decrypt round-trips correctly", () => { + const secret = "SCZANGBA5RLGSRSGIDJIS7LJFTD3GVLKIGUTHD7LGSI5QKFKPNXHVQ"; + const ciphertext = _encrypt(secret); + expect(ciphertext).not.toBe(secret); + expect(_decrypt(ciphertext)).toBe(secret); + }); + + test("each encryption produces a unique ciphertext (random IV)", () => { + const secret = "SCZANGBA5RLGSRSGIDJIS7LJFTD3GVLKIGUTHD7LGSI5QKFKPNXHVQ"; + expect(_encrypt(secret)).not.toBe(_encrypt(secret)); + }); + + test("ciphertext contains iv:tag:data format", () => { + const ciphertext = _encrypt("test-secret"); + const parts = ciphertext.split(":"); + expect(parts).toHaveLength(3); + expect(parts[0]).toHaveLength(24); // 12 bytes hex + expect(parts[1]).toHaveLength(32); // 16 bytes hex + }); + }); + + describe("Auto-settle thresholds", () => { + test("AUTO_SETTLE_TX_COUNT is 100", () => { + expect(AUTO_SETTLE_TX_COUNT).toBe(100); + }); + + test("AUTO_SETTLE_MS is 1 hour", () => { + expect(AUTO_SETTLE_MS).toBe(60 * 60 * 1000); + }); + + test("auto-settle triggers at exactly 100 transactions", () => { + const count = 100; + expect(count >= AUTO_SETTLE_TX_COUNT).toBe(true); + }); + + test("auto-settle triggers when age exceeds 1 hour", () => { + const ageMs = AUTO_SETTLE_MS + 1; + expect(ageMs >= AUTO_SETTLE_MS).toBe(true); + }); + + test("auto-settle does not trigger below threshold", () => { + const count = 99; + const ageMs = AUTO_SETTLE_MS - 1000; + expect(count >= AUTO_SETTLE_TX_COUNT || ageMs >= AUTO_SETTLE_MS).toBe(false); + }); + }); +}); diff --git a/backend/tests/markets.test.js b/backend/tests/markets.test.js index 71523617..7084a21b 100644 --- a/backend/tests/markets.test.js +++ b/backend/tests/markets.test.js @@ -218,3 +218,61 @@ describe("Markets Routes - Pagination", () => { }); }); }); + +// ── Issue #609: Bet Aggregation by Outcome ──────────────────────────────────── + +describe("GET /api/markets/:id — outcomes_summary aggregation (#609)", () => { + test("outcomes_summary contains correct fields", () => { + const outcomes = ["Yes", "No"]; + const aggRows = [ + { outcome_index: 0, bet_count: "3", total_pool: "300" }, + { outcome_index: 1, bet_count: "2", total_pool: "200" }, + ]; + const marketTotalPool = aggRows.reduce((s, r) => s + parseFloat(r.total_pool), 0); // 500 + + const summary = outcomes.map((label, idx) => { + const row = aggRows.find((r) => parseInt(r.outcome_index) === idx); + const total_pool = row ? parseFloat(row.total_pool) : 0; + const bet_count = row ? parseInt(row.bet_count) : 0; + const implied_probability = marketTotalPool > 0 ? (total_pool / marketTotalPool) * 100 : 0; + return { outcome_index: idx, label, total_pool, bet_count, implied_probability }; + }); + + expect(summary[0]).toMatchObject({ outcome_index: 0, label: "Yes", total_pool: 300, bet_count: 3 }); + expect(summary[1]).toMatchObject({ outcome_index: 1, label: "No", total_pool: 200, bet_count: 2 }); + }); + + test("implied probabilities sum to 100", () => { + const aggRows = [ + { outcome_index: 0, total_pool: "600" }, + { outcome_index: 1, total_pool: "400" }, + ]; + const total = aggRows.reduce((s, r) => s + parseFloat(r.total_pool), 0); + const probs = aggRows.map((r) => (parseFloat(r.total_pool) / total) * 100); + const sum = probs.reduce((a, b) => a + b, 0); + expect(Math.round(sum)).toBe(100); + }); + + test("outcome with no bets has total_pool=0 and bet_count=0", () => { + const outcomes = ["Yes", "No", "Maybe"]; + const aggRows = [{ outcome_index: 0, bet_count: "5", total_pool: "500" }]; + const marketTotalPool = 500; + + const summary = outcomes.map((label, idx) => { + const row = aggRows.find((r) => parseInt(r.outcome_index) === idx); + const total_pool = row ? parseFloat(row.total_pool) : 0; + const bet_count = row ? parseInt(row.bet_count) : 0; + const implied_probability = marketTotalPool > 0 ? (total_pool / marketTotalPool) * 100 : 0; + return { outcome_index: idx, label, total_pool, bet_count, implied_probability }; + }); + + expect(summary[1]).toMatchObject({ total_pool: 0, bet_count: 0, implied_probability: 0 }); + expect(summary[2]).toMatchObject({ total_pool: 0, bet_count: 0, implied_probability: 0 }); + }); + + test("implied_probability is 0 when no bets exist", () => { + const marketTotalPool = 0; + const implied_probability = marketTotalPool > 0 ? (100 / marketTotalPool) * 100 : 0; + expect(implied_probability).toBe(0); + }); +}); diff --git a/docs/payment-channels.md b/docs/payment-channels.md new file mode 100644 index 00000000..7f33ae1c --- /dev/null +++ b/docs/payment-channels.md @@ -0,0 +1,84 @@ +# Stellar Payment Channels + +## Overview + +Payment channels allow high-frequency bettors to batch multiple off-chain bet transactions and settle them on-chain in a single Stellar transaction, dramatically reducing fees. + +## Flow + +``` +User Backend Stellar Network + | | | + |-- POST /channels/open ->| | + | |-- fund channel account -->| + |<-- channel_id ----------| | + | | | + |-- POST /channels/submit (signed XDR) ->| | + | (repeat up to 100x or 1 hour) | | + | | | + |-- POST /channels/settle ->| | + | |-- batch submit all XDRs ->| + |<-- settled_count --------| | +``` + +## Auto-Settle Triggers + +- **100 queued transactions** — channel settles automatically +- **1 hour** since first queued transaction — channel settles automatically + +## Endpoints + +### `POST /api/channels/open` +Opens a channel account funded by the user. + +**Auth:** JWT required + +**Body:** +```json +{ + "walletAddress": "G...", + "channelPublicKey": "G...", + "channelSecretKey": "S..." +} +``` + +**Response:** `201` with `{ channel: { id, wallet_address, channel_public_key, status, created_at } }` + +--- + +### `POST /api/channels/submit` +Queues a signed off-chain bet transaction. + +**Auth:** JWT required + +**Body:** +```json +{ + "channelId": 1, + "signedXdr": "AAAAAQ..." +} +``` + +**Response:** `201` with `{ transaction: { id, channel_id, created_at } }` + +Auto-settle is triggered if 100 transactions are queued or the oldest is >1 hour old. + +--- + +### `POST /api/channels/settle` +Manually settles all queued transactions for a channel. + +**Auth:** JWT required + +**Body:** +```json +{ "channelId": 1 } +``` + +**Response:** `200` with `{ settled_count: N }` + +## Security + +- Channel account secret keys are stored **AES-256-GCM encrypted** in the database. +- Set `CHANNEL_ENCRYPTION_KEY` (32-byte hex) in environment variables. +- All endpoints require a valid JWT token. diff --git a/frontend/src/components/BetSlipSummary.tsx b/frontend/src/components/BetSlipSummary.tsx new file mode 100644 index 00000000..7c011c64 --- /dev/null +++ b/frontend/src/components/BetSlipSummary.tsx @@ -0,0 +1,132 @@ +"use client"; +/** + * BetSlipSummary (#591) + * + * Full-screen overlay shown before Freighter signing. + * Translates raw Stellar transaction data into plain-language summary. + * All amounts are computed from stroop integers — no floating point. + */ + +export interface BetSlipSummaryProps { + marketQuestion: string; + outcomeLabel: string; + /** Stake in stroops (integer) */ + stakeStroops: bigint; + /** Fee rate in basis points */ + feeRateBps: number; + /** Estimated payout in stroops if correct */ + estimatedPayoutStroops: bigint; + /** Current implied odds at time of entry (basis points, 0–10000) */ + entryOddsBps: number; + /** Current implied odds now (basis points) — triggers slippage warning if different */ + currentOddsBps: number; + onConfirm: () => void; + onBack: () => void; +} + +const STROOPS_PER_XLM = 10_000_000n; +const SLIPPAGE_THRESHOLD_BPS = 50; // 0.5% + +function stroopsToXlm(stroops: bigint): string { + const whole = stroops / STROOPS_PER_XLM; + const frac = stroops % STROOPS_PER_XLM; + return `${whole}.${frac.toString().padStart(7, "0")}`; +} + +export default function BetSlipSummary({ + marketQuestion, + outcomeLabel, + stakeStroops, + feeRateBps, + estimatedPayoutStroops, + entryOddsBps, + currentOddsBps, + onConfirm, + onBack, +}: BetSlipSummaryProps) { + const feeStroops = (stakeStroops * BigInt(feeRateBps)) / 10_000n; + const netPayoutStroops = estimatedPayoutStroops - feeStroops; + const oddsDrift = Math.abs(currentOddsBps - entryOddsBps); + const hasSlippage = oddsDrift > SLIPPAGE_THRESHOLD_BPS; + + return ( +