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); });