diff --git a/.env.example b/.env.example index 6b2f6ac..917413c 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,9 @@ PRODUCTION_APP_URL=https://polymarket-site-eta.vercel.app # Keep false until legal/compliance approval, provider credentials, wallet signing, # payment rails, and audit logging are production-ready. LIVE_TRADING_ENABLED=false -# Personal-only feature gate. Keep false until your own wallet/CLOB credentials -# are configured and tiny manual test orders are reviewed. +# Personal-only readiness gate. Keep false until your own wallet/CLOB +# credentials are configured and tiny manual test orders are reviewed. +# This does not let the AI or server hold private keys. PERSONAL_TRADING_ENABLED=false # Accounts / authentication diff --git a/REAL_MONEY_ROADMAP.md b/REAL_MONEY_ROADMAP.md index 794f4b7..5eebeaf 100644 --- a/REAL_MONEY_ROADMAP.md +++ b/REAL_MONEY_ROADMAP.md @@ -10,6 +10,9 @@ 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/accounts` creates, logs into, and saves password-backed paper accounts through Vercel Blob storage. - `/api/config` exposes safe public provider configuration, including the @@ -44,6 +47,8 @@ Personal mode is narrower than the public business launch: - No unattended agent trading. - Every live trade starts as a staged manual intent and must be reviewed before you do anything in Polymarket. +- Trade execution happens outside Poly Arena through your own Polymarket wallet + or UI. Poly Arena remains the analyst/ticketing layer. - Public `LIVE_TRADING_ENABLED` stays `false`. Personal mode still needs: diff --git a/api/_db.js b/api/_db.js index 3741b3f..6cdf720 100644 --- a/api/_db.js +++ b/api/_db.js @@ -77,6 +77,30 @@ export async function ensureSchema() { ) `; + await db` + create table if not exists trade_tickets ( + id bigserial primary key, + user_id text not null, + agent_id text not null, + market_id text not null, + question text, + market_url text, + side text not null, + max_amount numeric not null, + limit_price numeric, + rationale text, + status text not null default 'staged', + instructions jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ) + `; + + await db` + create index if not exists trade_tickets_user_status_idx + on trade_tickets (user_id, status, created_at desc) + `; + schemaReady = true; } @@ -194,6 +218,64 @@ export async function latestRiskProfile(userId) { return rows[0] || null; } +export async function createTradeTicket(ticket) { + await ensureSchema(); + const db = sql(); + const rows = await db` + insert into trade_tickets ( + user_id, + agent_id, + market_id, + question, + market_url, + side, + max_amount, + limit_price, + rationale, + status, + instructions + ) values ( + ${ticket.userId}, + ${ticket.agentId}, + ${ticket.marketId}, + ${ticket.question || null}, + ${ticket.marketUrl || null}, + ${ticket.side}, + ${Number(ticket.maxAmount || 0)}, + ${ticket.limitPrice == null ? null : Number(ticket.limitPrice)}, + ${ticket.rationale || null}, + ${ticket.status || "staged"}, + ${JSON.stringify(ticket.instructions || {})}::jsonb + ) + returning * + `; + return rows[0] || null; +} + +export async function listTradeTickets(userId, limit = 50) { + await ensureSchema(); + const db = sql(); + return db` + select * + from trade_tickets + where user_id = ${userId} + order by created_at desc + limit ${Math.max(1, Math.min(Number(limit) || 50, 100))} + `; +} + +export async function updateTradeTicketStatus(userId, ticketId, status) { + await ensureSchema(); + const db = sql(); + const rows = await db` + update trade_tickets + set status = ${status}, updated_at = now() + where user_id = ${userId} and id = ${Number(ticketId)} + returning * + `; + return rows[0] || null; +} + export async function providerEventSummary() { await ensureSchema(); const db = sql(); diff --git a/api/trade-tickets.js b/api/trade-tickets.js new file mode 100644 index 0000000..1f15e53 --- /dev/null +++ b/api/trade-tickets.js @@ -0,0 +1,105 @@ +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 86af8c6..830cef6 100644 --- a/index.html +++ b/index.html @@ -583,8 +583,10 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
${esc(JSON.stringify(d.audit_event||d,null,2))}