Security review
Three internal audit passes ran between July and August 2026, against the contracts, the deployment scripts and the app. Every finding below was proved with an executable test before anything was changed, and every one of them is fixed. The tests that prove them are still in the suite, so a regression would be caught rather than rediscovered.
Finding A — the launch could be stolen
The deploy script created the pool in one transaction and seeded it in another. In between, the pool was live with zero liquidity — and a Uniswap v4 swap against zero liquidity consumes no input. Anyone, holding no tokens and no ETH, could move the price for free and decide what price the creator seeded at.
Measured, before the fix:
- pushing the price down stranded 36.8 ETH of the creator's funds against zero tokens;
- pushing it up made the seed land as pure tokens, and 0.01 ETH then bought the entire seeded supply — roughly 50 ETH of value.
Fixed by initializeAndSeed, which does both steps in one transaction, plus an
expected-price check on seed as a second layer.
Finding B — all buys could be blocked for one token
Nothing forces traders through the app's router: anyone can call Uniswap directly with a tight price limit so that only a sliver of an order executes. The hook sized the retention on the amount requested while the proceeds came from the amount actually traded. Exploiting that gap produced a position with a reference value of zero, after which the division by that value reverted — blocking every buy until it expired.
Cost to the attacker, measured: 1.164 CVN.
Fixed by rejecting partially filled sells outright, which removes the gap at its source, plus a guard against zero-value positions.
Finding C — the hook could have been deployed already broken
A Uniswap v4 hook is only called for the actions encoded in its own address, which has to be mined to match the permissions it declares. Nothing checked that the two agreed. Drop a permission from the deploy script and you get a hook that deploys, initialises, and then silently stops being called — with 20% of every sell withheld and never delivered.
Nothing on-chain would have looked wrong. The pool would trade, the tax would flow, and the bonds would simply never appear.
Fixed by calling validateHookAddress in the constructor: a mismatch now fails the
deployment instead of succeeding sideways.
Finding D — an approved operator could destroy a bond instead of moving it
Merging burns the absorbed position. merge checked that the caller was authorised on
each bond, but not that the bonds shared an owner. An operator approved on two different
wallets could therefore fold one person's bond into another's — turning an approval, which
normally moves a position and leaves something to trace, into an irreversible destruction.
Fixed: every bond in a merge must already belong to the same wallet. Merging is further restricted to bonds of the same origin and the same boost state, so it cannot launder value across those boundaries either.
Also fixed
- The creator was on the critical path of every trade. The sell-side tax was pushed
to the creator during the swap. A creator address that cannot receive ETH — a contract
with no
receive, or an account delegated under EIP-7702 — would have reverted the transfer and, with it, every sell and every router buy, permanently, since the address is immutable. The tax now accrues as a claim and is paid out separately byflushTax. A claim cannot fail. - A testnet parameter could have been frozen into the mainnet launch. Deployment scripts read their environment, and an environment left over from a rehearsal holds test values — including the bonus window, which is an immutable constructor argument. A mainnet deploy inheriting it would have shipped a reward ladder 360x too fast, with no fix short of redeploying everything. The script now refuses to run on mainnet with testnet parameters, and checks that the reserve and the launch liquidity account for exactly 100% of the supply.
- The minimum commitment could be dodged. The 20% floor is measured against the caller's balance at call time, so moving tokens to a second address shrank it — below a 4-wei balance it rounded to zero and allowed 1-wei bonds. Since a bond is an NFT that can be pushed onto a stranger, that was also a way to flood someone's position list. An absolute floor of one token was added, which splitting a balance cannot dodge.
- The artwork source could rewrite the whole NFT. The image string is inserted between two quotes; a quote inside it would close the field and let the renderer rewrite the name and attributes. Any string containing a quote, a backslash, or longer than 8 KB is now rejected, degrading to "no image".
tokensOf()is unbounded by construction — anyone can push bonds onto a wallet, so its length is not something its owner controls.tokensOfSliceandbalanceOfBondswere added so an interface can page instead of failing.- The retention source had to become a contract. It books principal without pulling tokens, so a plain wallet wired there could have registered bonds the reserve never received.
- The launch price must sit exactly on the tick grid, otherwise the first buyer crosses a dead, zero-liquidity gap for free.
- Two defects in the test suite itself: one quietly weakening the conservation fuzz, and a pair of tests that reported success without executing because they were pointed at the wrong network. A green test that ran nothing is worse than a red one. Both fixed, and the suite re-run.
How it is checked
Beyond the tests written for each finding, the contracts are covered by a stateful invariant suite, a second independent fuzzing engine reaching the same properties by a different route, and a set of properties proved rather than sampled — including the one that matters most to holders: however long a position is left alone, a single touch credits at most one window of rewards.
What is still missing
No external audit. The work above was internal. A professional review has not been done, and it remains a condition before any mainnet deployment.
Two formal-verification passes are also outstanding: the prover specification needs refreshing against the current model, and mutation testing — which damages the code on purpose to check the tests notice — is impractical on this project as it stands, because the compiler settings the contracts require make each mutation cost minutes rather than seconds.
Open items carried forward: the launch price is read from pool spot and is therefore movable, and the app has a set of lower-severity defects tracked separately.