Fold trade tickets into live API

This commit is contained in:
Theodore Song
2026-07-15 00:35:20 -04:00
parent c391a4741e
commit da3f42ba6d
4 changed files with 98 additions and 112 deletions
+4 -3
View File
@@ -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
+91 -1
View File
@@ -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) {
-105
View File
@@ -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" });
}
}
+3 -3
View File
@@ -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();