Smart contract security review · Revision 2.5

Zxo Protocol Security Audit

Remediation review complete
Date
27 September 2026
Compiler
solc 0.8.36
Scope
24 Solidity files · 2,831 lines
DEX model
Uniswap V3 and forks (Pancake, Liberty)
Method
Manual line review + numeric tick/price verification
Reviewer
Claude (Anthropic), AI-assisted
01

Executive summary

Overall score
8.5/10
6.6 → 8.5 after remediation

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.

20Resolved
8Accepted / intended
0Open High / Medium
0Open informational
Limits of this review The review environment could not install dependencies. The team has since compiled Zxo with solc 0.8.36 and confirmed the explorer verification input reproduces the build bytecode, but no fuzzing, fork tests or live deployment were run as part of this review. This AI-assisted review does not replace an independent human audit. Contracts that will hold user savings should also get a professional re-audit of the diff before mainnet.
Revision 2.5 · 27 September 2026 All open items are closed. SepoliaTestLib now has a complete ZXO_ORACLE line, so the tree builds (I-12). ConfigUpdated now logs the value actually stored, including the capped target APR, from one shared log call (I-13). ZxoVerify has full NatSpec for the deploy order and both constructor flags. No change to runtime logic, funds flow or scores.
Revision 2.4 · 27 September 2026 ZxoVerify now checks ZxoOracle on-chain, behind a second constructor flag (verifyOracle): code present, WINDOW() == 900, and a fail-safe answer (no average, never in range) for an address that is not a pool. PoolInitialized now indexes the reward token, matching PoolCreated; I-13 is narrowed to the ConfigUpdated value. No change to runtime logic, funds flow or scores.
Revision 2.3 · 27 September 2026 The Zxo constructor now validates the full ChainLib configuration with readable error messages, at no runtime bytecode cost. New events record deployment, pool creation, pool initialization, core fee collection and config updates, and updatePoolConfig rejects unknown data types. All chain libraries now declare ZXO_ORACLE. I-11 resolved. Two informational items added (I-12 build, I-13 event data).
Revision 2.2 · 27 September 2026 Adds ZxoOracle: the health gate now also requires a 15-minute TWAP inside the stake range, which closes the same-transaction harvest bypass (M-02 resolved; the spot-based bonus remains accepted). Events now go through one shared function, redundant library checks and the write-only tokensIds_ mapping were removed, uint64 casts use a checked downcast, and the Zxo constructor validates the HOLDER settings. Revision 2.1 moved HOLDER_THRESHOLD into each chain library and added HOLDER checks to ZxoVerify. One informational item added (I-11, oracle setup).
02

Category scores

Original audit After remediation Scale 0–10
03

Findings and status

SeverityTotalResolvedAccepted / intendedOpen
High2200
Medium4310
Low9450
Info131120
04

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.

checkReverts on failure effectWrites state externalCalls another contract eventEmits log
05

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.

STEP 1createPool

Deploys the ZxoToken. Checks pair decimals, mints coreLP + coreStake to Zxo and v3StartAmnt to the deployer.

STEP 2initializePool · core

Creates and initializes the pool, requires price == launch price, and mints the single-sided core LP.

STEP 3startV3pool ×n

Seeds external DEXes only. NFTs are sent to 0xdEaD. Never seed the main position manager here.

STEP 4finalizeToken

Burns the deployer's remaining balance. Permanently blocks further seeding.

STEP 5initializePool · stake

Requires isFinalized. Mints the core stake, which opens staking.

Seeding-window risk at production size To push the tick from the launch tick (10) past the core-stake lower bound (31), buyers must absorb about 7% of the core LP. At the planned core sizes that is roughly $350k for a $5M core, $1.05M for $15M and $70M for $1B. Seeded DEX positions are too small to move the main pool through arbitrage. Before step 5, confirm that 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.
06

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

InvariantWhy it holds
Pending never underflowsrewardDebt 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 symmetricBonus liquidity is added to pos.liquidity and totalStaked together and removed together.
Referral is not additiveThe referral share comes out of the user's pending amount. A self-referral wallet earns nothing extra.
Record ⇒ custodyA record with owner ≠ 0 and liquidity > 0 exists only while Zxo holds the NFT. The one exit deletes the record before transferring.
Exit always availableUnstake has no health gate and no owner gate. The fail-safe subtractions stop accounting drift from blocking exits.
Pool cannot un-launchThe core stake keeps stakes ≥ 1 permanently.

Stake range and health (18/18 pair)

Zxo priceTickState
> ~1.0305> +300Unhealthy
~0.9704 – ~1.0305(−300, +300)Healthy
< ~0.9704< −300Unhealthy

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 parity04101620+
Bonus0%4%10%16%20%
With HOLDER0%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%.

07

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 · roleCanCannot
ZxoToken
owner = Zxo, permanent
MintTransfer 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.
08

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.

PairZxo isLaunch tickCore LP rangeCore stake rangeUser stake range
18 / 18token010[11, 300][31, 300][−300, 300]
18 / 18token1−10[−300, −11][−300, −31][−300, 300]
18 / 6token0−276314[−276313, −276024][−276293, −276024][−276624, −276024]
18 / 6token1276314[276024, 276313][276024, 276293][276024, 276624]
18 / 8token0−230260[−230259, −229970][−230239, −229970][−230570, −229970]
18 / 8token1230260[229970, 230259][229970, 230239][229970, 230570]
09

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.

IDRiskTeam position

Out of scope by design (declared before the audit)

BehaviorRationale
Bare revert() without reasonsSaves gas and bytecode. Frontends pre-check conditions.
Unstaking forfeits pending rewardsThe protocol pays out only while the tick range is healthy. Users harvest first.
HOLDER bonus can be bought, used and soldThe HOLDER token is only available on public DEXes. The team treats this as volume and participation.
Tick-range requirement on stakeCovered by the user-set slippage parameter.
10

Before mainnet

Build and configuration

  • Switch the ChainLib import from SepoliaTestLib.sol to the target chain with update-chain-lib.py, and set ZXO_TREASURY, ZXO_V3_DEPLOYER and ZXO_ORACLE
  • Compile. Watch createPool and initializePool for "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_TOKEN and HOLDER_THRESHOLD in 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
  • createPool reverts 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 to succeeds
  • 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. Pass address(0) to send the NFT to the caller.
  • queryData(…, 4) returns (false, 0, 0) when a price can't be read, instead of reverting. Treat sqrtPriceX96 == 0 as "not ready".
  • totalGlobalStakes() and referralsPerUser_() now return uint32.
  • New on ZxoV3Deployer: isFinalized(address), finalizeToken(address) and the event TokenFinalized(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 scanning poolInfo(i).rewardToken.
  • queryData(…, 4) "healthy" now also requires the 15-minute TWAP in range. Use ZxoOracle.twapTick(pool) to show the average price and explain a paused pool.
  • Trust panel for each pool: isFinalized is true, the deployer balance is 0, every PoolStarted posman is canonical, and the seeded NFTs are owned by 0xdEaD.
11

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.

Compiler: solc 0.8.36, legacy pipeline (viaIR off) Etherscan lists three known bugs for 0.8.36, all fixed in 0.8.37. None applies to Zxo. MisorderedNamedParametersInRequireWithCustomErrors and SpillSlotCollisionAcrossMutualRecursion only affect the via-IR pipeline, and Zxo has no custom errors in require and no recursion. MemoryByteArrayElementDeleteClearsWholeWord needs delete on an element of a memory bytes array; the only deletes in the build clear storage mappings. Keep viaIR off.
FileLinesSHA-256 (prefix)Role
Zxo.sol10678c8c2947cfe6Farm, staking, emissions, core LP
libraries/ZxoLib.sol5022af09f5c47afTicks, bonus, packing, validation
ZxoV3Deployer.sol3313c8fb36af7ddExternal DEX seeding and finalization
ZxoTreasury.sol231aa47be5c2210Fee and referral receiver
ZxoVerify.sol21102ff34b11012Deployment sanity check
tokens/ZxoToken.sol10387ba572691ddPer-pool reward token
interfaces/Iv3PosManager.sol560a8356439ff0Position manager interface
interfaces/IZxo.sol225fd91cc8d935poolInfo reader
interfaces/IZxoToken.sol12856487dac631mint, coreLP, coreStake
interfaces/IUniswapV3.sol7e5c8cd93d1d8Factory interface
interfaces/IZxoV3Deployer.sol5d332e8f4212bisFinalized
ZxoOracle.sol266ac1f047bc6115-minute TWAP tick reader
interfaces/IZxoOracle.sol17bb73eefaf56ftwapTick, inRange, WINDOW
libraries/*Lib.sol (11 chains)21–22 eachvariousPer-chain addresses and HOLDER threshold (all addresses EIP-55 valid)