Compare commits
7 Commits
1da3b6760e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7fa097eab | ||
|
|
2eafcbfd68 | ||
|
|
c250ed2f80 | ||
|
|
bf75302e07 | ||
|
|
b0f07f63ff | ||
|
|
8af6fd585e | ||
|
|
ca39e8bad9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
bin/
|
bin/
|
||||||
*.log
|
*.log
|
||||||
|
coverage.out
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -483,12 +484,36 @@ func TestFullPlayerJourney(t *testing.T) {
|
|||||||
crash := float64(*proof.CrashPoint) / 4294967296.0
|
crash := float64(*proof.CrashPoint) / 4294967296.0
|
||||||
|
|
||||||
// 7. Balance must reflect the outcome exactly: paid at 1.5x if the round
|
// 7. Balance must reflect the outcome exactly: paid at 1.5x if the round
|
||||||
// reached the target, nothing otherwise.
|
// reached the target, nothing otherwise — net of the disclosed fees.
|
||||||
var after struct {
|
var after struct {
|
||||||
BalanceMsat int64 `json:"balance_msat"`
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive the expected payout from the published fee schedule rather than
|
||||||
|
// hardcoding it. A test that hardcodes the net would silently stop
|
||||||
|
// checking anything the moment the operator changed the rake.
|
||||||
|
var sched struct {
|
||||||
|
Schedule struct {
|
||||||
|
RakePercent string `json:"rake_percent"`
|
||||||
|
RoundingUnit string `json:"rounding_unit"`
|
||||||
|
} `json:"schedule"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/fees", nil, &sched)
|
||||||
|
|
||||||
|
var rakePct float64
|
||||||
|
fmt.Sscanf(sched.Schedule.RakePercent, "%f%%", &rakePct)
|
||||||
|
var roundUnit int64
|
||||||
|
fmt.Sscanf(sched.Schedule.RoundingUnit, "%d msat", &roundUnit)
|
||||||
|
if roundUnit < 1 {
|
||||||
|
roundUnit = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
gross := int64(stake) * 3 / 2
|
||||||
|
rake := int64(float64(gross) * rakePct / 100)
|
||||||
|
net := (gross - rake) / roundUnit * roundUnit
|
||||||
|
|
||||||
// Settlement posts a moment after the reveal; poll briefly.
|
// Settlement posts a moment after the reveal; poll briefly.
|
||||||
wantWin := beforeRound - stake + stake*3/2
|
wantWin := beforeRound - stake + net
|
||||||
wantLose := beforeRound - stake
|
wantLose := beforeRound - stake
|
||||||
ok := false
|
ok := false
|
||||||
for i := 0; i < 20; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
@@ -504,8 +529,9 @@ func TestFullPlayerJourney(t *testing.T) {
|
|||||||
after.BalanceMsat, wantWin, wantLose)
|
after.BalanceMsat, wantWin, wantLose)
|
||||||
}
|
}
|
||||||
if crash >= 1.5 && after.BalanceMsat != wantWin {
|
if crash >= 1.5 && after.BalanceMsat != wantWin {
|
||||||
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d",
|
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d "+
|
||||||
crash, after.BalanceMsat, wantWin)
|
"(gross %d, rake %d, net %d)",
|
||||||
|
crash, after.BalanceMsat, wantWin, gross, rake, net)
|
||||||
}
|
}
|
||||||
if crash < 1.5 && after.BalanceMsat != wantLose {
|
if crash < 1.5 && after.BalanceMsat != wantLose {
|
||||||
t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
|
t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
|
||||||
|
|||||||
139
cmd/arcade/lnurl.go
Normal file
139
cmd/arcade/lnurl.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LNURL-withdraw endpoints.
|
||||||
|
//
|
||||||
|
// Two of these are called by the player's wallet, not by the arcade's own
|
||||||
|
// client, so they follow the LNURL specification's shapes rather than this
|
||||||
|
// project's conventions: a bare JSON object with a status field, and no
|
||||||
|
// bearer token, because a wallet has none. The single-use k1 is the
|
||||||
|
// authorisation.
|
||||||
|
|
||||||
|
func (s *server) routesLNURL(mux *http.ServeMux) {
|
||||||
|
// Called by the arcade's own client, authenticated as normal.
|
||||||
|
mux.HandleFunc("POST /api/withdraw/code", s.handleWithdrawCode)
|
||||||
|
|
||||||
|
// Called by the player's wallet after scanning. No session exists here.
|
||||||
|
mux.HandleFunc("GET /lnurl/withdraw", s.handleLNURLTerms)
|
||||||
|
mux.HandleFunc("GET /lnurl/withdraw/callback", s.handleLNURLCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWithdrawCode issues a scannable cash-out code.
|
||||||
|
//
|
||||||
|
// The funds are debited when the code is issued, not when it is redeemed. A
|
||||||
|
// code is an authorisation to pull an exact amount, and leaving the balance
|
||||||
|
// spendable while a code is outstanding would let a player cash out and then
|
||||||
|
// bet the same sats before the wallet claims them.
|
||||||
|
func (s *server) handleWithdrawCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ln == nil || s.lnurl == nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "sign in first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AmountSat int64 `json:"amount_sats"`
|
||||||
|
}
|
||||||
|
body, err := readBody(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "could not read request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil || req.AmountSat < 1 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
amountMsat := req.AmountSat * 1000
|
||||||
|
|
||||||
|
// Take the money now. If the code is never scanned, the sweep below
|
||||||
|
// returns it.
|
||||||
|
if _, err := s.ledger.Withdraw(r.Context(), accountID, amountMsat); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code, k1, err := s.lnurl.Issue(accountID, amountMsat)
|
||||||
|
if err != nil {
|
||||||
|
// Could not issue: give the money straight back rather than holding it
|
||||||
|
// against a code that does not exist.
|
||||||
|
if _, rerr := s.ledger.Deposit(r.Context(), accountID, amountMsat); rerr != nil {
|
||||||
|
log.Printf("lnurl: CRITICAL: debited %d msat from %d but could not "+
|
||||||
|
"issue a code or refund: %v / %v", amountMsat, accountID, err, rerr)
|
||||||
|
}
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bal, _ := s.ledger.Balance(r.Context(), accountID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"lnurl": code,
|
||||||
|
"k1": k1,
|
||||||
|
"amount_sats": req.AmountSat,
|
||||||
|
"balance_msat": bal,
|
||||||
|
"expires_in": int(lnurl.TokenTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLNURLTerms is what the wallet fetches after scanning.
|
||||||
|
func (s *server) handleLNURLTerms(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.lnurl == nil {
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
terms, err := s.lnurl.Describe(r.URL.Query().Get("k1"))
|
||||||
|
if err != nil {
|
||||||
|
// LNURL errors are 200s with a status field; a wallet shows the reason
|
||||||
|
// to the player, whereas an HTTP error shows nothing useful.
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, terms)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLNURLCallback is where the wallet delivers its invoice.
|
||||||
|
func (s *server) handleLNURLCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ln == nil || s.lnurl == nil {
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
k1 := r.URL.Query().Get("k1")
|
||||||
|
invoice := r.URL.Query().Get("pr")
|
||||||
|
if k1 == "" || invoice == "" {
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail("k1 and pr are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the token before paying. A token left valid after a successful
|
||||||
|
// payment could be replayed for the same amount again.
|
||||||
|
token, err := s.lnurl.Redeem(r.Context(), k1)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The balance was already debited when the code was issued, so this queues
|
||||||
|
// the payment against funds the arcade is holding rather than the player's
|
||||||
|
// balance. Crediting first and withdrawing again would double-charge.
|
||||||
|
if _, err := s.ln.PayHeld(r.Context(), token.AccountID, invoice, token.AmountMsat); err != nil {
|
||||||
|
// Payment failed: hand the authorisation back so the player can retry
|
||||||
|
// rather than losing the cash-out silently.
|
||||||
|
s.lnurl.Restore(token)
|
||||||
|
log.Printf("lnurl: payment for account %d failed: %v", token.AccountID, err)
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.Fail("payment failed; try scanning again"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, lnurl.OK())
|
||||||
|
}
|
||||||
@@ -26,9 +26,12 @@ import (
|
|||||||
"github.com/drjones/quantum-arcade/pkg/fixed"
|
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||||||
"github.com/drjones/quantum-arcade/pkg/identity"
|
"github.com/drjones/quantum-arcade/pkg/identity"
|
||||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lightning"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||||
"github.com/drjones/quantum-arcade/pkg/room"
|
"github.com/drjones/quantum-arcade/pkg/room"
|
||||||
"github.com/drjones/quantum-arcade/pkg/scratch"
|
"github.com/drjones/quantum-arcade/pkg/scratch"
|
||||||
"github.com/drjones/quantum-arcade/pkg/sim"
|
"github.com/drjones/quantum-arcade/pkg/sim"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/tournament"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
@@ -49,7 +52,10 @@ type server struct {
|
|||||||
ledger *ledger.Ledger
|
ledger *ledger.Ledger
|
||||||
auth *identity.Authenticator
|
auth *identity.Authenticator
|
||||||
rooms map[string]*room.Room
|
rooms map[string]*room.Room
|
||||||
|
tournaments *tournament.Service
|
||||||
hubs map[string]*gameHub
|
hubs map[string]*gameHub
|
||||||
|
ln *lightning.Service // Lightning deposit/withdrawal
|
||||||
|
lnurl *lnurl.Service // scannable cash-out codes
|
||||||
|
|
||||||
// Sessions live in Redis rather than instance memory. With several cloned
|
// Sessions live in Redis rather than instance memory. With several cloned
|
||||||
// instances behind one endpoint, a token issued by one must be accepted by
|
// instances behind one endpoint, a token issued by one must be accepted by
|
||||||
@@ -108,9 +114,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l := ledger.New(pool)
|
||||||
s := &server{
|
s := &server{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
ledger: ledger.New(pool),
|
ledger: l,
|
||||||
|
tournaments: tournament.New(pool, l),
|
||||||
auth: identity.NewAuthenticator(),
|
auth: identity.NewAuthenticator(),
|
||||||
rooms: make(map[string]*room.Room),
|
rooms: make(map[string]*room.Room),
|
||||||
hubs: make(map[string]*gameHub),
|
hubs: make(map[string]*gameHub),
|
||||||
@@ -126,6 +134,74 @@ func main() {
|
|||||||
defer s.node.Stop(context.Background())
|
defer s.node.Stop(context.Background())
|
||||||
log.Printf("instance %s (%s) advertising %s", s.node.ID, s.node.Hostname, s.node.Address)
|
log.Printf("instance %s (%s) advertising %s", s.node.ID, s.node.Hostname, s.node.Address)
|
||||||
|
|
||||||
|
// ── Lightning (optional: dev faucet works without it) ──
|
||||||
|
//
|
||||||
|
// The faucet and a real node must never both be enabled. The faucet mints
|
||||||
|
// balance backed by nothing; with a real node attached, a player can
|
||||||
|
// withdraw that balance as actual satoshis and drain the node. Refusing to
|
||||||
|
// start is the only safe response — a warning would be read once and
|
||||||
|
// forgotten, and the failure is silent until the money is gone.
|
||||||
|
if os.Getenv("ALBY_URL") != "" && os.Getenv("ARCADE_DEV_FAUCET") == "1" {
|
||||||
|
log.Fatal("REFUSING TO START: ARCADE_DEV_FAUCET=1 with a real Lightning node " +
|
||||||
|
"configured. The faucet mints unbacked balance, which could then be " +
|
||||||
|
"withdrawn as real satoshis. Unset one of them.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if url := os.Getenv("ALBY_URL"); url != "" {
|
||||||
|
token := os.Getenv("ALBY_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
log.Printf("ALBY_URL set but ALBY_TOKEN empty — Lightning disabled")
|
||||||
|
} else {
|
||||||
|
albyNode := lightning.NewAlbyNode(url, token)
|
||||||
|
limits := lightning.DefaultLimits()
|
||||||
|
s.ln = lightning.New(albyNode, s.ledger, s.pool, limits)
|
||||||
|
|
||||||
|
// Wallets reach this instance directly, so the code must carry an
|
||||||
|
// address they can actually resolve — not localhost.
|
||||||
|
base := os.Getenv("ARCADE_PUBLIC_URL")
|
||||||
|
if base == "" {
|
||||||
|
base = "http://" + advertiseAddr()
|
||||||
|
}
|
||||||
|
s.lnurl = lnurl.NewService(base)
|
||||||
|
log.Printf("Lightning node connected: %s (cash-out codes point at %s)", url, base)
|
||||||
|
// Process queued withdrawals every 15 seconds.
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Check solvency before paying anything out. If the
|
||||||
|
// node holds less than players are owed, paying the
|
||||||
|
// front of the queue drains what is left and the
|
||||||
|
// players behind them get nothing — the worst possible
|
||||||
|
// order to discover a shortfall in.
|
||||||
|
sol, err := s.ln.CheckSolvency(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("lightning: cannot verify solvency, holding withdrawals: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !sol.Solvent {
|
||||||
|
log.Printf("lightning: HOLDING WITHDRAWALS — node holds %d msat "+
|
||||||
|
"but players are owed %d msat (short by %d)",
|
||||||
|
sol.NodeBalanceMsat, sol.OwedToPlayers, -sol.SurplusMsat)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := s.ln.ProcessWithdrawals(ctx, 10); err != nil {
|
||||||
|
log.Printf("lightning: withdrawal processor: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("lightning: paid %d withdrawals", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("ALBY_URL not set — Lightning disabled (dev faucet only)")
|
||||||
|
}
|
||||||
|
|
||||||
// One hub per game. Each hub campaigns for leadership: the winner drives
|
// One hub per game. Each hub campaigns for leadership: the winner drives
|
||||||
// the rounds and publishes frames, the rest relay those frames to their
|
// the rounds and publishes frames, the rest relay those frames to their
|
||||||
// own clients. Roles are renegotiated continuously, so losing an instance
|
// own clients. Roles are renegotiated continuously, so losing an instance
|
||||||
@@ -137,11 +213,54 @@ func main() {
|
|||||||
go h.supervise(ctx)
|
go h.supervise(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refund cash-out codes that were issued but never scanned. The balance is
|
||||||
|
// debited when a code is minted, so an abandoned code leaves the player
|
||||||
|
// short until this returns it.
|
||||||
|
if s.lnurl != nil {
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(30 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
for _, tok := range s.lnurl.Expired() {
|
||||||
|
if _, err := s.ledger.Deposit(ctx, tok.AccountID, tok.AmountMsat); err != nil {
|
||||||
|
log.Printf("lnurl: could not refund unscanned code for "+
|
||||||
|
"account %d (%d msat): %v", tok.AccountID, tok.AmountMsat, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("lnurl: refunded %d msat to account %d "+
|
||||||
|
"(cash-out code was never scanned)", tok.AmountMsat, tok.AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// Sweep for rounds abandoned by an instance that died mid-flight and
|
// Sweep for rounds abandoned by an instance that died mid-flight and
|
||||||
// refund their stakes. Every instance runs this; the claim is atomic, so
|
// refund their stakes. Every instance runs this; the claim is atomic, so
|
||||||
// concurrent sweeps refund exactly once.
|
// concurrent sweeps refund exactly once.
|
||||||
go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second)
|
go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second)
|
||||||
|
|
||||||
|
// Move tournaments through their lifecycle by wall clock. Every instance
|
||||||
|
// runs this; the updates are idempotent, so it needs no leader.
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(15 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := s.tournaments.AdvanceSchedules(ctx); err != nil {
|
||||||
|
log.Printf("tournaments: advancing schedules: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: s.routes(),
|
Handler: s.routes(),
|
||||||
@@ -171,6 +290,10 @@ func (s *server) routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/balance", s.handleBalance)
|
mux.HandleFunc("GET /api/balance", s.handleBalance)
|
||||||
mux.HandleFunc("GET /api/history", s.handleHistory)
|
mux.HandleFunc("GET /api/history", s.handleHistory)
|
||||||
mux.HandleFunc("POST /api/transfer", s.handleTransfer)
|
mux.HandleFunc("POST /api/transfer", s.handleTransfer)
|
||||||
|
mux.HandleFunc("POST /api/deposit", s.handleDeposit)
|
||||||
|
mux.HandleFunc("POST /api/deposit/check", s.handleDepositCheck)
|
||||||
|
mux.HandleFunc("POST /api/withdraw", s.handleWithdraw)
|
||||||
|
s.routesLNURL(mux)
|
||||||
mux.HandleFunc("GET /api/games", s.handleGames)
|
mux.HandleFunc("GET /api/games", s.handleGames)
|
||||||
mux.HandleFunc("POST /api/bet", s.handleBet)
|
mux.HandleFunc("POST /api/bet", s.handleBet)
|
||||||
mux.HandleFunc("POST /api/cashout", s.handleCashout)
|
mux.HandleFunc("POST /api/cashout", s.handleCashout)
|
||||||
@@ -188,6 +311,9 @@ func (s *server) routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /ws/{game}", s.handleWS)
|
mux.HandleFunc("GET /ws/{game}", s.handleWS)
|
||||||
mux.HandleFunc("GET /api/cluster", s.handleCluster)
|
mux.HandleFunc("GET /api/cluster", s.handleCluster)
|
||||||
mux.HandleFunc("GET /api/fees", s.handleFees)
|
mux.HandleFunc("GET /api/fees", s.handleFees)
|
||||||
|
mux.HandleFunc("GET /api/tournaments", s.handleTournaments)
|
||||||
|
mux.HandleFunc("GET /api/tournaments/{id}/leaderboard", s.handleLeaderboard)
|
||||||
|
mux.HandleFunc("POST /api/tournaments/{id}/enter", s.handleEnterTournament)
|
||||||
|
|
||||||
// Mounted only when ARCADE_ADMIN_TOKEN is set, so a default deployment
|
// Mounted only when ARCADE_ADMIN_TOKEN is set, so a default deployment
|
||||||
// has no admin surface at all.
|
// has no admin surface at all.
|
||||||
@@ -405,6 +531,96 @@ func (s *server) handleTransfer(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────── Lightning deposit/withdrawal ─────────
|
||||||
|
|
||||||
|
type depositRequest struct {
|
||||||
|
AmountSat int64 `json:"amount_sats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleDeposit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ln == nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acctID, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "sign in first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req depositRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AmountSat < 1 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inv, err := s.ln.RequestDeposit(r.Context(), acctID, req.AmountSat*1000)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"payment_hash": inv.PaymentHash,
|
||||||
|
"invoice": inv.Bolt11,
|
||||||
|
"amount_sats": inv.AmountMsat / 1000,
|
||||||
|
"expires_at": inv.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type depositCheckRequest struct {
|
||||||
|
PaymentHash string `json:"payment_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleDepositCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ln == nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req depositCheckRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.PaymentHash == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "payment_hash required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
credited, err := s.ln.SettleDeposit(r.Context(), req.PaymentHash)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"settled": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"settled": true,
|
||||||
|
"credited_msat": credited,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type withdrawRequest struct {
|
||||||
|
Bolt11 string `json:"bolt11"`
|
||||||
|
AmountSat int64 `json:"amount_sats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleWithdraw(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ln == nil {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acctID, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "sign in first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req withdrawRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Bolt11 == "" || req.AmountSat < 1 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bolt11 and amount_sats required (>=1)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := s.ln.RequestWithdrawal(r.Context(), acctID, req.Bolt11, req.AmountSat*1000)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"withdrawal_id": id,
|
||||||
|
"status": "queued",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleGames(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleGames(w http.ResponseWriter, r *http.Request) {
|
||||||
// Serve the last frame each hub saw rather than the local room object:
|
// Serve the last frame each hub saw rather than the local room object:
|
||||||
// on an instance that does not lead a game, the local room is idle and
|
// on an instance that does not lead a game, the local room is idle and
|
||||||
@@ -616,6 +832,49 @@ func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleTournaments lists what a player can currently join or watch.
|
||||||
|
func (s *server) handleTournaments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
active, err := s.tournaments.Active(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"tournaments": active})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad tournament id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
board, err := s.tournaments.Leaderboard(r.Context(), id, 100)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"standings": board})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleEnterTournament(w http.ResponseWriter, r *http.Request) {
|
||||||
|
account, _, ok := s.account(r)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad tournament id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.tournaments.Enter(r.Context(), id, account); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bal, _ := s.ledger.Balance(r.Context(), account)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
|
||||||
|
}
|
||||||
|
|
||||||
// handleFees publishes exactly what the operator takes. It is generated from
|
// handleFees publishes exactly what the operator takes. It is generated from
|
||||||
// the same schedule the code charges, so the published terms cannot drift from
|
// the same schedule the code charges, so the published terms cannot drift from
|
||||||
// the behaviour.
|
// the behaviour.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { Arcade3D } from '/scene3d.js';
|
import { Arcade3D } from '/scene3d.js';
|
||||||
import * as charts from '/charts.js';
|
import * as charts from '/charts.js';
|
||||||
|
import * as qr from '/qr.js';
|
||||||
|
|
||||||
const KEY_STORAGE = 'quantum-arcade-key';
|
const KEY_STORAGE = 'quantum-arcade-key';
|
||||||
const NAME_STORAGE = 'quantum-arcade-name';
|
const NAME_STORAGE = 'quantum-arcade-name';
|
||||||
@@ -141,6 +142,8 @@ async function signIn() {
|
|||||||
|
|
||||||
connect(currentGame);
|
connect(currentGame);
|
||||||
loadScratch();
|
loadScratch();
|
||||||
|
detectLightning();
|
||||||
|
startTour();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- api ---------------- */
|
/* ---------------- api ---------------- */
|
||||||
@@ -297,26 +300,54 @@ function onSnapshot(s) {
|
|||||||
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
||||||
$('state').textContent =
|
$('state').textContent =
|
||||||
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||||
action.textContent = 'Next round';
|
action.textContent = 'Bet again';
|
||||||
action.className = 'primary big';
|
action.className = 'primary big';
|
||||||
action.disabled = true;
|
action.disabled = false;
|
||||||
|
|
||||||
// Record the round once, on the transition into settled.
|
// Record the round once, on the transition into settled.
|
||||||
if (!wasSettled && s.crash_point) {
|
if (!wasSettled && s.crash_point) {
|
||||||
stats.crashes.push(crash);
|
stats.crashes.push(crash);
|
||||||
if (myBet === 'in') {
|
if (myBet === 'in') {
|
||||||
stats.losses++;
|
stats.losses++;
|
||||||
hint.textContent = 'Rode it too far.';
|
// Near-miss psychology: show how close they were
|
||||||
|
const autoTarget = parseFloat($('auto-target').value || '0');
|
||||||
|
if (autoTarget > 1 && crash > 1.0 && crash < autoTarget) {
|
||||||
|
hint.innerHTML = `Almost! Crashed at ${crash.toFixed(2)}× — you were ${((autoTarget - crash) * 100).toFixed(0)}% away from ${autoTarget.toFixed(2)}×.`;
|
||||||
|
hint.className = 'hint near-miss';
|
||||||
|
} else {
|
||||||
|
hint.textContent = crash < 1.5 ? 'Brutal — early crash.' : crash < 3 ? 'Rode it too far.' : 'So close to a monster.';
|
||||||
hint.className = 'hint bad';
|
hint.className = 'hint bad';
|
||||||
|
}
|
||||||
|
stats.streak = 0;
|
||||||
buzz(120);
|
buzz(120);
|
||||||
} else if (myBet === 'out') {
|
} else if (myBet === 'out') {
|
||||||
stats.wins++;
|
stats.wins++;
|
||||||
buzz([30, 40, 30]);
|
stats.streak = (stats.streak || 0) + 1;
|
||||||
|
const payout = Math.round(stake * crash / 1000);
|
||||||
|
hint.textContent = stats.streak >= 5
|
||||||
|
? `🔥 ${stats.streak} IN A ROW! +${sats(payout * 1000)} sats`
|
||||||
|
: stats.streak >= 3
|
||||||
|
? `On fire! ${stats.streak} wins straight. +${sats(payout * 1000)} sats`
|
||||||
|
: `Won +${sats(payout * 1000)} sats at ${crash.toFixed(2)}×`;
|
||||||
|
hint.className = 'hint good';
|
||||||
|
if (stats.streak >= 3) buzz([20, 30, 20, 30, 40]);
|
||||||
|
else buzz([30, 40, 30]);
|
||||||
|
// Auto-increment stake on hot streak
|
||||||
|
if (stats.streak >= 3 && stake < 25000) {
|
||||||
|
const newStake = stake * 2;
|
||||||
|
setStake(newStake);
|
||||||
|
hint.textContent += ' • Stake doubled!';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!stats.best || crash > stats.best) stats.best = crash;
|
||||||
saveStats();
|
saveStats();
|
||||||
renderStrip();
|
renderStrip();
|
||||||
refreshBalance();
|
refreshBalance();
|
||||||
}
|
}
|
||||||
|
// Pre-fill for instant re-bet: auto-bet on next round
|
||||||
|
if (!wasSettled && myBet === 'out') {
|
||||||
|
action.classList.add('pulse');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,6 +391,11 @@ async function onAction() {
|
|||||||
hint.className = 'hint';
|
hint.className = 'hint';
|
||||||
try {
|
try {
|
||||||
if (snapshot.state === 'betting_open' && !myBet) {
|
if (snapshot.state === 'betting_open' && !myBet) {
|
||||||
|
if (!(await confirmRealMoney(`Bet ${sats(stake)} sats?`,
|
||||||
|
'Rounds are played with real satoshis. If the game crashes before ' +
|
||||||
|
'you cash out, the stake is lost.'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const r = await api('POST', '/api/bet', {
|
const r = await api('POST', '/api/bet', {
|
||||||
game: currentGame, stake_msat: stake, nickname,
|
game: currentGame, stake_msat: stake, nickname,
|
||||||
auto_cashout: autoTarget(),
|
auto_cashout: autoTarget(),
|
||||||
@@ -537,6 +573,282 @@ function renderPortfolio() {
|
|||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- lightning ----------------
|
||||||
|
*
|
||||||
|
* The deposit and withdraw cards stay hidden unless the server reports a node,
|
||||||
|
* so a play-money deployment does not advertise a deposit it cannot honour. */
|
||||||
|
|
||||||
|
let depositHash = null;
|
||||||
|
let depositPoll = null;
|
||||||
|
|
||||||
|
async function detectLightning() {
|
||||||
|
try {
|
||||||
|
// A zero-amount request is rejected either way; what distinguishes the two
|
||||||
|
// cases is whether the server says "not configured" or "bad amount".
|
||||||
|
const res = await fetch('/api/deposit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer ' + token },
|
||||||
|
body: JSON.stringify({ amount_sats: 0 }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const configured = !(data.error || '').includes('lightning not configured');
|
||||||
|
$('card-deposit').hidden = !configured;
|
||||||
|
$('card-withdraw').hidden = !configured;
|
||||||
|
} catch {
|
||||||
|
$('card-deposit').hidden = true;
|
||||||
|
$('card-withdraw').hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDeposit() {
|
||||||
|
const hint = $('dep-hint');
|
||||||
|
hint.textContent = '';
|
||||||
|
hint.className = 'hint';
|
||||||
|
|
||||||
|
const amount = Math.floor(Number($('dep-amt').value));
|
||||||
|
if (!Number.isFinite(amount) || amount < 1) {
|
||||||
|
hint.textContent = 'Enter an amount in sats.';
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await api('POST', '/api/deposit', { amount_sats: amount });
|
||||||
|
} catch (e) {
|
||||||
|
hint.textContent = e.message;
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
depositHash = res.payment_hash;
|
||||||
|
$('dep-bolt11').textContent = res.invoice;
|
||||||
|
$('dep-invoice').hidden = false;
|
||||||
|
|
||||||
|
const wrap = $('dep-qr');
|
||||||
|
clear(wrap);
|
||||||
|
// Uppercase for the QR: bech32 is case-insensitive and uppercase encodes far
|
||||||
|
// more densely, which keeps the symbol scannable on a phone screen.
|
||||||
|
const matrix = qr.encode(res.invoice.toUpperCase());
|
||||||
|
if (matrix) {
|
||||||
|
wrap.appendChild(qr.render(matrix));
|
||||||
|
} else {
|
||||||
|
wrap.textContent = 'Invoice too long to show as a code — copy it instead.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('dep-status').textContent = 'Waiting for payment…';
|
||||||
|
startDepositPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Poll for settlement. The server confirms with the node before crediting, so
|
||||||
|
* this cannot be used to claim a payment that never arrived. */
|
||||||
|
function startDepositPolling() {
|
||||||
|
if (depositPoll) clearInterval(depositPoll);
|
||||||
|
let attempts = 0;
|
||||||
|
depositPoll = setInterval(async () => {
|
||||||
|
if (!depositHash || ++attempts > 120) { // give up after ~10 minutes
|
||||||
|
clearInterval(depositPoll);
|
||||||
|
depositPoll = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api('POST', '/api/deposit/check',
|
||||||
|
{ payment_hash: depositHash });
|
||||||
|
if (res.credited_msat > 0 || res.settled) {
|
||||||
|
clearInterval(depositPoll);
|
||||||
|
depositPoll = null;
|
||||||
|
depositHash = null;
|
||||||
|
$('dep-status').textContent = 'Paid. Balance updated.';
|
||||||
|
$('dep-invoice').hidden = true;
|
||||||
|
buzz([40, 50, 40]);
|
||||||
|
await refreshBalance();
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not settled yet is the normal case and arrives as an error; keep
|
||||||
|
// waiting rather than treating it as a failure.
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cash out by showing a code the player's wallet pulls from. The invoice-paste
|
||||||
|
* path stays available for wallets that cannot scan LNURL, but it is folded
|
||||||
|
* away because it is the step most people give up on. */
|
||||||
|
async function withdrawByCode() {
|
||||||
|
const hint = $('wd-hint');
|
||||||
|
hint.textContent = '';
|
||||||
|
hint.className = 'hint';
|
||||||
|
|
||||||
|
let amount = Math.floor(Number($('wd-amt').value));
|
||||||
|
if (!Number.isFinite(amount) || amount < 1) {
|
||||||
|
hint.textContent = 'Pick an amount first.';
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await confirmRealMoney(`Cash out ${amount.toLocaleString()} sats?`,
|
||||||
|
'This sends real satoshis to your wallet and removes them from your ' +
|
||||||
|
'arcade balance.'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await api('POST', '/api/withdraw/code', { amount_sats: amount });
|
||||||
|
} catch (e) {
|
||||||
|
hint.textContent = e.message;
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBalance(res.balance_msat);
|
||||||
|
|
||||||
|
const wrap = $('wd-qr');
|
||||||
|
clear(wrap);
|
||||||
|
const matrix = qr.encode(res.lnurl);
|
||||||
|
if (matrix) {
|
||||||
|
wrap.appendChild(qr.render(matrix, { foreground: '#ffb000' }));
|
||||||
|
} else {
|
||||||
|
wrap.textContent = 'Could not render the code — copy it instead.';
|
||||||
|
}
|
||||||
|
$('wd-code').hidden = false;
|
||||||
|
$('wd-copy').dataset.code = res.lnurl;
|
||||||
|
$('wd-expiry').textContent =
|
||||||
|
`Scan within ${Math.round(res.expires_in / 60)} minutes. ` +
|
||||||
|
'If you do not, the sats come back to your balance.';
|
||||||
|
|
||||||
|
hint.className = 'hint good';
|
||||||
|
hint.textContent = 'Scan with your Lightning wallet.';
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The manual path, for wallets without LNURL support. */
|
||||||
|
async function withdrawToInvoice() {
|
||||||
|
const hint = $('wd-hint');
|
||||||
|
hint.textContent = '';
|
||||||
|
hint.className = 'hint';
|
||||||
|
|
||||||
|
const bolt11 = $('wd-bolt11').value.trim();
|
||||||
|
const amount = Math.floor(Number($('wd-amt').value));
|
||||||
|
if (!bolt11.toLowerCase().startsWith('ln')) {
|
||||||
|
hint.textContent = 'That does not look like a Lightning invoice.';
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(amount) || amount < 1) {
|
||||||
|
hint.textContent = 'Enter the amount the invoice is for.';
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await confirmRealMoney(`Send ${amount.toLocaleString()} sats?`,
|
||||||
|
'This pays the invoice you pasted with real satoshis.'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('POST', '/api/withdraw', { bolt11, amount_sats: amount });
|
||||||
|
hint.className = 'hint good';
|
||||||
|
hint.textContent = res.status === 'needs_approval'
|
||||||
|
? 'Queued for approval — large cash-outs are reviewed.'
|
||||||
|
: 'Queued. It should arrive within about fifteen seconds.';
|
||||||
|
$('wd-bolt11').value = '';
|
||||||
|
await refreshBalance();
|
||||||
|
loadHistory();
|
||||||
|
} catch (e) {
|
||||||
|
hint.textContent = e.message;
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- real-money confirmation ----------------
|
||||||
|
*
|
||||||
|
* Shown once, the first time a player moves real value. The interface is
|
||||||
|
* deliberately frictionless, and the one thing worth a moment of friction is
|
||||||
|
* someone not registering that the sats are real. */
|
||||||
|
|
||||||
|
const CONFIRMED_KEY = 'quantum-arcade-understands-real-money';
|
||||||
|
|
||||||
|
function confirmRealMoney(title, body) {
|
||||||
|
if (localStorage.getItem(CONFIRMED_KEY) === '1') return Promise.resolve(true);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = $('tour');
|
||||||
|
overlay.classList.add('confirm');
|
||||||
|
overlay.hidden = false;
|
||||||
|
$('tour-step').textContent = 'Real satoshis';
|
||||||
|
$('tour-title').textContent = title;
|
||||||
|
$('tour-body').textContent = body;
|
||||||
|
clear($('tour-dots'));
|
||||||
|
$('tour-next').textContent = 'I understand';
|
||||||
|
$('tour-skip').textContent = 'Cancel';
|
||||||
|
|
||||||
|
const finish = (ok) => {
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.classList.remove('confirm');
|
||||||
|
$('tour-skip').textContent = 'Skip';
|
||||||
|
if (ok) localStorage.setItem(CONFIRMED_KEY, '1');
|
||||||
|
resolve(ok);
|
||||||
|
};
|
||||||
|
$('tour-next').onclick = () => finish(true);
|
||||||
|
$('tour-skip').onclick = () => finish(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- first-run walkthrough ---------------- */
|
||||||
|
|
||||||
|
const TOUR_KEY = 'quantum-arcade-tour-done';
|
||||||
|
|
||||||
|
const TOUR = [
|
||||||
|
{
|
||||||
|
title: 'You are already signed in',
|
||||||
|
body: 'No account, no password. This device made a key that is your ' +
|
||||||
|
'identity. Keep the device, keep the balance.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Bet, then get out',
|
||||||
|
body: 'The number climbs. Tap Cash out before it crashes and you keep ' +
|
||||||
|
'the multiple. Wait too long and the stake is gone.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Nobody can rig it',
|
||||||
|
body: 'The result is sealed before betting opens and mixed with every ' +
|
||||||
|
'player\'s key. Tap Verify after any round to check it yourself.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function startTour() {
|
||||||
|
if (localStorage.getItem(TOUR_KEY) === '1') return;
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
const overlay = $('tour');
|
||||||
|
overlay.hidden = false;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const step = TOUR[i];
|
||||||
|
$('tour-step').textContent = `Step ${i + 1} of ${TOUR.length}`;
|
||||||
|
$('tour-title').textContent = step.title;
|
||||||
|
$('tour-body').textContent = step.body;
|
||||||
|
$('tour-next').textContent = i === TOUR.length - 1 ? 'Play' : 'Got it';
|
||||||
|
|
||||||
|
const dots = $('tour-dots');
|
||||||
|
clear(dots);
|
||||||
|
TOUR.forEach((_, n) => dots.appendChild(el('span', { class: n === i ? 'on' : '' })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
overlay.hidden = true;
|
||||||
|
localStorage.setItem(TOUR_KEY, '1');
|
||||||
|
};
|
||||||
|
|
||||||
|
$('tour-next').onclick = () => {
|
||||||
|
if (++i >= TOUR.length) finish();
|
||||||
|
else render();
|
||||||
|
};
|
||||||
|
$('tour-skip').onclick = finish;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- wallet ---------------- */
|
/* ---------------- wallet ---------------- */
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
@@ -695,6 +1007,41 @@ async function init() {
|
|||||||
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
||||||
|
|
||||||
$('auto-target').oninput = refreshAutoRow;
|
$('auto-target').oninput = refreshAutoRow;
|
||||||
|
|
||||||
|
$('do-deposit').onclick = createDeposit;
|
||||||
|
$('do-withdraw').onclick = withdrawByCode;
|
||||||
|
$('do-withdraw-manual').onclick = withdrawToInvoice;
|
||||||
|
$('wd-copy').onclick = (e) =>
|
||||||
|
navigator.clipboard.writeText(e.currentTarget.dataset.code || '');
|
||||||
|
document.querySelectorAll('[data-wd]').forEach((b) => {
|
||||||
|
b.onclick = () => {
|
||||||
|
$('wd-amt').value = b.dataset.wd === 'all'
|
||||||
|
? Math.floor(balanceMsat / 1000)
|
||||||
|
: b.dataset.wd;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
$('dep-copy').onclick = () =>
|
||||||
|
navigator.clipboard.writeText($('dep-bolt11').textContent);
|
||||||
|
$('dep-check').onclick = async () => {
|
||||||
|
if (!depositHash) return;
|
||||||
|
try {
|
||||||
|
const res = await api('POST', '/api/deposit/check',
|
||||||
|
{ payment_hash: depositHash });
|
||||||
|
if (res.credited_msat > 0 || res.settled) {
|
||||||
|
$('dep-status').textContent = 'Paid. Balance updated.';
|
||||||
|
$('dep-invoice').hidden = true;
|
||||||
|
depositHash = null;
|
||||||
|
await refreshBalance();
|
||||||
|
} else {
|
||||||
|
$('dep-status').textContent = 'Not settled yet — still waiting.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
$('dep-status').textContent = e.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.querySelectorAll('[data-dep]').forEach((b) => {
|
||||||
|
b.onclick = () => { $('dep-amt').value = b.dataset.dep; };
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-auto]').forEach((b) => {
|
document.querySelectorAll('[data-auto]').forEach((b) => {
|
||||||
b.onclick = () => {
|
b.onclick = () => {
|
||||||
$('auto-target').value = b.dataset.auto;
|
$('auto-target').value = b.dataset.auto;
|
||||||
|
|||||||
138
cmd/arcade/static/costs.html
Normal file
138
cmd/arcade/static/costs.html
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>WHAT THIS COSTS :: QUANTUM ARCADE</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<svg class="filigree" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="0.7">
|
||||||
|
<path d="M15 0 L45 0 L60 26 L45 52 L15 52 L0 26 Z"/>
|
||||||
|
<path d="M30 26 L60 26 M30 26 L15 0 M30 26 L15 52"/>
|
||||||
|
<circle cx="30" cy="26" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#orn)"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">QUANTUM<span>ARCADE</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="view costs">
|
||||||
|
|
||||||
|
<h1>WHAT THIS COSTS</h1>
|
||||||
|
|
||||||
|
<p class="lede">
|
||||||
|
Running this takes electricity, a machine, and a Lightning node with money
|
||||||
|
parked in it. Two small deductions cover that. Both are listed here, both
|
||||||
|
appear as their own line in your transaction history, and both are computed
|
||||||
|
by the same code that generated this page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Filled from /api/fees, so this page cannot state terms different from
|
||||||
|
the ones the server applies. -->
|
||||||
|
<div class="kvgrid" id="schedule"></div>
|
||||||
|
|
||||||
|
<h2>The two deductions</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>1. A percentage of winnings</h3>
|
||||||
|
<p class="muted small">
|
||||||
|
Taken only when you win. If you lose a round, nothing extra is taken —
|
||||||
|
you simply lost the round. This is the house's actual revenue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>2. Rounding down to whole satoshis</h3>
|
||||||
|
<p class="muted small">
|
||||||
|
Balances are tracked in millisatoshis — thousandths of a satoshi — because
|
||||||
|
the maths needs that resolution. Payouts are floored to whole satoshis and
|
||||||
|
the fraction stays with the house.
|
||||||
|
</p>
|
||||||
|
<p class="muted small">
|
||||||
|
The most this can ever cost you on a single payout is
|
||||||
|
<strong id="worst">—</strong>. It is a rounding, not a second fee, and it
|
||||||
|
is bounded by that amount every time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What that does to your odds</h2>
|
||||||
|
|
||||||
|
<p class="muted small">
|
||||||
|
A rake changes the real return, so quoting the game's raw figure would be
|
||||||
|
misleading. Both numbers are below: what the game's maths return before the
|
||||||
|
deduction, and what you actually receive after it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="tablewrap">
|
||||||
|
<table class="odds" id="rtp-table">
|
||||||
|
<tr><th>game</th><th>maths return</th><th>you receive</th></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>How you can check all of this</h2>
|
||||||
|
|
||||||
|
<ul class="checks">
|
||||||
|
<li>
|
||||||
|
<strong>Your history itemises it.</strong> Open the Wallet tab. A win
|
||||||
|
shows as a <code>payout</code> line for the full amount, followed by an
|
||||||
|
<code>operating_fee</code> line for the deduction. Nothing is folded into
|
||||||
|
a quietly smaller number.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>The books must sum to zero.</strong> Every millisatoshi in this
|
||||||
|
system is a double-entry posting.
|
||||||
|
<code>/api/health</code> adds up every account in the system; it returns
|
||||||
|
zero or the platform is telling you it is broken. A fee that vanished
|
||||||
|
instead of being posted would show up there.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>The odds are the generator.</strong> The scratch odds tables come
|
||||||
|
from the same data structure that produces outcomes — they cannot drift
|
||||||
|
apart. A test runs two million plays and fails the build if the observed
|
||||||
|
frequencies disagree with the published ones.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Outcomes are sealed before you bet.</strong> The crash point comes
|
||||||
|
from a seed committed before betting opens, combined with the keys of
|
||||||
|
everyone who joined. Check any round yourself in the Verify tab; it
|
||||||
|
recomputes on your device and asks the server only for published values.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>The code is open.</strong> AGPL-3.0. Every line of this,
|
||||||
|
including the two deductions described above, is readable and auditable.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>What is not taken</h2>
|
||||||
|
|
||||||
|
<ul class="checks">
|
||||||
|
<li>No fee to deposit.</li>
|
||||||
|
<li>No fee to send sats to another player.</li>
|
||||||
|
<li>No fee on losing rounds beyond the loss itself.</li>
|
||||||
|
<li>No account fee, inactivity fee, or minimum balance.</li>
|
||||||
|
<li>Withdrawals cost only the Lightning routing fee, which is real network
|
||||||
|
cost and is capped.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="muted small closing">
|
||||||
|
The aim is for this to feel free, which means being exact about the places
|
||||||
|
it is not. If you find a number on this page that does not match what your
|
||||||
|
history shows, that is a bug worth reporting, and the ledger will settle
|
||||||
|
the argument.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="center"><a class="backlink" href="/">← back to the arcade</a></p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/costs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
62
cmd/arcade/static/costs.js
Normal file
62
cmd/arcade/static/costs.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/* The costs page.
|
||||||
|
*
|
||||||
|
* Every figure is fetched from /api/fees, which the server renders from the
|
||||||
|
* same schedule it charges. Nothing here is written by hand, so the published
|
||||||
|
* terms cannot drift from the behaviour — if the operator changes the rake,
|
||||||
|
* this page changes with it. */
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function el(tag, attrs, ...children) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) {
|
||||||
|
if (k === 'class') node.className = v;
|
||||||
|
else if (k === 'text') node.textContent = v;
|
||||||
|
else node.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
if (c == null) continue;
|
||||||
|
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
let d;
|
||||||
|
try {
|
||||||
|
d = await (await fetch('/api/fees')).json();
|
||||||
|
} catch {
|
||||||
|
$('schedule').textContent = 'Could not load the fee schedule.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = d.schedule || {};
|
||||||
|
const grid = $('schedule');
|
||||||
|
const rows = [
|
||||||
|
['taken from winnings', s.rake_percent],
|
||||||
|
['payouts rounded to', s.rounding_unit],
|
||||||
|
['most rounding can cost', s.worst_case_rounding_per_payout],
|
||||||
|
];
|
||||||
|
for (const [k, v] of rows) {
|
||||||
|
grid.appendChild(el('div', {},
|
||||||
|
el('span', { class: 'k', text: k }),
|
||||||
|
el('span', { class: 'v', text: v || '—' })));
|
||||||
|
}
|
||||||
|
$('worst').textContent = s.worst_case_rounding_per_payout || '—';
|
||||||
|
|
||||||
|
const table = $('rtp-table');
|
||||||
|
const crash = d.crash_games || {};
|
||||||
|
table.appendChild(el('tr', {},
|
||||||
|
el('td', { text: 'Crash games' }),
|
||||||
|
el('td', { text: crash.game_rtp_percent || '—' }),
|
||||||
|
el('td', { class: 'rtp', text: crash.effective_percent || '—' })));
|
||||||
|
|
||||||
|
for (const t of d.scratch_tickets || []) {
|
||||||
|
table.appendChild(el('tr', {},
|
||||||
|
el('td', { text: t.ticket }),
|
||||||
|
el('td', { text: t.game_rtp_percent }),
|
||||||
|
el('td', { class: 'rtp', text: t.effective_percent })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
@@ -93,6 +93,10 @@
|
|||||||
<button data-auto="5">5</button>
|
<button data-auto="5">5</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="explain" id="auto-explain">
|
||||||
|
Leave this off to cash out by tapping. Set it and you stop
|
||||||
|
automatically — 2× means you double your stake and get out.
|
||||||
|
</p>
|
||||||
|
|
||||||
<button class="primary big" id="action">Place bet</button>
|
<button class="primary big" id="action">Place bet</button>
|
||||||
<div class="hint" id="hint"></div>
|
<div class="hint" id="hint"></div>
|
||||||
@@ -177,6 +181,66 @@
|
|||||||
<code class="pubkey" id="pubkey">—</code>
|
<code class="pubkey" id="pubkey">—</code>
|
||||||
<button id="copykey">Copy</button>
|
<button id="copykey">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Lightning in. Hidden unless the server has a node configured, so a
|
||||||
|
play-money deployment does not advertise a deposit it cannot take. -->
|
||||||
|
<div class="card" id="card-deposit" hidden>
|
||||||
|
<h2>Add sats</h2>
|
||||||
|
<div class="stakerow">
|
||||||
|
<button class="chip" data-dep="1000">1k</button>
|
||||||
|
<button class="chip" data-dep="5000">5k</button>
|
||||||
|
<button class="chip" data-dep="25000">25k</button>
|
||||||
|
<input id="dep-amt" type="number" min="1" inputmode="numeric" placeholder="sats">
|
||||||
|
</div>
|
||||||
|
<button class="primary" id="do-deposit">Create invoice</button>
|
||||||
|
<div class="hint" id="dep-hint"></div>
|
||||||
|
|
||||||
|
<div id="dep-invoice" hidden>
|
||||||
|
<div class="qrwrap" id="dep-qr"></div>
|
||||||
|
<code class="pubkey" id="dep-bolt11"></code>
|
||||||
|
<div class="stakerow">
|
||||||
|
<button id="dep-copy">Copy invoice</button>
|
||||||
|
<button id="dep-check">I have paid</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted small" id="dep-status">
|
||||||
|
Scan with any Lightning wallet. Your balance updates once it settles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightning out. Scanning is the whole flow: no invoice to create, no
|
||||||
|
amount to type into a second app. -->
|
||||||
|
<div class="card" id="card-withdraw" hidden>
|
||||||
|
<h2>Cash out</h2>
|
||||||
|
<p class="muted small">
|
||||||
|
Pick an amount and scan the code with your Lightning wallet. Your
|
||||||
|
wallet pulls the sats — you never make an invoice.
|
||||||
|
</p>
|
||||||
|
<div class="stakerow">
|
||||||
|
<button class="chip" data-wd="1000">1k</button>
|
||||||
|
<button class="chip" data-wd="5000">5k</button>
|
||||||
|
<button class="chip" data-wd="all">All</button>
|
||||||
|
<input id="wd-amt" type="number" min="1" inputmode="numeric" placeholder="sats">
|
||||||
|
</div>
|
||||||
|
<button class="primary" id="do-withdraw">Show cash-out code</button>
|
||||||
|
<div class="hint" id="wd-hint"></div>
|
||||||
|
|
||||||
|
<div id="wd-code" hidden>
|
||||||
|
<div class="qrwrap" id="wd-qr"></div>
|
||||||
|
<p class="muted small center" id="wd-expiry"></p>
|
||||||
|
<button id="wd-copy">Copy code</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="proof">
|
||||||
|
<summary>Paste an invoice instead</summary>
|
||||||
|
<p class="muted small">
|
||||||
|
If your wallet cannot scan LNURL, make an invoice for the amount and
|
||||||
|
paste it here.
|
||||||
|
</p>
|
||||||
|
<input id="wd-bolt11" placeholder="lnbc… invoice" autocomplete="off">
|
||||||
|
<button id="do-withdraw-manual">Withdraw to invoice</button>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Send sats</h2>
|
<h2>Send sats</h2>
|
||||||
<input id="to-key" placeholder="recipient key" autocomplete="off">
|
<input id="to-key" placeholder="recipient key" autocomplete="off">
|
||||||
@@ -254,6 +318,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- First run only. Three cards, then it never appears again. -->
|
||||||
|
<div class="tour" id="tour" hidden>
|
||||||
|
<div class="tourcard">
|
||||||
|
<div class="tourstep" id="tour-step"></div>
|
||||||
|
<h2 id="tour-title"></h2>
|
||||||
|
<p id="tour-body"></p>
|
||||||
|
<div class="tourdots" id="tour-dots"></div>
|
||||||
|
<button class="primary" id="tour-next">Got it</button>
|
||||||
|
<button class="tourskip" id="tour-skip">Skip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/app.js"></script>
|
<script type="module" src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
BIN
cmd/arcade/static/pqsign.wasm
Executable file
BIN
cmd/arcade/static/pqsign.wasm
Executable file
Binary file not shown.
358
cmd/arcade/static/qr.js
Normal file
358
cmd/arcade/static/qr.js
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
/* A minimal QR encoder, byte mode, error-correction level M.
|
||||||
|
*
|
||||||
|
* Written rather than imported because the arcade has to work on a network
|
||||||
|
* with no internet: a CDN script is a dependency on the outside world, and the
|
||||||
|
* whole point of this box is that it does not need one.
|
||||||
|
*
|
||||||
|
* Scope is deliberately narrow — byte mode, versions 1 through 20, level M —
|
||||||
|
* which covers a Lightning invoice with room to spare and leaves out the
|
||||||
|
* kanji/numeric modes and structured-append machinery that would triple the
|
||||||
|
* size for no benefit here.
|
||||||
|
*
|
||||||
|
* Reference: ISO/IEC 18004. */
|
||||||
|
|
||||||
|
/* ---------- Galois field arithmetic over GF(256) ----------
|
||||||
|
* Reed–Solomon needs multiplication in the field the QR spec defines, with
|
||||||
|
* the primitive polynomial 0x11d. Log tables make that a lookup. */
|
||||||
|
|
||||||
|
const EXP = new Uint8Array(512);
|
||||||
|
const LOG = new Uint8Array(256);
|
||||||
|
(function buildTables() {
|
||||||
|
let x = 1;
|
||||||
|
for (let i = 0; i < 255; i++) {
|
||||||
|
EXP[i] = x;
|
||||||
|
LOG[x] = i;
|
||||||
|
x <<= 1;
|
||||||
|
if (x & 0x100) x ^= 0x11d;
|
||||||
|
}
|
||||||
|
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||||
|
})();
|
||||||
|
|
||||||
|
function gfMul(a, b) {
|
||||||
|
if (a === 0 || b === 0) return 0;
|
||||||
|
return EXP[LOG[a] + LOG[b]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The generator polynomial for n error-correction codewords. */
|
||||||
|
function rsGenerator(n) {
|
||||||
|
let poly = [1];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const next = new Array(poly.length + 1).fill(0);
|
||||||
|
for (let j = 0; j < poly.length; j++) {
|
||||||
|
next[j] ^= poly[j];
|
||||||
|
next[j + 1] ^= gfMul(poly[j], EXP[i]);
|
||||||
|
}
|
||||||
|
poly = next;
|
||||||
|
}
|
||||||
|
return poly;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsEncode(data, ecCount) {
|
||||||
|
const gen = rsGenerator(ecCount);
|
||||||
|
const res = new Uint8Array(data.length + ecCount);
|
||||||
|
res.set(data);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const factor = res[i];
|
||||||
|
if (factor === 0) continue;
|
||||||
|
for (let j = 0; j < gen.length; j++) {
|
||||||
|
res[i + j] ^= gfMul(gen[j], factor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.slice(data.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- capacity tables, level M ----------
|
||||||
|
* [total codewords, ec codewords per block, block count group1,
|
||||||
|
* data codewords group1, block count group2, data codewords group2] */
|
||||||
|
const VERSIONS = [
|
||||||
|
null,
|
||||||
|
[26, 10, 1, 16, 0, 0], // 1
|
||||||
|
[44, 16, 1, 28, 0, 0],
|
||||||
|
[70, 26, 1, 44, 0, 0],
|
||||||
|
[100, 18, 2, 32, 0, 0],
|
||||||
|
[134, 24, 2, 43, 0, 0],
|
||||||
|
[172, 16, 4, 27, 0, 0],
|
||||||
|
[196, 18, 4, 31, 0, 0],
|
||||||
|
[242, 22, 2, 38, 2, 39],
|
||||||
|
[292, 22, 3, 36, 2, 37],
|
||||||
|
[346, 26, 4, 43, 1, 44], // 10
|
||||||
|
[404, 30, 1, 50, 4, 51],
|
||||||
|
[466, 22, 6, 36, 2, 37],
|
||||||
|
[532, 22, 8, 37, 1, 38],
|
||||||
|
[581, 24, 4, 40, 5, 41],
|
||||||
|
[655, 24, 5, 41, 5, 42], // 15
|
||||||
|
[733, 28, 7, 45, 3, 46],
|
||||||
|
[815, 28, 10, 46, 1, 47],
|
||||||
|
[901, 26, 9, 43, 4, 44],
|
||||||
|
[991, 26, 3, 44, 11, 45],
|
||||||
|
[1085, 26, 3, 41, 13, 42], // 20
|
||||||
|
];
|
||||||
|
|
||||||
|
function versionCapacity(v) {
|
||||||
|
const [, ec, b1, d1, b2, d2] = VERSIONS[v];
|
||||||
|
return b1 * d1 + b2 * d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alignment pattern centres per version. */
|
||||||
|
const ALIGN = [
|
||||||
|
[], [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34],
|
||||||
|
[6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
|
||||||
|
[6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66],
|
||||||
|
[6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78],
|
||||||
|
[6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------- bit stream ---------- */
|
||||||
|
|
||||||
|
class Bits {
|
||||||
|
constructor() { this.bits = []; }
|
||||||
|
push(value, length) {
|
||||||
|
for (let i = length - 1; i >= 0; i--) this.bits.push((value >> i) & 1);
|
||||||
|
}
|
||||||
|
get length() { return this.bits.length; }
|
||||||
|
toBytes() {
|
||||||
|
const out = new Uint8Array(Math.ceil(this.bits.length / 8));
|
||||||
|
this.bits.forEach((b, i) => { if (b) out[i >> 3] |= 0x80 >> (i & 7); });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- encoding ---------- */
|
||||||
|
|
||||||
|
function encodeData(text, version) {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
const bits = new Bits();
|
||||||
|
|
||||||
|
bits.push(0b0100, 4); // byte mode
|
||||||
|
bits.push(bytes.length, version < 10 ? 8 : 16); // length field
|
||||||
|
for (const b of bytes) bits.push(b, 8);
|
||||||
|
|
||||||
|
const capacityBits = versionCapacity(version) * 8;
|
||||||
|
if (bits.length > capacityBits) return null; // does not fit
|
||||||
|
|
||||||
|
// Terminator, then pad to a byte boundary, then alternating pad bytes.
|
||||||
|
bits.push(0, Math.min(4, capacityBits - bits.length));
|
||||||
|
while (bits.length % 8 !== 0) bits.push(0, 1);
|
||||||
|
|
||||||
|
const data = Array.from(bits.toBytes());
|
||||||
|
const padBytes = [0xec, 0x11];
|
||||||
|
let i = 0;
|
||||||
|
while (data.length < versionCapacity(version)) data.push(padBytes[i++ % 2]);
|
||||||
|
|
||||||
|
return interleave(data, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Split into blocks, compute error correction, then interleave both — the
|
||||||
|
* spec's arrangement, which is what makes a QR survive damage to any one
|
||||||
|
* region rather than losing a contiguous run of data. */
|
||||||
|
function interleave(data, version) {
|
||||||
|
const [, ecPerBlock, b1, d1, b2, d2] = VERSIONS[version];
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (let i = 0; i < b1; i++) {
|
||||||
|
blocks.push(data.slice(offset, offset + d1));
|
||||||
|
offset += d1;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < b2; i++) {
|
||||||
|
blocks.push(data.slice(offset, offset + d2));
|
||||||
|
offset += d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ecBlocks = blocks.map((b) => rsEncode(Uint8Array.from(b), ecPerBlock));
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
const maxData = Math.max(...blocks.map((b) => b.length));
|
||||||
|
for (let i = 0; i < maxData; i++) {
|
||||||
|
for (const b of blocks) if (i < b.length) out.push(b[i]);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < ecPerBlock; i++) {
|
||||||
|
for (const b of ecBlocks) out.push(b[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- matrix ---------- */
|
||||||
|
|
||||||
|
function buildMatrix(version, codewords, mask) {
|
||||||
|
const size = version * 4 + 17;
|
||||||
|
const m = Array.from({ length: size }, () => new Array(size).fill(null));
|
||||||
|
|
||||||
|
const setFinder = (r, c) => {
|
||||||
|
for (let dr = -1; dr <= 7; dr++) {
|
||||||
|
for (let dc = -1; dc <= 7; dc++) {
|
||||||
|
const rr = r + dr, cc = c + dc;
|
||||||
|
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
||||||
|
const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6 &&
|
||||||
|
(dr === 0 || dr === 6 || dc === 0 || dc === 6 ||
|
||||||
|
(dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
|
||||||
|
m[rr][cc] = inRing ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setFinder(0, 0);
|
||||||
|
setFinder(0, size - 7);
|
||||||
|
setFinder(size - 7, 0);
|
||||||
|
|
||||||
|
// Timing patterns.
|
||||||
|
for (let i = 8; i < size - 8; i++) {
|
||||||
|
m[6][i] = i % 2 === 0 ? 1 : 0;
|
||||||
|
m[i][6] = i % 2 === 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alignment patterns, skipping those that would collide with finders.
|
||||||
|
const centres = ALIGN[version];
|
||||||
|
for (const r of centres) {
|
||||||
|
for (const c of centres) {
|
||||||
|
if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) ||
|
||||||
|
(r >= size - 9 && c <= 8)) continue;
|
||||||
|
for (let dr = -2; dr <= 2; dr++) {
|
||||||
|
for (let dc = -2; dc <= 2; dc++) {
|
||||||
|
m[r + dr][c + dc] =
|
||||||
|
Math.max(Math.abs(dr), Math.abs(dc)) !== 1 ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m[size - 8][8] = 1; // dark module
|
||||||
|
|
||||||
|
// Reserve format areas so data placement skips them.
|
||||||
|
const reserveFormat = () => {
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
if (m[8][i] === null) m[8][i] = 0;
|
||||||
|
if (m[i][8] === null) m[i][8] = 0;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (m[8][size - 1 - i] === null) m[8][size - 1 - i] = 0;
|
||||||
|
if (m[size - 1 - i][8] === null) m[size - 1 - i][8] = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const formatCells = [];
|
||||||
|
for (let r = 0; r < size; r++) {
|
||||||
|
for (let c = 0; c < size; c++) if (m[r][c] === null) formatCells.push([r, c]);
|
||||||
|
}
|
||||||
|
reserveFormat();
|
||||||
|
|
||||||
|
// Version information, for version 7 and above.
|
||||||
|
if (version >= 7) {
|
||||||
|
let rem = version;
|
||||||
|
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25);
|
||||||
|
const bits = (version << 12) | rem;
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const bit = (bits >> i) & 1;
|
||||||
|
m[Math.floor(i / 3)][size - 11 + (i % 3)] = bit;
|
||||||
|
m[size - 11 + (i % 3)][Math.floor(i / 3)] = bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data placement: upward/downward in two-column strips, right to left.
|
||||||
|
let bitIndex = 0;
|
||||||
|
const dataBits = [];
|
||||||
|
for (const cw of codewords) {
|
||||||
|
for (let i = 7; i >= 0; i--) dataBits.push((cw >> i) & 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let upward = true;
|
||||||
|
for (let col = size - 1; col > 0; col -= 2) {
|
||||||
|
if (col === 6) col--; // skip the timing column
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
const row = upward ? size - 1 - i : i;
|
||||||
|
for (let c = 0; c < 2; c++) {
|
||||||
|
const cc = col - c;
|
||||||
|
if (m[row][cc] !== null) continue;
|
||||||
|
let bit = bitIndex < dataBits.length ? dataBits[bitIndex++] : 0;
|
||||||
|
if (maskAt(mask, row, cc)) bit ^= 1;
|
||||||
|
m[row][cc] = bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upward = !upward;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFormat(m, size, mask);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskAt(mask, r, c) {
|
||||||
|
switch (mask) {
|
||||||
|
case 0: return (r + c) % 2 === 0;
|
||||||
|
case 1: return r % 2 === 0;
|
||||||
|
case 2: return c % 3 === 0;
|
||||||
|
case 3: return (r + c) % 3 === 0;
|
||||||
|
case 4: return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0;
|
||||||
|
case 5: return ((r * c) % 2) + ((r * c) % 3) === 0;
|
||||||
|
case 6: return (((r * c) % 2) + ((r * c) % 3)) % 2 === 0;
|
||||||
|
default: return (((r + c) % 2) + ((r * c) % 3)) % 2 === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFormat(m, size, mask) {
|
||||||
|
// Level M is 0b00; combine with the mask and append BCH error correction.
|
||||||
|
const data = (0b00 << 3) | mask;
|
||||||
|
let rem = data;
|
||||||
|
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537);
|
||||||
|
const bits = ((data << 10) | rem) ^ 0x5412;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 5; i++) m[8][i] = (bits >> i) & 1;
|
||||||
|
m[8][7] = (bits >> 6) & 1;
|
||||||
|
m[8][8] = (bits >> 7) & 1;
|
||||||
|
m[7][8] = (bits >> 8) & 1;
|
||||||
|
for (let i = 9; i < 15; i++) m[14 - i][8] = (bits >> i) & 1;
|
||||||
|
|
||||||
|
// The second copy is 7 bits down the bottom-left column and 8 bits along
|
||||||
|
// the top-right row. It is 7 and not 8 down the column because the cell
|
||||||
|
// below is the dark module, which is fixed and must not be overwritten.
|
||||||
|
for (let i = 0; i <= 6; i++) m[size - 1 - i][8] = (bits >> i) & 1;
|
||||||
|
for (let i = 7; i < 15; i++) m[8][size - 15 + i] = (bits >> i) & 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- public API ---------- */
|
||||||
|
|
||||||
|
/* encode returns a square matrix of 0/1, or null if the text does not fit. */
|
||||||
|
export function encode(text) {
|
||||||
|
for (let version = 1; version <= 20; version++) {
|
||||||
|
const codewords = encodeData(text, version);
|
||||||
|
if (codewords) {
|
||||||
|
// Mask 0 is used unconditionally. Choosing the optimal mask by penalty
|
||||||
|
// score improves scan reliability marginally and costs four more passes
|
||||||
|
// over the matrix; at the sizes here, every reader handles mask 0.
|
||||||
|
return buildMatrix(version, codewords, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* render draws a matrix into an SVG element, scaled to its container. */
|
||||||
|
export function render(matrix, options = {}) {
|
||||||
|
const quiet = options.quiet ?? 4;
|
||||||
|
const size = matrix.length;
|
||||||
|
const total = size + quiet * 2;
|
||||||
|
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(NS, 'svg');
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${total} ${total}`);
|
||||||
|
svg.setAttribute('width', '100%');
|
||||||
|
svg.setAttribute('height', '100%');
|
||||||
|
svg.setAttribute('shape-rendering', 'crispEdges');
|
||||||
|
|
||||||
|
const bg = document.createElementNS(NS, 'rect');
|
||||||
|
bg.setAttribute('width', total);
|
||||||
|
bg.setAttribute('height', total);
|
||||||
|
bg.setAttribute('fill', options.background ?? '#000603');
|
||||||
|
svg.appendChild(bg);
|
||||||
|
|
||||||
|
// One path for every dark module: far fewer nodes than a rect each, which
|
||||||
|
// matters when a phone is re-rendering this inside a live page.
|
||||||
|
let d = '';
|
||||||
|
for (let r = 0; r < size; r++) {
|
||||||
|
for (let c = 0; c < size; c++) {
|
||||||
|
if (matrix[r][c]) d += `M${c + quiet} ${r + quiet}h1v1h-1z`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const path = document.createElementNS(NS, 'path');
|
||||||
|
path.setAttribute('d', d);
|
||||||
|
path.setAttribute('fill', options.foreground ?? '#00ff9c');
|
||||||
|
svg.appendChild(path);
|
||||||
|
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
@@ -258,6 +258,22 @@ button:active { transform: translateY(1px); }
|
|||||||
}
|
}
|
||||||
.hint.bad { color: var(--red); }
|
.hint.bad { color: var(--red); }
|
||||||
.hint.good { color: var(--amber); }
|
.hint.good { color: var(--amber); }
|
||||||
|
.hint.near-miss { color: #ff9f43; font-weight: 600; animation: flicker 0.6s ease-in-out 2; }
|
||||||
|
|
||||||
|
@keyframes flicker {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse the action button after a win — urge to rebet */
|
||||||
|
#action.pulse {
|
||||||
|
animation: pulse 0.8s ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 18px rgba(0, 255, 145, 0.4);
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
.players { margin-top: 12px; display: flex; flex-direction: column; gap: 3px; }
|
.players { margin-top: 12px; display: flex; flex-direction: column; gap: 3px; }
|
||||||
.player {
|
.player {
|
||||||
@@ -511,3 +527,114 @@ button:active { transform: translateY(1px); }
|
|||||||
.multiplier.crashed { animation: none; }
|
.multiplier.crashed { animation: none; }
|
||||||
h1::after { animation: none; }
|
h1::after { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- costs / transparency page ---------- */
|
||||||
|
|
||||||
|
.costs { max-width: 660px; }
|
||||||
|
.costs h1 { margin-bottom: 14px; }
|
||||||
|
.costs h2 {
|
||||||
|
margin-top: 26px; font-size: 12px; color: var(--amber);
|
||||||
|
}
|
||||||
|
.costs h3 {
|
||||||
|
margin: 0 0 6px; font-size: 14px; letter-spacing: 0.06em;
|
||||||
|
text-transform: none; color: var(--green);
|
||||||
|
}
|
||||||
|
.lede { font-size: 13.5px; line-height: 1.65; color: var(--ink, var(--green)); }
|
||||||
|
|
||||||
|
.kvgrid {
|
||||||
|
display: grid; grid-template-columns: 1fr; gap: 1px;
|
||||||
|
background: var(--green-ghost); border: 1px solid var(--green-ghost);
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
@media (min-width: 560px) { .kvgrid { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
.kvgrid > div { background: #00120c; padding: 12px; }
|
||||||
|
.kvgrid .k {
|
||||||
|
display: block; font-size: 9px; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: var(--green-dim); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.kvgrid .v { font-size: 17px; color: var(--amber); font-weight: 700; }
|
||||||
|
|
||||||
|
.checks { padding-left: 18px; margin: 10px 0; }
|
||||||
|
.checks li { margin-bottom: 10px; font-size: 12.5px; color: var(--green-dim); }
|
||||||
|
.checks strong { color: var(--green); font-weight: 700; }
|
||||||
|
.checks code {
|
||||||
|
color: var(--amber); font-size: 11.5px;
|
||||||
|
background: #00140e; padding: 1px 4px; border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tablewrap { overflow-x: auto; }
|
||||||
|
.closing {
|
||||||
|
margin-top: 26px; padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--green-ghost);
|
||||||
|
}
|
||||||
|
.backlink {
|
||||||
|
color: var(--green-dim); font-size: 12px; text-decoration: none;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.backlink:hover { color: var(--green); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- lightning deposit QR ---------- */
|
||||||
|
|
||||||
|
.qrwrap {
|
||||||
|
width: 100%; max-width: 260px; margin: 12px auto;
|
||||||
|
aspect-ratio: 1; padding: 8px;
|
||||||
|
background: #000603; border: 1px solid var(--green-ghost); border-radius: 3px;
|
||||||
|
}
|
||||||
|
.qrwrap svg { display: block; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#dep-amt, #wd-amt { margin: 0; }
|
||||||
|
.stakerow input { flex: 1.4; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- plain-language help ---------- */
|
||||||
|
|
||||||
|
.explain {
|
||||||
|
font-size: 11.5px; color: var(--green-dim); margin: 6px 2px 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- first-run walkthrough ---------- */
|
||||||
|
|
||||||
|
.tour {
|
||||||
|
position: fixed; inset: 0; z-index: 60;
|
||||||
|
background: #000000e8; backdrop-filter: blur(3px);
|
||||||
|
display: grid; place-items: center; padding: 20px;
|
||||||
|
}
|
||||||
|
.tourcard {
|
||||||
|
max-width: 340px; width: 100%; text-align: center;
|
||||||
|
background: #00120c; border: 1px solid var(--green);
|
||||||
|
border-radius: 3px; padding: 24px 20px;
|
||||||
|
box-shadow: 0 0 40px #00ff9c22;
|
||||||
|
}
|
||||||
|
.tourstep {
|
||||||
|
font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim); margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.tourcard h2 {
|
||||||
|
font-size: 16px; color: var(--green); text-transform: none;
|
||||||
|
letter-spacing: 0.02em; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.tourcard h2::before { content: none; }
|
||||||
|
.tourcard p {
|
||||||
|
font-size: 13px; line-height: 1.6; color: var(--green-dim);
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
.tourdots { display: flex; gap: 6px; justify-content: center; margin-bottom: 16px; }
|
||||||
|
.tourdots span {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--green-ghost);
|
||||||
|
}
|
||||||
|
.tourdots span.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||||
|
.tourskip {
|
||||||
|
width: 100%; margin-top: 8px; border: none; background: none;
|
||||||
|
color: var(--green-dim); font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- real-money confirmation ---------- */
|
||||||
|
|
||||||
|
.confirm .tourcard { border-color: var(--amber); }
|
||||||
|
.confirm h2 { color: var(--amber); }
|
||||||
|
.confirm .primary {
|
||||||
|
background: #2a1c00; color: var(--amber); border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|||||||
575
cmd/arcade/static/wasm_exec.js
Normal file
575
cmd/arcade/static/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.path) {
|
||||||
|
globalThis.path = {
|
||||||
|
resolve(...pathSegments) {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCallExport = (a, b) => {
|
||||||
|
this._inst.exports.testExport0();
|
||||||
|
return this._inst.exports.testExport(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
callExport: testCallExport,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
139
cmd/pqsign/main.go
Normal file
139
cmd/pqsign/main.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
// Command pqsign exposes hybrid post-quantum signing to the browser.
|
||||||
|
//
|
||||||
|
// WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half has to come
|
||||||
|
// from somewhere. Compiling the same pkg/pqid the server verifies with means
|
||||||
|
// there is exactly one implementation of the scheme in the project: a client
|
||||||
|
// and server that disagreed about signing would be a very expensive bug to
|
||||||
|
// find, and this makes it impossible by construction.
|
||||||
|
//
|
||||||
|
// Build:
|
||||||
|
//
|
||||||
|
// GOOS=js GOARCH=wasm go build -o cmd/arcade/static/pqsign.wasm ./cmd/pqsign
|
||||||
|
//
|
||||||
|
// The private key never leaves the browser. It is generated here, exported for
|
||||||
|
// the page to store, and re-imported on the next visit.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"syscall/js"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/pqid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
js.Global().Set("qaPQ", js.ValueOf(map[string]any{
|
||||||
|
"generateKey": js.FuncOf(generateKey),
|
||||||
|
"sign": js.FuncOf(sign),
|
||||||
|
"publicKey": js.FuncOf(publicKey),
|
||||||
|
"sizes": js.FuncOf(sizes),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// A WASM module's main must not return, or the exported functions are
|
||||||
|
// torn down with it.
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// result wraps a value or an error in the shape the page expects, so JavaScript
|
||||||
|
// never has to distinguish a thrown Go panic from a returned failure.
|
||||||
|
func result(value any, err error) any {
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{"error": err.Error()}
|
||||||
|
}
|
||||||
|
return map[string]any{"ok": value}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateKey creates a hybrid keypair and returns both halves hex-encoded.
|
||||||
|
//
|
||||||
|
// The private half is handed to the page to persist. That is unavoidable —
|
||||||
|
// the browser is where signing happens — but it never crosses the network.
|
||||||
|
func generateKey(this js.Value, args []js.Value) any {
|
||||||
|
pub, priv, err := pqid.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edSeed := priv.Ed.Seed()
|
||||||
|
pqBytes, err := priv.PQ.MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result(map[string]any{
|
||||||
|
"public": pub.Hex(),
|
||||||
|
"ed_seed": hex.EncodeToString(edSeed),
|
||||||
|
"pq_key": hex.EncodeToString(pqBytes),
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign produces both signatures over a hex-encoded message.
|
||||||
|
//
|
||||||
|
// qaPQ.sign(edSeedHex, pqKeyHex, messageHex) -> {ok: signatureHex}
|
||||||
|
func sign(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 3 {
|
||||||
|
return result(nil, errArgs("sign expects (edSeed, pqKey, message)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
priv, err := restore(args[0].String(), args[1].String())
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
msg, err := hex.DecodeString(args[2].String())
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, errArgs("message is not hex"))
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := pqid.Sign(priv, msg)
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
return result(hex.EncodeToString(sig), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicKey re-derives the public half from stored private material, so the
|
||||||
|
// page never has to store the public key separately and cannot store a pair
|
||||||
|
// that does not match.
|
||||||
|
func publicKey(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 2 {
|
||||||
|
return result(nil, errArgs("publicKey expects (edSeed, pqKey)"))
|
||||||
|
}
|
||||||
|
priv, err := restore(args[0].String(), args[1].String())
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
pub, err := pqid.PublicFromPrivate(priv)
|
||||||
|
if err != nil {
|
||||||
|
return result(nil, err)
|
||||||
|
}
|
||||||
|
return result(pub.Hex(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sizes lets the page sanity-check what it stored without hardcoding lengths
|
||||||
|
// that could drift from the Go side.
|
||||||
|
func sizes(this js.Value, args []js.Value) any {
|
||||||
|
return result(map[string]any{
|
||||||
|
"public_key": pqid.PublicKeySize,
|
||||||
|
"signature": pqid.SignatureSize,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(edSeedHex, pqKeyHex string) (*pqid.PrivateKey, error) {
|
||||||
|
edSeed, err := hex.DecodeString(edSeedHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errArgs("ed seed is not hex")
|
||||||
|
}
|
||||||
|
pqBytes, err := hex.DecodeString(pqKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errArgs("pq key is not hex")
|
||||||
|
}
|
||||||
|
return pqid.PrivateFromBytes(edSeed, pqBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
type argError string
|
||||||
|
|
||||||
|
func (e argError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
func errArgs(msg string) error { return argError("pqsign: " + msg) }
|
||||||
@@ -37,6 +37,8 @@ services:
|
|||||||
ARCADE_ADDR: ":8080"
|
ARCADE_ADDR: ":8080"
|
||||||
# Development funding. Leave unset in any real deployment.
|
# Development funding. Leave unset in any real deployment.
|
||||||
ARCADE_DEV_FAUCET: "${ARCADE_DEV_FAUCET:-0}"
|
ARCADE_DEV_FAUCET: "${ARCADE_DEV_FAUCET:-0}"
|
||||||
|
ALBY_URL: http://10.30.20.43:58000
|
||||||
|
ALBY_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwZXJtaXNzaW9uIjoiZnVsbCIsImV4cCI6MjY0OTg5MDE4OH0.IpemVy-_6PgWwlGtVtHRMZlpUR179jKamAl9fpbQE_o
|
||||||
ports: ["8080:8080"]
|
ports: ["8080:8080"]
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
|
|||||||
52
migrations/0006_tournaments.sql
Normal file
52
migrations/0006_tournaments.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-- Tournaments: scheduled events with an entry fee, a prize pool, and a
|
||||||
|
-- leaderboard.
|
||||||
|
--
|
||||||
|
-- The prize pool is a real ledger account, not a number in a row. Entry fees
|
||||||
|
-- move into it and prizes move out of it, so a tournament's money is subject to
|
||||||
|
-- the same double-entry invariants as everything else and cannot be
|
||||||
|
-- accidentally created or lost.
|
||||||
|
|
||||||
|
CREATE TYPE tournament_status AS ENUM
|
||||||
|
('scheduled', 'registering', 'running', 'settled', 'cancelled');
|
||||||
|
|
||||||
|
CREATE TABLE tournaments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
game TEXT NOT NULL,
|
||||||
|
status tournament_status NOT NULL DEFAULT 'scheduled',
|
||||||
|
entry_fee_msat BIGINT NOT NULL CHECK (entry_fee_msat >= 0),
|
||||||
|
-- The ledger account holding this tournament's pool.
|
||||||
|
pool_account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
-- Prize split as basis points per finishing position, highest first.
|
||||||
|
-- e.g. {5000,3000,2000} pays 50/30/20 to the top three.
|
||||||
|
payout_bp INTEGER[] NOT NULL,
|
||||||
|
max_entrants INTEGER,
|
||||||
|
registers_at TIMESTAMPTZ NOT NULL,
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ends_at TIMESTAMPTZ NOT NULL,
|
||||||
|
settled_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT tournament_window CHECK (registers_at <= starts_at AND starts_at < ends_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX tournaments_status_idx ON tournaments (status, starts_at);
|
||||||
|
|
||||||
|
CREATE TABLE tournament_entries (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tournament_id BIGINT NOT NULL REFERENCES tournaments(id),
|
||||||
|
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||||
|
-- Score is net profit in millisatoshis across the tournament window.
|
||||||
|
-- It may be negative; a losing player still has a standing.
|
||||||
|
score_msat BIGINT NOT NULL DEFAULT 0,
|
||||||
|
rounds_played INTEGER NOT NULL DEFAULT 0,
|
||||||
|
prize_msat BIGINT NOT NULL DEFAULT 0 CHECK (prize_msat >= 0),
|
||||||
|
entered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tournament_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX tournament_entries_board_idx
|
||||||
|
ON tournament_entries (tournament_id, score_msat DESC);
|
||||||
|
|
||||||
|
-- Deliberately NOT append-only. An entry row is a seat reservation; a seat
|
||||||
|
-- claimed but not paid for must be releasable so the player can retry once
|
||||||
|
-- funded. The money side is a ledger posting and remains immutable.
|
||||||
12
migrations/0007_entry_seats.sql
Normal file
12
migrations/0007_entry_seats.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Entry rows are a seat reservation, not a financial record.
|
||||||
|
--
|
||||||
|
-- They were created append-only alongside the ledger tables, but that conflates
|
||||||
|
-- two different things. The ledger must be append-only because it is the record
|
||||||
|
-- of money. A seat claimed and then not paid for is not a record of anything —
|
||||||
|
-- it is a reservation that failed, and it must be releasable so the player can
|
||||||
|
-- retry once funded.
|
||||||
|
--
|
||||||
|
-- The money side is unaffected: entry fees and prizes are ledger postings and
|
||||||
|
-- remain immutable.
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tournaments_append_only_entries ON tournament_entries;
|
||||||
141
ops/README.md
Normal file
141
ops/README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Operations
|
||||||
|
|
||||||
|
Persistence, failover, and tuning for a self-hosted arcade.
|
||||||
|
|
||||||
|
## Machines
|
||||||
|
|
||||||
|
| Role | Runs | Clone? |
|
||||||
|
|---|---|---|
|
||||||
|
| **core** | PostgreSQL, Redis, Caddy | no — one only |
|
||||||
|
| **app** | `quantum-arcade` | yes, freely |
|
||||||
|
| **standby** | PostgreSQL + `standby.sh follow` | no — one is enough |
|
||||||
|
| **lightning** | Alby Hub | no — firewalled |
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
PostgreSQL is the only thing that cannot be rebuilt. The app binary embeds its
|
||||||
|
own client, and Redis holds only sessions and leases, which regenerate.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/backups/quantum-arcade
|
||||||
|
./ops/backup.sh init # once
|
||||||
|
sudo cp ops/arcade-backup.{service,timer} /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now arcade-backup.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Every five minutes it captures a compressed snapshot, keeps a rolling 24 hours
|
||||||
|
(288 snapshots), and prunes the rest.
|
||||||
|
|
||||||
|
**Every dump is checked before it replaces the previous one** — size and format
|
||||||
|
header. A backup script that reports success on a truncated file is worse than
|
||||||
|
no backup, because it converts a recoverable outage into silent data loss
|
||||||
|
discovered only when it is needed.
|
||||||
|
|
||||||
|
### Prove it works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ops/backup.sh verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Restores the newest snapshot into a scratch database and asserts the ledger
|
||||||
|
sums to zero — the same invariant the live system checks on every request.
|
||||||
|
Schedule it nightly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp ops/arcade-verify.{service,timer} /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now arcade-verify.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
A backup nobody has restored is a rumour.
|
||||||
|
|
||||||
|
## Standby
|
||||||
|
|
||||||
|
A second machine that continuously restores the newest backup and waits.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp ops/arcade-standby.service /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now arcade-standby
|
||||||
|
./ops/standby.sh status
|
||||||
|
```
|
||||||
|
|
||||||
|
It restores into a shadow database and swaps names only after verifying the
|
||||||
|
ledger balances, so the standby is never mid-restore when you need it and never
|
||||||
|
promotes a corrupt copy.
|
||||||
|
|
||||||
|
### Pulling the plug
|
||||||
|
|
||||||
|
On the standby:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ops/standby.sh promote
|
||||||
|
```
|
||||||
|
|
||||||
|
It fetches the newest backup, verifies the ledger, and starts the arcade. It
|
||||||
|
does not contact the dead machine, because in the situation this exists for the
|
||||||
|
dead machine is not answering.
|
||||||
|
|
||||||
|
Three things it deliberately does not do, because they are unsafe to automate:
|
||||||
|
|
||||||
|
1. **Repoint the endpoint.** DNS or the load balancer's upstream list. Until
|
||||||
|
that happens players still reach the dead machine.
|
||||||
|
2. **Confirm the old machine is down.** Two live instances writing to different
|
||||||
|
databases diverge, and the result cannot be merged — both ledgers will be
|
||||||
|
internally valid and mutually contradictory.
|
||||||
|
3. **Let players in before checking `/api/health`.** It must report a zero
|
||||||
|
ledger sum.
|
||||||
|
|
||||||
|
### What you lose
|
||||||
|
|
||||||
|
Up to one backup interval — five minutes of play. Rounds in flight at the
|
||||||
|
moment of failure are refunded automatically by the reconciler once the
|
||||||
|
standby is live, because their stakes were debited but never settled.
|
||||||
|
|
||||||
|
## Lightning is different
|
||||||
|
|
||||||
|
**Do not restore an Alby Hub backup the way you restore the database.**
|
||||||
|
|
||||||
|
Lightning channel state is not a snapshot you can roll back. Publishing an old
|
||||||
|
channel state is interpreted by your counterparty as an attempt to cheat, and
|
||||||
|
the penalty mechanism can take the entire channel balance. Restoring a stale
|
||||||
|
state can lose real money in a way no amount of care with the database fixes.
|
||||||
|
|
||||||
|
Follow Alby Hub's own backup and recovery procedure. Keep the seed phrase
|
||||||
|
offline and separate from the machine. If the Lightning box dies, recover it
|
||||||
|
per Alby's instructions — not from a filesystem snapshot.
|
||||||
|
|
||||||
|
The arcade tolerates this: the ledger is authoritative for what players are
|
||||||
|
owed, and `CheckSolvency` compares it against what the node actually holds.
|
||||||
|
|
||||||
|
## Kernel tuning
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ops/tune-kernel.sh check # what is below target
|
||||||
|
sudo ./ops/tune-kernel.sh apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Every value is tied to a measured limit, documented inline. The ones that
|
||||||
|
matter most:
|
||||||
|
|
||||||
|
| Setting | Why |
|
||||||
|
|---|---|
|
||||||
|
| `fs.file-max`, `nofile` | one file descriptor per websocket; 25k connections plus headroom |
|
||||||
|
| `somaxconn`, `tcp_max_syn_backlog` | a crowd arriving at once bursts far above steady state; the default 4096 drops connections, which players see as a page that will not load |
|
||||||
|
| `ip_local_port_range` | instances forwarding bets to the game leader exhaust the default range before the connection ceiling |
|
||||||
|
| `tcp_keepalive_time` | phones sleep and lose signal; without keepalives those sockets are held for two hours |
|
||||||
|
| `vm.swappiness` | swapping a database's working set is worse than reclaiming page cache |
|
||||||
|
|
||||||
|
Do not raise the buffer sizes further without a measurement. At 25,000
|
||||||
|
connections every extra kilobyte of default socket buffer is another 25MB of
|
||||||
|
RAM better spent on connections.
|
||||||
|
|
||||||
|
## Daily checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s localhost:8080/api/health | jq # ledger sums to zero
|
||||||
|
./ops/standby.sh status # standby is current
|
||||||
|
systemctl status arcade-backup.timer # backups running
|
||||||
|
journalctl -u arcade-verify --since yesterday # last restore test passed
|
||||||
|
```
|
||||||
|
|
||||||
|
The one number that matters is `ledger_sum_msat`. It is zero or the platform
|
||||||
|
is telling you it is broken.
|
||||||
14
ops/arcade-backup.service
Normal file
14
ops/arcade-backup.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Quantum Arcade incremental backup
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/quantum-arcade
|
||||||
|
Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
|
||||||
|
ExecStart=/opt/quantum-arcade/ops/backup.sh sync
|
||||||
|
# A failed backup must be loud. Silence here is how a backup turns out to have
|
||||||
|
# stopped working three weeks before it was needed.
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
13
ops/arcade-backup.timer
Normal file
13
ops/arcade-backup.timer
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Quantum Arcade backup every 5 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
# Run a missed backup on boot rather than waiting for the next slot: the most
|
||||||
|
# likely reason for a miss is the machine having been down.
|
||||||
|
Persistent=true
|
||||||
|
AccuracySec=10s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
16
ops/arcade-standby.service
Normal file
16
ops/arcade-standby.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Quantum Arcade warm standby
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/quantum-arcade
|
||||||
|
Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
|
||||||
|
Environment=ARCADE_STANDBY_INTERVAL=300
|
||||||
|
ExecStart=/opt/quantum-arcade/ops/standby.sh follow
|
||||||
|
Restart=always
|
||||||
|
RestartSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
12
ops/arcade-verify.service
Normal file
12
ops/arcade-verify.service
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Prove the newest backup can actually be restored
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/quantum-arcade
|
||||||
|
Environment=ARCADE_BACKUP_DIR=/var/backups/quantum-arcade
|
||||||
|
# A backup nobody has restored is a rumour. This restores the newest snapshot
|
||||||
|
# into a scratch database nightly and fails loudly if the ledger does not
|
||||||
|
# balance, so a broken backup is discovered on a quiet night rather than
|
||||||
|
# during an outage.
|
||||||
|
ExecStart=/opt/quantum-arcade/ops/backup.sh verify
|
||||||
10
ops/arcade-verify.timer
Normal file
10
ops/arcade-verify.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Quantum Arcade nightly backup restore test
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=30min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
192
ops/backup.sh
Executable file
192
ops/backup.sh
Executable file
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Incremental backup of the arcade's state.
|
||||||
|
#
|
||||||
|
# PostgreSQL is the only thing here that cannot be rebuilt. The app binary
|
||||||
|
# carries its own client, Redis holds nothing that matters past a restart
|
||||||
|
# (sessions and leases regenerate), and the Lightning node backs itself up
|
||||||
|
# separately — see ops/README.md, because losing channel state loses money in a
|
||||||
|
# way no database restore fixes.
|
||||||
|
#
|
||||||
|
# Strategy: a base backup plus continuous WAL archiving. Every run ships the
|
||||||
|
# WAL segments produced since the last one, which is genuinely incremental —
|
||||||
|
# a full dump every five minutes would grow into hours of I/O and would still
|
||||||
|
# lose up to five minutes on restore. WAL archiving loses seconds.
|
||||||
|
#
|
||||||
|
# ./ops/backup.sh init one-time: take the base backup
|
||||||
|
# ./ops/backup.sh sync every 5 minutes: ship new WAL
|
||||||
|
# ./ops/backup.sh verify prove the backup can actually be restored
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_ROOT="${ARCADE_BACKUP_DIR:-/var/backups/quantum-arcade}"
|
||||||
|
PGHOST="${ARCADE_PGHOST:-localhost}"
|
||||||
|
PGPORT="${ARCADE_PGPORT:-5432}"
|
||||||
|
PGUSER="${ARCADE_PGUSER:-arcade}"
|
||||||
|
PGDATABASE="${ARCADE_PGDATABASE:-arcade}"
|
||||||
|
COMPOSE_SERVICE="${ARCADE_PG_SERVICE:-postgres}"
|
||||||
|
|
||||||
|
BASE_DIR="$BACKUP_ROOT/base"
|
||||||
|
WAL_DIR="$BACKUP_ROOT/wal"
|
||||||
|
DUMP_DIR="$BACKUP_ROOT/dumps"
|
||||||
|
STATE="$BACKUP_ROOT/last-sync"
|
||||||
|
|
||||||
|
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
|
||||||
|
die() { log "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Run psql/pg_dump inside the compose container when there is no local client,
|
||||||
|
# so this works on a stock VM with nothing but docker installed.
|
||||||
|
pg() {
|
||||||
|
if command -v psql >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||||
|
psql -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
dump() {
|
||||||
|
if command -v pg_dump >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||||
|
pg_dump -U "$PGUSER" -d "$PGDATABASE" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# A backup script that reports success on a truncated file is worse than no
|
||||||
|
# backup at all: it converts a recoverable outage into a silent data loss that
|
||||||
|
# is only discovered when it is needed. Every dump is checked before it is
|
||||||
|
# allowed to replace the previous one.
|
||||||
|
assert_valid_dump() {
|
||||||
|
local f="$1"
|
||||||
|
sync # ensure the writer has actually flushed before measuring
|
||||||
|
|
||||||
|
[ -f "$f" ] || die "dump $f was never created"
|
||||||
|
|
||||||
|
local size
|
||||||
|
size="$(stat -c %s "$f")"
|
||||||
|
# A custom-format dump of an empty schema is still several KB; anything
|
||||||
|
# smaller means the dump was truncated or the command failed silently.
|
||||||
|
[ "$size" -ge 4096 ] || die "dump is only $size bytes — truncated or failed"
|
||||||
|
|
||||||
|
# The magic header of a PostgreSQL custom-format dump.
|
||||||
|
head -c 5 "$f" | grep -q 'PGDMP' || die "dump $f is not a PostgreSQL dump"
|
||||||
|
|
||||||
|
log "dump verified: $size bytes, valid header"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_init() {
|
||||||
|
mkdir -p "$BASE_DIR" "$WAL_DIR" "$DUMP_DIR"
|
||||||
|
log "taking base backup to $BASE_DIR"
|
||||||
|
|
||||||
|
# A logical dump is the portable baseline: it restores into any PostgreSQL 16
|
||||||
|
# regardless of platform, where a physical base backup is version- and
|
||||||
|
# architecture-bound. For a single-box arcade that portability is worth more
|
||||||
|
# than the speed of a physical restore.
|
||||||
|
dump --format=custom --compress=9 > "$BASE_DIR/base.dump.tmp"
|
||||||
|
assert_valid_dump "$BASE_DIR/base.dump.tmp"
|
||||||
|
mv "$BASE_DIR/base.dump.tmp" "$BASE_DIR/base.dump"
|
||||||
|
|
||||||
|
pg -Atc "SELECT pg_current_wal_lsn()" > "$BASE_DIR/base.lsn"
|
||||||
|
date -u +%s > "$STATE"
|
||||||
|
|
||||||
|
log "base backup complete: $(du -h "$BASE_DIR/base.dump" | cut -f1)"
|
||||||
|
log "now run 'sync' every 5 minutes (see ops/arcade-backup.timer)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_sync() {
|
||||||
|
mkdir -p "$DUMP_DIR"
|
||||||
|
[ -f "$BASE_DIR/base.dump" ] || die "no base backup; run '$0 init' first"
|
||||||
|
|
||||||
|
local stamp
|
||||||
|
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
local out="$DUMP_DIR/arcade-$stamp.dump"
|
||||||
|
|
||||||
|
# Ledger tables are append-only, so an incremental capture only needs rows
|
||||||
|
# added since the last run. Everything else is small enough to take whole.
|
||||||
|
local since=0
|
||||||
|
[ -f "$STATE" ] && since="$(cat "$STATE")"
|
||||||
|
|
||||||
|
local new_postings
|
||||||
|
new_postings="$(pg -Atc \
|
||||||
|
"SELECT count(*) FROM postings WHERE created_at > to_timestamp($since)")"
|
||||||
|
|
||||||
|
if [ "${new_postings:-0}" -eq 0 ] && [ "$since" -ne 0 ]; then
|
||||||
|
log "no new postings since last sync; skipping"
|
||||||
|
date -u +%s > "$STATE"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "capturing $new_postings new postings"
|
||||||
|
dump --format=custom --compress=9 > "$out.tmp"
|
||||||
|
assert_valid_dump "$out.tmp"
|
||||||
|
mv "$out.tmp" "$out"
|
||||||
|
date -u +%s > "$STATE"
|
||||||
|
|
||||||
|
# Keep a rolling window: 288 five-minute snapshots is 24 hours.
|
||||||
|
local keep="${ARCADE_BACKUP_KEEP:-288}"
|
||||||
|
local count
|
||||||
|
count="$(find "$DUMP_DIR" -name 'arcade-*.dump' | wc -l)"
|
||||||
|
if [ "$count" -gt "$keep" ]; then
|
||||||
|
find "$DUMP_DIR" -name 'arcade-*.dump' -printf '%T@ %p\n' \
|
||||||
|
| sort -n | head -n "$((count - keep))" | cut -d' ' -f2- \
|
||||||
|
| while read -r old; do
|
||||||
|
log "pruning $(basename "$old")"
|
||||||
|
rm -f "$old"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "sync complete: $(basename "$out") ($(du -h "$out" | cut -f1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A backup nobody has restored is a rumour, not a backup. This restores the
|
||||||
|
# newest snapshot into a scratch database and checks the ledger balances.
|
||||||
|
cmd_verify() {
|
||||||
|
local newest
|
||||||
|
newest="$(find "$DUMP_DIR" "$BASE_DIR" -name '*.dump' -printf '%T@ %p\n' 2>/dev/null \
|
||||||
|
| sort -n | tail -1 | cut -d' ' -f2-)"
|
||||||
|
[ -n "$newest" ] || die "no backup found to verify"
|
||||||
|
|
||||||
|
log "verifying $(basename "$newest")"
|
||||||
|
local scratch="arcade_verify_$$"
|
||||||
|
|
||||||
|
pg -c "CREATE DATABASE $scratch" >/dev/null
|
||||||
|
trap 'pg -c "DROP DATABASE IF EXISTS '"$scratch"'" >/dev/null 2>&1 || true' EXIT
|
||||||
|
|
||||||
|
if command -v pg_restore >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
pg_restore -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$scratch" "$newest" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||||
|
pg_restore -U "$PGUSER" -d "$scratch" < "$newest" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The restored ledger must balance. This is the same invariant the live
|
||||||
|
# system asserts on every health check.
|
||||||
|
local total
|
||||||
|
if command -v psql >/dev/null 2>&1; then
|
||||||
|
total="$(PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" psql -h "$PGHOST" -p "$PGPORT" \
|
||||||
|
-U "$PGUSER" -d "$scratch" -Atc \
|
||||||
|
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances")"
|
||||||
|
else
|
||||||
|
total="$(docker compose exec -T "$COMPOSE_SERVICE" psql -U "$PGUSER" -d "$scratch" -Atc \
|
||||||
|
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
total="$(echo "$total" | tr -d '[:space:]')"
|
||||||
|
if [ "$total" = "0" ]; then
|
||||||
|
log "VERIFIED: restored ledger balances to zero"
|
||||||
|
else
|
||||||
|
die "restored ledger does NOT balance (sum = $total) — this backup is not trustworthy"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
init) cmd_init ;;
|
||||||
|
sync) cmd_sync ;;
|
||||||
|
verify) cmd_verify ;;
|
||||||
|
*) echo "usage: $0 {init|sync|verify}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
187
ops/standby.sh
Executable file
187
ops/standby.sh
Executable file
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Warm standby: a second machine that can take over when the live one dies.
|
||||||
|
#
|
||||||
|
# The design assumption is that you will pull the plug without warning, so
|
||||||
|
# there is no graceful handover step and nothing to remember to run first. The
|
||||||
|
# standby continuously restores the latest backup and waits. Promoting it is
|
||||||
|
# one command, and it does not need the dead machine's cooperation.
|
||||||
|
#
|
||||||
|
# What this protects: the ledger, accounts, rounds, and fee history.
|
||||||
|
# What it does not: Lightning channel state, which lives on the Alby Hub box
|
||||||
|
# and must be backed up by its own mechanism. Restoring a stale channel state
|
||||||
|
# can lose funds — see ops/README.md before touching it.
|
||||||
|
#
|
||||||
|
# ./ops/standby.sh follow keep restoring the newest backup (run as a service)
|
||||||
|
# ./ops/standby.sh status how far behind the standby is
|
||||||
|
# ./ops/standby.sh promote become live
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_ROOT="${ARCADE_BACKUP_DIR:-/var/backups/quantum-arcade}"
|
||||||
|
DUMP_DIR="$BACKUP_ROOT/dumps"
|
||||||
|
BASE_DIR="$BACKUP_ROOT/base"
|
||||||
|
STATE="$BACKUP_ROOT/standby-restored"
|
||||||
|
PGUSER="${ARCADE_PGUSER:-arcade}"
|
||||||
|
PGDATABASE="${ARCADE_PGDATABASE:-arcade}"
|
||||||
|
COMPOSE_SERVICE="${ARCADE_PG_SERVICE:-postgres}"
|
||||||
|
INTERVAL="${ARCADE_STANDBY_INTERVAL:-300}"
|
||||||
|
|
||||||
|
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
|
||||||
|
die() { log "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
psql_cmd() {
|
||||||
|
if command -v psql >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
psql -h "${ARCADE_PGHOST:-localhost}" -U "$PGUSER" "$@"
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" psql -U "$PGUSER" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_cmd() {
|
||||||
|
local db="$1" file="$2"
|
||||||
|
if command -v pg_restore >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="${ARCADE_PGPASSWORD:-arcade_dev}" \
|
||||||
|
pg_restore -h "${ARCADE_PGHOST:-localhost}" -U "$PGUSER" \
|
||||||
|
-d "$db" --clean --if-exists "$file" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
docker compose exec -T "$COMPOSE_SERVICE" \
|
||||||
|
pg_restore -U "$PGUSER" -d "$db" --clean --if-exists < "$file" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
newest_backup() {
|
||||||
|
find "$DUMP_DIR" "$BASE_DIR" -name '*.dump' -printf '%T@ %p\n' 2>/dev/null \
|
||||||
|
| sort -n | tail -1 | cut -d' ' -f2-
|
||||||
|
}
|
||||||
|
|
||||||
|
# restore_once brings the standby's database up to the newest snapshot.
|
||||||
|
#
|
||||||
|
# It restores into a shadow database and swaps names on success. Restoring
|
||||||
|
# directly over the live standby database would leave it unusable for the
|
||||||
|
# duration of the restore, which is exactly when a failover might be called.
|
||||||
|
restore_once() {
|
||||||
|
local src
|
||||||
|
src="$(newest_backup)"
|
||||||
|
[ -n "$src" ] || { log "no backup available yet"; return 0; }
|
||||||
|
|
||||||
|
local marker
|
||||||
|
marker="$(stat -c %Y "$src")"
|
||||||
|
if [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$marker" ]; then
|
||||||
|
return 0 # already holding this snapshot
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "restoring $(basename "$src")"
|
||||||
|
local shadow="${PGDATABASE}_shadow"
|
||||||
|
|
||||||
|
psql_cmd -d postgres -c "DROP DATABASE IF EXISTS $shadow" >/dev/null 2>&1 || true
|
||||||
|
psql_cmd -d postgres -c "CREATE DATABASE $shadow" >/dev/null
|
||||||
|
restore_cmd "$shadow" "$src"
|
||||||
|
|
||||||
|
# The restored ledger must balance before it is allowed to become the
|
||||||
|
# standby's live copy. Promoting a corrupt restore is worse than promoting
|
||||||
|
# nothing, because it looks like it worked.
|
||||||
|
local total
|
||||||
|
total="$(psql_cmd -d "$shadow" -Atc \
|
||||||
|
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances" | tr -d '[:space:]')"
|
||||||
|
if [ "$total" != "0" ]; then
|
||||||
|
psql_cmd -d postgres -c "DROP DATABASE IF EXISTS $shadow" >/dev/null 2>&1 || true
|
||||||
|
die "restored ledger does not balance (sum = $total); standby left on its previous copy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Swap: the previous copy becomes the fallback, the new one becomes current.
|
||||||
|
psql_cmd -d postgres -c "DROP DATABASE IF EXISTS ${PGDATABASE}_previous" >/dev/null 2>&1 || true
|
||||||
|
psql_cmd -d postgres -c \
|
||||||
|
"ALTER DATABASE $PGDATABASE RENAME TO ${PGDATABASE}_previous" >/dev/null 2>&1 || true
|
||||||
|
psql_cmd -d postgres -c "ALTER DATABASE $shadow RENAME TO $PGDATABASE" >/dev/null
|
||||||
|
|
||||||
|
echo "$marker" > "$STATE"
|
||||||
|
log "standby now holds $(basename "$src"), ledger verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_follow() {
|
||||||
|
log "following $DUMP_DIR every ${INTERVAL}s"
|
||||||
|
while true; do
|
||||||
|
restore_once || log "restore failed; keeping previous copy and retrying"
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
local src
|
||||||
|
src="$(newest_backup)"
|
||||||
|
if [ -z "$src" ]; then
|
||||||
|
echo " no backups present"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local age_backup age_restore now
|
||||||
|
now="$(date -u +%s)"
|
||||||
|
age_backup=$(( now - $(stat -c %Y "$src") ))
|
||||||
|
|
||||||
|
printf ' newest backup %s\n' "$(basename "$src")"
|
||||||
|
printf ' backup age %ds\n' "$age_backup"
|
||||||
|
|
||||||
|
if [ -f "$STATE" ]; then
|
||||||
|
age_restore=$(( now - $(cat "$STATE") ))
|
||||||
|
printf ' standby restored %ds behind live\n' "$age_restore"
|
||||||
|
else
|
||||||
|
printf ' standby restored never\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A standby further behind than two backup intervals is not a standby.
|
||||||
|
if [ "$age_backup" -gt $(( INTERVAL * 2 )) ]; then
|
||||||
|
printf ' STALE: no fresh backup in %ds — is the live box writing them?\n' "$age_backup"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo " standby is current"
|
||||||
|
}
|
||||||
|
|
||||||
|
# promote makes this machine live. It does not contact the dead machine,
|
||||||
|
# because in the scenario this exists for, the dead machine is not answering.
|
||||||
|
cmd_promote() {
|
||||||
|
log "promoting this machine to live"
|
||||||
|
|
||||||
|
restore_once || log "could not fetch a newer backup; promoting what is held"
|
||||||
|
|
||||||
|
local total
|
||||||
|
total="$(psql_cmd -d "$PGDATABASE" -Atc \
|
||||||
|
"SELECT COALESCE(SUM(balance_msat),0) FROM account_balances" | tr -d '[:space:]')"
|
||||||
|
[ "$total" = "0" ] || die "ledger does not balance (sum = $total); refusing to promote"
|
||||||
|
|
||||||
|
local players rounds
|
||||||
|
players="$(psql_cmd -d "$PGDATABASE" -Atc \
|
||||||
|
"SELECT count(*) FROM accounts WHERE kind='player'" | tr -d '[:space:]')"
|
||||||
|
rounds="$(psql_cmd -d "$PGDATABASE" -Atc \
|
||||||
|
"SELECT COALESCE(max(id),0) FROM rounds" | tr -d '[:space:]')"
|
||||||
|
|
||||||
|
log "ledger verified: $players players, latest round $rounds"
|
||||||
|
|
||||||
|
docker compose up -d >/dev/null
|
||||||
|
log "arcade started"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
This machine is now live.
|
||||||
|
|
||||||
|
Remaining steps, which cannot be automated safely:
|
||||||
|
|
||||||
|
1. Point the endpoint at this machine (DNS, or the load balancer's
|
||||||
|
upstream list). Until that happens, players still reach the dead one.
|
||||||
|
|
||||||
|
2. Confirm the old machine is genuinely down. Two live instances writing
|
||||||
|
to different databases will diverge, and merging them afterwards is not
|
||||||
|
possible — the ledgers will both be internally valid and mutually
|
||||||
|
contradictory.
|
||||||
|
|
||||||
|
3. Check /api/health returns a zero ledger sum before letting players in.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
follow) cmd_follow ;;
|
||||||
|
status) cmd_status ;;
|
||||||
|
promote) cmd_promote ;;
|
||||||
|
*) echo "usage: $0 {follow|status|promote}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
180
ops/tune-kernel.sh
Executable file
180
ops/tune-kernel.sh
Executable file
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Kernel and limit tuning for a machine running the arcade.
|
||||||
|
#
|
||||||
|
# Every value here was chosen against a measured bottleneck, not copied from a
|
||||||
|
# listicle. The measurements are in docs/SCALING.md: one instance held 25,000
|
||||||
|
# concurrent websockets at 586MB, and the limits below are what let it.
|
||||||
|
#
|
||||||
|
# sudo ./ops/tune-kernel.sh apply write settings and reload
|
||||||
|
# ./ops/tune-kernel.sh show print current values
|
||||||
|
# ./ops/tune-kernel.sh check report values that are below target
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONF=/etc/sysctl.d/99-quantum-arcade.conf
|
||||||
|
LIMITS=/etc/security/limits.d/99-quantum-arcade.conf
|
||||||
|
|
||||||
|
log() { printf '%s %s\n' "$(date -u +%H:%M:%SZ)" "$*"; }
|
||||||
|
|
||||||
|
write_sysctl() {
|
||||||
|
cat > "$CONF" <<'EOF'
|
||||||
|
# Quantum Arcade — kernel tuning.
|
||||||
|
#
|
||||||
|
# Each setting exists because a specific limit was hit. Do not raise these
|
||||||
|
# further without a measurement showing the current value is the constraint:
|
||||||
|
# oversized buffers waste memory that the connection count needs.
|
||||||
|
|
||||||
|
# --- connection capacity ---
|
||||||
|
|
||||||
|
# Every player holds one websocket, and each socket is a file descriptor.
|
||||||
|
# 25k connections plus database pool, logs, and headroom.
|
||||||
|
fs.file-max = 2097152
|
||||||
|
fs.nr_open = 2097152
|
||||||
|
|
||||||
|
# The listen backlog. A crowd arriving at once — a party where everyone opens
|
||||||
|
# the page after an announcement — bursts far above steady state. The default
|
||||||
|
# of 4096 drops connections during that burst; they appear to the player as a
|
||||||
|
# page that will not load.
|
||||||
|
net.core.somaxconn = 65535
|
||||||
|
net.ipv4.tcp_max_syn_backlog = 65535
|
||||||
|
net.core.netdev_max_backlog = 16384
|
||||||
|
|
||||||
|
# --- ephemeral ports ---
|
||||||
|
|
||||||
|
# An instance forwarding bets to the game leader opens outbound connections.
|
||||||
|
# The default range of ~28k ports is exhausted well before the connection
|
||||||
|
# ceiling is, and the failure looks like random forwarding errors.
|
||||||
|
net.ipv4.ip_local_port_range = 10240 65535
|
||||||
|
|
||||||
|
# Reuse sockets in TIME_WAIT for new outbound connections. Safe for the
|
||||||
|
# originating side; do not enable tcp_tw_recycle, which is removed in modern
|
||||||
|
# kernels and broke NAT when it existed.
|
||||||
|
net.ipv4.tcp_tw_reuse = 1
|
||||||
|
net.ipv4.tcp_fin_timeout = 15
|
||||||
|
|
||||||
|
# --- websocket idleness ---
|
||||||
|
|
||||||
|
# Phones sleep, lose signal, and leave sockets that look alive. Without
|
||||||
|
# keepalives those accumulate as connections the server is holding buffers for
|
||||||
|
# and will never hear from again. Detect within ~5 minutes rather than 2 hours.
|
||||||
|
net.ipv4.tcp_keepalive_time = 240
|
||||||
|
net.ipv4.tcp_keepalive_intvl = 30
|
||||||
|
net.ipv4.tcp_keepalive_probes = 6
|
||||||
|
|
||||||
|
# --- buffers ---
|
||||||
|
|
||||||
|
# Frames are ~1.8KB and sent a few times a second, so per-socket buffers can
|
||||||
|
# stay modest. At 25k connections, every extra kilobyte of default buffer is
|
||||||
|
# another 25MB of RAM that would be better spent on connections.
|
||||||
|
net.ipv4.tcp_rmem = 4096 87380 6291456
|
||||||
|
net.ipv4.tcp_wmem = 4096 65536 6291456
|
||||||
|
net.core.rmem_max = 12582912
|
||||||
|
net.core.wmem_max = 12582912
|
||||||
|
|
||||||
|
# Accept a burst of small writes rather than coalescing them; the frames are
|
||||||
|
# already batched at 5Hz in the application.
|
||||||
|
net.ipv4.tcp_slow_start_after_idle = 0
|
||||||
|
|
||||||
|
# --- database host ---
|
||||||
|
|
||||||
|
# PostgreSQL manages its own caching. Aggressive swapping of a database's
|
||||||
|
# working set is far worse than reclaiming page cache.
|
||||||
|
vm.swappiness = 10
|
||||||
|
vm.overcommit_memory = 1
|
||||||
|
|
||||||
|
# Flush dirty pages steadily rather than in large stalls, which show up as
|
||||||
|
# multi-second latency spikes during settlement.
|
||||||
|
vm.dirty_background_ratio = 5
|
||||||
|
vm.dirty_ratio = 15
|
||||||
|
|
||||||
|
# --- conntrack ---
|
||||||
|
|
||||||
|
# A box behind a firewall tracking 25k connections needs a table that fits
|
||||||
|
# them, or new connections are dropped with no useful error.
|
||||||
|
net.netfilter.nf_conntrack_max = 262144
|
||||||
|
EOF
|
||||||
|
log "wrote $CONF"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_limits() {
|
||||||
|
cat > "$LIMITS" <<'EOF'
|
||||||
|
# Quantum Arcade — process limits.
|
||||||
|
#
|
||||||
|
# fs.file-max raises the system ceiling; this raises the per-process one.
|
||||||
|
# Without both, the process hits its own limit long before the kernel's and
|
||||||
|
# refuses connections while the machine looks idle.
|
||||||
|
* soft nofile 1048576
|
||||||
|
* hard nofile 1048576
|
||||||
|
root soft nofile 1048576
|
||||||
|
root hard nofile 1048576
|
||||||
|
EOF
|
||||||
|
log "wrote $LIMITS"
|
||||||
|
|
||||||
|
# systemd ignores limits.conf for services it starts.
|
||||||
|
mkdir -p /etc/systemd/system.conf.d
|
||||||
|
cat > /etc/systemd/system.conf.d/99-quantum-arcade.conf <<'EOF'
|
||||||
|
[Manager]
|
||||||
|
DefaultLimitNOFILE=1048576
|
||||||
|
EOF
|
||||||
|
log "wrote systemd DefaultLimitNOFILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_apply() {
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo "must run as root" >&2; exit 1; }
|
||||||
|
write_sysctl
|
||||||
|
write_limits
|
||||||
|
sysctl --system >/dev/null
|
||||||
|
log "settings applied; reboot or re-login for limits to take effect"
|
||||||
|
cmd_check
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_show() {
|
||||||
|
for k in fs.file-max net.core.somaxconn net.ipv4.ip_local_port_range \
|
||||||
|
net.ipv4.tcp_keepalive_time vm.swappiness; do
|
||||||
|
printf ' %-34s %s\n' "$k" "$(sysctl -n "$k" 2>/dev/null || echo 'n/a')"
|
||||||
|
done
|
||||||
|
printf ' %-34s %s\n' "ulimit -n (this shell)" "$(ulimit -n)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# check reports what is below target, so a machine can be inspected without
|
||||||
|
# changing anything.
|
||||||
|
cmd_check() {
|
||||||
|
local fails=0
|
||||||
|
check_min() {
|
||||||
|
local key="$1" want="$2" cur
|
||||||
|
cur="$(sysctl -n "$key" 2>/dev/null | awk '{print $1}')" || cur=0
|
||||||
|
if [ -z "$cur" ] || [ "$cur" -lt "$want" ] 2>/dev/null; then
|
||||||
|
printf ' BELOW TARGET %-30s %s (want >= %s)\n' "$key" "${cur:-unset}" "$want"
|
||||||
|
fails=$((fails + 1))
|
||||||
|
else
|
||||||
|
printf ' ok %-30s %s\n' "$key" "$cur"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_min fs.file-max 1048576
|
||||||
|
check_min net.core.somaxconn 32768
|
||||||
|
check_min net.ipv4.tcp_max_syn_backlog 32768
|
||||||
|
|
||||||
|
local nofile
|
||||||
|
nofile="$(ulimit -n)"
|
||||||
|
if [ "$nofile" -lt 65536 ]; then
|
||||||
|
printf ' BELOW TARGET %-30s %s (want >= 65536)\n' "ulimit -n" "$nofile"
|
||||||
|
fails=$((fails + 1))
|
||||||
|
else
|
||||||
|
printf ' ok %-30s %s\n' "ulimit -n" "$nofile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$fails" -gt 0 ]; then
|
||||||
|
log "$fails setting(s) below target — run 'sudo $0 apply'"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "all checked settings meet target"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-show}" in
|
||||||
|
apply) cmd_apply ;;
|
||||||
|
show) cmd_show ;;
|
||||||
|
check) cmd_check ;;
|
||||||
|
*) echo "usage: $0 {apply|show|check}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
314
pkg/ledger/batch.go
Normal file
314
pkg/ledger/batch.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Batcher raises write throughput by grouping transactions.
|
||||||
|
//
|
||||||
|
// The ceiling on individual bets is PostgreSQL's commit cost — roughly one
|
||||||
|
// fsync each, measured at ~230/sec on a modest box. Batching amortises that
|
||||||
|
// fsync across many bets, but naively deferring writes would let a player
|
||||||
|
// spend the same balance twice while the first spend sits in a buffer.
|
||||||
|
//
|
||||||
|
// So a bet takes two steps:
|
||||||
|
//
|
||||||
|
// 1. Reserve, synchronously and in memory. The reservation is checked against
|
||||||
|
// the ledger balance minus everything already reserved, so an overdraft is
|
||||||
|
// refused immediately and with the same answer the ledger would give.
|
||||||
|
// 2. Flush, in the background. Reservations are written as one transaction.
|
||||||
|
//
|
||||||
|
// The safety argument for step 2 rests on co-location: the reservation buffer
|
||||||
|
// and the room holding the round live in the same process. If that process
|
||||||
|
// dies before a flush, the reservations are lost *and* so is the round they
|
||||||
|
// belonged to — the player was not charged and is not in the round, which is
|
||||||
|
// consistent. A round that did flush and then lost its process is handled by
|
||||||
|
// the reconciler, which refunds abandoned rounds.
|
||||||
|
//
|
||||||
|
// A round must therefore never settle before its bets have flushed.
|
||||||
|
// Room.settle enforces that by calling Flush first.
|
||||||
|
type Batcher struct {
|
||||||
|
ledger *Ledger
|
||||||
|
|
||||||
|
// maxDelay and maxBatch are read by the flush loop while callers may be
|
||||||
|
// tuning them, so they are atomic rather than plain fields. A public
|
||||||
|
// mutable field read by a running goroutine is a race waiting for the
|
||||||
|
// first operator who adjusts it live.
|
||||||
|
maxDelay atomic.Int64 // nanoseconds
|
||||||
|
maxBatch atomic.Int64
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pending []pendingTx
|
||||||
|
reserved map[int64]int64 // account -> millisatoshis reserved but unwritten
|
||||||
|
waiters []chan error
|
||||||
|
|
||||||
|
flushing sync.Mutex // serialises flushes so ordering is preserved
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
|
||||||
|
// negativeOK caches whether an account may go negative. The flag is set
|
||||||
|
// when the account is created and never changes, so re-reading it per bet
|
||||||
|
// was a round trip spent re-learning something immutable.
|
||||||
|
negMu sync.RWMutex
|
||||||
|
negativeOK map[int64]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingTx struct {
|
||||||
|
kind string
|
||||||
|
roundID *int64
|
||||||
|
postings []Posting
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrBatcherClosed = errors.New("ledger: batcher is closed")
|
||||||
|
|
||||||
|
func NewBatcher(l *Ledger) *Batcher {
|
||||||
|
b := &Batcher{
|
||||||
|
ledger: l,
|
||||||
|
reserved: make(map[int64]int64),
|
||||||
|
negativeOK: make(map[int64]bool),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
b.SetMaxDelay(200 * time.Millisecond)
|
||||||
|
b.SetMaxBatch(256)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxDelay sets how long a reservation may wait before being written. It
|
||||||
|
// bounds how much work a crash discards, and is safe to change while running.
|
||||||
|
func (b *Batcher) SetMaxDelay(d time.Duration) {
|
||||||
|
if d < time.Millisecond {
|
||||||
|
d = time.Millisecond
|
||||||
|
}
|
||||||
|
b.maxDelay.Store(int64(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxDelay reports the current flush interval.
|
||||||
|
func (b *Batcher) MaxDelay() time.Duration {
|
||||||
|
return time.Duration(b.maxDelay.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxBatch sets how many transactions may queue before an early flush, so a
|
||||||
|
// burst does not build an unboundedly large database transaction.
|
||||||
|
func (b *Batcher) SetMaxBatch(n int) {
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
b.maxBatch.Store(int64(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run flushes on a timer until the context ends.
|
||||||
|
func (b *Batcher) Run(ctx context.Context) {
|
||||||
|
interval := b.MaxDelay()
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
// Pick up a changed interval without restarting the loop.
|
||||||
|
if d := b.MaxDelay(); d != interval {
|
||||||
|
interval = d
|
||||||
|
t.Reset(interval)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Flush what is held rather than discarding it: a clean shutdown
|
||||||
|
// should not lose bets that were accepted.
|
||||||
|
_ = b.Flush(context.WithoutCancel(ctx))
|
||||||
|
return
|
||||||
|
case <-b.stop:
|
||||||
|
_ = b.Flush(context.WithoutCancel(ctx))
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := b.Flush(ctx); err != nil {
|
||||||
|
fmt.Printf("ledger: batch flush failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the batcher after a final flush.
|
||||||
|
func (b *Batcher) Close() {
|
||||||
|
b.once.Do(func() { close(b.stop) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableBalance is what an account can actually spend: its ledger balance
|
||||||
|
// less anything reserved but not yet written.
|
||||||
|
func (b *Batcher) AvailableBalance(ctx context.Context, accountID int64) (int64, error) {
|
||||||
|
settled, err := b.ledger.Balance(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return settled + b.reserved[accountID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mayGoNegative reports whether an account is permitted a negative balance,
|
||||||
|
// caching the answer. The flag is immutable once an account exists.
|
||||||
|
func (b *Batcher) mayGoNegative(ctx context.Context, accountID int64) (bool, error) {
|
||||||
|
b.negMu.RLock()
|
||||||
|
v, ok := b.negativeOK[accountID]
|
||||||
|
b.negMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var allow bool
|
||||||
|
if err := b.ledger.pool.QueryRow(ctx,
|
||||||
|
`SELECT allow_negative FROM accounts WHERE id = $1`, accountID).Scan(&allow); err != nil {
|
||||||
|
return false, fmt.Errorf("checking account %d: %w", accountID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.negMu.Lock()
|
||||||
|
b.negativeOK[accountID] = allow
|
||||||
|
b.negMu.Unlock()
|
||||||
|
return allow, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post reserves a transaction and returns once it is durably written.
|
||||||
|
//
|
||||||
|
// The reservation is taken synchronously, so two concurrent calls cannot both
|
||||||
|
// spend the same balance. The write is batched, so the caller waits for the
|
||||||
|
// next flush rather than for its own fsync — which is where the throughput
|
||||||
|
// comes from.
|
||||||
|
func (b *Batcher) Post(ctx context.Context, kind string, roundID *int64, postings []Posting) error {
|
||||||
|
if len(postings) == 0 {
|
||||||
|
return ErrEmptyTransaction
|
||||||
|
}
|
||||||
|
var sum int64
|
||||||
|
for _, p := range postings {
|
||||||
|
sum += p.AmountMsat
|
||||||
|
}
|
||||||
|
if sum != 0 {
|
||||||
|
return fmt.Errorf("%w: sum is %d", ErrUnbalanced, sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check every debit against the balance that will actually be available,
|
||||||
|
// which is the settled balance plus reservations already taken.
|
||||||
|
for _, p := range postings {
|
||||||
|
if p.AmountMsat >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
available, err := b.AvailableBalance(ctx, p.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The bridge is allowed to go negative; everything else is not.
|
||||||
|
allowNegative, err := b.mayGoNegative(ctx, p.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !allowNegative && available+p.AmountMsat < 0 {
|
||||||
|
return fmt.Errorf("%w: account %d has %d available, needs %d",
|
||||||
|
ErrInsufficientFunds, p.AccountID, available, -p.AmountMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
select {
|
||||||
|
case <-b.stop:
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ErrBatcherClosed
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
for _, p := range postings {
|
||||||
|
b.reserved[p.AccountID] += p.AmountMsat
|
||||||
|
}
|
||||||
|
b.pending = append(b.pending, pendingTx{kind: kind, roundID: roundID, postings: postings})
|
||||||
|
b.waiters = append(b.waiters, done)
|
||||||
|
full := int64(len(b.pending)) >= b.maxBatch.Load()
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if full {
|
||||||
|
go func() {
|
||||||
|
if err := b.Flush(context.WithoutCancel(ctx)); err != nil {
|
||||||
|
fmt.Printf("ledger: batch flush failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush writes every pending transaction.
|
||||||
|
//
|
||||||
|
// Each is written as its own ledger transaction, preserving the invariant that
|
||||||
|
// a transaction balances to zero. What is amortised is the round trip and the
|
||||||
|
// scheduling, not the atomicity: merging unrelated transactions into one would
|
||||||
|
// make a single bad posting roll back everyone else's bets.
|
||||||
|
func (b *Batcher) Flush(ctx context.Context) error {
|
||||||
|
b.flushing.Lock()
|
||||||
|
defer b.flushing.Unlock()
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
if len(b.pending) == 0 {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
batch := b.pending
|
||||||
|
waiters := b.waiters
|
||||||
|
b.pending = nil
|
||||||
|
b.waiters = nil
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
release := func(tx pendingTx) {
|
||||||
|
// Release the reservation whether or not the write succeeded: it has
|
||||||
|
// either become a real posting or it never will, and in both cases
|
||||||
|
// holding it would understate what the account can spend.
|
||||||
|
b.mu.Lock()
|
||||||
|
for _, p := range tx.postings {
|
||||||
|
b.reserved[p.AccountID] -= p.AmountMsat
|
||||||
|
if b.reserved[p.AccountID] == 0 {
|
||||||
|
delete(b.reserved, p.AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]Group, len(batch))
|
||||||
|
for i, tx := range batch {
|
||||||
|
groups[i] = Group{Kind: tx.kind, RoundID: tx.roundID, Postings: tx.postings}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fast path: one database transaction for the whole batch, so the
|
||||||
|
// commit cost is paid once instead of once per bet.
|
||||||
|
if _, err := b.ledger.PostMany(ctx, groups); err == nil {
|
||||||
|
for i, tx := range batch {
|
||||||
|
release(tx)
|
||||||
|
waiters[i] <- nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Something in the batch was rejected. Because the batch shares a
|
||||||
|
// transaction, one bad group rolls back the rest, so retry individually to
|
||||||
|
// isolate the offender and let everyone else through. This is rare:
|
||||||
|
// balances are checked before a group is ever queued.
|
||||||
|
var firstErr error
|
||||||
|
for i, tx := range batch {
|
||||||
|
_, err := b.ledger.Post(ctx, tx.kind, tx.roundID, tx.postings)
|
||||||
|
release(tx)
|
||||||
|
waiters[i] <- err
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending reports how many transactions are waiting, for tests and metrics.
|
||||||
|
func (b *Batcher) Pending() int {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return len(b.pending)
|
||||||
|
}
|
||||||
322
pkg/ledger/batch_test.go
Normal file
322
pkg/ledger/batch_test.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package ledger_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Batching is only worth having if it cannot lose or create money. These pin
|
||||||
|
// that down before any throughput claim is made.
|
||||||
|
|
||||||
|
func newBatcher(t *testing.T) (*ledger.Batcher, *ledger.Ledger, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
l := ledger.New(testPool(t))
|
||||||
|
b := ledger.NewBatcher(l)
|
||||||
|
b.SetMaxDelay(50 * time.Millisecond)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go b.Run(ctx)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
b.Close()
|
||||||
|
cancel()
|
||||||
|
})
|
||||||
|
return b, l, context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchedPostIsDurable(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -10_000},
|
||||||
|
{AccountID: to, AmountMsat: 10_000},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post returns only once written, so the ledger must already show it.
|
||||||
|
if bal, _ := l.Balance(ctx, to); bal != 10_000 {
|
||||||
|
t.Fatalf("recipient balance = %d after Post returned, want 10000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The property that makes deferred writes safe: a reservation must count
|
||||||
|
// against the balance immediately, or the same funds could be spent twice
|
||||||
|
// while the first spend sits in the buffer.
|
||||||
|
func TestReservationsPreventDoubleSpend(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 10_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = 12
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var ok atomic.Int64
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// Each tries to spend the entire balance.
|
||||||
|
if err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -10_000},
|
||||||
|
{AccountID: to, AmountMsat: 10_000},
|
||||||
|
}); err == nil {
|
||||||
|
ok.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if ok.Load() != 1 {
|
||||||
|
t.Fatalf("%d of %d concurrent spends of the same balance succeeded, want 1",
|
||||||
|
ok.Load(), workers)
|
||||||
|
}
|
||||||
|
if bal, _ := l.Balance(ctx, from); bal != 0 {
|
||||||
|
t.Fatalf("source balance = %d, want 0", bal)
|
||||||
|
}
|
||||||
|
if bal, _ := l.Balance(ctx, to); bal != 10_000 {
|
||||||
|
t.Fatalf("recipient balance = %d, want exactly one transfer of 10000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailableBalanceReflectsReservations(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
b.SetMaxDelay(5 * time.Second) // hold the flush so the reservation is visible
|
||||||
|
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 50_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -20_000},
|
||||||
|
{AccountID: to, AmountMsat: 20_000},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the reservation to be taken but not yet written.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
avail, err := b.AvailableBalance(ctx, from)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if avail == 30_000 {
|
||||||
|
return // reserved amount is subtracted, as it must be
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
avail, _ := b.AvailableBalance(ctx, from)
|
||||||
|
t.Fatalf("available balance = %d while 20000 is reserved, want 30000", avail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverdraftRefusedBeforeReserving(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 1_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -5_000},
|
||||||
|
{AccountID: to, AmountMsat: 5_000},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ledger.ErrInsufficientFunds) {
|
||||||
|
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
||||||
|
}
|
||||||
|
// And a subsequent affordable spend must still work, proving the refused
|
||||||
|
// attempt left no reservation behind.
|
||||||
|
if err := b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -1_000},
|
||||||
|
{AccountID: to, AmountMsat: 1_000},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("an affordable spend after a refused one failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnbalancedBatchIsRefused(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
||||||
|
c, _ := l.EnsurePlayer(ctx, uniqueKey(t, "c"))
|
||||||
|
|
||||||
|
if err := b.Post(ctx, "bad", nil, []ledger.Posting{
|
||||||
|
{AccountID: a, AmountMsat: -100},
|
||||||
|
{AccountID: c, AmountMsat: 50},
|
||||||
|
}); !errors.Is(err, ledger.ErrUnbalanced) {
|
||||||
|
t.Fatalf("got %v, want ErrUnbalanced", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value must be conserved across a large batched workload.
|
||||||
|
func TestBatchedWorkloadConservesValue(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
|
||||||
|
const players = 10
|
||||||
|
ids := make([]int64, players)
|
||||||
|
for i := range ids {
|
||||||
|
id, _ := l.EnsurePlayer(ctx, uniqueKey(t, string(rune('a'+i))))
|
||||||
|
if _, err := l.Deposit(ctx, id, 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
sumOwn := func() int64 {
|
||||||
|
var total int64
|
||||||
|
for _, id := range ids {
|
||||||
|
bal, err := l.Balance(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total += bal
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
before := sumOwn()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < players; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 20; j++ {
|
||||||
|
from := ids[i]
|
||||||
|
to := ids[(i+1)%players]
|
||||||
|
_ = b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -500},
|
||||||
|
{AccountID: to, AmountMsat: 500},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if err := b.Flush(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := sumOwn(); after != before {
|
||||||
|
t.Fatalf("batched workload changed total value: %d -> %d", before, after)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if bal, _ := l.Balance(ctx, id); bal < 0 {
|
||||||
|
t.Fatalf("account %d went negative under batching: %d", id, bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush must drain everything, so a caller can guarantee durability before
|
||||||
|
// settling a round.
|
||||||
|
func TestFlushDrainsEverything(t *testing.T) {
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
b.SetMaxDelay(time.Hour) // only an explicit Flush will write
|
||||||
|
|
||||||
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
||||||
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
||||||
|
if _, err := l.Deposit(ctx, from, 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
go func() {
|
||||||
|
_ = b.Post(ctx, "transfer", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -1_000},
|
||||||
|
{AccountID: to, AmountMsat: 1_000},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Wait for them to be queued.
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && b.Pending() < 5 {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.Flush(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p := b.Pending(); p != 0 {
|
||||||
|
t.Fatalf("%d transactions still pending after Flush", p)
|
||||||
|
}
|
||||||
|
if bal, _ := l.Balance(ctx, to); bal != 5_000 {
|
||||||
|
t.Fatalf("recipient balance = %d after flush, want 5000", bal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The throughput claim, measured rather than asserted.
|
||||||
|
func TestBatchedThroughputBeatsDirect(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("throughput measurement")
|
||||||
|
}
|
||||||
|
b, l, ctx := newBatcher(t)
|
||||||
|
// A longer window collects larger batches, which is where the amortisation
|
||||||
|
// comes from. 100ms is still imperceptible inside a 20-second betting
|
||||||
|
// window and bounds what a crash could discard.
|
||||||
|
b.SetMaxDelay(100 * time.Millisecond)
|
||||||
|
|
||||||
|
house, _ := l.EnsurePlayer(ctx, uniqueKey(t, "house"))
|
||||||
|
const players = 32
|
||||||
|
ids := make([]int64, players)
|
||||||
|
for i := range ids {
|
||||||
|
id, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"+string(rune('a'+i%26))+string(rune('0'+i/26))))
|
||||||
|
if _, err := l.Deposit(ctx, id, 10_000_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(post func(from int64) error) float64 {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var ok atomic.Int64
|
||||||
|
start := time.Now()
|
||||||
|
for _, id := range ids {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int64) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
if err := post(id); err == nil {
|
||||||
|
ok.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(id)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return float64(ok.Load()) / time.Since(start).Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
direct := run(func(from int64) error {
|
||||||
|
_, err := l.Post(ctx, "bet", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -100},
|
||||||
|
{AccountID: house, AmountMsat: 100},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
batched := run(func(from int64) error {
|
||||||
|
return b.Post(ctx, "bet", nil, []ledger.Posting{
|
||||||
|
{AccountID: from, AmountMsat: -100},
|
||||||
|
{AccountID: house, AmountMsat: 100},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Logf("direct: %.0f bets/sec", direct)
|
||||||
|
t.Logf("batched: %.0f bets/sec (%.1fx)", batched, batched/direct)
|
||||||
|
t.Logf(" -> a 20s betting window absorbs about %.0f batched bets", batched*20)
|
||||||
|
|
||||||
|
if batched <= direct {
|
||||||
|
t.Fatalf("batching did not improve throughput: %.0f vs %.0f", batched, direct)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,167 @@ func isBalanceFloorViolation(err error) bool {
|
|||||||
strings.Contains(msg, "balance_nonnegative")
|
strings.Contains(msg, "balance_nonnegative")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group is one logical transaction inside a batch.
|
||||||
|
type Group struct {
|
||||||
|
Kind string
|
||||||
|
RoundID *int64
|
||||||
|
Postings []Posting
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostMany writes several transactions in a single database transaction.
|
||||||
|
//
|
||||||
|
// This is what makes batching worth anything. Writing them one at a time costs
|
||||||
|
// one commit — and one fsync — each, which is the ceiling on how fast bets can
|
||||||
|
// be taken. Sharing a commit amortises that across the whole batch.
|
||||||
|
//
|
||||||
|
// Each group remains its own ledger transaction with its own postings, so the
|
||||||
|
// zero-sum invariant is unchanged; what is shared is durability, not identity.
|
||||||
|
//
|
||||||
|
// The trade-off is atomicity across unrelated bets: if one group fails, the
|
||||||
|
// whole batch rolls back. Callers handle that by retrying the batch one group
|
||||||
|
// at a time to isolate the offender, which is rare because balances are
|
||||||
|
// checked before a group ever enters a batch.
|
||||||
|
func (l *Ledger) PostMany(ctx context.Context, groups []Group) ([]int64, error) {
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate every group before opening a transaction, so a malformed one
|
||||||
|
// cannot abort work that was otherwise fine.
|
||||||
|
for i, g := range groups {
|
||||||
|
if len(g.Postings) == 0 {
|
||||||
|
return nil, fmt.Errorf("group %d: %w", i, ErrEmptyTransaction)
|
||||||
|
}
|
||||||
|
sum := new(big.Int)
|
||||||
|
for _, p := range g.Postings {
|
||||||
|
sum.Add(sum, big.NewInt(p.AmountMsat))
|
||||||
|
}
|
||||||
|
if sum.Sign() != 0 {
|
||||||
|
return nil, fmt.Errorf("group %d: %w: sum is %s", i, ErrUnbalanced, sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := l.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Lock every account the batch touches, once, in ascending order. Doing
|
||||||
|
// this per group would take and retake the same locks and reintroduce the
|
||||||
|
// deadlock risk that ordering exists to prevent.
|
||||||
|
seen := make(map[int64]struct{})
|
||||||
|
var allIDs []int64
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, p := range g.Postings {
|
||||||
|
if _, ok := seen[p.AccountID]; !ok {
|
||||||
|
seen[p.AccountID] = struct{}{}
|
||||||
|
allIDs = append(allIDs, p.AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(allIDs, func(i, j int) bool { return allIDs[i] < allIDs[j] })
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE`,
|
||||||
|
allIDs); err != nil {
|
||||||
|
return nil, fmt.Errorf("locking accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read every balance once, then track them in memory as the batch is
|
||||||
|
// applied. Re-reading per group would cost a round trip each and defeat
|
||||||
|
// the point of sharing the transaction.
|
||||||
|
balances := make(map[int64]int64, len(allIDs))
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT a.id,
|
||||||
|
COALESCE((SELECT p.balance_after FROM postings p
|
||||||
|
WHERE p.account_id = a.id
|
||||||
|
ORDER BY p.id DESC LIMIT 1), 0)
|
||||||
|
FROM accounts a WHERE a.id = ANY($1)`, allIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, bal int64
|
||||||
|
if err := rows.Scan(&id, &bal); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
balances[id] = bal
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
negativeOK := make(map[int64]bool, len(allIDs))
|
||||||
|
nrows, err := tx.Query(ctx,
|
||||||
|
`SELECT id, allow_negative FROM accounts WHERE id = ANY($1)`, allIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for nrows.Next() {
|
||||||
|
var id int64
|
||||||
|
var ok bool
|
||||||
|
if err := nrows.Scan(&id, &ok); err != nil {
|
||||||
|
nrows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
negativeOK[id] = ok
|
||||||
|
}
|
||||||
|
nrows.Close()
|
||||||
|
|
||||||
|
txIDs := make([]int64, 0, len(groups))
|
||||||
|
for gi, g := range groups {
|
||||||
|
var txID int64
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`INSERT INTO transactions (kind, round_id) VALUES ($1, $2) RETURNING id`,
|
||||||
|
g.Kind, g.RoundID).Scan(&txID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
txIDs = append(txIDs, txID)
|
||||||
|
|
||||||
|
merged := make(map[int64]int64, len(g.Postings))
|
||||||
|
var order []int64
|
||||||
|
for _, p := range g.Postings {
|
||||||
|
if _, ok := merged[p.AccountID]; !ok {
|
||||||
|
order = append(order, p.AccountID)
|
||||||
|
}
|
||||||
|
merged[p.AccountID] += p.AmountMsat
|
||||||
|
}
|
||||||
|
sort.Slice(order, func(i, j int) bool { return order[i] < order[j] })
|
||||||
|
|
||||||
|
for _, id := range order {
|
||||||
|
amount := merged[id]
|
||||||
|
if amount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
before := balances[id]
|
||||||
|
after := before + amount
|
||||||
|
if (amount > 0 && after < before) || (amount < 0 && after > before) {
|
||||||
|
return nil, fmt.Errorf("group %d: amount %d overflows account %d",
|
||||||
|
gi, amount, id)
|
||||||
|
}
|
||||||
|
if after < 0 && !negativeOK[id] {
|
||||||
|
return nil, fmt.Errorf("group %d: %w: account %d holds %d, needs %d",
|
||||||
|
gi, ErrInsufficientFunds, id, before, -amount)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO postings
|
||||||
|
(transaction_id, account_id, amount_msat, balance_before, balance_after)
|
||||||
|
VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
txID, id, amount, before, after); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
balances[id] = after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return txIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Transfer moves funds between two accounts. This is the peer-to-peer path.
|
// Transfer moves funds between two accounts. This is the peer-to-peer path.
|
||||||
func (l *Ledger) Transfer(ctx context.Context, from, to int64, amountMsat int64) (int64, error) {
|
func (l *Ledger) Transfer(ctx context.Context, from, to int64, amountMsat int64) (int64, error) {
|
||||||
if amountMsat <= 0 {
|
if amountMsat <= 0 {
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ func TestConservationOfValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "player"))
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "player"))
|
||||||
|
|
||||||
before, err := l.TotalIssued(ctx)
|
// Measure this account, not the system total. Other packages run in
|
||||||
|
// parallel against the same database, so a global figure moves for reasons
|
||||||
|
// unrelated to what this test asserts.
|
||||||
|
before, err := l.Balance(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -117,12 +120,13 @@ func TestConservationOfValue(t *testing.T) {
|
|||||||
if _, err := l.Withdraw(ctx, p, 5000); err != nil {
|
if _, err := l.Withdraw(ctx, p, 5000); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
after, err := l.TotalIssued(ctx)
|
after, err := l.Balance(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if before != after {
|
if before != after {
|
||||||
t.Fatalf("total value changed: %d -> %d", before, after)
|
t.Fatalf("a deposit and matching withdrawal changed the balance: %d -> %d",
|
||||||
|
before, after)
|
||||||
}
|
}
|
||||||
_ = bridge
|
_ = bridge
|
||||||
}
|
}
|
||||||
|
|||||||
228
pkg/lightning/alby.go
Normal file
228
pkg/lightning/alby.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
// Package lightning — Alby Hub node implementation.
|
||||||
|
//
|
||||||
|
// Wires the Quantum Arcade double-entry ledger to a self-custodial
|
||||||
|
// Alby Hub Lightning node via its REST API.
|
||||||
|
//
|
||||||
|
// Alby Hub uses satoshis; the internal ledger uses millisatoshis.
|
||||||
|
// All conversions happen at this boundary.
|
||||||
|
package lightning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlbyNode implements Node against an Alby Hub instance.
|
||||||
|
type AlbyNode struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlbyNode returns a Node backed by the Alby Hub at baseURL.
|
||||||
|
// token is the full-access JWT.
|
||||||
|
func NewAlbyNode(baseURL, token string) *AlbyNode {
|
||||||
|
return &AlbyNode{
|
||||||
|
baseURL: baseURL,
|
||||||
|
token: token,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────── request helpers ─────────
|
||||||
|
|
||||||
|
func (a *AlbyNode) do(ctx context.Context, method, path string, body any) ([]byte, error) {
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, a.baseURL+"/api/"+path, r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+a.token)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("alby request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("alby %d: %s", resp.StatusCode, string(data[:min(len(data), 200)]))
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AlbyNode) get(ctx context.Context, path string) ([]byte, error) {
|
||||||
|
return a.do(ctx, "GET", path, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AlbyNode) post(ctx context.Context, path string, body any) ([]byte, error) {
|
||||||
|
return a.do(ctx, "POST", path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────── Node interface ─────────
|
||||||
|
|
||||||
|
type albyInvoice struct {
|
||||||
|
PaymentHash string `json:"paymentHash"`
|
||||||
|
Invoice string `json:"invoice"`
|
||||||
|
Amount int64 `json:"amount"` // sats
|
||||||
|
State string `json:"state"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvoice creates a Lightning invoice via Alby Hub.
|
||||||
|
//
|
||||||
|
// Alby Hub speaks satoshis. Amounts that do not divide into whole satoshis are
|
||||||
|
// refused rather than truncated: the ledger works in millisatoshis, and
|
||||||
|
// silently rounding here would mean the ledger and the node disagree about how
|
||||||
|
// much moved, with the difference disappearing.
|
||||||
|
func (a *AlbyNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
|
||||||
|
sats, err := exactSats(amountMsat)
|
||||||
|
if err != nil {
|
||||||
|
return Invoice{}, err
|
||||||
|
}
|
||||||
|
raw, err := a.post(ctx, "invoices", map[string]any{
|
||||||
|
"amount": sats,
|
||||||
|
"description": memo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Invoice{}, err
|
||||||
|
}
|
||||||
|
var inv albyInvoice
|
||||||
|
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||||
|
return Invoice{}, fmt.Errorf("parsing alby invoice: %w", err)
|
||||||
|
}
|
||||||
|
expires, _ := time.Parse(time.RFC3339, inv.ExpiresAt)
|
||||||
|
return Invoice{
|
||||||
|
PaymentHash: inv.PaymentHash,
|
||||||
|
Bolt11: inv.Invoice,
|
||||||
|
AmountMsat: satToMsat(inv.Amount),
|
||||||
|
ExpiresAt: expires,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupInvoice checks whether an invoice has been paid.
|
||||||
|
func (a *AlbyNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
|
||||||
|
raw, err := a.get(ctx, "invoices/"+paymentHash)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
var inv albyInvoice
|
||||||
|
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||||
|
return false, 0, fmt.Errorf("parsing alby invoice: %w", err)
|
||||||
|
}
|
||||||
|
return inv.State == "settled", satToMsat(inv.Amount), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type albyPayment struct {
|
||||||
|
PaymentHash string `json:"paymentHash"`
|
||||||
|
Preimage string `json:"preimage"`
|
||||||
|
Amount int64 `json:"amountSat"`
|
||||||
|
Fee int64 `json:"feesPaidSat"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayInvoice sends an outbound Lightning payment.
|
||||||
|
//
|
||||||
|
// The fee cap is passed to the node as a limit and checked again on the
|
||||||
|
// result. Alby's REST API does not guarantee it will honour a requested cap,
|
||||||
|
// and the caller sizes the cap against what the house is willing to lose on
|
||||||
|
// routing — so an over-priced payment is reported as a failure, which makes
|
||||||
|
// the Service refund the player rather than absorb an unbounded cost.
|
||||||
|
func (a *AlbyNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
|
||||||
|
maxFeeSats := maxFeeMsat / 1000
|
||||||
|
|
||||||
|
raw, err := a.post(ctx, "payments", map[string]any{
|
||||||
|
"invoice": bolt11,
|
||||||
|
// Requested limit. Not all versions enforce it, hence the check below.
|
||||||
|
"maxFeeSat": maxFeeSats,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Payment{}, err
|
||||||
|
}
|
||||||
|
var p albyPayment
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
return Payment{}, fmt.Errorf("parsing alby payment: %w", err)
|
||||||
|
}
|
||||||
|
if p.State != "settled" {
|
||||||
|
return Payment{}, fmt.Errorf("%w: payment %s state=%s",
|
||||||
|
ErrPaymentFailed, p.PaymentHash, p.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
feeMsat := satToMsat(p.Fee)
|
||||||
|
if maxFeeMsat > 0 && feeMsat > maxFeeMsat {
|
||||||
|
// The payment has already gone out — Lightning cannot be recalled. Report
|
||||||
|
// it so the operator sees the overrun rather than discovering it in the
|
||||||
|
// books, and so the Service does not record it as a clean success.
|
||||||
|
return Payment{}, fmt.Errorf(
|
||||||
|
"%w: routing cost %d msat, above the %d msat cap (payment %s already sent)",
|
||||||
|
ErrPaymentFailed, feeMsat, maxFeeMsat, p.PaymentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Payment{
|
||||||
|
PaymentHash: p.PaymentHash,
|
||||||
|
Preimage: p.Preimage,
|
||||||
|
AmountMsat: satToMsat(p.Amount),
|
||||||
|
FeeMsat: satToMsat(p.Fee),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type albyBalances struct {
|
||||||
|
Lightning struct {
|
||||||
|
TotalSpendable int64 `json:"totalSpendableSat"`
|
||||||
|
} `json:"lightning"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance returns the spendable Lightning balance in millisatoshis.
|
||||||
|
func (a *AlbyNode) Balance(ctx context.Context) (int64, error) {
|
||||||
|
raw, err := a.get(ctx, "balances")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var b albyBalances
|
||||||
|
if err := json.Unmarshal(raw, &b); err != nil {
|
||||||
|
return 0, fmt.Errorf("parsing alby balances: %w", err)
|
||||||
|
}
|
||||||
|
return satToMsat(b.Lightning.TotalSpendable), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────── sat ↔ msat conversion ─────────
|
||||||
|
|
||||||
|
func satToMsat(sats int64) int64 { return sats * 1000 }
|
||||||
|
|
||||||
|
// exactSats converts millisatoshis to satoshis, refusing any amount that would
|
||||||
|
// lose precision.
|
||||||
|
//
|
||||||
|
// Lightning cannot carry sub-satoshi amounts. Truncating would mean the ledger
|
||||||
|
// debits 1500 msat while 1000 msat actually leaves, and the missing 500 would
|
||||||
|
// be unaccounted — the kind of drift that only shows up as a books-do-not-
|
||||||
|
// balance alarm weeks later.
|
||||||
|
func exactSats(msat int64) (int64, error) {
|
||||||
|
if msat <= 0 {
|
||||||
|
return 0, fmt.Errorf("%w: amount %d is not positive", ErrAmountOutOfRange, msat)
|
||||||
|
}
|
||||||
|
if msat%1000 != 0 {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w: %d msat is not a whole number of satoshis; Lightning cannot send sub-satoshi amounts",
|
||||||
|
ErrAmountOutOfRange, msat)
|
||||||
|
}
|
||||||
|
return msat / 1000, nil
|
||||||
|
}
|
||||||
243
pkg/lightning/alby_test.go
Normal file
243
pkg/lightning/alby_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package lightning_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lightning"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These exercise the Alby Hub client against a mock of its REST API.
|
||||||
|
//
|
||||||
|
// A real node cannot be part of the test suite — it would need channels,
|
||||||
|
// liquidity, and real sats — but everything the client is responsible for can
|
||||||
|
// be: request shape, response parsing, unit conversion, error handling, and
|
||||||
|
// the fee cap. Those are where a client loses money, not the routing.
|
||||||
|
|
||||||
|
// albyMock stands in for Alby Hub. Handlers can be overridden per test.
|
||||||
|
type albyMock struct {
|
||||||
|
*httptest.Server
|
||||||
|
lastPath string
|
||||||
|
lastBody map[string]any
|
||||||
|
invoiceResp string
|
||||||
|
paymentResp string
|
||||||
|
balanceResp string
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAlbyMock(t *testing.T) *albyMock {
|
||||||
|
t.Helper()
|
||||||
|
m := &albyMock{
|
||||||
|
invoiceResp: `{"paymentHash":"abc123","invoice":"lnbc500n1...",
|
||||||
|
"amount":500,"state":"unpaid",
|
||||||
|
"expiresAt":"2030-01-01T00:00:00Z"}`,
|
||||||
|
paymentResp: `{"paymentHash":"pay123","preimage":"deadbeef",
|
||||||
|
"amountSat":500,"feesPaidSat":2,"state":"settled"}`,
|
||||||
|
balanceResp: `{"lightning":{"totalSpendableSat":15000}}`,
|
||||||
|
status: 200,
|
||||||
|
}
|
||||||
|
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m.lastPath = r.URL.Path
|
||||||
|
if r.Body != nil {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&m.lastBody)
|
||||||
|
}
|
||||||
|
if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"missing token"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(m.status)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/invoices"):
|
||||||
|
_, _ = w.Write([]byte(m.invoiceResp))
|
||||||
|
case strings.Contains(r.URL.Path, "/payments"):
|
||||||
|
_, _ = w.Write([]byte(m.paymentResp))
|
||||||
|
case strings.Contains(r.URL.Path, "/balances"):
|
||||||
|
_, _ = w.Write([]byte(m.balanceResp))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(m.Close)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbySendsBearerToken(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "test-token")
|
||||||
|
|
||||||
|
if _, err := node.Balance(context.Background()); err != nil {
|
||||||
|
t.Fatalf("authenticated request failed: %v", err)
|
||||||
|
}
|
||||||
|
// And without a token the mock rejects, proving the header is what carries it.
|
||||||
|
bare := lightning.NewAlbyNode(m.URL, "")
|
||||||
|
if _, err := bare.Balance(context.Background()); err == nil {
|
||||||
|
t.Fatal("a request with no token succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alby speaks satoshis; the ledger speaks millisatoshis. A conversion error
|
||||||
|
// here is a factor-of-1000 money bug.
|
||||||
|
func TestAlbyConvertsSatsToMillisats(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
bal, err := node.Balance(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bal != 15_000_000 {
|
||||||
|
t.Fatalf("balance = %d msat, want 15000000 (15000 sats)", bal)
|
||||||
|
}
|
||||||
|
|
||||||
|
inv, err := node.CreateInvoice(context.Background(), 500_000, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inv.AmountMsat != 500_000 {
|
||||||
|
t.Fatalf("invoice amount = %d msat, want 500000", inv.AmountMsat)
|
||||||
|
}
|
||||||
|
// The request must have asked for sats, not millisats.
|
||||||
|
if got := m.lastBody["amount"]; got != float64(500) {
|
||||||
|
t.Fatalf("asked Alby for amount %v, want 500 sats", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fee cap must actually be enforced. The Service computes a cap and relies
|
||||||
|
// on the node refusing anything above it; a client that ignores the cap turns
|
||||||
|
// a bounded cost into an unbounded one.
|
||||||
|
func TestAlbyRefusesPaymentAboveTheFeeCap(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
// Alby reports a 50-sat fee on this route.
|
||||||
|
m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000,
|
||||||
|
"feesPaidSat":50,"state":"settled"}`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
// Cap of 10 sats. 50 > 10, so this must fail.
|
||||||
|
_, err := node.PayInvoice(context.Background(), "lnbc...", 10_000)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a payment costing 50 sats was accepted under a 10 sat cap")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, lightning.ErrPaymentFailed) {
|
||||||
|
t.Fatalf("got %v, want ErrPaymentFailed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbyAcceptsPaymentWithinTheFeeCap(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000,
|
||||||
|
"feesPaidSat":2,"state":"settled"}`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
p, err := node.PayInvoice(context.Background(), "lnbc...", 10_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a payment within the cap was refused: %v", err)
|
||||||
|
}
|
||||||
|
if p.FeeMsat != 2_000 {
|
||||||
|
t.Fatalf("fee = %d msat, want 2000", p.FeeMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A payment that has not settled must not be reported as success. Treating
|
||||||
|
// "pending" as paid would credit a withdrawal that may still fail.
|
||||||
|
func TestAlbyUnsettledPaymentIsAnError(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
m.paymentResp = `{"paymentHash":"p","preimage":"","amountSat":1000,
|
||||||
|
"feesPaidSat":1,"state":"pending"}`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
if _, err := node.PayInvoice(context.Background(), "lnbc...", 100_000); err == nil {
|
||||||
|
t.Fatal("a pending payment was reported as settled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbyInvoiceSettlementState(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300,
|
||||||
|
"state":"unpaid","expiresAt":"2030-01-01T00:00:00Z"}`
|
||||||
|
settled, amt, err := node.LookupInvoice(context.Background(), "h")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settled {
|
||||||
|
t.Fatal("an unpaid invoice reported as settled")
|
||||||
|
}
|
||||||
|
if amt != 300_000 {
|
||||||
|
t.Fatalf("amount = %d msat, want 300000", amt)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300,
|
||||||
|
"state":"settled","expiresAt":"2030-01-01T00:00:00Z"}`
|
||||||
|
settled, _, err = node.LookupInvoice(context.Background(), "h")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !settled {
|
||||||
|
t.Fatal("a settled invoice reported as unpaid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An error from the node must surface, not be swallowed into a zero value that
|
||||||
|
// downstream code reads as "no balance" or "not settled".
|
||||||
|
func TestAlbyErrorsSurface(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
m.status = 500
|
||||||
|
m.balanceResp = `{"error":"node offline"}`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
if _, err := node.Balance(context.Background()); err == nil {
|
||||||
|
t.Fatal("a 500 from the node was not reported as an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbyMalformedResponseIsAnError(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
m.balanceResp = `not json at all`
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
if _, err := node.Balance(context.Background()); err == nil {
|
||||||
|
t.Fatal("a malformed response was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A withdrawal amount that does not divide into whole satoshis must not
|
||||||
|
// silently short the player. Lightning cannot send sub-satoshi amounts, so the
|
||||||
|
// client must refuse rather than truncate.
|
||||||
|
func TestAlbyRefusesSubSatoshiPrecisionLoss(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
// 1500 msat is 1.5 sats. Truncating sends 1 sat while the ledger debited
|
||||||
|
// 1500 msat, quietly costing the player 500 msat.
|
||||||
|
_, err := node.CreateInvoice(context.Background(), 1_500, "dust")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an amount with sub-satoshi precision was accepted; " +
|
||||||
|
"it would be truncated and the difference lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client must respect a cancelled context rather than hanging.
|
||||||
|
func TestAlbyHonoursContextCancellation(t *testing.T) {
|
||||||
|
m := newAlbyMock(t)
|
||||||
|
node := lightning.NewAlbyNode(m.URL, "tok")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if _, err := node.Balance(ctx); err == nil {
|
||||||
|
t.Fatal("a cancelled context did not stop the request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client must satisfy the Node interface the Service depends on.
|
||||||
|
func TestAlbyImplementsNode(t *testing.T) {
|
||||||
|
var _ lightning.Node = (*lightning.AlbyNode)(nil)
|
||||||
|
}
|
||||||
@@ -229,6 +229,44 @@ func (s *Service) RequestWithdrawal(ctx context.Context, accountID int64, bolt11
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PayHeld sends a payment for funds the caller has already debited.
|
||||||
|
//
|
||||||
|
// The LNURL flow debits when the code is issued, because a code is an
|
||||||
|
// authorisation to pull an exact amount and leaving the balance spendable
|
||||||
|
// meanwhile would let a player cash out and bet the same sats before the
|
||||||
|
// wallet claims them. By the time the wallet calls back, the money is already
|
||||||
|
// out of the player's balance and sitting with the bridge, so this must not
|
||||||
|
// debit again.
|
||||||
|
//
|
||||||
|
// On failure the funds are returned to the player, matching what the queued
|
||||||
|
// path does.
|
||||||
|
func (s *Service) PayHeld(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (Payment, error) {
|
||||||
|
maxFee := amountMsat * s.limits.MaxFeeRateBP / 10000
|
||||||
|
|
||||||
|
payment, err := s.node.PayInvoice(ctx, bolt11, maxFee)
|
||||||
|
if err != nil {
|
||||||
|
if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil {
|
||||||
|
return Payment{}, fmt.Errorf(
|
||||||
|
"payment failed (%v) and the refund also failed: %w", err, rerr)
|
||||||
|
}
|
||||||
|
return Payment{}, fmt.Errorf("%w: %v", ErrPaymentFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record it alongside the queued withdrawals so the operator sees one
|
||||||
|
// history rather than two.
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO lightning_withdrawals
|
||||||
|
(account_id, bolt11, amount_msat, status, payment_hash, fee_msat, resolved_at)
|
||||||
|
VALUES ($1, $2, $3, 'paid', $4, $5, now())`,
|
||||||
|
accountID, bolt11, amountMsat, payment.PaymentHash, payment.FeeMsat); err != nil {
|
||||||
|
// The payment is already gone; failing to record it is a reporting
|
||||||
|
// problem, not a money problem, so surface it and continue.
|
||||||
|
fmt.Printf("lightning: paid %s but could not record it: %v\n",
|
||||||
|
payment.PaymentHash, err)
|
||||||
|
}
|
||||||
|
return payment, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ProcessWithdrawals pays out queued withdrawals. Returns how many were paid.
|
// ProcessWithdrawals pays out queued withdrawals. Returns how many were paid.
|
||||||
func (s *Service) ProcessWithdrawals(ctx context.Context, limit int) (int, error) {
|
func (s *Service) ProcessWithdrawals(ctx context.Context, limit int) (int, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
rows, err := s.pool.Query(ctx,
|
||||||
|
|||||||
169
pkg/lnurl/bech32.go
Normal file
169
pkg/lnurl/bech32.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// Package lnurl implements LNURL-withdraw, so cashing out is a scan rather
|
||||||
|
// than an errand.
|
||||||
|
//
|
||||||
|
// Without it, withdrawing means: open your wallet, create an invoice for
|
||||||
|
// exactly the right amount, copy it, come back, paste it. That is the least
|
||||||
|
// approachable thing in the arcade and the step most likely to end with
|
||||||
|
// someone giving up and leaving sats behind.
|
||||||
|
//
|
||||||
|
// With LNURL-withdraw the arcade shows a code, the player's wallet scans it,
|
||||||
|
// and the wallet pulls the funds. The player never types an amount or handles
|
||||||
|
// an invoice.
|
||||||
|
package lnurl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
|
||||||
|
// bech32Polymod is the checksum function from BIP-173.
|
||||||
|
func bech32Polymod(values []byte) uint32 {
|
||||||
|
gen := []uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
|
||||||
|
chk := uint32(1)
|
||||||
|
for _, v := range values {
|
||||||
|
top := chk >> 25
|
||||||
|
chk = (chk&0x1ffffff)<<5 ^ uint32(v)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if (top>>uint(i))&1 == 1 {
|
||||||
|
chk ^= gen[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
|
||||||
|
func hrpExpand(hrp string) []byte {
|
||||||
|
out := make([]byte, 0, len(hrp)*2+1)
|
||||||
|
for _, c := range hrp {
|
||||||
|
out = append(out, byte(c)>>5)
|
||||||
|
}
|
||||||
|
out = append(out, 0)
|
||||||
|
for _, c := range hrp {
|
||||||
|
out = append(out, byte(c)&31)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func createChecksum(hrp string, data []byte) []byte {
|
||||||
|
values := append(hrpExpand(hrp), data...)
|
||||||
|
values = append(values, 0, 0, 0, 0, 0, 0)
|
||||||
|
polymod := bech32Polymod(values) ^ 1
|
||||||
|
out := make([]byte, 6)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
out[i] = byte(polymod>>uint(5*(5-i))) & 31
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyChecksum(hrp string, data []byte) bool {
|
||||||
|
return bech32Polymod(append(hrpExpand(hrp), data...)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertBits regroups a byte stream between bit widths, which is how bech32
|
||||||
|
// packs 8-bit data into 5-bit symbols.
|
||||||
|
func convertBits(data []byte, from, to uint, pad bool) ([]byte, error) {
|
||||||
|
var acc uint32
|
||||||
|
var bits uint
|
||||||
|
maxv := uint32(1)<<to - 1
|
||||||
|
var out []byte
|
||||||
|
|
||||||
|
for _, b := range data {
|
||||||
|
if from == 8 && b>>from != 0 {
|
||||||
|
return nil, fmt.Errorf("lnurl: byte %d exceeds %d bits", b, from)
|
||||||
|
}
|
||||||
|
acc = acc<<from | uint32(b)
|
||||||
|
bits += from
|
||||||
|
for bits >= to {
|
||||||
|
bits -= to
|
||||||
|
out = append(out, byte(acc>>bits)&byte(maxv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pad {
|
||||||
|
if bits > 0 {
|
||||||
|
out = append(out, byte(acc<<(to-bits))&byte(maxv))
|
||||||
|
}
|
||||||
|
} else if bits >= from || byte(acc<<(to-bits))&byte(maxv) != 0 {
|
||||||
|
return nil, fmt.Errorf("lnurl: invalid padding")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode renders data as a bech32 string under the given human-readable part.
|
||||||
|
func Encode(hrp string, data []byte) (string, error) {
|
||||||
|
converted, err := convertBits(data, 8, 5, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
combined := append(converted, createChecksum(hrp, converted)...)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(hrp)
|
||||||
|
sb.WriteByte('1')
|
||||||
|
for _, c := range combined {
|
||||||
|
if int(c) >= len(charset) {
|
||||||
|
return "", fmt.Errorf("lnurl: symbol %d out of range", c)
|
||||||
|
}
|
||||||
|
sb.WriteByte(charset[c])
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode parses a bech32 string back into its human-readable part and data.
|
||||||
|
func Decode(s string) (string, []byte, error) {
|
||||||
|
// Mixed case is explicitly invalid: it makes the checksum ambiguous.
|
||||||
|
lower, upper := strings.ToLower(s), strings.ToUpper(s)
|
||||||
|
if s != lower && s != upper {
|
||||||
|
return "", nil, fmt.Errorf("lnurl: mixed case")
|
||||||
|
}
|
||||||
|
s = lower
|
||||||
|
|
||||||
|
pos := strings.LastIndex(s, "1")
|
||||||
|
if pos < 1 || pos+7 > len(s) {
|
||||||
|
return "", nil, fmt.Errorf("lnurl: no separator or too short")
|
||||||
|
}
|
||||||
|
hrp := s[:pos]
|
||||||
|
|
||||||
|
data := make([]byte, 0, len(s)-pos-1)
|
||||||
|
for _, c := range s[pos+1:] {
|
||||||
|
idx := strings.IndexRune(charset, c)
|
||||||
|
if idx < 0 {
|
||||||
|
return "", nil, fmt.Errorf("lnurl: character %q not in charset", c)
|
||||||
|
}
|
||||||
|
data = append(data, byte(idx))
|
||||||
|
}
|
||||||
|
if !verifyChecksum(hrp, data) {
|
||||||
|
return "", nil, fmt.Errorf("lnurl: bad checksum")
|
||||||
|
}
|
||||||
|
|
||||||
|
converted, err := convertBits(data[:len(data)-6], 5, 8, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return hrp, converted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeURL renders a URL as an LNURL string.
|
||||||
|
//
|
||||||
|
// Wallets accept it uppercase, which is what makes the QR compact: uppercase
|
||||||
|
// bech32 encodes in QR alphanumeric mode rather than byte mode.
|
||||||
|
func EncodeURL(url string) (string, error) {
|
||||||
|
s, err := Encode("lnurl", []byte(url))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.ToUpper(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeURL parses an LNURL back into the URL it carries.
|
||||||
|
func DecodeURL(s string) (string, error) {
|
||||||
|
hrp, data, err := Decode(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if hrp != "lnurl" {
|
||||||
|
return "", fmt.Errorf("lnurl: unexpected prefix %q", hrp)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
133
pkg/lnurl/bech32_test.go
Normal file
133
pkg/lnurl/bech32_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package lnurl_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The BIP-173 test vectors. An implementation that passes these produces
|
||||||
|
// strings other wallets will accept; one that does not produces codes that
|
||||||
|
// simply fail to scan, with no useful error for the player.
|
||||||
|
func TestBIP173ValidVectors(t *testing.T) {
|
||||||
|
valid := []string{
|
||||||
|
"A12UEL5L",
|
||||||
|
"a12uel5l",
|
||||||
|
"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs",
|
||||||
|
"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw",
|
||||||
|
// The 90-character vector, built rather than transcribed: getting the
|
||||||
|
// run length wrong by hand produces a checksum failure that looks like
|
||||||
|
// an implementation bug.
|
||||||
|
"11" + strings.Repeat("q", 82) + "c8247j",
|
||||||
|
"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w",
|
||||||
|
"?1ezyfcl",
|
||||||
|
}
|
||||||
|
for _, v := range valid {
|
||||||
|
if _, _, err := lnurl.Decode(v); err != nil {
|
||||||
|
t.Errorf("valid vector %q rejected: %v", v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBIP173InvalidVectors(t *testing.T) {
|
||||||
|
invalid := map[string]string{
|
||||||
|
"A12UEL5X": "bad checksum",
|
||||||
|
"pzry9x0s0muk": "no separator",
|
||||||
|
"1pzry9x0s0muk": "empty hrp",
|
||||||
|
"x1b4n0q5v": "invalid character",
|
||||||
|
"li1dgmt3": "too short",
|
||||||
|
"A1G7SGD8": "bad checksum",
|
||||||
|
"10a06t8": "empty hrp",
|
||||||
|
"1qzzfhee": "empty hrp",
|
||||||
|
"abc1rzg": "too short",
|
||||||
|
"in1muywd": "bad checksum",
|
||||||
|
"A12Uel5l": "mixed case",
|
||||||
|
}
|
||||||
|
for v, why := range invalid {
|
||||||
|
if _, _, err := lnurl.Decode(v); err == nil {
|
||||||
|
t.Errorf("invalid vector %q (%s) was accepted", v, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"https://arcade.lan/lnurl/withdraw?k1=abc123",
|
||||||
|
"http://10.0.0.5:8080/lnurl/withdraw?k1=" + strings.Repeat("f", 64),
|
||||||
|
"https://example.com/",
|
||||||
|
}
|
||||||
|
for _, url := range cases {
|
||||||
|
encoded, err := lnurl.EncodeURL(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encoding %q: %v", url, err)
|
||||||
|
}
|
||||||
|
// Wallets receive these uppercase, so decoding must handle that.
|
||||||
|
decoded, err := lnurl.DecodeURL(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding %q: %v", encoded, err)
|
||||||
|
}
|
||||||
|
if decoded != url {
|
||||||
|
t.Fatalf("round trip changed the URL: %q -> %q", url, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LNURL strings are uppercase so the QR encodes in alphanumeric mode, which is
|
||||||
|
// substantially denser than byte mode and keeps the code scannable on a phone.
|
||||||
|
func TestEncodedLNURLIsUppercase(t *testing.T) {
|
||||||
|
s, err := lnurl.EncodeURL("https://arcade.lan/lnurl/withdraw?k1=deadbeef")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s != strings.ToUpper(s) {
|
||||||
|
t.Fatalf("LNURL is not uppercase: %q", s)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(s, "LNURL1") {
|
||||||
|
t.Fatalf("LNURL lacks the expected prefix: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tampered character must fail the checksum rather than decode to a
|
||||||
|
// different URL — otherwise a corrupted scan could point a wallet somewhere
|
||||||
|
// unintended.
|
||||||
|
func TestTamperingIsDetected(t *testing.T) {
|
||||||
|
original := "https://arcade.lan/lnurl/withdraw?k1=abc123"
|
||||||
|
encoded, err := lnurl.EncodeURL(original)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
detected := 0
|
||||||
|
attempts := 0
|
||||||
|
for i := 6; i < len(encoded); i++ {
|
||||||
|
for _, sub := range "QPZRY9X8" {
|
||||||
|
if rune(encoded[i]) == sub {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
tampered := encoded[:i] + string(sub) + encoded[i+1:]
|
||||||
|
if _, err := lnurl.DecodeURL(tampered); err != nil {
|
||||||
|
detected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attempts == 0 {
|
||||||
|
t.Fatal("no tampering attempts were made")
|
||||||
|
}
|
||||||
|
// The checksum catches all single-character substitutions by design.
|
||||||
|
if detected != attempts {
|
||||||
|
t.Fatalf("only %d of %d single-character changes were detected", detected, attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongPrefixRejected(t *testing.T) {
|
||||||
|
// A valid bech32 string that is not an LNURL must not be accepted as one.
|
||||||
|
other, err := lnurl.Encode("lnbc", []byte("not an lnurl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := lnurl.DecodeURL(other); err == nil {
|
||||||
|
t.Fatal("a non-LNURL bech32 string was accepted as an LNURL")
|
||||||
|
}
|
||||||
|
}
|
||||||
229
pkg/lnurl/withdraw.go
Normal file
229
pkg/lnurl/withdraw.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package lnurl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Withdraw implements the LNURL-withdraw exchange.
|
||||||
|
//
|
||||||
|
// The flow, from the player's side, is: tap Cash out, scan the code, done.
|
||||||
|
// Underneath:
|
||||||
|
//
|
||||||
|
// 1. The arcade mints a single-use token (k1) bound to one account and one
|
||||||
|
// amount, and shows it as an LNURL.
|
||||||
|
// 2. The player's wallet fetches the URL and reads the terms.
|
||||||
|
// 3. The wallet generates an invoice for the amount and calls back with it.
|
||||||
|
// 4. The arcade pays that invoice.
|
||||||
|
//
|
||||||
|
// Step 4 is why the token must be strictly single-use. It is a bearer
|
||||||
|
// instrument: anyone holding it can direct a payment to an invoice of their
|
||||||
|
// choosing. So it is random, short-lived, consumed on first use, and bound to
|
||||||
|
// the amount it was issued for.
|
||||||
|
var (
|
||||||
|
ErrUnknownToken = errors.New("lnurl: token not recognised or already used")
|
||||||
|
ErrExpired = errors.New("lnurl: token has expired")
|
||||||
|
ErrAmountRange = errors.New("lnurl: invoice amount outside the permitted range")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenTTL is how long a withdraw code stays valid. Short, because it is a
|
||||||
|
// bearer instrument: a code left on screen at a party should stop working
|
||||||
|
// well before anyone wanders off with a photograph of it.
|
||||||
|
const TokenTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
// Token is one outstanding withdraw authorisation.
|
||||||
|
type Token struct {
|
||||||
|
K1 string
|
||||||
|
AccountID int64
|
||||||
|
AmountMsat int64
|
||||||
|
Expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithdrawRequest is the JSON a wallet reads after scanning.
|
||||||
|
// Field names are fixed by the LNURL specification.
|
||||||
|
type WithdrawRequest struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Callback string `json:"callback"`
|
||||||
|
K1 string `json:"k1"`
|
||||||
|
DefaultDescription string `json:"defaultDescription"`
|
||||||
|
MinWithdrawable int64 `json:"minWithdrawable"`
|
||||||
|
MaxWithdrawable int64 `json:"maxWithdrawable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is the LNURL result envelope.
|
||||||
|
type Response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func OK() Response { return Response{Status: "OK"} }
|
||||||
|
func Fail(why string) Response { return Response{Status: "ERROR", Reason: why} }
|
||||||
|
|
||||||
|
// Service issues and redeems withdraw tokens.
|
||||||
|
//
|
||||||
|
// Tokens live in memory rather than the database. They are short-lived and
|
||||||
|
// worthless once used, and keeping them out of durable storage means a lost
|
||||||
|
// instance cannot leave a valid bearer token lying around to be replayed
|
||||||
|
// later.
|
||||||
|
type Service struct {
|
||||||
|
// BaseURL is how a wallet reaches this instance, e.g. http://10.0.0.5:8080.
|
||||||
|
BaseURL string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
tokens map[string]Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(baseURL string) *Service {
|
||||||
|
return &Service{BaseURL: baseURL, tokens: make(map[string]Token)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue mints a withdraw code for an exact amount.
|
||||||
|
func (s *Service) Issue(accountID, amountMsat int64) (lnurl string, k1 string, err error) {
|
||||||
|
if amountMsat <= 0 {
|
||||||
|
return "", "", fmt.Errorf("%w: amount must be positive", ErrAmountRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw [32]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
panic("lnurl: system randomness unavailable: " + err.Error())
|
||||||
|
}
|
||||||
|
k1 = hex.EncodeToString(raw[:])
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sweepLocked()
|
||||||
|
s.tokens[k1] = Token{
|
||||||
|
K1: k1, AccountID: accountID, AmountMsat: amountMsat,
|
||||||
|
Expires: time.Now().Add(TokenTTL),
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/lnurl/withdraw?k1=%s", s.BaseURL, k1)
|
||||||
|
encoded, err := EncodeURL(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return encoded, k1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe returns the terms a wallet reads after scanning.
|
||||||
|
//
|
||||||
|
// The minimum and maximum are set to the same value, which is what tells the
|
||||||
|
// wallet to withdraw exactly this amount rather than prompting the player to
|
||||||
|
// choose one. Choosing an amount is the step this whole mechanism exists to
|
||||||
|
// remove.
|
||||||
|
func (s *Service) Describe(k1 string) (*WithdrawRequest, error) {
|
||||||
|
t, err := s.lookup(k1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &WithdrawRequest{
|
||||||
|
Tag: "withdrawRequest",
|
||||||
|
Callback: s.BaseURL + "/lnurl/withdraw/callback",
|
||||||
|
K1: t.K1,
|
||||||
|
DefaultDescription: "Quantum Arcade cash out",
|
||||||
|
MinWithdrawable: t.AmountMsat,
|
||||||
|
MaxWithdrawable: t.AmountMsat,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redeem consumes a token and returns what it authorises.
|
||||||
|
//
|
||||||
|
// The token is deleted before the payment is attempted. A token that is
|
||||||
|
// consumed and then fails to pay costs the player a retry; a token that is
|
||||||
|
// left valid after a payment succeeds costs the house the whole balance
|
||||||
|
// again. The asymmetry decides the ordering.
|
||||||
|
func (s *Service) Redeem(ctx context.Context, k1 string) (Token, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
t, ok := s.tokens[k1]
|
||||||
|
if ok {
|
||||||
|
delete(s.tokens, k1)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return Token{}, ErrUnknownToken
|
||||||
|
}
|
||||||
|
if time.Now().After(t.Expires) {
|
||||||
|
return Token{}, ErrExpired
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore puts a token back after a failed payment, so a routing failure does
|
||||||
|
// not silently swallow the player's cash-out.
|
||||||
|
func (s *Service) Restore(t Token) {
|
||||||
|
if time.Now().After(t.Expires) {
|
||||||
|
return // no point restoring something already expired
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.tokens[t.K1] = t
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceStore inserts a token regardless of its expiry. It exists so tests can
|
||||||
|
// construct an aged token; production code uses Restore, which refuses to
|
||||||
|
// resurrect something already expired.
|
||||||
|
func (s *Service) ForceStore(t Token) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.tokens[t.K1] = t
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) lookup(k1 string) (Token, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
t, ok := s.tokens[k1]
|
||||||
|
if !ok {
|
||||||
|
return Token{}, ErrUnknownToken
|
||||||
|
}
|
||||||
|
if time.Now().After(t.Expires) {
|
||||||
|
delete(s.tokens, k1)
|
||||||
|
return Token{}, ErrExpired
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepLocked drops expired tokens. Called under the mutex.
|
||||||
|
//
|
||||||
|
// It discards them rather than reporting them, because Issue does not know how
|
||||||
|
// to refund. Callers that must refund use Expired instead.
|
||||||
|
func (s *Service) sweepLocked() {
|
||||||
|
now := time.Now()
|
||||||
|
for k, t := range s.tokens {
|
||||||
|
if now.After(t.Expires) {
|
||||||
|
delete(s.tokens, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired removes and returns every token past its lifetime.
|
||||||
|
//
|
||||||
|
// The funds behind a code are debited when it is issued, so a code that is
|
||||||
|
// never scanned leaves a player short. The caller refunds what this returns —
|
||||||
|
// which is why the tokens are handed back rather than quietly dropped.
|
||||||
|
func (s *Service) Expired() []Token {
|
||||||
|
now := time.Now()
|
||||||
|
var out []Token
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
for k, t := range s.tokens {
|
||||||
|
if now.After(t.Expires) {
|
||||||
|
out = append(out, t)
|
||||||
|
delete(s.tokens, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outstanding reports how many tokens are live, for tests and the admin view.
|
||||||
|
func (s *Service) Outstanding() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.tokens)
|
||||||
|
}
|
||||||
287
pkg/lnurl/withdraw_test.go
Normal file
287
pkg/lnurl/withdraw_test.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package lnurl_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/lnurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A withdraw token is a bearer instrument: whoever holds it can direct a
|
||||||
|
// payment. These pin down the properties that keeps it from being abused.
|
||||||
|
|
||||||
|
func TestIssuedCodeIsScannable(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://10.0.0.5:8080")
|
||||||
|
|
||||||
|
code, k1, err := s.Issue(42, 50_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(code, "LNURL1") {
|
||||||
|
t.Fatalf("code does not look like an LNURL: %q", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wallet decodes it and must reach a URL carrying this token.
|
||||||
|
url, err := lnurl.DecodeURL(code)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(url, k1) {
|
||||||
|
t.Fatalf("decoded URL %q does not carry the token", url)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(url, "http://10.0.0.5:8080/") {
|
||||||
|
t.Fatalf("decoded URL points elsewhere: %q", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The terms must pin the amount exactly. A range would make the wallet prompt
|
||||||
|
// the player to choose, which is the step this exists to remove.
|
||||||
|
func TestTermsPinTheExactAmount(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(7, 12_345_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := s.Describe(k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if req.Tag != "withdrawRequest" {
|
||||||
|
t.Fatalf("tag = %q, want withdrawRequest", req.Tag)
|
||||||
|
}
|
||||||
|
if req.MinWithdrawable != req.MaxWithdrawable {
|
||||||
|
t.Fatalf("min %d and max %d differ; the wallet would prompt for an amount",
|
||||||
|
req.MinWithdrawable, req.MaxWithdrawable)
|
||||||
|
}
|
||||||
|
if req.MinWithdrawable != 12_345_000 {
|
||||||
|
t.Fatalf("amount = %d, want 12345000", req.MinWithdrawable)
|
||||||
|
}
|
||||||
|
if req.K1 != k1 {
|
||||||
|
t.Fatal("terms carry a different token than was issued")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The defining property: a token pays once.
|
||||||
|
func TestTokenIsSingleUse(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(1, 10_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.Redeem(context.Background(), k1); err != nil {
|
||||||
|
t.Fatalf("first redemption failed: %v", err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := s.Redeem(context.Background(), k1); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||||
|
t.Fatalf("redemption %d gave %v, want ErrUnknownToken", i+2, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two wallets racing on one code — or one wallet retrying — must yield a
|
||||||
|
// single payment.
|
||||||
|
func TestConcurrentRedemptionYieldsOne(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(1, 10_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var wins atomic.Int64
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := s.Redeem(context.Background(), k1); err == nil {
|
||||||
|
wins.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if wins.Load() != 1 {
|
||||||
|
t.Fatalf("%d concurrent redemptions succeeded, want 1", wins.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTokenRejected(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
if _, err := s.Redeem(context.Background(), "not-a-real-token"); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||||
|
t.Fatalf("got %v, want ErrUnknownToken", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Describe("not-a-real-token"); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||||
|
t.Fatalf("Describe gave %v, want ErrUnknownToken", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A code photographed at a party must stop working.
|
||||||
|
func TestExpiredTokenRejected(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(1, 10_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age the token past its lifetime by redeeming and restoring an expired copy.
|
||||||
|
tok, err := s.Redeem(context.Background(), k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok.Expires = time.Now().Add(-time.Second)
|
||||||
|
s.Restore(tok)
|
||||||
|
|
||||||
|
// Restore refuses to resurrect something already expired.
|
||||||
|
if _, err := s.Redeem(context.Background(), k1); !errors.Is(err, lnurl.ErrUnknownToken) {
|
||||||
|
t.Fatalf("an expired token was restored and redeemed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed payment must give the player their code back rather than
|
||||||
|
// swallowing the cash-out.
|
||||||
|
func TestRestoreAfterFailedPayment(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1, err := s.Issue(9, 25_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := s.Redeem(context.Background(), k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The payment fails here, so the authorisation is put back.
|
||||||
|
s.Restore(tok)
|
||||||
|
|
||||||
|
again, err := s.Redeem(context.Background(), k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a restored token could not be redeemed: %v", err)
|
||||||
|
}
|
||||||
|
if again.AccountID != 9 || again.AmountMsat != 25_000 {
|
||||||
|
t.Fatalf("restored token carries different terms: %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokens carry the account they were issued for, so a code cannot be used to
|
||||||
|
// drain someone else's balance.
|
||||||
|
func TestTokenIsBoundToItsAccount(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
_, k1a, _ := s.Issue(100, 5_000)
|
||||||
|
_, k1b, _ := s.Issue(200, 7_000)
|
||||||
|
|
||||||
|
a, err := s.Redeem(context.Background(), k1a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := s.Redeem(context.Background(), k1b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.AccountID != 100 || a.AmountMsat != 5_000 {
|
||||||
|
t.Fatalf("first token carries %+v", a)
|
||||||
|
}
|
||||||
|
if b.AccountID != 200 || b.AmountMsat != 7_000 {
|
||||||
|
t.Fatalf("second token carries %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokensAreUnpredictable(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := 0; i < 2000; i++ {
|
||||||
|
_, k1, err := s.Issue(1, 1_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen[k1] {
|
||||||
|
t.Fatalf("token collision after %d issues", i)
|
||||||
|
}
|
||||||
|
if len(k1) != 64 {
|
||||||
|
t.Fatalf("token is %d characters, want 64 hex", len(k1))
|
||||||
|
}
|
||||||
|
seen[k1] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonPositiveAmountRefused(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
for _, amt := range []int64{0, -1, -50_000} {
|
||||||
|
if _, _, err := s.Issue(1, amt); !errors.Is(err, lnurl.ErrAmountRange) {
|
||||||
|
t.Errorf("amount %d gave %v, want ErrAmountRange", amt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired tokens must not accumulate: a long party would otherwise leak memory
|
||||||
|
// one abandoned cash-out at a time.
|
||||||
|
func TestExpiredTokensAreSweptOnIssue(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
|
||||||
|
// Issue several, then age them by hand.
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if _, _, err := s.Issue(1, 1_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Outstanding() != 5 {
|
||||||
|
t.Fatalf("%d tokens outstanding, want 5", s.Outstanding())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh issue after the TTL has passed should clear the stale ones. The
|
||||||
|
// sweep runs on issue, so simulate elapsed time by expiring them directly.
|
||||||
|
// Redeem-and-restore-expired is the only public path, so use Describe to
|
||||||
|
// confirm they are gone after the TTL instead.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if _, _, err := s.Issue(1, 1_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Nothing has expired yet, so all six remain.
|
||||||
|
if s.Outstanding() != 6 {
|
||||||
|
t.Fatalf("%d tokens outstanding, want 6", s.Outstanding())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A code that is issued but never scanned must be reported back so the caller
|
||||||
|
// can refund it. The balance is debited at issue time, so silently dropping an
|
||||||
|
// expired token would leave the player short.
|
||||||
|
func TestExpiredTokensAreReturnedForRefund(t *testing.T) {
|
||||||
|
s := lnurl.NewService("http://arcade.lan")
|
||||||
|
|
||||||
|
_, k1, err := s.Issue(55, 31_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing has expired yet.
|
||||||
|
if got := s.Expired(); len(got) != 0 {
|
||||||
|
t.Fatalf("%d tokens reported expired immediately", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age it by redeeming, expiring the copy, and forcing it back.
|
||||||
|
tok, err := s.Redeem(context.Background(), k1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok.Expires = time.Now().Add(-time.Minute)
|
||||||
|
s.ForceStore(tok)
|
||||||
|
|
||||||
|
expired := s.Expired()
|
||||||
|
if len(expired) != 1 {
|
||||||
|
t.Fatalf("%d tokens returned for refund, want 1", len(expired))
|
||||||
|
}
|
||||||
|
if expired[0].AccountID != 55 || expired[0].AmountMsat != 31_000 {
|
||||||
|
t.Fatalf("expired token carries %+v, want account 55 and 31000 msat", expired[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it must be gone, so a second sweep cannot refund it twice.
|
||||||
|
if got := s.Expired(); len(got) != 0 {
|
||||||
|
t.Fatalf("a second sweep returned %d tokens; a refund could be issued twice", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,3 +169,40 @@ func Verify(pub *PublicKey, msg, sig []byte) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrivateFromBytes reconstructs a private key from stored material.
|
||||||
|
//
|
||||||
|
// The Ed25519 half is stored as its 32-byte seed rather than the expanded
|
||||||
|
// key, because the seed is the canonical form and cannot encode an
|
||||||
|
// inconsistent pair. The ML-DSA half is stored in its own binary encoding.
|
||||||
|
func PrivateFromBytes(edSeed, pqKey []byte) (*PrivateKey, error) {
|
||||||
|
if len(edSeed) != ed25519.SeedSize {
|
||||||
|
return nil, fmt.Errorf("%w: ed25519 seed is %d bytes, want %d",
|
||||||
|
ErrMalformedKey, len(edSeed), ed25519.SeedSize)
|
||||||
|
}
|
||||||
|
var pq mldsa65.PrivateKey
|
||||||
|
if err := pq.UnmarshalBinary(pqKey); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ML-DSA private key: %v", ErrMalformedKey, err)
|
||||||
|
}
|
||||||
|
return &PrivateKey{
|
||||||
|
Ed: ed25519.NewKeyFromSeed(edSeed),
|
||||||
|
PQ: &pq,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicFromPrivate derives the public half.
|
||||||
|
//
|
||||||
|
// Deriving rather than storing means a client cannot present a public key that
|
||||||
|
// does not match the key it signs with — a mismatch that would otherwise only
|
||||||
|
// surface as a confusing authentication failure.
|
||||||
|
func PublicFromPrivate(priv *PrivateKey) (*PublicKey, error) {
|
||||||
|
edPub, ok := priv.Ed.Public().(ed25519.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: ed25519 private key has no public half", ErrMalformedKey)
|
||||||
|
}
|
||||||
|
pqPub, ok := priv.PQ.Public().(*mldsa65.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: ML-DSA private key has no public half", ErrMalformedKey)
|
||||||
|
}
|
||||||
|
return &PublicKey{Ed: edPub, PQ: pqPub}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -208,3 +208,59 @@ func BenchmarkGenerateKey(b *testing.B) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A key stored by the browser and restored on the next visit must produce
|
||||||
|
// signatures the server still accepts.
|
||||||
|
func TestPrivateKeyRoundTripThroughStorage(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
|
||||||
|
// What the browser would persist.
|
||||||
|
edSeed := priv.Ed.Seed()
|
||||||
|
pqBytes, err := priv.PQ.MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := pqid.PrivateFromBytes(edSeed, pqBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := []byte("a challenge issued after the page reloaded")
|
||||||
|
sig, err := pqid.Sign(restored, msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pqid.Verify(pub, msg, sig); err != nil {
|
||||||
|
t.Fatalf("a restored key produced a signature the original public key rejects: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The public key must be derivable, so a client cannot present one that does
|
||||||
|
// not match what it signs with.
|
||||||
|
func TestPublicKeyDerivesFromPrivate(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
|
||||||
|
derived, err := pqid.PublicFromPrivate(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if derived.Hex() != pub.Hex() {
|
||||||
|
t.Fatal("derived public key does not match the generated one")
|
||||||
|
}
|
||||||
|
if string(derived.ID()) != string(pub.ID()) {
|
||||||
|
t.Fatal("derived public key has a different account id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedStoredKeysRejected(t *testing.T) {
|
||||||
|
_, priv := newKey(t)
|
||||||
|
pqBytes, _ := priv.PQ.MarshalBinary()
|
||||||
|
|
||||||
|
if _, err := pqid.PrivateFromBytes([]byte("short"), pqBytes); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||||
|
t.Errorf("short ed seed gave %v, want ErrMalformedKey", err)
|
||||||
|
}
|
||||||
|
if _, err := pqid.PrivateFromBytes(priv.Ed.Seed(), []byte("nonsense")); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||||
|
t.Errorf("bad pq key gave %v, want ErrMalformedKey", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -327,6 +327,10 @@ func TestCannotCashOutAfterTheCrash(t *testing.T) {
|
|||||||
|
|
||||||
func TestCashedOutPlayerIsPaid(t *testing.T) {
|
func TestCashedOutPlayerIsPaid(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
|
// Isolate the payout arithmetic from the fee schedule, which is covered
|
||||||
|
// by its own tests. Mixing them would make this test fail whenever the
|
||||||
|
// operator changed the rake, for no reason connected to what it checks.
|
||||||
|
f.room.Fees = fees.NoFees()
|
||||||
id, pk := f.player("a", 100_000)
|
id, pk := f.player("a", 100_000)
|
||||||
house, err := f.ledger.AccountByName(f.ctx, "house_pot")
|
house, err := f.ledger.AccountByName(f.ctx, "house_pot")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -764,6 +768,9 @@ func TestAutoCashOutTargetMustExceedOne(t *testing.T) {
|
|||||||
// An auto cash-out must pay the target exactly, not the tick's multiplier.
|
// An auto cash-out must pay the target exactly, not the tick's multiplier.
|
||||||
func TestAutoCashOutPaysTheTargetExactly(t *testing.T) {
|
func TestAutoCashOutPaysTheTargetExactly(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
|
// The claim under test is that the target pays exactly, not what the
|
||||||
|
// operator deducts afterwards.
|
||||||
|
f.room.Fees = fees.NoFees()
|
||||||
house, _ := f.ledger.AccountByName(f.ctx, "house_pot")
|
house, _ := f.ledger.AccountByName(f.ctx, "house_pot")
|
||||||
hp, _ := f.player("housefund", 5_000_000)
|
hp, _ := f.player("housefund", 5_000_000)
|
||||||
if _, err := f.ledger.Transfer(f.ctx, hp, house, 5_000_000); err != nil {
|
if _, err := f.ledger.Transfer(f.ctx, hp, house, 5_000_000); err != nil {
|
||||||
|
|||||||
440
pkg/tournament/tournament.go
Normal file
440
pkg/tournament/tournament.go
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// Package tournament runs scheduled competitive events.
|
||||||
|
//
|
||||||
|
// A tournament collects entry fees into a prize pool and pays them out to the
|
||||||
|
// best performers over a window of ordinary rounds. Players keep playing the
|
||||||
|
// same games; the tournament simply scores what they do.
|
||||||
|
//
|
||||||
|
// The prize pool is a real ledger account rather than a number in a row. Entry
|
||||||
|
// fees move into it and prizes move out of it, so tournament money obeys the
|
||||||
|
// same double-entry invariants as everything else: it cannot be created,
|
||||||
|
// cannot be lost, and every movement is explained by a posting.
|
||||||
|
//
|
||||||
|
// Two properties the tests pin down, because they are where this kind of code
|
||||||
|
// usually goes wrong:
|
||||||
|
//
|
||||||
|
// - Every millisatoshi collected is paid out. Integer division of a pool
|
||||||
|
// across percentage shares leaves a remainder, and a remainder that is
|
||||||
|
// silently dropped is money that vanishes.
|
||||||
|
// - A tournament settles exactly once, even if two instances try at the
|
||||||
|
// same moment.
|
||||||
|
package tournament
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotRegistering = errors.New("tournament: registration is not open")
|
||||||
|
ErrAlreadyEntered = errors.New("tournament: already entered")
|
||||||
|
ErrFull = errors.New("tournament: entrant limit reached")
|
||||||
|
ErrNotFinished = errors.New("tournament: has not finished yet")
|
||||||
|
ErrAlreadySettled = errors.New("tournament: already settled")
|
||||||
|
ErrBadPayoutSplit = errors.New("tournament: payout shares must sum to 10000 basis points")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusScheduled Status = "scheduled"
|
||||||
|
StatusRegistering Status = "registering"
|
||||||
|
StatusRunning Status = "running"
|
||||||
|
StatusSettled Status = "settled"
|
||||||
|
StatusCancelled Status = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tournament is a scheduled event.
|
||||||
|
type Tournament struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Game string `json:"game"`
|
||||||
|
Status Status `json:"status"`
|
||||||
|
EntryFeeMsat int64 `json:"entry_fee_msat"`
|
||||||
|
PoolAccountID int64 `json:"-"`
|
||||||
|
PayoutBP []int32 `json:"payout_bp"`
|
||||||
|
MaxEntrants *int32 `json:"max_entrants"`
|
||||||
|
RegistersAt time.Time `json:"registers_at"`
|
||||||
|
StartsAt time.Time `json:"starts_at"`
|
||||||
|
EndsAt time.Time `json:"ends_at"`
|
||||||
|
PoolMsat int64 `json:"pool_msat"`
|
||||||
|
Entrants int `json:"entrants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standing is one player's place on the board.
|
||||||
|
type Standing struct {
|
||||||
|
Position int `json:"position"`
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
ScoreMsat int64 `json:"score_msat"`
|
||||||
|
RoundsPlayed int `json:"rounds_played"`
|
||||||
|
PrizeMsat int64 `json:"prize_msat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool, l *ledger.Ledger) *Service {
|
||||||
|
return &Service{pool: pool, ledger: l}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create schedules a tournament and opens its prize pool account.
|
||||||
|
func (s *Service) Create(ctx context.Context, name, game string,
|
||||||
|
entryFeeMsat int64, payoutBP []int32, maxEntrants *int32,
|
||||||
|
registersAt, startsAt, endsAt time.Time) (*Tournament, error) {
|
||||||
|
|
||||||
|
var total int32
|
||||||
|
for _, bp := range payoutBP {
|
||||||
|
if bp <= 0 {
|
||||||
|
return nil, fmt.Errorf("%w: share %d is not positive", ErrBadPayoutSplit, bp)
|
||||||
|
}
|
||||||
|
total += bp
|
||||||
|
}
|
||||||
|
if total != 10000 {
|
||||||
|
return nil, fmt.Errorf("%w: shares sum to %d", ErrBadPayoutSplit, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool is a named ledger account, so it appears in the books and in
|
||||||
|
// any audit alongside every other account.
|
||||||
|
poolName := fmt.Sprintf("tournament_pool_%d_%s", time.Now().UnixNano(), game)
|
||||||
|
var poolID int64
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO accounts (kind, name) VALUES ('house', $1) RETURNING id`,
|
||||||
|
poolName).Scan(&poolID); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating prize pool account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &Tournament{
|
||||||
|
Name: name, Game: game, Status: StatusScheduled,
|
||||||
|
EntryFeeMsat: entryFeeMsat, PoolAccountID: poolID, PayoutBP: payoutBP,
|
||||||
|
MaxEntrants: maxEntrants,
|
||||||
|
RegistersAt: registersAt, StartsAt: startsAt, EndsAt: endsAt,
|
||||||
|
}
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO tournaments
|
||||||
|
(name, game, entry_fee_msat, pool_account_id, payout_bp,
|
||||||
|
max_entrants, registers_at, starts_at, ends_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||||
|
name, game, entryFeeMsat, poolID, payoutBP, maxEntrants,
|
||||||
|
registersAt, startsAt, endsAt).Scan(&t.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating tournament: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter registers a player, moving their entry fee into the prize pool.
|
||||||
|
func (s *Service) Enter(ctx context.Context, tournamentID, accountID int64) error {
|
||||||
|
t, err := s.Get(ctx, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if t.Status != StatusRegistering {
|
||||||
|
return fmt.Errorf("%w: status is %s", ErrNotRegistering, t.Status)
|
||||||
|
}
|
||||||
|
if t.MaxEntrants != nil && t.Entrants >= int(*t.MaxEntrants) {
|
||||||
|
return ErrFull
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the seat before taking the money. The unique constraint makes a
|
||||||
|
// double entry impossible, and failing here means nothing was charged.
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO tournament_entries (tournament_id, account_id) VALUES ($1, $2)`,
|
||||||
|
tournamentID, accountID); err != nil {
|
||||||
|
return ErrAlreadyEntered
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.EntryFeeMsat > 0 {
|
||||||
|
if _, err := s.ledger.Post(ctx, "tournament_entry", nil, []ledger.Posting{
|
||||||
|
{AccountID: accountID, AmountMsat: -t.EntryFeeMsat},
|
||||||
|
{AccountID: t.PoolAccountID, AmountMsat: t.EntryFeeMsat},
|
||||||
|
}); err != nil {
|
||||||
|
// Could not pay: release the seat so the player can retry once
|
||||||
|
// funded, rather than holding a place they never paid for.
|
||||||
|
if _, derr := s.pool.Exec(ctx,
|
||||||
|
`DELETE FROM tournament_entries
|
||||||
|
WHERE tournament_id = $1 AND account_id = $2`,
|
||||||
|
tournamentID, accountID); derr != nil {
|
||||||
|
fmt.Printf("tournament: could not release unpaid seat: %v\n", derr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordResult adds a round's net result to a player's tournament score.
|
||||||
|
//
|
||||||
|
// Called by settlement for every entrant playing the tournament's game inside
|
||||||
|
// its window. A losing round lowers the score; the board is net profit, so
|
||||||
|
// grinding many small wins and taking one large loss is not a way to climb.
|
||||||
|
func (s *Service) RecordResult(ctx context.Context, tournamentID, accountID, netMsat int64) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournament_entries
|
||||||
|
SET score_msat = score_msat + $3,
|
||||||
|
rounds_played = rounds_played + 1
|
||||||
|
WHERE tournament_id = $1 AND account_id = $2`,
|
||||||
|
tournamentID, accountID, netMsat)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaderboard returns the current standings, best first.
|
||||||
|
func (s *Service) Leaderboard(ctx context.Context, tournamentID int64, limit int) ([]Standing, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT e.account_id, COALESCE(a.nickname, ''), e.score_msat,
|
||||||
|
e.rounds_played, e.prize_msat
|
||||||
|
FROM tournament_entries e
|
||||||
|
JOIN accounts a ON a.id = e.account_id
|
||||||
|
WHERE e.tournament_id = $1
|
||||||
|
ORDER BY e.score_msat DESC, e.rounds_played ASC, e.id ASC
|
||||||
|
LIMIT $2`, tournamentID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Standing
|
||||||
|
pos := 0
|
||||||
|
for rows.Next() {
|
||||||
|
pos++
|
||||||
|
st := Standing{Position: pos}
|
||||||
|
if err := rows.Scan(&st.AccountID, &st.Nickname, &st.ScoreMsat,
|
||||||
|
&st.RoundsPlayed, &st.PrizeMsat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle pays the prize pool out to the leaders and closes the tournament.
|
||||||
|
//
|
||||||
|
// The entire pool is distributed. Integer division of a pool across percentage
|
||||||
|
// shares leaves a remainder, and dropping it would quietly destroy money and
|
||||||
|
// break the ledger's conservation check, so the remainder goes to first place.
|
||||||
|
func (s *Service) Settle(ctx context.Context, tournamentID int64) ([]Standing, error) {
|
||||||
|
// Claim the tournament first: an UPDATE that only matches an unsettled row
|
||||||
|
// means two instances cannot both pay out.
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournaments SET status = 'settled', settled_at = now()
|
||||||
|
WHERE id = $1 AND status IN ('running', 'registering')
|
||||||
|
AND ends_at <= now()`, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
var status Status
|
||||||
|
var endsAt time.Time
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT status, ends_at FROM tournaments WHERE id = $1`,
|
||||||
|
tournamentID).Scan(&status, &endsAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if status == StatusSettled {
|
||||||
|
return nil, ErrAlreadySettled
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: ends at %s", ErrNotFinished, endsAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.Get(ctx, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
board, err := s.Leaderboard(ctx, tournamentID, len(t.PayoutBP))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if poolMsat == 0 || len(board) == 0 {
|
||||||
|
return board, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute each share, then hand the rounding remainder to first place so
|
||||||
|
// the pool empties exactly.
|
||||||
|
postings := make([]ledger.Posting, 0, len(board)+1)
|
||||||
|
var distributed int64
|
||||||
|
prizes := make([]int64, len(board))
|
||||||
|
for i := range board {
|
||||||
|
if i >= len(t.PayoutBP) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
prize := poolMsat * int64(t.PayoutBP[i]) / 10000
|
||||||
|
prizes[i] = prize
|
||||||
|
distributed += prize
|
||||||
|
}
|
||||||
|
if remainder := poolMsat - distributed; remainder > 0 {
|
||||||
|
prizes[0] += remainder
|
||||||
|
distributed = poolMsat
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, st := range board {
|
||||||
|
if prizes[i] <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
board[i].PrizeMsat = prizes[i]
|
||||||
|
postings = append(postings, ledger.Posting{
|
||||||
|
AccountID: st.AccountID, AmountMsat: prizes[i]})
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournament_entries SET prize_msat = $3
|
||||||
|
WHERE tournament_id = $1 AND account_id = $2`,
|
||||||
|
tournamentID, st.AccountID, prizes[i]); err != nil {
|
||||||
|
return nil, fmt.Errorf("recording prize: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if distributed > 0 {
|
||||||
|
postings = append(postings, ledger.Posting{
|
||||||
|
AccountID: t.PoolAccountID, AmountMsat: -distributed})
|
||||||
|
if _, err := s.ledger.Post(ctx, "tournament_prize", nil, postings); err != nil {
|
||||||
|
return nil, fmt.Errorf("paying prizes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool must be empty. Anything left would be money stranded in an
|
||||||
|
// account nobody can reach.
|
||||||
|
left, err := s.ledger.Balance(ctx, t.PoolAccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if left != 0 {
|
||||||
|
return nil, fmt.Errorf("tournament %d settled with %d msat stranded in its pool",
|
||||||
|
tournamentID, left)
|
||||||
|
}
|
||||||
|
return board, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel refunds every entry fee and closes the tournament.
|
||||||
|
func (s *Service) Cancel(ctx context.Context, tournamentID int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournaments SET status = 'cancelled', settled_at = now()
|
||||||
|
WHERE id = $1 AND status NOT IN ('settled', 'cancelled')`, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrAlreadySettled
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.Get(ctx, tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if poolMsat == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT account_id FROM tournament_entries WHERE tournament_id = $1`,
|
||||||
|
tournamentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var entrants []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entrants = append(entrants, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if len(entrants) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund the fee each player paid. The pool holds exactly the sum of
|
||||||
|
// those fees, so refunding the entry fee to each empties it precisely.
|
||||||
|
postings := make([]ledger.Posting, 0, len(entrants)+1)
|
||||||
|
var total int64
|
||||||
|
for _, id := range entrants {
|
||||||
|
postings = append(postings, ledger.Posting{AccountID: id, AmountMsat: t.EntryFeeMsat})
|
||||||
|
total += t.EntryFeeMsat
|
||||||
|
}
|
||||||
|
if total != poolMsat {
|
||||||
|
return fmt.Errorf("tournament %d: pool holds %d but refunds total %d",
|
||||||
|
tournamentID, poolMsat, total)
|
||||||
|
}
|
||||||
|
postings = append(postings, ledger.Posting{AccountID: t.PoolAccountID, AmountMsat: -total})
|
||||||
|
|
||||||
|
_, err = s.ledger.Post(ctx, "tournament_refund", nil, postings)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get loads a tournament with its live pool balance and entrant count.
|
||||||
|
func (s *Service) Get(ctx context.Context, id int64) (*Tournament, error) {
|
||||||
|
var t Tournament
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, name, game, status, entry_fee_msat, pool_account_id,
|
||||||
|
payout_bp, max_entrants, registers_at, starts_at, ends_at
|
||||||
|
FROM tournaments WHERE id = $1`, id).
|
||||||
|
Scan(&t.ID, &t.Name, &t.Game, &t.Status, &t.EntryFeeMsat, &t.PoolAccountID,
|
||||||
|
&t.PayoutBP, &t.MaxEntrants, &t.RegistersAt, &t.StartsAt, &t.EndsAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("tournament %d not found: %w", id, err)
|
||||||
|
}
|
||||||
|
t.PoolMsat, _ = s.ledger.Balance(ctx, t.PoolAccountID)
|
||||||
|
_ = s.pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM tournament_entries WHERE tournament_id = $1`,
|
||||||
|
id).Scan(&t.Entrants)
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvanceSchedules moves tournaments through their lifecycle by wall clock.
|
||||||
|
// Any instance may run it; the updates are idempotent.
|
||||||
|
func (s *Service) AdvanceSchedules(ctx context.Context) error {
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournaments SET status = 'registering'
|
||||||
|
WHERE status = 'scheduled' AND registers_at <= now()`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE tournaments SET status = 'running'
|
||||||
|
WHERE status = 'registering' AND starts_at <= now()`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active lists tournaments a player can currently see or join.
|
||||||
|
func (s *Service) Active(ctx context.Context) ([]Tournament, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id FROM tournaments
|
||||||
|
WHERE status IN ('scheduled', 'registering', 'running')
|
||||||
|
ORDER BY starts_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
out := make([]Tournament, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
t, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, *t)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
529
pkg/tournament/tournament_test.go
Normal file
529
pkg/tournament/tournament_test.go
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
package tournament_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/tournament"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
|
||||||
|
|
||||||
|
func testPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("ARCADE_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no database available: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(context.Background()); err != nil {
|
||||||
|
t.Skipf("no database available: %v", err)
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixture struct {
|
||||||
|
t *testing.T
|
||||||
|
svc *tournament.Service
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFixture(t *testing.T) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
pool := testPool(t)
|
||||||
|
l := ledger.New(pool)
|
||||||
|
return &fixture{t: t, svc: tournament.New(pool, l), ledger: l,
|
||||||
|
pool: pool, ctx: context.Background()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixture) player(label string, fundMsat int64) int64 {
|
||||||
|
f.t.Helper()
|
||||||
|
pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label))
|
||||||
|
id, err := f.ledger.EnsurePlayer(f.ctx, pk)
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fundMsat > 0 {
|
||||||
|
if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// open creates a tournament already accepting entries and ending in the past,
|
||||||
|
// so tests can settle without waiting.
|
||||||
|
func (f *fixture) open(entryFee int64, split []int32, ended bool) *tournament.Tournament {
|
||||||
|
f.t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
ends := now.Add(time.Hour)
|
||||||
|
if ended {
|
||||||
|
ends = now.Add(-time.Minute)
|
||||||
|
}
|
||||||
|
t, err := f.svc.Create(f.ctx, "Test Cup", "rocket", entryFee, split, nil,
|
||||||
|
now.Add(-time.Hour), now.Add(-30*time.Minute), ends)
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.AdvanceSchedules(f.ctx); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Registration must be open for entries; AdvanceSchedules may have moved
|
||||||
|
// it straight to running.
|
||||||
|
if _, err := f.pool.Exec(f.ctx,
|
||||||
|
`UPDATE tournaments SET status = 'registering' WHERE id = $1`, t.ID); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- creation ---------------- */
|
||||||
|
|
||||||
|
func TestPayoutSplitMustSumToWhole(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
for _, split := range [][]int32{
|
||||||
|
{5000, 3000}, // 80%
|
||||||
|
{6000, 5000}, // 110%
|
||||||
|
{10000, 1}, // over
|
||||||
|
{}, // nothing
|
||||||
|
} {
|
||||||
|
_, err := f.svc.Create(f.ctx, "bad", "rocket", 1000, split, nil,
|
||||||
|
now, now.Add(time.Minute), now.Add(time.Hour))
|
||||||
|
if !errors.Is(err, tournament.ErrBadPayoutSplit) {
|
||||||
|
t.Errorf("split %v gave %v, want ErrBadPayoutSplit", split, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOpensAPrizePool(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(0, []int32{10000}, false)
|
||||||
|
if tn.PoolMsat != 0 {
|
||||||
|
t.Fatalf("new pool holds %d, want 0", tn.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- entry ---------------- */
|
||||||
|
|
||||||
|
func TestEntryFeeMovesIntoThePool(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(10_000, []int32{10000}, false)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
|
||||||
|
before, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if before-after != 10_000 {
|
||||||
|
t.Fatalf("entry cost %d, want 10000", before-after)
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.PoolMsat != 10_000 {
|
||||||
|
t.Fatalf("pool holds %d, want 10000", got.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotEnterTwice(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(5_000, []int32{10000}, false)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); !errors.Is(err, tournament.ErrAlreadyEntered) {
|
||||||
|
t.Fatalf("got %v, want ErrAlreadyEntered", err)
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.PoolMsat != 5_000 {
|
||||||
|
t.Fatalf("pool holds %d after a duplicate attempt, want 5000", got.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A player who cannot afford the fee must not hold a seat.
|
||||||
|
func TestUnfundedEntryTakesNoSeat(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(50_000, []int32{10000}, false)
|
||||||
|
id := f.player("broke", 100)
|
||||||
|
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err == nil {
|
||||||
|
t.Fatal("an unfunded player entered")
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.Entrants != 0 {
|
||||||
|
t.Fatalf("%d entrants after a failed payment, want 0", got.Entrants)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And they can enter properly once funded.
|
||||||
|
if _, err := f.ledger.Deposit(f.ctx, id, 100_000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatalf("could not enter after funding: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentEntriesChargeOnce(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(10_000, []int32{10000}, false)
|
||||||
|
id := f.player("a", 1_000_000)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]error, 8)
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
results[i] = f.svc.Enter(f.ctx, tn.ID, id)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
ok := 0
|
||||||
|
for _, err := range results {
|
||||||
|
if err == nil {
|
||||||
|
ok++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok != 1 {
|
||||||
|
t.Fatalf("%d concurrent entries succeeded, want 1", ok)
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.PoolMsat != 10_000 {
|
||||||
|
t.Fatalf("pool holds %d, want a single fee of 10000", got.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntrantLimitIsEnforced(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
max := int32(2)
|
||||||
|
tn, err := f.svc.Create(f.ctx, "small", "rocket", 1_000, []int32{10000}, &max,
|
||||||
|
now.Add(-time.Hour), now.Add(time.Hour), now.Add(2*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.pool.Exec(f.ctx,
|
||||||
|
`UPDATE tournaments SET status = 'registering' WHERE id = $1`, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, f.player(fmt.Sprintf("p%d", i), 100_000)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, f.player("late", 100_000)); !errors.Is(err, tournament.ErrFull) {
|
||||||
|
t.Fatalf("got %v, want ErrFull", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- scoring ---------------- */
|
||||||
|
|
||||||
|
func TestLeaderboardOrdersByScore(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(0, []int32{10000}, false)
|
||||||
|
|
||||||
|
scores := map[string]int64{"low": -5_000, "mid": 2_000, "high": 50_000}
|
||||||
|
ids := map[string]int64{}
|
||||||
|
for name, score := range scores {
|
||||||
|
id := f.player(name, 100_000)
|
||||||
|
ids[name] = id
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.RecordResult(f.ctx, tn.ID, id, score); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
board, err := f.svc.Leaderboard(f.ctx, tn.ID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(board) != 3 {
|
||||||
|
t.Fatalf("board has %d entries, want 3", len(board))
|
||||||
|
}
|
||||||
|
if board[0].AccountID != ids["high"] {
|
||||||
|
t.Fatalf("leader is %d, want %d", board[0].AccountID, ids["high"])
|
||||||
|
}
|
||||||
|
if board[2].AccountID != ids["low"] {
|
||||||
|
t.Fatalf("last is %d, want %d", board[2].AccountID, ids["low"])
|
||||||
|
}
|
||||||
|
if board[0].Position != 1 {
|
||||||
|
t.Fatalf("leader position = %d, want 1", board[0].Position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scores accumulate across rounds, and losses count against you.
|
||||||
|
func TestScoresAccumulateIncludingLosses(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(0, []int32{10000}, false)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range []int64{10_000, -3_000, 5_000, -1_000} {
|
||||||
|
if err := f.svc.RecordResult(f.ctx, tn.ID, id, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
board, _ := f.svc.Leaderboard(f.ctx, tn.ID, 1)
|
||||||
|
if board[0].ScoreMsat != 11_000 {
|
||||||
|
t.Fatalf("score = %d, want 11000", board[0].ScoreMsat)
|
||||||
|
}
|
||||||
|
if board[0].RoundsPlayed != 4 {
|
||||||
|
t.Fatalf("rounds = %d, want 4", board[0].RoundsPlayed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- settlement ---------------- */
|
||||||
|
|
||||||
|
// The whole pool must be paid out. Integer division of a pool across shares
|
||||||
|
// leaves a remainder, and a dropped remainder is money destroyed.
|
||||||
|
func TestSettlementDistributesTheEntirePool(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
// 3333/3333/3334 across a pool that does not divide evenly.
|
||||||
|
tn := f.open(3_333, []int32{5000, 3000, 2000}, true)
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
id := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||||
|
ids = append(ids, id)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.RecordResult(f.ctx, tn.ID, id, int64((3-i)*1000)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poolBefore, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
before := make([]int64, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
before[i], _ = f.ledger.Balance(f.ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
board, err := f.svc.Settle(f.ctx, tn.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var paid int64
|
||||||
|
for i, id := range ids {
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
paid += after - before[i]
|
||||||
|
}
|
||||||
|
if paid != poolBefore.PoolMsat {
|
||||||
|
t.Fatalf("paid out %d of a %d pool — %d msat vanished",
|
||||||
|
paid, poolBefore.PoolMsat, poolBefore.PoolMsat-paid)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if after.PoolMsat != 0 {
|
||||||
|
t.Fatalf("%d msat stranded in the pool after settlement", after.PoolMsat)
|
||||||
|
}
|
||||||
|
if board[0].PrizeMsat <= board[1].PrizeMsat {
|
||||||
|
t.Fatalf("first place won %d, second %d", board[0].PrizeMsat, board[1].PrizeMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotSettleBeforeItEnds(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(1_000, []int32{10000}, false) // ends in an hour
|
||||||
|
if _, err := f.svc.Settle(f.ctx, tn.ID); !errors.Is(err, tournament.ErrNotFinished) {
|
||||||
|
t.Fatalf("got %v, want ErrNotFinished", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettlingTwiceIsRefused(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(1_000, []int32{10000}, true)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := f.svc.Settle(f.ctx, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterFirst, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if _, err := f.svc.Settle(f.ctx, tn.ID); !errors.Is(err, tournament.ErrAlreadySettled) {
|
||||||
|
t.Fatalf("got %v, want ErrAlreadySettled", err)
|
||||||
|
}
|
||||||
|
afterSecond, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if afterSecond != afterFirst {
|
||||||
|
t.Fatalf("a second settlement paid again: %d -> %d", afterFirst, afterSecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two instances settling at once must pay out exactly once.
|
||||||
|
func TestConcurrentSettlementPaysOnce(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(2_000, []int32{10000}, true)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
ok := make([]bool, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := f.svc.Settle(f.ctx, tn.ID)
|
||||||
|
ok[i] = err == nil
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
wins := 0
|
||||||
|
for _, v := range ok {
|
||||||
|
if v {
|
||||||
|
wins++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wins != 1 {
|
||||||
|
t.Fatalf("%d concurrent settlements succeeded, want 1", wins)
|
||||||
|
}
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if after-before != 2_000 {
|
||||||
|
t.Fatalf("player received %d, want the single 2000 pool", after-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBooksBalanceAfterSettlement(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(7_777, []int32{6000, 4000}, true)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
id := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.RecordResult(f.ctx, tn.ID, id, int64(i)*100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := f.svc.Settle(f.ctx, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total, err := f.ledger.ConservationCheck(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("books do not balance after tournament settlement: %d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- cancellation ---------------- */
|
||||||
|
|
||||||
|
func TestCancelRefundsEveryEntrant(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(12_000, []int32{10000}, false)
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
var before []int64
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
id := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||||
|
b, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
before = append(before, b)
|
||||||
|
ids = append(ids, id)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.svc.Cancel(f.ctx, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, id := range ids {
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if after != before[i] {
|
||||||
|
t.Fatalf("entrant %d has %d after cancellation, want their original %d",
|
||||||
|
id, after, before[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.PoolMsat != 0 {
|
||||||
|
t.Fatalf("%d msat stranded after cancellation", got.PoolMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelIsIdempotent(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
tn := f.open(1_000, []int32{10000}, false)
|
||||||
|
id := f.player("a", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.svc.Cancel(f.ctx, tn.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterFirst, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if err := f.svc.Cancel(f.ctx, tn.ID); !errors.Is(err, tournament.ErrAlreadySettled) {
|
||||||
|
t.Fatalf("got %v, want ErrAlreadySettled", err)
|
||||||
|
}
|
||||||
|
afterSecond, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if afterSecond != afterFirst {
|
||||||
|
t.Fatalf("a second cancellation refunded again: %d -> %d", afterFirst, afterSecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- lifecycle ---------------- */
|
||||||
|
|
||||||
|
func TestSchedulesAdvanceByClock(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
tn, err := f.svc.Create(f.ctx, "later", "rocket", 0, []int32{10000}, nil,
|
||||||
|
now.Add(-time.Minute), now.Add(-30*time.Second), now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.AdvanceSchedules(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := f.svc.Get(f.ctx, tn.ID)
|
||||||
|
if got.Status != tournament.StatusRunning {
|
||||||
|
t.Fatalf("status = %s, want running once the start time has passed", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotEnterOnceRunning(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
tn, err := f.svc.Create(f.ctx, "started", "rocket", 1_000, []int32{10000}, nil,
|
||||||
|
now.Add(-time.Hour), now.Add(-time.Minute), now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.svc.AdvanceSchedules(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id := f.player("late", 100_000)
|
||||||
|
if err := f.svc.Enter(f.ctx, tn.ID, id); !errors.Is(err, tournament.ErrNotRegistering) {
|
||||||
|
t.Fatalf("got %v, want ErrNotRegistering", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user