# Audit report

> 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.
>
> Entrypoints: 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.
>
> The 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.
>
> ProtectedToken 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.
>
> The 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.
>
> The 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.
>
> Fork 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.
>
> Return 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.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network-contracts |
| Commit | `541e82145e2c13f64890a9c2e8a1751dd9e42a12` |
| Job | `9f40cf8c-caa2-4b18-bceb-5f5ad428686e` |
| Judged | 2026-10-15 11:02 UTC |
| Findings | 1 high · 1 medium · 1 low · 4 info |

Four agents audited the code as it is at `541e821`, 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. High: 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

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

```
        if(owner_==address(0) || token_==hook_ || address(tokenAdmin_)==address(hookAdmin_)
```

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.

**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).

**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 {PhaseOneFinalizer} from "src/PhaseOneFinalizer.sol";
import {ProtectedToken} from "src/ProtectedToken.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";

/// Placeholder for whatever implementation the two live proxies currently run.
contract OldImplementation {
    function protectionVersion() external pure returns (uint256) { return 0; }
    function init() external {}
}

/// Stand-in for the ProtectedHook implementation (the real one can only be
/// constructed against mainnet code hashes). It answers the finalizer's
/// post-upgrade self-checks exactly like ProtectedHook does.
contract HookStandIn {
    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; }
}

/// The upgrade owner, driving the finalizer with the token and hook arguments swapped.
contract Operator {
    function run(address tokenProxy, address hookProxy, ProxyAdmin tokenAdmin, ProxyAdmin hookAdmin,
        address tokenImpl, address hookImpl) external
    {
        // token_ and hook_ swapped, and the admins swapped to match them.
        PhaseOneFinalizer f = new PhaseOneFinalizer(address(this), hookProxy, tokenProxy, hookAdmin, tokenAdmin, tokenImpl, hookImpl);
        tokenAdmin.transferOwnership(address(f));
        hookAdmin.transferOwnership(address(f));
        f.finalize();
    }
}

contract FinalizerSwappedArgsTest is Test {
    address constant TOKEN = 0x1b54E762aa34CF6E28E9C082F2848e28E45DA6b8;
    address constant HOOK = 0x37Bfb8AC7C960E558657871D41Ca70E07e7DbfFf;
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

    Operator operator;
    ProxyAdmin tokenAdmin;
    ProxyAdmin hookAdmin;
    ProtectedToken tokenImpl;
    HookStandIn hookImpl;

    function setUp() public {
        operator = new Operator();
        OldImplementation old = new OldImplementation();
        bytes memory init = abi.encodeCall(OldImplementation.init, ());
        TransparentUpgradeableProxy t = new TransparentUpgradeableProxy(address(old), address(operator), init);
        TransparentUpgradeableProxy h = new TransparentUpgradeableProxy(address(old), address(operator), init);
        _place(address(t), TOKEN);
        _place(address(h), HOOK);
        tokenAdmin = ProxyAdmin(address(uint160(uint256(vm.load(TOKEN, ADMIN_SLOT)))));
        hookAdmin = ProxyAdmin(address(uint160(uint256(vm.load(HOOK, ADMIN_SLOT)))));
        assertEq(tokenAdmin.owner(), address(operator));
        assertEq(hookAdmin.owner(), address(operator));
        tokenImpl = new ProtectedToken();
        hookImpl = new HookStandIn();
    }

    /// Move a freshly deployed transparent proxy (code with its immutable admin, plus
    /// the ERC-1967 implementation and admin slots) to the pinned mainnet address.
    function _place(address from, address to) internal {
        vm.etch(to, from.code);
        vm.store(to, IMPL_SLOT, vm.load(from, IMPL_SLOT));
        vm.store(to, ADMIN_SLOT, vm.load(from, ADMIN_SLOT));
    }

    /// With token_/hook_ (and their admins) swapped, every finalizer check still passes:
    /// the ERC20 code is installed on the hook proxy, the hook code on the token proxy,
    /// and both ProxyAdmins are renounced, so the mistake is permanent.
    /// Expected: construction or finalize() reverts. Actual: the run completes.
    function testSwappedTokenAndHookArgumentsMustNotFinalize() public {
        vm.expectRevert();
        operator.run(TOKEN, HOOK, tokenAdmin, hookAdmin, address(tokenImpl), address(hookImpl));
    }
}
```

### 2. Medium: 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

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

```
        if (address(base.manager) != address(MANAGER) || base.reserve.token() != TOKEN || base.reserve.hook() != HOOK
            || address(base.processor) != 0x271f808cb55C7Cbbb3d4df0DA075deF9AE5F7340
            || address(base.processor).codehash != 0x5edefdcfd727df0398f04ec40b22722bc48db21d7c3de6323de65551b7bed2c5
```

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).

**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.

### 3. Low: 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

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

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

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).

**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.

### 4. Info: 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

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

```
    function setExtraFeePips(uint24 value) external {
        if(msg.sender != IFeeGovernor(TOKEN).owner() || value > ClausFeeBudget.MAX_EXTRA) revert InvalidActivation();
        ClausFeeBudget.config().extraPips=value; emit ExtraFeeChanged(value);
```

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).

**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.

### 5. Info: 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

`candidates/phase1/contract/src/legacy/privacy/FeeNftStakingHook.sol:67`

```
            emit NftFeeAccrued(gross, nftEth, epoch);
            emit NftStakingFeeAccrued(gross, nftEth, epoch);
```

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).

**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.

### 6. Info: Dead pre-staking accrual branch would route the global extra fee to PlatformRevenue instead of the project vault

`candidates/phase1/contract/src/legacy/privacy/FeeNftStakingHook.sol:42`

```
        if (block.timestamp < nftStakingStartsAt) {
            super._accrue(gross, totalFee);
```

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).

**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.

### 7. Info: Fork suite omits assertions that directly protect the irreversible step (metadata survival, timed-window migration, swapped finalizer arguments, substituted destinations, partial fills, dust trades)

`candidates/phase1/contract/test/PhaseOneFork.t.sol:85`

```
    function testAtomicSealPreservesCustodyAndRemovesBothUpgradeOwners() public {
```

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).

**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.

---

Judge's submission `7d2e3092266c288876704b3dbe2d915ab2788912ccbc0a2f55be9779b9d6bbe8`, 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.
