Skip to Content
⚔ BattleMeme docs · early preview · expect rough edges
ContractsBattleOrchestrator

BattleOrchestrator

The keeper-driven state machine for the queue → warmup → battle → graduation lifecycle. Holds escrowed ETH from eliminated tokens, computes scores, runs rounds on a fixed cadence, and finalizes battles into GraduationLP + ClaimManager.

Source: contracts/src/core/BattleOrchestrator.sol · AutomationCompatibleInterface (Chainlink).

Roles

CallerWhat they can do
hook (set by owner)enterQueue(poolId) — pushed automatically from afterSwap once a token crosses 5 ETH
owner (multisig in prod)emergencyEndBattle, set hook/claimManager/graduationLp references
anyonetickQueue, commenceBattle, runRound, earlyFinish, performUpkeep — all public, time-gated. Chainlink Automation is just a polite fallback.

Public entrypoints

function enterQueue(PoolId poolId) external; // hook-only function tickQueue() external; // anyone — schedule next action function commenceBattle(uint256 battleId) external; // anyone — WARMUP → BATTLE function runRound(uint256 battleId) external nonReentrant; // anyone — execute next round cut function earlyFinish(uint256 battleId, PoolId winnerPid) external; // anyone — used when one // token already crossed 950 ETH function nextRoundIsSafeToAutoAdvance(uint256 battleId) external view returns (bool);

tickQueue() — queue dispatcher

Behavior depends on queue length and oldest-queued time:

  • queue.length ≥ 10_startBattle() with all 10
  • 2 ≤ queue.length < 10 AND now ≥ oldestQueuedAt + QUEUE_TIMEOUT_startBattle() with all
  • 0 < queue.length < 2 AND timed out → _unlockQueue(): every queued token reverts to INIT

commenceBattle(battleId) — WARMUP → BATTLE

Only callable once per battle, after warmupEndsAt passes. Sets nextRoundAt = now + TRADE_WINDOW, transitions every roster token from WARMUP → BATTLE.

If any token already has ethRaisedInPool ≥ EARLY_FINISH_THRESHOLD (950 ETH) at this point, the orchestrator skips rounds entirely and finalizes immediately with that token as winner.

runRound(battleId) — execute next round cut

Time-gated by block.timestamp ≥ nextRoundAt. Each call:

  1. Compute scores: score[poolId] = hook.getEthReserves(poolId) — live ETH in LP.
  2. Look up cut size from _cutSizeForRound(round, M) (see cut table).
  3. Sort active[] ascending by score; _eliminate the bottom cutSize:
    • Snapshot eliminationPrice[loser] = sqrtPriceX96
    • hook.withdrawForOrchestrator(loser) → unwind LP, ETH lands in this contract’s escrowedLoserEth
    • hook.transitionToEliminated(loser)
  4. If round < TOTAL_ROUNDS (4):
    • nextRoundAt = now + ROUND_GAP
    • currentRound++
  5. Else (R4) → _finalize:
    • Snapshot winner price
    • Unwind winner LP
    • Register every loser with ClaimManager at ratio = winnerPriceQ96 × 1e18 / loserPriceQ96
    • hook.provideTokens(winner, ClaimManager, winnerCover) for each loser
    • GraduationLP.deployForGraduation(battleId, winner, winnerEth, escrowedLoserEth)
    • hook.burnLeftoverTokens(winner) — burn unused supply
    • hook.disableMintFor(winner)
    • Mark battle.resolved = true, battle.winner = winner, emit BattleEnded

Battle struct (returned by getBattle)

struct Battle { PoolId[] tokens; // initial roster PoolId[] active; // survivors uint256 startedAt; uint256 currentRound; uint256 nextRoundAt; bool resolved; PoolId winner; uint256 escrowedLoserEth; uint256 warmupEndsAt; // 0 once battle has commenced }

Use getBattle(battleId) to fetch a full snapshot.

Implements AutomationCompatibleInterface:

function checkUpkeep(bytes calldata) external view returns (bool upkeepNeeded, bytes memory performData); function performUpkeep(bytes calldata performData) external;

checkUpkeep returns the first ready action in priority order:

  1. TickQueue — when queue is full or timeout has passed
  2. CommenceBattle — when a battle is in WARMUP past warmupEndsAt
  3. RunRound — when any active battle is past nextRoundAt

performUpkeep dispatches via this. to keep all the per-action access checks. Anyone can call — Chainlink is just the most reliable trigger.

Events

event QueueEntered(PoolId indexed poolId, uint256 enteredAt); event QueueUnlocked(PoolId[] tokens); event WarmupStarted(uint256 indexed battleId, PoolId[] tokens, uint256 warmupEndsAt); event BattleStarted(uint256 indexed battleId, PoolId[] tokens, uint256 firstRoundAt); event RoundExecuted(uint256 indexed battleId, uint256 round, PoolId[] active); event BattleEnded(uint256 indexed battleId, PoolId winner, uint256 winnerEth); event TokenEliminated(uint256 indexed battleId, PoolId indexed poolId, uint256 eliminationPrice);

Indexers typically subscribe to:

  • WarmupStarted, BattleStarted, RoundExecuted, BattleEnded — battle timeline
  • QueueEntered — show “in queue” cards on /discover

Errors

ErrorCauseFix
NotHookCaller of enterQueue wasn’t the registered hookOnly BattleHook.afterSwap should reach this
BattleNotFoundbattleId doesn’t existUse nextBattleId() - 1 or scan logs
BattleAlreadyResolvedrunRound/commenceBattle on a finished battleBattle has a winner; nothing more to do
TooEarlyblock.timestamp < nextRoundAt / warmupEndsAtWait for the time gate
WarmupAlreadyCommencedSecond commenceBattle attemptThe battle is already in BATTLE state
TokenNotInQueueInternal — should never trigger via public API

Common usage patterns

Reading current round + countdown

const { data: battle } = useReadContract({ address: ADDRESSES.BattleOrchestrator, abi: BattleOrchestratorAbi, functionName: "getBattle", args: [battleId], }); // battle = [tokens, active, startedAt, currentRound, nextRoundAt, // resolved, winner, escrowedLoserEth, warmupEndsAt] const nextRoundAt = Number(battle![4]); // unix-seconds const secondsLeft = Math.max(0, nextRoundAt - Math.floor(Date.now() / 1000));

Triggering an upkeep from a bot

const [needed, data] = await publicClient.readContract({ address: ADDRESSES.BattleOrchestrator, abi: BattleOrchestratorAbi, functionName: "checkUpkeep", args: ["0x"], }); if (needed) { await walletClient.writeContract({ address: ADDRESSES.BattleOrchestrator, abi: BattleOrchestratorAbi, functionName: "performUpkeep", args: [data], }); }

Sanity-check: can afterSwap safely auto-advance?

const safe = await publicClient.readContract({ address: ADDRESSES.BattleOrchestrator, abi: BattleOrchestratorAbi, functionName: "nextRoundIsSafeToAutoAdvance", args: [battleId], }); // true → next round is a no-cut tick; hook can piggy-back on the user's swap. // false → next round cuts/finalizes; the keeper must handle it (don't re-enter PM).

See also: BattleHook, Cut table, State machine.