Skip to content

feat(arena): permissionless keeper for matchmaking + settlement#53

Merged
colinisme merged 1 commit into
mainfrom
feat/arena-keeper
Jun 4, 2026
Merged

feat(arena): permissionless keeper for matchmaking + settlement#53
colinisme merged 1 commit into
mainfrom
feat/arena-keeper

Conversation

@colinisme

@colinisme colinisme commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

What

Adds the arena keeper — the off-chain heartbeat that drives tier matchmaking and match settlement.

The contract's runMatchmaking(tier) and settleMatch(id) are permissionless, but the EVM can't trigger itself. Something off-chain has to call them on a schedule. That's this script.

  • mcp-server/scripts/keeper.mjs — a standalone ethers-v5 process (no MCP-server dependency)
  • justfilekeeper-start (local anvil) and keeper-gravity (testnet) recipes

What it does each tick

  1. Matchmaking — for each tier (Bronze / Silver / Gold), calls runMatchmaking(tier) only when the pool has ≥2 ghosts AND the tier's cooldown has elapsed. Both checks mirror the contract's require(last == 0 || now >= last + effectiveTierPeriod), so it never burns gas on an empty pool and never eats a rate limited revert.
  2. Settlement — settles every created-but-unsettled match. It walks an in-memory watermark (settleCursor) that advances past the leading run of already-settled matches, so steady-state reads stay O(new matches) instead of O(all matches) per tick.

RPC + router come from frontend/config/<NETWORK>.json — the same config files the frontend already reads — so the keeper never drifts from the deployed addresses and you don't pass them by hand. localhost.json carries no router_address, so local naturally falls through to deployed-addresses.json. Env vars (RPC_URL / ROUTER_ADDRESS / ARENA_ADDRESS) still override the file when needed. It recovers from transient RPC/tx errors per-tick without dying.

Can the keeper be any address?

Yes — any funded address works. runMatchmaking and settleMatch have no access control; the contract only checks the cooldown and match state. The keeper key needs gas and nothing else — no operator role, no owner, no agent registration.

Implication: the keeper is a public heartbeat, not a privileged party. If it goes down, anyone (even the frontend) can call the same functions to unblock matchmaking. Use a dedicated key holding only a little gas — never the owner/deployer key.

How to run

No addresses are passed on the command line — the network name selects the config file.

Local (anvil): RPC from config/localhost.json, router from deployed-addresses.json.

just anvil-deploy       # if not already deployed
just keeper-start       # NETWORK=localhost, anvil dev key #0, 15s tick
just keeper-start 5     # 5s tick

Testnet (Gravity): RPC + router both read from frontend/config/gravity.json.

just keeper-gravity 0x<keeper_private_key> [tick_seconds]

Direct / cron (one tick then exit):

ONCE=1 NETWORK=gravity KEEPER_KEY=0x<keeper_key> \
  node mcp-server/scripts/keeper.mjs

Production (always-on): run under pm2, systemd, or launchd with auto-restart, e.g.

NETWORK=gravity KEEPER_KEY=0x<keeper_key> TICK_SECONDS=60 \
  pm2 start mcp-server/scripts/keeper.mjs --name arena-keeper
pm2 save && pm2 startup

Config (env)

Var Default Meaning
NETWORK localhost selects frontend/config/<NETWORK>.json (rpc + router)
KEEPER_KEY — (required) keeper private key (any funded address)
RPC_URL config rpc_url override the config file's RPC
ROUTER_ADDRESS config router_address override; else deployed-addresses.json
ARENA_ADDRESS ArenaEngine override (skips Router resolution)
TICK_SECONDS 15 loop cadence
TIERS 0,1,2 tiers to drive (Bronze, Silver, Gold)
ONCE 1 → run a single tick then exit (cron)

Note

The keeper only drives matchmaking + settlement. For a full self-running loop you still need a producer (the agent-runner, or any client calling arena_submit) to put ghosts into the tier pools — otherwise the keeper just sees empty pools and does nothing.

Test plan

  • ABI signatures verified line-by-line against ArenaEngine.sol (getMatch tuple order, nextMatchId starts at 1, lastTierMatchmakingAt mapping getter, cooldown require).
  • ONCE=1 smoke test, local (NETWORK=localhost, chain 31337): RPC from localhost.json, arena resolved from deployed-addresses.json; reads tierPopulation ×3 + nextMatchId without error; empty pools → no-op.
  • ONCE=1 smoke test, testnet (NETWORK=gravity, chain 7771625): RPC + router read from gravity.json, arena resolved via Router with no addresses passed.
  • Continuous loop mode verified running at a 10s tick.
  • End-to-end on testnet: submit ≥2 ghosts to one tier, observe matchmade + settled log lines.

The arena's runMatchmaking(tier) and settleMatch(id) are permissionless
but the EVM can't self-trigger, so an off-chain heartbeat must tick them.
Add scripts/keeper.mjs: an ethers-v5 process that, every tick, runs
matchmaking for each tier whose cooldown has elapsed and settles any
created-but-unsettled match. Talks straight to the chain (no MCP-server
dependency) so it runs as a standalone systemd/pm2/launchd service.

- Gates matchmaking on pool>=2 AND cooldown elapsed (mirrors the contract
  require) so it never burns gas on empty pools or eats rate-limit reverts.
- Settlement walks an in-memory watermark, advancing past the leading run
  of settled matches so steady-state reads stay O(new), not O(all).
- RPC + router are read from frontend/config/<NETWORK>.json (the same files
  the frontend uses), so addresses never drift and you don't pass them by
  hand. localhost.json carries no router, so local falls through to
  deployed-addresses.json. Env (RPC_URL/ROUTER_ADDRESS/ARENA_ADDRESS) still
  overrides the file when needed.
- KEEPER_KEY can be any funded address — gas only; no role required.

justfile recipes: keeper-start (NETWORK=localhost) and keeper-gravity
(NETWORK=gravity) — the testnet recipe takes only the keeper key now.
@colinisme colinisme force-pushed the feat/arena-keeper branch from 63657d1 to fd0b024 Compare June 4, 2026 08:58
@colinisme colinisme merged commit 99000e0 into main Jun 4, 2026
4 checks passed
@colinisme colinisme deleted the feat/arena-keeper branch June 4, 2026 09:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant