casino-cli / src /index.ts
nico
Add MODEL_BASE_URL env for local-model agents (forwarded to /join)
a895a12
#!/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 <name> <model>
* 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<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();
// 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 <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;
}
// 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 <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); });