BattleHook
The Uniswap V4 hook that owns every BattleMeme token’s LP, runs the per-token state machine, accounts ETH raised, splits fees between platform & creator, and enforces the trading freeze on QUEUE/WARMUP/ELIMINATED/LOST states.
Source:
contracts/src/core/BattleHook.sol. Hook permissions: all 14 V4 callbacks enabled (0x3FFF).
Roles
| Caller | What they can do |
|---|---|
factory (immutable after init) | bootstrapToken (via TokenFactory.createToken) |
orchestrator (set by owner) | All state transitions (transitionTo*), withdrawForOrchestrator, provideTokens, burnLeftoverTokens, disableMintFor, commitRoundScore |
claimManager | burnLoserForClaim — burn LOST tokens for the holder during claim |
poolManager | unlockCallback and all 14 hook callbacks |
| anyone | sweepLpFees(poolId) — permissionless poke; all view functions |
Public views — read state
For an FE/integrator the most useful read paths:
| Function | Returns | Notes |
|---|---|---|
poolToToken(poolId) | address | Resolve a V4 pool back to its ERC-20 |
tokenToPool(token) | PoolId | Inverse lookup |
getState(poolId) | State enum (0..8) | Drives every UI banner — see State machine |
getCurrentPrice(poolId) | uint256 (sqrtPriceX96 from slot0) | Square it / Q96 to get TOKEN-per-ETH |
getEthReserves(poolId) | uint256 | The score during a battle — live net ETH in the 5 LP positions |
totalETHRaised(poolId) | uint256 | Gross cumulative ETH spent on buys (compared against INIT_ETH_THRESHOLD = 5 ETH) |
cumulativeSupply(poolId) | uint256 | Token amount sold out of the curve so far |
uniqueBuyers(poolId) | uint256 | Wallets that cumulatively spent ≥ MIN_BUYER_ETH |
currentRoundScore(poolId) | uint256 | Net ETH inflow during the active TRADE window |
roundScore(poolId, round) | uint256 | Commited score for round 1..4 |
cumulativeBattleScore(poolId) | uint256 | Sum of all committed round scores |
getBattleStats(poolId) | (ethRaisedInBattle, uniqueBuyersFiltered, diamondHandsBps) | One-shot tuple for arena UI |
buyerInfo(poolId, buyer) | (firstSeenAt, cumulativeETH, largestBuy, counted) | Per-wallet history |
Bootstrap & creator pre-buy
function bootstrapToken(address token, address creator)
external payable
returns (PoolId poolId);Called once per token by TokenFactory.createToken:
- Initialize the V4 pool at
sqrtPriceX96 = sqrt(price@tick 173220)(top of the LP range). - Mint
LP_MINT = 1.6Btokens to the hook. poolManager.unlock(BootstrapAddLp)→ deposit all 1.6B across 5 chained single-sided positions at saltsbytes32(0..4).- If
msg.value > 0:poolManager.unlock(CreatorPrebuy)→ exact-input ETH→TOKEN swap ofmsg.valueat the freshly initialised price. Output TOKEN goes tocreator. The ETH leg creditstotalETHRaisedand counts toward the 5 ETH queue threshold.
Emits TokenBootstrapped(token, poolId).
Fee sweep — 1.0% / 0.3% split
function sweepLpFees(PoolId poolId)
external
returns (uint256 ethSwept, uint256 tokenSwept);Permissionless. Walks all 5 positions with modifyLiquidity(delta=0) to harvest accrued fees, then routes the combined BalanceDelta:
CREATOR_FEE_BPS / POOL_FEE = 3000/13000 ≈ 0.3% of trade→BattleMemeToken.creator- Remainder =
10000/13000 ≈ 1.0% of trade→factory.feeRecipient()
The same split is applied automatically before any LP unwind (graduation, withdrawal) so the vault captures principal only.
Emits LpFeesSwept(poolId, ethToPlatform, ethToCreator, tokenToPlatform, tokenToCreator).
State-transition gates
All callable only by orchestrator:
| Function | Transition |
|---|---|
transitionToQueue | INIT → QUEUE (also auto-called from afterSwap when totalETHRaised ≥ 5 ETH) |
transitionToWarmup | QUEUE → WARMUP |
transitionToBattle | WARMUP → BATTLE (round 1 active) |
transitionToResting | BATTLE → RESTING |
transitionToEliminated | BATTLE/RESTING → ELIMINATED |
transitionToGraduated | BATTLE/RESTING → GRADUATED |
transitionToLost | ELIMINATED → LOST (at finalize) |
unlockFromQueue | QUEUE → INIT (queue alone, timeout fired) |
commitRoundScore | Snapshot current TRADE-window net ETH to roundScores[round] |
Lifecycle support — orchestrator-only
| Function | What it does |
|---|---|
withdrawForOrchestrator(poolId) | Unwinds all 5 LPs, sweeps fees first, forwards ETH to caller. Used at elimination + finalize. |
provideTokens(token, recipient, amount) | Sends tokens to recipient — uses hook’s leftover balance first, mints the shortfall. Used to pre-fund ClaimManager + seed GraduationLP wide LP. |
burnLeftoverTokens(token) | Burns 100% of the hook’s residual balance. Run after elimination (loser tokens are dead) and after graduation (excess winner tokens become supply pollution). |
disableMintFor(token) | Permanent freeze on BattleMemeToken.mint. Called once on the winner at finalize. |
burnLoserForClaim(loserToken, holder, amount) | Called by ClaimManager.claim to burn the holder’s loser balance during a claim. |
Uniswap V4 callbacks
All 14 callbacks are enabled (mask 0x3FFF) but only a few are load-bearing:
| Callback | Behavior |
|---|---|
beforeInitialize | Validates pool key (ETH = currency0, fee 1.3%, hook = self) |
beforeAddLiquidity | Gates external LP adds — only selfLpAdding (hook-owned) allowed pre-grad; opens up for GRADUATED pools |
beforeRemoveLiquidity | Gates external LP removes — only selfLpRemoving pre-grad; opens up post-grad |
beforeSwap | Reverts on QUEUE/WARMUP/ELIMINATED/LOST/NONE (TokenFrozenForTrade / TokenLost); pass-through otherwise |
afterSwap | Updates totalETHRaised, ethRaisedInPool, currentRoundNetEth. Tracks buyers. Auto-triggers orchestrator.enterQueue at 5 ETH threshold. Opportunistically calls runRound for safe (no-cut) rounds. |
| Others | No-op pass-through (declared for forward-compat) |
Events
event TokenBootstrapped(address indexed token, PoolId indexed poolId);
event StateChanged(PoolId indexed poolId, State previous, State next);
event Trade(PoolId indexed poolId, address indexed trader, bool isBuy,
int128 amount0, int128 amount1, uint256 ethRaisedInPool);
event QualifiedForQueue(PoolId indexed poolId);
event LpFeesSwept(PoolId indexed poolId,
uint256 ethToPlatform, uint256 ethToCreator,
uint256 tokenToPlatform, uint256 tokenToCreator);
event RoundScoreCommitted(PoolId indexed poolId, uint256 indexed round, uint256 score);Indexers typically subscribe to:
Trade— price chart + fill bookStateChanged— drives UI bannersTokenBootstrapped+QualifiedForQueue— token lifecycleLpFeesSwept— platform / creator revenue accounting
Errors
| Error | Cause | Fix |
|---|---|---|
NotFactory | Caller wasn’t the registered factory | Only TokenFactory.createToken can call bootstrapToken |
NotOrchestrator | Caller wasn’t orchestrator (or claimManager for the claim path) | Use the keeper / wait for the lifecycle to schedule it |
NotPoolManager | Caller wasn’t Uniswap V4’s PoolManager | Hook callbacks are PM-only |
TokenAlreadyRegistered | Re-bootstrapping a pool | Each (currency0, currency1, fee, tickSpacing, hooks) combo is unique |
UnknownToken | poolId has no registered token | Pool was never bootstrapped through this hook |
TokenFrozenForTrade | swap attempted on QUEUE/WARMUP/ELIMINATED | Wait for the next tradable state |
TokenLost | swap attempted on a LOST token | Use ClaimManager.claim instead |
InvalidStateTransition | Orchestrator called a transition that isn’t valid from the current state | Check getState(poolId) first |
InvalidCurrencyOrdering | Pool initialized with wrong currency0 | Currency0 MUST be address(0) (ETH) |
UnauthorizedLiquidityAdd | External LP add attempted on a non-graduated pool | Only the hook can add LP pre-grad |
LiquidityRemovalForbidden | External LP remove attempted on a non-graduated pool | Only the hook can unwind pre-grad |
UnknownUnlockKind | Bad UnlockKind passed to unlockCallback | Internal — shouldn’t happen unless hook is misused |
Common usage patterns
Reading current state from a frontend
import { useReadContract } from "wagmi";
import { BattleHookAbi } from "@/lib/abis";
import { ADDRESSES } from "@/lib/addresses";
const { data: state } = useReadContract({
address: ADDRESSES.BattleHook,
abi: BattleHookAbi,
functionName: "getState",
args: [poolId],
});
// state: 0=NONE 1=INIT 2=QUEUE 3=WARMUP 4=BATTLE 5=RESTING 6=ELIMINATED 7=GRADUATED 8=LOSTLive score during a battle
const { data: ethInPool } = useReadContract({
address: ADDRESSES.BattleHook,
abi: BattleHookAbi,
functionName: "getEthReserves",
args: [poolId],
query: { refetchInterval: 3_000 }, // 3s polling is enough
});
// `ethInPool` is the live battle score, in wei.Sweeping fees as a keeper
import { useWriteContract } from "wagmi";
const { writeContract } = useWriteContract();
writeContract({
address: ADDRESSES.BattleHook,
abi: BattleHookAbi,
functionName: "sweepLpFees",
args: [poolId],
});
// No payment needed; anyone can do it. The 1.0%/0.3% split happens automatically.See also: BattleOrchestrator, State machine, Tokenomics.