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
| Caller | What 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 |
| anyone | tickQueue, 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 102 ≤ queue.length < 10ANDnow ≥ oldestQueuedAt + QUEUE_TIMEOUT→_startBattle()with all0 < queue.length < 2AND 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:
- Compute scores:
score[poolId] = hook.getEthReserves(poolId)— live ETH in LP. - Look up cut size from
_cutSizeForRound(round, M)(see cut table). - Sort
active[]ascending by score;_eliminatethe bottomcutSize:- Snapshot
eliminationPrice[loser] = sqrtPriceX96 hook.withdrawForOrchestrator(loser)→ unwind LP, ETH lands in this contract’sescrowedLoserEthhook.transitionToEliminated(loser)
- Snapshot
- If
round < TOTAL_ROUNDS (4):nextRoundAt = now + ROUND_GAPcurrentRound++
- Else (R4) →
_finalize:- Snapshot winner price
- Unwind winner LP
- Register every loser with
ClaimManagerat ratio =winnerPriceQ96 × 1e18 / loserPriceQ96 hook.provideTokens(winner, ClaimManager, winnerCover)for each loserGraduationLP.deployForGraduation(battleId, winner, winnerEth, escrowedLoserEth)hook.burnLeftoverTokens(winner)— burn unused supplyhook.disableMintFor(winner)- Mark
battle.resolved = true,battle.winner = winner, emitBattleEnded
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.
Chainlink Automation integration
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:
TickQueue— when queue is full or timeout has passedCommenceBattle— when a battle is in WARMUP pastwarmupEndsAtRunRound— when any active battle is pastnextRoundAt
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 timelineQueueEntered— show “in queue” cards on /discover
Errors
| Error | Cause | Fix |
|---|---|---|
NotHook | Caller of enterQueue wasn’t the registered hook | Only BattleHook.afterSwap should reach this |
BattleNotFound | battleId doesn’t exist | Use nextBattleId() - 1 or scan logs |
BattleAlreadyResolved | runRound/commenceBattle on a finished battle | Battle has a winner; nothing more to do |
TooEarly | block.timestamp < nextRoundAt / warmupEndsAt | Wait for the time gate |
WarmupAlreadyCommenced | Second commenceBattle attempt | The battle is already in BATTLE state |
TokenNotInQueue | Internal — 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.