#!/usr/bin/env bun /** * casino-cli — agent client for the AI Poker/Blackjack Casino. * * Uses viem to send USDC on Base mainnet (no Coinbase, no facilitator), * then posts the tx_hash to the casino's /api/twitch/topup for credit. * * Commands: * bun start balance * bun start topup [usdc] (default 0.01) * bun start join * bun start state * bun start cashout [wallet] * bun start watch (poll state every 3s) */ import { config } from "dotenv"; import * as fs from "fs"; import * as path from "path"; import * as crypto from "crypto"; import chalk from "chalk"; import { createWalletClient, createPublicClient, http, erc20Abi, parseUnits, type Hex } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { base } from "viem/chains"; config(); const TOKEN = process.env.CASINO_TOKEN || ""; // required for server calls; validated lazily const CASINO = (process.env.CASINO_URL || "http://localhost:8000").replace(/\/$/, ""); const MODEL = process.env.MODEL || "openai/gpt-5-nano"; const APIKEY = process.env.OPENROUTER_API_KEY || ""; const CASHOUT_WALLET = process.env.CASHOUT_WALLET || ""; // Optional: public HTTPS URL of an OpenAI-compat endpoint (your local model exposed via cloudflared/ngrok). const BASE_URL = process.env.MODEL_BASE_URL || ""; function mustEnv(name: string): string { const v = process.env[name]; if (!v) { console.error(chalk.red(`Missing ${name} in .env`)); process.exit(1); } return v; } function requireToken() { if (!TOKEN) { console.error(chalk.red("Missing CASINO_TOKEN in .env")); process.exit(1); } } async function api(path: string, body?: any, method: "GET" | "POST" = body ? "POST" : "GET"): Promise { const url = CASINO + path + (method === "GET" && body ? "?" + new URLSearchParams(body).toString() : ""); const init: RequestInit = { method, headers: { "Content-Type": "application/json" } }; if (method !== "GET" && body) init.body = JSON.stringify(body); const r = await fetch(url, init); const text = await r.text(); try { return JSON.parse(text) as T; } catch { throw new Error(`Non-JSON ${r.status}: ${text.slice(0, 200)}`); } } async function cmdBalance() { requireToken(); const r: any = await api("/api/twitch/balance", { token: TOKEN }, "GET"); console.log(chalk.cyan(JSON.stringify(r, null, 2))); } async function cmdTopup(usdc = 0.01) { requireToken(); // Step 1 — get payment instructions const quote: any = await api("/api/twitch/topup", { token: TOKEN, usdc }); const inst = quote.instructions; if (!inst) { console.error(chalk.red("No instructions in 402:"), quote); return; } console.log(chalk.yellow(`→ Pay ${usdc} USDC to ${inst.pay_to} on ${inst.network}`)); // Step 2 — send USDC ERC20 transfer const pk = mustEnv("PRIVATE_KEY") as Hex; const account = privateKeyToAccount(pk); const walletClient = createWalletClient({ account, chain: base, transport: http() }); const publicClient = createPublicClient({ chain: base, transport: http() }); console.log(chalk.gray(` from ${account.address} → sending transfer...`)); const txHash = await walletClient.writeContract({ address: inst.usdc_contract as Hex, abi: erc20Abi, functionName: "transfer", args: [inst.pay_to as Hex, parseUnits(String(usdc), 6)], // USDC has 6 decimals }); console.log(chalk.green(` sent: ${txHash}`)); console.log(chalk.gray(" waiting for 1 confirmation...")); const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash, confirmations: 1 }); if (receipt.status !== "success") { console.error(chalk.red(`tx reverted: ${txHash}`)); return; } console.log(chalk.green(` confirmed in block ${receipt.blockNumber}`)); // Step 3 — post tx_hash to casino. RPC indexers lag a few seconds behind the block; // retry with linear backoff before giving up. let credit: any; for (const delay of [0, 3000, 5000, 8000, 12000]) { if (delay) await new Promise(r => setTimeout(r, delay)); credit = await api("/api/twitch/topup", { token: TOKEN, usdc, tx_hash: txHash }); if (credit.ok) break; const reason = String(credit.reason || ""); if (!reason.includes("no result")) break; // only retry RPC-propagation errors console.log(chalk.gray(` indexer lag, retrying in ${delay || 3000}ms...`)); } if (credit?.ok) { console.log(chalk.green.bold(`✓ credited ${credit.credited_mcents} mcents; balance ${credit.balance_mcents}`)); } else { console.error(chalk.red("credit failed:"), credit); console.error(chalk.gray(` retry manually: curl -X POST ${CASINO}/api/twitch/topup -H 'Content-Type: application/json' -d '{"token":"${TOKEN.slice(0,8)}...","usdc":${usdc},"tx_hash":"${txHash}"}'`)); } } async function cmdJoin(name: string, model = MODEL, buyIn = 10.0) { // $10 = 1000 chips, matches house-bot stacks requireToken(); if (!name) { console.error(chalk.red("usage: join [model] [buy_in_usdc]")); process.exit(1); } const body: any = { name, model, api_key: APIKEY, buy_in_usdc: buyIn, token: TOKEN }; if (BASE_URL) { body.base_url = BASE_URL; console.log(chalk.gray(` using local agent endpoint: ${BASE_URL}`)); } const r: any = await api("/api/twitch/join", body); console.log(chalk.cyan(JSON.stringify(r, null, 2))); } async function cmdState() { const s: any = await api("/api/twitch/state", undefined, "GET"); const me = (s.players || []).find((p: any) => p.name && p.name !== "—"); console.log(chalk.white(`hand #${s.hand_num} street=${s.street} pot=$${s.pot}`)); for (const p of (s.players || [])) { console.log(` seat ${p.seat}: ${p.name.padEnd(18)} stack=$${p.stack} ${p.last_action || ""}`); } if (s.queue_names?.length) console.log(chalk.gray(`queue: ${s.queue_names.join(", ")}`)); } async function cmdCashout(wallet = CASHOUT_WALLET) { requireToken(); const r: any = await api("/api/twitch/cashout", { token: TOKEN, wallet }); if (!r.queued_for_cashout) { console.log(chalk.green(`✓ withdrawn ${r.withdrawn_usdc} USDC${r.note ? ` (${r.note})` : ""}`)); return; } // Seated → poll status until settled or 30s timeout. console.log(chalk.yellow(`→ queued (seat ${r.seat}, chips ${r.current_chips}, max ~$${r.estimated_max_usdc})`)); console.log(chalk.gray(" waiting for current hand to end...")); for (let i = 0; i < 60; i++) { await new Promise(res => setTimeout(res, 500)); const s: any = await api(`/api/twitch/cashout/status?token=${encodeURIComponent(TOKEN)}`); if (!s.pending && s.last_settled) { console.log(chalk.green.bold(`✓ withdrawn ${s.last_settled.withdrawn_usdc} USDC → ${s.last_settled.wallet}`)); return; } } console.log(chalk.red("⚠ still pending after 30s — re-poll later with: cashout (no args, just to check)")); } async function cmdWalletNew(force = false) { // Offline — generates a fresh Base/EVM-compatible keypair + auto-saves to .env. const envPath = path.resolve(process.cwd(), ".env"); const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : ""; const hasPK = /^PRIVATE_KEY=0x[0-9a-fA-F]{64}/m.test(existing); if (hasPK && !force) { console.error(chalk.red("✗ .env already has PRIVATE_KEY. Rerun with --force to overwrite (previous funds will be unreachable).")); process.exit(1); } const pk = generatePrivateKey(); const acct = privateKeyToAccount(pk); // Also auto-generate CASINO_TOKEN if missing (random 24-hex bearer) const hasToken = /^CASINO_TOKEN=/m.test(existing); const token = hasToken ? null : "bearer-" + crypto.randomBytes(12).toString("hex"); let next = existing; next = next.replace(/^PRIVATE_KEY=.*$/m, ""); // strip old key if (!next.endsWith("\n") && next.length) next += "\n"; next += `PRIVATE_KEY=${pk}\n`; if (token) next += `CASINO_TOKEN=${token}\n`; if (!/^CASINO_URL=/m.test(next)) next += "CASINO_URL=http://127.0.0.1:8000\n"; fs.writeFileSync(envPath, next, { mode: 0o600 }); console.log(chalk.green.bold("✓ Fresh burner wallet saved to .env")); console.log(); console.log(chalk.white(" Address: ") + chalk.green(acct.address)); if (token) console.log(chalk.white(" Token: ") + chalk.cyan(token) + chalk.gray(" (keep secret — it owns your casino balance)")); console.log(); console.log(chalk.yellow("Next:")); console.log(chalk.gray(` 1. Send ≥0.05 USDC on Base to ${chalk.green(acct.address)} (from MetaMask/Coinbase/etc.)`)); console.log(chalk.gray(" Also send ~$0.001 worth of ETH for gas (or use a gas sponsor).")); console.log(chalk.gray(" 2. npx tsx src/index.ts topup 0.01")); } async function cmdWatch() { while (true) { try { await cmdState(); } catch (e) { console.error(chalk.red(String(e))); } console.log(chalk.gray("─".repeat(60))); await new Promise(r => setTimeout(r, 3000)); } } const [, , cmd, ...rest] = process.argv; (async () => { switch (cmd) { case "balance": return cmdBalance(); case "topup": return cmdTopup(rest[0] ? Number(rest[0]) : 0.01); case "join": return cmdJoin(rest[0], rest[1] || MODEL, rest[2] ? Number(rest[2]) : 10.0); case "state": return cmdState(); case "cashout": return cmdCashout(rest[0] || CASHOUT_WALLET); case "watch": return cmdWatch(); case "wallet": if (rest[0] === "new") return cmdWalletNew(rest.includes("--force")); console.error(chalk.red("usage: wallet new [--force]")); return; default: console.log(chalk.white.bold("casino-cli") + chalk.gray(" — agent client for the AI Poker/Blackjack Casino")); console.log("\nCommands:"); console.log(" wallet new generate a fresh Base wallet (offline)"); console.log(" balance check your USDC-denominated balance"); console.log(" topup [usdc] send USDC on Base + credit balance (default 0.01)"); console.log(" join [model] [usdc] queue to play (buys in for usdc chips)"); console.log(" state show the current hand + seats"); console.log(" cashout [wallet] withdraw balance (to wallet or mock)"); console.log(" watch poll state every 3s"); console.log(chalk.gray(`\n Set PRIVATE_KEY + CASINO_TOKEN + CASINO_URL in .env`)); } })().catch(e => { console.error(chalk.red("error:"), e); process.exit(1); });