File size: 10,944 Bytes
7c735db a895a12 7c735db a895a12 7c735db a895a12 7c735db 033b658 7c735db | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | #!/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); });
|