From 493c8bf09cfbd7cd908cb8a1c87dc779d81e80b9 Mon Sep 17 00:00:00 2001 From: zxxma Date: Tue, 15 Sep 2026 10:07:14 +0200 Subject: [PATCH] Count every arcade broadcast attempt toward the cycle limit --- CHANGELOG.md | 5 + backend/.env.example | 4 +- backend/internal/arcade/batcher.go | 17 ++- .../internal/arcade/batcher_attempts_test.go | 112 ++++++++++++++++++ 4 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 backend/internal/arcade/batcher_attempts_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 36edb97ff..372b56095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ Full changelogs are split by version range for easier navigation: - Completed fire taps are held until a fixed simulation step consumes them, including slow frames and high-refresh frames with no simulation step. Held firing and deterministic daily replay remain unchanged. - Cancelled touches, lost focus and run/menu/pause boundaries clear stale input. The mobile regression now delivers a complete tap between frames instead of relying on a timed hold. +### Arcade attestation limits include failed attempts (2026-09-15) + +- The day-close worker now applies its per-cycle limit to every broadcast attempt, including failed transactions and failures to save a receipt. Failed boards can no longer leave the full attempt allowance available for later boards in the same cycle. +- Successful-attestation counts and retry parking are preserved. The cycle limit remains distinct from a daily or monthly gas budget. + ### Proposal details read the realm's status field (2026-09-15) - GovDAO proposal details now agree with the overview when a description contains words such as “restricted”: the status comes from the generated Stats section rather than the first status-like word in the page. Legacy metadata, generated Memba template footers and basedao/daokit status formats are preserved. diff --git a/backend/.env.example b/backend/.env.example index d41f5dd50..3cfa74a3f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -122,7 +122,9 @@ FEED_TAILER_INTERVAL=3s # MEMBA_ARCADE_REALM=gno.land/r/samcrew/memba_arcade_leaderboard_v1 # RPC for attestation broadcasts (defaults to GNO_RPC_URL). # MEMBA_ARCADE_RPC_URL= -# gnokey binary (default: gnokey). Scan cadence + per-cycle attestation cap. +# gnokey binary (default: gnokey). Scan cadence + per-cycle broadcast-attempt cap. +# Failures and already-delivered transactions consume this attempt allowance too. +# This is not a persistent daily/monthly gas budget; keep the signer balance bounded. # MEMBA_ARCADE_GNOKEY_BIN=gnokey # MEMBA_ARCADE_ATTEST_INTERVAL=15m # MEMBA_ARCADE_ATTEST_MAX_PER_CYCLE=100 diff --git a/backend/internal/arcade/batcher.go b/backend/internal/arcade/batcher.go index e08304085..9ab263d3f 100644 --- a/backend/internal/arcade/batcher.go +++ b/backend/internal/arcade/batcher.go @@ -10,7 +10,7 @@ import ( // BatcherConfig tunes the day-close batcher. Enabled=false leaves it dormant. type BatcherConfig struct { Enabled bool - MaxPerCycle int // cap on attestations per scan cycle (rest drain later); default 100 + MaxPerCycle int // cap on broadcast attempts per scan cycle (rest drain later); default 100 Interval time.Duration // scan cadence; default 15m } @@ -93,7 +93,8 @@ func RunBatchOnce(ctx context.Context, store *Store, b Broadcaster, maxPerCycle // a transient failure is left 'verified' to retry — but only up to maxAttestRetries // consecutive cycles (tracked in `failures`), after which the run is parked // ('errored') so a poisoned row can't drip gas forever. maxPerCycle bounds one -// cycle's work; the rest drain on later cycles. +// cycle's broadcast attempts, including failed or already-delivered transactions +// and failures to save a receipt locally; the rest drain on later cycles. func runBatchOnce(ctx context.Context, store *Store, b Broadcaster, maxPerCycle int, now func() time.Time, failures map[string]int) (int, error) { if maxPerCycle <= 0 { maxPerCycle = 100 @@ -104,12 +105,15 @@ func runBatchOnce(ctx context.Context, store *Store, b Broadcaster, maxPerCycle if err != nil { return 0, err } - attested := 0 + attested, attempted := 0, 0 for _, gd := range boards { + if attempted >= maxPerCycle { + return attested, nil + } if ctx.Err() != nil { return attested, ctx.Err() } - runs, err := store.BestVerifiedDaily(gd.Game, gd.Day, maxPerCycle-attested) + runs, err := store.BestVerifiedDaily(gd.Game, gd.Day, maxPerCycle-attempted) if err != nil { return attested, err } @@ -117,9 +121,12 @@ func runBatchOnce(ctx context.Context, store *Store, b Broadcaster, maxPerCycle if ctx.Err() != nil { return attested, ctx.Err() } - if attested >= maxPerCycle { + if attempted >= maxPerCycle { return attested, nil // drain the rest next cycle } + // Reserve this cycle's attempt before broadcasting: every outcome + // may have spent gas, even if no attestation is recorded locally. + attempted++ txHash, err := b.AttestScore(ctx, run) switch { case err == nil: diff --git a/backend/internal/arcade/batcher_attempts_test.go b/backend/internal/arcade/batcher_attempts_test.go new file mode 100644 index 000000000..fd75b12e2 --- /dev/null +++ b/backend/internal/arcade/batcher_attempts_test.go @@ -0,0 +1,112 @@ +package arcade + +import ( + "context" + "errors" + "fmt" + "testing" +) + +// Unlike fakeBroadcaster's successful-call history, this counts every attempt, +// including calls whose response reports a failed or already-delivered tx. +type attemptBroadcaster struct { + calls []Run + result error +} + +func (b *attemptBroadcaster) AttestScore(_ context.Context, run Run) (string, error) { + b.calls = append(b.calls, run) + return "tx-" + run.LogHash, b.result +} + +func TestRunBatchOnce_AttemptCapIncludesEveryOutcome(t *testing.T) { + for _, tc := range []struct { + name string + result error + wantAttested int + }{ + {"success", nil, 2}, + {"transient", errors.New("network failure"), 0}, + {"permanent", ErrPermanentReject, 0}, + {"conflicting log", ErrLogBoundElsewhere, 0}, + {"already on chain", ErrAlreadyOnChain, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + s := batchStore(t) + // Two game boards on each of three closed days: a failed board must + // not leave the full attempt allowance available for the next one. + for day := 9; day <= 11; day++ { + for _, game := range []string{"barricade", "invaders"} { + id := fmt.Sprintf("%s-%d", game, day) + mustInsertGame(t, s, game, id, "g1"+id, fmt.Sprintf("2026-07-%02d", day), 100) + } + } + b := &attemptBroadcaster{result: tc.result} + n, err := RunBatchOnce(context.Background(), s, b, 2, atFixedDay) + if err != nil { + t.Fatal(err) + } + if len(b.calls) != 2 { + t.Fatalf("cap=2 made %d broadcast attempts", len(b.calls)) + } + if n != tc.wantAttested { + t.Fatalf("attested count=%d, want %d", n, tc.wantAttested) + } + for _, game := range []string{"barricade", "invaders"} { + r, _, err := s.GetRunByLogHash(game + "-10") + if err != nil || r.Status != "verified" { + t.Fatalf("later board changed: %+v, %v", r, err) + } + } + }) + } +} + +func TestRunBatchOnce_AttemptCapIncludesPostBroadcastStorageFailure(t *testing.T) { + s := batchStore(t) + for day := 9; day <= 11; day++ { + id := fmt.Sprintf("run-%d", day) + mustInsert(t, s, id, fmt.Sprintf("2026-07-%02d", day), 100) + } + // A tx may have spent gas even when recording its receipt fails locally. + _, err := s.db.Exec(`CREATE TRIGGER fail_attested_write BEFORE UPDATE ON arcade_runs + WHEN NEW.status = 'attested' BEGIN SELECT RAISE(ABORT, 'receipt write failed'); END`) + if err != nil { + t.Fatal(err) + } + b := &attemptBroadcaster{} + n, err := RunBatchOnce(context.Background(), s, b, 1, atFixedDay) + if err != nil { + t.Fatal(err) + } + if len(b.calls) != 1 || n != 0 { + t.Fatalf("attempts=%d, attested=%d; want 1 and 0", len(b.calls), n) + } +} + +func TestRunBatchOnce_AttemptCapRetainsRetryParkingAndProgress(t *testing.T) { + s := batchStore(t) + mustInsert(t, s, "poison", "2026-07-09", 100) + mustInsert(t, s, "later", "2026-07-10", 100) + b := &attemptBroadcaster{result: errors.New("temporary failure")} + failures := map[string]int{} + for cycle := 0; cycle < maxAttestRetries; cycle++ { + before := len(b.calls) + if _, err := runBatchOnce(context.Background(), s, b, 1, atFixedDay, failures); err != nil { + t.Fatal(err) + } + if len(b.calls)-before != 1 { + t.Fatal("retry cycle exceeded attempt cap") + } + } + r, _, err := s.GetRunByLogHash("poison") + if err != nil || r.Status != "errored" { + t.Fatalf("poison was not parked: %+v %v", r, err) + } + b.result = nil + before := len(b.calls) + n, err := runBatchOnce(context.Background(), s, b, 1, atFixedDay, failures) + if err != nil || n != 1 || len(b.calls)-before != 1 || b.calls[len(b.calls)-1].LogHash != "later" { + t.Fatalf("later board failed to progress: n=%d err=%v calls=%+v", n, err, b.calls) + } +}