ERC-EVOLVE Documentation
A living token standard for the post-v4 era. ERC-EVOLVE extends ERC-20 with per-wallet experience and levels, then uses a Uniswap v4 hook to assign each trader a personalized fee tier on every swap. Long-term holders trade cheaper than short-term flippers — automatically, on-chain, with no trusted intermediary.
Overview
ERC-EVOLVE is composed of two contracts that work together:
ERC20Evolve— a backward-compatible ERC-20 token that tracks per-wallet XP, level, and peak balance. Every transfer settles the sender's and receiver's XP based on time held.EvolveHook— a Uniswap v4 hook deployed alongside the canonical pool. It reads the trader's level onbeforeSwap, overrides the pool's swap fee with the trader's personal fee, and updates XP onafterSwap.
To a wallet, block explorer, or DEX aggregator that doesn't know about ERC-EVOLVE, the token behaves exactly like a normal ERC-20. The extended state is exposed through additional view functions and lives entirely on-chain.
This is the v0.1 release of the standard. The reference implementation has not yet been audited and should be reviewed before any production deployment.
Why this exists
The ERC-20 standard treats every holder identically. A bot that buys in block 0 and a believer who held since the seed round look the same to the protocol. ERC-EVOLVE introduces a measurable, on-chain notion of conviction: how much you've held, for how long, with what discipline. That conviction translates directly into economic preferential treatment on the canonical trading venue.
Quick start
The fastest path to a working ERC-EVOLVE deployment:
1. Install
# Add the reference contracts to your Foundry project
forge install OpenZeppelin/openzeppelin-contracts
forge install Uniswap/v4-core
forge install Uniswap/v4-periphery
2. Deploy the token
import {ERC20Evolve} from "./ERC20Evolve.sol"; ERC20Evolve token = new ERC20Evolve( "Evolve Token", "EVO", 1_000_000_000e18, // total supply treasury // mint recipient );
3. Deploy the hook (with address mining)
// v4 encodes hook permissions in the deployment address's low bits. // Mine an address that matches beforeSwap | afterSwap | beforeAddLiquidity. uint160 flags = uint160( Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG ); (address hookAddr, bytes32 salt) = HookMiner.find( address(this), flags, type(EvolveHook).creationCode, abi.encode(poolManager, token) ); EvolveHook hook = new EvolveHook{salt: salt}(poolManager, token); require(address(hook) == hookAddr); token.bindHook(address(hook));
4. Initialize the pool
PoolKey memory key = PoolKey({ currency0: Currency.wrap(address(0)), // ETH currency1: Currency.wrap(address(token)), fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, // hook sets fee per swap tickSpacing: 60, hooks: IHooks(address(hook)) }); poolManager.initialize(key, startSqrtPriceX96);
Setting fee: LPFeeLibrary.DYNAMIC_FEE_FLAG is what gives the hook permission to override the fee per-swap. Without it, the hook's fee override will revert.
Installation
ERC-EVOLVE has two runtime dependencies and works with Foundry, Hardhat, and any tool that supports the Solidity standard library.
| Package | Version | Purpose |
|---|---|---|
openzeppelin-contracts | ≥5.0.0 | ERC20 base, ownership, math |
v4-core | main | PoolManager, hook types |
v4-periphery | main | BaseHook helper |
Experience points
Every wallet accrues XP whenever it holds a positive token balance. XP is calculated as a time integral of balance:
ΔXP = balance × Δt × BASE_RATE // where: // balance - the wallet's balance immediately before settlement // Δt - seconds since the wallet's last balance change // BASE_RATE - protocol constant (xp per token-second)
XP is settled lazily: the contract only writes new XP to storage when a wallet's balance changes (transfer in, transfer out, or swap). Between settlements, the unsettled XP is computed on-demand by xpOf(account).
Settlement rule
On any balance-changing operation:
- Read
balanceBefore,lastTouch, and existingxp. - Add
balanceBefore × (block.timestamp - lastTouch) × BASE_RATEtoxp. - Update
lastTouch = block.timestamp. - Apply the balance delta.
- If balance decreased, apply the sell penalty.
Levels & tiers
Level is a pure function of XP — a square-root curve that gives quick early progression and slower late progression:
function _levelFromXp(uint256 xp) internal view returns (uint16) { uint256 lvl = _sqrt(xp / XP_PER_LEVEL_UNIT); return lvl > MAX_LEVEL ? MAX_LEVEL : uint16(lvl); }
The reference tiers — used for narrative purposes in front-ends and metadata — map levels to symbolic stages:
| Level | Tier | Default rebate | Notes |
|---|---|---|---|
| 0–4 | Egg | 0% | New holder; default fee |
| 5–9 | Hatchling | 10% | ~30 days of holding a typical bag |
| 10–19 | Adolescent | 25% | Unlocks evolved metadata art |
| 20–49 | Adult | 50% | 2× governance voting weight |
| 50 | Mythic | 95% | Protocol revenue share eligible |
Dynamic fees
The hook overrides the pool's fee on every swap. The fee is a linear function of the trader's level:
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); }
With the default parameters (MAX_FEE_BPS = 100, MIN_FEE_BPS = 5, MAX_LEVEL = 50), fees range from 1.00% for a fresh wallet to 0.05% at the cap.
The pool must be initialized with the DYNAMIC_FEE_FLAG set in PoolKey.fee. A pool initialized with a static fee will revert when the hook tries to override.
Soulbound state
XP and level are tied to the receiving wallet, not to the tokens themselves. When tokens move from A to B:
- A's XP is settled on A's pre-transfer balance, then the sell penalty is applied (if applicable).
- B's XP is settled on B's pre-transfer balance.
- The tokens move. No XP travels with them.
This makes level-laundering impossible: you cannot buy a "Level 50 bag" from another wallet. The XP belongs to whichever wallet held the tokens through time.
Sell penalty
When a wallet's balance decreases, a fraction of its XP burns. The burn is proportional to the fraction of the bag sold:
XP_burn = currentXP × (Δb / b_old) × (PENALTY_BPS / 10000) // PENALTY_BPS default: 5000 (= 50%) // Selling 100% of the bag burns 50% of accumulated XP. // Selling 50% burns 25%. And so on, linearly.
The intent is to make selling costly without being punitive. A holder who has built up significant reputation should not lose everything in a single forced exit.
Loyalty Yield
A fee discount is a cost reducer — not a reason to hold. ERC-EVOLVE therefore routes a slice of every swap into a Loyalty Treasury that streams continuously to long-term holders.
Fee routing
On every swap through the canonical v4 pool, the hook routes LOYALTY_BPS of the trade size (default: 30 bps = 0.30%) into the treasury. The rest of the fee is paid normally to LPs.
loyaltyTake = swapSize × LOYALTY_BPS / 10000 // At 30 bps and $10M daily volume: ~$30k/day → ~$11M/year // All of which is paid out to high-XP holders.
Share weight
Each wallet's claim on the treasury is proportional to its balance times a steep tier multiplier. Mythic holders weigh 10× more per token than baseline:
| Level | Tier | Multiplier | What it means |
|---|---|---|---|
| 0–4 | Egg | 0 | No yield. New holders earn XP first. |
| 5–9 | Hatchling | 100 (1×) | Baseline yield share unlocks |
| 10–19 | Adolescent | 250 (2.5×) | Yield grows meaningfully |
| 20–49 | Adult | 500 (5×) | Material passive income |
| 50 | Mythic | 1000 (10×) | Top of the curve — significantly different |
Accumulator pattern
The treasury uses the standard MasterChef-style accumulator. Each deposit advances a global rate per unit weight; each wallet checkpoints its share whenever its weight changes:
// On deposit (called by the hook on every swap): accLoyaltyPerWeight += deposit / totalLoyaltyWeight // User's unclaimed yield: pending = (userWeight × accLoyaltyPerWeight) − rewardDebt + pendingHarvest // crystallized at old weight // On user balance or level change: pendingHarvest += oldWeight × accLoyaltyPerWeight − rewardDebt rewardDebt = newWeight × accLoyaltyPerWeight
Claiming
Wallets call claimLoyalty() at any time. The treasury holds the asset paid in (typically WETH on the chain); the function transfers the wallet's pending balance and resets the harvest.
uint256 claimed = evo.claimLoyalty(); // transfers `claimed` units of LOYALTY_TOKEN to msg.sender
At $10M daily volume with the default 30-bp routing and ~10k qualifying wallets, baseline (Lv 5) wallets earn roughly $500/year per equivalent bag, while Lv 50 wallets earn roughly $5,000/year. Yield scales with protocol usage; there is no inflation funding it — snipers and paperhand traders are paying it directly.
Tier perks (beyond yield)
Each tier also unlocks discrete utility, in addition to lower fees and a bigger yield share:
| Tier | Fee rebate | Yield multiplier | Other perks |
|---|---|---|---|
| Egg (Lv 0–4) | 0% | 0× | — |
| Hatchling (Lv 5–9) | 10% | 1× | Soulbound badge, evolved metadata stage 1 |
| Adolescent (Lv 10–19) | 25% | 2.5× | Launchpad whitelist (partner allocations) |
| Adult (Lv 20–49) | 50% | 5× | 2× governance voting weight, NFT badge mint |
| Mythic (Lv 50) | 95% | 10× | Treasury proposal rights, exclusive pool access |
The fee rebate is enforced by the hook on every swap. The yield multiplier is enforced by the loyalty accumulator on every claim. The discrete perks are enforced by integrating contracts that read levelOf(account) — including the launchpad, governance module, and gated pools.
IERC20Evolve interface
The extension layer adds five view functions and a hook callback on top of standard ERC-20.
interface IERC20Evolve is IERC20 { // XP / level function xpOf(address account) external view returns (uint256); function levelOf(address account) external view returns (uint16); function lastTouchOf(address account) external view returns (uint64); function peakBalanceOf(address account) external view returns (uint256); function feeBpsOf(address account) external view returns (uint24); // Loyalty Yield function tierMultiplier(uint16 level) external pure returns (uint256); function loyaltyWeightOf(address account) external view returns (uint256); function pendingLoyalty(address account) external view returns (uint256); function claimLoyalty() external returns (uint256); // Hook callbacks function applyTradeEffect(address trader, int256 balanceDelta) external; 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); }
Function reference
| Function | Returns | Description |
|---|---|---|
xpOf | uint256 | Settled + unsettled XP for the account, in XP units |
levelOf | uint16 | Current level (0 to MAX_LEVEL), derived from xpOf |
lastTouchOf | uint64 | Unix timestamp of last balance-changing op |
peakBalanceOf | uint256 | Highest balance ever held by this wallet |
feeBpsOf | uint24 | Effective fee in basis points the account would pay now |
applyTradeEffect | — | Hook-only callback to record post-swap XP and penalty |
ERC20Evolve contract
The reference token implementation extends OpenZeppelin's ERC20 and overrides _update to settle XP on every transfer:
function _update(address from, address to, uint256 value) internal override { // Settle BEFORE balances change so XP is computed on 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); // Track peak balance + emit level changes. if (to != address(0)) { Soul storage s = _souls[to]; if (balanceOf(to) > s.peakBalance) s.peakBalance = balanceOf(to); _maybeRecordLevelChange(to, s); } }
Storage layout
| Field | Type | Purpose |
|---|---|---|
_souls[a].xp | uint256 | Settled XP for account a |
_souls[a].lastTouch | uint64 | Last settlement timestamp |
_souls[a].peakBalance | uint256 | All-time high balance |
_souls[a].cachedLevel | uint16 | Last emitted level (for LevelChanged) |
EvolveHook contract
The hook extends BaseHook from v4-periphery and declares three permissions:
function getHookPermissions() public pure override returns (Hooks.Permissions memory) { return Hooks.Permissions({ beforeSwap: true, afterSwap: true, beforeAddLiquidity: true, // all others: false beforeInitialize: false, afterInitialize: false, afterAddLiquidity: false, beforeRemoveLiquidity: false, afterRemoveLiquidity: false, beforeDonate: false, afterDonate: false, beforeSwapReturnDelta: false, afterSwapReturnDelta: false, afterAddLiquidityReturnDelta: false, afterRemoveLiquidityReturnDelta: false }); }
beforeSwap: set personal fee
The hook reads tx.origin as the trader identity and returns a fee with the override flag set:
function _beforeSwap(/* ... */) internal override returns (bytes4, BeforeSwapDelta, uint24) { address trader = tx.origin; uint24 fee = token.feeBpsOf(trader) | LPFeeLibrary.OVERRIDE_FEE_FLAG; return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, fee); }
afterSwap: update XP & apply penalty
function _afterSwap(/* ... */, BalanceDelta delta, /* ... */) internal override returns (bytes4, int128) { bool tokenIsC0 = address(token) == Currency.unwrap(key.currency0); int256 d = tokenIsC0 ? int256(delta.amount0()) : int256(delta.amount1()); token.applyTradeEffect(tx.origin, d); return (BaseHook.afterSwap.selector, 0); }
Events
| Event | Emitted when |
|---|---|
XpUpdated(address account, uint256 newXp) | Any balance change for account |
LevelChanged(address account, uint16 old, uint16 new) | The account's level crosses a boundary |
Transfer (ERC-20) | Standard token transfer event |
Errors
| Error | Cause |
|---|---|
OnlyHook() | An address other than the bound hook called applyTradeEffect |
HookAlreadySet() | bindHook called more than once |
Deployment guide
A canonical ERC-EVOLVE deployment proceeds in five steps:
- Deploy
ERC20Evolvewith desired name, symbol, supply, and treasury. - Mine an address for
EvolveHookwhose low bits match the required permission flags. - Deploy
EvolveHookwith the mined salt, pointing at the token + PoolManager. - Call
token.bindHook(hookAddress)— irreversible. - Initialize the v4 pool with
fee: DYNAMIC_FEE_FLAGandhooks: hookAddress.
Foundry script
contract Deploy is Script { function run() external { vm.startBroadcast(); ERC20Evolve token = new ERC20Evolve("Evolve", "EVO", 1e27, msg.sender); uint160 flags = uint160( Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG ); (address hookAddr, bytes32 salt) = HookMiner.find(CREATE2_DEPLOYER, flags, type(EvolveHook).creationCode, abi.encode(POOL_MANAGER, token)); EvolveHook hook = new EvolveHook{salt: salt}(POOL_MANAGER, token); require(address(hook) == hookAddr, "hook mining failed"); token.bindHook(address(hook)); vm.stopBroadcast(); } }
Frontend reads
Display a wallet's level and effective fee using the extension view functions:
import { createPublicClient, http, formatUnits } from 'viem'; import { mainnet } from 'viem/chains'; import { evolveAbi } from './abi'; const client = createPublicClient({ chain: mainnet, transport: http() }); const [xp, level, fee] = await client.multicall({ contracts: [ { address: EVOLVE, abi: evolveAbi, functionName: 'xpOf', args: [user] }, { address: EVOLVE, abi: evolveAbi, functionName: 'levelOf', args: [user] }, { address: EVOLVE, abi: evolveAbi, functionName: 'feeBpsOf', args: [user] }, ], }); console.log(`Level ${level.result}, fee ${Number(fee.result) / 100}%`);
xpOf includes unsettled XP — the value will grow each second even when the wallet hasn't transacted. For displays that update in real-time, just re-poll every few seconds rather than indexing every block.
Routers & aggregators
The hook reads tx.origin to identify the trader. This has implications for trades routed through aggregators or batch contracts:
- Direct user → pool swaps:
tx.originis the user; they pay their personal fee. - User → router → pool:
tx.originis still the user. They pay their personal fee. - Smart-contract-only trades (e.g., a vault rebalancing):
tx.originequals the EOA that started the call chain. The vault's own level does not apply.
EIP-3074 / EIP-7702 delegation may change how tx.origin behaves. Implementations targeting post-3074 chains should consider an alternative trader-identification strategy (e.g., per-pool whitelisted resolvers).
Security model
Sybil resistance
Splitting a bag across N wallets produces no XP advantage. XP is balance × time; the sum across partitions equals the sum of one unified bag held for the same time. Sybilling is economically neutral.
Re-entrancy
The hook calls back into the token contract on afterSwap. The reference implementation follows checks-effects-interactions and the token's state changes are idempotent under the hook's call pattern. Production deployments should add an explicit re-entrancy guard on applyTradeEffect.
MEV
Personalized fees create a new MEV surface: a searcher with a high-level wallet could lend its level out to capture a fee discount. Mitigation: the lastTouch cooldown discounts the effective level of a wallet that received a large incoming transfer within the same block, preventing borrow-and-trade attacks.
Hook address mining
v4 enforces hook permissions through the deployment address. A misconfigured deployment will fail at pool initialization. Always verify with address(hook).codehash and the expected permission bits before binding.
Audit status
| Component | Status |
|---|---|
ERC20Evolve | Unaudited (v0.1) |
EvolveHook | Unaudited (v0.1) |
| Interface freeze | Pending review |
Governance
The reference implementation freezes all parameters at deployment. Production deployments may want to expose:
BASE_RATE— XP accrual rate. Lower values slow leveling; higher values speed it up.PENALTY_BPS— sell penalty multiplier. Tunes how punitive exits feel.MAX_FEE_BPS/MIN_FEE_BPS— the dynamic fee range.
These should be gated by a timelock and DAO vote. Changing them mid-flight does not retroactively re-compute existing XP — the new rate takes effect from the next settlement onward.
FAQ
Does this break my ERC-20 integrations?
No. ERC-EVOLVE is a strict superset of ERC-20. All standard methods (transfer, approve, balanceOf, etc.) work as expected. Wallets and explorers see a normal token.
Can I "buy" a level?
No. Levels are tied to the wallet, not the tokens. Buying a bag from a high-level wallet starts your XP from your wallet's current XP, not theirs.
What happens if I send tokens to a new wallet?
The receiving wallet starts accruing XP from zero (or its existing XP, if it has any). The sender keeps their XP (minus the sell-penalty for the amount transferred out).
Do LPs earn XP?
Yes — the hook's beforeAddLiquidity permission lets LPs accrue XP at a separate rate. The reference implementation settles LP XP on adds; production deployments can apply a multiplier.
Why tx.origin?
It's the simplest way to identify the actual trader through router intermediaries. The trade-offs are discussed in Routers & aggregators. Implementations on chains that have moved past tx.origin semantics will need a different scheme.
Can I use this without Uniswap v4?
The token alone (without the hook) gives you the XP/level extension — but the killer feature (personalized fees enforced at the AMM) requires v4. On v3 or v2, you can still display levels and use them for off-chain logic, but you cannot price trades by level.
Is this the same as a reflection token?
No. Reflection tokens redistribute fees to all holders on every transfer, applied to the token itself. ERC-EVOLVE leaves the token clean — the conditional behavior lives in the hook, which only fires on the canonical pool. Routing through any other venue costs nothing extra.