diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..587e787 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +.PHONY: help test test-race test-e2e cover db-up db-reset run build lint + +help: + @echo "test run unit and integration tests" + @echo "test-race run tests under the race detector" + @echo "test-e2e run end-to-end tests against a live server" + @echo "cover report coverage per package" + @echo "db-up start postgres and redis" + @echo "db-reset destroy and recreate the database" + @echo "run run the server with the dev faucet enabled" + @echo "build build the server binary" + +test: db-up + go test ./... -count=1 + +test-race: db-up + go test ./pkg/... -race -count=1 + +# The end-to-end suite drives a live server; start one first. +test-e2e: db-up build + @echo "start the server with 'make run' in another shell, then:" + @echo " ARCADE_E2E=http://localhost:8080 go test ./cmd/arcade/ -v" + +cover: db-up + go test ./pkg/... -count=1 -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -1 + +db-up: + @docker compose up -d postgres redis >/dev/null + @for i in $$(seq 1 40); do \ + docker compose exec -T postgres pg_isready -U arcade >/dev/null 2>&1 && exit 0; \ + sleep 1; \ + done; echo "postgres did not become ready" >&2; exit 1 + +# The ledger is append-only, so tests that mint large amounts steadily consume +# the bridge's headroom. This wipes the volume and reapplies every migration. +db-reset: + docker compose down -v + docker compose up -d postgres + @for i in $$(seq 1 40); do \ + docker compose exec -T postgres pg_isready -U arcade >/dev/null 2>&1 && break; \ + sleep 1; \ + done + @# Migrations are mounted into docker-entrypoint-initdb.d and run + @# automatically on a fresh volume, so there is nothing to apply here. + @docker compose exec -T postgres psql -q -U arcade -d arcade \ + -c "SELECT count(*) AS tables FROM information_schema.tables WHERE table_schema='public'" + @echo "database reset" + +run: db-up + ARCADE_DEV_FAUCET=1 go run ./cmd/arcade + +build: + go build -o bin/qa ./cmd/arcade + +lint: + go vet ./... + gofmt -l . diff --git a/cmd/arcade/e2e_test.go b/cmd/arcade/e2e_test.go index 74679c4..a0f95c4 100644 --- a/cmd/arcade/e2e_test.go +++ b/cmd/arcade/e2e_test.go @@ -185,10 +185,10 @@ func TestFullRoundLifecycleAndVerification(t *testing.T) { // Wait for the round to settle and expose its proof. var proof struct { - Commitment string `json:"commitment"` - ServerSeed string `json:"server_seed"` - ClientSeed string `json:"client_seed"` - Nonce int64 `json:"nonce"` + Commitment string `json:"commitment"` + ServerSeed string `json:"server_seed"` + ClientSeed string `json:"client_seed"` + Nonce int64 `json:"nonce"` Participants []string `json:"participants"` } settled := false diff --git a/cmd/arcade/main.go b/cmd/arcade/main.go index 9ede439..2eda82e 100644 --- a/cmd/arcade/main.go +++ b/cmd/arcade/main.go @@ -218,8 +218,8 @@ func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) { status = "ledger_imbalance" } writeJSON(w, http.StatusOK, map[string]any{ - "status": status, - "ledger_sum_msat": total, + "status": status, + "ledger_sum_msat": total, }) } @@ -407,12 +407,12 @@ func (s *server) handleCashout(w http.ResponseWriter, r *http.Request) { func (s *server) handleScratchCatalog(w http.ResponseWriter, r *http.Request) { type entry struct { - ID string `json:"id"` - Name string `json:"name"` - Blurb string `json:"blurb"` - Cells int `json:"cells"` - RTPBP uint64 `json:"rtp_bp"` - Odds []scratch.OddsRow `json:"odds"` + ID string `json:"id"` + Name string `json:"name"` + Blurb string `json:"blurb"` + Cells int `json:"cells"` + RTPBP uint64 `json:"rtp_bp"` + Odds []scratch.OddsRow `json:"odds"` } out := make([]entry, 0, len(scratch.Catalog)) for _, t := range scratch.Catalog { diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..ac10920 --- /dev/null +++ b/coverage.out @@ -0,0 +1,290 @@ +mode: set +github.com/drjones/quantum-arcade/pkg/fair/fair.go:37.33,39.45 2 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:39.45,42.63 1 0 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:44.2,44.10 1 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:48.49,48.76 1 0 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:51.38,51.52 1 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:54.34,54.71 1 0 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:57.43,57.75 1 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:62.66,65.2 2 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:70.44,72.29 2 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:72.29,79.3 4 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:80.2,82.12 3 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:88.75,97.2 8 1 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:110.74,115.29 4 0 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:115.29,117.3 1 0 +github.com/drjones/quantum-arcade/pkg/fair/fair.go:118.2,126.3 2 0 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:34.25,35.30 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:35.30,36.87 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:38.2,38.25 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:42.24,42.55 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:44.23,44.39 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:45.23,45.39 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:49.23,52.11 3 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:52.11,54.3 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:55.2,55.11 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:55.11,57.3 1 0 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:58.2,60.9 3 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:60.9,62.3 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:63.2,63.15 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:67.23,68.12 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:68.12,69.35 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:71.2,73.11 3 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:73.11,75.3 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:76.2,76.11 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:76.11,78.3 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:79.2,83.9 5 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:83.9,85.3 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:86.2,86.15 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:91.18,92.11 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:92.11,93.35 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:95.2,95.12 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:95.12,97.3 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:99.2,101.26 3 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:101.26,103.31 2 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:103.31,105.9 2 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:107.3,107.11 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:110.2,110.28 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:110.28,112.3 1 0 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:113.2,113.10 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:117.28,119.9 2 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:119.9,121.3 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:122.2,126.9 5 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:126.9,128.3 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:129.2,129.10 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:132.27,134.17 2 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:134.17,136.3 1 1 +github.com/drjones/quantum-arcade/pkg/fixed/fixed.go:137.2,137.10 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:41.40,46.2 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:49.69,51.16 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:51.16,53.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:54.2,55.43 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:55.43,56.67 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:59.2,66.38 5 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:71.70,73.16 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:73.16,75.3 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:76.2,77.53 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:77.53,79.3 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:81.2,84.8 4 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:84.8,86.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:87.2,90.33 3 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:90.33,92.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:93.2,93.42 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:93.42,95.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:96.2,96.12 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:100.39,102.33 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:102.33,103.27 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:103.27,105.4 1 0 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:110.58,112.51 2 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:112.51,114.3 1 1 +github.com/drjones/quantum-arcade/pkg/identity/identity.go:115.2,115.34 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:25.30,25.69 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:36.40,59.24 7 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:59.24,61.3 1 0 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:63.2,64.20 2 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:64.20,66.3 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:67.2,67.11 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:78.37,79.15 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:79.15,81.3 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:85.2,85.24 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:85.24,87.3 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:89.2,90.48 2 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:95.39,96.20 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:96.20,98.3 1 0 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:99.2,99.26 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:99.26,101.3 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:102.2,108.44 4 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:108.44,110.3 1 0 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:111.2,111.50 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:111.50,113.3 1 1 +github.com/drjones/quantum-arcade/pkg/sim/crash.go:114.2,114.13 1 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:25.33,29.25 2 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:29.25,32.3 2 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:33.2,34.25 2 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:34.25,36.3 1 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:39.2,39.54 1 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:39.54,41.3 1 0 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:42.2,42.10 1 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:47.35,53.2 5 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:56.31,67.2 10 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:69.36,69.73 1 1 +github.com/drjones/quantum-arcade/pkg/sim/rng.go:73.30,75.2 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:103.67,113.2 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:118.54,124.20 5 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:124.20,129.3 4 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:132.28,136.32 4 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:136.32,137.10 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:138.19,138.19 0 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:139.11,139.11 0 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:145.47,149.6 3 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:149.6,150.10 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:151.21,152.20 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:153.19,154.38 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:154.38,158.5 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:163.48,170.15 5 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:171.20,172.27 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:172.27,174.4 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:175.20,176.27 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:176.27,182.4 5 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:183.19,184.27 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:184.27,186.4 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:187.20,196.14 6 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:196.14,198.4 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:199.3,199.16 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:201.2,201.12 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:205.53,218.16 9 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:218.16,220.3 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:222.2,235.12 13 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:240.56,254.53 10 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:254.53,256.3 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:258.2,259.12 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:265.50,271.27 6 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:271.27,273.3 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:274.2,279.16 5 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:279.16,281.3 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:283.2,285.25 3 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:285.25,286.25 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:286.25,287.12 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:289.3,291.17 3 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:291.17,294.4 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:295.3,298.46 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:298.46,300.4 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:303.2,303.19 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:303.19,306.73 3 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:306.73,308.4 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:312.2,315.38 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:315.38,317.3 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:319.2,321.12 3 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:326.118,327.20 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:327.20,329.3 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:331.2,332.29 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:332.29,335.3 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:336.2,336.44 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:336.44,339.3 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:340.2,344.16 4 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:344.16,346.3 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:347.2,351.17 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:351.17,353.3 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:355.2,357.46 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:357.46,359.3 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:361.2,363.53 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:363.53,366.3 2 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:367.2,375.12 5 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:380.58,384.29 3 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:384.29,386.3 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:387.2,388.9 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:388.9,390.3 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:391.2,391.24 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:391.24,393.3 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:394.2,395.24 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:395.24,397.3 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:398.2,400.12 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:400.12,403.49 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:403.49,405.4 1 0 +github.com/drjones/quantum-arcade/pkg/room/room.go:408.2,408.16 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:412.36,417.27 4 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:417.27,424.25 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:424.25,426.4 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:427.3,427.31 1 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:430.2,441.50 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:441.50,444.3 2 1 +github.com/drjones/quantum-arcade/pkg/room/room.go:445.2,445.10 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:66.34,68.31 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:68.31,70.3 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:71.2,71.24 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:71.24,74.3 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:75.2,75.12 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:79.34,81.31 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:81.31,88.22 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:88.22,90.4 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:91.3,91.27 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:93.2,93.13 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:98.41,100.31 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:100.31,102.3 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:103.2,103.28 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:107.62,114.31 4 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:114.31,116.24 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:116.24,118.9 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:122.2,129.3 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:142.60,144.23 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:144.23,146.3 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:147.2,152.38 3 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:152.38,155.26 3 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:155.26,158.4 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:162.2,162.23 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:162.23,163.21 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:163.21,164.12 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:166.3,167.57 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:167.57,168.43 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:168.43,170.13 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:172.4,172.38 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:172.38,174.13 2 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:176.4,176.9 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:178.3,179.17 2 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:181.2,181.14 1 1 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:221.37,222.28 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:222.28,223.17 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:223.17,225.4 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:227.2,227.24 1 0 +github.com/drjones/quantum-arcade/pkg/scratch/scratch.go:233.122,237.2 3 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:50.38,50.68 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:57.108,58.24 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:58.24,60.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:65.2,66.29 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:66.29,68.3 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:69.2,69.21 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:69.21,71.3 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:73.2,74.16 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:74.16,76.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:77.2,82.42 3 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:82.42,84.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:86.2,87.42 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:87.42,89.3 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:91.2,91.28 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:91.28,97.50 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:97.50,99.4 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:101.3,106.43 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:106.43,108.4 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:113.3,114.83 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:114.83,117.4 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:118.3,118.34 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:118.34,121.4 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:123.3,127.64 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:127.64,129.4 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:132.2,132.39 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:132.39,134.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:135.2,135.18 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:139.97,140.21 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:140.21,142.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:143.2,146.4 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:150.94,151.21 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:151.21,153.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:154.2,155.16 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:155.16,157.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:158.2,161.4 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:165.95,166.21 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:166.21,168.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:169.2,170.16 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:170.16,172.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:173.2,176.4 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:180.79,188.2 3 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:191.92,200.16 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:200.16,202.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:203.2,206.18 3 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:206.18,209.80 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:209.80,211.4 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:212.3,212.23 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:214.2,214.24 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:218.82,225.2 3 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:228.81,232.35 3 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:232.35,234.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:235.2,235.16 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:241.66,243.2 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:248.72,250.2 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:258.80,264.16 3 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:264.16,266.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:267.2,268.9 2 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:268.9,270.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:271.2,271.22 1 1 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:271.22,273.3 1 0 +github.com/drjones/quantum-arcade/pkg/ledger/ledger.go:274.2,274.27 1 1 diff --git a/pkg/fair/fair.go b/pkg/fair/fair.go index ec85ae3..84df27c 100644 --- a/pkg/fair/fair.go +++ b/pkg/fair/fair.go @@ -99,11 +99,11 @@ func RoundSeed(server ServerSeed, client [32]byte, nonce uint64) [32]byte { // Proof is everything a player needs to verify one outcome without trusting // any server response. It is what the verification endpoint returns. type Proof struct { - Commitment string `json:"commitment"` // published before the round - ServerSeed string `json:"server_seed"` // revealed after settlement - Participants []string `json:"participants"` // hex public keys, join order + Commitment string `json:"commitment"` // published before the round + ServerSeed string `json:"server_seed"` // revealed after settlement + Participants []string `json:"participants"` // hex public keys, join order Nonce uint64 `json:"nonce"` - RoundSeed string `json:"round_seed"` // derived, shown for convenience + RoundSeed string `json:"round_seed"` // derived, shown for convenience } // BuildProof assembles the verification record for a settled round. diff --git a/pkg/fair/fair_test.go b/pkg/fair/fair_test.go index 497c370..5f09598 100644 --- a/pkg/fair/fair_test.go +++ b/pkg/fair/fair_test.go @@ -2,6 +2,8 @@ package fair_test import ( "bytes" + "crypto/sha256" + "encoding/hex" "testing" "github.com/drjones/quantum-arcade/pkg/fair" @@ -106,3 +108,108 @@ func TestServerSeedsAreUnique(t *testing.T) { seen[s.Bytes()] = true } } + +func TestServerSeedRoundTripsThroughBytes(t *testing.T) { + original := fair.NewServerSeed() + restored := fair.ServerSeedFromBytes(original.Bytes()) + + if restored.Bytes() != original.Bytes() { + t.Fatal("seed did not survive a byte round trip") + } + if restored.Commitment() != original.Commitment() { + t.Fatal("restored seed produces a different commitment") + } + if restored.Hex() != original.Hex() { + t.Fatal("restored seed renders differently") + } +} + +func TestHexIsFullLength(t *testing.T) { + s := fair.NewServerSeed() + if len(s.Hex()) != 64 { + t.Fatalf("hex seed is %d characters, want 64", len(s.Hex())) + } +} + +func TestBuildProofIsSelfConsistent(t *testing.T) { + server := fair.NewServerSeed() + keys := [][]byte{[]byte("alice"), []byte("bob"), []byte("carol")} + const nonce = 17 + + proof := fair.BuildProof(server, keys, nonce) + + if proof.Nonce != nonce { + t.Fatalf("proof nonce = %d, want %d", proof.Nonce, nonce) + } + if len(proof.Participants) != len(keys) { + t.Fatalf("proof lists %d participants, want %d", len(proof.Participants), len(keys)) + } + // Every value in the proof must be reproducible from the others. + seedBytes, err := hex.DecodeString(proof.ServerSeed) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(seedBytes) + if hex.EncodeToString(sum[:]) != proof.Commitment { + t.Fatal("proof commitment does not match its own seed") + } + var restored [32]byte + copy(restored[:], seedBytes) + want := fair.RoundSeed(fair.ServerSeedFromBytes(restored), fair.ClientSeed(keys), nonce) + if hex.EncodeToString(want[:]) != proof.RoundSeed { + t.Fatal("proof round seed does not follow from its inputs") + } +} + +func TestProofParticipantsPreserveOrder(t *testing.T) { + server := fair.NewServerSeed() + keys := [][]byte{[]byte("first"), []byte("second")} + proof := fair.BuildProof(server, keys, 1) + + if proof.Participants[0] != hex.EncodeToString(keys[0]) { + t.Fatal("participant order was not preserved") + } + if proof.Participants[1] != hex.EncodeToString(keys[1]) { + t.Fatal("participant order was not preserved") + } +} + +// Reordering the same players must change the seed, since order is part of the +// commitment. Otherwise a player could be swapped in without detection. +func TestParticipantOrderAffectsTheSeed(t *testing.T) { + a, b := []byte("alice"), []byte("bob") + if fair.ClientSeed([][]byte{a, b}) == fair.ClientSeed([][]byte{b, a}) { + t.Fatal("reordering participants did not change the client seed") + } +} + +// Length-prefixing must prevent two different participant lists from colliding +// through simple concatenation. +func TestClientSeedResistsConcatenationCollisions(t *testing.T) { + // Without length prefixes, {"ab","c"} and {"a","bc"} would hash the same. + one := fair.ClientSeed([][]byte{[]byte("ab"), []byte("c")}) + two := fair.ClientSeed([][]byte{[]byte("a"), []byte("bc")}) + if one == two { + t.Fatal("different participant lists collided; length prefixing is broken") + } +} + +func TestEmptyParticipantListIsStable(t *testing.T) { + if fair.ClientSeed(nil) != fair.ClientSeed([][]byte{}) { + t.Fatal("nil and empty participant lists disagree") + } +} + +// A round with no players must still produce a valid, verifiable outcome. +func TestRoundWithNoPlayersStillVerifies(t *testing.T) { + server := fair.NewServerSeed() + commitment := server.Commitment() + proof := fair.BuildProof(server, nil, 5) + + if !fair.VerifyCommitment(commitment, server) { + t.Fatal("empty round does not verify") + } + if proof.RoundSeed == "" { + t.Fatal("empty round produced no seed") + } +} diff --git a/pkg/fixed/edge_test.go b/pkg/fixed/edge_test.go new file mode 100644 index 0000000..286d00b --- /dev/null +++ b/pkg/fixed/edge_test.go @@ -0,0 +1,199 @@ +package fixed + +import ( + "math" + "testing" +) + +// The simulation's verifiability depends on this arithmetic behaving +// identically everywhere, including at the extremes. These tests attack the +// boundaries. + +func TestMulByZeroAndOne(t *testing.T) { + for _, v := range []int64{0, 1, -1, 1000, -1000, 1 << 20} { + a := FromInt(v) + if got := a.Mul(0); got != 0 { + t.Errorf("%d * 0 = %v, want 0", v, got) + } + if got := a.Mul(One); got != a { + t.Errorf("%d * 1 = %v, want %v", v, got, a) + } + } +} + +func TestDivByOneAndSelf(t *testing.T) { + for _, v := range []int64{1, -1, 7, -7, 1000, 1 << 20} { + a := FromInt(v) + if got := a.Div(One); got != a { + t.Errorf("%d / 1 = %v, want %v", v, got, a) + } + if got := a.Div(a); got != One { + t.Errorf("%d / %d = %v, want 1", v, v, got) + } + } +} + +func TestDivByZeroPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("division by zero did not panic") + } + }() + _ = One.Div(0) +} + +func TestSqrtOfNegativePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("sqrt of a negative did not panic") + } + }() + _ = Sqrt(FromInt(-1)) +} + +// Multiplication must stay associative-ish and exact for representable values, +// which is what keeps a replayed round identical to the original. +func TestMulIsExactForFractions(t *testing.T) { + cases := []struct { + a, b, want F + }{ + {One / 2, One / 2, One / 4}, + {One / 4, One / 4, One / 16}, + {One / 2, One / 4, One / 8}, + {One * 3 / 2, One * 2, One * 3}, + } + for _, c := range cases { + if got := c.a.Mul(c.b); got != c.want { + t.Errorf("%v * %v = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +// Round-tripping a value through multiply and divide must return it exactly +// for powers of two, where no precision can be lost. +func TestMulDivRoundTripOnPowersOfTwo(t *testing.T) { + for shift := 0; shift < 20; shift++ { + v := FromInt(1 << shift) + for _, by := range []F{One * 2, One * 4, One * 8} { + if got := v.Mul(by).Div(by); got != v { + t.Errorf("2^%d round trip through %v gave %v, want %v", shift, by, got, v) + } + } + } +} + +func TestSqrtIsMonotonic(t *testing.T) { + prev := Sqrt(0) + for i := int64(1); i < 5000; i++ { + cur := Sqrt(FromInt(i)) + if cur < prev { + t.Fatalf("Sqrt decreased at %d: %v -> %v", i, prev, cur) + } + prev = cur + } +} + +// Sqrt must never overshoot: its square must not exceed the input. +func TestSqrtNeverOvershoots(t *testing.T) { + for i := int64(0); i < 20000; i++ { + a := FromInt(i) + r := Sqrt(a) + if r.Mul(r) > a { + t.Fatalf("Sqrt(%d) = %v squares to %v, which exceeds %v", i, r, r.Mul(r), a) + } + } +} + +func TestSqrtOfLargeValues(t *testing.T) { + // Values in the range the crash curve actually produces, up to the + // representable maximum. + for _, v := range []int64{1_000_000, 12_960_000, 100_000_000, MaxInt} { + a := FromInt(v) + r := Sqrt(a) + if r <= 0 { + t.Fatalf("Sqrt(%d) = %v, want positive", v, r) + } + if r.Mul(r) > a { + t.Fatalf("Sqrt(%d) overshoots", v) + } + } +} + +func TestIntTruncatesTowardNegativeInfinity(t *testing.T) { + cases := []struct { + in F + want int64 + }{ + {One, 1}, + {One + One/2, 1}, + {One*2 - 1, 1}, + {0, 0}, + {-One, -1}, + } + for _, c := range cases { + if got := c.in.Int(); got != c.want { + t.Errorf("(%v).Int() = %d, want %d", c.in, got, c.want) + } + } +} + +func TestFromIntPanicsOutsideRange(t *testing.T) { + for _, v := range []int64{MaxInt + 1, MinInt - 1, 4_000_000_000, -4_000_000_000} { + func() { + defer func() { + if recover() == nil { + t.Errorf("FromInt(%d) did not panic", v) + } + }() + _ = FromInt(v) + }() + } + // The boundaries themselves must be accepted. + _ = FromInt(MaxInt) + _ = FromInt(MinInt) +} + +func TestStringNeverPanicsAcrossRange(t *testing.T) { + values := []F{ + 0, 1, -1, One, -One, One / 3, math.MaxInt64, math.MinInt64 + 1, + FromInt(4_000_000), + } + for _, v := range values { + if s := v.String(); s == "" { + t.Errorf("String() of %d returned empty", int64(v)) + } + } +} + +// Addition and subtraction are plain integer ops, but the inverse property is +// what payout arithmetic relies on. +func TestAddSubAreInverse(t *testing.T) { + for _, a := range []F{0, One, -One, One * 12345, One / 7} { + for _, b := range []F{0, One, -One, One * 999} { + if got := a.Add(b).Sub(b); got != a { + t.Errorf("(%v + %v) - %v = %v, want %v", a, b, b, got, a) + } + } + } +} + +// Determinism check: the same operations in the same order must produce +// bit-identical results every time, which is the whole premise of replay. +func TestOperationsAreBitStable(t *testing.T) { + compute := func() F { + acc := One + for i := int64(1); i < 500; i++ { + acc = acc.Mul(One + One/F(i+1)) + acc = acc.Div(One + One/F(i+2)) + acc = acc.Add(FromInt(i % 3)) + acc = Sqrt(acc) + } + return acc + } + first := compute() + for i := 0; i < 200; i++ { + if got := compute(); got != first { + t.Fatalf("run %d diverged: %v != %v", i, got, first) + } + } +} diff --git a/pkg/fixed/fixed.go b/pkg/fixed/fixed.go index 6ef818e..a68041d 100644 --- a/pkg/fixed/fixed.go +++ b/pkg/fixed/fixed.go @@ -19,8 +19,24 @@ const One F = 1 << 32 const fracBits = 32 +// MaxInt is the largest whole number representable in Q32.32. Values beyond +// it cannot be held in the 32 integer bits. +const MaxInt int64 = 1<<31 - 1 + +// MinInt is the smallest whole number representable in Q32.32. +const MinInt int64 = -(1 << 31) + // FromInt converts a whole number to fixed-point. -func FromInt(v int64) F { return F(v << fracBits) } +// +// It panics outside [MinInt, MaxInt] rather than wrapping. A silent wrap here +// produced a negative multiplier from a positive input, which is exactly the +// kind of fault that is invisible until it corrupts a payout. +func FromInt(v int64) F { + if v > MaxInt || v < MinInt { + panic("fixed: " + strconv.FormatInt(v, 10) + " is outside the Q32.32 integer range") + } + return F(v << fracBits) +} // Int truncates toward negative infinity and returns the whole part. func (a F) Int() int64 { return int64(a) >> fracBits } diff --git a/pkg/ledger/edge_test.go b/pkg/ledger/edge_test.go index 35ff14a..0901a4c 100644 --- a/pkg/ledger/edge_test.go +++ b/pkg/ledger/edge_test.go @@ -21,8 +21,20 @@ func TestHugeBalanceIsExact(t *testing.T) { } // 21 million BTC in millisatoshis is the largest amount that can ever - // exist: 2.1e15. It must round-trip exactly, with no float contamination. + // 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) } diff --git a/pkg/room/room.go b/pkg/room/room.go index ec7bdf5..469c2da 100644 --- a/pkg/room/room.go +++ b/pkg/room/room.go @@ -44,28 +44,28 @@ const ( // Bet is one player's position in the current round. type Bet struct { - AccountID int64 - Pubkey []byte - Nickname string - StakeMsat int64 + AccountID int64 + Pubkey []byte + Nickname string + StakeMsat int64 CashedOutAt fixed.F // zero until they cash out - PayoutMsat int64 + PayoutMsat int64 } // Snapshot is what clients render. It carries the seed inputs so a client can // verify the round the moment it settles. type Snapshot struct { - RoundID int64 `json:"round_id"` - Game string `json:"game"` - State State `json:"state"` - Tick int `json:"tick"` - Multiplier string `json:"multiplier"` - Commitment string `json:"commitment"` - ServerSeed string `json:"server_seed,omitempty"` // only once settled - CrashPoint string `json:"crash_point,omitempty"` // only once settled - Players []Player `json:"players"` + RoundID int64 `json:"round_id"` + Game string `json:"game"` + State State `json:"state"` + Tick int `json:"tick"` + Multiplier string `json:"multiplier"` + Commitment string `json:"commitment"` + ServerSeed string `json:"server_seed,omitempty"` // only once settled + CrashPoint string `json:"crash_point,omitempty"` // only once settled + Players []Player `json:"players"` HousePotMsat int64 `json:"house_pot_msat"` - NextPhaseIn float64 `json:"next_phase_in_seconds"` + NextPhaseIn float64 `json:"next_phase_in_seconds"` } // Player is the public view of a participant. diff --git a/pkg/room/room_test.go b/pkg/room/room_test.go index 0fdf5cb..30c9a26 100644 --- a/pkg/room/room_test.go +++ b/pkg/room/room_test.go @@ -91,6 +91,17 @@ func (f *fixture) startRun() { } } +// forceCrashPoint pins the round's crash point so cash-out tests do not depend +// on a random draw. A genuine 1.00x crash is an instant bust where nobody can +// cash out, which is correct behaviour but useless for testing the cash-out +// path. +func (f *fixture) forceCrashPoint(multiplier int64) { + f.t.Helper() + f.room.mu.Lock() + f.room.crashPoint = fixed.FromInt(multiplier) + f.room.mu.Unlock() +} + // advanceTo moves the round to a specific tick without settling. func (f *fixture) advanceTo(tick int) { f.t.Helper() @@ -257,6 +268,7 @@ func TestCashOutOnlyWhileRunning(t *testing.T) { t.Fatal("cash out accepted during betting") } f.startRun() + f.forceCrashPoint(100) if _, err := f.room.CashOut(id); err != nil { t.Fatalf("cash out rejected while running: %v", err) } @@ -268,6 +280,7 @@ func TestCannotCashOutTwice(t *testing.T) { f.openBetting() _ = f.room.PlaceBet(f.ctx, id, pk, "a", 1_000) f.startRun() + f.forceCrashPoint(100) if _, err := f.room.CashOut(id); err != nil { t.Fatal(err) @@ -282,6 +295,7 @@ func TestCannotCashOutWithoutABet(t *testing.T) { id, _ := f.player("a", 10_000) f.openBetting() f.startRun() + f.forceCrashPoint(100) if _, err := f.room.CashOut(id); err == nil { t.Fatal("cash out accepted with no bet placed") @@ -574,6 +588,7 @@ func TestConcurrentCashOutsYieldOne(t *testing.T) { f.openBetting() _ = f.room.PlaceBet(f.ctx, id, pk, "a", 5_000) f.startRun() + f.forceCrashPoint(100) const attempts = 10 var wg sync.WaitGroup @@ -606,6 +621,7 @@ func TestSnapshotReportsCashOutMultiplier(t *testing.T) { f.openBetting() _ = f.room.PlaceBet(f.ctx, id, pk, "nick", 5_000) f.startRun() + f.forceCrashPoint(100) if _, err := f.room.CashOut(id); err != nil { t.Fatal(err) diff --git a/pkg/scratch/scratch.go b/pkg/scratch/scratch.go index 46a8afe..68876d5 100644 --- a/pkg/scratch/scratch.go +++ b/pkg/scratch/scratch.go @@ -31,12 +31,12 @@ type Tier struct { // Ticket is a scratch game definition. type Ticket struct { - ID string - Name string - Blurb string + ID string + Name string + Blurb string // Cells is how many squares the player scratches, for presentation. - Cells int - Tiers []Tier + Cells int + Tiers []Tier } // Outcome is the result of one ticket. diff --git a/pkg/sim/crash.go b/pkg/sim/crash.go index 491ea8b..4abf2fc 100644 --- a/pkg/sim/crash.go +++ b/pkg/sim/crash.go @@ -44,10 +44,22 @@ func CrashPoint(seed [32]byte) fixed.F { // crash = payoutRatio / (u / 2^32), computed as (payoutRatio * 2^32) / u // through a 128-bit intermediate so no precision is lost. - // hi is zero because payoutRatio < 2^32, so the division cannot overflow. hi, lo := bits.Mul64(payoutRatio, 1<<32) q, _ := bits.Div64(hi, lo, u) + // The quotient can exceed int64 for the very smallest u — at u=1 it wraps + // negative, which would silently turn the rarest and most valuable outcome + // into an instant loss. Compare in unsigned space before converting. + // + // The cap is the largest multiplier the curve can express. Anything above + // it is unreachable anyway: the round would hit its tick ceiling first. + // It also bounds the maximum payout, so a single round cannot demand more + // than the house can hold. + maxCP := MaxMultiplier() + if q >= uint64(maxCP) { + return maxCP + } + cp := fixed.F(q) if cp < fixed.One { cp = fixed.One diff --git a/pkg/sim/crash_test.go b/pkg/sim/crash_test.go index df5d2e2..d525647 100644 --- a/pkg/sim/crash_test.go +++ b/pkg/sim/crash_test.go @@ -102,8 +102,7 @@ func TestRoundLengthIsBounded(t *testing.T) { t.Fatalf("curve past the ceiling = %v, want %v", got, MaxMultiplier()) } // Even the most extreme crash point settles within the ceiling. - worst := fixed.FromInt(4_000_000_000) - if tick := TicksToMultiplier(worst); tick > RoundTicks { + if tick := TicksToMultiplier(MaxMultiplier()); tick > RoundTicks { t.Fatalf("extreme crash point needs %d ticks, ceiling is %d", tick, RoundTicks) } } @@ -114,7 +113,7 @@ func TestCurveTimings(t *testing.T) { multiplier int64 maxSeconds float64 }{ - {2, 20}, // the common case should arrive quickly + {2, 20}, // the common case should arrive quickly {10, 45}, {100, 56}, } { @@ -125,3 +124,42 @@ func TestCurveTimings(t *testing.T) { } } } + +// The crash point must never be negative or below 1.0, at any seed. An +// unsigned quotient exceeding int64 previously wrapped negative here. +func TestCrashPointNeverOverflows(t *testing.T) { + // Drive the derivation across seeds chosen to produce very small u, which + // is where the quotient is largest. + for i := 0; i < 200000; i++ { + var seed [32]byte + for j := 0; j < 32; j++ { + seed[j] = byte(i >> (8 * (j % 4))) + } + cp := CrashPoint(seed) + if cp < fixed.One { + t.Fatalf("seed %d produced crash point %v, below 1.0", i, cp) + } + if cp > MaxMultiplier() { + t.Fatalf("seed %d produced crash point %v, above the ceiling %v", + i, cp, MaxMultiplier()) + } + } +} + +// The payout a single round can demand must be bounded, so settlement can +// always be covered. +func TestMaximumPayoutIsBounded(t *testing.T) { + max := MaxMultiplier() + if max <= 0 { + t.Fatalf("ceiling is not positive: %v", max) + } + // A 1000-sat stake at the ceiling must stay well inside int64. + const stakeMsat = int64(1_000_000) + payout := stakeMsat * int64(max) / int64(fixed.One) + if payout <= 0 { + t.Fatalf("payout at the ceiling overflowed: %d", payout) + } + if payout > 1<<62 { + t.Fatalf("payout at the ceiling is %d, unreasonably large", payout) + } +}