// SPDX-License-Identifier: MIT pragma solidity 0.8.28; interface ILaunchpad { function collectFees(address token) external returns (uint256, uint256, uint256, uint256); } interface IWHYPE { function deposit() external payable; function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); } interface IERC20 { function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); } interface IUniswapV3Pool { function token0() external view returns (address); function token1() external view returns (address); function slot0() external view returns ( uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked ); function observe(uint32[] calldata secondsAgos) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); function increaseObservationCardinalityNext(uint16 observationCardinalityNext) external; function swap(address recipient, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, bytes calldata data) external returns (int256 amount0, int256 amount1); } /// @title FeeBurner /// @notice Holds the creator role of one ElysPump launch. Each `burn()` collects the creator's share of /// the launch pool's fees, buys the token back on that pool with the HYPE share, and sends every token /// the contract holds to the dead address. /// @dev The contract has no owner, no admin and no withdraw path; every parameter is fixed at deploy. /// Anyone may call `burn()`. A buyback requires an available pool TWAP and skips when the token is /// more expensive than that TWAP by over `maxDeviationTicks`. It spends at most `maxSpendPerBurn`, /// with a spot-based `maxImpactBps` price limit; unspent WHYPE waits for a later burn. Excluding the /// pool fee, one burn pays at most about `maxDeviationTicks` (a price factor of 1.0001^ticks) plus /// `maxImpactBps` above the TWAP, plus up to one tick of rounding. Including the 1% pool fee every /// buyer pays, that is about 2.5% at the defaults: at most about 0.025 HYPE per burn with a /// `maxSpendPerBurn` of 1 HYPE. Moving the price that far and back costs the other party about 2% /// in pool fees on the volume needed. Burns are at least `minInterval` seconds apart, so they cannot /// be chained in one transaction. This bound is relative to the TWAP, not an independent fair price; /// sustained manipulation of the TWAP is not ruled out. contract FeeBurner { address public constant DEAD = 0x000000000000000000000000000000000000dEaD; // Uniswap V3 TickMath bounds. uint160 internal constant MIN_SQRT_RATIO = 4295128739; uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; ILaunchpad public immutable launchpad; IERC20 public immutable token; IWHYPE public immutable whype; IUniswapV3Pool public immutable pool; bool public immutable whypeIsToken0; uint16 public immutable maxImpactBps; uint32 public immutable minInterval; uint32 public immutable twapWindow; uint24 public immutable maxDeviationTicks; uint256 public immutable maxSpendPerBurn; uint16 public immutable observationCardinality; // 0: swapped or nothing to spend; 1: TWAP unavailable; 2: token too expensive versus TWAP. uint8 public lastSkipReason; uint64 public lastBurnAt; uint256 public totalHypeSpent; uint256 public totalTokensBought; uint256 public totalBurned; uint256 public burnCount; bool private locked; event Burned( address indexed caller, uint256 hypeSpent, uint256 tokensBought, uint256 tokensBurned, uint256 hypeCarried, uint160 sqrtPriceX96After, uint8 skipReason ); error BadPool(); error BadParam(); error TooSoon(uint256 nextBurnAt); error Reentered(); error NotPool(); error TransferFailed(); constructor( address launchpad_, address token_, address whype_, address pool_, uint16 maxImpactBps_, uint32 minInterval_, uint32 twapWindow_, uint24 maxDeviationTicks_, uint256 maxSpendPerBurn_, uint16 observationCardinality_ ) { if ( maxImpactBps_ == 0 || maxImpactBps_ >= 10_000 || minInterval_ == 0 || twapWindow_ == 0 || maxDeviationTicks_ == 0 || maxSpendPerBurn_ == 0 || observationCardinality_ == 0 ) revert BadParam(); address t0 = IUniswapV3Pool(pool_).token0(); address t1 = IUniswapV3Pool(pool_).token1(); if (t0 == whype_ && t1 == token_) whypeIsToken0 = true; else if (t0 == token_ && t1 == whype_) whypeIsToken0 = false; else revert BadPool(); launchpad = ILaunchpad(launchpad_); token = IERC20(token_); whype = IWHYPE(whype_); pool = IUniswapV3Pool(pool_); maxImpactBps = maxImpactBps_; minInterval = minInterval_; twapWindow = twapWindow_; maxDeviationTicks = maxDeviationTicks_; maxSpendPerBurn = maxSpendPerBurn_; observationCardinality = observationCardinality_; pool.increaseObservationCardinalityNext(observationCardinality_); } /// @notice The launchpad pays the creator's HYPE share here as native HYPE. Kept empty on purpose: /// if this reverted, the launchpad would still complete the collect and the HYPE would never arrive. receive() external payable {} /// @notice Earliest timestamp at which `burn()` succeeds. function nextBurnAt() public view returns (uint256) { return lastBurnAt == 0 ? 0 : uint256(lastBurnAt) + minInterval; } /// @notice Collect fees, buy back within the TWAP, spend and impact limits, and burn every token held. /// A skipped buyback still burns fee tokens and carries WHYPE forward. function burn() external returns (uint256 hypeSpent, uint256 tokensBought, uint256 tokensBurned) { if (locked) revert Reentered(); locked = true; uint256 next = nextBurnAt(); if (block.timestamp < next) revert TooSoon(next); lastBurnAt = uint64(block.timestamp); // Permissionless on the launchpad. A failure (for example nothing to collect, or the // launchpad paused) must not block burning what is already here. (bool collected,) = address(launchpad).call(abi.encodeCall(ILaunchpad.collectFees, (address(token)))); collected; uint256 native = address(this).balance; if (native > 0) whype.deposit{value: native}(); uint256 available = whype.balanceOf(address(this)); uint160 sqrtAfter; lastSkipReason = 0; if (available > 0) (hypeSpent, tokensBought) = _buyback(available); (sqrtAfter,,,,,,) = pool.slot0(); tokensBurned = token.balanceOf(address(this)); if (tokensBurned > 0 && !token.transfer(DEAD, tokensBurned)) revert TransferFailed(); totalHypeSpent += hypeSpent; totalTokensBought += tokensBought; totalBurned += tokensBurned; burnCount += 1; emit Burned(msg.sender, hypeSpent, tokensBought, tokensBurned, available - hypeSpent, sqrtAfter, lastSkipReason); locked = false; } function _buyback(uint256 available) internal returns (uint256 hypeSpent, uint256 tokensBought) { uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = twapWindow; int56 meanTick; try pool.observe(secondsAgos) returns (int56[] memory ticks, uint160[] memory) { int56 delta = ticks[1] - ticks[0]; int56 window = int56(uint56(twapWindow)); meanTick = delta / window; // Match OracleLibrary.consult: Solidity division truncates toward zero, not -infinity. if (delta < 0 && delta % window != 0) meanTick--; } catch { lastSkipReason = 1; return (0, 0); } (uint160 sqrtPrice, int24 spotTick,,,,,) = pool.slot0(); // Widen before arithmetic so the full uint24 deviation range cannot overflow int24. int256 maxDev = int256(uint256(maxDeviationTicks)); if ( whypeIsToken0 ? int256(spotTick) < int256(meanTick) - maxDev : int256(spotTick) > int256(meanTick) + maxDev ) { lastSkipReason = 2; return (0, 0); } uint256 spend = available < maxSpendPerBurn ? available : maxSpendPerBurn; (int256 amount0, int256 amount1) = pool.swap(address(this), whypeIsToken0, int256(spend), _priceLimit(sqrtPrice), ""); if (whypeIsToken0) return (uint256(amount0), uint256(-amount1)); return (uint256(amount1), uint256(-amount0)); } /// @dev Pays the pool what the buyback owes. Only the pool can call it, and the pool only calls /// it inside a swap this contract started. function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external { if (msg.sender != address(pool)) revert NotPool(); int256 owed = whypeIsToken0 ? amount0Delta : amount1Delta; if (owed > 0 && !whype.transfer(address(pool), uint256(owed))) revert TransferFailed(); } /// @dev Price limit for one buyback: the price of the token in HYPE may rise by at most maxImpactBps. /// WHYPE as token0: we sell token0, sqrtPrice falls, limit = sqrtP * sqrt(1 - b). /// WHYPE as token1: we sell token1, sqrtPrice rises, limit = sqrtP / sqrt(1 - b). function _priceLimit(uint160 sqrtPrice) internal view returns (uint160) { uint256 factor = _sqrt(uint256(10_000 - maxImpactBps) * 1e32); // sqrt(1 - b) scaled by 1e18 if (whypeIsToken0) { uint256 limit = uint256(sqrtPrice) * factor / 1e18; return limit <= MIN_SQRT_RATIO ? MIN_SQRT_RATIO + 1 : uint160(limit); } uint256 up = uint256(sqrtPrice) * 1e18 / factor; return up >= MAX_SQRT_RATIO ? MAX_SQRT_RATIO - 1 : uint160(up); } function _sqrt(uint256 x) internal pure returns (uint256 z) { if (x == 0) return 0; z = x; uint256 y = (x >> 1) + 1; while (y < z) { z = y; y = (x / y + y) >> 1; } } }