| #!/usr/bin/env bun |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 || ""; |
| 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 || ""; |
| |
| 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<T = any>(path: string, body?: any, method: "GET" | "POST" = body ? "POST" : "GET"): Promise<T> { |
| 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(); |
| |
| 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}`)); |
|
|
| |
| 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)], |
| }); |
| 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}`)); |
|
|
| |
| |
| 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; |
| 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) { |
| requireToken(); |
| if (!name) { console.error(chalk.red("usage: join <name> [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; |
| } |
| |
| 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) { |
| |
| 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); |
|
|
| |
| 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, ""); |
| 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 <name> [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); }); |
|
|