# 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).