Executive summary
Unweighted mean of ten categories. Conditional on successful compilation and the fork tests listed under Before mainnet.
Zxo is a single-contract liquidity farm. Users stake Uniswap V3 positions minted on a fixed tick range around parity, and earn a newly minted reward token (one ZxoToken per pool). Emissions follow a target APR on staked value. Each pool is anchored by a permanently locked, single-sided core LP that sells the reward token on the way up.
The original review found 2 High, 4 Medium, 7 Low and 10 informational issues. Both High issues and every Medium issue that could be fixed in code have been fixed. The two remaining Mediums are spot-price dependence and the core-stake weighting. The team has accepted both as design trade-offs and they are recorded in the accepted-risk register.
No path was found that lets anyone take user principal. Unstake has no price-range gate and no owner gate, so staked positions can always be withdrawn after the 1-day lock-up. The core LP has no withdrawal function.
Category scores
Findings and status
| Severity | Total | Resolved | Accepted / intended | Open |
|---|---|---|---|---|
| High | 2 | 2 | 0 | 0 |
| Medium | 4 | 3 | 1 | 0 |
| Low | 9 | 4 | 5 | 0 |
| Info | 13 | 11 | 2 | 0 |
Function flows
Each flow lists the order of operations in the current code. Tags mark each step as a check (reverts on failure), a state effect, an external interaction, or an event.
Pool launch sequence
The contracts enforce the order of steps 4 and 5. The core-stake mint reverts until the reward token is finalized on the deployer, and staking can't open until the core stake exists. Step 2 comes before any Zxo is in public hands, so no one can seed the main pool at a price of their choosing.
Deploys the ZxoToken. Checks pair decimals, mints coreLP + coreStake to Zxo and v3StartAmnt to the deployer.
Creates and initializes the pool, requires price == launch price, and mints the single-sided core LP.
Seeds external DEXes only. NFTs are sent to 0xdEaD. Never seed the main position manager here.
Burns the deployer's remaining balance. Permanently blocks further seeding.
Requires isFinalized. Mints the core stake, which opens staking.
queryData(0, poolId, 4, 0) returns a tick below 31 (or above −31 when Zxo is token1).
Oracle setup (Revision 2.2): after step 2, run set-observations.py for the new pool (it calls increaseObservationCardinalityNext with a size suited to the chain's block time). Staking and harvest stay closed until the pool has 15 minutes of price history.
Farming logic (MasterChef on V3 liquidity)
Accounting model
acc += elapsed × rewardRate × 1e18 / totalStaked pending = liquidity × acc / 1e18 − rewardDebt rewardDebt = liquidity × acc / 1e18 (on stake, claim)
Rewards are minted on claim, not pre-funded. Forfeited or unclaimed rewards are never minted. Every user position shares one fixed tick range, so liquidity is proportional to position value at any price, and liquidity weighting is fair between users.
Emission controller
rewardRate = min( TVL × targetApr,
TVL × maxApr, // 2× maxNormalAPR in year 1
max30dRate / 30 days )
cooldown = 1 hour after each change
TVL is totalTokens, the 18-decimal stake-time amounts summed at 1:1. Since M-01, every stake counts toward TVL. Lowering the rate by hand (updatePoolConfig type 2) only lasts until the next auto-adjust; lowering targetAprBps is the lasting control.
Invariants verified
| Invariant | Why it holds |
|---|---|
| Pending never underflows | rewardDebt is always set from the current acc, and acc only increases. |
| No retroactive rate changes | _updatePool runs before every change to totalStaked and before every rate change. |
| Bonus is symmetric | Bonus liquidity is added to pos.liquidity and totalStaked together and removed together. |
| Referral is not additive | The referral share comes out of the user's pending amount. A self-referral wallet earns nothing extra. |
| Record ⇒ custody | A record with owner ≠ 0 and liquidity > 0 exists only while Zxo holds the NFT. The one exit deletes the record before transferring. |
| Exit always available | Unstake has no health gate and no owner gate. The fail-safe subtractions stop accounting drift from blocking exits. |
| Pool cannot un-launch | The core stake keeps stakes ≥ 1 permanently. |
Stake range and health (18/18 pair)
| Zxo price | Tick | State |
|---|---|---|
| > ~1.0305 | > +300 | Unhealthy |
| ~0.9704 – ~1.0305 | (−300, +300) | Healthy |
| < ~0.9704 | < −300 | Unhealthy |
When the pool is unhealthy, stake and harvest revert, unstake still works, and rewards keep accruing. Bounds are strict. For 6- and 8-decimal pairs the ranges center on ±276324 and ±230270.
Stake bonus curve
| Ticks above parity | 0 | 4 | 10 | 16 | 20+ |
|---|---|---|---|---|---|
| Bonus | 0% | 4% | 10% | 16% | 20% |
| With HOLDER | 0% | 5% | 12.5% | 20% | 25% |
The bonus reaches its cap at a 53.36% paired ratio. The core LP launches at tick 10, so the organic bonus is expected to be around 10–12%.
Owner powers
Zxo owner can
- Create up to 255 pools and tokens, setting coreLP, coreStake, maxNormalAPR (≤ 320%), decimals (validated), max30dRate and v3StartAmnt
- Initialize the core LP and core stake once per pool
- Set targetAprBps from 0 to the cap (turns emissions on or off)
- Set the referral rate between 3% and 30%
- Lower rewardRate temporarily
- Send core-LP trading fees to the treasury
- Transfer ownership (two-step) or renounce it
Zxo owner cannot
- Touch user NFTs, principal or unclaimed rewards
- Block or delay unstake
- Withdraw or reduce core LP or core-stake liquidity
- Mint the reward token outside pool creation and emissions
- Burn anyone's tokens (burn(address) removed)
- Change tick ranges, fee tier, position manager, factory or reward token
- Pause, upgrade, or rescue any asset held by Zxo
| Contract · role | Can | Cannot |
|---|---|---|
| ZxoToken owner = Zxo, permanent | Mint | Transfer or renounce ownership. Burn others' tokens. Holders can burn their own tokens. |
| ZxoV3Deployer Ownable2Step | Seed external DEXes with any position manager until the token is finalized. Finalize (burn the remainder). Set ZXO_CONTRACT once. | Seed after finalization. Repoint ZXO_CONTRACT. Withdraw tokens on mainnet (testnet-only validator path). |
| ZxoTreasury Ownable (single-step) | Sweep any token to the destination. Change the destination after a 7-day delay. Lock the destination forever. | Receive ETH. Bypass the delay. |
Core LP ownership
The core LP NFT is minted to address(this) and its id is stored in poolConfig. Zxo has no function that transfers it or reduces its liquidity. The core liquidity, and all pair tokens it collects as it sells Zxo between ~1.001 and ~1.03, is permanently locked. Trading fees are the only value that leaves, and they go to the fixed treasury through an owner-only call. If Zxo ownership is renounced, those fees are locked too.
Before minting, the launch price is now checked against the pool's actual price (L-01). All six launch prices were checked with a port of Uniswap's TickMath. Each sits exactly one tick outside its core range, on the correct side for a single-sided Zxo deposit.
| Pair | Zxo is | Launch tick | Core LP range | Core stake range | User stake range |
|---|---|---|---|---|---|
| 18 / 18 | token0 | 10 | [11, 300] | [31, 300] | [−300, 300] |
| 18 / 18 | token1 | −10 | [−300, −11] | [−300, −31] | [−300, 300] |
| 18 / 6 | token0 | −276314 | [−276313, −276024] | [−276293, −276024] | [−276624, −276024] |
| 18 / 6 | token1 | 276314 | [276024, 276313] | [276024, 276293] | [276024, 276624] |
| 18 / 8 | token0 | −230260 | [−230259, −229970] | [−230239, −229970] | [−230570, −229970] |
| 18 / 8 | token1 | 230260 | [229970, 230259] | [229970, 230239] | [229970, 230570] |
Accepted-risk register
These items were reviewed with the development team and deliberately left as they are. Items the team excluded from scope before the audit are listed at the end.
| ID | Risk | Team position |
|---|
Out of scope by design (declared before the audit)
| Behavior | Rationale |
|---|---|
Bare revert() without reasons | Saves gas and bytecode. Frontends pre-check conditions. |
| Unstaking forfeits pending rewards | The protocol pays out only while the tick range is healthy. Users harvest first. |
| HOLDER bonus can be bought, used and sold | The HOLDER token is only available on public DEXes. The team treats this as volume and participation. |
| Tick-range requirement on stake | Covered by the user-set slippage parameter. |
Before mainnet
Build and configuration
- Switch the ChainLib import from
SepoliaTestLib.solto the target chain withupdate-chain-lib.py, and setZXO_TREASURY,ZXO_V3_DEPLOYERandZXO_ORACLE - Compile. Watch
createPoolandinitializePoolfor "stack too deep" - Confirm Zxo runtime bytecode is under 24,576 bytes (EIP-170)
- compile.py stops on standard-JSON errors (done)
- Verification input compiles with no local files and matches the build (confirmed for Zxo; repeat for the other contracts)
- EVM version selectable in compile.py. Use Shanghai: PulseChain supports nothing newer (done)
- Set
HOLDER_TOKENandHOLDER_THRESHOLDin each chain library (currently zero on 8 mainnets; Arbitrum and test libraries are set) - Run ZxoVerify with the same ChainLib as Zxo: once before ZxoV3Deployer exists, then again with verifyV3Deployer = true and verifyOracle = true
- Run Slither or Aderyn and triage the results against this register
- Move Zxo and deployer ownership to a multisig
Tests
- Fork: read slot0 on every target DEX. The old typed call reverts on PancakeSwap
- Fork: stake a USDT pair on Ethereum
- Fork: run the full launch order. The core stake reverts until finalizeToken
- Fork:
initializePool(core)reverts on a pool pre-initialized at another price createPoolreverts when decimals don't match the pair token- Unstake to a contract without the NFT receiver reverts with no state change. A retry with an EOA as
tosucceeds - Invariant:
totalStaked == Σ pos.liquidity(core stake included) - Invariant:
totalTokens == Σ pos.tokens(core stake included) - Invariant: the owner can always unstake after the 1-day lock-up
- Independent re-audit of the diff since Revision 1
Frontend and ABI changes
unstake(uint64 tokenId, uint8 poolId, bool acceptForfeit, address to). The selector changed. Passaddress(0)to send the NFT to the caller.queryData(…, 4)returns(false, 0, 0)when a price can't be read, instead of reverting. TreatsqrtPriceX96 == 0as "not ready".totalGlobalStakes()andreferralsPerUser_()now returnuint32.- New on ZxoV3Deployer:
isFinalized(address),finalizeToken(address)and the eventTokenFinalized(address indexed, uint256). - New event action codes: 8 Deployed, 9 PoolCreated, 10 CoreFeesCollected, 11 ConfigUpdated, 12 PoolInitialized. Codes 0–7 are unchanged.
tokensIds_(address)was removed. Map a reward token to its pool by scanningpoolInfo(i).rewardToken.queryData(…, 4)"healthy" now also requires the 15-minute TWAP in range. UseZxoOracle.twapTick(pool)to show the average price and explain a paused pool.- Trust panel for each pool:
isFinalizedis true, the deployer balance is 0, everyPoolStartedposman is canonical, and the seeded NFTs are owned by 0xdEaD.
Scope and file fingerprints
Revision 2.5 covers the contracts as uploaded on 27 September 2026 (14:19), with Zxo.sol as updated at 14:27. Hashes are the first 12 hex characters of each file's SHA-256. Any later change to these files is outside this review.
delete on an element of a memory bytes array; the only deletes in the build clear storage mappings. Keep viaIR off.
| File | Lines | SHA-256 (prefix) | Role |
|---|---|---|---|
| Zxo.sol | 1067 | 8c8c2947cfe6 | Farm, staking, emissions, core LP |
| libraries/ZxoLib.sol | 502 | 2af09f5c47af | Ticks, bonus, packing, validation |
| ZxoV3Deployer.sol | 331 | 3c8fb36af7dd | External DEX seeding and finalization |
| ZxoTreasury.sol | 231 | aa47be5c2210 | Fee and referral receiver |
| ZxoVerify.sol | 211 | 02ff34b11012 | Deployment sanity check |
| tokens/ZxoToken.sol | 103 | 87ba572691dd | Per-pool reward token |
| interfaces/Iv3PosManager.sol | 56 | 0a8356439ff0 | Position manager interface |
| interfaces/IZxo.sol | 22 | 5fd91cc8d935 | poolInfo reader |
| interfaces/IZxoToken.sol | 12 | 856487dac631 | mint, coreLP, coreStake |
| interfaces/IUniswapV3.sol | 7 | e5c8cd93d1d8 | Factory interface |
| interfaces/IZxoV3Deployer.sol | 5 | d332e8f4212b | isFinalized |
| ZxoOracle.sol | 26 | 6ac1f047bc61 | 15-minute TWAP tick reader |
| interfaces/IZxoOracle.sol | 17 | bb73eefaf56f | twapTick, inRange, WINDOW |
| libraries/*Lib.sol (11 chains) | 21–22 each | various | Per-chain addresses and HOLDER threshold (all addresses EIP-55 valid) |