From c391a4741ebcf81c12407f9a74403d63f97cd7ce Mon Sep 17 00:00:00 2001 From: Theodore Song Date: Wed, 15 Jul 2026 00:32:50 -0400 Subject: [PATCH] Add non-custodial manual trade tickets --- .env.example | 5 +- REAL_MONEY_ROADMAP.md | 5 ++ api/_db.js | 82 +++++++++++++++++++++++++++++++++ api/trade-tickets.js | 105 ++++++++++++++++++++++++++++++++++++++++++ index.html | 97 +++++++++++++++++++++++++++++++++++++- 5 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 api/trade-tickets.js 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
+ +
    @@ -600,6 +602,10 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
    +
    +

    Manual trade ticket queue

    AI suggests, you sign elsewhere
    +
    +

    Provider webhooks

    events are stored in Neon
    Checking webhook setup...
    @@ -697,6 +703,7 @@ const PAPER_KEY = "pma_paper_accounts_v1"; const LIVE_KEY = "pma_live_readiness_v1"; const PAPER_SESSION_PREFIX = "pma_cloud_session_"; const PERSONAL_MODE = new URLSearchParams(location.search).get("personal") === "1"; +const PERSONAL_USER_ID = "local-readiness-user"; const CATEGORIES = ["All","Politics","Sports","Crypto","Economy","Pop Culture","Other"]; const SYNC_KEYS=[AGENTS_KEY,SUG_KEY,FOCUS_KEY,VIEW_KEY,PF_SORT_KEY,CHART_RANGE_KEY,INVEST_KEY,PAPER_KEY,LIVE_KEY]; const CAT_COLORS = {Politics:"#fb7185",Sports:"#34d399",Crypto:"#fbbf24",Economy:"#7c8cff", @@ -709,6 +716,7 @@ let PAPER_MARKET_OFFSET = 0; let PAPER_MARKET_QUERY = ""; let LIVE_BACKEND_STATUS = null; let LIVE_POLICY_STATUS = null; +let PERSONAL_TICKETS = []; let PROVIDER_CONFIG = null; const catColor = (c) => CAT_COLORS[c] || CAT_COLORS.Other; const getFocus = () => localStorage.getItem(FOCUS_KEY) || "All"; @@ -1808,8 +1816,10 @@ function renderSuggestions(){
    Vol ${fmtUSD(s.volume)}24h ${fmtUSD(s.volume_24hr)}Resolves ${days} ${s.url?`Open on Polymarket ↗`:""} + ${PERSONAL_MODE?``:""}
    `; }).join(""); + document.querySelectorAll("[data-stage-suggestion]").forEach(btn=>btn.onclick=()=>stageSuggestionTicket(btn.dataset.stageSuggestion)); } function renderPortfolioTab(){ const st=loadState(); @@ -2379,6 +2389,7 @@ async function fetchLiveBackendStatus(){ LIVE_POLICY_STATUS={ok:false,note:"Policy status endpoint is unavailable."}; } renderLiveBackendStatus(); + if(PERSONAL_MODE)loadTradeTickets(); } function renderLiveBackendStatus(){ const root=$("liveBackendStatus");if(!root)return; @@ -2407,24 +2418,106 @@ function renderLiveBackendStatus(){ async function dryRunLiveOrderIntent(){ const s=loadLiveState(); const intent={ - user_id:"local-readiness-user", + user_id:PERSONAL_USER_ID, wallet_address:s.walletAddress, agent_id:$("liveIntentAgent").value, market_id:$("liveIntentMarket").value.trim(), + question:($("liveIntentQuestion")&&$("liveIntentQuestion").value||"").trim(), side:$("liveIntentSide").value, max_amount:Number($("liveIntentAmount").value||0), + limit_price:Number(($("liveIntentPrice")&&$("liveIntentPrice").value)||0)||null, execution_mode:"manual_approval", account_scope:PERSONAL_MODE?"personal":"public_readiness", }; try{ + if(PERSONAL_MODE)await createTradeTicket({ + user_id:intent.user_id, + agent_id:intent.agent_id, + market_id:intent.market_id, + question:intent.question, + side:intent.side, + max_amount:intent.max_amount, + limit_price:intent.limit_price, + rationale:"Manually staged from the personal cockpit.", + }); const r=await fetch("/api/live",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({intent})}); const d=await r.json(); $("liveOrderDryRun").innerHTML=`
    Order intent dry-run
    ${esc(d.error||"Accepted dry-run.")}
    ${esc(JSON.stringify(d.audit_event||d,null,2))}
    `; saveLiveState(s,`${PERSONAL_MODE?"Staged manual":"Dry-ran"} ${intent.side} order intent for ${intent.agent_id} on ${intent.market_id}`); + if(PERSONAL_MODE)await loadTradeTickets(); }catch(e){ - toast("Order dry-run failed."); + toast(e.message||"Order dry-run failed."); } } +async function createTradeTicket(ticket){ + const r=await fetch("/api/trade-tickets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(ticket)}); + const d=await r.json(); + if(!r.ok)throw new Error(d.error||"Ticket staging failed"); + toast("Manual trade ticket staged."); + return d.ticket; +} +async function stageSuggestionTicket(marketId){ + const s=(loadSuggestions().suggestions||[]).find(x=>String(x.market_id)===String(marketId)); + if(!s)return toast("Suggestion not found."); + const amount=Number(prompt("Max amount for this manual ticket?", "10")||0); + if(!amount||amount<=0)return; + try{ + await createTradeTicket({ + user_id:PERSONAL_USER_ID, + agent_id:localStorage.getItem(VIEW_KEY)||"value", + market_id:s.market_id, + question:s.question, + market_url:s.url, + side:s.side, + max_amount:amount, + limit_price:s.entry_price, + rationale:s.rationale, + }); + await loadTradeTickets(); + showTab("live"); + }catch(e){toast(e.message||"Ticket staging failed.");} +} +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 d=await r.json(); + PERSONAL_TICKETS=d.tickets||[]; + }catch(e){PERSONAL_TICKETS=[];} + renderTradeTickets(); +} +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 d=await r.json(); + if(!r.ok)throw new Error(d.error||"Ticket update failed"); + await loadTradeTickets(); + toast("Ticket updated."); + }catch(e){toast(e.message||"Ticket update failed.");} +} +function renderTradeTickets(){ + const root=$("personalTicketQueue");if(!root)return; + if(!PERSONAL_MODE){root.innerHTML=`
    Personal trade tickets are hidden on the public site.
    `;return;} + if(!PERSONAL_TICKETS.length){root.innerHTML=`
    No staged tickets yet. Stage one from Suggestions or the manual console.
    `;return;} + root.innerHTML=PERSONAL_TICKETS.map(t=>{ + const instructions=t.instructions||{},steps=instructions.steps||[]; + const url=t.market_url||instructions.market_url||marketSearchUrl(t.question||t.market_id); + return `
    +
    ${esc(t.question||t.market_id)}
    ${esc(t.agent_id)}${esc(t.status)}${new Date(t.created_at).toLocaleString()}
    BUY ${esc(t.side)}
    +
    Max ${fmtUSD(Number(t.max_amount||0))}${t.limit_price?` · limit ${Math.round(Number(t.limit_price)*100)}¢`:""} · ticket #${t.id}
    + ${t.rationale?`
    ${esc(t.rationale)}
    `:""} +
      ${steps.map(step=>`
    1. ${esc(step)}
    2. `).join("")}
    +
    + Open Polymarket + + + + +
    +
    `; + }).join(""); + document.querySelectorAll("[data-ticket-status]").forEach(btn=>btn.onclick=()=>{const [id,status]=btn.dataset.ticketStatus.split(":");updateTicketStatus(id,status);}); +} async function saveRiskProfileToServer(s){ const permissions=JSON.parse(JSON.stringify(s.agents||{})); Object.values(permissions).forEach(p=>{p.autoTrade=false;p.manualApprove=true;});