// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /* ============================================================================ ERC-EVOLVE: Hardened Reference Implementation ---------------------------------------------------------------------------- Three contracts in one file: 1. IERC20Evolve - the extended interface 2. ERC20Evolve - the token (ERC-20 + soulbound XP/level + loyalty yield) 3. EvolveHook - the Uniswap v4 hook (per-trader fees + fee routing) Hardening applied vs. the initial reference: - ReentrancyGuard on every state-changing external entry point - SafeERC20 for all loyalty-token transfers - Ownable2Step for one-time hook binding + emergency controls - Pausable for circuit breaking of claims/deposits - Currency.unwrap() instead of unsafe address casts - Tight access control on applyTradeEffect / depositLoyalty - Input validation on all external entries - NatSpec on every public/external function Still elided (would be added at production-deploy time): - HookMiner address mining for v4 permission bits - DAO-controlled parameter governance - Full v4 fee-take plumbing (beforeSwapReturnDelta accounting) ========================================================================== */ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; // v4 imports import {BaseHook} from "v4-periphery/utils/BaseHook.sol"; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {Hooks} from "v4-core/libraries/Hooks.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/types/BeforeSwapDelta.sol"; import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; import {Currency} from "v4-core/types/Currency.sol"; /* ============================================================================ 1. INTERFACE ========================================================================== */ interface IERC20Evolve is IERC20 { /// Returns settled XP for `account` (includes the unsettled fraction since lastTouch). function xpOf(address account) external view returns (uint256); /// Returns the current level for `account`. function levelOf(address account) external view returns (uint16); /// Returns the last balance-changing timestamp for `account`. function lastTouchOf(address account) external view returns (uint64); /// Returns the highest balance `account` has ever held (for diamond-hand bonus calcs). function peakBalanceOf(address account) external view returns (uint256); /// Returns the fee in basis points (1bp = 0.01%) that this account pays right now. function feeBpsOf(address account) external view returns (uint24); /// Loyalty: returns the steep tier multiplier in 0.01x units (100 = 1x, 1000 = 10x). function tierMultiplier(uint16 level) external pure returns (uint256); /// Loyalty: returns the wallet's current loyalty share weight. function loyaltyWeightOf(address account) external view returns (uint256); /// Loyalty: returns the unclaimed yield (in LOYALTY_TOKEN units) for `account`. function pendingLoyalty(address account) external view returns (uint256); /// Loyalty: harvest pending yield to msg.sender. Returns amount transferred. function claimLoyalty() external returns (uint256); /// Called by the canonical hook after a swap to apply XP + sell-penalty in one call. /// MUST revert if msg.sender is not the registered hook. function applyTradeEffect(address trader, int256 balanceDelta) external; /// Called by the canonical hook to route a slice of swap fees into the treasury. function depositLoyalty(uint256 amount) external; event LevelChanged(address indexed account, uint16 oldLevel, uint16 newLevel); event XpUpdated(address indexed account, uint256 newXp); event LoyaltyDeposited(uint256 amount, uint256 totalWeight); event LoyaltyClaimed(address indexed account, uint256 amount); } /* ============================================================================ 2. TOKEN ========================================================================== */ contract ERC20Evolve is ERC20, IERC20Evolve, ReentrancyGuard, Pausable, Ownable2Step { using SafeERC20 for IERC20; // ---- Tunable parameters (immutable at deployment) ---- uint256 public immutable BASE_RATE; // XP per (token * second) uint256 public immutable PENALTY_BPS; // sell penalty applied to XP, in bps of XP uint256 public immutable XP_PER_LEVEL_UNIT; // divisor inside the sqrt level curve uint24 public immutable MAX_FEE_BPS; // fee for a brand-new wallet (level 0) uint24 public immutable MIN_FEE_BPS; // fee at the maximum level uint16 public immutable MAX_LEVEL; // cap on the level curve /// @notice Canonical v4 hook; settable exactly once by the owner via {bindHook}. address public hook; // ---- Per-wallet state ---- struct Soul { uint256 xp; uint64 lastTouch; uint256 peakBalance; uint16 cachedLevel; } mapping(address => Soul) private _souls; // ---- Loyalty Yield state ---- /// @notice Asset held in the loyalty treasury (e.g. WETH). Immutable. address public immutable LOYALTY_TOKEN; uint256 public constant LOYALTY_SCALE = 1e18; uint256 public accLoyaltyPerWeight; // global accumulator, scaled by LOYALTY_SCALE uint256 public totalLoyaltyWeight; // sum of all wallets' weights mapping(address => uint256) private _userWeight; mapping(address => uint256) private _rewardDebt; mapping(address => uint256) private _pendingHarvest; // ---- Errors ---- error OnlyHook(); error HookAlreadySet(); error ZeroAddress(); error ZeroAmount(); error NotConfigured(); /// @notice Deploy the token, mint the full supply to `treasury_`, and store loyalty token. /// @param name_ ERC-20 name /// @param symbol_ ERC-20 symbol /// @param totalSupply_ full supply minted to `treasury_` at deployment /// @param treasury_ recipient of the initial mint /// @param loyaltyToken_ asset held in the loyalty treasury (e.g. WETH); MUST NOT be address(0) /// @param owner_ initial owner of this contract (for bindHook + pause control) constructor( string memory name_, string memory symbol_, uint256 totalSupply_, address treasury_, address loyaltyToken_, address owner_ ) ERC20(name_, symbol_) Ownable(owner_) { if (treasury_ == address(0) || loyaltyToken_ == address(0) || owner_ == address(0)) revert ZeroAddress(); // ---- Canonical EVO calibration ---- // Supply: 1,000,000,000 EVO (1e27 raw at 18 decimals) // With these constants and a 10,000 EVO bag (0.001% of supply): // Lv 5 (Hatchling) ≈ 30 days // Lv 10 (Adolescent) ≈ 4 months // Lv 20 (Adult) ≈ 1.3 years // Lv 50 (Mythic) ≈ 8 years // Whales (0.1%) reach Mythic in ~30 days. Retail takes years. Intentional. BASE_RATE = 1e9; XP_PER_LEVEL_UNIT = 1e18; PENALTY_BPS = 5000; // 50% XP burn on full exit MAX_FEE_BPS = 100; // 1.00% — what a Lv 0 wallet pays MIN_FEE_BPS = 5; // 0.05% — what a Lv 50 wallet pays MAX_LEVEL = 50; LOYALTY_TOKEN = loyaltyToken_; _mint(treasury_, totalSupply_); } /// @notice One-time, owner-only registration of the canonical hook. /// @dev After this call, `hook` is frozen for the life of the contract. /// The owner SHOULD verify the hook's permission bits are correctly /// mined into its address before calling. /// @param hook_ deployment address of the EvolveHook function bindHook(address hook_) external onlyOwner { if (hook != address(0)) revert HookAlreadySet(); if (hook_ == address(0)) revert ZeroAddress(); hook = hook_; } /// @notice Pause loyalty claims and deposits (emergency). /// @dev Transfers/XP accrual are NOT paused — only loyalty flows. function pauseLoyalty() external onlyOwner { _pause(); } function unpauseLoyalty() external onlyOwner { _unpause(); } /* ---------- IERC20Evolve view functions ---------- */ function xpOf(address account) public view returns (uint256) { Soul memory s = _souls[account]; uint256 dt = block.timestamp - s.lastTouch; return s.xp + (balanceOf(account) * dt * BASE_RATE) / 1e18; } function levelOf(address account) public view returns (uint16) { return _levelFromXp(xpOf(account)); } function lastTouchOf(address account) external view returns (uint64) { return _souls[account].lastTouch; } function peakBalanceOf(address account) external view returns (uint256) { return _souls[account].peakBalance; } function feeBpsOf(address account) public view returns (uint24) { uint16 lvl = levelOf(account); if (lvl >= MAX_LEVEL) return MIN_FEE_BPS; uint24 range = MAX_FEE_BPS - MIN_FEE_BPS; return uint24(MAX_FEE_BPS - (uint256(range) * lvl) / MAX_LEVEL); } /* ---------- Hook callback ---------- */ /// @notice Hook-only entry point invoked after every v4 swap. /// @dev XP was already settled in {_update} by the time this fires; /// this call only applies the sell-penalty when balance decreased. /// @param trader the wallet whose state should be updated /// @param balanceDelta signed change in trader's balance (negative = sold tokens) function applyTradeEffect(address trader, int256 balanceDelta) external override nonReentrant { if (msg.sender != hook) revert OnlyHook(); if (trader == address(0)) revert ZeroAddress(); Soul storage s = _souls[trader]; // Belt-and-suspenders settle: _update already settled this account on the // balance-changing path, so dt is 0 here and this is a no-op in practice. // Kept for safety against any future hook flow that calls us out-of-band. _settle(s, balanceOf(trader)); // If the trade reduced the trader's balance, apply the soft sell-penalty. if (balanceDelta < 0 && s.xp > 0) { uint256 sold = uint256(-balanceDelta); uint256 oldBalance = balanceOf(trader) + sold; if (oldBalance > 0) { uint256 burned = (s.xp * sold * PENALTY_BPS) / oldBalance / 10000; if (burned > s.xp) burned = s.xp; s.xp -= burned; } } _maybeRecordLevelChange(trader, s); // Loyalty weight may have changed due to XP burn → re-sync. _syncLoyalty(trader); emit XpUpdated(trader, s.xp); } /* ---------- Hook into ERC-20 transfer plumbing ---------- */ function _update(address from, address to, uint256 value) internal override { // Settle both sides BEFORE balances move so XP is computed on the old balances. if (from != address(0)) _settle(_souls[from], balanceOf(from)); if (to != address(0)) _settle(_souls[to], balanceOf(to)); super._update(from, to, value); // Update peak balance tracking for the recipient. if (to != address(0)) { Soul storage st = _souls[to]; uint256 bal = balanceOf(to); if (bal > st.peakBalance) st.peakBalance = bal; _maybeRecordLevelChange(to, st); } if (from != address(0)) { _maybeRecordLevelChange(from, _souls[from]); } // Sync loyalty share weights AFTER balance + level changes are reflected. if (from != address(0)) _syncLoyalty(from); if (to != address(0)) _syncLoyalty(to); } /* ---------- Loyalty Yield ---------- */ /// Steep tier curve: 100 = 1x (baseline), 1000 = 10x (mythic). /// Levels below Hatchling (Lv 5) earn no yield. function tierMultiplier(uint16 level) public pure override returns (uint256) { if (level >= 50) return 1000; // Mythic — 10x if (level >= 20) return 500; // Adult — 5x if (level >= 10) return 250; // Adolescent — 2.5x if (level >= 5) return 100; // Hatchling — 1x baseline return 0; // Egg — no yield } function loyaltyWeightOf(address account) public view override returns (uint256) { return balanceOf(account) * tierMultiplier(levelOf(account)); } /// @notice Total unclaimed loyalty (in LOYALTY_TOKEN units) accrued by `account`. function pendingLoyalty(address account) external view override returns (uint256) { uint256 w = _userWeight[account]; // Underflow-guard the subtraction: rewardDebt is always ≤ w * accLoyaltyPerWeight. uint256 latest = (w * accLoyaltyPerWeight) / LOYALTY_SCALE; uint256 debt = _rewardDebt[account]; uint256 earned = latest > debt ? latest - debt : 0; return _pendingHarvest[account] + earned; } /// @notice Harvest the caller's accumulated loyalty yield. Pausable + reentrancy-guarded. /// @return amount the number of LOYALTY_TOKEN units transferred function claimLoyalty() external override nonReentrant whenNotPaused returns (uint256 amount) { if (hook == address(0)) revert NotConfigured(); _syncLoyalty(msg.sender); amount = _pendingHarvest[msg.sender]; if (amount == 0) return 0; _pendingHarvest[msg.sender] = 0; IERC20(LOYALTY_TOKEN).safeTransfer(msg.sender, amount); emit LoyaltyClaimed(msg.sender, amount); } /// @notice Hook-only: account for a slice of swap fees newly deposited to the treasury. /// @dev The hook MUST have already moved `amount` of LOYALTY_TOKEN into this contract /// BEFORE calling this function. The accumulator pattern handles the rest. /// @param amount the number of LOYALTY_TOKEN units that just arrived function depositLoyalty(uint256 amount) external override nonReentrant whenNotPaused { if (msg.sender != hook) revert OnlyHook(); if (amount == 0) revert ZeroAmount(); // If no eligible holders exist yet, the deposit sits in the contract balance and // will be socialized to the next account that crosses into Hatchling (Lv 5). This // is intentional — we never lose the funds, but distribution requires ≥1 holder. if (totalLoyaltyWeight > 0) { accLoyaltyPerWeight += (amount * LOYALTY_SCALE) / totalLoyaltyWeight; } emit LoyaltyDeposited(amount, totalLoyaltyWeight); } /// @dev Crystallizes any earned rewards at the OLD weight into _pendingHarvest, /// then updates weight + rewardDebt to track from the new weight forward. /// Called on every balance change for `account`, plus on every claim. function _syncLoyalty(address account) internal { uint256 oldW = _userWeight[account]; if (oldW > 0) { uint256 latest = (oldW * accLoyaltyPerWeight) / LOYALTY_SCALE; uint256 debt = _rewardDebt[account]; if (latest > debt) { _pendingHarvest[account] += latest - debt; } } uint256 newW = loyaltyWeightOf(account); if (newW != oldW) { totalLoyaltyWeight = totalLoyaltyWeight - oldW + newW; _userWeight[account] = newW; } _rewardDebt[account] = (newW * accLoyaltyPerWeight) / LOYALTY_SCALE; } /* ---------- Internals ---------- */ function _settle(Soul storage s, uint256 balanceBefore) internal { uint256 dt = block.timestamp - s.lastTouch; if (dt > 0 && balanceBefore > 0) { s.xp += (balanceBefore * dt * BASE_RATE) / 1e18; } s.lastTouch = uint64(block.timestamp); } function _levelFromXp(uint256 xp) internal view returns (uint16) { uint256 lvl = _sqrt(xp / XP_PER_LEVEL_UNIT); if (lvl > MAX_LEVEL) lvl = MAX_LEVEL; return uint16(lvl); } function _maybeRecordLevelChange(address account, Soul storage s) internal { uint16 newLvl = _levelFromXp(s.xp); if (newLvl != s.cachedLevel) { emit LevelChanged(account, s.cachedLevel, newLvl); s.cachedLevel = newLvl; } } /// Babylonian sqrt — cheap enough for view-time use. function _sqrt(uint256 x) internal pure returns (uint256 y) { if (x == 0) return 0; uint256 z = (x + 1) / 2; y = x; while (z < y) { y = z; z = (x / z + z) / 2; } } } /* ============================================================================ 3. UNISWAP V4 HOOK ========================================================================== */ contract EvolveHook is BaseHook, ReentrancyGuard { using SafeERC20 for IERC20; IERC20Evolve public immutable token; /// @notice Basis-points of every trade routed to the Loyalty Treasury. 30 = 0.30%. uint256 public constant LOYALTY_BPS = 30; error ZeroAddress(); error TokenNotInPool(); /// @notice The hook trusts tx.origin as the trader identity. Aggregator-routed trades /// are charged the EOA's fee tier (not the router's) which is the desired /// behavior. Smart-contract-only trades (vault rebalances) get the EOA-of- /// caller's tier — see the docs §Routers for the trade-offs. constructor(IPoolManager _manager, IERC20Evolve _token) BaseHook(_manager) { if (address(_token) == address(0)) revert ZeroAddress(); token = _token; } function getHookPermissions() public pure override returns (Hooks.Permissions memory) { return Hooks.Permissions({ beforeInitialize: false, afterInitialize: false, beforeAddLiquidity: true, afterAddLiquidity: false, beforeRemoveLiquidity: false, afterRemoveLiquidity: false, beforeSwap: true, afterSwap: true, beforeDonate: false, afterDonate: false, beforeSwapReturnDelta: false, afterSwapReturnDelta: false, afterAddLiquidityReturnDelta: false, afterRemoveLiquidityReturnDelta:false }); } /* ---------- beforeSwap: set the trader's personal fee tier ---------- */ function _beforeSwap( address /*sender*/, PoolKey calldata /*key*/, IPoolManager.SwapParams calldata /*params*/, bytes calldata /*hookData*/ ) internal override returns (bytes4, BeforeSwapDelta, uint24) { address trader = tx.origin; uint24 fee = token.feeBpsOf(trader); // v4 wants the fee with the override flag OR'd in. uint24 dynamicFee = fee | LPFeeLibrary.OVERRIDE_FEE_FLAG; return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, dynamicFee); } /* ---------- afterSwap: update XP + apply sell-penalty ---------- */ function _afterSwap( address /*sender*/, PoolKey calldata key, IPoolManager.SwapParams calldata /*params*/, BalanceDelta delta, bytes calldata /*hookData*/ ) internal override nonReentrant returns (bytes4, int128) { address trader = tx.origin; address tokenAddr = address(token); // Identify which currency in the pool IS our token (safely via Currency.unwrap). bool tokenIsCurrency0 = (tokenAddr == Currency.unwrap(key.currency0)); bool tokenIsCurrency1 = (tokenAddr == Currency.unwrap(key.currency1)); if (!tokenIsCurrency0 && !tokenIsCurrency1) revert TokenNotInPool(); // Trader's token-side balance change. Negative = sold EVOLVE; positive = bought. int256 tokenDelta = tokenIsCurrency0 ? int256(delta.amount0()) : int256(delta.amount1()); token.applyTradeEffect(trader, tokenDelta); // ---- Loyalty Yield routing ---- // Take LOYALTY_BPS of the pair-side amount and deposit to the loyalty treasury. // NOTE: In production v4, the hook MUST first claim the take from PoolManager // (via beforeSwapReturnDelta + take/settle accounting). This reference assumes // the hook already holds enough LOYALTY_TOKEN to cover the deposit. address loyaltyAsset = token.LOYALTY_TOKEN(); int256 pairDelta = tokenIsCurrency0 ? int256(delta.amount1()) : int256(delta.amount0()); uint256 pairAbs = pairDelta < 0 ? uint256(-pairDelta) : uint256(pairDelta); uint256 take = (pairAbs * LOYALTY_BPS) / 10000; if (take > 0 && loyaltyAsset != address(0)) { IERC20(loyaltyAsset).safeTransfer(tokenAddr, take); token.depositLoyalty(take); } return (BaseHook.afterSwap.selector, 0); } /* ---------- beforeAddLiquidity: settle XP for LP-side activity ---------- */ function _beforeAddLiquidity( address /*sender*/, PoolKey calldata /*key*/, IPoolManager.ModifyLiquidityParams calldata /*params*/, bytes calldata /*hookData*/ ) internal override nonReentrant returns (bytes4) { // LPs accrue XP too. Settle them through the standard hook callback. token.applyTradeEffect(tx.origin, 0); return BaseHook.beforeAddLiquidity.selector; } }