Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions backend/src/routes/channels.js
Original file line number Diff line number Diff line change
@@ -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;
45 changes: 27 additions & 18 deletions backend/src/routes/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);

Expand All @@ -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";
Expand All @@ -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" }),
Expand All @@ -147,26 +142,40 @@ 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" }),
};

logger.debug(body, "[Health] /ready");
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;
70 changes: 70 additions & 0 deletions backend/src/routes/markets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
53 changes: 53 additions & 0 deletions backend/tests/channels.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading