Security review
Four 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 C4.
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 10% 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.
Finding E — parking a listing beat playing fair
Rewards are windowed: show up once per window and you collect it, stay away and the surplus is forfeited. That rule is the whole product, and a listed bond was exempt from it — its owner cannot act on it while it sits in escrow, so escrow time was credited in full.
But the owner chooses when to list and when to cancel, and no on-chain rule can tell an honest ask from one nobody will ever pay. "Cannot show up" became "need not show up". A position parked for a month behind an unbuyable price and then cancelled collected the entire month plus a maxed-out rate ladder, for two transactions — beating an attentive holder by two orders of magnitude. The listing fee could not deter it: it is a percentage of a price the parker picks.
Fixed: the exemption is gone. Escrow time earns exactly what owner-held time earns.
Finding F — the same exploit, through a different door
The fix above bounded what a single touch of an escrowed position pays. It left the number of touches free — and that path is permissionless, so anyone can poke a listed bond as often as they are willing to pay gas for. Compounding the same elapsed time in many small steps earns strictly more than doing it once, so parking became profitable again by a quieter route.
Measured on two identical bonds: the parked one, poked every ten minutes, ended 2.77% ahead over a week — taken from a finite reward pool, so what it gained someone else will not receive.
What made this one hard to see is that five separate guardrails covered that area — two tests, a stateful invariant, a mathematically proved property, and the second fuzzer's harness — and every one of them measured what one call pays. None measured how often a call may pay.
Fixed: the escrow path now banks one window per window, not one per call. The regression tests were checked in both directions — with the fix removed, they fail.
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 trial parameter could have been frozen into the launch. Deployment scripts read their environment, and an environment left over from a trial run holds shortened values — including the bonus window, which is an immutable constructor argument. A launch inheriting one would have shipped a reward ladder 360x too fast, with no fix short of redeploying everything. The script now refuses to run on Ethereum mainnet with anything but production values, and checks that every share of the supply is accounted for exactly, to the token, before it will deploy.
- 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.
- A bond sent straight to the marketplace was lost for good. An ERC-721 cannot refuse
a transfer, and the contract held it with no listing attached: no cancel, no recovery,
no path. A
rescuewas added, restricted to the fee recipient and refused for any live listing. - A listing under 100 wei was free. Both fees are a percentage rounded down, so they came out at zero. A minimum price was added.
- 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.