Files
agents-marketplace/gateway/src/account/routes.ts
david 4ebe7c9683 feat(gateway,supplier,shared): shared demo key with session-backed OpenAI endpoint + tool-calling
MVP demo: one operator-funded API key anyone can plug into an IDE/harness,
running real escrow-per-session marketplace conversations (llm.chat.v1)
behind the standard OpenAI /chat/completions wire shape.

- shared: ChatMessage gains tool role + tool_calls; normalizeChatMessage/
  chatMessagesEquivalent keep supplier transcript and gateway mirror
  hash-identical for receipts (field presence is part of the identity)
- supplier: /v1/chat/message accepts {messages,tools,tool_choice} alongside
  legacy {content}; callOpenAiStream forwards tools, accumulates fragmented
  delta.tool_calls, allows content-less tool turns; done frame carries the
  assistant message verbatim + finish_reason + usage
- gateway: demo flag on api_keys (additive migration), create-demo-key CLI,
  withdraw 403 + hidden deposit address for demo keys, per-IP demo rate
  limit (demo keys skip the shared per-key limit), models filtered to chat
  capability
- gateway executor (demoChat.ts): transcript-prefix affinity maps stateless
  calls onto open sessions, forwards only unseen deltas, transparently
  reopens on supplier idle-close; never falls back to one-shot (its settle
  path assumes <=1 in-flight escrow per wallet)
- mutex scope: key mutex held only for chain work (open/close); off-chain
  turns serialize on per-session locks so replicas serve users concurrently
- sweeper: chat-aware reclaim guard (35min vs 11min) and abandoned-session
  cleanup; terminal state confirmed via on-chain queryUtxo (indexer keeps
  lineage rows at their old state forever)
- deploy: kimi-chat replicas 2+3 compose files; chat idle timeout 2.5min

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:30:17 +02:00

164 lines
6.5 KiB
TypeScript

/**
* gateway/src/account/routes.ts — signup, account, withdraw.
*
* POST /signup → mint a custodial wallet + API key (key shown once)
* GET /account → balance, collateral readiness, spend, recent usage
* POST /account/withdraw → move unspent AP3X out to an external address (exit)
*/
import { randomBytes, randomUUID } from "crypto";
import type { Request, Response } from "express";
import { buildWithdrawTx } from "@marketplace/shared/tx/server";
import type { GatewayDeps } from "../deps.js";
import { genPrivKeyHex, deriveWalletKey } from "../wallet.js";
import { seal } from "../crypto/seal.js";
import { hashApiKey, requireKey } from "../middleware/apiKeyAuth.js";
import { asyncHandler } from "../middleware/http.js";
import { totalLovelace, hasCollateral } from "../onchain/preflight.js";
import { badRequest, forbidden } from "../openai/errors.js";
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
function ap3x(lovelace: bigint): string {
return (Number(lovelace) / 1e6).toFixed(2);
}
// ─── signup ──────────────────────────────────────────────────────────────────
export function makeSignupHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const label = typeof body.label === "string" ? body.label.slice(0, 120) : null;
const privHex = genPrivKeyHex();
const walletKey = deriveWalletKey(privHex, deps.config.networkId);
const sealed = seal(privHex, deps.config.masterKeyHex);
const rawKey = `vk_${deps.config.networkId === 1 ? "live" : "test"}_${randomBytes(24).toString("hex")}`;
const keyPrefix = rawKey.slice(0, 12);
deps.store.insertKey({
id: randomUUID(),
key_hash: hashApiKey(rawKey),
key_prefix: keyPrefix,
label,
wallet_pkh: walletKey.pubKeyHash,
deposit_address: walletKey.address,
enc_priv_nonce: sealed.nonce,
enc_priv_ct: sealed.ct,
enc_priv_tag: sealed.tag,
master_key_version: 1,
created_at: Date.now(),
});
res.status(201).json({
api_key: rawKey,
key_prefix: keyPrefix,
deposit_address: walletKey.address,
note: "Save api_key now — it is shown only once. Fund deposit_address with AP3X to use the gateway.",
});
});
}
// ─── account ─────────────────────────────────────────────────────────────────
export function makeAccountHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
const utxos = await deps.chain.queryUtxosByAddress(keyRow.deposit_address);
const available = totalLovelace(utxos);
let lockedInEscrow = 0n;
try {
const r = await deps.fetchFn(`${deps.config.indexerUrl}/escrows?buyer=${keyRow.wallet_pkh}`);
if (r.ok) {
const rows = (await r.json()) as Array<Record<string, unknown>>;
if (Array.isArray(rows)) {
for (const row of rows) {
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue;
lockedInEscrow +=
BigInt(String(row.payment_lovelace ?? "0")) +
BigInt(String(row.buyer_bond_lovelace ?? "0")) +
BigInt(String(row.supplier_bond_lovelace ?? "0"));
}
}
}
} catch {
/* best-effort; report 0 locked on indexer hiccup */
}
res.status(200).json({
key_prefix: keyRow.key_prefix,
// The shared demo key's wallet is operator-funded; hiding the deposit
// address keeps strangers from confusing it with their own wallet.
deposit_address: keyRow.demo ? null : keyRow.deposit_address,
...(keyRow.demo ? { demo: true } : {}),
balance: {
available_lovelace: available.toString(),
locked_in_escrow_lovelace: lockedInEscrow.toString(),
ap3x: ap3x(available),
},
collateral_ok: hasCollateral(utxos),
spend: {
total_cost_lovelace: deps.store.sumCostLovelace(keyRow.id).toString(),
request_count: deps.store.countUsage(keyRow.id),
},
recent_usage: deps.store.listUsage(keyRow.id, 20).map((u) => ({
created_at: u.created_at,
kind: u.kind,
model: u.model,
status: u.status,
cost_lovelace: u.cost_lovelace,
escrow_ref: u.escrow_ref,
failure_reason: u.failure_reason,
})),
});
});
}
// ─── withdraw ────────────────────────────────────────────────────────────────
export function makeWithdrawHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
if (keyRow.demo) {
// The demo key is shared publicly; without this, anyone holding it
// could drain the operator-funded wallet to their own address.
throw forbidden("demo_key_restricted", "withdrawals are disabled for the shared demo key");
}
const body = (req.body ?? {}) as Record<string, unknown>;
const toAddress = body.to_address;
if (typeof toAddress !== "string" || !/^addr(_test)?1[0-9a-z]+$/.test(toAddress)) {
throw badRequest("invalid_address", "`to_address` must be a bech32 Cardano address");
}
let amountLovelace: bigint | undefined;
if (body.amount_lovelace !== undefined && body.amount_lovelace !== null) {
try {
amountLovelace = BigInt(String(body.amount_lovelace));
} catch {
throw badRequest("invalid_amount", "`amount_lovelace` must be an integer (string or number)");
}
if (amountLovelace <= 0n) throw badRequest("invalid_amount", "`amount_lovelace` must be positive");
}
const ctx = deps.registry.getContext(keyRow);
await ctx.mutex.run(async () => {
const built = await buildWithdrawTx({
chain: deps.chain,
walletKey: ctx.walletKey,
toAddress,
amountLovelace,
});
await deps.chain.awaitTx(built.expectedTxHash, 120_000);
res.status(200).json({
status: "submitted",
tx_hash: built.expectedTxHash,
amount_lovelace: built.amountLovelace.toString(),
to_address: built.toAddress,
});
});
});
}