Security
Self-review tracking for testnet deployment readiness. Not a substitute for an external audit before mainnet. Bug bounty starts at testnet launch.
Trust assumptions
- No upfront team grant. The creator’s 0.01 ETH is spent as an atomic pre-buy at the pool’s floor price — they receive only what 0.01 ETH buys at price 1×, the same as any walk-up first buyer.
- Mint gated. All mints go through the hook;
disableMint()permanently freezes mint on the winner at graduation. Leftover winner tokens are burned at finalize so total supply never exceeds what holders actually need. - LP lock is in-protocol. Principal cannot be withdrawn until the lock window passes — anyone can call
unlock(battleId, recipient)once eligible. - Public entrypoints.
tickQueue,commenceBattle,runRoundare permissionless; anyone can advance the protocol if Chainlink Automation lags. - No on-chain randomness. Round cadence is fully deterministic — no VRF, no
block.prevrandao.
Creator alignment
- Creator receives a first-buy stake (tokens bought with their 0.01 ETH at price 1×) — same price any other first buyer would pay. Skin in the game without a pre-mint.
- Creator earns 0.3% of every swap (out of the 1.3% pool fee) — perpetually, both during the battle and after graduation. Alignment incentive: more volume = more fee revenue.
BattleMemeToken.creatorisimmutable(set at deployment, can never be transferred). If the creator address is somehowaddress(0)(extreme edge case), the 0.3% share is forfeited and the full 1.3% goes to platform.
Architecture-level
| Item | Status | Notes |
|---|---|---|
Native AMM (no BeforeSwapDelta) means no custom curve invariants to break | ✅ | Curve dynamics emerge from concentrated LP + Uniswap v4 standard math |
Single shared hook for all tokens (state keyed by PoolId) | ✅ | Reduces deploy cost; permissions still encoded in hook proxy address |
BattleMemeToken.creator is immutable | ✅ | Can never be transferred; aligns creator fee accrual forever |
| Hook permissions encoded in proxy address (CREATE2 + HookMiner) | ✅ | Mask 0x3FFF (all 14 callbacks) — forward-compat without redeploy |
Reentrancy
| Item | Status | Notes |
|---|---|---|
BattleOrchestrator.runRound guarded with nonReentrant | ✅ | Inherits ReentrancyGuard |
ClaimManager.claim guarded with nonReentrant | ✅ | Same |
VoucherManager.redeem / redeemWhitelist guarded with nonReentrant | ✅ | Same |
GraduationLP.collectFees / unlock guarded with nonReentrant | ✅ | Same |
BattleHook external calls happen inside PM unlock callback | ✅ | PM enforces single in-flight unlock — implicit reentrancy guard |
Checks-effects-interactions on claim | ✅ | Burns loser via hook → transfers winner |
Access control
| Function | Gate |
|---|---|
BattleHook.bootstrapToken | onlyFactory |
BattleHook.transitionTo* | onlyOrchestrator |
BattleHook.withdrawForOrchestrator | onlyOrchestrator |
BattleHook.provideTokens | onlyOrchestrator (or graduationLp) |
BattleHook.burnLeftoverTokens | onlyOrchestrator |
BattleHook.disableMintFor | onlyOrchestrator |
BattleHook.burnLoserForClaim | msg.sender == claimManager |
BattleHook.set{Orchestrator,Factory,ClaimManager,GraduationLp} | onlyOwner |
BattleHook.beforeAddLiquidity | selfLpAdding && sender == hook (pre-grad); open for GRADUATED |
BattleHook.beforeRemoveLiquidity | selfLpRemoving && sender == hook (pre-grad); open for GRADUATED |
BattleHook.beforeSwap/afterSwap/... | onlyPoolManager |
BattleHook.unlockCallback | onlyPoolManager |
BattleHook.sweepLpFees | public (permissionless) — fees auto-split 1.0% / 0.3% |
BattleOrchestrator.enterQueue | msg.sender == hook |
BattleOrchestrator.emergencyEndBattle | onlyOwner |
BattleOrchestrator.tickQueue/commenceBattle/runRound | public (trustless fallback — Chainlink Automation primary, anyone secondary) |
BattleOrchestrator.performUpkeep | public (validates state via dispatched method) |
ClaimManager.registerLosingToken | msg.sender == orchestrator |
ClaimManager.setOrchestrator | onlyOwner |
ClaimManager.claim | public (any holder) |
GraduationLP.deployForGraduation | msg.sender == orchestrator |
GraduationLP.collectFees | public (permissionless — auto-split) |
GraduationLP.unlock | public (gated by unlockAt) |
VoucherManager.cancelCampaign / cancelAll | msg.sender == campaign.creator |
State machine integrity
| Invariant | Status |
|---|---|
| State transitions match documented graph (no skips, no reverse) | ✅ |
| Once GRADUATED, mint disabled forever | ✅ |
| Token in QUEUE/WARMUP/ELIMINATED/LOST cannot trade | ✅ |
INIT-phase token has totalETHRaised < INIT_ETH_THRESHOLD | ✅ |
Total winner supply bounded by LP_MINT + claim coverage, then burned down | ✅ |
| Hook never accumulates physical ETH (transient only inside unlock) | ✅ |
ETH flow
| Path |
|---|
| Creation fee 0.01 ETH → atomic creator pre-buy (NOT directly to platform) |
| Buy: ETH paid by user → PM → LP positions hold it as currency0 reserve |
| Sell: tokens paid by user → PM, ETH released from LP reserves |
| Eliminated/winner ETH withdrawal: hook unwinds 5 LP positions, takes ETH, forwards to orchestrator |
Orchestrator receives ETH, forwards to GraduationLP.deployForGraduation |
GraduationLP custodies positions; principal locked until unlockAt |
| Hook never accumulates physical ETH (transient only inside PM unlock) |
Token flow
| Path |
|---|
| Bootstrap: 1.6B mint → all into 5 LP positions on PM, hook holds 0 balance |
| Atomic creator pre-buy: 0.01 ETH → exact-input swap → tokens land in creator |
| Buy: PM → user via standard AMM swap |
| Sell: user → PM via standard AMM swap |
| Graduation: hook mints additional winner tokens directly to ClaimManager |
| Burn-leftover at finalize: hook burns 100% of its own residual token balance |
| LOST token: transfers frozen except via burn-to-claim path |
| Burn-to-claim: hook burns loser, ClaimManager transfers winner tokens |
Fee flow
| Path |
|---|
Pre-grad sweep: hook.sweepLpFees(poolId) → 1.0% LP fee + 0.3% creator |
Post-grad sweep: GraduationLP.collectFees(battleId) → same 1.0% / 0.3% split |
| Sweep auto-runs before any LP unwind (vault captures principal only) |
| Rounding goes to platform, not lost (creator share computed first; platform takes remainder) |
MEV considerations
| Vector | Mitigation |
|---|---|
| Sandwich on user swaps | User-supplied slippage limit via Uniswap UI / aggregator (out of our scope) |
| Front-run INIT→Queue trigger | The auto-trigger in afterSwap happens atomically — no separate tx to MEV |
| Battle scoring last-block manipulation | Score reads hook.getEthReserves(poolId) (live ethRaisedInPool). Sells immediately reduce score — a last-block sandwich could shift rank, but the deterministic nextRoundAt schedule is public so all participants can prepare. Future: TWAP or commit-reveal for hardening. |
| Just-in-time LP add/remove | LP add/remove gated to hook-self only pre-grad — external JIT impossible. Post-grad, standard LP rules apply. |
| Round-time prediction | By design. ROUND_GAP is constant; users SEE the next cutoff and can plan around it. No randomness. |
| Voucher signature replay | Each (campaignId, voucherIndex) is single-use; consumed mapping prevents replay. Sigs are also EIP-191 personal-sign tied to chainId. |
Edge cases handled
- ✅ Empty queue +
tickQueue→ no-op - ✅ 1 token in queue + timeout →
_unlockQueue(back to INIT) - ✅ ≥ 2 tokens + queue full or timeout → start battle
- ✅
runRoundcalled too early → revert withTooEarly - ✅
runRoundcalled twice in a row → second call reverts (nextRoundAtupdated after first) - ✅
commenceBattlecalled twice → revert (WarmupAlreadyCommenced) - ✅
emergencyEndBattlecalled on resolved battle → revert (BattleAlreadyResolved) - ✅ Insufficient ETH reserves on sell → AMM math reverts naturally (no panic)
- ✅ External user adds liquidity directly pre-grad → revert (
UnauthorizedLiquidityAdd) - ✅ External user removes liquidity pre-grad → revert (
LiquidityRemovalForbidden) - ✅ Pool initialized with wrong currency ordering → revert (
InvalidCurrencyOrdering) - ✅
claimwith 0 result (dust) → revert (NothingToClaim) - ✅ Voucher redeem on QUEUE/WARMUP token → revert (
BadTokenState) - ✅ Voucher signature wrong signer → revert (
BadSignature)
Known limitations / future work
| Item | Plan |
|---|---|
TWAP for scoring (currently spot from slot0) | Post-MVP — guards against last-block manipulation |
| Wallet age / hold-time anti-Sybil checks | Post-MVP — Sismo / Privy attestations |
| Multi-sig + 48h timelock on owner role | Mainnet deployment script |
| Real UNCX V4 locker integration | Future — IUNCXV4Locker interface already exists for the swap |
| Composite score (ETH + buyers + price-growth) | Library scaffolded (BattleScoring.sol); not wired today |
unlockedCooldownUntil enforcement | Field written but not read — currently a no-op |
Known design trade-offs
- Score = live ETH in pool means sells immediately reduce rank — by design, this puts pressure on holders and rewards conviction. A wallet pumping then dumping in the same trade window swings their own score back down.
- Cut table is bracket-aware (varies with
M) — UI should compute the at-risk preview client-side fromgetBattle+_cutSizeForRound. - GraduationLP.unlock recipient is caller-chosen — by design the lock window enforces time, not recipient. The protocol’s keeper/operator should call this with the appropriate destination at unlock time (e.g. a community multisig).
- R1 has no warning event — the schedule is public via
nextRoundAt; UI computes its own countdown. Eliminates the surface area of an on-chain warning emitter.
Files
src/core/BattleMemeToken.sol— ERC-20, hook-controlled mint/burn (immutable)src/core/TokenFactory.sol— deploys token + forwards 0.01 ETH for atomic creator pre-buysrc/core/BattleHook.sol— single shared hook for all tokenssrc/core/BattleOrchestrator.sol— queue + battle + Chainlink Automationsrc/core/ClaimManager.sol— loser → winner conversionsrc/core/GraduationLP.sol— per-winner wide+wall LP custodiansrc/core/VoucherManager.sol— creator promo (signed + Merkle)src/libraries/HookMiner.sol— CREATE2 salt mining for hook proxy
See also: Architecture, Tokenomics, all contract refs under
/contracts/.