// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; import {AdaptiveFeePolicy} from "./AdaptiveFeePolicy.sol"; import {BaseHook} from "@uniswap/v4-periphery/src/utils/BaseHook.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; /// @title TransparentAdaptiveFeeHook (7Labs product V3; Uniswap v4) /// @notice Fixed or completed-window volume-based Hook fees selected before pool creation. /// @dev Policy is immutable per pool. Quoters and traders execute identical rules. No keeper, /// oracle, caller discrimination or browser uptime is required. Reference budget is not TVL. contract TransparentAdaptiveFeeHook is BaseHook, Ownable2Step, ReentrancyGuard, IUnlockCallback { using CurrencyLibrary for Currency; using PoolIdLibrary for PoolKey; using SafeCast for uint256; uint24 public constant FEE_DENOMINATOR = 1_000_000; uint24 public constant MAX_FEE_PPM = 180_000; uint16 public constant SHARE_DENOMINATOR = 10_000; uint64 public constant FEE_CHANGE_DELAY = 1 days; uint256 public constant MAX_CLAIM_PER_UNLOCK = uint256(uint128(type(int128).max)); struct PoolConfig { address baseCurrency; address treasury; address creator; uint16 creatorShareBps; uint64 version; bool configured; bool feesPaused; AdaptiveFeePolicy.Policy policy; } struct SwapGuard { uint24 maxFeePpm; uint64 expectedVersion; uint48 deadline; } mapping(PoolId poolId => PoolConfig config) private _poolConfigs; mapping(PoolId poolId => AdaptiveFeePolicy.State state) private _feeStates; mapping(PoolId poolId => uint64 validAfter) public feeResumeAfter; mapping(PoolId poolId => mapping(address currency => mapping(address recipient => uint256 amount))) public claimable; mapping(PoolId poolId => mapping(address currency => uint256 amount)) public totalAccrued; mapping(PoolId poolId => mapping(address currency => uint256 amount)) public totalClaimed; mapping(PoolId poolId => mapping(address currency => uint256 remainder)) public creatorRemainder; mapping(PoolId poolId => uint160 sqrtPriceX96) private _pendingInitialPrice; bool private _claimUnlockActive; bytes32 private _claimUnlockHash; error DynamicLPFeeRequired(uint24 suppliedFee); error HookAddressMismatch(address suppliedHook); error PoolNotConfigured(PoolId poolId); error PoolAlreadyConfigured(PoolId poolId); error InvalidBaseCurrency(address baseCurrency); error InvalidRecipient(address recipient); error InvalidPoolManager(address manager); error OwnershipRenunciationDisabled(); error InvalidShare(uint16 shareBps); error PoolFeesNotPaused(PoolId poolId); error NoPendingFeeResume(PoolId poolId); error FeeResumeNotReady(uint64 validAfter); error SwapGuardExpired(uint48 deadline); error PolicyVersionChanged(uint64 expected, uint64 actual); error FeeExceedsUserMaximum(uint24 applied, uint24 maximum); error InvalidHookDataLength(uint256 suppliedLength); error AdjustedInputTooLarge(uint256 ammInput, uint256 hookFee); error FeeRoundsToZero(uint256 feeBasis, uint24 feePpm); error NothingToClaim(); error NoActiveClaimUnlock(); error UnexpectedClaimUnlockData(); error ClaimUnlockNotCompleted(); error UnauthorizedInitializer(address sender); error UnexpectedInitialPrice(uint160 supplied, uint160 expected); error RecipientAmountMismatch(address currency, address recipient, uint256 expected, uint256 actual); event PoolConfigured( PoolId indexed poolId, address indexed baseCurrency, address indexed treasury, address creator, uint16 creatorShareBps, uint64 version, AdaptiveFeePolicy.Policy policy ); event AdaptiveFeeWindow(PoolId indexed poolId, uint64 windowStart, uint24 feePpm, uint128 previousCappedVolumeQuoteRaw); event QuoteVolumeObserved(PoolId indexed poolId, uint64 windowStart, uint256 quoteAmountRaw, uint128 cappedWindowVolumeRaw); event FeeResumeScheduled(PoolId indexed poolId, uint64 validAfter); event FeesPaused(PoolId indexed poolId, bool paused, uint64 version); event HookFeeCharged( PoolId indexed poolId, address indexed sender, address indexed feeCurrency, uint24 feePpm, uint256 unspecifiedAmount, uint256 totalFee, uint256 treasuryAmount, uint256 creatorAmount, bool isBuy, bool exactInput, uint64 policyVersion ); event FeesClaimed( PoolId indexed poolId, address indexed currency, address indexed recipient, uint256 amount ); event PoolInitialized(PoolId indexed poolId, uint160 sqrtPriceX96, int24 tick); constructor(IPoolManager manager_, address initialOwner_) BaseHook(manager_) Ownable(initialOwner_) { if (address(manager_).code.length == 0) revert InvalidPoolManager(address(manager_)); } receive() external payable {} /// @dev Required low-bit mask: 0x20c4. function getHookPermissions() public pure override returns (Hooks.Permissions memory permissions) { permissions.beforeInitialize = true; permissions.beforeSwap = true; permissions.afterSwap = true; permissions.afterSwapReturnDelta = true; } /// @notice Atomically configures and initializes a pool at the owner-approved price. /// @dev Keeping both actions in one transaction removes the public initialization race. function configureAndInitialize( PoolKey calldata key, address baseCurrency_, AdaptiveFeePolicy.Policy calldata policy_, address treasury_, address creator_, uint16 creatorShareBps_, uint160 sqrtPriceX96_ ) external onlyOwner nonReentrant returns (PoolId poolId, int24 tick) { _validateKey(key); AdaptiveFeePolicy.validate(policy_); address currency0 = Currency.unwrap(key.currency0); address currency1 = Currency.unwrap(key.currency1); if (baseCurrency_ != currency0 && baseCurrency_ != currency1) { revert InvalidBaseCurrency(baseCurrency_); } _validateRecipient(treasury_); if (creatorShareBps_ > SHARE_DENOMINATOR) revert InvalidShare(creatorShareBps_); if (creatorShareBps_ != 0) _validateRecipient(creator_); poolId = key.toId(); if (_poolConfigs[poolId].configured) revert PoolAlreadyConfigured(poolId); _poolConfigs[poolId] = PoolConfig({ baseCurrency: baseCurrency_, treasury: treasury_, creator: creator_, creatorShareBps: creatorShareBps_, version: 1, configured: true, feesPaused: false, policy: policy_ }); _feeStates[poolId] = AdaptiveFeePolicy.initial(policy_, block.timestamp); emit PoolConfigured(poolId, baseCurrency_, treasury_, creator_, creatorShareBps_, 1, policy_); _pendingInitialPrice[poolId] = sqrtPriceX96_; tick = poolManager.initialize(key, sqrtPriceX96_); delete _pendingInitialPrice[poolId]; emit PoolInitialized(poolId, sqrtPriceX96_, tick); } /// @notice Schedules restoration of Hook fees after the immutable delay. /// @dev Scheduling again can only move the effective time later. function scheduleFeeResume(PoolId poolId) external onlyOwner returns (uint64 validAfter) { _requireConfigured(poolId); if (!_poolConfigs[poolId].feesPaused) revert PoolFeesNotPaused(poolId); validAfter = uint64(block.timestamp) + FEE_CHANGE_DELAY; feeResumeAfter[poolId] = validAfter; emit FeeResumeScheduled(poolId, validAfter); } /// @notice Emergency pause sets Hook fees to zero but keeps pool swaps available. /// @dev Pausing is immediate. Restoring fees requires scheduleFeeResume and the full delay. /// Calling pause again deliberately cancels any pending resume. function setFeesPaused(PoolId poolId, bool paused_) external onlyOwner { _requireConfigured(poolId); PoolConfig storage config = _poolConfigs[poolId]; if (paused_) { config.feesPaused = true; _feeStates[poolId] = AdaptiveFeePolicy.initial(config.policy, block.timestamp); delete feeResumeAfter[poolId]; config.version += 1; emit FeesPaused(poolId, true, config.version); return; } if (!config.feesPaused) revert PoolFeesNotPaused(poolId); uint64 validAfter = feeResumeAfter[poolId]; if (validAfter == 0) revert NoPendingFeeResume(poolId); if (block.timestamp < validAfter) revert FeeResumeNotReady(validAfter); delete feeResumeAfter[poolId]; config.feesPaused = false; _feeStates[poolId] = AdaptiveFeePolicy.initial(config.policy, block.timestamp); config.version += 1; emit FeesPaused(poolId, false, config.version); } function getPoolConfig(PoolId poolId) external view returns (PoolConfig memory) { return _poolConfigs[poolId]; } /// @notice Current projected rate; a new window is reflected even before the next swap. function getAdaptiveState(PoolId poolId) external view returns (AdaptiveFeePolicy.State memory state, uint24 effectiveFeePpm) { _requireConfigured(poolId); PoolConfig storage config = _poolConfigs[poolId]; state = AdaptiveFeePolicy.project(config.policy, _feeStates[poolId], block.timestamp); effectiveFeePpm = config.feesPaused ? 0 : state.feePpm; } /// @notice Preview Hook fee from the raw pre-Hook AMM amount on the unspecified side. /// @dev A standard V4Quoter result already includes Hook deltas and must not be passed here. /// The split preview includes the current rounding remainder and can move by one minimum unit /// if another transaction for this pool and currency executes before the quoted transaction. function previewFee( PoolKey calldata key, bool zeroForOne, bool exactInput, uint256 unspecifiedAmount ) external view returns ( uint24 feePpm, address feeCurrency, uint256 totalFee, uint256 treasuryAmount, uint256 creatorAmount, bool isBuy, uint64 policyVersion ) { PoolId poolId = key.toId(); PoolConfig memory config = _poolConfigs[poolId]; if (!config.configured) revert PoolNotConfigured(poolId); Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1; isBuy = Currency.unwrap(inputCurrency) == config.baseCurrency; feePpm = config.feesPaused ? 0 : AdaptiveFeePolicy.project(config.policy, _feeStates[poolId], block.timestamp).feePpm; Currency unspecifiedCurrency = (zeroForOne == exactInput) ? key.currency1 : key.currency0; feeCurrency = Currency.unwrap(unspecifiedCurrency); totalFee = unspecifiedAmount * feePpm / FEE_DENOMINATOR; if (feePpm != 0 && totalFee == 0) revert FeeRoundsToZero(unspecifiedAmount, feePpm); uint256 splitNumerator = totalFee * config.creatorShareBps + creatorRemainder[poolId][feeCurrency]; creatorAmount = splitNumerator / SHARE_DENOMINATOR; treasuryAmount = totalFee - creatorAmount; policyVersion = config.version; } /// @notice Claims one PoolManager-safe chunk for a recipient. /// @dev PoolManager converts burn/take amounts to int128. If accrued revenue exceeds that /// bound, repeated calls deterministically redeem the balance without making it unclaimable. function claimFor(PoolId poolId, address currencyAddress, address recipient) public nonReentrant returns (uint256 amount) { uint256 outstanding = claimable[poolId][currencyAddress][recipient]; if (outstanding == 0) revert NothingToClaim(); amount = outstanding > MAX_CLAIM_PER_UNLOCK ? MAX_CLAIM_PER_UNLOCK : outstanding; claimable[poolId][currencyAddress][recipient] = outstanding - amount; totalClaimed[poolId][currencyAddress] += amount; bytes memory unlockData = abi.encode(currencyAddress, recipient, amount); _claimUnlockActive = true; _claimUnlockHash = keccak256(unlockData); poolManager.unlock(unlockData); if (_claimUnlockActive) revert ClaimUnlockNotCompleted(); emit FeesClaimed(poolId, currencyAddress, recipient, amount); } function claim(PoolId poolId, address currencyAddress) external returns (uint256 amount) { amount = claimFor(poolId, currencyAddress, msg.sender); } /// @notice Ownership must remain recoverable so a paused-fee policy can be resumed. function renounceOwnership() public pure override { revert OwnershipRenunciationDisabled(); } /// @inheritdoc IUnlockCallback function unlockCallback(bytes calldata data) external override onlyPoolManager returns (bytes memory) { if (!_claimUnlockActive) revert NoActiveClaimUnlock(); if (keccak256(data) != _claimUnlockHash) revert UnexpectedClaimUnlockData(); _claimUnlockActive = false; delete _claimUnlockHash; (address currencyAddress, address recipient, uint256 amount) = abi.decode(data, (address, address, uint256)); Currency currency = Currency.wrap(currencyAddress); uint256 balanceBefore; if (currencyAddress != address(0)) { balanceBefore = IERC20(currencyAddress).balanceOf(recipient); } poolManager.burn(address(this), currency.toId(), amount); poolManager.take(currency, recipient, amount); // PoolManager.take already reverts when its native transfer fails. Do not require the // recipient to retain native currency: a Safe module or treasury may atomically forward or // wrap it in receive(). ERC-20 claims keep the strict net-receipt check so taxed or otherwise // non-standard transfers cannot silently underpay the fixed recipient. if (currencyAddress != address(0)) { uint256 balanceAfter = IERC20(currencyAddress).balanceOf(recipient); uint256 received = balanceAfter >= balanceBefore ? balanceAfter - balanceBefore : 0; if (received != amount) { revert RecipientAmountMismatch(currencyAddress, recipient, amount, received); } } return bytes(""); } function _beforeInitialize(address sender, PoolKey calldata key, uint160 sqrtPriceX96) internal view override returns (bytes4) { _validateKey(key); PoolId poolId = key.toId(); if (!_poolConfigs[poolId].configured) revert PoolNotConfigured(poolId); if (sender != address(this)) revert UnauthorizedInitializer(sender); uint160 expected = _pendingInitialPrice[poolId]; if (expected == 0 || sqrtPriceX96 != expected) { revert UnexpectedInitialPrice(sqrtPriceX96, expected); } return IHooks.beforeInitialize.selector; } function _beforeSwap(address, PoolKey calldata key, SwapParams calldata, bytes calldata) internal view override returns (bytes4, BeforeSwapDelta, uint24) { _validateKey(key); PoolId poolId = key.toId(); if (!_poolConfigs[poolId].configured) revert PoolNotConfigured(poolId); // Dynamic PoolKey, but effective Uniswap LP fee is exactly zero for this swap. return ( IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, LPFeeLibrary.OVERRIDE_FEE_FLAG ); } function _afterSwap( address sender, PoolKey calldata key, SwapParams calldata params, BalanceDelta delta, bytes calldata hookData ) internal override nonReentrant returns (bytes4, int128) { PoolId poolId = key.toId(); PoolConfig memory config = _poolConfigs[poolId]; if (!config.configured) revert PoolNotConfigured(poolId); AdaptiveFeePolicy.State memory projected = AdaptiveFeePolicy.project( config.policy, _feeStates[poolId], block.timestamp ); if (projected.windowStart != _feeStates[poolId].windowStart) { emit AdaptiveFeeWindow(poolId, projected.windowStart, projected.feePpm, projected.lastWindowVolumeQuoteRaw); } _feeStates[poolId] = projected; bool exactInput = params.amountSpecified < 0; Currency inputCurrency = params.zeroForOne ? key.currency0 : key.currency1; bool isBuy = Currency.unwrap(inputCurrency) == config.baseCurrency; uint24 feePpm = config.feesPaused ? 0 : _feeStates[poolId].feePpm; _checkSwapGuard(hookData, feePpm, config.version); if (config.policy.dynamicMode && !config.feesPaused) { int128 quoteDelta = config.baseCurrency == Currency.unwrap(key.currency0) ? delta.amount0() : delta.amount1(); uint256 quoteAmount = quoteDelta < 0 ? uint256(-int256(quoteDelta)) : uint256(int256(quoteDelta)); AdaptiveFeePolicy.State memory state = AdaptiveFeePolicy.observe(config.policy, _feeStates[poolId], quoteAmount); _feeStates[poolId] = state; emit QuoteVolumeObserved(poolId, state.windowStart, quoteAmount, state.volumeQuoteRaw); } // afterSwap return delta always applies to the unspecified currency: // exact-input => output currency; exact-output => input currency. bool specifiedCurrencyIs0 = params.zeroForOne == exactInput; (Currency feeCurrency, int128 unspecifiedDelta) = specifiedCurrencyIs0 ? (key.currency1, delta.amount1()) : (key.currency0, delta.amount0()); uint256 unspecifiedAmount = unspecifiedDelta < 0 ? uint256(-int256(unspecifiedDelta)) : uint256(int256(unspecifiedDelta)); uint256 totalFee = unspecifiedAmount * feePpm / FEE_DENOMINATOR; if (totalFee == 0) { if (feePpm != 0) revert FeeRoundsToZero(unspecifiedAmount, feePpm); return (IHooks.afterSwap.selector, 0); } if (!exactInput && unspecifiedAmount > uint256(uint128(type(int128).max)) - totalFee) { revert AdjustedInputTooLarge(unspecifiedAmount, totalFee); } address feeCurrencyAddress = Currency.unwrap(feeCurrency); uint256 splitNumerator = totalFee * config.creatorShareBps + creatorRemainder[poolId][feeCurrencyAddress]; uint256 creatorAmount = splitNumerator / SHARE_DENOMINATOR; creatorRemainder[poolId][feeCurrencyAddress] = splitNumerator % SHARE_DENOMINATOR; uint256 treasuryAmount = totalFee - creatorAmount; // Convert the Hook's positive settlement delta into a PoolManager ERC-6909 claim. // This avoids transferring an input currency before the swap caller has settled it. poolManager.mint(address(this), feeCurrency.toId(), totalFee); claimable[poolId][feeCurrencyAddress][config.treasury] += treasuryAmount; if (creatorAmount != 0) { claimable[poolId][feeCurrencyAddress][config.creator] += creatorAmount; } totalAccrued[poolId][feeCurrencyAddress] += totalFee; emit HookFeeCharged( poolId, sender, feeCurrencyAddress, feePpm, unspecifiedAmount, totalFee, treasuryAmount, creatorAmount, isBuy, exactInput, config.version ); return (IHooks.afterSwap.selector, totalFee.toInt128()); } function _checkSwapGuard(bytes calldata hookData, uint24 appliedFeePpm, uint64 version) private view { if (hookData.length == 0) return; if (hookData.length != 96) revert InvalidHookDataLength(hookData.length); SwapGuard memory guard = abi.decode(hookData, (SwapGuard)); if (block.timestamp > guard.deadline) revert SwapGuardExpired(guard.deadline); if (guard.expectedVersion != version) revert PolicyVersionChanged(guard.expectedVersion, version); if (appliedFeePpm > guard.maxFeePpm) { revert FeeExceedsUserMaximum(appliedFeePpm, guard.maxFeePpm); } } function _validateKey(PoolKey calldata key) private view { if (address(key.hooks) != address(this)) revert HookAddressMismatch(address(key.hooks)); if (key.fee != LPFeeLibrary.DYNAMIC_FEE_FLAG) revert DynamicLPFeeRequired(key.fee); } function _validateRecipient(address recipient) private view { if ( recipient == address(0) || recipient == address(this) || recipient == address(poolManager) ) { revert InvalidRecipient(recipient); } } function _requireConfigured(PoolId poolId) private view { if (!_poolConfigs[poolId].configured) revert PoolNotConfigured(poolId); } }