Files
casino/docs/superpowers/plans/2026-08-05-foundation.md
2026-08-05 02:19:06 +00:00

1082 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 14), §7 ledger invariants (Tasks 57), §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).