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

28 KiB
Raw Permalink Blame History

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

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
// 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
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

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
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

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
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
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

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
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
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

-- 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
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
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

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
// 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
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

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