28 KiB
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 inpkg/fixedorpkg/sim. Enforced by a test that greps the packages. - All monetary amounts are
int64millisatoshis. Never a float, never a string. - Ledger tables are append-only: no
UPDATE, noDELETE. 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:
int64with 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; viewaccount_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 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).