At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the rarest and most valuable outcome silently became an instant 1.00x loss. At u=2 it produced a 2.1-billion-times payout the house could never cover, which would have left settlement failing and the player unpaid. The crash point is now capped at the largest multiplier the curve can express, which is unreachable anyway since the round hits its tick ceiling first. FromInt now panics outside the Q32.32 integer range instead of wrapping a positive input into a negative value. Raises coverage to 88% overall; adds a Makefile with db-reset, since the append-only ledger steadily consumes bridge headroom across test runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
7.0 KiB
Go
245 lines
7.0 KiB
Go
package ledger_test
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
|
)
|
|
|
|
// These tests attack the ledger with extreme values. Money code fails at the
|
|
// boundaries, so the boundaries are where it should be hit hardest.
|
|
|
|
func TestHugeBalanceIsExact(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
p, err := l.EnsurePlayer(ctx, uniqueKey(t, "whale"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 21 million BTC in millisatoshis is the largest amount that can ever
|
|
// exist: 2.1e18. It must round-trip exactly, with no float contamination.
|
|
const allTheBitcoin int64 = 21_000_000 * 100_000_000 * 1000
|
|
|
|
// The ledger is append-only and never truncated, so repeated runs steadily
|
|
// consume the bridge's headroom. Skip rather than fail when it is spent —
|
|
// that is an exhausted fixture, not a defect. Reset with `make db-reset`.
|
|
issued, err := l.TotalIssued(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if math.MaxInt64-issued < allTheBitcoin {
|
|
t.Skipf("bridge headroom exhausted (%d issued); run `make db-reset`", issued)
|
|
}
|
|
|
|
if _, err := l.Deposit(ctx, p, allTheBitcoin); err != nil {
|
|
t.Fatalf("depositing the entire supply: %v", err)
|
|
}
|
|
bal, err := l.Balance(ctx, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bal != allTheBitcoin {
|
|
t.Fatalf("balance = %d, want %d (off by %d)", bal, allTheBitcoin, bal-allTheBitcoin)
|
|
}
|
|
}
|
|
|
|
// A deposit that would overflow int64 must be refused, not wrap around.
|
|
func TestOverflowingDepositIsRejected(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "overflow"))
|
|
|
|
// A MaxInt64 deposit must be refused: it would underflow the bridge, whose
|
|
// balance is already negative by everything owed to players.
|
|
if _, err := l.Deposit(ctx, p, math.MaxInt64); err == nil {
|
|
t.Fatal("a MaxInt64 deposit was accepted")
|
|
}
|
|
if bal, _ := l.Balance(ctx, p); bal != 0 {
|
|
t.Fatalf("rejected deposit still moved the balance to %d", bal)
|
|
}
|
|
}
|
|
|
|
// Credit overflow is structurally unreachable, and that is a stronger
|
|
// guarantee than the runtime guard.
|
|
//
|
|
// Every millisatoshi inside the system was issued by debiting the bridge, and
|
|
// the bridge cannot pass MinInt64. So the sum of all non-bridge balances is
|
|
// bounded by MaxInt64, and no individual account can be pushed past it by any
|
|
// sequence of balanced transactions. The guard in Post remains as defence in
|
|
// depth against a future issuance path that does not go through the bridge.
|
|
func TestIssuanceIsBoundedByTheBridge(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "bounded"))
|
|
|
|
// Attempting to issue more than the bridge can back must fail.
|
|
if _, err := l.Deposit(ctx, p, math.MaxInt64); err == nil {
|
|
t.Fatal("issued more than the bridge can back")
|
|
}
|
|
|
|
// And whatever has been issued must still fit in an int64, which is what
|
|
// makes every downstream balance arithmetic safe.
|
|
issued, err := l.TotalIssued(ctx)
|
|
if err != nil {
|
|
t.Fatalf("total issuance is no longer representable: %v", err)
|
|
}
|
|
if issued < 0 {
|
|
t.Fatalf("total issued is negative: %d", issued)
|
|
}
|
|
}
|
|
|
|
// Postings that individually fit but collectively overflow the zero-sum check.
|
|
func TestOverflowingPostingSetIsRejected(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
|
b, _ := l.EnsurePlayer(ctx, uniqueKey(t, "b"))
|
|
c, _ := l.EnsurePlayer(ctx, uniqueKey(t, "c"))
|
|
|
|
// These sum to zero only if you ignore wraparound.
|
|
_, err := l.Post(ctx, "attack", nil, []ledger.Posting{
|
|
{AccountID: a, AmountMsat: math.MaxInt64},
|
|
{AccountID: b, AmountMsat: math.MaxInt64},
|
|
{AccountID: c, AmountMsat: 2},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("a posting set that overflows int64 was accepted")
|
|
}
|
|
}
|
|
|
|
func TestSmallestPossibleAmount(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "dust"))
|
|
|
|
if _, err := l.Deposit(ctx, p, 1); err != nil {
|
|
t.Fatalf("one millisatoshi rejected: %v", err)
|
|
}
|
|
if bal, _ := l.Balance(ctx, p); bal != 1 {
|
|
t.Fatalf("balance = %d, want 1", bal)
|
|
}
|
|
// Spending exactly the balance must leave zero, not fail.
|
|
q, _ := l.EnsurePlayer(ctx, uniqueKey(t, "dust2"))
|
|
if _, err := l.Transfer(ctx, p, q, 1); err != nil {
|
|
t.Fatalf("spending the exact balance failed: %v", err)
|
|
}
|
|
if bal, _ := l.Balance(ctx, p); bal != 0 {
|
|
t.Fatalf("balance = %d after spending everything, want 0", bal)
|
|
}
|
|
}
|
|
|
|
// Spending one millisatoshi more than you hold must fail, at every scale.
|
|
func TestOffByOneOverdraftAtEveryScale(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
|
|
for _, amount := range []int64{1, 1000, 1_000_000, 100_000_000_000} {
|
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "scale"+itoa(amount)))
|
|
q, _ := l.EnsurePlayer(ctx, uniqueKey(t, "scaledst"+itoa(amount)))
|
|
if _, err := l.Deposit(ctx, p, amount); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := l.Transfer(ctx, p, q, amount+1); err == nil {
|
|
t.Fatalf("overdraft by 1 accepted at scale %d", amount)
|
|
}
|
|
if _, err := l.Transfer(ctx, p, q, amount); err != nil {
|
|
t.Fatalf("exact-balance transfer rejected at scale %d: %v", amount, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hammer one account from many goroutines and confirm not a single
|
|
// millisatoshi is created or lost.
|
|
func TestHighContentionConservesExactly(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
|
|
hub, _ := l.EnsurePlayer(ctx, uniqueKey(t, "hub"))
|
|
if _, err := l.Deposit(ctx, hub, 1_000_000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const workers = 16
|
|
spokes := make([]int64, workers)
|
|
for i := range spokes {
|
|
spokes[i], _ = l.EnsurePlayer(ctx, uniqueKey(t, "spoke"+itoa(int64(i))))
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
for j := 0; j < 20; j++ {
|
|
// Push out and pull back; net zero if nothing is lost.
|
|
if _, err := l.Transfer(ctx, hub, spokes[i], 137); err == nil {
|
|
_, _ = l.Transfer(ctx, spokes[i], hub, 137)
|
|
}
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
total := int64(0)
|
|
for _, id := range spokes {
|
|
bal, err := l.Balance(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
total += bal
|
|
}
|
|
hubBal, err := l.Balance(ctx, hub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if total+hubBal != 1_000_000 {
|
|
t.Fatalf("value changed under contention: %d, want 1000000", total+hubBal)
|
|
}
|
|
}
|
|
|
|
// Self-transfers must not mint money through double-counting the same account.
|
|
func TestSelfTransferDoesNotMint(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "self"))
|
|
if _, err := l.Deposit(ctx, p, 10_000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, _ = l.Transfer(ctx, p, p, 5_000)
|
|
|
|
bal, err := l.Balance(ctx, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bal != 10_000 {
|
|
t.Fatalf("self-transfer changed balance to %d, want 10000", bal)
|
|
}
|
|
}
|
|
|
|
func itoa(v int64) string {
|
|
if v == 0 {
|
|
return "0"
|
|
}
|
|
neg := v < 0
|
|
if neg {
|
|
v = -v
|
|
}
|
|
var buf [24]byte
|
|
i := len(buf)
|
|
for v > 0 {
|
|
i--
|
|
buf[i] = byte('0' + v%10)
|
|
v /= 10
|
|
}
|
|
if neg {
|
|
i--
|
|
buf[i] = '-'
|
|
}
|
|
return string(buf[i:])
|
|
}
|