feat: auto cash-out targets, 1% house edge, terminal aesthetic
Auto cash-out closes a position at exactly the chosen target rather than the next tick's multiplier, and fires whenever the target is at or below the crash point. This is the feature that makes the game playable over a network, where manual timing is at the mercy of latency. House edge drops from 2% to 1% across crash and scratch. Scratch prize tables retuned so the published 99% RTP is exact. Adds docs/API.md: the client uses no private endpoints, so anyone can write a bot against the same API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
287
docs/API.md
Normal file
287
docs/API.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user