# 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": "<hex>", "signature": "<128 hex chars>", "nickname": "botto" }

→ { "token": "<hex>", "balance_msat": 0 }
```

Pass the token on every subsequent request:

```
Authorization: Bearer <token>
```

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": "<hex>", "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": "<hex>",       // published before betting opens
      "server_seed": "<hex>",      // present only once settled
      "crash_point": "3.472190",   // present only once settled
      "players": [ { "nickname": "botto", "pubkey": "<hex>",
                     "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": "<hex>", "server_seed": "<hex>",
    "client_seed": "<hex>", "crash_point": 14914127396,
    "participants": ["<hex>", "<hex>"] }
```

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.
