{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"9f40cf8c-caa2-4b18-bceb-5f5ad428686e","kind":"audit","nodes":[{"acceptedSubmissionHash":"d93360c9683260efbb8198d39c6dc36ca9dde0da2c15de6332e4afbb9474cdc2","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"87b1bedea15c51879a98f6c4de3233003681544e6f897ff845915c8b72dece3c","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"7d2e3092266c288876704b3dbe2d915ab2788912ccbc0a2f55be9779b9d6bbe8","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"b453474cbe522569bd2e03dd1b2c2b5566ece734419ceea64bd982509494782d","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"a2532832d32beaa8d7679b0f9a793c43f105e86bba50ee626c63b5467e041250","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Independently audit the permanent CLAUS token/hook protection candidate at candidates/phase1/contract. It is NOT live. The repository's unrelated root network contracts are not this review's target.\n\nEntrypoints: src/ProtectedToken.sol, src/ProtectedHook.sol, src/PhaseOneFinalizer.sol and the shared src/ClausFeeBudget.sol. Inspect every inherited path in src/legacy, in particular FeeEfficientHook, FeeNftStakingHook, FeeLpRewardsHook, FeePrivacyHook, FeePredictionHook and the two exact-address delegate processors. Build from candidates/phase1/contract with Solidity 0.8.26, optimizer 200, via IR, Cancun; exact dependency files are included in lib. candidates/phase1/manifest.json records code-template and source hashes. Local tests are evidence, not your conclusion. Review actual source and reachable flows.\n\nThe owner wants an irreversible 15% aggregate trading tax ceiling, with no privileged wallet/pool blacklist or arbitrary buy/sell/transfer freeze and no theft/mint/confiscation of holder assets. Existing token/pool addresses, NFT backing/redemption, LP ownership/exits and accrued rewards must survive. The starting main-pool fee remains 2.3%, with extra fee zero; future global extra fees are bounded to 12.6%, leaving 0.1% for v4 protocol fees. Check BOTH buy/sell exact-input/exact-output, partial-fill behavior, tiny-rounding, fee stacking and protocol fee accounting. Is the combined basis and actual fee ceiling mathematically correct? Can any owner setting create a honeypot or selectively block trading? Flag unsafe fee invariants or arithmetic, and any fee accrual/event discrepancy.\n\nProtectedToken retains the exact previous ERC20, ownership and metadata namespaces. The existing token proxy is 0x1b54e762aa34cf6e28e9c082f2848e28e45da6b8, hook 0x37bfb8ac7c960e558657871d41ca70e07e7dbfff, PoolManager 0x000000000004444c5dc75cb358380d2e3de08a90. Check upgrade compatibility, initializer/mint/alternate slot writes, allowance/burn semantics and no hidden restriction left after finalization.\n\nThe original ProxyAdmins are token 0xfb4b9d2ba3ef17787bfe3fd90c3ea7b319807a57 and hook 0x1f22e4b065b2f15fed5be556dc0fa636048d29bc. The finalizer temporarily owns both, pins new code, upgrades both atomically and renounces both owners. Pre-completion cancellation should recover either admin, including a mistakenly late transfer after cancellation. Find rollback, reentrancy, deployment-argument, authority or bypass problems. The retained token metadata owner can set the bounded global extra fee and a directory of independent optional modules; those modules are never invoked on the mandatory trade path and cannot delegatewrite the core. Unlimited replacement of the core is intentionally not offered.\n\nThe hook has no arbitrary fallback delegate. The two legacy delegate targets are fixed by address AND runtime hash in its constructor. Analyze ALL their reachable actions and storage writes, custody, LP/add-remove deltas, arbitrary destinations and indirect upgrade paths. Optional prediction observation is gas-bounded and catches errors. Mandatory NFT/LP reward accounting must preserve obligations; consider reentrancy, dependent contracts, zero stakers, and malicious caller influence. Existing timed buyback state is retained until its original end, after which ordinary processing resumes. No new timed activation is allowed.\n\nFork tests at Ethereum block 26197295 run only against local chain ID31337 and cover both buy/sell modes at base/max rates, NFT unstake/redemption, LP stake/full exit, independent pool trade/exit, module failure isolation, admin cancellation, and burns before/after the old window. Test setup injects test-user funds; it is not a live transaction. Review missing adversarial scenarios and bugs; do not call tests an audit. The 15% ceiling excludes gas, market price impact and fees in independently created third-party pools. Privately owned LP remains withdrawable by its owner. This does not promise universal economic safety.\n\nReturn concrete findings with affected code/path, exploit preconditions, severity, and required fixes. Be explicit about limits to your review. This is a permanent core change, so liveness, custody and upgrade bypass findings are release-blocking. Do not write or publish changes, deploy contracts, accept terms, send social posts or spend funds.\n","parentJobId":null,"planHash":"d392cd83a7dedb19b992c1b3adc8dc42ed26474df9f86f2c57b5bd61bbc22d60","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"9f40cf8c-caa2-4b18-bceb-5f5ad428686e","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51885","feedbackHash":"ee9fead65c068a4af2361c062576d849be64ed073f2893c6ab71e9e901f2347e","nodeKey":"audit_economics","submissionHash":"d93360c9683260efbb8198d39c6dc36ca9dde0da2c15de6332e4afbb9474cdc2","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51331","feedbackHash":"7f678c5109741af68f1e2d5fdb590a9ea900ca27e44ca566213309c9a0b5a60a","nodeKey":"audit_flow","submissionHash":"87b1bedea15c51879a98f6c4de3233003681544e6f897ff845915c8b72dece3c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52290","feedbackHash":"c78c9ae0ac672d2d8175da056b6b8bde6d6ed6cd13b79db1d0afcff20a6ff2b3","nodeKey":"audit_judge","submissionHash":"7d2e3092266c288876704b3dbe2d915ab2788912ccbc0a2f55be9779b9d6bbe8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51884","feedbackHash":"5bf1bd6d48054bd8a7465cc2252d7f89bcc368634769e0bdf0a5b68ecc2275fd","nodeKey":"audit_math","submissionHash":"b453474cbe522569bd2e03dd1b2c2b5566ece734419ceea64bd982509494782d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51872","feedbackHash":"b6d25c754bada9610035d6e045751e945d72e89ae7d7dd05bcc4f2eb4a7abae4","nodeKey":"audit_permissions","submissionHash":"a2532832d32beaa8d7679b0f9a793c43f105e86bba50ee626c63b5467e041250","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"2394fdedad90acb8e36c3597daab999481befe92ff1bb4ec69c71a6579ccb604","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"60c045240bb09c33","findings":[{"citation":"resolved","description":"The finalizer is the single irreversible gate of the phase-one upgrade. Its constructor only checks that token_ and hook_ differ, have code, and that both ProxyAdmins are owned by owner_. It never binds token_ to the pinned CLAUS proxy 0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8 or hook_ to the pinned hook proxy 0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf, even though ProtectedToken.hook and ProtectedHook's private TOKEN/HOOK constants already pin exactly those addresses. finalize() (lines 42-46) verifies protectionVersion/protectionKind/maximumTotalTaxPips only through the proxy addresses it was given, so with (token_=hook proxy, hook_=token proxy, tokenAdmin_=hook ProxyAdmin, hookAdmin_=token ProxyAdmin) every check passes: the hook proxy runs ProtectedToken and answers CLAUS_TOKEN, the CLAUS proxy runs ProtectedHook and answers CLAUS_HOOK, and both ProxyAdmins are renounced in the same transaction. Afterwards the token proxy exposes no transfer/balanceOf (every CLAUS balance, allowance and the 50,000-CLAUS-per-NFT backing become unreachable), the hook address no longer implements IHooks so every main-pool swap reverts, and no admin remains to roll back. Note the asymmetry: swapping tokenImpl_/hookImpl_ IS caught (the token proxy would answer CLAUS_HOOK), so the finalizer evidently intends to self-check argument errors but misses the proxy/admin pair. Precondition: an argument-order mistake by the deploying owner, no attacker. Because the result is permanent loss of custody and liveness, this is release-blocking. Fix: in the constructor require token_==0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8 and hook_==0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf (and tokenAdmin_/hookAdmin_ equal to the two known ProxyAdmins), and additionally require ProtectedToken(tokenImpl_).protectionKind()==keccak256(\"CLAUS_TOKEN\") and IProtectedCore(hookImpl_).protectionKind()==keccak256(\"CLAUS_HOOK\") on the raw implementations before any upgrade. Keep the post-upgrade checks. Merged from audit_economics (0b0933d0...); the attached proof is the specialist's and was run by me: it fails on the current code because finalize() succeeds.","line":31,"path":"candidates/phase1/contract/src/PhaseOneFinalizer.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PhaseOneFinalizer} from \"src/PhaseOneFinalizer.sol\";\nimport {ProtectedToken} from \"src/ProtectedToken.sol\";\nimport {ProxyAdmin} from \"@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol\";\nimport {TransparentUpgradeableProxy} from \"@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol\";\n\n/// Placeholder for whatever implementation the two live proxies currently run.\ncontract OldImplementation {\n    function protectionVersion() external pure returns (uint256) { return 0; }\n    function init() external {}\n}\n\n/// Stand-in for the ProtectedHook implementation (the real one can only be\n/// constructed against mainnet code hashes). It answers the finalizer's\n/// post-upgrade self-checks exactly like ProtectedHook does.\ncontract HookStandIn {\n    function protectionVersion() external pure returns (uint256) { return 1; }\n    function protectionKind() external pure returns (bytes32) { return keccak256(\"CLAUS_HOOK\"); }\n    function maximumTotalTaxPips() external pure returns (uint24) { return 150_000; }\n}\n\n/// The upgrade owner, driving the finalizer with the token and hook arguments swapped.\ncontract Operator {\n    function run(address tokenProxy, address hookProxy, ProxyAdmin tokenAdmin, ProxyAdmin hookAdmin,\n        address tokenImpl, address hookImpl) external\n    {\n        // token_ and hook_ swapped, and the admins swapped to match them.\n        PhaseOneFinalizer f = new PhaseOneFinalizer(address(this), hookProxy, tokenProxy, hookAdmin, tokenAdmin, tokenImpl, hookImpl);\n        tokenAdmin.transferOwnership(address(f));\n        hookAdmin.transferOwnership(address(f));\n        f.finalize();\n    }\n}\n\ncontract FinalizerSwappedArgsTest is Test {\n    address constant TOKEN = 0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8;\n    address constant HOOK = 0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf;\n    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;\n    bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;\n\n    Operator operator;\n    ProxyAdmin tokenAdmin;\n    ProxyAdmin hookAdmin;\n    ProtectedToken tokenImpl;\n    HookStandIn hookImpl;\n\n    function setUp() public {\n        operator = new Operator();\n        OldImplementation old = new OldImplementation();\n        bytes memory init = abi.encodeCall(OldImplementation.init, ());\n        TransparentUpgradeableProxy t = new TransparentUpgradeableProxy(address(old), address(operator), init);\n        TransparentUpgradeableProxy h = new TransparentUpgradeableProxy(address(old), address(operator), init);\n        _place(address(t), TOKEN);\n        _place(address(h), HOOK);\n        tokenAdmin = ProxyAdmin(address(uint160(uint256(vm.load(TOKEN, ADMIN_SLOT)))));\n        hookAdmin = ProxyAdmin(address(uint160(uint256(vm.load(HOOK, ADMIN_SLOT)))));\n        assertEq(tokenAdmin.owner(), address(operator));\n        assertEq(hookAdmin.owner(), address(operator));\n        tokenImpl = new ProtectedToken();\n        hookImpl = new HookStandIn();\n    }\n\n    /// Move a freshly deployed transparent proxy (code with its immutable admin, plus\n    /// the ERC-1967 implementation and admin slots) to the pinned mainnet address.\n    function _place(address from, address to) internal {\n        vm.etch(to, from.code);\n        vm.store(to, IMPL_SLOT, vm.load(from, IMPL_SLOT));\n        vm.store(to, ADMIN_SLOT, vm.load(from, ADMIN_SLOT));\n    }\n\n    /// With token_/hook_ (and their admins) swapped, every finalizer check still passes:\n    /// the ERC20 code is installed on the hook proxy, the hook code on the token proxy,\n    /// and both ProxyAdmins are renounced, so the mistake is permanent.\n    /// Expected: construction or finalize() reverts. Actual: the run completes.\n    function testSwappedTokenAndHookArgumentsMustNotFinalize() public {\n        vm.expectRevert();\n        operator.run(TOKEN, HOOK, tokenAdmin, hookAdmin, address(tokenImpl), address(hookImpl));\n    }\n}","reproduction":"Local, no fork: forge test --match-path test/scratch/Proof_0b0933d0b0c9.t.sol. The test deploys two OZ TransparentUpgradeableProxy instances owned by one operator, etches them (code + ERC-1967 implementation/admin slots) to 0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8 and 0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf, deploys ProtectedToken and a hook stand-in answering protectionVersion()=1, protectionKind()=keccak256(\"CLAUS_HOOK\"), maximumTotalTaxPips()=150000, then constructs PhaseOneFinalizer(owner, token_=0x37Bf..., hook_=0x1b54..., tokenAdmin_=admin of 0x37Bf..., hookAdmin_=admin of 0x1b54..., tokenImpl, hookImpl), transfers both ProxyAdmins and calls finalize(). Expected: construction or finalize() reverts InvalidFinalization. Actual (observed): '[FAIL: next call did not revert as expected] testSwappedTokenAndHookArgumentsMustNotFinalize()' - finalize() completes, 0x1b54... now runs the hook code, 0x37Bf... runs the ERC20 code, both ProxyAdmins report owner()==address(0).","severity":"high","snippet":"        if(owner_==address(0) || token_==hook_ || address(tokenAdmin_)==address(hookAdmin_)","title":"PhaseOneFinalizer accepts swapped token/hook proxy arguments and irreversibly installs the ERC20 code on the hook proxy and the hook code on the token proxy"},{"citation":"resolved","description":"The constructor hard-pins the PoolManager, the LiquidityReserve's token/hook, and the two delegatecall targets by address and runtime hash, but ProjectRevenue (receives 1.3% base wallet share plus the entire 0..12.6% extra), PlatformRevenue (0.3%), ClausNFT, ClausNftStaking (recordReward() is called on every swap carrying an NFT share), the IClausLpRewards vault (recordReward() on every swap with LP stakers), MarketCapOracle, ClausPrivacyBuy, the weather attester/questionHash and tick spacing are only checked relationally against each other (FeeEfficientHook.sol:114 checks project_.poolManager()==manager; FeeNftStakingHook.sol:25-30 checks staking_.collection()==base.nft, staking_.projectRevenue()==base.project, staking_.hook()==HOOK, epochStart/EPOCH; FeeLpRewardsHook.sol:66-73 likewise). A consistent substitute set passes all of them. PhaseOneFinalizer.finalize() (PhaseOneFinalizer.sol:44-46) then checks only protectionVersion/protectionKind/maximumTotalTaxPips, which any build of this source returns, and renounces both ProxyAdmins. Consequences of sealing a wrong candidate: (a) a substitute ClausNftStaking (or lpRewards/PlatformRevenue) whose recordReward()/recordAllocation() reverts conditionally turns afterSwap into a permanent, un-upgradeable, selectively blocking honeypot, exactly what the brief says must be impossible after finalization, and the real vault 0x4e5916428452891E1deb59d162c20266ED08CAD4 reports stakingOpen()==false forever (its check hook.nftStaking()==address(this) fails), so no new NFT stakes or rewards; (b) a substitute ProjectRevenue/PlatformRevenue (together with substitute staking and LP vaults, which is required because those check projectRevenue relationally) diverts 1.3%+extra and 0.3% of every trade permanently. Nothing already accrued is lost. This requires the finalizing owner to pin a mis-built implementation (deployer error or compromised deployment pipeline), so it is a verification gap in the one irreversible step rather than a runtime bypass; the README itself says immutable-populated code 'must be checked separately', i.e. the check is manual, and the live destination addresses are recorded nowhere in the candidate. Fix: pin the live destinations as constants in the ProtectedHook constructor the same way the processors are pinned (ProjectRevenue 0x49A91293a8dFf24d5c54b798C45376301D29b9f3, PlatformRevenue 0xF1749E78e76CfE1119206833aE93beD2dede5353, ClausNFT 0x80396c7131159eB92E7E84839e51C9887A7A95d9, ClausNftStaking 0x4e5916428452891E1deb59d162c20266ED08CAD4, lpRewards 0xB2304d3DE407398de441b147e2c64c7c48DAf44E, plus oracle, privacyBuy, feed, fomo, attester/questionHash, spacing==1; these are the values the live hook returns at block 26197295), and/or make PhaseOneFinalizer.finalize() read projectRevenue(), platformRevenue(), nft(), nftStaking(), lpRewards(), liquidityReserve(), processor(), lpRemainderProcessor(), predictionOracle() through the hook proxy before and after the upgrade and require equality before renouncing; record the values in manifest.json. Merged from audit_economics (e26d072a), audit_permissions (ac30b710) and audit_flow (bfcb8fba).","line":52,"path":"candidates/phase1/contract/src/ProtectedHook.sol","reproduction":"Executed on a local anvil fork of Ethereum at block 26197295 (chain id 31337), test/scratch/Judge.t.sol::testSubstitutedVaultFinalizesAndSelectivelyBlocksTrading (passes = behaviour confirmed): deploy FakeStaking with collection()=old.nft(), poolManager()=PM, projectRevenue()=old.projectRevenue(), hook()=0x37Bf...bfFf, epochStart()=startsAt()=nft.epochStart(), EPOCH()=1 days, totalStaked()=1 and recordReward(uint256) that reverts('not for you') unless tx.origin==0xF00D. Build BaseConfig from the live hook getters exactly as test/PhaseOneFork.t.sol:62 does and call new ProtectedHook(cfg, old.privacyBuy(), old.lpRewards(), old.lpRemainderProcessor(), fake): constructor succeeds. new PhaseOneFinalizer(OWNER, T, H, TA, HA, new ProtectedToken(), rogue); OWNER transfers both ProxyAdmins and calls finalize(): succeeds, TA.owner()==HA.owner()==address(0), ProtectedHook(H).nftStaking()==fake, ClausNftStaking(0x4e59...CAD4).stakingOpen()==false. An exact-input buy of 0.01 ether by ALICE (tx.origin ALICE) through the Universal Router 0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85 reverts with 'not for you' from FeeNftStakingHook._accrue -> nftStaking.recordReward; the same buy with tx.origin 0xF00D succeeds. Expected: constructor or finalize() rejects a non-canonical vault. Variant (testSubstitutedProjectRevenueDivertsFees): with cfg.project = new ProjectRevenue(PM, 0xBAD) and the live lpRewards vault the constructor reverts only because lpRewards_.projectRevenue()!=base.project; substituting a minimal IClausLpRewards stand-in as well (as audit_permissions did) passes and routes the wallet share to the substitute.","severity":"medium","snippet":"        if (address(base.manager) != address(MANAGER) || base.reserve.token() != TOKEN || base.reserve.hook() != HOOK\n            || address(base.processor) != 0x271f808cb55C7Cbbb3d4df0DA075deF9AE5F7340\n            || address(base.processor).codehash != 0x5edefdcfd727df0398f04ec40b22722bc48db21d7c3de6323de65551b7bed2c5","title":"ProtectedHook pins only the two delegate processors; every fee destination and reward vault is a free constructor argument that PhaseOneFinalizer never verifies, so a mis-built candidate is sealed per"},{"citation":"resolved","description":"The aggregate ceiling is enforced wei-exactly: the right side floors gross*0.15, the hook fee floors gross*rate/1e6, and the v4 PoolManager rounds the protocol fee UP per swap step (SwapMath: feeAmount = amountRemaining - floor(amountRemaining*(1e6-pips)/1e6) for exact input; mulDivRoundingUp for exact output). At extraPips=126000 (rate 149000) the nominal slack is 0.1% of gross, so whenever gross*0.001 < the rounding error (up to 1 wei per component plus 1 wei per initialized tick crossed) the check trips and the whole swap reverts. Example: gross=101 -> fee=floor(101*0.149)=15, pool input 86, protocol=ceil(86*0.001)=1, 16 > floor(101*0.15)=15. The sell branch (line 335: protocol>input*150_000/1_000_000 || protocol==input, and the derived allowedPips) has the same shape for token inputs of a few atoms, where one atom of protocol fee exceeds 0.1% of input. At the base rate (extra 0) no amount reverts, and above roughly 1e4 wei of ETH no buy can revert, so there is no honeypot and the owner cannot target an address with it (a protocol fee must also be set by the Uniswap protocol fee controller). The ceiling math is otherwise correct for all sizes tested: max 14.9% hook + 0.1% of the 85.1% remaining input = 14.985% < 15% on buys, and the proportional pre-fee basis on sells. Reported because the brief asks for tiny-rounding and fee-stacking behaviour and because this is the only path on which the honest 2.3%+12.6% configuration plus the reserved 0.1% protocol fee rejects a trade; routers that sweep exact dust balances will see unexpected reverts. Fix (optional): evaluate the cap with rounding in the trader's favour, e.g. compare against FullMath.mulDivRoundingUp(gross,150_000,1_000_000)+1 on the buy side and compute allowedPips with mulDivRoundingUp (or skip the proportional check when input < 1_000_000/150_000 atoms, where no hook fee can be charged) on the sell side; otherwise document the dust behaviour. Merged from audit_economics (1be82f7f), audit_math (f2b3837e), audit_permissions (1efe68db, sell side line 335) and audit_flow (b4b62b6e).","line":330,"path":"candidates/phase1/contract/src/legacy/FeeEfficientHook.sol","reproduction":"Executed on a local anvil fork at block 26197295, test/scratch/Judge.t.sol::testDustBuyRevertsAtMaxFeeWithProtocolFee (passes = behaviour confirmed): seal the candidate; OWNER setExtraFeePips(126000); exact-input buy of 10 wei through the Universal Router succeeds while the protocol fee is 0. Then vm.prank(PM.protocolFeeController()); PM.setProtocolFee(main, 1000|(1000<<12)). Exact-input buy of 1 ether succeeds; exact-input buys of 10 wei and 101 wei revert (trace shows TotalTaxExceeded from afterSwap); exact-input buy of 100000 wei succeeds. Expected per README: any trade whose combined tax is at most 15% settles. Actual: 10 wei -> fee 1 + protocol 1 = 2 > floor(1.5) = 1 -> revert.","severity":"low","snippet":"            if(fee+protocol>gross*150_000/1_000_000) revert TotalTaxExceeded();","title":"At the maximum extra fee with a nonzero v4 protocol fee, dust-sized trades revert with TotalTaxExceeded because the ceiling floors 15% while the protocol charge rounds up"},{"citation":"resolved","description":"Reviewed the owner surface for honeypot or selective-block ability: setExtraFeePips is global, bounded to 126000 pips, read live in beforeSwap and afterSwap via ClausFeeBudget.config(), and the 15% aggregate cap (hook + actual protocol) is enforced per swap in _checkTotalTax; setModule only writes a registry that nothing on the trade path reads; updateMetadata is cosmetic; the token has no transfer hooks; the hook has no sender/pool checks beyond _requirePool. No selective blocking or confiscation path was found. The remaining power is the instantaneous rate change: the owner (or whoever holds the token owner key) can bundle [setExtraFeePips(126000), victim swap, setExtraFeePips(0)] or front-run a pending swap so that one trade pays 14.9% while neighbouring trades pay 2.3%; the victim's only defence is a tight minimum output. If the owner later calls renounceOwnership() on the token, IFeeGovernor(TOKEN).owner() becomes address(0) and the extra fee is frozen at its last value. All of this stays inside the commissioned design and the 15% ceiling, so it is documented as a trust assumption, not a defect. Optional hardening if the owner wants it: a two-step pending value with a fixed delay (e.g. >= 1 day) for increases, decreases immediate, same 126000 cap. Merged from audit_economics (67d7b699), audit_math (3734a789), audit_permissions (b6b8813a) and audit_flow (c0d49d00).","line":73,"path":"candidates/phase1/contract/src/ProtectedHook.sol","reproduction":"Fork at block 26197295 (project test testTradingAtBaseAndMaximumWithProtocolFee, re-run and passing): after seal, vm.prank(OWNER); live.setExtraFeePips(126000); then an exact-input buy of 0.005 ether in the same block credits PM.balanceOf(projectRevenue,0) by exactly 0.005e18*139000/1e6 (13.9%) instead of 0.005e18*13000/1e6, with no announcement or delay. Expected by a user who quoted 2.3%: fee 0.000115 ETH. Actual: 0.000745 ETH, trade succeeds if within the user's minimum output.","severity":"info","snippet":"    function setExtraFeePips(uint24 value) external {\n        if(msg.sender != IFeeGovernor(TOKEN).owner() || value > ClausFeeBudget.MAX_EXTRA) revert InvalidActivation();\n        ClausFeeBudget.config().extraPips=value; emit ExtraFeeChanged(value);","title":"Trust assumption: the token owner can move the global hook rate from 2.3% to 14.9% instantly with no delay, so one chosen trade can be sandwiched between two fee changes; renouncing ownership freezes "},{"citation":"resolved","description":"Historically NftFeeAccrued(gross, nftEth, epoch) meant nftEth was minted to the ClausNFT collection and recorded through NftRewards.recordReward (FeeNftHook.sol:56-63, FeeLpRewardsHook.sol:170-172). On the staking branch _accrue mints nftEth to ClausNftStaking once and emits both events with identical values. A consumer that totals NftFeeAccrued to reconcile the collection's epochPot over-counts by every post-staking accrual, and one that totals both events double-counts NFT rewards. On-chain accounting is correct: the claim is minted exactly once and recordReward is called once. All other allocation events reconcile against minted amounts (walletEth+burnEth+rewardEth+nftEth+platform == totalFee; FeeAllocation.projectEth == project+extra; PlatformFeeAllocation canonical+additional == platform; LiquidityFeeAccrued/FomoFeeAccrued(gross,0) are legacy-shaped but truthful). Fix: emit only NftStakingFeeAccrued on the staking branch, or document that NftFeeAccrued now denotes the vault destination. Merged from audit_economics (22a83a7a) and audit_flow (2f3527d2).","line":67,"path":"candidates/phase1/contract/src/legacy/privacy/FeeNftStakingHook.sol","reproduction":"Code path, confirmed by the project fork test testTradingAtBaseAndMaximumWithProtocolFee: an exact-input buy of 0.005 ether after seal increases vault.totalAccrued() by exactly 0.005e18*3500/1e6 = 17.5e12 once, while the same transaction's logs carry NftFeeAccrued(5e15, 17.5e12, epoch) and NftStakingFeeAccrued(5e15, 17.5e12, epoch) (lines 67-68 execute unconditionally together when nftEth != 0). Expected: one event per accrual, or documented semantics. Actual: two events for one mint.","severity":"info","snippet":"            emit NftFeeAccrued(gross, nftEth, epoch);\n            emit NftStakingFeeAccrued(gross, nftEth, epoch);","title":"NFT reward accrual emits both the historical NftFeeAccrued and the new NftStakingFeeAccrued for the same amount, so indexers that sum NftFeeAccrued over-count rewards that no longer reach the collecti"},{"citation":"resolved","description":"When block.timestamp < nftStakingStartsAt, accrual falls through to FeeLpRewardsHook._accrue (FeeLpRewardsHook.sol:161: platform = totalFee - project), so any extraPips surcharge would be minted to PlatformRevenue, contradicting WALLET_FEE_PIPS (18_500 + extraPips - ...), the comment 'Future globally configured extras accrue only to the fixed project vault' and the FeeAllocation event. On the target chain the branch is unreachable: the live vault's startsAt() is already in the past at block 26197295 (the project fork test's very first swap credits vault.totalAccrued by 3500 pips, which only the staking branch does), and nftStakingStartsAt is an immutable read from that vault, so no funds can be misrouted by the final core. Kept as a code-consistency note and a coverage limit (no test covers the branch). Fix: delete the fallback branch from the final core, or make FeeLpRewardsHook._accrue split extra into walletEth exactly as FeeNftStakingHook does. From audit_permissions (f6cd1142).","line":42,"path":"candidates/phase1/contract/src/legacy/privacy/FeeNftStakingHook.sol","reproduction":"Not reachable on the target chain. Hypothetically, with a staking vault whose startsAt() is in the future, setExtraFeePips(126000) then an exact-input buy of gross g: expected projectRevenue receives g*0.139 - burn - rewards - nft and platformRevenue receives g*0.003; actual (FeeLpRewardsHook.sol:161-178) platformRevenue receives g*0.129 and projectRevenue only the base share.","severity":"info","snippet":"        if (block.timestamp < nftStakingStartsAt) {\n            super._accrue(gross, totalFee);","title":"Dead pre-staking accrual branch would route the global extra fee to PlatformRevenue instead of the project vault"},{"citation":"resolved","description":"Scenarios absent from test/PhaseOneFork.t.sol (verified manually on the fork; none produced a new bug except as reported in the other findings): (1) name()/symbol()/decimals()/tokenURI() equality before and after seal - verified on the fork: 'Claus'/'CLAUS'/18 survive, so the literal METADATA_SLOT in ProtectedToken.sol:12 does match the live token's namespace, but the suite only snapshots balances/supply/owner and a wrong slot would have been permanent; (2) the retained timed-buyback schedule - verified: after seal timedBuybackState() returns startsAt 1792023599, endsAt 1792066799, active==true, availableEth 0.1297 ETH at fork timestamp 1792059851, but testBuybackBurnPreservedDuringAndAfterExistingWindow never asserts active/endsAt>now and would pass with endsAt==0 through the legacy processor path; (3) negative finalizer tests: swapped token_/hook_ proxies (finding 1) and swapped tokenImpl_/hookImpl_ (which does revert at the protectionKind check, proving atomic rollback) are untested; (4) negative ProtectedHook constructor tests showing substituted processor/remainder are rejected and, per finding 2, substituted revenue/reward destinations are NOT; (5) partial-fill paths: exact-output buy with a price limit (PartialTokenOutput), exact-in buy and exact-out sell hitting the limit (PartialNativeFill), exact-in sell partial fill which is allowed; (6) wei-level trades at max extra plus protocol fee (finding 3); (7) processLiquidityRemainder()/processLiquidity() and the unguarded legacy processBuybacks() after seal, and the timed path with nothing vested (BatchOutputTooLow); (8) owner renouncing token ownership and the resulting permanent freeze of setExtraFeePips/setModule; (9) ERC20 allowance semantics after upgrade (infinite allowance not decremented). Adding (1), (2) and (3) is cheap and directly guards the one-shot upgrade. Merged from audit_economics (6d67f939), audit_math (b371bd5b) and audit_flow (ccb3e3ff).","line":85,"path":"candidates/phase1/contract/test/PhaseOneFork.t.sol","reproduction":"Run the suite: 9 passing tests at block 26197295 on chain 31337 (re-run by me); grep shows no name()/symbol()/timedBuybackState active assertions. Verification evidence: test/scratch/Judge.t.sol::testMetadataAndTimedWindowSurviveSeal passes with logs name=Claus, symbol=CLAUS, startsAt=1792023599, endsAt=1792066799, now=1792059851, avail=129670502584680844. Expected: the suite itself asserts these before renouncing both ProxyAdmins. Actual: it does not.","severity":"info","snippet":"    function testAtomicSealPreservesCustodyAndRemovesBothUpgradeOwners() public {","title":"Fork suite omits assertions that directly protect the irreversible step (metadata survival, timed-window migration, swapped finalizer arguments, substituted destinations, partial fills, dust trades)"}],"hash":"7d2e3092266c288876704b3dbe2d915ab2788912ccbc0a2f55be9779b9d6bbe8","nodeId":"771c12f8-d39f-46f9-b30b-eccf45b2eee2","outcome":"completed","summary":"Review complete. The findings file at `.imd-findings.json` holds seven merged, reproduced findings. Nothing outside `test/scratch/` and the findings file was changed, and the local anvil fork is stopped.\n\n**What I reproduced**\n\n- **High: swapped proxy arguments seal the wrong code.** I ran the economics specialist's proof. It fails on the current code because finalize succeeds with the token and hook proxies swapped, leaving the ERC20 code on the hook proxy and both ProxyAdmins renounced. Swapping the implementations is already caught by the protectionKind check, so the finalizer plainly intends to self-check and misses this pair. The proof is attached to the finding.\n- **Medium: fee destinations are not pinned and the finalizer never verifies them.** Three specialists reported this. On a local fork at block 26197295 I built a substitute NFT staking vault that passes every relational constructor check, sealed it through the finalizer, and confirmed the result: an ordinary buy reverts with the vault's error while an allow-listed origin trades, and the real vault reports staking closed. Merged into one finding with the fee-diversion variant.\n- **Low: dust trades revert at the maximum rate once a protocol fee is set.** Four specialists reported this at lines 330 and 335. On the fork, after sealing and setting the extra fee to its cap plus a 0.1% protocol fee, buys of 10 and 101 wei revert with TotalTaxExceeded while 100000 wei and 1 ether succeed. Merged into one finding covering both sides.\n- **Info: instant extra-fee change.** Four specialists filed it. It reproduces trivially and is within the commissioned design, so it is recorded as a trust assumption.\n- **Info: double NFT accrual event, dead pre-staking branch, and test-coverage gaps.** All confirmed from code or the fork.\n\n**What I downgraded or dropped**\n\n- The metadata-slot and timed-window concerns were verification gaps, not defects. On the fork, name and symbol survive the seal, and the retained buyback window is active with a future end. Both were folded into the coverage finding with the verified values.\n- The permissionless buyback MEV note was dropped. The specialist said no change was needed and the behaviour is unchanged from the live hook.\n\n**Limits**\n\n- The live hook and token implementation sources are not in the candidate, so storage compatibility was checked only by behaviour on the fork.\n- Static analysis tools were not run. The prediction observation path was checked by reading: it is gas-bounded and wrapped in try/catch.","treeHash":null,"usage":{"cachedInputTokens":2716255,"inputTokens":770,"model":"claude-fable-5-1","outputTokens":31119,"runtime":"claude","turns":39,"wallClockMs":571027}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d2d5a117dd72f6b4","findings":[{"citation":"resolved","description":"The permanence story is: PhaseOneFinalizer pins the codehash of whatever ProtectedHook implementation the owner deployed, upgrades once and renounces both ProxyAdmins. The ProtectedHook constructor, however, only pins the PoolManager, the LiquidityReserve (transitively via the pinned FeeProcessor) and the two delegate processors by address and codehash. ProjectRevenue (receives 1.3% base wallet share plus the entire 0..12.6% extra), PlatformRevenue (0.3%, and recordAllocation() reverts with NotHook unless its stored hook equals the caller), ClausNFT, ClausNftStaking (recordReward() is called on every swap that carries an NFT share), the IClausLpRewards vault (recordReward() on every swap with LP stakers), MarketCapOracle, ClausPrivacyBuy, the weather attester/questionHash and the tick spacing are all free constructor inputs checked only for self-consistency (FeeEfficientHook.sol:114 checks project_.poolManager()==manager only; FeeNftStakingHook.sol:25-29 checks staking_.projectRevenue()==base.project, staking_.collection()==base.nft etc., i.e. a consistent substitute set passes). finalize() (PhaseOneFinalizer.sol:44-46) then checks only three pure getters (protectionVersion, protectionKind, maximumTotalTaxPips) before renouncing. Consequence: a build of this exact source with substituted immutables is indistinguishable to the finalizer and to anyone who only verifies the source hashes in manifest.json. With substitutes, (a) all project/extra fees are minted to a vault whose fixed recipient is not the existing project wallet (fee theft up to 13.9% of every buy, permanently), or (b) a substitute ClausNftStaking/lpRewards/PlatformRevenue whose recordReward()/recordAllocation() reverts conditionally (e.g. unless tx.origin is allow-listed) makes every ordinary swap revert in afterSwap -> a permanent, un-upgradeable, selectively blocking honeypot, which is exactly what the brief says must be impossible after finalization. The README acknowledges 'immutable-populated deployed code must be checked separately', but the candidate offers no on-chain anchor for these values and the live values (ProjectRevenue 0x49A91293a8dFf24d5c54b798C45376301D29b9f3, PlatformRevenue 0xF1749E78e76CfE1119206833aE93beD2dede5353, ClausNftStaking 0x4e5916428452891E1deb59d162c20266ED08CAD4, lpRewards 0xB2304d3DE407398de441b147e2c64c7c48DAf44E, ClausNFT 0x80396c7131159eB92E7E84839e51C9887A7A95d9, read from the live hook at block 26197295) are not recorded anywhere in the candidate. Because the finalization is irreversible, this is release-blocking as a verification gap rather than a code bug in the intended deployment.","line":52,"path":"candidates/phase1/contract/src/ProtectedHook.sol","reproduction":"EXECUTED on a local anvil fork of Ethereum at block 26197295 (chain id 31337): deploy FakeStaking with collection()=old.nft(), poolManager()=PM, projectRevenue()=old.projectRevenue(), hook()=0x37Bf...bfFf, epochStart()=nft.epochStart() (1791240095), EPOCH()=1 days, startsAt()=epochStart, totalStaked()=1 and recordReward(uint256) that reverts('not for you') unless tx.origin==0xF00D. Build BaseConfig from the live hook's getters exactly as test/PhaseOneFork.t.sol does and call new ProtectedHook(cfg, old.privacyBuy(), old.lpRewards(), old.lpRemainderProcessor(), ClausNftStaking(address(fake))): the constructor succeeds (ProtectedHook.sol:52-57 and FeeNftStakingHook.sol:25-30 all pass). new PhaseOneFinalizer(OWNER, T, H, TA, HA, new ProtectedToken(), thatHook); OWNER transfers both ProxyAdmins and calls finalize(): it succeeds, TA.owner()==HA.owner()==address(0), ProtectedHook(H).nftStaking()==fake. Then an exact-input buy of 0.01 ether by ALICE through the Universal Router (0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85) reverts with WrappedError(hook, afterSwap, Error('not for you')) from FeeNftStakingHook._accrue -> nftStaking.recordReward, while the same buy by 0xF00D succeeds. Expected: a finalized core cannot selectively block trading and the finalizer cannot complete with a non-canonical reward vault. Actual: permanent (un-upgradeable) selective trading block with one substituted constructor argument; the same mechanism with a substituted ProjectRevenue/PlatformRevenue diverts the 1.3%+extra and 0.3% shares. Required fix: pin, in the ProtectedHook constructor, the exact mainnet addresses (and codehash for non-proxy contracts) of ProjectRevenue 0x49A91293a8dFf24d5c54b798C45376301D29b9f3, PlatformRevenue 0xF1749E78e76CfE1119206833aE93beD2dede5353, ClausNFT 0x80396c7131159eB92E7E84839e51C9887A7A95d9, ClausNftStaking 0x4e5916428452891E1deb59d162c20266ED08CAD4, the lpRewards vault 0xB2304d3DE407398de441b147e2c64c7c48DAf44E, MarketCapOracle, ClausPrivacyBuy, attester/questionHash and spacing==1, the same way the two processors are pinned; and/or make PhaseOneFinalizer.finalize() assert those public getters on the upgraded hook proxy before renounceOwnership(); and record the values in candidates/phase1/manifest.json.","severity":"medium","snippet":"        if (address(base.manager) != address(MANAGER) || base.reserve.token() != TOKEN || base.reserve.hook() != HOOK","title":"ProtectedHook constructor pins only the two processors; every fee destination and mandatory trade-path dependency is a deployer-chosen immutable that the finalizer never verifies"},{"citation":"resolved","description":"setExtraFeePips() takes effect in the same block with no announcement delay. The bounded extra fee is an intended owner power (documented trust assumption, not a bypass), but the lack of a delay makes it exploitable against individual trades: a user who submits a buy with a typical 10-15% slippage tolerance can be sandwiched by the owner (or anyone who obtains the token owner key) with setExtraFeePips(126000) before and setExtraFeePips(0) after, extracting up to 12.6% of that trade into ProjectRevenue while all other users see 2.3%. This does not break the 15% ceiling and is not a blacklist, but it is a selective, targeted charge that the 'no selective trading harm' requirement arguably intends to exclude.","line":75,"path":"candidates/phase1/contract/src/ProtectedHook.sol","reproduction":"Fork at 26197295, seal. extraFeePips()==0. ALICE submits an exact-input buy of 1 ether with amountOutMinimum = 88% of the quoted output. In the same block, before ALICE's tx, OWNER calls live.setExtraFeePips(126000); after it, OWNER calls live.setExtraFeePips(0). Expected (by a user quoting at 2.3%): fee 0.023 ether. Actual: ProjectFeeAccrued emits fee 0.149 ether, ALICE's swap still succeeds (output within slippage), ProjectRevenue claim balance grows by 0.139 ether from this one trade, and the next block's traders are back at 2.3%. Required fix (keeps the owner power and the cap): two-phase setting, e.g. a pending extraPips plus an effective timestamp >= block.timestamp + 24h, with the live rate() reading the pending value only after it is effective; emit the pending value so routers/users can see it.","severity":"low","snippet":"        ClausFeeBudget.config().extraPips=value; emit ExtraFeeChanged(value);","title":"Owner can raise the extra fee from 0 to 12.6% instantly, so a pending user trade within its slippage tolerance can be front-run into a 14.9% charge"},{"citation":"resolved","description":"The ceiling check compares a floored 15% of gross against hook fee (floored) plus the pool's protocol fee, which v4 rounds UP per step (SwapMath). For gross below roughly 1000 wei the 0.1% headroom between 14.9% and 15% is less than 1 wei, so any up-rounded protocol fee pushes the sum over the floor and the swap reverts. The same happens for an exact-output buy of 1 token-wei (its ETH input is also wei-level). Economically meaningless (<1e-15 ETH), it does not affect sells at any tested size, and it cannot be triggered by the owner alone (needs the Uniswap protocol fee controller to set a protocol fee), so this is informational: the ceiling math itself (fee+protocol<=15% on buys; the proportional allowedPips basis on sells) is otherwise correct for all sizes I tested, including exact-in/exact-out both directions at base and max rate.","line":330,"path":"candidates/phase1/contract/src/legacy/FeeEfficientHook.sol","reproduction":"Fork at 26197295, seal, OWNER setExtraFeePips(126000), protocolFeeController setProtocolFee(main, 1000|(1000<<12)). Exact-input buys of 10, 50 and 101 wei through the Universal Router revert with WrappedError(hook, afterSwap, TotalTaxExceeded 0xe8f1a8aa); buys of 999, 1000, 10000 wei succeed; exact-output buy of 1 token-wei reverts the same way; exact-input sells of 100, 1e3, 1e6, 1e12, 1e18 token-wei and exact-output sell of 1 wei ETH all succeed. With protocol fee 0 the 10-wei buy succeeds. Example arithmetic: gross=101 -> fee=floor(101*0.149)=15, pool input 86, protocol=ceil(86*0.001)=1, 16 > floor(101*0.15)=15. Optional fix: allow one wei of rounding headroom (fee+protocol > cap+1) or compute the cap with mulDivRoundingUp; otherwise simply document that sub-1000-wei buys may revert.","severity":"info","snippet":"            if(fee+protocol>gross*150_000/1_000_000) revert TotalTaxExceeded();","title":"At the maximum extra fee with a v4 protocol fee set, wei-level buys revert with TotalTaxExceeded due to integer rounding"},{"citation":"resolved","description":"Accrual/event review: the per-swap sums reconcile (walletEth+burnEth+rewardEth+nftEth+platform == totalFee, FeeAllocation.projectEth == project+extra, PlatformFeeAllocation canonical+additional == platform), and LiquidityFeeAccrued/FomoFeeAccrued(gross,0) are legacy-shaped but truthful. The only discrepancy is that the same nftEth is logged twice under two names on every swap with NFT stakers; an off-chain accounting that sums all *FeeAccrued events over-reports NFT rewards by exactly nftEth per swap. This is inherited from the live implementation and has no on-chain effect.","line":67,"path":"candidates/phase1/contract/src/legacy/privacy/FeeNftStakingHook.sol","reproduction":"Fork at 26197295, seal, exact-input buy of 0.005 ether: logs contain NftFeeAccrued(5e15, 17500000000000, epoch) and NftStakingFeeAccrued(5e15, 17500000000000, epoch) for one 0.35% accrual of 17.5e12 wei (vault.totalAccrued grows by 17.5e12 once). Fix: drop one of the two events or document that NftStakingFeeAccrued supersedes NftFeeAccrued.","severity":"info","snippet":"            emit NftFeeAccrued(gross, nftEth, epoch);\n            emit NftStakingFeeAccrued(gross, nftEth, epoch);","title":"NFT share is emitted under two event names per swap (NftFeeAccrued and NftStakingFeeAccrued), double-counting for indexers that sum both"},{"citation":"resolved","description":"Missing scenarios (none of which produced a bug when I ran them manually, but they are not in the suite): (1) name()/symbol()/tokenURI() equality before and after seal (I verified on the fork: 'Claus'/'CLAUS'/18 survive, and the live METADATA slot 0xaf7f...a400 holds those strings); (2) negative constructor tests showing ProtectedHook rejects a substituted processor/remainder and, per finding 1, it currently does NOT reject substituted revenue/reward destinations; (3) partial-fill paths: exact-output buy with a price limit (PartialTokenOutput), exact-in buy and exact-out sell hitting the limit (PartialNativeFill), exact-in sell partial fill which is allowed; (4) wei-level trades at max extra plus protocol fee (finding 3); (5) processLiquidityRemainder()/LpRemainderProcessor and processLiquidity()/FeeProcessor.executeLiquidity after seal (the reserve holds 0.181 ETH and 5.16 CLAUS claims at this block); (6) processBuybacks() (unguarded legacy entry, still public) and the timed path when nothing has vested (BatchOutputTooLow); (7) owner renouncing token ownership and the resulting permanent freeze of setExtraFeePips/setModule; (8) ERC20 allowance semantics (infinite allowance not decremented, approve-race) after upgrade; (9) finalize() with swapped tokenImpl/hookImpl arguments (should revert at the protectionKind check after both upgrades, proving atomic rollback).","line":85,"path":"candidates/phase1/contract/test/PhaseOneFork.t.sol","reproduction":"Run the suite: 9 passing tests at block 26197295 on chain 31337; none of the listed scenarios appear in test/PhaseOneFork.t.sol. Adding (1) and (9) is cheap and directly protects the irreversible step.","severity":"info","snippet":"    function testAtomicSealPreservesCustodyAndRemovesBothUpgradeOwners() public {","title":"Supplied fork tests omit several adversarial/compatibility scenarios for an irreversible upgrade"}],"hash":"87b1bedea15c51879a98f6c4de3233003681544e6f897ff845915c8b72dece3c","nodeId":"e4d885a1-d143-4d19-a047-86d836cbd54e","outcome":"completed","summary":"Review complete. The findings are in `.imd-findings.json` at the repository root (5 entries), the scratch tests are removed, and the local fork is stopped. Working tree is clean apart from the findings file.\n\n**Outcome.** The protected core is largely sound: the 15% ceiling arithmetic is correct for buys and sells in both exact-input and exact-output modes, the fee split never underflows, partial fills are handled consistently, the finalizer is atomic with working cancellation, the token keeps its ERC20, ownership and metadata namespaces, and the two delegate processors have no arbitrary destinations. I verified this on a local anvil fork at block 26197295 as well as by source review. One release-blocking verification gap and one owner-power risk stand out.\n\n**Findings written:**\n- **Medium, release-blocking as a verification gap.** The ProtectedHook constructor pins only the PoolManager, reserve and the two processors. ProjectRevenue, PlatformRevenue, ClausNFT, ClausNftStaking, the LP rewards vault, oracle, privacy buy, attester and spacing are free constructor inputs with only self-consistency checks, and the finalizer checks just three pure getters before renouncing both ProxyAdmins. I executed this on the fork: with one substituted staking vault whose recordReward reverts unless tx.origin is allow-listed, finalize succeeds, both admins are renounced, and an ordinary user's buy permanently reverts while the allow-listed address trades. Fix: pin those exact addresses in the constructor or assert them in finalize, and record them in the manifest.\n- **Low.** The extra fee takes effect instantly, so a pending trade within its slippage tolerance can be sandwiched from 2.3% to 14.9% by the token owner. Suggested a timelocked two-phase setter that keeps the cap and the owner power.\n- **Info.** At max extra fee plus a v4 protocol fee, buys under about 1000 wei revert from rounding (verified on the fork). Economically irrelevant.\n- **Info.** The NFT share is emitted under two event names per swap, which double-counts for naive indexers.\n- **Info.** Test gaps for an irreversible step: metadata survival (I verified it manually: name, symbol, URI and decimals survive), constructor negative tests, partial-fill paths, remainder-processor paths after seal, swapped implementation arguments.\n\n**Limits of this review.** The live hook and token implementation sources are not in the tree, so compatibility of the timed-buyback schedule layout rests on the fork tests passing, not on source comparison. The LP rewards vault at 0xB2304d… is called on the mandatory trade path but its source is not supplied, so I could not rule out revert conditions inside it. No Slither or fuzzing beyond the supplied fee budget fuzz test ran. Fork tests ran against a local anvil fork only; nothing was broadcast.","treeHash":null,"usage":{"cachedInputTokens":2645711,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":67159,"runtime":"claude","turns":56,"wallClockMs":1242118}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f5666f1d1aa75678","findings":[{"citation":"resolved","description":"The constructor hard-pins the PoolManager, the LiquidityReserve (transitively through the FeeProcessor immutables) and the two delegatecall targets by address and runtime hash, but `base.project`, `base.platform`, `base.nft`, `base.oracle`, `privacyBuy_`, `lpRewards_` and `staking_` are only checked relationally against each other (e.g. `staking_.projectRevenue() == base.project`, `lpRewards_.projectRevenue() == base.project`, `staking_.collection() == base.nft`). A consistent set of substitute contracts satisfies all inherited constructor checks in FeeEfficientHook, FeeNftHook, FeePredictionHook, FeePrivacyHook, FeeLpRewardsHook and FeeNftStakingHook. PhaseOneFinalizer then pins whatever implementation address it is constructed with (it only compares `extcodehash` against itself and checks protectionVersion/kind/ceiling, which the rogue build also returns) and irrevocably renounces both ProxyAdmins. Consequences of a wrong or malicious argument set: 100% of the project fee (1.85%+extra of every trade) and the 0.3% platform fee accrue to attacker-controlled ProjectRevenue/PlatformRevenue; NFT and LP reward fees flow to fake vaults; and the real ClausNftStaking vault at 0x4e5916428452891E1deb59d162c20266ED08CAD4 is permanently closed because `stakingOpen()` requires `hook.nftStaking() == address(this)` (new stakes revert with StakingClosed, and the vault can never receive new rewards). Because the upgrade is final, there is no remediation. ProtectedToken already demonstrates the intended pattern by pinning `collection` as a constant; the hook does not pin any destination. This is a deployment-time authority/verification gap rather than a runtime exploit: it requires the finalizing owner to pin a mis-built implementation, but the brief explicitly asks for deployment-argument problems and this is the only irreversible step.","line":52,"path":"candidates/phase1/contract/src/ProtectedHook.sol","reproduction":"On the chain-ID 31337 fork at block 26197295: deploy `ProjectRevenue(PM, ATTACKER)`, `PlatformRevenue(PM, ATTACKER, anyContract)` + `initialize(0x37Bf...bfFf)`, a fake staking contract returning collection()=old.nft(), poolManager()=PM, projectRevenue()=fakeProject, hook()=0x37Bf...bfFf, epochStart()=old.nft().epochStart(), EPOCH()=1 days, totalStaked()=0, and a fake lpRewards returning poolManager, positionManager()=reserve.positionManager(), hook, poolId()=main pool id, epochStart, EPOCH, totalLiquidity()=0, projectRevenue()=fakeProject. Build `BaseConfig` from the live hook getters but with `project=fakeProject`, `platform=fakePlatform`, then `new ProtectedHook(cfg, old.privacyBuy(), fakeLp, old.lpRemainderProcessor(), fakeStaking)`. Expected: constructor reverts (InvalidActivation) because destinations are not the canonical ones. Actual: deployment succeeds; `rogue.projectRevenue()==fakeProject`, `rogue.nftStaking()==fakeStaking`; `new PhaseOneFinalizer(OWNER, token, hook, TA, HA, new ProtectedToken(), rogue)` + transferOwnership x2 + `finalize()` succeeds and renounces both admins; an exact-input buy of 0.005 ETH then increases `PM.balanceOf(fakeProject, 0)`; `ClausNftStaking(0x4e59...CAD4).stakingOpen()` returns false. Verified with a scratch fork test (4/4 passing) on the local anvil fork. Fix: pin the live destinations as constants in the ProtectedHook constructor exactly as the processors are pinned, e.g. `address(base.project)==0x49A91293a8dFf24d5c54b798C45376301D29b9f3 && address(base.platform)==0xF1749E78e76CfE1119206833aE93beD2dede5353 && address(base.nft)==0x80396c7131159eB92E7E84839e51C9887A7A95d9 && address(lpRewards_)==0xB2304d3DE407398de441b147e2c64c7c48DAf44E && address(staking_)==0x4e5916428452891E1deb59d162c20266ED08CAD4` (plus privacyBuy/oracle), and/or have PhaseOneFinalizer.finalize() read `projectRevenue()`, `platformRevenue()`, `nft()`, `nftStaking()`, `lpRewards()` through the hook proxy after the upgrade and compare them with the pre-upgrade values before renouncing.","severity":"medium","snippet":"        if (address(base.manager) != address(MANAGER) || base.reserve.token() != TOKEN || base.reserve.hook() != HOOK","title":"ProtectedHook constructor pins only the two delegate processors; every fee destination (project, platform, NFT, staking vault, LP rewards) can be substituted at deployment and the finalizer will pin t"},{"citation":"resolved","description":"For oneForZero swaps the proportional ceiling check compares the token-denominated protocol charge against `floor(input*15%)`. The v4 PoolManager rounds the protocol fee up to at least 1 atom for any non-zero input, so when the protocol fee is enabled (max 0.1%) an exact-input sell of 1..6 token atoms has `protocol == 1 > floor(input*0.15) == 0` (or `protocol == input`) and reverts with TotalTaxExceeded even though no hook fee is charged at all. Economic impact is nil (sub-wei trades) and the main pool currently has protocol fee 0, so this is a liveness nit rather than a honeypot; it is reported because the brief asks for tiny-rounding behaviour of the ceiling and because routers that sweep exact dust balances will see unexpected reverts. The buy side has the same shape (`fee+protocol > gross*15%`) for 1..6 wei.","line":335,"path":"candidates/phase1/contract/src/legacy/FeeEfficientHook.sol","reproduction":"Fork at block 26197295, seal the candidate, then `vm.prank(PM.protocolFeeController()); PM.setProtocolFee(mainKey, uint24(1000|(1000<<12)))`. An exact-input sell of 1 ether of CLAUS succeeds; an exact-input sell of 6 atoms (`amountSpecified = -6`, oneForZero) reverts (`vm.expectRevert()` passes in the scratch fork test). Expected: a trade whose combined tax is at most 1 atom passes the 15% check or is rounded on the same basis as the fee. Fix: round the allowance up on the token side, e.g. `if (protocol * 1_000_000 > input * 150_000 + 999_999 || protocol == input)`, or skip the proportional check when `input < 1_000_000 / 150_000` since such inputs cannot carry a hook fee.","severity":"low","snippet":"            if(protocol>input*150_000/1_000_000 || protocol==input) revert TotalTaxExceeded();","title":"Sell-side ceiling check rejects dust-sized sells whenever a v4 protocol fee is configured"},{"citation":"resolved","description":"When `block.timestamp < nftStakingStartsAt`, accrual falls through to FeeLpRewardsHook._accrue, which computes `platform = totalFee - project`, so any `extraPips` surcharge would be minted to PlatformRevenue, contradicting the WALLET_FEE_PIPS view (`18_500 + extraPips - ...`), the FeeNftStakingHook comment ('extras accrue only to the fixed project vault') and the FeeAllocation event. On mainnet the live vault's `startsAt()` is 1791672095 and the fork timestamp is 1792059851, so this branch is dead for the final core and no funds can be misrouted; it is kept here as a code-consistency note and as a limit on test coverage (no test covers the branch).","line":42,"path":"candidates/phase1/contract/src/legacy/privacy/FeeNftStakingHook.sol","reproduction":"Not reachable on the target chain: `ClausNftStaking(0x4e5916428452891E1deb59d162c20266ED08CAD4).startsAt()` = 1791672095 < current time. On a hypothetical deployment with a future `startsAt`, set `setExtraFeePips(126000)` and perform an exact-input buy of 1 ETH: expected project vault receives 0.126 ETH extra; actual PlatformRevenue receives it. Fix: delete the branch (and the inherited FeeLpRewardsHook._accrue override) from the final core, or make FeeLpRewardsHook._accrue split `extra` into the wallet exactly as FeeNftStakingHook does.","severity":"info","snippet":"        if (block.timestamp < nftStakingStartsAt) {","title":"Unreachable pre-staking accrual branch would route the global extra fee to PlatformRevenue instead of the project vault"},{"citation":"resolved","description":"By design the retained token owner may set `extraPips` anywhere in [0, 126000] at any time. There is no timelock or minimum notice, so a pending user sell that was quoted at 2.3% can be executed at 14.9% if the owner's transaction lands first in the same block; the only user protection is the router's own minimum-output parameter. If the owner then calls `renounceOwnership()` on the token, `IFeeGovernor(TOKEN).owner()` becomes address(0) and the fee is permanently fixed at the last value (verified on the fork: after `setExtraFeePips(50_000)` and `renounceOwnership()`, `setExtraFeePips(0)` reverts and `totalHookFeePips()` stays 73_000 while trading continues). This is within the requested 15% ceiling and is documented here as the material residual power of the owner role, not as a bypass.","line":73,"path":"candidates/phase1/contract/src/ProtectedHook.sol","reproduction":"Fork at block 26197295, seal; `vm.prank(OWNER); hook.setExtraFeePips(126000);` then any user exact-input sell of 10 ether pays 14.9% hook fee in the same block with no prior notice. `vm.prank(OWNER); token.renounceOwnership();` then `vm.prank(OWNER); hook.setExtraFeePips(0)` reverts with InvalidActivation. Suggested mitigation if desired (design decision): a two-step pending value with a short delay, or capping the per-step increase.","severity":"info","snippet":"    function setExtraFeePips(uint24 value) external {","title":"Trust assumption: the token owner can change the extra fee instantly (no delay/event lead) and renouncing ownership freezes whatever value is set"},{"citation":"resolved","description":"Anyone may call `processBuybacksGuarded(1, type(uint256).max, block.timestamp)`; the only output protection is the caller-chosen minimum, so a searcher can push the price up to just below the 0.5% sqrt limit, trigger the buyback (which spends the hook's accumulated 0.15% burn reserve and, after the timed window, the Fomo reserve), and sell back. The loss is bounded to roughly 1% of the pending reserve per trigger and the reserve is protocol-owned (not holder custody), and the behaviour is unchanged from the live implementation, so it is recorded as a residual economic assumption rather than a defect of this upgrade. The timed-window path in ProtectedHook additionally caps each batch at 1% of virtual ETH depth.","line":29,"path":"candidates/phase1/contract/src/legacy/privacy/FeeBatchedPrivacyHook.sol","reproduction":"Fork at block 26197295, seal, `hook.processBuybacksGuarded(1, hook.pendingBurnEth()*2, block.timestamp+60)` from any EOA succeeds and burns tokens (project test testBuybackBurnPreservedDuringAndAfterExistingWindow shows this). A sandwich of buy -> trigger -> sell gains at most the price-envelope slice of the batch minus the 2.3% hook fee on the attacker's own legs, which at current reserve size (0.254 ETH) is below gas cost. No code change required; if desired, require `minimumTokensPerEth` to be within a bounded distance of the current spot quote.","severity":"info","snippet":"    function processBuybacksGuarded(uint256 minimumTokensPerEth, uint256 maximumPendingEth, uint256 deadline)","title":"Permissionless buyback trigger with minimumTokensPerEth = 1 is effectively unguarded; MEV can capture up to the 0.5% sqrt-price envelope of each pending batch"}],"hash":"a2532832d32beaa8d7679b0f9a793c43f105e86bba50ee626c63b5467e041250","nodeId":"7f2981d4-6d1e-422f-9df4-faa68f6651e8","outcome":"completed","summary":"The anvil fork is stopped (the only match is the shell running the check itself), and the tree is clean apart from the findings file. The review is complete.\n\n## Result\n\nThe phase-one candidate is sound on the release-blocking axes I was asked to check. I found no custody, liveness, honeypot or upgrade-bypass defect. One medium deployment-argument gap and one low rounding nit are substantiated, plus three informational trust assumptions. All are in `.imd-findings.json`.\n\n**Findings**\n\n- **Medium, ProtectedHook constructor.** Only the PoolManager, the reserve chain and the two delegate processors are pinned. Project, platform, NFT, staking vault and LP rewards are checked only against each other. On the fork I deployed a ProtectedHook with a fake ProjectRevenue, PlatformRevenue, staking vault and LP vault. The constructor accepted it, the finalizer pinned it and renounced both admins, a buy then paid the project fee to the attacker's vault, and the real staking vault reported closed for good. This needs the owner to pin a mis-built implementation, so it is a verification gap rather than a runtime exploit, but it is the one irreversible step. Fix is to pin the five live addresses as constants, as ProtectedToken already does for the collection.\n- **Low, sell-side ceiling check.** With a v4 protocol fee enabled, sells of six token atoms or fewer revert because the protocol charge rounds up to one atom while the 15% allowance floors to zero. Economic impact is nil and the pool currently has no protocol fee.\n- **Info.** The pre-staking accrual branch would route the extra fee to the platform vault, but it is dead on mainnet since staking started before the fork block. The owner can raise the extra fee instantly and renouncing ownership freezes it. The guarded buyback trigger accepts a minimum of one, so it is effectively permissionless, bounded by the 0.5% sqrt-price envelope.\n\n**Verified clean**\n\nFee ceiling arithmetic holds for all four swap modes: the net-side formula floors so exact-output trades never exceed the rate, the base split is always affordable after rounding, the hook's ERC-6909 mints sum exactly to the fee so settlement cannot fail, and the protocol-fee basis conversion on the sell side is algebraically correct with no underflow. Partial native fills are rejected rather than over-taxed. The finalizer's repeated cancel recovers a late admin transfer. The timed buyback state carries across the upgrade with identical values. Allowances, name, symbol and owner survive the upgrade. The live processor and remainder code hashes match the constants.\n\n**Evidence**\n\nI built with the pinned toolchain and ran the project's nine fork tests and two budget tests against a local anvil fork at block 26197295 on chain 31337. All passed. My four scratch scenarios also passed and have been removed.\n\n**Limits**\n\nThe live LP rewards vault's source is not in the tree, so its recordReward behaviour is trusted from its interface. The previous hook and token implementations' sources were not available, so storage-namespace continuity rests on the fork tests rather than a source diff. No Slither or long fuzzing was run.","treeHash":null,"usage":{"cachedInputTokens":2149879,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":49741,"runtime":"claude","turns":42,"wallClockMs":1049644}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4aa8623bfb8d8e5e","findings":[{"citation":"resolved","description":"The aggregate ceiling is enforced wei-exactly: the right side floors gross*0.15, while the hook fee floors gross*rate/1e6 and the v4 protocol charge is rounded UP per swap step (SwapMath: feeAmount = amountRemaining - floor(amountRemaining*(1e6-pips)/1e6) for exact input, mulDivRoundingUp(amountIn, pips, 1e6-pips) for exact output). At extraPips=126000 (rate 149000) the nominal slack is only 0.0149% of gross, so whenever gross*0.000149 < (rounding error, up to 1 wei per component plus 1 wei per initialized tick crossed) the check trips and the whole swap reverts. The same happens in the sell branch (line 335): protocol is ceil(input*0.001) in token atoms, so for small token inputs protocol/input exceeds 0.1%, allowedPips drops below 149000 and the check reverts; for input of 1 atom protocol==input also reverts. Confirmed with a scratch test replicating v4 single-step rounding (test/scratch/Dust.t.sol): with rate 149000 and protocol fee 1000 pips, exact-input buys of 10 wei (fee 1, protocol 1, ceiling 1) and scattered amounts up to 4933 wei revert (analytically any gross < ~13,400 wei can revert in one step, more with tick crossings); exact-input sells of up to 5100 token atoms revert. At the base rate (extra 0) no amount reverts. Economic impact is nil (1e-14 ETH), but it contradicts the stated invariant that the ceiling 'cannot trip' for legitimate trades and it is the only path on which the honest 2.3%+12.6% configuration plus the reserved 0.1% protocol fee is rejected. No honeypot: the owner cannot target an address with it.","line":330,"path":"candidates/phase1/contract/src/legacy/FeeEfficientHook.sol","reproduction":"State: extraPips=126000 (owner setExtraFeePips), PoolManager protocol fee for the main pool = 1000|(1000<<12) (as in testTradingAtBaseAndMaximumWithProtocolFee), deep liquidity. Input: exact-input buy with amountSpecified=-10 wei. Expected per README: trade executes, total tax <= 15%. Actual: beforeSwap fee=floor(10*149000/1e6)=1, pool swaps 9 wei, v4 protocol fee = 9-floor(9*0.999)=1, afterSwap computes fee+protocol=2 > floor(10*150000/1e6)=1 and reverts TotalTaxExceeded. Likewise gross=4933 wei: fee=735, protocol=5, 740 > 739 -> revert. Sell: exact-input sell of 100 token atoms: protocol=1, allowedPips=(100*150000-1e6)/99=141414, fee=floor(gross*0.149) > gross*0.141414 -> revert. Fix: evaluate the ceiling with rounding in the trader's favour, e.g. `uint256 cap = FullMath.mulDivRoundingUp(gross,150_000,1_000_000) + 1 + ticksCrossedOrSimply 1;` or clamp protocol to its nominal share `protocol = min(protocol, FullMath.mulDivRoundingUp(gross-fee,1_000,1_000_000))` before comparing, and in the sell branch compute allowedPips with mulDivRoundingUp and skip the check when input < 1e6 atoms (where a single atom of protocol fee exceeds 0.0001%). The scratch test's assertTrue(r10) documents the current behaviour and should flip once fixed.","severity":"low","snippet":"            if(fee+protocol>gross*150_000/1_000_000) revert TotalTaxExceeded();","title":"Dust-sized swaps revert with TotalTaxExceeded at the maximum extra fee once a v4 protocol fee is set (floor/ceil rounding mismatch in the 15% ceiling check)"},{"citation":"resolved","description":"The only owner lever on the mandatory trade path is the global extra fee. It is bounded (<=126000 pips) but has no delay, rate limit or announcement, and the fee is read live in both beforeSwap and afterSwap via ClausFeeBudget.config(). An owner (or whoever controls the retained token owner key) can therefore bundle [setExtraFeePips(126000), victim swap, setExtraFeePips(0)] via a builder, or front-run a pending swap in the public mempool, so that one specific trade is taxed 14.9% while trades in adjacent positions pay 2.3%. This is per-transaction selective taxation rather than a blacklist, and it stays inside the 15% ceiling, so it does not violate the arithmetic invariants; but it is an owner power that the brief's 'no selectively blocking/honeypot' intent should be documented against, and the victim's only defence is a tight minAmountOut. Reported as a trust assumption with a concrete abuse path, not as a bypass.","line":73,"path":"candidates/phase1/contract/src/ProtectedHook.sol","reproduction":"State: finalized core, extraPips=0, victim submits exact-input buy of 1 ETH with amountOutMin reflecting 2.3% fee minus normal slippage tolerance (e.g. 3%). Owner bundle: tx1 live.setExtraFeePips(126000); tx2 victim swap; tx3 live.setExtraFeePips(0). Expected (from the victim's view): fee 0.023 ETH. Actual: beforeSwap charges floor(1e18*149000/1e6)=0.149 ETH to the project vault (walletEth += extra), victim receives tokens for 0.851 ETH; if their minAmountOut tolerated <=12.6% less, the trade succeeds; neighbouring trades in the same block pay 2.3%. Fix options (design decision): make increases effective only after a fixed delay (e.g. store pendingExtraPips + effectiveAt >= block.timestamp + 1 day and read the pending value only once effective), or cap the per-day increase. Decreases can remain immediate.","severity":"low","snippet":"    function setExtraFeePips(uint24 value) external {","title":"setExtraFeePips takes effect instantly, letting the token owner sandwich one chosen trade with a 14.9% fee while everyone else pays 2.3%"},{"citation":"resolved","description":"name() and symbol() are overridden to read a custom Metadata struct at a literal slot instead of ERC20Upgradeable's ERC-7201 namespace (0x52c63247...ce00 + 2/3). The previous implementation's source (0xDdB5A5f8...6F81) is not part of the candidate and the slot label is not derivable from the repository, so an auditor cannot confirm that this is the live token's namespace. The fork test snapshot() covers totalSupply, balances and owner but not name(), symbol(), decimals() or tokenURI(); testAtomicSealPreservesCustodyAndRemovesBothUpgradeOwners only checks T.owner(). Because the finalizer renounces both ProxyAdmins in the same transaction, a wrong slot would make the token permanently report empty name/symbol (and updateMetadata would write a new namespace, not repair the old one). This is a verification gap in a permanent change, not a demonstrated defect.","line":12,"path":"candidates/phase1/contract/src/ProtectedToken.sol","reproduction":"Failing state: if the live proxy stores its metadata anywhere other than 0xaf7f7287...ab400 (e.g. in the OZ ERC20 namespace), then after seal() T.name() returns \"\" and T.symbol() returns \"\" although before the upgrade they returned the live values; nothing can revert this because TA.owner()==address(0). Expected: name()/symbol()/decimals() identical before and after finalize(). Required action: add `string memory n=T.name(); string memory s=T.symbol(); uint8 d=T.decimals();` before seal() in the fork test and assertEq them after, and record in manifest.json the derivation of METADATA_SLOT (the keccak label or the previous implementation's verified source) so the slot can be checked against the deployed implementation.","severity":"low","snippet":"    bytes32 private constant METADATA_SLOT = 0xaf7f7287c9c163f7ebe81ce19f70fbce273b445ed7fb836babcf15904daab400;","title":"ProtectedToken metadata namespace is an unverifiable hard-coded slot and the fork suite never asserts name()/symbol() survive the upgrade"}],"hash":"b453474cbe522569bd2e03dd1b2c2b5566ece734419ceea64bd982509494782d","nodeId":"239c7024-3a89-4e6d-a461-92c4c231cb10","outcome":"completed","summary":"Review complete. The findings file at the repository root is valid and holds three substantiated low-severity items. All snippets match the tree.\n\n**Outcome.** The assigned math area is sound. I found no release-blocking defect in the fee arithmetic, the ceiling check, custody paths or the finalizer.\n\n**What I verified in depth**\n\n- **Fee split sums exactly to the charged fee.** In the active accrual path the mints to project, burn, LP vault, NFT vault and platform total baseFee + extra = totalFee, so the hook's v4 delta nets to zero on every swap mode. No underflow is reachable: floor(gross·rate) ≥ floor(gross·2.3%) ≥ floor(gross·2%) ≥ sum of the three sub-floors.\n- **Exact-output rounding is consistent.** netFee floors, and I proved (and fuzzed) that floor((net+fee)·rate) equals that fee, so exact-output trades never pay above the rate and always afford the base split.\n- **Ceiling formula is correct on both sides.** Buys compare fee plus ETH protocol fee against 15% of gross ETH. Sells use p + f(1−p), expressed as allowedPips over the token input. With extra capped at 12.6% the true maximum is 14.985%, so the check only trips through rounding.\n- **Mode coverage.** Exact-input buys and all exact-output trades revert on partial fill rather than mischarge. Exact-input sells partially fill with the fee computed on actual output. Before/after fee deltas are mutually exclusive, so no stacking. LP fee is forced to zero by the override flag.\n- **Owner levers.** Only extraPips (bounded) and the module directory exist. No address-keyed path reaches a swap. Timed buyback state cannot be reactivated. Processor and remainder delegates are pinned by address and runtime hash and move funds only to fixed reserves.\n\n**Findings written**\n\n1. **Dust trades revert at max fee.** At extra 12.6% with a 0.1% protocol fee, floor/ceil mismatch makes buys up to a few thousand wei and sells up to a few thousand token atoms revert. Confirmed with a scratch test against v4's step rounding.\n2. **Instant fee changes allow sandwiching one trade** at 14.9% while neighbours pay 2.3%. Within the ceiling, reported as a trust assumption with a delay-based fix option.\n3. **Token metadata slot is unverifiable** and the fork suite never asserts name and symbol survive the irreversible upgrade.\n\n**Limits of this review**\n\n- No fork RPC was available, so the eleven fork tests could not be executed here. Only the budget unit tests and my scratch tests ran.\n- The previous token and hook implementation sources are not in the candidate, so retained-slot compatibility rests on the authors' fork assertions.\n- The LP rewards vault source is absent. Its recordReward call sits on the mandatory trade path, so a revert there would halt swaps whenever LP stakers exist. Unverified.\n- Privacy pool and VRF dependencies were not analyzed beyond their call surface.","treeHash":null,"usage":{"cachedInputTokens":1295917,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":51007,"runtime":"claude","turns":25,"wallClockMs":885905}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"aca5d7170d77c721","findings":[{"citation":"resolved","description":"The finalizer is the single irreversible gate of the phase-one upgrade ('one exact atomic final upgrade'). Its constructor only checks that token_ and hook_ differ, have code, and that both ProxyAdmins are owned by owner_. It never binds token_ to the pinned CLAUS proxy 0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8 or hook_ to the pinned hook proxy 0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf, although ProtectedToken.hook and ProtectedHook's private HOOK/TOKEN constants already pin exactly those addresses, and it never checks the raw implementations against the proxies they are meant for (for example ProtectedToken(tokenImpl_).hook()==hook_). finalize() verifies protectionVersion/protectionKind/maximumTotalTaxPips only through the proxy addresses it was given, so with (token_=HOOK proxy, hook_=TOKEN proxy, tokenAdmin_=hook ProxyAdmin, hookAdmin_=token ProxyAdmin) every check passes: the hook proxy now runs ProtectedToken and answers CLAUS_TOKEN for 'token', the CLAUS proxy now runs ProtectedHook and answers CLAUS_HOOK for 'hook', and both ProxyAdmins are renounced in the same transaction. Every CLAUS balance, allowance and the 50,000-CLAUS-per-NFT backing become unreachable (the token proxy no longer exposes transfer/balanceOf), every main-pool swap reverts because the hook address no longer implements IHooks, and no admin remains to roll back. Precondition: an argument-order mistake by the deployer (no attacker), which is precisely the error class a self-checking finalizer exists to stop. Release-blocking as an upgrade-safety/liveness/custody gap.","line":31,"path":"candidates/phase1/contract/src/PhaseOneFinalizer.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PhaseOneFinalizer} from \"src/PhaseOneFinalizer.sol\";\nimport {ProtectedToken} from \"src/ProtectedToken.sol\";\nimport {ProxyAdmin} from \"@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol\";\nimport {TransparentUpgradeableProxy} from \"@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol\";\n\n/// Placeholder for whatever implementation the two live proxies currently run.\ncontract OldImplementation {\n    function protectionVersion() external pure returns (uint256) { return 0; }\n    function init() external {}\n}\n\n/// Stand-in for the ProtectedHook implementation (the real one can only be\n/// constructed against mainnet code hashes). It answers the finalizer's\n/// post-upgrade self-checks exactly like ProtectedHook does.\ncontract HookStandIn {\n    function protectionVersion() external pure returns (uint256) { return 1; }\n    function protectionKind() external pure returns (bytes32) { return keccak256(\"CLAUS_HOOK\"); }\n    function maximumTotalTaxPips() external pure returns (uint24) { return 150_000; }\n}\n\n/// The upgrade owner, driving the finalizer with the token and hook arguments swapped.\ncontract Operator {\n    function run(address tokenProxy, address hookProxy, ProxyAdmin tokenAdmin, ProxyAdmin hookAdmin,\n        address tokenImpl, address hookImpl) external\n    {\n        // token_ and hook_ swapped, and the admins swapped to match them.\n        PhaseOneFinalizer f = new PhaseOneFinalizer(address(this), hookProxy, tokenProxy, hookAdmin, tokenAdmin, tokenImpl, hookImpl);\n        tokenAdmin.transferOwnership(address(f));\n        hookAdmin.transferOwnership(address(f));\n        f.finalize();\n    }\n}\n\ncontract FinalizerSwappedArgsTest is Test {\n    address constant TOKEN = 0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8;\n    address constant HOOK = 0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf;\n    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;\n    bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;\n\n    Operator operator;\n    ProxyAdmin tokenAdmin;\n    ProxyAdmin hookAdmin;\n    ProtectedToken tokenImpl;\n    HookStandIn hookImpl;\n\n    function setUp() public {\n        operator = new Operator();\n        OldImplementation old = new OldImplementation();\n        bytes memory init = abi.encodeCall(OldImplementation.init, ());\n        TransparentUpgradeableProxy t = new TransparentUpgradeableProxy(address(old), address(operator), init);\n        TransparentUpgradeableProxy h = new TransparentUpgradeableProxy(address(old), address(operator), init);\n        _place(address(t), TOKEN);\n        _place(address(h), HOOK);\n        tokenAdmin = ProxyAdmin(address(uint160(uint256(vm.load(TOKEN, ADMIN_SLOT)))));\n        hookAdmin = ProxyAdmin(address(uint160(uint256(vm.load(HOOK, ADMIN_SLOT)))));\n        assertEq(tokenAdmin.owner(), address(operator));\n        assertEq(hookAdmin.owner(), address(operator));\n        tokenImpl = new ProtectedToken();\n        hookImpl = new HookStandIn();\n    }\n\n    /// Move a freshly deployed transparent proxy (code with its immutable admin, plus\n    /// the ERC-1967 implementation and admin slots) to the pinned mainnet address.\n    function _place(address from, address to) internal {\n        vm.etch(to, from.code);\n        vm.store(to, IMPL_SLOT, vm.load(from, IMPL_SLOT));\n        vm.store(to, ADMIN_SLOT, vm.load(from, ADMIN_SLOT));\n    }\n\n    /// With token_/hook_ (and their admins) swapped, every finalizer check still passes:\n    /// the ERC20 code is installed on the hook proxy, the hook code on the token proxy,\n    /// and both ProxyAdmins are renounced, so the mistake is permanent.\n    /// Expected: construction or finalize() reverts. Actual: the run completes.\n    function testSwappedTokenAndHookArgumentsMustNotFinalize() public {\n        vm.expectRevert();\n        operator.run(TOKEN, HOOK, tokenAdmin, hookAdmin, address(tokenImpl), address(hookImpl));\n    }\n}","reproduction":"Local, no fork (test/scratch/FinalizerSwappedArgs.t.sol): deploy two OZ TransparentUpgradeableProxy instances owned by one operator and etch them (code plus ERC-1967 implementation/admin slots) to 0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8 and 0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf; deploy ProtectedToken and a hook stand-in answering protectionVersion()=1, protectionKind()=keccak256(\"CLAUS_HOOK\"), maximumTotalTaxPips()=150000. Construct PhaseOneFinalizer(owner, token_=0x37Bf..., hook_=0x1b54..., tokenAdmin_=admin of 0x37Bf..., hookAdmin_=admin of 0x1b54..., tokenImpl, hookImpl), transfer both ProxyAdmins to it, call finalize(). Expected: construction or finalize() reverts InvalidFinalization. Actual: finalize() succeeds; 0x1b54... now runs the hook code, 0x37Bf... runs the ERC20 code, both ProxyAdmins report owner()==address(0). Fix: in the constructor require token_==0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8 and hook_==0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf (and/or require ProtectedToken(tokenImpl_).hook()==hook_, IProtectedCore(tokenImpl_).protectionKind()==keccak256(\"CLAUS_TOKEN\") and IProtectedCore(hookImpl_).protectionKind()==keccak256(\"CLAUS_HOOK\") on the raw implementations before any upgrade). Keep the post-upgrade checks.","severity":"high","snippet":"        if(owner_==address(0) || token_==hook_ || address(tokenAdmin_)==address(hookAdmin_)\n           || token_.code.length==0 || hook_.code.length==0 || tokenImpl_.code.length==0 || hookImpl_.code.length==0\n           || tokenAdmin_.owner()!=owner_ || hookAdmin_.owner()!=owner_) revert InvalidFinalization();","title":"PhaseOneFinalizer accepts swapped token/hook proxy arguments and irreversibly installs the ERC20 code on the hook proxy and the hook code on the token proxy"},{"citation":"resolved","description":"ProtectedHook pins only the manager, the token, the hook address, and the two delegate processors (address+codehash) in its constructor (src/ProtectedHook.sol:52-57). Every other destination that receives money on the mandatory trade path is an immutable taken from constructor arguments and only cross-checked relationally: projectRevenue (receives 13%+extra of gross via _accrue, checked only by project_.poolManager()==manager in FeeEfficientHook:114), platformRevenue, nft, nftStaking (checked against staking_.collection()==base.nft, staking_.hook()==HOOK, etc.), lpRewards (hook()/poolId()/epochStart() views), feed and oracle. Any freshly deployed contract can satisfy those view checks: e.g. `new ProjectRevenue(PM, ATTACKER)` passes, a ClausNftStaking clone constructed with the real collection passes, and an IClausLpRewards stand-in returning the right views passes. The finalizer's only post-upgrade checks are protectionVersion/protectionKind/maximumTotalTaxPips, which say nothing about bindings, and it then renounces both ProxyAdmins. Consequence of a wrong candidate: 100% of project/wallet fees, platform fees, NFT rewards and LP rewards flow to the wrong contracts forever; the real ClausNftStaking.stakingOpen() turns false (hook.nftStaking()!=vault) so no new NFT stakes and no new NFT rewards reach existing stakers; nothing already accrued is lost. Precondition: deployer error or a compromised deployment pipeline; the owner is trusted, but the finalizer is explicitly the self-verifying gate for this one-shot irreversible change and the README itself says immutable-populated code 'must be checked separately', i.e. the check is currently manual.","line":44,"path":"candidates/phase1/contract/src/PhaseOneFinalizer.sol","reproduction":"Build cfg exactly as test/PhaseOneFork.t.sol:62 does but with cfg.project = new ProjectRevenue(PM, ATTACKER) (and feeRecipient irrelevant: FeeEfficientHook.initialize is never re-run on the proxy), construct ProtectedHook(cfg, privacyBuy, lpRewards, remainder, vault); constructor succeeds because only project_.poolManager()==manager is checked. Deploy PhaseOneFinalizer with that implementation; finalize() succeeds. Then any buy of 1 ETH credits 0.13 ETH + extra to ATTACKER's ProjectRevenue instead of the live one, permanently. Fix: before upgrading, have the finalizer read the live hook's projectRevenue(), platformRevenue(), nft(), nftStaking(), lpRewards(), liquidityReserve(), fomoReserve(), processor(), lpRemainderProcessor(), predictionOracle(), ethUsdFeed() through the hook proxy and require the raw hookImplementation to return identical addresses (immutables are readable on the implementation directly), reverting otherwise; equivalently pin those addresses as constants in ProtectedHook's constructor as is already done for the processors.","severity":"medium","snippet":"        if(IProtectedCore(token).protectionVersion()!=1 || IProtectedCore(hook).protectionVersion()!=1\n            || IProtectedCore(token).protectionKind()!=keccak256(\"CLAUS_TOKEN\") || IProtectedCore(hook).protectionKind()!=keccak256(\"CLAUS_HOOK\")\n            || IProtectedCore(token).maximumTotalTaxPips()!=150_000 || IProtectedCore(hook).maximumTotalTaxPips()!=150_000) revert InvalidFinalization();","title":"finalize() does not verify that the new hook implementation binds the same revenue, NFT, staking, LP-reward and oracle contracts as the live hook, so a mis-deployed candidate irreversibly redirects al"},{"citation":"resolved","description":"The ceiling check compares integer-floored quantities: fee=floor(gross*149000/1e6) and the actual protocol charge, which Uniswap v4 SwapMath rounds UP per step (feeAmount = amountRemaining - floor(amountRemaining*(1e6-1000)/1e6), i.e. ceil(0.1%) of the pool input; with lpFee=0 the whole feeAmount goes to the protocol). The bound gross*150_000/1_000_000 is floored. For small gross the two floors coincide while the protocol round-up adds 1 wei, so fee+protocol > bound and the swap reverts. Analytically, with extraPips=126000 and protocol fee 1000 pips, exact-input buys of gross g revert whenever floor(0.149g)+ceil(0.001(g-floor(0.149g))) > floor(0.15g), e.g. g=7,13,14,21,27,34,1176 wei; for g >= ~13,500 wei the slack 0.001g exceeds the rounding error and no revert is possible. The sell side has the same shape at src/legacy/FeeEfficientHook.sol:335 (protocol>input*15% / protocol==input) for token inputs of a few wei. This is not a honeypot: every economically meaningful trade (anything above ~1.4e4 wei of ETH) passes, and at the starting 2.3% rate the margin is far larger. It is reported because the task asks for tiny-rounding and fee-stacking behaviour explicitly, and because the ceiling is otherwise mathematically correct (max 14.9% hook + 0.1% of the 85.1% remaining input = 14.985% < 15%).","line":330,"path":"candidates/phase1/contract/src/legacy/FeeEfficientHook.sol","reproduction":"State: extraPips=126000 (setExtraFeePips), pool protocol fee set to 1000|(1000<<12) as in test/PhaseOneFork.t.sol:100. Input: exact-input buy (zeroForOne, amountSpecified=-7). beforeSwap takes fee=floor(7*149000/1e6)=1; the pool swaps 6 wei; v4 charges protocol feeAmount=6-floor(6*999000/1e6)=1; afterSwap: fee+protocol=2 > floor(7*0.15)=1 -> TotalTaxExceeded. Expected: a 7-wei buy at 14.9%+0.1% should settle (2 wei of tax on 7 wei is the unavoidable integer result, still within the spirit of 15% rounded up). Fix (optional): compare against a ceiling that rounds up, e.g. `if (fee + protocol > FullMath.mulDivRoundingUp(gross, 150_000, 1_000_000)) revert`, or exempt the 1-wei protocol round-up (`protocol - 1` when protocol!=0), while keeping the exact check for all larger amounts; document the dust behaviour otherwise.","severity":"low","snippet":"            if(fee+protocol>gross*150_000/1_000_000) revert TotalTaxExceeded();","title":"At the maximum extra fee with a nonzero v4 protocol fee, dust-sized trades revert with TotalTaxExceeded because protocol-fee round-up plus hook-fee flooring exceeds the floored 15% bound"},{"citation":"resolved","description":"ProtectedHook reads the existing schedule from the namespaced slot keccak256(\"claus.hook.timed-buyback.2026-10-15.v1\") and its _released() accounting assumes the live implementation stored availableEth/lockedEth against the same pending definition (balanceOf(HOOK,0)-1); if either differs, _active() is false (ordinary processing silently resumes early, contrary to 'Existing timed buyback state is retained until its original end') or _released() reverts InvalidContext for the whole window (timedBuybackState() and processBuybacksGuarded() revert until endsAt). testBuybackBurnPreservedDuringAndAfterExistingWindow only checks that totalSupply decreased before and after warping to end+1. If end were 0 (slot not populated or a different slot), vm.warp(1) still runs and processBuybacksGuarded still burns through the legacy FeeProcessor path, so the test passes without exercising the timed branch at all. The source for the live hook implementation 0xD770a418310bc8fD164AAC5F1E1e3F4159102f2c is not in the candidate and no RPC was available in this review, so the migration could not be verified here either.","line":154,"path":"candidates/phase1/contract/test/PhaseOneFork.t.sol","reproduction":"In the fork test, add after seal(): (uint64 s,uint64 e,bool active,uint256 avail)=live.timedBuybackState(); assertTrue(active); assertGt(e, block.timestamp); assertGt(avail,0) (or assert the pre-upgrade old-implementation values equal the post-upgrade ones), and assert the first processBuybacksGuarded spends at most 1% of the pool's virtual ETH depth (the timed cap) rather than the whole pending balance. Expected: an assertion that fails if the slot or accounting does not match. Actual: the current test is satisfied with end==0 and with the legacy path.","severity":"low","snippet":"        (,uint64 end,,)=live.timedBuybackState();vm.warp(uint256(end)+1);","title":"Fork test for the retained timed-buyback window never asserts the window is active or that the schedule slot was actually migrated, so a slot/accounting mismatch with the live implementation would go "},{"citation":"resolved","description":"Reviewed the owner surface for honeypot or selective-block ability: setExtraFeePips is global, bounded to 126000 pips, takes effect immediately for every pool participant on both sides and both modes, and the 15% aggregate cap (hook+actual protocol) is enforced per swap in _checkTotalTax; setModule only writes a registry that nothing on the trade path reads; updateMetadata is cosmetic; the token has no transfer hooks; the hook has no sender/pool checks beyond _requirePool. No selective blocking or confiscation path was found. The remaining power is the instantaneous rate change: a user who signs a sell expecting 2.3% can receive 14.9% less ETH if the owner raises the extra fee in the same block, limited only by the user's own amountOutMinimum. This is within the commissioned design and is reported as a documented trust assumption rather than a defect.","line":73,"path":"candidates/phase1/contract/src/ProtectedHook.sol","reproduction":"Owner calls setExtraFeePips(126000) immediately before a victim's exact-input sell of 10 ether of output value: victim receives gross*(1-0.149) instead of gross*(1-0.023); both swaps succeed and pass the ceiling check. Optional hardening if the owner wants it: a two-step or time-delayed rate change (e.g. announce then apply after >= 1 day) with the same 126000 cap.","severity":"info","snippet":"    function setExtraFeePips(uint24 value) external {\n        if(msg.sender != IFeeGovernor(TOKEN).owner() || value > ClausFeeBudget.MAX_EXTRA) revert InvalidActivation();\n        ClausFeeBudget.config().extraPips=value; emit ExtraFeeChanged(value);\n    }","title":"Trust assumption: the token owner can move the global hook rate from 2.3% to 14.9% in one transaction with no timelock, so pending sells can be sandwiched by a fee change (bounded by design, not selec"},{"citation":"resolved","description":"Historically NftFeeAccrued(gross, nftEth, epoch) meant nftEth was minted to the ClausNFT collection and recorded through NftRewards.recordReward. Since staking started, _accrue mints nftEth to ClausNftStaking and emits both events with identical values. Any consumer that totals NftFeeAccrued to reconcile the collection's uncollected/epochPot will over-count by every post-staking accrual, and one that totals both events double-counts NFT rewards. On-chain accounting is correct: the claim is minted exactly once and recordReward is called once; the mismatch is purely in the event stream. All other allocation events (ProjectFeeSplit, LiquidityFeeAccrued, FomoFeeAccrued(gross,0), FeeAllocation with project+extra, PlatformFeeAllocation) were checked against the minted amounts and sum to totalFee.","line":67,"path":"candidates/phase1/contract/src/legacy/privacy/FeeNftStakingHook.sol","reproduction":"Any post-staking swap with nftEth != 0 (e.g. the 0.005 ether buy in testTradingAtBaseAndMaximumWithProtocolFee) emits NftFeeAccrued(0.005e18, 17.5e12, epoch) and NftStakingFeeAccrued(0.005e18, 17.5e12, epoch) in the same transaction while the collection's NftRewards.epochPot does not change. Fix: emit only NftStakingFeeAccrued on the staking branch (or document that NftFeeAccrued now denotes the vault destination).","severity":"info","snippet":"            emit NftFeeAccrued(gross, nftEth, epoch);\n            emit NftStakingFeeAccrued(gross, nftEth, epoch);","title":"NFT reward accrual emits both the historical NftFeeAccrued and the new NftStakingFeeAccrued for the same amount, so indexers summing NftFeeAccrued double-count rewards that no longer reach the collect"}],"hash":"d93360c9683260efbb8198d39c6dc36ca9dde0da2c15de6332e4afbb9474cdc2","nodeId":"da8e444d-6c30-4260-aca6-96681a6ab80b","outcome":"completed","summary":"The proof compiles only from the candidate's own Foundry root (the repository root uses solc 0.8.27 and lacks the v4/upgradeable remappings), which is the build root the task specifies. I'm done with verification and will wrap up.\n\n## Review result\n\nFindings are written to `.imd-findings.json` (6 entries, all snippets verified against the tree). One carries a Foundry proof at `candidates/phase1/contract/test/scratch/FinalizerSwappedArgs.t.sol`, which fails on the current code when run from `candidates/phase1/contract`.\n\n**Findings, most severe first**\n\n- **High, PhaseOneFinalizer constructor (line 31).** The finalizer never pins `token_`/`hook_` to the two live proxy addresses or checks the raw implementations against them. With the token and hook arguments (and their admins) swapped, every constructor and post-upgrade check passes, the CLAUS proxy ends up running the hook code, the hook proxy runs the ERC20 code, and both ProxyAdmins are renounced in the same transaction. All balances and NFT backing become unreachable and every swap reverts, permanently. Proof test provided. Fix: require the two pinned addresses in the constructor and verify `protectionKind`/`hook()` on the raw implementations before upgrading.\n- **Medium, finalize() (line 44).** Post-upgrade checks cover only version, kind and tax cap. A candidate hook built with a wrong `ProjectRevenue`, staking vault, LP vault or platform contract passes every relational constructor check and the finalizer, then redirects all future fees irreversibly. Fix: compare the live hook's bound addresses with the new implementation's immutables before upgrading.\n- **Low, FeeEfficientHook ceiling check (line 330).** At the maximum extra fee with a nonzero protocol fee, dust trades below roughly 13,500 wei revert because v4 rounds the protocol fee up while the 15% bound is floored. Not a honeypot. All meaningful trades pass, and the ceiling math is otherwise correct (14.9% plus 0.1% of the remaining 85.1% is under 15%).\n- **Low, fork test (line 154).** The timed-buyback test never asserts the retained window is active or that the schedule slot migrated, so a slot or accounting mismatch with the live implementation would go undetected.\n- **Info.** Instant global fee change to 14.9% with no timelock is a trust assumption, not a selective block. The staking branch emits both `NftFeeAccrued` and `NftStakingFeeAccrued` for one amount, which double-counts for indexers.\n\n**What I verified as sound**\nFee basis and ceiling for buy/sell, exact-input/exact-output, partial fills, floor rounding of net fees, allocation sums equal to the hook's delta, no sender or pool blacklist on the trade path, modules never invoked on the trade path, both delegate targets stateless and pinned by address and codehash, no sequential storage in the hook, token upgrade with no initializer or mint, and cancellation recovery of late admin transfers.\n\n**Limits of this review**\nNo RPC was available, so I could not check the live token's metadata slot (name and symbol could read empty after upgrade if the old implementation used a different slot), the migrated timed-buyback schedule, or the storage layout of the live implementations. The LP rewards vault's code is not in the candidate, and a revert in its `recordReward` would halt trading. Fork tests were not run here.","treeHash":null,"usage":{"cachedInputTokens":2331624,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":67406,"runtime":"claude","turns":48,"wallClockMs":991610}}],"verification":[]}