// SPDX-License-Identifier: MIT pragma solidity 0.8.26; import {IHooks} from "v4-core/interfaces/IHooks.sol"; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {IUnlockCallback} from "v4-core/interfaces/callback/IUnlockCallback.sol"; import {Hooks} from "v4-core/libraries/Hooks.sol"; import {TickMath} from "v4-core/libraries/TickMath.sol"; import {FullMath} from "v4-core/libraries/FullMath.sol"; import {FixedPoint96} from "v4-core/libraries/FixedPoint96.sol"; import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; import {Currency} from "v4-core/types/Currency.sol"; import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; import {BeforeSwapDelta, BeforeSwapDeltaLibrary, toBeforeSwapDelta} from "v4-core/types/BeforeSwapDelta.sol"; import {ModifyLiquidityParams, SwapParams} from "v4-core/types/PoolOperation.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; interface IAmbrosiaToken { function transfer(address, uint256) external returns (bool); function balanceOf(address) external view returns (uint256); function burn(uint256) external; } interface IStakingFunding { function notifyHype() external payable; } /// @title Ambrosia pool hook /// @notice Owns the only liquidity in the native HYPE / $AMBR Uniswap v4 pool and has no way to remove it. /// Takes a 2% tax in HYPE on every buy and sell (30% falling to 2% over the first 10 minutes): /// 0.5% buys $AMBR and burns it, 0.5% deepens the pool, 0.5% to stakers, 0.5% to the team. /// Protocol buys run through `process()`, each capped at 1% of the pool's HYPE side. contract AmbrosiaHook is IHooks, IUnlockCallback { using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; uint256 public constant TAX_BPS = 200; uint256 public constant SNIPE_START_BPS = 3000; uint256 public constant SNIPE_WINDOW = 10 minutes; uint256 public constant PROCESS_CAP_BPS = 100; // 1% of the pool's HYPE side per protocol buy int24 public constant TICK_SPACING = 60; int24 public constant MIN_TICK = -887220; int24 public constant MAX_TICK = 887220; IPoolManager public immutable poolManager; IAmbrosiaToken public immutable token; address public immutable team; address private immutable deployer; address public minter; IStakingFunding public staking; uint64 public launchTime; PoolKey internal _key; uint256 public burnHype; uint256 public lpHype; uint256 public stakerHype; uint256 public teamHype; uint256 public totalTaxHype; uint256 public totalBurned; enum Action { Launch, BuyBurn, BuyLp } event Launched(uint160 sqrtPriceX96, uint256 hype, uint256 tokens, uint128 liquidity); event Tax(uint256 hype, uint256 bps); event Funded(address indexed from, uint256 toBurn, uint256 toPool); event BoughtAndBurned(uint256 hype, uint256 tokens); event Deepened(uint256 hype, uint256 tokens, uint128 liquidity); error NotPoolManager(); error NotMinter(); error AlreadyWired(); error AlreadyLaunched(); error NotLaunched(); error HookNotImplemented(); error OnlySelf(); error SendFailed(); modifier onlyPoolManager() { if (msg.sender != address(poolManager)) revert NotPoolManager(); _; } /// @param wirer_ The one address allowed to call `wire` once. Passed in because the hook is deployed /// through a CREATE2 factory to land on an address with the right permission bits. constructor(IPoolManager pm, IAmbrosiaToken token_, address team_, address wirer_) { poolManager = pm; token = token_; team = team_; deployer = wirer_; Hooks.validateHookPermissions(IHooks(address(this)), permissions()); } function permissions() public pure returns (Hooks.Permissions memory p) { p.beforeInitialize = true; p.beforeAddLiquidity = true; p.beforeSwap = true; p.afterSwap = true; p.beforeSwapReturnDelta = true; p.afterSwapReturnDelta = true; } function wire(address minter_, IStakingFunding staking_) external { if (msg.sender != deployer || minter != address(0)) revert AlreadyWired(); minter = minter_; staking = staking_; } receive() external payable {} // ------------------------------------------------------------------ views function poolKey() public view returns (PoolKey memory) { return _key; } function poolId() external view returns (PoolId) { return _key.toId(); } function currentTaxBps() public view returns (uint256) { uint256 t = launchTime; if (t == 0) return SNIPE_START_BPS; if (block.timestamp >= t + SNIPE_WINDOW) return TAX_BPS; return SNIPE_START_BPS - (SNIPE_START_BPS - TAX_BPS) * (block.timestamp - t) / SNIPE_WINDOW; } /// @notice HYPE on the pool's side of the full-range position (L / sqrtP in Q96). function poolHype() public view returns (uint256) { if (launchTime == 0) return 0; PoolId id = _key.toId(); (uint160 sqrtP,,,) = poolManager.getSlot0(id); uint128 liq = poolManager.getLiquidity(id); return FullMath.mulDiv(uint256(liq), FixedPoint96.Q96, sqrtP); } /// @notice Price of 1 $AMBR in HYPE wei, scaled by 1e18. function priceHypePerToken() external view returns (uint256) { if (launchTime == 0) return 0; (uint160 sqrtP,,,) = poolManager.getSlot0(_key.toId()); // sqrtP^2 / 2^192 = tokens per HYPE, so HYPE per token = 2^192 / sqrtP^2 return FullMath.mulDiv(1e18, 1 << 192, uint256(sqrtP) * uint256(sqrtP)); } // --------------------------------------------------------------- minter /// @notice Opens the pool with every token and HYPE sent. Only the minter, once. function launch(uint256 tokens) external payable { if (msg.sender != minter) revert NotMinter(); if (launchTime != 0) revert AlreadyLaunched(); _key = PoolKey({ currency0: Currency.wrap(address(0)), currency1: Currency.wrap(address(token)), fee: 0, tickSpacing: TICK_SPACING, hooks: IHooks(address(this)) }); uint160 sqrtP = uint160(Math.sqrt(FullMath.mulDiv(tokens, 1 << 192, msg.value))); launchTime = uint64(block.timestamp); poolManager.initialize(_key, sqrtP); bytes memory r = poolManager.unlock(abi.encode(Action.Launch, msg.value, tokens)); (uint256 usedHype, uint256 usedTokens, uint128 liq) = abi.decode(r, (uint256, uint256, uint128)); _sweepDust(msg.value - usedHype, tokens - usedTokens); emit Launched(sqrtP, usedHype, usedTokens, liq); } /// @notice A mint after launch: half buys $AMBR, then both halves deepen the pool. No tax. function deepenFromMint() external payable { if (msg.sender != minter) revert NotMinter(); if (launchTime == 0) revert NotLaunched(); _buyAndDeepen(msg.value); } // ---------------------------------------------------------------- funding /// @notice Anyone may fund buybacks. The rounds contract sends 5% of every pot here. function fund(uint256 toBurn) external payable { if (toBurn > msg.value) toBurn = msg.value; burnHype += toBurn; lpHype += msg.value - toBurn; emit Funded(msg.sender, toBurn, msg.value - toBurn); } /// @notice Anyone may call. Pays stakers, then one capped buy-and-burn and one capped buy-and-deepen. function process() external { if (launchTime == 0) revert NotLaunched(); uint256 s = stakerHype; if (s > 0) { stakerHype = 0; staking.notifyHype{value: s}(); } uint256 cap = poolHype() * PROCESS_CAP_BPS / 10_000; uint256 b = burnHype < cap ? burnHype : cap; if (b > 0) { burnHype -= b; bytes memory r = poolManager.unlock(abi.encode(Action.BuyBurn, b, uint256(0))); uint256 got = abi.decode(r, (uint256)); token.burn(got); totalBurned += got; emit BoughtAndBurned(b, got); } uint256 l = lpHype < cap ? lpHype : cap; if (l > 0) { lpHype -= l; _buyAndDeepen(l); } } function withdrawTeam() external { uint256 a = teamHype; teamHype = 0; (bool ok,) = team.call{value: a}(""); if (!ok) revert SendFailed(); } // ------------------------------------------------------------- internals function _buyAndDeepen(uint256 amount) private { if (amount < 2) { lpHype += amount; return; } uint256 half = amount / 2; bytes memory r = poolManager.unlock(abi.encode(Action.BuyLp, half, amount - half)); (uint256 usedHype, uint256 bought, uint256 usedTokens, uint128 liq) = abi.decode(r, (uint256, uint256, uint256, uint128)); _sweepDust(amount - half - usedHype, bought - usedTokens); emit Deepened(half + usedHype, usedTokens, liq); } function _sweepDust(uint256 hype, uint256 tokens) private { if (hype > 0) lpHype += hype; if (tokens > 0) { token.burn(tokens); totalBurned += tokens; } } function unlockCallback(bytes calldata data) external onlyPoolManager returns (bytes memory) { (Action a, uint256 x, uint256 y) = abi.decode(data, (Action, uint256, uint256)); if (a == Action.Launch) { (uint256 h, uint256 t, uint128 liq) = _addLiquidity(x, y); return abi.encode(h, t, liq); } uint256 bought = _swapIn(x); if (a == Action.BuyBurn) return abi.encode(bought); (uint256 h2, uint256 t2, uint128 liq2) = _addLiquidity(y, bought); return abi.encode(h2, bought, t2, liq2); } /// @dev Exact-input HYPE -> $AMBR swap by the hook itself (no tax). Returns tokens received. function _swapIn(uint256 hype) private returns (uint256) { BalanceDelta d = poolManager.swap( _key, SwapParams({zeroForOne: true, amountSpecified: -int256(hype), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), "" ); uint256 paid = uint256(uint128(-d.amount0())); uint256 got = uint256(uint128(d.amount1())); poolManager.settle{value: paid}(); poolManager.take(_key.currency1, address(this), got); if (paid < hype) lpHype += hype - paid; return got; } /// @dev Adds full-range liquidity with up to `hype` and `tokens` at the current price. function _addLiquidity(uint256 hype, uint256 tokens) private returns (uint256, uint256, uint128) { (uint160 sqrtP,,,) = poolManager.getSlot0(_key.toId()); uint128 liq = _liquidityFor(sqrtP, hype, tokens); if (liq == 0) return (0, 0, 0); (BalanceDelta d,) = poolManager.modifyLiquidity( _key, ModifyLiquidityParams({tickLower: MIN_TICK, tickUpper: MAX_TICK, liquidityDelta: int256(uint256(liq)), salt: 0}), "" ); uint256 h = uint256(uint128(-d.amount0())); uint256 t = uint256(uint128(-d.amount1())); if (h > 0) poolManager.settle{value: h}(); if (t > 0) { poolManager.sync(_key.currency1); token.transfer(address(poolManager), t); poolManager.settle(); } return (h, t, liq); } function _liquidityFor(uint160 sqrtP, uint256 amount0, uint256 amount1) private pure returns (uint128) { uint160 sqrtA = TickMath.getSqrtPriceAtTick(MIN_TICK); uint160 sqrtB = TickMath.getSqrtPriceAtTick(MAX_TICK); // keep one wei of headroom on each side for rounding up inside the pool if (amount0 > 0) amount0 -= 1; if (amount1 > 0) amount1 -= 1; uint256 l0 = FullMath.mulDiv(amount0, FullMath.mulDiv(sqrtP, sqrtB, FixedPoint96.Q96), sqrtB - sqrtP); uint256 l1 = FullMath.mulDiv(amount1, FixedPoint96.Q96, sqrtP - sqrtA); uint256 l = l0 < l1 ? l0 : l1; return l > type(uint128).max ? type(uint128).max : uint128(l); } function _takeTax(uint256 hypeAmount) private returns (uint256 fee) { uint256 bps = currentTaxBps(); fee = hypeAmount * bps / 10_000; if (fee == 0) return 0; poolManager.take(Currency.wrap(address(0)), address(this), fee); uint256 base = hypeAmount * TAX_BPS / 10_000; if (base > fee) base = fee; uint256 q = base / 4; lpHype += q; stakerHype += q; teamHype += q; burnHype += fee - 3 * q; // anything above 2% during the anti-snipe window is burned totalTaxHype += fee; emit Tax(fee, bps); } /// @dev HYPE is currency0. The tax is taken on the HYPE leg: in beforeSwap when HYPE is the /// specified currency, otherwise in afterSwap on the unspecified delta. function _hypeSpecified(SwapParams calldata p) private pure returns (bool) { return p.zeroForOne == (p.amountSpecified < 0); } // ------------------------------------------------------------------ hooks function beforeInitialize(address sender, PoolKey calldata, uint160) external view onlyPoolManager returns (bytes4) { if (sender != address(this)) revert OnlySelf(); return IHooks.beforeInitialize.selector; } function beforeAddLiquidity(address sender, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) external view onlyPoolManager returns (bytes4) { if (sender != address(this)) revert OnlySelf(); return IHooks.beforeAddLiquidity.selector; } function beforeSwap(address sender, PoolKey calldata, SwapParams calldata params, bytes calldata) external onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) { if (sender == address(this) || !_hypeSpecified(params)) { return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); } uint256 amt = params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified); uint256 fee = _takeTax(amt); return (IHooks.beforeSwap.selector, toBeforeSwapDelta(int128(int256(fee)), 0), 0); } function afterSwap(address sender, PoolKey calldata, SwapParams calldata params, BalanceDelta delta, bytes calldata) external onlyPoolManager returns (bytes4, int128) { if (sender == address(this) || _hypeSpecified(params)) return (IHooks.afterSwap.selector, 0); int128 h = delta.amount0(); uint256 amt = h < 0 ? uint256(uint128(-h)) : uint256(uint128(h)); uint256 fee = _takeTax(amt); return (IHooks.afterSwap.selector, int128(int256(fee))); } function afterInitialize(address, PoolKey calldata, uint160, int24) external pure returns (bytes4) { revert HookNotImplemented(); } function afterAddLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, BalanceDelta, BalanceDelta, bytes calldata) external pure returns (bytes4, BalanceDelta) { revert HookNotImplemented(); } function beforeRemoveLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) external pure returns (bytes4) { revert HookNotImplemented(); } function afterRemoveLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, BalanceDelta, BalanceDelta, bytes calldata) external pure returns (bytes4, BalanceDelta) { revert HookNotImplemented(); } function beforeDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { revert HookNotImplemented(); } function afterDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { revert HookNotImplemented(); } }