# Audit report

> Independently audit revision r3 of the permanent CLAUS core protections. Exact candidate is candidates/phase1/contract/src and manifest. Review all four entrypoints: ProtectedToken, ProtectedHook, TradeExtensionHost and PhaseOneFinalizer, including every inherited legacy contract and fixed delegate target. This is NOT r1: owner explicitly requires replaceable trade programs, so the module directory-only r1 is rejected and must never be activated. Current mainnet remains unchanged. Original r1/r2 paid reviews remain separate historical reviews, not authority for this revision.
>
> Rejecting a specifically oversized caller payload or an insufficient caller gas budget is allowed; neither may persistently affect a later valid trade. Check that no owner, new program, unexpected caller, malformed program quote or composition can freeze transfers/buys/sells/LP exits, confiscate holder assets, redirect previously accrued rewards or exceed 15% aggregate core plus actual main-pool protocol fees. The hook bounds its TOTAL fee plan to 14.9%, reserving 0.1% for v4 protocol fees and checking actual proportional charges after swap. Six pips allocations sum to one fee, not six independent limits. All four buy/sell/exact-input/exact-output modes, rounding, zero-rate plans, partial fills, max protocol fees, zero stakers, invalid data and integer limits matter.
>
> TradeExtensionHost code is fixed and hash-checked by the core constructor, but activeModule remains replaceable by token owner. Module fee plans are gas-bounded STATICCALLs, with exact six-word output and no arbitrary code/storage authority. Malformed, excessive, write-attempting, reverting or OOG plans fall back to default 2.3%. Do not mistake fee plan approval for arbitrary delegate authority. Plans and module identity are snapshotted in transient slots; test reentrancy and contamination across multi-swaps, internal buybacks and malicious views. Address-dependent quotes still cannot exceed the common ceiling or reject trading. Unrelated gas and market slippage, or independent third-party pool fees, are outside the ceiling.
>
> Only newly earned fees can be minted as a PoolManager claim to the program itself, per its bounded plan; no old vault, NFT backing or LP assets can be taken. Check complete allocation arithmetic and original default fee recipients. Future rates and distribution are intentionally changeable, but past ownership/claims are not. Legacy rate getters represent the DEFAULT policy; actual integrations need the per-trade quote. Confirm no hidden baseline charge stacks on the plan.
>
> No stateful arbitrary CALL executes inside a user's swap. It could poison unlocked PoolManager deltas even without reverting. Instead, fixed host record() attests a trade, commits to its original module/data/amounts, and emits a receipt. Anyone can call execute() separately while PM is locked, at most once after success. It requires original data, original module, gas bounds and callback selector. Failure must roll back only delivery and preserve receipt; replay and replacement must not redirect it. Sender is the PM caller (often a router), not proven beneficial owner. No receipt is proof that a program actually completed business work; no autonomous dispatcher is in this contract release. Examine locked checks, return bombs, forged receipts, reentrancy, callback effects and gas griefing. A malicious optional program must not block the original trade.
>
> The token retains ERC20/ownership/metadata namespaces and removes privileged mint/restriction/upgrade logic. The finalizer atomically upgrades both actual proxies then renounces both actual ProxyAdmin owners; cancellation before completion must recover both even after partial handover. Remaining token owner may select programs and edit metadata, not core protections. Check all codehash/constructor checks, alternative selectors and delegate chains. Fixed legacy burn/liquidity processors remain by exact address AND runtime hash. Existing timed-burn interval is preserved, no new activation. New programs can use their own future allocations for different independent processors.
>
> Mainnet addresses: token 0x1b54e762aa34cf6e28e9c082f2848e28e45da6b8; hook 0x37bfb8ac7c960e558657871d41ca70e07e7dbfff; tokenAdmin 0xfb4b9d2ba3ef17787bfe3fd90c3ea7b319807a57; hookAdmin 0x1f22e4b065b2f15fed5be556dc0fa636048d29bc. Exact baselines in manifest/inventory; fork tests use isolated chain31337 at the exact Ethereum block recorded in the manifest and injected test-user funds, not production writes. EIP170 size, layout, custody and atomic finalization require scrutiny because final core is irreversible.
>
> Report concrete findings with severity, affected code, exploit preconditions and required fix. Do not deploy, mutate repositories, accept terms, send social posts or spend funds. Passing local cases are not an independent audit. Distinguish verified bugs from hypothetical concerns and limits of review. Trade-freezing, custody, ceiling and alternative-upgrade findings are release-blocking.
>
>
> Revision r3 addresses actual accepted findings in the original r1 audit that also affected r2: PhaseOneFinalizer now pins the canonical proxies/admins and checks raw implementation roles and current token ownership; ProtectedHook pins all thirteen existing mandatory dependency addresses/runtime hashes plus attester, question hash and spacing. Verify these fixes rather than assuming they work. ProtectedToken and fee accounting are unchanged from r2. TradeExtensionHost additionally fixes the independently reported caller-input bypass: oversized user data (>4096 bytes) and insufficient caller gas reject instead of choosing a cheaper default plan or dropping the receipt. Valid-input program failures still fall back safely. Check that this distinction works in real routed trades as well as view quotations. Never treat either earlier review as approval of this revision.
>
> The existing sources for the previous OpenTransferToken, TimedBuybackHook and ClausLpRewards are supplied in candidates/phase1/references. Compare metadata/storage and mandatory custody dependencies. The additional local cases assert exact metadata and nonempty 12-hour schedule slots survive finalization. There are 19 core and four pool-program cases. The example under test/support is local test code only, not a production deployment target: it reads a test oracle and uses its own earned claims for buy/burn and own LP add/rebalance/remove. Verify failed oracle/foreign-claim actions do not prevent ordinary user trades or exits. No automatic executor or live selected module is part of phase one.
>
> Known retained boundaries: fallback after a failed program quote still records the program with zero program fees; receipts do not prove successful quotation or paid work, and programs must inspect actual fee fields. fee plans may change immediately within the shared ceiling, so slippage applies; no mandatory timelock was requested. Dust whose protocol fee rounds above the strict 15% cap is intentionally rejected rather than overcharged. Historical and new NFT accrual events are aliases for the same single payment. Evaluate real exploit paths and material residual risks; do not silently weaken the user's ceiling or require removing useful pool actions.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network-contracts |
| Commit | `ad2b72702448b2d85b5581dec01d4ccaffc2ede8` |
| Job | `b17295c2-b8a1-4cfc-a730-d3693ee8c6c6` |
| Judged | 2026-10-15 11:54 UTC |
| Findings | 3 low · 7 info |

Four agents audited the code as it is at `ad2b727`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Low: finalize() renounces both ProxyAdmins without re-verifying owner/storage continuity through the new implementations

`candidates/phase1/contract/src/PhaseOneFinalizer.sol:56`

```
        if(IProtectedCore(token).protectionVersion()!=1 || IProtectedCore(hook).protectionVersion()!=1
```

finalize() checks ICurrentTokenOwner(token).owner()==owner (line 52) while the OLD implementation is still active, upgrades both proxies, and then verifies only protectionVersion/protectionKind/maximumTotalTaxPips (lines 56-58). Those three selectors are pure constants in ProtectedToken/ProtectedHook, so they prove an identity claim, not that the new implementation's ERC-7201 Ownable/ERC20/metadata namespaces coincide with the proxy's live storage. Because renounceOwnership() of both ProxyAdmins follows in the same transaction (line 59), a namespace mismatch would be permanent: owner() would read address(0), so TradeExtensionHost.setModule and ProtectedToken.updateMetadata would be unusable forever, or balances/name would read empty. The real r3 ProtectedToken is NOT affected (control test: with ProtectedToken as implementation the owner survives; the live proxy's Ownable slot 0x9016d0..9300 holds 0xBb145c.. and its Ownable2Step pendingOwner slot 0x237e15..2c00 reads zero via RPC at the current head, so no stale acceptOwnership path exists today). The gap is that the irreversible step relies on the operator passing the right artifact rather than on the finalizer checking continuity itself. Required fix (keeps the atomic design, a few hundred bytes): capture ownerBefore, totalSupply() and keccak256(name()) at the top of finalize(), and after the two upgradeAndCall calls revert InvalidFinalization() unless owner()==owner, totalSupply()==supplyBefore and name hash unchanged, before calling renounceOwnership(). Merged from audit_economics (finalizer continuity) and audit_permissions (stale pendingOwner state check, resolved by RPC read).

**Reproduction**

Local test test/scratch/FinalizerPostCheck.t.sol (no fork): the canonical proxy/admin addresses are etched with a minimal transparent proxy and OZ ProxyAdmin, the proxy's ERC-7201 Ownable slot holds the owner, and the candidate token implementation returns the three expected getters but reads owner() from slot 0. Constructor and both admin handovers succeed. Owner calls finalize(). Expected: revert InvalidFinalization (owner lost). Actual: finalize() succeeds, token.owner()==address(0), both ProxyAdmin.owner()==address(0). forge test --match-path test/scratch/FinalizerPostCheck.t.sol: testFinalizeMustRevertWhenOwnerIsLostAfterUpgrade FAILS ('next call did not revert as expected'); control testRealProtectedTokenPreservesOwner PASSES.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {PhaseOneFinalizer} from "src/PhaseOneFinalizer.sol";
import {ProtectedToken} from "src/ProtectedToken.sol";

/// Minimal transparent proxy standing in for the canonical proxies (etched at their addresses).
contract MiniProxy {
    bytes32 constant IMPL = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 constant ADMIN = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
    function _load(bytes32 s) private view returns (address a) { assembly { a := sload(s) } }
    function upgradeToAndCall(address impl, bytes calldata data) external payable {
        require(msg.sender == _load(ADMIN), "not admin");
        bytes32 s = IMPL; assembly { sstore(s, impl) }
        if (data.length > 0) { (bool ok,) = impl.delegatecall(data); require(ok); }
    }
    fallback() external payable {
        address impl = _load(IMPL);
        assembly {
            calldatacopy(0, 0, calldatasize())
            let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            if iszero(ok) { revert(0, returndatasize()) }
            return(0, returndatasize())
        }
    }
}
/// Current implementation stand-in: owner lives in the ERC-7201 Ownable namespace.
contract LegacyTokenImpl {
    bytes32 constant OWNER_SLOT = 0x9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c199300;
    function owner() external view returns (address a) { bytes32 s = OWNER_SLOT; assembly { a := sload(s) } }
}
/// Candidate that passes every finalizer identity check but reads owner from the wrong namespace.
contract WrongNamespaceToken {
    address private _owner; // slot 0: never written in the proxy
    function owner() external view returns (address) { return _owner; }
    function protectionVersion() external pure returns (uint256) { return 1; }
    function protectionKind() external pure returns (bytes32) { return keccak256("CLAUS_TOKEN"); }
    function maximumTotalTaxPips() external pure returns (uint24) { return 150_000; }
}
contract HookImplStub {
    function protectionVersion() external pure returns (uint256) { return 1; }
    function protectionKind() external pure returns (bytes32) { return keccak256("CLAUS_HOOK"); }
    function maximumTotalTaxPips() external pure returns (uint24) { return 150_000; }
}

contract FinalizerPostCheckTest is Test {
    address constant OWNER = 0xBb145cA83272c3806D4dDc75cA1D5514789cF1C5;
    address constant TOKEN = 0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8;
    address constant HOOK = 0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf;
    address constant TA = 0xfB4b9d2ba3eF17787bFE3FD90C3eA7b319807a57;
    address constant HA = 0x1f22E4B065b2f15Fed5BE556DC0fA636048d29bc;
    bytes32 constant IMPL = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 constant ADMIN = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
    bytes32 constant OWNER_SLOT = 0x9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c199300;

    function setUp() public {
        bytes memory proxyCode = address(new MiniProxy()).code;
        bytes memory adminCode = address(new ProxyAdmin(OWNER)).code;
        address legacyToken = address(new LegacyTokenImpl());
        address legacyHook = address(new HookImplStub());
        vm.etch(TOKEN, proxyCode); vm.etch(HOOK, proxyCode);
        vm.store(TOKEN, ADMIN, bytes32(uint256(uint160(TA)))); vm.store(HOOK, ADMIN, bytes32(uint256(uint160(HA))));
        vm.store(TOKEN, IMPL, bytes32(uint256(uint160(legacyToken)))); vm.store(HOOK, IMPL, bytes32(uint256(uint160(legacyHook))));
        vm.store(TOKEN, OWNER_SLOT, bytes32(uint256(uint160(OWNER))));
        vm.etch(TA, adminCode); vm.etch(HA, adminCode);
        vm.store(TA, bytes32(0), bytes32(uint256(uint160(OWNER)))); vm.store(HA, bytes32(0), bytes32(uint256(uint160(OWNER))));
        assertEq(LegacyTokenImpl(TOKEN).owner(), OWNER);
        assertEq(ProxyAdmin(TA).owner(), OWNER);
    }
    function _handover(PhaseOneFinalizer f) internal {
        vm.startPrank(OWNER);
        ProxyAdmin(TA).transferOwnership(address(f)); ProxyAdmin(HA).transferOwnership(address(f));
        vm.stopPrank();
    }
    /// Fails on current code: finalize() succeeds although the upgraded proxy no longer reports the owner,
    /// and both ProxyAdmins are renounced in the same transaction. Passes once finalize() re-checks owner()
    /// through the new implementation before renouncing.
    function testFinalizeMustRevertWhenOwnerIsLostAfterUpgrade() public {
        PhaseOneFinalizer f = new PhaseOneFinalizer(OWNER, TOKEN, HOOK, ProxyAdmin(TA), ProxyAdmin(HA), address(new WrongNamespaceToken()), address(new HookImplStub()));
        _handover(f);
        vm.prank(OWNER);
        vm.expectRevert(PhaseOneFinalizer.InvalidFinalization.selector);
        f.finalize();
        assertEq(LegacyTokenImpl(TOKEN).owner(), OWNER, "owner must survive or finalization must fail");
    }
    /// Control: the real ProtectedToken keeps the owner; unaffected by the fix.
    function testRealProtectedTokenPreservesOwner() public {
        PhaseOneFinalizer f = new PhaseOneFinalizer(OWNER, TOKEN, HOOK, ProxyAdmin(TA), ProxyAdmin(HA), address(new ProtectedToken()), address(new HookImplStub()));
        _handover(f);
        vm.prank(OWNER); f.finalize();
        assertEq(ProtectedToken(TOKEN).owner(), OWNER);
        assertEq(ProxyAdmin(TA).owner(), address(0)); assertEq(ProxyAdmin(HA).owner(), address(0));
    }
}
```

### 2. Low: Oversized hookData (>4096 bytes) is rejected even when no trade program is selected (behaviour change from the live hook)

`candidates/phase1/contract/src/TradeExtensionHost.sol:69`

```
        if(data.length>4096) revert HookDataTooLarge();
```

The 4096-byte limit is enforced before the module==address(0) early return on line 71. In the phase-one initial state no program is selected and the payload is never read, yet any main-pool swap whose router attaches more than 4096 bytes of hookData reverts in beforeSwap (ProtectedHook._beforeSwapExtension -> extensions.quote -> HookDataTooLarge). The live FeePrivacyHook ignores hookData entirely, so aggregators that forward large opaque bytes would start failing at finalization, before any program exists. The brief allows rejecting an oversized caller payload and nothing persists (the same caller succeeds with <=4096 bytes), so this is not a freeze; it is an unannounced change to the no-program path. Fix if the no-module path should stay identical to today: move the length check after the module==address(0) return (record() only runs when a module is set, so receipts are unaffected), or document the 4096-byte limit for integrators. Merged from audit_permissions and audit_flow.

**Reproduction**

test/scratch/HostUnit.t.sol testOversizedDataRejectedWithoutModule (passes, confirming the behaviour): activeModule==address(0); host.quote(addr, true, -1 ether, new bytes(4097)) reverts HookDataTooLarge; host.quote(..., new bytes(4096)) returns (address(0), DEFAULT_PLAN). Expected per the live hook: hookData ignored.

### 3. Low: Permissionless execute() lets any third party pick the delivery gas, consuming a receipt whose program swallows internal failure

`candidates/phase1/contract/src/TradeExtensionHost.sol:108`

```
        if(gasLimit<10_000 || gasLimit>5_000_000 || gasleft()<gasLimit+gasLimit/63+30_000) revert ModuleExecutionFailed();
```

execute() has no caller restriction and accepts any gasLimit in [10_000, 5_000_000]; the host only requires that onTrade returns its selector (line 117). The host does guarantee the module receives the full gasLimit (the 63/64 check on this line) and that a failed delivery rolls back and keeps the receipt. But a program whose onTrade catches its own failures (try/catch or low-level call) and still returns the selector has its receipt permanently consumed by anyone calling execute(trade, data, smallGas): pending[id] is deleted, TradeExecuted is emitted, and the receipt cannot be re-issued. This is inside the documented boundary (a receipt is not proof of paid work) and touches no user funds or core accounting, but the consequence is one-shot and irreversible per receipt. Fix: document that onTrade must revert (not swallow) on any failed step, and/or let the module declare a minimum delivery gas (e.g. a view consulted by execute()) that is enforced as a floor on gasLimit. Merged from audit_permissions and audit_flow.

**Reproduction**

test/scratch/HostUnit.t.sol testThirdPartyLowGasExecuteConsumesReceipt (passes, confirming the behaviour): HOOK records a receipt for module M whose onTrade does target.call{gas:3000}(work()) and returns the selector regardless. An unrelated address calls host.execute(trade, '', 20_000). Expected by the program author: work done or receipt kept. Actual: pending[1]==0, target.n()==0 (work never ran), and a later execute(trade, '', 2_000_000) reverts InvalidReceipt.

### 4. Info: Caller hookData within the 4096-byte limit can still starve a data-dependent program quote into the default 2.3% plan

`candidates/phase1/contract/src/TradeExtensionHost.sol:78`

```
            ok := staticcall(80000,module,add(input,32),mload(input),allocations,192)
```

r3 rejects >4096-byte data and insufficient caller gas, but the fixed 80,000-gas STATICCALL budget is shared between the program's own logic and whatever work the caller's data induces. A program whose quote cost scales with data length can be pushed out of gas by a swapper supplying up to 4096 bytes of junk; the host then applies DEFAULT_PLAN (module allocation 0) and, in a real swap, still records a receipt with moduleFeeEth=0. When the program's plan is above 2.3% this is a swapper-side fee reduction; when below, a swapper-side overcharge, always inside the 15% ceiling and never a revert. This is the retained 'program failures fall back safely' boundary, not a host defect; recorded so program authors know a quote must be O(1) in data length and well under 80k gas for any 4096-byte input, and must not treat a receipt as proof the quoted plan applied. Optional hardening: pass the program only keccak256(data) (plus a bounded prefix) so data-dependent work is capped in the host.

**Reproduction**

test/scratch/HostUnit.t.sol testCallerDataCanStarveProgramQuoteIntoDefault (passes): owner installs a module whose quote loops over data and returns [0,0,0,0,0,149000]. host.quote(addr,true,-1 ether,'') yields rate 149000; host.quote(..., new bytes(4096)) yields exactly DEFAULT_PLAN (2.3%, module 0). Expected under the program's intent: 14.9%. Actual: 2.3%.

### 5. Info: Trust assumption: token owner can install any program instantly; quotes are sender- and context-dependent and only the 14.9% ceiling is binding

`candidates/phase1/contract/src/TradeExtensionHost.sol:55`

```
        if(msg.sender != IClausGovernor(TOKEN).owner()) revert NotAuthorized();
```

setModule has no timelock and the quote receives the PoolManager caller (usually a router) plus full chain context. A program may therefore (a) return [0,0,0,0,0,149000] for every router except the owner's own and zero for that one, directing 14.9% of all other volume to itself as new PoolManager claims, and (b) answer differently to quoteTradeFee (an eth_call, MANAGER.isUnlocked()==false) and to the swap-time quote (isUnlocked()==true), so the view getter the hook exposes on ProtectedHook.sol line 83 is indicative, not a commitment. All core bounds hold: per-field and sum caps at MAX_RATE, fallback on malformed output, only newly earned fee minted to the module in FeeNftStakingHook._accrue, no rejection path. This is the intended retained authority (README/REVIEW-FOLLOWUP: quotes are indicative, slippage applies). Recorded so users and integrators see the asymmetry; router slippage is the only user-side protection. No core change requested; a voluntary delay on module changes is optional. Merged from audit_economics (two-faced quote) and audit_permissions/audit_flow (owner trust assumption).

**Reproduction**

Code-level: TradeExtensionHost.quote line 75 forwards sender and data to the program; IClausTradeModule.quote is a view that may call MANAGER.isUnlocked() or branch on sender; nothing in the host compares the swap-time plan with any earlier quotation. Owner calls setModule(P) with P.quote returning [0,0,0,0,0,149000] when sender!=OWNER_ROUTER (or when isUnlocked()) and zeros otherwise: both plans pass lines 83-90 (sum<=149000), the next buy pays 14.9% to P while quoteTradeFee reported 0. The specialist's fork case (module claim grew by 0.005 ether*149000/1e6 wei) was not independently rerun here.

### 6. Info: Receipt amounts are pool-side swap deltas, not user-side amounts; programs must add feeEth

`candidates/phase1/contract/src/ProtectedHook.sol:95`

```
            extensions.record(module,sender,params.zeroForOne,delta.amount0(),delta.amount1(),fee,extra,keccak256(data));
```

v4 Hooks.afterSwap passes swapDelta to the hook and subtracts the hook's returned delta afterwards (Hooks.sol afterSwap: callHookWithReturnDelta(..., swapDelta, ...) then hookDelta applied in PoolManager.swap). FeeEfficientHook._settleFee relies on exactly this (expected = requested - fee for exact-input buys, line 254). The receipt therefore stores amount0 = -(requested - fee) for an exact-input buy and requested + fee for an exact-output sell, not what the user paid or received. A program treating amount0 as the trader's gross ETH undercounts by feeEth on buys and overcounts on exact-output sells. Not a custody or ceiling defect; receipts are informational. Fix: document the delta basis in IClausTradeModule/TradeRecorded, or store the user-side amounts (pool delta adjusted by the hook delta).

**Reproduction**

Exact-input buy of 0.005 ether with an active 149000 plan: fee = 0.005e18*149000/1e6 = 7.45e14; the delta passed to afterSwap has amount0 = -(5e15 - 7.45e14) = -4.255e15 (verified from Hooks.afterSwap ordering and _settleFee's `expected = requested - fee` check), while the user paid 5e15. Expected by a naive program: -5e15. Actual: -4.255e15; the difference equals trade.feeEth.

### 7. Info: Finalizer accepts any implementation that self-reports protectionKind/version/ceiling; artifact hashes must be verified off-chain before admin handover

`candidates/phase1/contract/src/PhaseOneFinalizer.sol:43`

```
        if (IProtectedCore(tokenImpl_).protectionKind() != keccak256("CLAUS_TOKEN")
```

r3 correctly pins the canonical proxies/admins (lines 33-37), checks the live token owner at construction and at finalize, snapshots the implementation codehashes and re-checks them before the atomic upgrade, and post-checks version/kind/ceiling before renouncing both ProxyAdmins; cancel() returns whichever admins were handed over. What it cannot do is prove that tokenImpl_/hookImpl_ were built from the reviewed source: any contract answering the three getters passes, and its codehash is then frozen as the irreversible final core. A genuine ProtectedHook cannot be mis-wired (its constructor pins all thirteen dependencies, attester, question hash, spacing and the host runtime hash), but the owner could still finalize a different contract. This matches the requester's own disposition ('a self-reported protectionKind is not proof of source'). Open release-process item: publish the expected implementation addresses and the finalizer's tokenCodeHash/hookCodeHash, and have independent parties reproduce them (manifest runtime hashes with immutables applied) before transferring the ProxyAdmins; or embed the expected codehashes as constants in the finalizer. Merged from audit_permissions and audit_flow.

**Reproduction**

test/scratch/FinalizerPostCheck.t.sol testRealProtectedTokenPreservesOwner (passes): the hook implementation passed to the finalizer is HookImplStub, a stub with only protectionVersion()/protectionKind()/maximumTotalTaxPips() and no hook logic. Constructor accepts it, finalize() installs it on the hook proxy and renounces both admins. Expected (operator intent): only the reviewed ProtectedHook is accepted. Actual: any such stub is accepted.

### 8. Info: Dead pre-staking accrual branch would underflow and revert every swap for any plan below 2% if it were reachable

`candidates/phase1/contract/src/legacy/privacy/FeeLpRewardsHook.sol:161`

```
        uint256 platform = totalFee - project;
```

FeeNftStakingHook._accrue (line 43) delegates to this legacy _accrue when block.timestamp < nftStakingStartsAt. That path hard-codes project = gross/50 (FeeMath.projectFromGross) and computes platform = totalFee - project with checked arithmetic. Under r3 totalFee is the program-selected rate, which may be anywhere in [0, 14.9%]; any rate below 20_000 pips (including the supported zero-fee plan) underflows and reverts the swap, i.e. a trade freeze rather than a fallback. nftStakingStartsAt is an immutable read from the pinned ClausNftStaking 0x4e59..CAD4 whose startsAt() reads 1791672095 via RPC, earlier than the manifest fork block 26197496 timestamp 1792062275, so on the target chain the branch is unreachable and the plan-based path (lines 47-79 of FeeNftStakingHook) is the only live one. Recorded as a latent hazard because the final core is irreversible and this is the only path in the accounting kernel that can turn a valid plan into a revert; removing the branch or routing it through the plan-based arithmetic costs nothing behaviourally. Merged from audit_economics, audit_permissions and audit_flow.

**Reproduction**

Hypothetical state only: block.timestamp < 1791672095 and a module plan [0,0,0,0,0,0] (or any plan with sum < 20000). Exact-input buy of 1 ether: _settleFee computes fee=0, _accrue(1e18, 0): project = 2e16, platform = 0 - 2e16 -> Panic(0x11), swap reverts. Expected: zero-fee trade settles. Not reachable at or after the recorded fork block (startsAt already past).

### 9. Info: Quantified dust boundary of the strict 15% ceiling (intentional): exact-input buys up to 5,933 wei can revert at the maximum plan with the maximum protocol fee

`candidates/phase1/contract/src/legacy/FeeEfficientHook.sol:330`

```
            if(fee+protocol>gross*150_000/1_000_000) revert TotalTaxExceeded();
```

The buy-side ceiling compares floor(gross*plan) plus the v4 protocol charge against floor(gross*15%). The hook fee and the ceiling floor while v4's protocol fee rounds up (Pool.sol: with lpFee 0, swapFee==protocolFee so the entire SwapMath feeAmount = remaining - floor(remaining*(1e6-fee)/1e6) goes to the protocol), so small trades fail even though no party overcharges. This is the boundary REVIEW-FOLLOWUP calls intentional; this entry only quantifies it so integrators can set minimums. With a 149,000 pip plan and a 1,000 pip zeroForOne protocol fee, gross amounts up to 5,933 wei can revert TotalTaxExceeded (the specialist counted 1,130 distinct failing values); with the default 23,000 pip plan only gross 1-6 wei revert. On the sell side (line 335) token inputs of 1-6 atoms whose protocol fee rounds to one atom revert. No state persists, later trades are unaffected, and no amount above 5,933 wei fails. No fix is required if the strict ceiling is the requested behaviour; the only alternative (integer cross-multiplication of the ceiling) would let one-atom protocol rounding slightly exceed 15% on sub-20,000-wei trades, which the owner has declined.

**Reproduction**

Arithmetic reproduced by hand: protocol fee 1000|(1000<<12), plan sum 149,000, exact-input buy amountSpecified=-5933: fee=floor(5933*149000/1e6)=884, poolIn=5049, protocol=5049-floor(5049*999000/1e6)=6, 884+6=890 > floor(5933*0.15)=889 -> TotalTaxExceeded. amountSpecified=-5934: fee=884, poolIn=5050, protocol=6, 890 <= 890 -> succeeds. Default plan: amountSpecified=-6: fee=0, protocol=1, 1 > 0 -> reverts; -7 succeeds.

### 10. Info: A sell that consumes zero input reverts with TotalTaxExceeded instead of a descriptive no-fill error

`candidates/phase1/contract/src/legacy/FeeEfficientHook.sol:335`

```
            if(protocol>input*150_000/1_000_000 || protocol==input) revert TotalTaxExceeded();
```

The division-by-zero guard protocol==input also fires when both are zero. A oneForZero swap whose price limit is reached before any in-range liquidity (v4 returns a zero BalanceDelta rather than reverting) reaches afterSwap with input=0 and protocol=0: _settleFee computes fee=0 from ethAmount=0, then this check reverts under the ceiling error. The buy side rejects the same situation as PartialNativeFill. Impact is nil: no fee is charged, nothing persists, and the main pool currently has full-range liquidity so the state needs a limit set inside an empty range. Reported for completeness; a fix is `if(input==0) revert PartialNativeFill();` ahead of the ceiling check, or leaving it since the revert is harmless.

**Reproduction**

State: no liquidity between the current sqrt price and the caller's sqrtPriceLimitX96. Input: swap(zeroForOne=false, amountSpecified=-1e18, sqrtPriceLimitX96 = current price + 1 tick). Expected: a no-fill revert or zero-delta success. Actual (by code): delta.amount1()==0 so input=0, protocolFeesAccrued diff=0, `protocol==input` is true and the swap reverts TotalTaxExceeded, misreporting a tax violation. Not executed on a pool locally.

---

Judge's submission `1ecbd80033c9b34cdb8e261b3fe0d4b941d92495d9340db5284b3998f73eaf53`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
