Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<!-- categories: memba -->
- 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)
<!-- categories: memba -->
- 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.
Expand Down
4 changes: 3 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 12 additions & 5 deletions backend/internal/arcade/batcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -104,22 +105,28 @@ 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
}
for _, run := range runs {
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:
Expand Down
112 changes: 112 additions & 0 deletions backend/internal/arcade/batcher_attempts_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading