# Quantum Arcade API Everything the browser client does, it does over this API. There is no private back channel — you can write a bot that plays exactly as well as a human, and nothing in the protocol is reserved for the official client. Base URL is wherever the server is listening, e.g. `http://arcade.lan:8080`. All request and response bodies are JSON. Amounts are **millisatoshis** (`1 sat = 1000 msat`) and always integers. ## Authentication Identity is an ed25519 keypair. You prove ownership by signing a server-issued challenge; there is no password and nothing to register. ### 1. Request a challenge ``` POST /api/auth/challenge { "pubkey": "<64 hex chars>" } → { "challenge": "<64 hex chars>" } ``` The challenge is single-use and expires after two minutes. ### 2. Sign it and verify Sign the **raw 32 bytes** of the challenge (decode the hex first — do not sign the hex string). ``` POST /api/auth/verify { "pubkey": "", "signature": "<128 hex chars>", "nickname": "botto" } → { "token": "", "balance_msat": 0 } ``` Pass the token on every subsequent request: ``` Authorization: Bearer ``` Tokens live in memory, so a server restart signs everyone out. Just re-authenticate — it costs two requests and no human interaction. ## Balance and history ``` GET /api/balance → { "balance_msat": 4500000 } ``` ``` GET /api/history → { "entries": [ { "Kind": "payout", "AmountMsat": 2500000, "BalanceBefore": 2000000, "BalanceAfter": 4500000, "RoundID": 412, "CreatedAt": "..." } ] } ``` Every balance change has exactly one entry explaining it. Nothing moves without a record. ## Peer-to-peer transfers ``` POST /api/transfer { "to_pubkey": "", "amount_msat": 100000 } → { "balance_msat": 4400000 } ``` Instant and internal. Fails with 400 if you cannot cover it. ## Crash games Three rooms share one engine: `rocket`, `orbital`, `tower`. ### Watch the state ``` GET /api/games → { "rooms": [ { "round_id": 412, "game": "rocket", "state": "betting_open", // betting_open | locked | running | settled "tick": 0, "multiplier": "1.000000", "commitment": "", // published before betting opens "server_seed": "", // present only once settled "crash_point": "3.472190", // present only once settled "players": [ { "nickname": "botto", "pubkey": "", "stake_msat": 100000, "cashed_out": "2.500000", "auto": true, "payout_msat": 250000 } ], "next_phase_in_seconds": 12.4 } ] } ``` For a live feed instead of polling, open a WebSocket to `/ws/{game}` and you will receive the same object on every tick. ### Place a bet Only during `betting_open`, once per round. ``` POST /api/bet { "game": "rocket", "stake_msat": 100000, "nickname": "botto", "auto_cashout": 2.5 } // optional; omit or 0 for no target → { "balance_msat": 4300000 } ``` The stake leaves your balance immediately. `auto_cashout` must be above 1.00 and closes your position at **exactly** that multiplier — not at whatever the next tick shows — provided it is at or below the round's crash point. Setting a target is the reliable way to bot this game: network latency makes manual cash-out timing unreliable, and the target is evaluated server-side against the tick sequence. ### Cash out manually Only during `running`. ``` POST /api/cashout { "game": "rocket" } → { "cashed_out_at": "2.317445" } ``` Payment lands at settlement, a moment later. ## Scratch tickets ``` GET /api/scratch/catalog → { "tickets": [ { "id": "nebula-nine", "name": "Nebula Nine", "cells": 9, "rtp_bp": 9900, "odds": [ { "tier": "Double", "payout_bp": 20000, "weight": 155000, "denominator": 1000000, "one_in": 6 } ] } ] } ``` The odds table is generated from the same data that produces outcomes, so it cannot drift from reality. `rtp_bp` is in basis points: 9900 is 99%. ``` POST /api/scratch/play { "ticket_id": "nebula-nine", "stake_msat": 10000 } → { "outcome": { "tier_name": "Double", "payout_bp": 20000, "payout_msat": 20000, "roll": 481203, "cells": [2,5,2,0,2,4,1,3,5] }, "proof": { "commitment": "...", "server_seed": "...", "participants": ["..."], "nonce": 91, "round_seed": "..." }, "balance_msat": 4310000 } ``` Resolves immediately. The proof is returned with the result, so a bot can verify every single play as it goes. ## Verification ``` GET /api/verify/{roundID} → { "round_id": 412, "game": "rocket", "nonce": 412, "commitment": "", "server_seed": "", "client_seed": "", "crash_point": 14914127396, "participants": ["", ""] } ``` Returns 409 while a round is still open — the seed stays sealed until settlement, otherwise you could compute the outcome before betting closed. To check it yourself: 1. `SHA256(server_seed)` must equal `commitment`. 2. `client_seed` must equal `SHA256(` each participant pubkey, each prefixed by its 4-byte big-endian length, concatenated in join order `)`. 3. The round seed is `HMAC-SHA256(server_seed, client_seed || uint64be(nonce))`. 4. `crash_point` is derived from that seed. It is Q32.32 fixed-point: divide by 2³² to get the multiplier. ## Health ``` GET /api/health → { "status": "ok", "ledger_sum_msat": 0 } ``` `ledger_sum_msat` sums every account. Because each transaction balances to zero, it must always be zero. Anything else means the books are corrupt and `status` will say `ledger_imbalance`. ## Errors Failures return the appropriate status with `{ "error": "..." }`. Common cases: | Status | Meaning | |---|---| | 400 | Bad request, insufficient funds, betting closed, already in this round | | 401 | Missing or unknown token | | 404 | No such game or ticket | | 409 | Round has not settled; the seed is still sealed | ## A complete bot Plays every rocket round with a 2× target and verifies each result. ```python import time, requests from nacl.signing import SigningKey # pip install pynacl BASE = "http://arcade.lan:8080" key = SigningKey.generate() # persist this to keep your balance pub = key.verify_key.encode().hex() chal = requests.post(f"{BASE}/api/auth/challenge", json={"pubkey": pub}).json() sig = key.sign(bytes.fromhex(chal["challenge"])).signature.hex() tok = requests.post(f"{BASE}/api/auth/verify", json={"pubkey": pub, "signature": sig, "nickname": "botto"}).json()["token"] S = requests.Session() S.headers["Authorization"] = f"Bearer {tok}" seen = None while True: room = next(r for r in S.get(f"{BASE}/api/games").json()["rooms"] if r["game"] == "rocket") if room["state"] == "betting_open" and room["round_id"] != seen: r = S.post(f"{BASE}/api/bet", json={ "game": "rocket", "stake_msat": 10_000, "auto_cashout": 2.0, "nickname": "botto"}) if r.ok: seen = room["round_id"] print(f"round {seen}: in, balance {r.json()['balance_msat']}") if room["state"] == "settled" and room.get("server_seed"): print(f" crashed at {room['crash_point']}") time.sleep(1) ``` Verifying a settled round, using only published values: ```python import hashlib, hmac, struct def verify(round_id): r = requests.get(f"{BASE}/api/verify/{round_id}").json() seed = bytes.fromhex(r["server_seed"]) assert hashlib.sha256(seed).hexdigest() == r["commitment"], "bad commitment" h = hashlib.sha256() for p in r["participants"]: pk = bytes.fromhex(p) h.update(struct.pack(">I", len(pk)) + pk) assert h.hexdigest() == r["client_seed"], "bad client seed" round_seed = hmac.new(seed, bytes.fromhex(r["client_seed"]) + struct.pack(">Q", r["nonce"]), hashlib.sha256).digest() print("verified:", round_seed.hex(), "crash", r["crash_point"] / 2**32) ``` ## Rate and fairness notes There is no rate limiting, because this runs on a private network among people who know each other. If you point it at a hostile network, add some. A bot has no edge over a human here beyond reaction time, and the auto cash-out target removes even that: the outcome was fixed by the committed seed before either of you acted.