diff --git a/REAL_MONEY_ROADMAP.md b/REAL_MONEY_ROADMAP.md index 5eebeaf..5832f38 100644 --- a/REAL_MONEY_ROADMAP.md +++ b/REAL_MONEY_ROADMAP.md @@ -10,9 +10,10 @@ wallet, your own money, manual approval, and audit logging. - `/api/live` reports which live-money providers are configured or missing. - `/api/live` accepts public order intents only as dry-runs and returns an audit event. Personal-mode intents are staged for manual review, not executed. -- `/api/trade-tickets` stores non-custodial manual trade tickets. These tickets - contain the market, side, amount, limit, rationale, Polymarket link, and - review instructions, but never a private key or signed order. +- `/api/live?action=tickets` and `/api/live` ticket actions store + non-custodial manual trade tickets. These tickets contain the market, side, + amount, limit, rationale, Polymarket link, and review instructions, but never + a private key or signed order. - `/api/accounts` creates, logs into, and saves password-backed paper accounts through Vercel Blob storage. - `/api/config` exposes safe public provider configuration, including the diff --git a/api/live.js b/api/live.js index 01050d7..6f0fdec 100644 --- a/api/live.js +++ b/api/live.js @@ -1,4 +1,4 @@ -import { recordAuditEvent } from "./_db.js"; +import { createTradeTicket, listTradeTickets, recordAuditEvent, updateTradeTicketStatus } from "./_db.js"; const REQUIRED_ENV = [ ["PRODUCTION_APP_URL", "Production app URL"], @@ -232,15 +232,105 @@ function validateIntent(intent) { return missing; } +const VALID_TICKET_STATUSES = new Set(["staged", "reviewed", "placed_manually", "skipped", "cancelled"]); + +function marketSearchUrl(question) { + return `https://polymarket.com/search?query=${encodeURIComponent(String(question || ""))}`; +} + +function buildTicketInstructions(ticket) { + const marketUrl = ticket.market_url || ticket.marketUrl || marketSearchUrl(ticket.question || ticket.market_id || ticket.marketId); + return { + warning: "Manual review only. The AI/site does not hold a private key and does not submit orders.", + steps: [ + "Open the Polymarket market link.", + "Confirm the exact market, side, current price, liquidity, and resolution terms.", + "Keep the order at or below the staged limit price and max amount.", + "Sign or place the order only from your own wallet/account.", + "Return here and mark the ticket as placed manually or skipped.", + ], + market_url: marketUrl, + }; +} + +function normalizeTicket(body) { + return { + userId: String(body.user_id || "local-readiness-user").trim(), + agentId: String(body.agent_id || "").trim(), + marketId: String(body.market_id || "").trim(), + question: String(body.question || "").trim(), + marketUrl: String(body.market_url || "").trim(), + side: String(body.side || "").trim().toUpperCase(), + maxAmount: Number(body.max_amount || 0), + limitPrice: body.limit_price === undefined || body.limit_price === "" ? null : Number(body.limit_price), + rationale: String(body.rationale || "").trim(), + }; +} + +function validateTicket(ticket) { + const errors = []; + if (!ticket.userId) errors.push("user_id"); + if (!ticket.agentId) errors.push("agent_id"); + if (!ticket.marketId) errors.push("market_id"); + if (!["YES", "NO"].includes(ticket.side)) errors.push("side must be YES or NO"); + if (!Number.isFinite(ticket.maxAmount) || ticket.maxAmount <= 0) errors.push("max_amount must be positive"); + if (ticket.limitPrice != null && (!Number.isFinite(ticket.limitPrice) || ticket.limitPrice <= 0 || ticket.limitPrice >= 1)) { + errors.push("limit_price must be between 0 and 1"); + } + return errors; +} + export default async function handler(req, res) { res.setHeader("Cache-Control", "no-store"); if (req.method === "GET") { + if (req.query?.action === "tickets") { + const userId = String(req.query?.user_id || "local-readiness-user").trim(); + const tickets = await listTradeTickets(userId, req.query?.limit || 50); + return res.status(200).json({ ok: true, tickets }); + } return res.status(200).json(baseStatus()); } if (req.method === "POST") { const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {}); + + if (body.action === "ticket") { + const ticket = normalizeTicket(body); + const ticketErrors = validateTicket(ticket); + if (ticketErrors.length) return res.status(400).json({ ok: false, error: "Invalid trade ticket", details: ticketErrors }); + const saved = await createTradeTicket({ + ...ticket, + status: "staged", + instructions: buildTicketInstructions(ticket), + }); + await recordAuditEvent("TRADE_TICKET_STAGED", { + user_id: ticket.userId, + ticket_id: saved?.id, + agent_id: ticket.agentId, + market_id: ticket.marketId, + side: ticket.side, + max_amount: ticket.maxAmount, + }); + return res.status(201).json({ + ok: true, + ticket: saved, + private_key_used: false, + live_order_placed: false, + manual_review_required: true, + }); + } + + if (body.action === "ticket_status") { + const userId = String(body.user_id || "local-readiness-user").trim(); + const status = String(body.status || "").trim(); + if (!VALID_TICKET_STATUSES.has(status)) return res.status(400).json({ ok: false, error: "Invalid ticket status" }); + const ticket = await updateTradeTicketStatus(userId, body.ticket_id, status); + if (!ticket) return res.status(404).json({ ok: false, error: "Ticket not found" }); + await recordAuditEvent("TRADE_TICKET_STATUS_UPDATED", { user_id: userId, ticket_id: ticket.id, status }); + return res.status(200).json({ ok: true, ticket }); + } + const intent = body.intent || body; const errors = validateIntent(intent); if (errors.length) { diff --git a/api/trade-tickets.js b/api/trade-tickets.js deleted file mode 100644 index 1f15e53..0000000 --- a/api/trade-tickets.js +++ /dev/null @@ -1,105 +0,0 @@ -import { createTradeTicket, listTradeTickets, recordAuditEvent, updateTradeTicketStatus } from "./_db.js"; - -const VALID_STATUSES = new Set(["staged", "reviewed", "placed_manually", "skipped", "cancelled"]); - -function marketSearchUrl(question) { - return `https://polymarket.com/search?query=${encodeURIComponent(String(question || ""))}`; -} - -function buildInstructions(ticket) { - const marketUrl = ticket.marketUrl || marketSearchUrl(ticket.question || ticket.marketId); - return { - warning: "Manual review only. The AI/site does not hold a private key and does not submit orders.", - steps: [ - "Open the Polymarket market link.", - "Confirm the exact market, side, current price, liquidity, and resolution terms.", - "Keep the order at or below the staged limit price and max amount.", - "Sign or place the order only from your own wallet/account.", - "Return here and mark the ticket as placed manually or skipped.", - ], - market_url: marketUrl, - }; -} - -function normalizeTicket(body) { - return { - userId: String(body.user_id || "local-readiness-user").trim(), - agentId: String(body.agent_id || "").trim(), - marketId: String(body.market_id || "").trim(), - question: String(body.question || "").trim(), - marketUrl: String(body.market_url || "").trim(), - side: String(body.side || "").trim().toUpperCase(), - maxAmount: Number(body.max_amount || 0), - limitPrice: body.limit_price === undefined || body.limit_price === "" ? null : Number(body.limit_price), - rationale: String(body.rationale || "").trim(), - }; -} - -function validate(ticket) { - const errors = []; - if (!ticket.userId) errors.push("user_id"); - if (!ticket.agentId) errors.push("agent_id"); - if (!ticket.marketId) errors.push("market_id"); - if (!["YES", "NO"].includes(ticket.side)) errors.push("side must be YES or NO"); - if (!Number.isFinite(ticket.maxAmount) || ticket.maxAmount <= 0) errors.push("max_amount must be positive"); - if (ticket.limitPrice != null && (!Number.isFinite(ticket.limitPrice) || ticket.limitPrice <= 0 || ticket.limitPrice >= 1)) { - errors.push("limit_price must be between 0 and 1"); - } - return errors; -} - -export default async function handler(req, res) { - res.setHeader("Cache-Control", "no-store"); - - try { - if (req.method === "GET") { - const userId = String(req.query?.user_id || "local-readiness-user").trim(); - const tickets = await listTradeTickets(userId, req.query?.limit || 50); - return res.status(200).json({ ok: true, tickets }); - } - - if (req.method === "POST") { - const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {}); - if (body.action === "status") { - const userId = String(body.user_id || "local-readiness-user").trim(); - const status = String(body.status || "").trim(); - if (!VALID_STATUSES.has(status)) return res.status(400).json({ ok: false, error: "Invalid ticket status" }); - const ticket = await updateTradeTicketStatus(userId, body.ticket_id, status); - if (!ticket) return res.status(404).json({ ok: false, error: "Ticket not found" }); - await recordAuditEvent("TRADE_TICKET_STATUS_UPDATED", { user_id: userId, ticket_id: ticket.id, status }); - return res.status(200).json({ ok: true, ticket }); - } - - const ticket = normalizeTicket(body); - const errors = validate(ticket); - if (errors.length) return res.status(400).json({ ok: false, error: "Invalid trade ticket", details: errors }); - - const saved = await createTradeTicket({ - ...ticket, - status: "staged", - instructions: buildInstructions(ticket), - }); - await recordAuditEvent("TRADE_TICKET_STAGED", { - user_id: ticket.userId, - ticket_id: saved?.id, - agent_id: ticket.agentId, - market_id: ticket.marketId, - side: ticket.side, - max_amount: ticket.maxAmount, - }); - - return res.status(201).json({ - ok: true, - ticket: saved, - private_key_used: false, - live_order_placed: false, - manual_review_required: true, - }); - } - - res.setHeader("Allow", "GET, POST"); - return res.status(405).json({ ok: false, error: "Method not allowed" }); - } catch (err) { - return res.status(500).json({ ok: false, error: err?.message || "Trade ticket request failed" }); - } -} diff --git a/index.html b/index.html index 830cef6..5c661b3 100644 --- a/index.html +++ b/index.html @@ -2450,7 +2450,7 @@ async function dryRunLiveOrderIntent(){ } } async function createTradeTicket(ticket){ - const r=await fetch("/api/trade-tickets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(ticket)}); + const r=await fetch("/api/live",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...ticket,action:"ticket"})}); const d=await r.json(); if(!r.ok)throw new Error(d.error||"Ticket staging failed"); toast("Manual trade ticket staged."); @@ -2480,7 +2480,7 @@ async function stageSuggestionTicket(marketId){ async function loadTradeTickets(){ if(!PERSONAL_MODE)return; try{ - const r=await fetch(`/api/trade-tickets?user_id=${encodeURIComponent(PERSONAL_USER_ID)}&limit=30`,{cache:"no-store"}); + const r=await fetch(`/api/live?action=tickets&user_id=${encodeURIComponent(PERSONAL_USER_ID)}&limit=30`,{cache:"no-store"}); const d=await r.json(); PERSONAL_TICKETS=d.tickets||[]; }catch(e){PERSONAL_TICKETS=[];} @@ -2488,7 +2488,7 @@ async function loadTradeTickets(){ } async function updateTicketStatus(ticketId,status){ try{ - const r=await fetch("/api/trade-tickets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({action:"status",user_id:PERSONAL_USER_ID,ticket_id:ticketId,status})}); + const r=await fetch("/api/live",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({action:"ticket_status",user_id:PERSONAL_USER_ID,ticket_id:ticketId,status})}); const d=await r.json(); if(!r.ok)throw new Error(d.error||"Ticket update failed"); await loadTradeTickets();