1082 lines
28 KiB
Markdown
1082 lines
28 KiB
Markdown
# Quantum Arcade Foundation Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the deterministic fixed-point physics core and the double-entry ledger — the two components every other part of Quantum Arcade depends on.
|
||
|
||
**Architecture:** A single Go module. `pkg/fixed` provides deterministic Q32.32 fixed-point arithmetic with no floating point anywhere. `pkg/sim` builds a fixed-timestep simulation on top of it, seeded by an integer, producing bit-identical results natively and under WASM. `pkg/ledger` implements append-only double-entry accounting over PostgreSQL, where every transaction's postings sum to zero and balances can never go negative.
|
||
|
||
**Tech Stack:** Go 1.26, PostgreSQL 16, pgx/v5, testify, Docker Compose.
|
||
|
||
## Global Constraints
|
||
|
||
- Go module path: `github.com/drjones/quantum-arcade`
|
||
- No floating-point types (`float32`, `float64`) anywhere in `pkg/fixed` or `pkg/sim`. Enforced by a test that greps the packages.
|
||
- All monetary amounts are `int64` millisatoshis. Never a float, never a string.
|
||
- Ledger tables are append-only: no `UPDATE`, no `DELETE`. Corrections are compensating entries.
|
||
- Every posting set within one transaction sums to exactly zero.
|
||
- Player balances may never go negative.
|
||
- Fixed-point format is Q32.32: `int64` with 32 fractional bits. `One = 1 << 32`.
|
||
|
||
---
|
||
|
||
### Task 1: Fixed-point arithmetic primitives
|
||
|
||
**Files:**
|
||
- Create: `pkg/fixed/fixed.go`
|
||
- Test: `pkg/fixed/fixed_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing
|
||
- Produces: `type F int64`; `const One F = 1 << 32`; `func FromInt(int64) F`; `func (F) Int() int64`; `func (F) Mul(F) F`; `func (F) Div(F) F`; `func (F) Add(F) F`; `func (F) Sub(F) F`; `func (F) String() string`; `func Sqrt(F) F`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package fixed
|
||
|
||
import "testing"
|
||
|
||
func TestFromIntAndBack(t *testing.T) {
|
||
if got := FromInt(7).Int(); got != 7 {
|
||
t.Fatalf("FromInt(7).Int() = %d, want 7", got)
|
||
}
|
||
}
|
||
|
||
func TestMulIsExact(t *testing.T) {
|
||
half := One / 2
|
||
if got := half.Mul(half); got != One/4 {
|
||
t.Fatalf("0.5*0.5 = %d, want %d", got, One/4)
|
||
}
|
||
}
|
||
|
||
func TestDivIsExact(t *testing.T) {
|
||
if got := FromInt(1).Div(FromInt(4)); got != One/4 {
|
||
t.Fatalf("1/4 = %d, want %d", got, One/4)
|
||
}
|
||
}
|
||
|
||
func TestSqrt(t *testing.T) {
|
||
if got := Sqrt(FromInt(4)); got != FromInt(2) {
|
||
t.Fatalf("Sqrt(4) = %v, want 2", got)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `go test ./pkg/fixed/ -run TestFromIntAndBack -v`
|
||
Expected: FAIL — undefined: FromInt
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```go
|
||
// Package fixed provides deterministic Q32.32 fixed-point arithmetic.
|
||
// No floating-point operation appears anywhere in this package: results must be
|
||
// bit-identical across architectures and between native and WASM builds.
|
||
package fixed
|
||
|
||
import (
|
||
"math/bits"
|
||
"strconv"
|
||
)
|
||
|
||
// F is a Q32.32 fixed-point number: an int64 with 32 fractional bits.
|
||
type F int64
|
||
|
||
// One is the fixed-point representation of 1.0.
|
||
const One F = 1 << 32
|
||
|
||
const fracBits = 32
|
||
|
||
// FromInt converts a whole number to fixed-point.
|
||
func FromInt(v int64) F { return F(v << fracBits) }
|
||
|
||
// Int truncates toward zero and returns the whole part.
|
||
func (a F) Int() int64 { return int64(a) >> fracBits }
|
||
|
||
func (a F) Add(b F) F { return a + b }
|
||
func (a F) Sub(b F) F { return a - b }
|
||
|
||
// Mul multiplies via a 128-bit intermediate so no precision is lost before the
|
||
// shift back down. A naive (a*b)>>32 overflows for operands above ~2^15.
|
||
func (a F) Mul(b F) F {
|
||
neg := false
|
||
x, y := int64(a), int64(b)
|
||
if x < 0 {
|
||
x, neg = -x, !neg
|
||
}
|
||
if y < 0 {
|
||
y, neg = -y, !neg
|
||
}
|
||
hi, lo := bits.Mul64(uint64(x), uint64(y))
|
||
res := int64(lo>>fracBits | hi<<(64-fracBits))
|
||
if neg {
|
||
res = -res
|
||
}
|
||
return F(res)
|
||
}
|
||
|
||
// Div divides via a 128-bit intermediate for the same reason as Mul.
|
||
func (a F) Div(b F) F {
|
||
if b == 0 {
|
||
panic("fixed: division by zero")
|
||
}
|
||
neg := false
|
||
x, y := int64(a), int64(b)
|
||
if x < 0 {
|
||
x, neg = -x, !neg
|
||
}
|
||
if y < 0 {
|
||
y, neg = -y, !neg
|
||
}
|
||
hi := uint64(x) >> (64 - fracBits)
|
||
lo := uint64(x) << fracBits
|
||
q, _ := bits.Div64(hi, lo, uint64(y))
|
||
res := int64(q)
|
||
if neg {
|
||
res = -res
|
||
}
|
||
return F(res)
|
||
}
|
||
|
||
// Sqrt returns the fixed-point square root using integer Newton iteration.
|
||
func Sqrt(a F) F {
|
||
if a < 0 {
|
||
panic("fixed: sqrt of negative")
|
||
}
|
||
if a == 0 {
|
||
return 0
|
||
}
|
||
// Initial guess: shift-based estimate of the integer square root.
|
||
x := F(1) << F(uint(bits.Len64(uint64(a))+fracBits)/2)
|
||
for i := 0; i < 40; i++ {
|
||
if x == 0 {
|
||
return 0
|
||
}
|
||
next := (x + a.Div(x)) / 2
|
||
if next == x {
|
||
break
|
||
}
|
||
x = next
|
||
}
|
||
return x
|
||
}
|
||
|
||
// String renders the value with 6 fractional digits, without using floats.
|
||
func (a F) String() string {
|
||
neg := a < 0
|
||
if neg {
|
||
a = -a
|
||
}
|
||
whole := int64(a) >> fracBits
|
||
frac := int64(a) & (int64(One) - 1)
|
||
// Scale the fraction to 6 decimal places using integer math only.
|
||
micros := (frac * 1_000_000) >> fracBits
|
||
s := strconv.FormatInt(whole, 10) + "." + pad6(micros)
|
||
if neg {
|
||
return "-" + s
|
||
}
|
||
return s
|
||
}
|
||
|
||
func pad6(v int64) string {
|
||
s := strconv.FormatInt(v, 10)
|
||
for len(s) < 6 {
|
||
s = "0" + s
|
||
}
|
||
return s
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `go test ./pkg/fixed/ -v`
|
||
Expected: PASS (all four tests)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add pkg/fixed/
|
||
git commit -m "feat(fixed): add Q32.32 deterministic fixed-point arithmetic"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: No-float enforcement test
|
||
|
||
**Files:**
|
||
- Create: `pkg/fixed/nofloat_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing
|
||
- Produces: nothing (a guard test)
|
||
|
||
- [ ] **Step 1: Write the test**
|
||
|
||
```go
|
||
package fixed_test
|
||
|
||
import (
|
||
"go/ast"
|
||
"go/parser"
|
||
"go/token"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// Determinism depends on there being no floating-point arithmetic in the
|
||
// simulation path. This test fails the build if a float type is ever
|
||
// introduced into pkg/fixed or pkg/sim.
|
||
func TestNoFloatingPointInDeterministicPackages(t *testing.T) {
|
||
for _, dir := range []string{".", "../sim"} {
|
||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||
continue
|
||
}
|
||
fset := token.NewFileSet()
|
||
pkgs, err := parser.ParseDir(fset, dir, nil, 0)
|
||
if err != nil {
|
||
t.Fatalf("parse %s: %v", dir, err)
|
||
}
|
||
for _, pkg := range pkgs {
|
||
for name, file := range pkg.Files {
|
||
if strings.HasSuffix(name, "_test.go") {
|
||
continue
|
||
}
|
||
ast.Inspect(file, func(n ast.Node) bool {
|
||
id, ok := n.(*ast.Ident)
|
||
if !ok {
|
||
return true
|
||
}
|
||
if id.Name == "float32" || id.Name == "float64" {
|
||
t.Errorf("%s: forbidden float type %q in deterministic package",
|
||
filepath.Base(name), id.Name)
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it**
|
||
|
||
Run: `go test ./pkg/fixed/ -run TestNoFloatingPoint -v`
|
||
Expected: PASS (no floats present yet)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add pkg/fixed/nofloat_test.go
|
||
git commit -m "test(fixed): forbid float types in deterministic packages"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Seeded deterministic RNG
|
||
|
||
**Files:**
|
||
- Create: `pkg/sim/rng.go`
|
||
- Test: `pkg/sim/rng_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `fixed.F`, `fixed.One`
|
||
- Produces: `type RNG struct{...}`; `func NewRNG(seed [32]byte) *RNG`; `func (*RNG) Uint64() uint64`; `func (*RNG) Unit() fixed.F` (returns [0,1))
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package sim
|
||
|
||
import "testing"
|
||
|
||
func TestRNGIsDeterministic(t *testing.T) {
|
||
var seed [32]byte
|
||
copy(seed[:], "quantum-arcade-test-seed")
|
||
a, b := NewRNG(seed), NewRNG(seed)
|
||
for i := 0; i < 1000; i++ {
|
||
if x, y := a.Uint64(), b.Uint64(); x != y {
|
||
t.Fatalf("iteration %d: %d != %d", i, x, y)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestUnitInRange(t *testing.T) {
|
||
var seed [32]byte
|
||
seed[0] = 9
|
||
r := NewRNG(seed)
|
||
for i := 0; i < 10000; i++ {
|
||
u := r.Unit()
|
||
if u < 0 || u >= 1<<32 {
|
||
t.Fatalf("Unit() = %v out of [0,1)", u)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `go test ./pkg/sim/ -run TestRNG -v`
|
||
Expected: FAIL — undefined: NewRNG
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```go
|
||
package sim
|
||
|
||
import (
|
||
"encoding/binary"
|
||
|
||
"github.com/drjones/quantum-arcade/pkg/fixed"
|
||
)
|
||
|
||
// RNG is a deterministic ChaCha-style counter PRNG seeded from 32 bytes.
|
||
// It uses only integer operations so it is reproducible across platforms.
|
||
type RNG struct {
|
||
state [4]uint64
|
||
counter uint64
|
||
}
|
||
|
||
// NewRNG creates a reproducible generator from a 32-byte seed.
|
||
func NewRNG(seed [32]byte) *RNG {
|
||
r := &RNG{}
|
||
for i := 0; i < 4; i++ {
|
||
r.state[i] = binary.LittleEndian.Uint64(seed[i*8 : i*8+8])
|
||
}
|
||
// Guard against an all-zero state, which would be a fixed point.
|
||
if r.state[0]|r.state[1]|r.state[2]|r.state[3] == 0 {
|
||
r.state[0] = 0x9E3779B97F4A7C15
|
||
}
|
||
return r
|
||
}
|
||
|
||
// Uint64 returns the next 64 bits using xoshiro256** mixing.
|
||
func (r *RNG) Uint64() uint64 {
|
||
s := &r.state
|
||
result := rotl(s[1]*5, 7) * 9
|
||
t := s[1] << 17
|
||
s[2] ^= s[0]
|
||
s[3] ^= s[1]
|
||
s[1] ^= s[2]
|
||
s[0] ^= s[3]
|
||
s[2] ^= t
|
||
s[3] = rotl(s[3], 45)
|
||
r.counter++
|
||
return result
|
||
}
|
||
|
||
func rotl(x uint64, k uint) uint64 { return (x << k) | (x >> (64 - k)) }
|
||
|
||
// Unit returns a fixed-point value uniformly distributed over [0, 1).
|
||
func (r *RNG) Unit() fixed.F {
|
||
// Take the top 32 bits so the result occupies exactly the fractional part.
|
||
return fixed.F(r.Uint64() >> 32)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `go test ./pkg/sim/ -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add pkg/sim/rng.go pkg/sim/rng_test.go
|
||
git commit -m "feat(sim): add deterministic seeded RNG"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Crash curve and outcome derivation
|
||
|
||
**Files:**
|
||
- Create: `pkg/sim/crash.go`
|
||
- Test: `pkg/sim/crash_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `fixed.F`, `RNG`
|
||
- Produces: `const HouseEdgeBP int64 = 200`; `func CrashPoint(seed [32]byte) fixed.F`; `func MultiplierAt(tick int) fixed.F`; `func TicksToMultiplier(m fixed.F) int`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package sim
|
||
|
||
import "testing"
|
||
|
||
func TestCrashPointNeverBelowOne(t *testing.T) {
|
||
for i := 0; i < 20000; i++ {
|
||
var seed [32]byte
|
||
seed[0], seed[1] = byte(i), byte(i>>8)
|
||
if cp := CrashPoint(seed); cp < 1<<32 {
|
||
t.Fatalf("seed %d: crash point %v below 1.0", i, cp)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCrashPointIsDeterministic(t *testing.T) {
|
||
var seed [32]byte
|
||
copy(seed[:], "repeatable")
|
||
first := CrashPoint(seed)
|
||
for i := 0; i < 100; i++ {
|
||
if got := CrashPoint(seed); got != first {
|
||
t.Fatalf("run %d: %v != %v", i, got, first)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestHouseEdgeIsApproximatelyTwoPercent(t *testing.T) {
|
||
// With a 2% edge, cashing out at exactly 2.00x should win slightly
|
||
// under half the time. Sample enough seeds to check the distribution.
|
||
const n = 200000
|
||
target := 2 * (1 << 32)
|
||
wins := 0
|
||
for i := 0; i < n; i++ {
|
||
var seed [32]byte
|
||
seed[0], seed[1], seed[2] = byte(i), byte(i>>8), byte(i>>16)
|
||
if int64(CrashPoint(seed)) >= int64(target) {
|
||
wins++
|
||
}
|
||
}
|
||
// Fair would be 50%; a 2% edge puts it near 49%. Allow a 1.5pt band.
|
||
pct := float64(wins) * 100 / n
|
||
if pct < 47.5 || pct > 50.5 {
|
||
t.Fatalf("win rate at 2.00x = %.2f%%, want ~49%%", pct)
|
||
}
|
||
}
|
||
|
||
func TestMultiplierStartsAtOne(t *testing.T) {
|
||
if got := MultiplierAt(0); got != 1<<32 {
|
||
t.Fatalf("MultiplierAt(0) = %v, want 1.0", got)
|
||
}
|
||
}
|
||
|
||
func TestMultiplierIsMonotonic(t *testing.T) {
|
||
prev := MultiplierAt(0)
|
||
for tick := 1; tick < 5000; tick++ {
|
||
cur := MultiplierAt(tick)
|
||
if cur < prev {
|
||
t.Fatalf("tick %d: multiplier decreased %v -> %v", tick, prev, cur)
|
||
}
|
||
prev = cur
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: `crash_test.go` may use floats for statistical assertions; the no-float
|
||
guard in Task 2 skips `_test.go` files for exactly this reason.
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `go test ./pkg/sim/ -run TestCrash -v`
|
||
Expected: FAIL — undefined: CrashPoint
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```go
|
||
package sim
|
||
|
||
import "github.com/drjones/quantum-arcade/pkg/fixed"
|
||
|
||
// HouseEdgeBP is the house edge in basis points (200 = 2.00%).
|
||
const HouseEdgeBP int64 = 200
|
||
|
||
// TickHz is the simulation rate. All rounds advance in whole ticks.
|
||
const TickHz = 60
|
||
|
||
// growthPerTickBP is the multiplier growth per tick in basis points of
|
||
// the current value: 1.0006x per tick compounds to roughly 2x in ~19 seconds.
|
||
const growthPerTickBP int64 = 6
|
||
|
||
// CrashPoint derives the multiplier at which a round ends, as a pure function
|
||
// of the seed. The distribution is the standard inverse-uniform curve scaled by
|
||
// the house edge, which gives an expected return of (1 - edge) at every
|
||
// cash-out target.
|
||
func CrashPoint(seed [32]byte) fixed.F {
|
||
r := NewRNG(seed)
|
||
// u is uniform over [0,1); take the top 52 bits for resolution.
|
||
u := r.Uint64() >> 12
|
||
const denom = 1 << 52
|
||
if u == 0 {
|
||
u = 1
|
||
}
|
||
// crash = (1 - edge) / u, in fixed point.
|
||
edgeNum := 10000 - HouseEdgeBP
|
||
// numerator = (edgeNum/10000) * denom, kept in integer form.
|
||
num := fixed.FromInt(int64(denom)).Mul(fixed.F(edgeNum << 32 / 10000))
|
||
cp := num.Div(fixed.FromInt(int64(u)))
|
||
if cp < fixed.One {
|
||
cp = fixed.One
|
||
}
|
||
return cp
|
||
}
|
||
|
||
// MultiplierAt returns the multiplier shown at a given tick of the round.
|
||
// It compounds growthPerTickBP per tick starting from 1.0.
|
||
func MultiplierAt(tick int) fixed.F {
|
||
m := fixed.One
|
||
step := fixed.One + fixed.F(growthPerTickBP<<32/10000)
|
||
for i := 0; i < tick; i++ {
|
||
m = m.Mul(step)
|
||
}
|
||
return m
|
||
}
|
||
|
||
// TicksToMultiplier returns the first tick at which MultiplierAt reaches m.
|
||
func TicksToMultiplier(m fixed.F) int {
|
||
cur := fixed.One
|
||
step := fixed.One + fixed.F(growthPerTickBP<<32/10000)
|
||
for tick := 0; tick < 1_000_000; tick++ {
|
||
if cur >= m {
|
||
return tick
|
||
}
|
||
cur = cur.Mul(step)
|
||
}
|
||
return 1_000_000
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `go test ./pkg/sim/ -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add pkg/sim/crash.go pkg/sim/crash_test.go
|
||
git commit -m "feat(sim): add crash point derivation and multiplier curve"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Ledger schema
|
||
|
||
**Files:**
|
||
- Create: `migrations/0001_ledger.sql`
|
||
- Create: `docker-compose.yml`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing
|
||
- Produces: tables `accounts`, `transactions`, `postings`; view `account_balances`
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
```sql
|
||
-- Quantum Arcade ledger: append-only double-entry accounting.
|
||
-- Amounts are millisatoshis stored as BIGINT. No UPDATE or DELETE is ever
|
||
-- issued against these tables; corrections are compensating transactions.
|
||
|
||
CREATE TYPE account_kind AS ENUM ('player', 'house', 'lightning_bridge');
|
||
|
||
CREATE TABLE accounts (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
kind account_kind NOT NULL,
|
||
-- Player accounts key on the ed25519 public key; system accounts use a
|
||
-- stable name. Exactly one of these is set.
|
||
pubkey BYTEA UNIQUE,
|
||
name TEXT UNIQUE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CONSTRAINT account_identity CHECK (
|
||
(kind = 'player' AND pubkey IS NOT NULL AND name IS NULL) OR
|
||
(kind <> 'player' AND pubkey IS NULL AND name IS NOT NULL)
|
||
)
|
||
);
|
||
|
||
CREATE TABLE transactions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
kind TEXT NOT NULL, -- 'bet', 'payout', 'deposit', ...
|
||
round_id BIGINT, -- NULL for non-game transactions
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE postings (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
transaction_id BIGINT NOT NULL REFERENCES transactions(id),
|
||
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||
-- Positive credits the account, negative debits it.
|
||
amount_msat BIGINT NOT NULL,
|
||
balance_before BIGINT NOT NULL,
|
||
balance_after BIGINT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CONSTRAINT amount_nonzero CHECK (amount_msat <> 0),
|
||
CONSTRAINT balance_arithmetic CHECK (balance_after = balance_before + amount_msat),
|
||
CONSTRAINT balance_nonnegative CHECK (balance_after >= 0)
|
||
);
|
||
|
||
CREATE INDEX postings_account_idx ON postings (account_id, id DESC);
|
||
CREATE INDEX postings_transaction_idx ON postings (transaction_id);
|
||
CREATE INDEX transactions_round_idx ON transactions (round_id) WHERE round_id IS NOT NULL;
|
||
|
||
-- Current balance is the most recent posting's balance_after.
|
||
CREATE VIEW account_balances AS
|
||
SELECT DISTINCT ON (account_id)
|
||
account_id, balance_after AS balance_msat
|
||
FROM postings
|
||
ORDER BY account_id, id DESC;
|
||
|
||
-- Enforce append-only at the database level.
|
||
CREATE OR REPLACE FUNCTION reject_mutation() RETURNS TRIGGER AS $$
|
||
BEGIN
|
||
RAISE EXCEPTION 'ledger tables are append-only';
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER postings_append_only
|
||
BEFORE UPDATE OR DELETE ON postings
|
||
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
|
||
|
||
CREATE TRIGGER transactions_append_only
|
||
BEFORE UPDATE OR DELETE ON transactions
|
||
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
|
||
|
||
INSERT INTO accounts (kind, name) VALUES
|
||
('house', 'house_pot'),
|
||
('lightning_bridge', 'lightning_bridge');
|
||
```
|
||
|
||
- [ ] **Step 2: Write the compose file**
|
||
|
||
```yaml
|
||
services:
|
||
postgres:
|
||
image: postgres:16-alpine
|
||
environment:
|
||
POSTGRES_USER: arcade
|
||
POSTGRES_PASSWORD: arcade_dev
|
||
POSTGRES_DB: arcade
|
||
ports: ["5432:5432"]
|
||
volumes:
|
||
- pgdata:/var/lib/postgresql/data
|
||
- ./migrations:/docker-entrypoint-initdb.d:ro
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U arcade"]
|
||
interval: 2s
|
||
timeout: 3s
|
||
retries: 20
|
||
|
||
redis:
|
||
image: redis:7-alpine
|
||
ports: ["6379:6379"]
|
||
volumes:
|
||
- redisdata:/data
|
||
|
||
volumes:
|
||
pgdata:
|
||
redisdata:
|
||
```
|
||
|
||
- [ ] **Step 3: Bring it up and verify the schema loads**
|
||
|
||
Run: `docker compose up -d postgres && sleep 8 && docker compose exec -T postgres psql -U arcade -d arcade -c '\dt'`
|
||
Expected: `accounts`, `postings`, `transactions` listed
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add migrations/ docker-compose.yml
|
||
git commit -m "feat(ledger): add append-only double-entry schema"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Ledger engine
|
||
|
||
**Files:**
|
||
- Create: `pkg/ledger/ledger.go`
|
||
- Test: `pkg/ledger/ledger_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: schema from Task 5
|
||
- Produces: `type Posting struct{AccountID int64; AmountMsat int64}`; `type Ledger struct{...}`; `func New(*pgxpool.Pool) *Ledger`; `func (*Ledger) Post(ctx, kind string, roundID *int64, postings []Posting) (txID int64, err error)`; `func (*Ledger) Balance(ctx, accountID int64) (int64, error)`; `func (*Ledger) EnsurePlayer(ctx, pubkey []byte) (int64, error)`; `var ErrUnbalanced`; `var ErrInsufficientFunds`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package ledger_test
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"os"
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||
)
|
||
|
||
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)
|
||
}
|
||
return pool
|
||
}
|
||
|
||
func TestPostRejectsUnbalanced(t *testing.T) {
|
||
l := ledger.New(testPool(t))
|
||
ctx := context.Background()
|
||
a, _ := l.EnsurePlayer(ctx, []byte("pubkey-unbalanced-a"))
|
||
b, _ := l.EnsurePlayer(ctx, []byte("pubkey-unbalanced-b"))
|
||
_, err := l.Post(ctx, "test", nil, []ledger.Posting{
|
||
{AccountID: a, AmountMsat: -100},
|
||
{AccountID: b, AmountMsat: 50},
|
||
})
|
||
if !errors.Is(err, ledger.ErrUnbalanced) {
|
||
t.Fatalf("got %v, want ErrUnbalanced", err)
|
||
}
|
||
}
|
||
|
||
func TestPostRejectsOverdraft(t *testing.T) {
|
||
l := ledger.New(testPool(t))
|
||
ctx := context.Background()
|
||
a, _ := l.EnsurePlayer(ctx, []byte("pubkey-overdraft-a"))
|
||
b, _ := l.EnsurePlayer(ctx, []byte("pubkey-overdraft-b"))
|
||
_, err := l.Post(ctx, "test", nil, []ledger.Posting{
|
||
{AccountID: a, AmountMsat: -1_000_000},
|
||
{AccountID: b, AmountMsat: 1_000_000},
|
||
})
|
||
if !errors.Is(err, ledger.ErrInsufficientFunds) {
|
||
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
||
}
|
||
}
|
||
|
||
func TestConservationOfValue(t *testing.T) {
|
||
l := ledger.New(testPool(t))
|
||
ctx := context.Background()
|
||
bridge, err := l.AccountByName(ctx, "lightning_bridge")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
p, _ := l.EnsurePlayer(ctx, []byte("pubkey-conservation"))
|
||
|
||
before, _ := l.TotalIssued(ctx)
|
||
// Fund the player from the bridge, then move it back.
|
||
if _, err := l.Post(ctx, "deposit", nil, []ledger.Posting{
|
||
{AccountID: bridge, AmountMsat: -5000},
|
||
{AccountID: p, AmountMsat: 5000},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := l.Post(ctx, "withdraw", nil, []ledger.Posting{
|
||
{AccountID: p, AmountMsat: -5000},
|
||
{AccountID: bridge, AmountMsat: 5000},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
after, _ := l.TotalIssued(ctx)
|
||
if before != after {
|
||
t.Fatalf("total value changed: %d -> %d", before, after)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `go test ./pkg/ledger/ -v`
|
||
Expected: FAIL — undefined: ledger.New
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```go
|
||
// Package ledger implements append-only double-entry accounting.
|
||
//
|
||
// Invariants, enforced here and again by database constraints:
|
||
// - every transaction's postings sum to exactly zero
|
||
// - no account balance may go negative
|
||
// - rows are never updated or deleted
|
||
package ledger
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
)
|
||
|
||
var (
|
||
ErrUnbalanced = errors.New("ledger: postings do not sum to zero")
|
||
ErrInsufficientFunds = errors.New("ledger: insufficient funds")
|
||
ErrEmptyTransaction = errors.New("ledger: transaction has no postings")
|
||
)
|
||
|
||
// Posting is a single leg of a transaction. Positive credits, negative debits.
|
||
type Posting struct {
|
||
AccountID int64
|
||
AmountMsat int64
|
||
}
|
||
|
||
type Ledger struct{ pool *pgxpool.Pool }
|
||
|
||
func New(pool *pgxpool.Pool) *Ledger { return &Ledger{pool: pool} }
|
||
|
||
// Post writes one balanced transaction atomically. Accounts are locked in a
|
||
// stable order so concurrent transactions cannot deadlock.
|
||
func (l *Ledger) Post(ctx context.Context, kind string, roundID *int64, postings []Posting) (int64, error) {
|
||
if len(postings) == 0 {
|
||
return 0, ErrEmptyTransaction
|
||
}
|
||
var sum int64
|
||
for _, p := range postings {
|
||
sum += p.AmountMsat
|
||
}
|
||
if sum != 0 {
|
||
return 0, fmt.Errorf("%w: sum is %d", ErrUnbalanced, sum)
|
||
}
|
||
|
||
tx, err := l.pool.Begin(ctx)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer tx.Rollback(ctx)
|
||
|
||
var txID int64
|
||
if err := tx.QueryRow(ctx,
|
||
`INSERT INTO transactions (kind, round_id) VALUES ($1, $2) RETURNING id`,
|
||
kind, roundID).Scan(&txID); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
// Lock accounts in ascending id order to keep lock acquisition consistent.
|
||
ordered := append([]Posting(nil), postings...)
|
||
for i := 1; i < len(ordered); i++ {
|
||
for j := i; j > 0 && ordered[j].AccountID < ordered[j-1].AccountID; j-- {
|
||
ordered[j], ordered[j-1] = ordered[j-1], ordered[j]
|
||
}
|
||
}
|
||
|
||
for _, p := range ordered {
|
||
var before int64
|
||
err := tx.QueryRow(ctx,
|
||
`SELECT COALESCE(
|
||
(SELECT balance_after FROM postings
|
||
WHERE account_id = $1 ORDER BY id DESC LIMIT 1), 0)
|
||
FROM accounts WHERE id = $1 FOR UPDATE`,
|
||
p.AccountID).Scan(&before)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("locking account %d: %w", p.AccountID, err)
|
||
}
|
||
after := before + p.AmountMsat
|
||
if after < 0 {
|
||
return 0, fmt.Errorf("%w: account %d has %d, needs %d",
|
||
ErrInsufficientFunds, p.AccountID, before, -p.AmountMsat)
|
||
}
|
||
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, p.AccountID, p.AmountMsat, before, after); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return 0, err
|
||
}
|
||
return txID, nil
|
||
}
|
||
|
||
// Balance returns the account's current balance in millisatoshis.
|
||
func (l *Ledger) Balance(ctx context.Context, accountID int64) (int64, error) {
|
||
var bal int64
|
||
err := l.pool.QueryRow(ctx,
|
||
`SELECT COALESCE(
|
||
(SELECT balance_after FROM postings
|
||
WHERE account_id = $1 ORDER BY id DESC LIMIT 1), 0)`,
|
||
accountID).Scan(&bal)
|
||
return bal, err
|
||
}
|
||
|
||
// EnsurePlayer returns the account id for a public key, creating it if needed.
|
||
func (l *Ledger) EnsurePlayer(ctx context.Context, pubkey []byte) (int64, error) {
|
||
var id int64
|
||
err := l.pool.QueryRow(ctx,
|
||
`INSERT INTO accounts (kind, pubkey) VALUES ('player', $1)
|
||
ON CONFLICT (pubkey) DO UPDATE SET pubkey = EXCLUDED.pubkey
|
||
RETURNING id`, pubkey).Scan(&id)
|
||
return id, err
|
||
}
|
||
|
||
// AccountByName resolves a system account such as "house_pot".
|
||
func (l *Ledger) AccountByName(ctx context.Context, name string) (int64, error) {
|
||
var id int64
|
||
err := l.pool.QueryRow(ctx,
|
||
`SELECT id FROM accounts WHERE name = $1`, name).Scan(&id)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return 0, fmt.Errorf("ledger: no account named %q", name)
|
||
}
|
||
return id, err
|
||
}
|
||
|
||
// TotalIssued sums every account balance. It must be invariant across any
|
||
// sequence of balanced transactions.
|
||
func (l *Ledger) TotalIssued(ctx context.Context) (int64, error) {
|
||
var total int64
|
||
err := l.pool.QueryRow(ctx,
|
||
`SELECT COALESCE(SUM(balance_msat), 0) FROM account_balances`).Scan(&total)
|
||
return total, err
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `docker compose up -d postgres && sleep 8 && go test ./pkg/ledger/ -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add pkg/ledger/
|
||
git commit -m "feat(ledger): add double-entry posting engine"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Ledger property tests
|
||
|
||
**Files:**
|
||
- Create: `pkg/ledger/property_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ledger.Ledger`, `ledger.Posting`
|
||
- Produces: nothing
|
||
|
||
- [ ] **Step 1: Write the test**
|
||
|
||
```go
|
||
package ledger_test
|
||
|
||
import (
|
||
"context"
|
||
"math/rand"
|
||
"testing"
|
||
|
||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||
)
|
||
|
||
// Across a long run of random balanced transfers between random accounts,
|
||
// total value must never change and no balance may go negative.
|
||
func TestRandomTransfersConserveValue(t *testing.T) {
|
||
l := ledger.New(testPool(t))
|
||
ctx := context.Background()
|
||
|
||
bridge, err := l.AccountByName(ctx, "lightning_bridge")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
const players = 8
|
||
ids := make([]int64, players)
|
||
for i := range ids {
|
||
id, err := l.EnsurePlayer(ctx, []byte{'p', 'r', 'o', 'p', byte(i)})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ids[i] = id
|
||
// Fund each player so transfers have something to move.
|
||
if _, err := l.Post(ctx, "deposit", nil, []ledger.Posting{
|
||
{AccountID: bridge, AmountMsat: -100_000},
|
||
{AccountID: id, AmountMsat: 100_000},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
before, err := l.TotalIssued(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
rng := rand.New(rand.NewSource(1))
|
||
for i := 0; i < 300; i++ {
|
||
from := ids[rng.Intn(players)]
|
||
to := ids[rng.Intn(players)]
|
||
if from == to {
|
||
continue
|
||
}
|
||
amt := int64(rng.Intn(5000) + 1)
|
||
_, err := l.Post(ctx, "transfer", nil, []ledger.Posting{
|
||
{AccountID: from, AmountMsat: -amt},
|
||
{AccountID: to, AmountMsat: amt},
|
||
})
|
||
// Insufficient funds is an acceptable outcome; anything else is not.
|
||
if err != nil && !isInsufficient(err) {
|
||
t.Fatalf("iteration %d: %v", i, err)
|
||
}
|
||
}
|
||
|
||
after, err := l.TotalIssued(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if before != after {
|
||
t.Fatalf("value not conserved: %d -> %d", before, after)
|
||
}
|
||
|
||
for _, id := range ids {
|
||
bal, err := l.Balance(ctx, id)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if bal < 0 {
|
||
t.Fatalf("account %d went negative: %d", id, bal)
|
||
}
|
||
}
|
||
}
|
||
|
||
func isInsufficient(err error) bool {
|
||
return err != nil && (err == ledger.ErrInsufficientFunds ||
|
||
containsErr(err, ledger.ErrInsufficientFunds))
|
||
}
|
||
|
||
func containsErr(err, target error) bool {
|
||
type unwrapper interface{ Unwrap() error }
|
||
for err != nil {
|
||
if err == target {
|
||
return true
|
||
}
|
||
u, ok := err.(unwrapper)
|
||
if !ok {
|
||
return false
|
||
}
|
||
err = u.Unwrap()
|
||
}
|
||
return false
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it**
|
||
|
||
Run: `go test ./pkg/ledger/ -run TestRandomTransfers -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add pkg/ledger/property_test.go
|
||
git commit -m "test(ledger): add value-conservation property test"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
**Spec coverage for this milestone:** §3 module boundaries (packages created),
|
||
§4 determinism (Tasks 1–4), §7 ledger invariants (Tasks 5–7), §9 testing for
|
||
ledger and determinism (Tasks 2, 3, 7).
|
||
|
||
**Deferred to later plans:** identity/keypairs (§2), commit-reveal fairness
|
||
(§5), games and round lifecycle (§6), Lightning (§7), UI (§8), load harness
|
||
(§9), deployment (§10).
|