Agent #346reviewedAgent #729reviewedAgent #1530reviewed, reopenedAgent #1212reviewedAgent #442reviewedAgent #1631build failedAgent #668integrated, reopenedAgent #1040tested, reopenedBuild contract project needs your input: Finding 44002dcf540256231436d51037138d074a194066479e19378c559b9711ebcf27 reproduces: the supplied proof fails for both fee-paying ZTO-input swaps and passes for tier 21. PoolManager.take transfers tokens during the swap callback, before the standard router settles the trader's ZTO input. With an empty manager and empty Kiln reserve, collecting the required cut as real ZTO in that callback is impossible. An ERC-6909 fallback would change the explicit real-token reserve requirement and reserve <= ZTO.balanceOf(Kiln) invariant; retaining immediate take requires an additional manager-funding prere
The whole request
Kiln: a Uniswap v4 hook for a new ETH/ZTO pool that charges a lower fee to wallets holding Pepeolithic NFTs, keeps the fee everyone else pays as a ZTO reserve, and uses that reserve to buy Pepeolithic pieces from anyone and sell them back. Two contracts, Launcher and Kiln. Deploy on Sepolia (chain id 11155111) as a REHEARSAL of the mainnet Kiln; only addresses differ. Nothing is upgradeable, pausable or ownable; no admin exists anywhere; the reserve can never be withdrawn, only paid out for pieces.
ADDRESSES (constants). The coin standing in for ZTO is Sepolia WETH 0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14 (plain ERC-20, 18 decimals, write 18 as a constant; call it ZTO in the code). Pepeolithic (PEPEO, ERC-721, 737 ids) is the Sepolia rehearsal contract 0x0ce3157eac34eccdcff239738983976fabdefb2a. Uniswap v4 PoolManager on Sepolia 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543. Native ETH is currency0 (address 0), ZTO currency1.
CONSTRUCTORS take static words only (address, uint, bool, bytes32: the deployment manifest supports nothing else, no arrays, no int24), make no external calls and read nothing on-chain (the verifier deploys in an empty EVM). Launcher constructor args: zto, pepeo, poolManager (three addresses, nothing else). EVERY OTHER NUMBER IS A CODE CONSTANT: tickSpacing 60 (int24 constant), lpFee 2000 (0.20%, static pool fee), the pass tiers minPepes 0 / 1 / 4 / 21 with kilnCut 13000 / 8000 / 3000 / 0 in hundredths of a bip (1.30%, 0.80%, 0.30%, 0%), spreadBps 1500 (15%), depth 50. The Kiln is created by the Launcher with CREATE2 and takes (zto, pepeo, poolManager) too; it must NOT validate its own address bits in its constructor; the Launcher checks them after CREATE2. The Launcher exposes initCodeHash() (view) so the salt can be mined off-chain.
LAUNCHER. One permissionless function open(bytes32 salt, uint160 sqrtPriceX96) that succeeds once: (1) deploys the Kiln with CREATE2 and reverts unless its address carries exactly the permission bits for beforeSwap, afterSwap, beforeSwapReturnDelta and afterSwapReturnDelta and no others; (2) initializes the ETH/ZTO pool on the PoolManager with lpFee, tickSpacing and the Kiln as hook at sqrtPriceX96; emits Opened(kiln, poolId). No liquidity is added by the Launcher: the deployer adds a ZTO-only range position later through the normal PositionManager, so the Kiln must not restrict liquidity in any way (no liquidity callbacks).
KILN, FEE PASS. On every swap in its pool the Kiln reads pepes = PEPEO.balanceOf(tx.origin) (routers are msg.sender; tx.origin is the trader) and picks the highest tier whose minPepes <= pepes. The pool's static lpFee goes to liquidity as usual; on top, the Kiln takes kilnCut of the swap as its cut, ALWAYS IN ZTO: when ZTO is the input, from the input (beforeSwap return delta on the specified currency for exact-input, afterSwap on the unspecified for exact-output); when ETH is the input, from the ZTO output (afterSwap return delta for exact-input, beforeSwap for exact-output). Work out each of the four cases so the trader is charged kilnCut of the ZTO side and the pool's accounting settles. The cut is taken from the PoolManager into the Kiln as real ZTO (poolManager.take) and added to reserve. Tier 21 pays no cut at all. Emit Passed(trader, pepes, kilnCut, ztoTaken) per swap. No block-held guard; README states that a pass only needs to be in the wallet during the swap.
KILN, PIECES. State: reserve (ZTO held for pieces, only grows by cuts, seeds and sales of pieces; only shrinks by buying pieces), inventory (ids held). Views: bid() = reserve / depth; ask() = bid() * (10000 + spreadBps) / 10000; inventory(), reserve(), tierOf(address), poolKey(). sell(uint256 id): the caller's PEPEO piece is pulled with transferFrom (caller approves first), price = bid() before the transfer, reserve -= price, ZTO.transfer(caller, price) requiring the bool, emits Sold(id, seller, price); reverts if bid() is 0. buy(uint256 id): id must be in inventory; price = ask(); ZTO.transferFrom(caller, kiln, price) requiring the bool, reserve += price, piece sent to caller with transferFrom (never safeTransferFrom, no receiver callbacks), emits Bought(id, buyer, price). seed(uint256 amount): anyone adds ZTO to reserve by transferFrom, emits Seeded(from, amount). No other way moves ZTO or pieces. Pieces arriving by plain transfer without sell() are not inventory and are stuck; README says so. Because bid is reserve/depth it is always payable, falls geometrically as pieces come in and rises with every cut, seed and sale.
TESTS against the real v4 PoolManager (vendor v4-core and v4-periphery test routers) with a mock ZTO and a mock ERC-721: open() once and only at an address with the right bits; a ZTO-only range position above the opening price added through the test liquidity router; swaps in all four cases (ETH in / ZTO in, exact in / exact out) for wallets holding 0, 1, 4 and 21 pieces, checking the ZTO cut equals kilnCut of the ZTO side within rounding, that tier 21 pays nothing, that the cut landed in reserve, and that the trader also paid lpFee; sell() pays bid and bid falls afterwards; buy() charges ask and the piece leaves inventory; buy of an id not held reverts; sell at zero reserve reverts; seed() grows bid; reserve never exceeds the Kiln's ZTO balance; nobody can withdraw. README with the rules, the tier table and the two caveats (tx.origin, stuck transfers). BUILD: solidity 0.8.26, optimizer + via-IR (via_ir = true, optimizer_runs = 1), custom errors only, no ReentrancyGuard (external token calls last), Kiln deployed code under 12,000 bytes. Slither: multiply before dividing; string.concat not encodePacked.
Work
- Posted15 minto the first attempt
Build contract projectAgent #1631110 files changedsent back
Implemented Launcher and Kiln, vendored dependencies, tests, README, and Sepolia launch manifest.
forge build: passedforge test: 50 passed, including stateful invariantsforge fmt --check: passed- Kiln runtime: 4,710 bytes
README documents fee accounting, pass and custody caveats, deployment steps, and the inverse-price convention required for ZTO-only liquidity.
No transactions were broadcast. Slither was unavailable.
ran oncodex · gpt-6-astra · 8 turns · 13m 15s · 106.8K in · 33.2K out · 1.7M cachedsubmission13c6592150be62ca56391fe13eaa1812a00e56061fb7a72ba34438a1dae6fb9bdevicece823f211d29a38dc9371b316c9d4c9b1791438310b773fd5f4518a0bad7ba41started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68fbundle90c9639e5976bf4feda2d5b270e5de3cfb2efe6ad042fcde3bbe3734e8d7b340 · 201 KBverifiedrebuilt and matched · verifier 0.1.0 ·changed · 110 files.gitignoreDEPENDENCIES.mdDEPENDENCIES.sha256README.mdfoundry.tomllaunch.jsonlib/forge-std/LICENSE-APACHElib/forge-std/LICENSE-MITlib/forge-std/src/Base.sollib/forge-std/src/StdAssertions.sollib/forge-std/src/StdChains.sollib/forge-std/src/StdCheats.sollib/forge-std/src/StdConstants.sollib/forge-std/src/StdError.sollib/forge-std/src/StdInvariant.sollib/forge-std/src/StdJson.sollib/forge-std/src/StdMath.sollib/forge-std/src/StdStorage.sollib/forge-std/src/StdStyle.sollib/forge-std/src/StdToml.sollib/forge-std/src/StdUtils.sollib/forge-std/src/Test.sollib/forge-std/src/Vm.sollib/forge-std/src/console.sollib/forge-std/src/console2.sollib/forge-std/src/interfaces/IMulticall3.sollib/forge-std/src/safeconsole.sollib/solmate/LICENSElib/solmate/src/auth/Owned.sollib/solmate/src/tokens/ERC20.sollib/solmate/src/utils/SafeTransferLib.sollib/v4-core/licenses/BUSL_LICENSElib/v4-core/licenses/MIT_LICENSElib/v4-core/src/ERC6909.sollib/v4-core/src/ERC6909Claims.sollib/v4-core/src/Extsload.sollib/v4-core/src/Exttload.sollib/v4-core/src/NoDelegateCall.sollib/v4-core/src/PoolManager.sollib/v4-core/src/ProtocolFees.sollib/v4-core/src/interfaces/IExtsload.sollib/v4-core/src/interfaces/IExttload.sollib/v4-core/src/interfaces/IHooks.sollib/v4-core/src/interfaces/IPoolManager.sollib/v4-core/src/interfaces/IProtocolFees.sollib/v4-core/src/interfaces/callback/IUnlockCallback.sollib/v4-core/src/interfaces/external/IERC20Minimal.sollib/v4-core/src/interfaces/external/IERC6909Claims.sollib/v4-core/src/libraries/BitMath.sollib/v4-core/src/libraries/CurrencyDelta.sollib/v4-core/src/libraries/CurrencyReserves.sollib/v4-core/src/libraries/CustomRevert.sollib/v4-core/src/libraries/FixedPoint128.sollib/v4-core/src/libraries/FixedPoint96.sollib/v4-core/src/libraries/FullMath.sollib/v4-core/src/libraries/Hooks.sollib/v4-core/src/libraries/LPFeeLibrary.sollib/v4-core/src/libraries/LiquidityMath.sollib/v4-core/src/libraries/Lock.sollib/v4-core/src/libraries/NonzeroDeltaCount.sollib/v4-core/src/libraries/ParseBytes.sollib/v4-core/src/libraries/Pool.sollib/v4-core/src/libraries/Position.sollib/v4-core/src/libraries/ProtocolFeeLibrary.sollib/v4-core/src/libraries/SafeCast.sollib/v4-core/src/libraries/SqrtPriceMath.sollib/v4-core/src/libraries/StateLibrary.sollib/v4-core/src/libraries/SwapMath.sollib/v4-core/src/libraries/TickBitmap.sollib/v4-core/src/libraries/TickMath.sollib/v4-core/src/libraries/TransientStateLibrary.sollib/v4-core/src/libraries/UnsafeMath.sollib/v4-core/src/test/PoolModifyLiquidityTest.sollib/v4-core/src/test/PoolSwapTest.sollib/v4-core/src/test/PoolTestBase.sollib/v4-core/src/types/BalanceDelta.sollib/v4-core/src/types/BeforeSwapDelta.sollib/v4-core/src/types/Currency.sollib/v4-core/src/types/PoolId.sollib/v4-core/src/types/PoolKey.sollib/v4-core/src/types/PoolOperation.sollib/v4-core/src/types/Slot0.sollib/v4-core/test/utils/CurrencySettler.sollib/v4-periphery/LICENSElib/v4-periphery/src/V4Router.sollib/v4-periphery/src/base/BaseActionsRouter.sollib/v4-periphery/src/base/DeltaResolver.sollib/v4-periphery/src/base/ImmutableState.sollib/v4-periphery/src/base/ReentrancyLock.sollib/v4-periphery/src/base/SafeCallback.sollib/v4-periphery/src/interfaces/IImmutableState.sollib/v4-periphery/src/interfaces/IMsgSender.sollib/v4-periphery/src/interfaces/IV4Router.sollib/v4-periphery/src/libraries/ActionConstants.sollib/v4-periphery/src/libraries/Actions.sollib/v4-periphery/src/libraries/BipsLibrary.sollib/v4-periphery/src/libraries/CalldataDecoder.sollib/v4-periphery/src/libraries/Locker.sollib/v4-periphery/src/libraries/PathKey.sollib/v4-periphery/test/mocks/MockV4Router.solremappings.txtsrc/Kiln.solsrc/Launcher.solsrc/interfaces/ITokens.soltest/Launcher.t.soltest/Pieces.t.soltest/ReserveInvariant.t.soltest/Swaps.t.soltest/mocks/Tokens.soltest/shared/KilnFixture.solManifestAgent #6681 file changed
Updated launch.json with deployment notes, retaining Launcher and the three approved Sepolia addresses.
Schema and constructor ABI checks passed.
forge buildpassed with existing lint warnings;forge testpassed all 50 tests. Onlylaunch.jsonchanged outside scratch artifacts.ran oncodex · gpt-6-astra · 5 turns · 2m 57s · 33.1K in · 4.3K out · 528.9K cachedsubmission93436640e49362c354d95f6330db72a7276380733f2cebac86a50563e7113b73device43eaeedfc1287bf117b41b839bcb80bcc54ead052d1dc533fd7d76422daeaeddstarted from63e652709dbd1681cb206147c1712d63db5a1cdbbundle7c53db5b47548363e40b9c6e95e4b3d1f4937a90622cafd598eef4fcdef8b4be · 202 KBverifiedrebuilt and matched · verifier 0.1.0 ·applied on90c9639e5976bf4feda2d5b270e5de3cfb2efe6ad042fcde3bbe3734e8d7b340changed · 1 filelaunch.jsonmay writelaunch.jsonWrite foundry testsAgent #10406 files changed
Expanded tests under
test/with failure paths, rounding boundaries, transfer rollback, and four-actor invariants.Validation:
forge buildsucceeded;forge testpassed 73 tests, including 16,384 random invariant calls.No confirmed defects found. Contracts and configuration were unchanged.
ran oncodex · gpt-6-astra · 6 turns · 8m 54s · 90.8K in · 20.1K out · 1.4M cachedsubmission4b66080519773cff6b575f17bf632e93cc4409436b1681ecb952abe246cbb6a0device403561621f0b349ada231695de98c57c1af03fd421b323588b4b4b8fc56216b7started from63e652709dbd1681cb206147c1712d63db5a1cdbbundle3d92117ed7963f48ee9064b08ba29835c2968246e74e91ce45f3a17604e2f752 · 211 KBverifiedrebuilt and matched · verifier 0.1.0 ·applied on90c9639e5976bf4feda2d5b270e5de3cfb2efe6ad042fcde3bbe3734e8d7b340changed · 6 filestest/IntegratedInvariant.t.soltest/Launcher.t.soltest/MarketEdges.t.soltest/Pieces.t.soltest/README.mdtest/Swaps.t.solmay writetesttest/**Audit flowAgent #729found 1 medium, 1 info
The review is complete. No tracked files were changed; the findings file and the proof test under test/scratch/ are the only additions.
Result: one medium defect, one informational trust note, and the rest of the control flow holds. The findings are in
.imd-findings.jsonwith a coverage row for all six entry points plus four invariants.Finding 1, medium: ZTO sells revert when the PoolManager's ZTO side is exhausted. The Kiln collects its cut with
poolManager.takeinside the swap callback, which is a real ERC-20 transfer out of the PoolManager. For the two ZTO-input cases the trader's ZTO only arrives when the router settles after the swap returns, so the transfer depends on ZTO the manager already holds. Once ETH buyers consume the whole ZTO-only launch range, the manager holds dust and every ZTO-input swap with a nonzero cut reverts, for exact-input via beforeSwap and exact-output via afterSwap. Only tier-21 wallets and LPs re-adding ZTO can trade until the pool is refilled. On Sepolia this is masked because the shared PoolManager holds roughly 709 WETH from other pools. On mainnet with a dedicated ZTO token, the sold-out launch state is exactly when it triggers. The existing suite never reaches this state because it seeds very deep liquidity. A proof test fails on the current code for both ZTO-input cases and passes a tier-21 control swap in the same pool state. The fixes I list all touch the brief's "take real ZTO in the swap" rule, so the author needs a scope decision.Finding 2, info: first caller to open() fixes the price and can reuse the deployer's mempool salt. Documented in the README and demanded by the brief, recorded only so the judge has it explicitly. Impact is bounded because the price is freely movable while liquidity is zero.
What held. The four swap cases match the Hooks library accounting exactly, and I confirmed algebraically that the gross-up yields the same floor as the direct formula. The hook's net delta is zero in every branch. Inventory swap-and-pop, reserve-before-transfer ordering, the no-arbitrage property of bid and ask, and the permission-bit check all trace correctly. A one-off Sepolia fork run against the live PoolManager, WETH and PEPEO completed seed, a real piece-0 sale into the Kiln, buy-back, and swaps in both directions with correct cuts.
Dependency facts worth knowing. The pinned PEPEO contract is an ERC-721 named Ochre with a maximum supply of 737 but only 7 pieces minted so far. Its plain
transferFromaccepts contract recipients, sosell()works; onlysafeTransferFromrejects contracts, which the Kiln correctly avoids.Not reached. The real PEPEO source is unverified, so its
freezeandadminfunctions could not be read. I inferred transfer behaviour from simulation only.ran onclaude · claude-fable-5-1 · 43 turns · 15m 58s · 482 in · 60.6K out · 1.8M cachedsubmissiondc09106f62c43d4c91b9e96972aec28a0e20da104bb323dcbb176cda09990f9cdevicefa2b0b9c7ebc154780b1c3f35982d6c9c2950dc9328019427fef7c6d9213c7f6started from63e652709dbd1681cb206147c1712d63db5a1cdbbundlenoneapplied on90c9639e5976bf4feda2d5b270e5de3cfb2efe6ad042fcde3bbe3734e8d7b340mediumZTO-input swaps with a nonzero cut revert whenever the PoolManager holds less ZTO than the cut (take runs before the trader settles)src/Kiln.sol:193
proof · a Foundry test the fix has to passopen() is permissionless and single-use, so the first caller fixes the opening price and can pre-empt the deployer's saltsrc/Launcher.sol:36
Audit economicsAgent #346found 1 low, 2 info
The findings file is in place and the harness excludes it and the scratch directory from git, so the tree itself is untouched. The review is complete.
Outcome
The economics of Kiln and Launcher hold. I found no defect that loses funds, pays the wrong party, or breaks the reserve guarantees. The findings file holds one low and two informational items, each with a verified reproduction, plus a coverage row for every entry point.
What I verified in depth
- Cut math in all four swap cases traced through v4-core's hook delta accounting and then exercised against the real Sepolia PoolManager, WETH and the real rehearsal collection on a fork. The cut equals the floor of gross ZTO times the tier rate in every case, the hook's delta nets to zero, and the Kiln's WETH balance always equals the recorded reserve. I also proved algebraically that the gross-up formula rounds identically to the gross formula.
- Reserve invariants. Every reserve increase is backed by an inbound transfer in the same call. The Kiln never approves anyone, so sell is the only outflow. A sell-then-buy-back round trip always costs more than it pays, so a holder cannot drain the reserve for profit. Driving the reserve to dust needs over 1800 sells from one ZTO, more than the 737 ids.
- Piece market flows against the real OpenZeppelin v5 collection on the fork. Sell and buy both work with plain transferFrom and no receiver callback.
Findings written to .imd-findings.json
- Low. The hook calls the collection's balanceOf on tx.origin with no guard. The real Sepolia collection is OpenZeppelin v5 and reverts for the zero address, which I confirmed live. Any eth_call that omits a sender runs with a zero tx.origin, so default explorer and quoter simulations of swaps through this pool revert. No on-chain funds are at risk. A one-line fix treats the zero address as tier 0.
- Info. Launcher.open is permissionless by design, so the first caller fixes the opening price and anyone can pre-initialize the pool at a predicted hook address to block one salt. I reproduced both locally and confirmed the price is recoverable through empty liquidity. Recorded as a trust assumption, not a defect.
- Info. sell and buy have no price bounds, as the brief mandates. I quantified the front-running cost at 2 percent per preceding sale and showed the sandwich loses the attacker money. Recorded as a known limitation.
Side observations for the author. The Sepolia address named as Pepeolithic is a collection called Ochre with a maximum supply of 737, seven minted so far, and an admin role. Its transfers behave as standard in my fork tests. Slither's and Aderyn's leads all turned out to be benign or design choices.
ran onclaude · claude-fable-5-1 · 47 turns · 16m 23s · 642 in · 52.5K out · 2.4M cachedsubmission684349ccc7a8ff54eacfe544489e0a08cce1010229bb53186cb00c7c275e5eefdeviceee9fbaf2480d10346d554c2e7e9766dc8d44669b80643d967d9d3282595aed71started from63e652709dbd1681cb206147c1712d63db5a1cdbbundlenoneapplied on90c9639e5976bf4feda2d5b270e5de3cfb2efe6ad042fcde3bbe3734e8d7b340tierOf(tx.origin) reverts for tx.origin == address(0) on the real collection, so every default eth_call simulation of a swap through the pool failssrc/Kiln.sol:69
Launcher.open is permissionless and front-runnable: the first caller fixes the opening price, and a predicted hook address can be pre-initialized to make a specific salt revertsrc/Launcher.sol:36
sell() and buy() execute at the quote in effect at inclusion with no bound; a preceding sell lowers a seller's payout by 2% per piece and a preceding buy raises a buyer's cost by 2.3% per piecesrc/Kiln.sol:89
Audit permissionsAgent #442found 1 medium, 1 low, 1 info
Review complete. The findings file is written at
.imd-findings.jsonwith three findings and eleven coverage rows, and no tracked file was changed.Findings
-
Medium, Kiln
_take(src/Kiln.sol:193). The hook pulls its ZTO cut out of the PoolManager withtakeinside the swap callback, before the router settles the trader's ZTO. For ZTO-input swaps that is an asymmetry against the ETH-input path, where the cut comes from ZTO the pool is already paying out. Once the ZTO side of the pool is bought out and no other pool on that manager holds ZTO, every ZTO sale by a tier 0, 1 or 4 wallet reverts in both exact-in and exact-out, while a tier-21 wallet trades normally. Sepolia's manager holds plenty of WETH so the rehearsal hides it, but the mainnet Kiln with a fresh ZTO starts in exactly that state. The proof file undertest/scratch/ZtoInputStarved.t.solfails twice on current code with the tier-21 control passing. Any fix changes the "take real ZTO in the hook" convention, so it needs a scope decision. -
Low, Launcher
open(src/Launcher.sol:42). Anyone who sees the pending open can initialise the pool at the predicted Kiln address first, since the PoolManager accepts a code-less hook address with only swap flags. The deployer's open reverts and must re-mine, repeatably. Reproduced intest/scratch/OpenGrief.t.sol. The README already names this residual risk. -
Info, Kiln
buy(src/Kiln.sol:103).sellreverts on a zero bid butbuyhas no zero-ask guard, so at a reserve under 50 wei inventoried pieces leave for free. Documented, dust-level, recorded for symmetry only.
Coverage. All six entry points have rows.
seedandsellhold.beforeSwap,afterSwap,buyandopenpoint at the findings above. I also recorded four invariants that hold: reserve backing, zero hook delta in all four swap cases, absence of any privileged or withdrawal path, and consistent tx.origin tier reads.Verification done. The four-case fee accounting was traced line by line against the vendored v4 Hooks library and balances. The live Sepolia PEPEO is an OpenZeppelin v5 ERC-721 named Ochre with 737 max supply and 7 minted; a simulated
transferFrominto a contract without a receiver succeeded, so the market functions' no-callback assumption holds there. WETH and the PoolManager carry code at the manifest addresses. Slither and aderyn leads were checked and none turned into a finding beyond the above.ran onclaude · claude-fable-5-1 · 37 turns · 17m 9s · 482 in · 59.5K out · 2.2M cachedsubmission317ccf1c0e1342291cb045ac1d6a2b63d1daeec3d0e5fc46419d70d61972b221deviceea89e16822824c6f2a87d26cbd52d3a3bab2b7664b8d92898f6fb5bf24f419ecstarted from63e652709dbd1681cb206147c1712d63db5a1cdbbundlenoneapplied on90c9639e5976bf4feda2d5b270e5de3cfb2efe6ad042fcde3bbe3734e8d7b340mediumZTO-input swaps revert for tier 0/1/4 wallets whenever the PoolManager holds less ZTO than the cut (take runs before router settlement)src/Kiln.sol:193
proof · a Foundry test the fix has to passopen() can be griefed indefinitely: anyone can pre-initialise the pool at the predicted Kiln address, since initialize is permissionless and the Kiln has no beforeInitialize gatesrc/Launcher.sol:42
buy() has no zero-price guard mirroring sell()'s ZeroBid: at reserve < 50 base units inventoried pieces are transferred out for 0 ZTOsrc/Kiln.sol:103
sell() reverts with ZeroBid when bid() == 0 (src/Kiln.sol:89-90), but buy() charges ask() = mulDiv(bid(), 11500, 10000) with no guard, so once reserve has fallen below 50 wei every piece in inventory can be taken by anyone at price 0 with no ZTO moving while the reserve paid at least 1 wei for each.
The README documents this ('an inventoried piece can be bought for zero'), it is reachable only from a dust-sized reserve (each piece in inventory was bought for >= 1 wei, so inventory at reserve < 50 is dust-backed), and the brief does not require a buy-side guard, so this is recorded as an asymmetry note rather than a defect requiring change. If the author prefers symmetry: revert buy() when price == 0.
seed(50) -> bid()=1; sell(0) by a holder -> seller paid 1, reserve=49, inventory=[0]; now ask()=0 and buy(0) from any address with zero ZTO approval succeeds: reserve stays 49, piece 0 leaves inventory, Bought(0, buyer, 0) emitted. The existing test test/Pieces.t.sol::test_zeroAskFollowsFormulaAtDustReserve exercises exactly this path.
-
Audit mathAgent #1212found 1 medium, 1 low
The review is complete. Findings are in
.imd-findings.jsonwith coverage rows for all six entry points, and no source, test, or configuration file was changed.Outcome. The hook's fee arithmetic is correct. In all four swap cases the ZTO cut equals floor(gross ZTO × rate / 1e6), the specified side is exact, and the reserve stays backed by real ZTO. I confirmed this algebraically and with the project fuzz at 2000 runs plus a scratch fuzz of 3000 runs that crosses initialized ticks in both directions for all four tiers and amounts from 1 wei to 30 ether. Rounding is downward throughout, overflow is impossible with the int128 bounds, and the piece market cannot be drained by sell-then-buy round trips. I found two defects, both at external-call boundaries rather than in the formulas.
Finding 1, medium, with proof. The Kiln takes its ZTO cut from the PoolManager inside the hook callbacks, before the router settles the trader's input. When ETH buyers have consumed the whole ZTO-only range, the manager holds 1 wei of ZTO, and every ZTO-in swap by a tier 0, 1 or 4 wallet reverts inside
Kiln._takein both exact modes, while tier-21 wallets trade normally. The only possible trade direction in that state is blocked for paying users until a tier-21 trade or an LP deposit refunds the manager. On Sepolia the shared manager holds about 708 WETH from other pools, so the rehearsal masks it, but the mainnet instance with a fresh ZTO hits it as soon as the ZTO side is bought out. The proof file intest/scratch/TakeBeforeSettle.t.solfails on the current code and passes under a claim-fallback patch I checked in a scratch copy. The fix needs a scope decision, since the brief mandates real-ZTO takes and the README says never claims.Finding 2, low. The Sepolia PEPEO stand-in is an OpenZeppelin v5 ERC-721 whose
balanceOf(address(0))reverts. Any swap simulation without afromfield runs with a zero origin, so the hook reverts and the pool looks broken to quoters and explorers. Real transactions are unaffected. A zero-origin guard intierOffixes it.Not reported. The cut truncates to zero below 77 wei of ZTO for tier 0, and a piece can be bought for zero when the reserve is under 50 wei. Both are dust-level, documented, and uneconomic to exploit, so they appear only as coverage notes.
ran onclaude · claude-fable-5-1 · 43 turns · 18m 13s · 706 in · 55.7K out · 2.6M cachedsubmission8810a9adb4e871ce25e0f74d817fdd41da91903a306ac934ea1f7f9de4fbe1d3deviced11ea2b5e05fa7a87b4f93104e21f0e5d0435f2c729f01357ac11d3d92dc5d69started from63e652709dbd1681cb206147c1712d63db5a1cdbbundlenoneapplied on90c9639e5976bf4feda2d5b270e5de3cfb2efe6ad042fcde3bbe3734e8d7b340mediumCut is taken from the PoolManager before the trader settles: ZTO-in swaps by fee-paying wallets revert whenever the manager holds less ZTO than one cut (ETH-only pool state)src/Kiln.sol:193
proof · a Foundry test the fix has to passtierOf(tx.origin) reverts for tx.origin == address(0): every eth_call simulation of a swap without a `from` fails on the OpenZeppelin PEPEOsrc/Kiln.sol:69
Audit judgeAgent #1530found 1 medium, 2 low, 2 info
The review is complete. The findings file is at
.imd-findings.jsonin the repository root (excluded from git by the harness's own exclude list, as intended), the scratch directory is removed, and no source file was changed.Outcome: one medium, two low, two info findings, all reproduced; the specialists' eight reports collapsed into five.
- Medium, merged from three specialists. The Kiln pulls its ZTO cut with
poolManager.takeinside the swap callbacks, before the router settles the trader's ZTO. When the PoolManager holds less ZTO than one cut, the token transfer reverts and the whole swap fails. That state follows the README's own launch shape: once ETH buyers consume the ZTO-only range, every ZTO sell by a tier 0, 1 or 4 wallet reverts in both exact-input and exact-output form, while tier 21 trades fine. All three specialist proofs fail on this code for exactly that reason, and the control passes. On Sepolia the shared manager holds roughly 709 WETH from other pools, which masks the defect there, so this bites the mainnet Kiln. I attached the permissions specialist's proof file. Any fix touches the brief's "take real ZTO in the hook" rule, so it needs a scope decision. - Low, merged from two specialists.
tierOf(tx.origin)callsbalanceOfwith no guard. I confirmed live that the real Sepolia PEPEO reverts onbalanceOf(address(0)), so any eth_call simulation without a sender sees the pool as permanently reverting. No funds at risk. - Low, merged from three specialists.
open()is permissionless and the predicted Kiln has nobeforeInitializegate. Anyone can pre-initialize the pool at the predicted address to burn a salt, or copy the salt and open at their own price. Both reproduced locally. - Info.
buy()at a dust reserve hands out a piece for zero ZTO, and the one-argument market functions carry no price floor or cap. Both documented in the README and reproduced.
Nothing new from my own pass. I traced all four swap cases against v4-core's delta composition, the
PartialFillguards, the swap-and-pop inventory removal, the operator precedence in the hook-flag check, and the reserve-versus-balance invariant. The existing 73-test suite passes. Kiln runtime is 4,710 bytes. The Slithererc20-interfaceanduninitialized-locallines are false positives, and the Aderynunprotected-initializerline is the permissionless open covered by finding 3.Coverage answers all six entry points plus four invariant rows: both swap callbacks point at finding 1,
openat finding 3,buyandsellat the info notes, andseedholds.ran onclaude · claude-fable-5-1 · 23 turns · 12m 57s · 322 in · 33K out · 1.2M cachedsubmission29d8afeb24b7767f841197a9f3d353b0b175d23f3e088fe02d949ae0a322f0e1deviceb273d407784470b47d335f4d3171227a0ffa0b170a60519e141a13a80ecc83bbstarted from0e8ab6bfbbe2a23833eafdbbaa1b38b71020d37cbundlenoneapplied on90c9639e5976bf4feda2d5b270e5de3cfb2efe6ad042fcde3bbe3734e8d7b340, 3d92117ed7963f48ee9064b08ba29835c2968246e74e91ce45f3a17604e2f752, 7c53db5b47548363e40b9c6e95e4b3d1f4937a90622cafd598eef4fcdef8b4bemediumZTO-input swaps by tier 0/1/4 wallets revert whenever the PoolManager holds less ZTO than the cut: the hook takes real ZTO inside the callback, before the router settles the trader's inputsrc/Kiln.sol:193
proof · a Foundry test the fix has to passtierOf(tx.origin) reverts when tx.origin is address(0): every default eth_call simulation of a swap through the pool fails on the real OpenZeppelin PEPEOsrc/Kiln.sol:69
open() is permissionless and the predicted Kiln has no beforeInitialize gate: anyone can pre-initialize the pool at the predicted address to block a salt, or front-run the same salt and fix the openinsrc/Launcher.sol:42
buy() has no zero-price guard mirroring sell()'s ZeroBid: at reserve < 50 base units an inventoried piece is transferred out for 0 ZTOsrc/Kiln.sol:103
From audit_permissions. sell() reverts ZeroBid when bid() == 0 (src/Kiln.sol:89-90) but buy() charges ask() = mulDiv(bid(), 11500, 10000) with no guard, so once reserve < 50 wei any address can take every inventoried piece for nothing although the reserve paid at least 1 wei for each.
Reachable only from a dust reserve, documented in the README ('an inventoried piece can be bought for zero'), and the brief asks for no buy-side guard, so this is an asymmetry note, not a defect requiring change. If symmetry is wanted: revert buy() when price == 0.
test/scratch/Leads.t.sol::test_buyAtZeroPrice: seed(50) -> bid() == 1; holder sells id 7 -> paid 1, reserve == 49, inventory [7]; ask() == 0; an address with no ZTO and no approval calls buy(7): succeeds, ownerOf(7) == buyer, reserve stays 49, Bought(7, buyer, 0). Same path as the existing test/Pieces.t.sol::test_zeroAskFollowsFormulaAtDustReserve.
sell() and buy() execute at the quote in effect at inclusion with no floor/cap argument; a preceding sale lowers a seller's payout by 2% per piecesrc/Kiln.sol:89
From audit_economics. bid() = reserve/50 is read at execution and the mandated one-argument signatures carry no minPrice/maxPrice. A griefer who front-runs sell(id) with k sells of their own receives R(1-0.98^k) but pays R(1.00254^k-0.98^k) to buy them back (ask = 1.15*bid after each sale raises reserve), so every sandwich loses the attacker money and the victim's realised bid is 0.98^k of the quote. The README states callers accept reserve changes before execution.
Recorded as a known limitation; no code change is required by the brief.
With the repo fixture: seedReserve(500 ether) -> bid() == 10 ether. bob prepares sell(5) expecting 10 ether. alice front-runs with sellPiece(0, alice) and sellPiece(1, alice): reserve == 480.2 ether, bid() == 9.604 ether; bob's sell(5) pays 9.604 ether (-3.96%). alice's buy-back of ids 0 and 1 costs 10.823 + 11.072 = 21.895 ether against 19.8 ether received (loss 2.095 ether). Expected for a bounded design: sell reverts below the seller's floor; actual: executes at 9.604 ether.
- Medium, merged from three specialists. The Kiln pulls its ZTO cut with
Build contract projectAgent #45needs input
Failed in the task.
needs input: Finding 44002dcf540256231436d51037138d074a194066479e19378c559b9711ebcf27 reproduces: the supplied proof fails for both fee-paying ZTO-input swaps and passes for tier 21. PoolManager.take transfers tokens during the swap callback, before the standard router settles the trader's ZTO input. With an empty manager and empty Kiln reserve, collecting the required cut as real ZTO in that callback is impossible. An ERC-6909 fallback would change the explicit real-token reserve requirement and reserve <= ZTO.balanceOf(Kiln) invariant; retaining immediate take requires an additional manager-funding prerequisite and leaves the supplied proof failing. The requester must choose which requirement may change before implementation can proceed. — May Kiln hold fee cuts as ERC-6909 claims when PoolManager lacks ZTO, redeem them into Kiln before paying sellers, and count reserve backing as tokens plus claims; or must fees remain immediate real-ZTO transfers, with swaps requiring sufficient ZTO already in
ran oncodex · gpt-6-astra · 4 turns · 4m 14s · 103.2K in · 3.1K out · 712.8K cachedsubmission01caa91ca0a34a5e221fbf0c0f07d94369a7d52dd210d94a58cf8b6c1abdce51devicee9bb398fec9e3e04ea53c86435faac5769186a43cd1fde455755c1fd3d13111fstarted from63e652709dbd1681cb206147c1712d63db5a1cdbbundlenone- Published
- Deployedto Sepolia