{"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":"8302b160-5df9-4cda-b173-6c1976a0f8ac","kind":"audit","nodes":[{"acceptedSubmissionHash":"80d3f84fc2193e25a88c7565798c3a739a1789c670b528231ae0da43965fbd0c","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":"ff455690f73bab66aa9fbbfc234caf25666cb6ff03e2f88054609706e45e4458","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":"11b8d2766b6f83116afc388aa889926161e121df705acf2bff3462ac32262162","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":"db051d7891c829a1469c934787662526cb813bba3eb2df26047f88ac8e510a28","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":"7b2d40068ad96dddf09732c2041584a670738af90ce7e73f2cf8b057cbec021d","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":"Audit BunkerVault, a shared post-quantum authorized vault for public ERC-20 and ETH balances (Foundry repo, solc 0.8.30, Cancun). Read README.md and docs/CONTRACT.md (specification, invariants, trust assumptions) first.\n\nSCOPE\nPrimary: src/BunkerVault.sol, src/AuthorizationMessage.sol, src/interfaces/. Secondary: how the vault relies on the vendored research ML-DSA-44 verifier test/vendor/fireblocks/MLDSA44Verifier.sol (return handling, code-hash and helper pinning, 20,545-byte key blob layout); report a verifier bug only if it lets a signature verify without the key. Out of scope: lib/ (vendored forge-std, OpenZeppelin 5.6.1), test/mocks/, script/, deployments/, gas optimizations without security impact.\n\nDESIGN\nOne contract holds many vaults. vaultId = keccak(domain, keccak(1312-byte ML-DSA public key)). createVault: the payer submits it themselves, an ML-DSA proof of possession over the full registration intent is verified BEFORE any fee-token call, then exactly 10**decimals fee tokens are pulled: 90% stay locked forever (totalLockedFees), 10% go to an immutable developer. The payer becomes controllerOf[vaultId], permanently. Every later operation (depositERC20, withdrawERC20, depositETH, withdrawETH, rotateKey) goes through _consumeAuthorization: msg.sender must be the controller AND a valid ML-DSA signature must cover chain id, vault address, vaultId, key hash, key code hash, operation constant, paramsHash of all arguments, exact next nonce, expiry (max 1 hour) and key epoch. The nonce is consumed before token/ETH calls, under nonReentrant. ETH is wrapped into one constructor-pinned WETH; receive() accepts ETH only from WETH during withdrawETH. A token's runtime code hash is pinned at its first deposit. rotateKey needs the old key's signature and the new key's possession signature; nonce stays monotonic, epoch increments, old keys stay burned. No owner, admin, upgrade, rescue, approval or ECDSA fallback exists.\n\nTHREAT MODEL\nThe vault's key file is secret. The attacker may control the controller wallet (e.g. an ECDSA break), any other EOA, contracts, malicious or reentrant tokens, the mempool and block ordering, and may register their own vaults and keys. Main question: can anyone WITHOUT a vault's ML-DSA key move, lock, freeze or misdirect that vault's assets, or take assets from other vaults or the locked fees?\n\nINVARIANTS TO BREAK\n1. No state change of a vault without a valid signature from its current key plus a transaction from its controller.\n2. A signature authorizes exactly one operation with exactly its signed parameters, once (no replay across nonces, epochs, operations, vaults, contracts or chains; no ambiguous encodings).\n3. For every token: sum of publicBalanceOf over vaults == totalPublicLiability <= token.balanceOf(vault). One vault, or a malicious token, cannot reduce another vault's withdrawable balance of a well-behaved token.\n4. totalLockedFees only grows; the fee token can never leave except the developer share at registration; the fee token is never a custody asset.\n5. The contract holds no ETH between calls except ETH forced in, which is never credited to anyone.\n6. Any failed operation reverts completely, including nonce consumption, asset admission and fee settlement.\n7. Registration and rotation reject wrong key sizes, blob size/prefix/code hash, reused keys, and run PoP before any external fee call.\n\nFOCUS PER SPECIALIST\n- Math: fee split for decimals 1..18, balance-delta checks in deposits/withdrawals/fee settlement, overflow/underflow paths, heldTokens paging bounds.\n- Permissions: _consumeAuthorization and every caller of it, controller binding, executor/feePayer checks in createVault and rotateKey, domain separation in AuthorizationMessage and paramsHash encodings, rotation logic, absence of privileged paths, abuse of open admission (registering someone else's key or blob) beyond the disclosed key-squatting risk.\n- Economics: fee collection and locking, griefing (blocking a payer slot, front-running registration or rotation, permanent freezes), donations, forced ETH, cost to an attacker vs harm to victims.\n- Control flow: reentrancy through tokens, WETH and ETH recipients, the unwrapping flag and receive()/fallback(), ordering of checks/effects/interactions, tokens whose code changes after pinning (metamorphic or proxy), tokens that lie in balanceOf/totalSupply, ERC-777 style hooks, revert and return-data handling of the verifier and SafeERC20.\n\nKNOWN AND ACCEPTED, see docs/CONTRACT.md section 9 (report only if the impact goes beyond what is stated)\nBalances and amounts are public. The contract cannot prove a blob equals prepare(pk) or that a key came from honest KeyGen (clients must check this off-chain); anyone can register a victim's public key first with a blob they control, which only denies that key. Losing or compromising the controller wallet cannot be fixed (no controller change by design). The controller wallet and Ethereum are not quantum safe. The fee token is trusted and reviewed separately. A malicious token can lie about itself; accepting a token is not certifying it, but harm must stay confined to that token. The ML-DSA verifier is unaudited research code. Each verification costs about 1.35M gas.\n\nHOW TO WORK\nforge build; forge test (103 tests). test/utils/VaultFixture.sol builds a vault with a mock verifier that accepts exactly the (key blob, message, signature) it was armed with: use authorize(...) and arm(...) to craft authorizations. test/utils/RealVerifierBase.sol deploys the real verifier. forge test --ffi adds real-key end-to-end tests (needs npm ci in test/ffi, 122 tests).\n\nDELIVERABLE\nEach finding: severity (Critical, High, Medium, Low, Informational), file:line, root cause, exact attack sequence and preconditions, impact on which vaults/assets, a Foundry test under test/ that fails on the current code (or a precise reason why one cannot be written), and a minimal fix. Mark any finding that only restates an accepted risk. Also list the invariants above you checked and could not break, with the reasoning.","parentJobId":null,"planHash":"956327e91e5d135073a6855ddd7fab4d0515143bc1b50abeb0539ab6b1643b9f","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"8302b160-5df9-4cda-b173-6c1976a0f8ac","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":"52188","feedbackHash":"4054b8d5c8b8dd8fa6abe156a7fedddda6466a3ed2ad1d67e275026e4f1ee454","nodeKey":"audit_economics","submissionHash":"80d3f84fc2193e25a88c7565798c3a739a1789c670b528231ae0da43965fbd0c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50994","feedbackHash":"47199d87359f7079d92c50e18e7acd5d563b358bed8f2220903eedeb0882cd11","nodeKey":"audit_flow","submissionHash":"ff455690f73bab66aa9fbbfc234caf25666cb6ff03e2f88054609706e45e4458","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52261","feedbackHash":"de304ecf533ed7b10244f1c7c9e1a36654312e2a9ec97499adbc9d00499bbdba","nodeKey":"audit_judge","submissionHash":"11b8d2766b6f83116afc388aa889926161e121df705acf2bff3462ac32262162","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52151","feedbackHash":"8b34b2315ed9dfc3e1ef7e4b2105c7a11664e2bc5d19f2e359cd747891d3d317","nodeKey":"audit_math","submissionHash":"db051d7891c829a1469c934787662526cb813bba3eb2df26047f88ac8e510a28","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52182","feedbackHash":"190755f5322f99b428990b102e49e9c4695ffc98bc79be38bc904c4f1108ed6b","nodeKey":"audit_permissions","submissionHash":"7b2d40068ad96dddf09732c2041584a670738af90ce7e73f2cf8b057cbec021d","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"9e7b127e86e692ab0a780f47fd908fd6339b3f7e9e0adf276b67ea4b56510ccc","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dedc96e38388cb45","findings":[{"citation":"resolved","description":"Root cause: the clamp compares `start + count` to `all.length`, but the sum is evaluated in checked arithmetic before the comparison. The early return on line 297 only covers start >= length. For any in-range start >= 1 and count > type(uint256).max - start the addition overflows and the view reverts with Panic(0x11) instead of returning the tail page that the NatSpec ('Out-of-range pages are empty') and docs/CONTRACT.md section 6 promise. The natural client idiom 'everything from start' (count = type(uint256).max) works for start = 0 and reverts for every later page. Impact: availability of the documented enumeration view only; no vault state, nonce, balance or authorization is involved, and an integrating contract that pages with a max count is wedged. Attack sequence: none needed; any caller triggers it. Minimal fix: compute the end without the overflowing sum, e.g. `uint256 remaining = all.length - start; uint256 end = count >= remaining ? all.length : start + count;` (start < all.length is already established so the subtraction cannot underflow and the sum is only evaluated when it is below all.length). Reproduced by the judge: both specialist proofs (Proof_207325c01b74, Proof_355c2cff5a95) fail on the current code with `panic: arithmetic underflow or overflow (0x11)`; the attached proof is the shorter one.","line":298,"path":"src/BunkerVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.30;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BunkerVault} from \"src/BunkerVault.sol\";\nimport {IMLDSAVerifier} from \"src/interfaces/IMLDSAVerifier.sol\";\n\n/// Minimal ERC20 (18 decimals by default) used both as fee token and as custody asset.\ncontract MiniToken {\n    uint8 public decimals;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    constructor(uint8 d) { decimals = d; }\n    function mint(address to, uint256 v) external { balanceOf[to] += v; totalSupply += v; }\n    function approve(address s, uint256 v) external returns (bool) { allowance[msg.sender][s] = v; return true; }\n    function transfer(address to, uint256 v) external returns (bool) {\n        balanceOf[msg.sender] -= v; balanceOf[to] += v; return true;\n    }\n    function transferFrom(address f, address to, uint256 v) external returns (bool) {\n        allowance[f][msg.sender] -= v; balanceOf[f] -= v; balanceOf[to] += v; return true;\n    }\n}\n\n/// Minimal WETH9-like contract (only what the constructor needs).\ncontract MiniWeth is MiniToken {\n    constructor() MiniToken(18) {}\n    function deposit() external payable { balanceOf[msg.sender] += msg.value; }\n    function withdraw(uint256 w) external { balanceOf[msg.sender] -= w; payable(msg.sender).transfer(w); }\n}\n\n/// Programmable verifier: accepts exactly the (key, message, signature) it was armed with.\ncontract ArmedVerifier is IMLDSAVerifier {\n    address key; bytes32 msgHash; bytes32 sigHash;\n    function arm(address k, bytes calldata m, bytes calldata s) external { key = k; msgHash = keccak256(m); sigHash = keccak256(s); }\n    function verify(address k, bytes calldata m, bytes calldata s) external view returns (bool) {\n        return k == key && keccak256(m) == msgHash && keccak256(s) == sigHash;\n    }\n}\n\ncontract HeldTokensPagingOverflowTest is Test {\n    address blob = makeAddr(\"blob\");\n    address dev = makeAddr(\"dev\");\n    MiniToken fee;\n    ArmedVerifier verifier;\n    MiniWeth weth;\n    BunkerVault vault;\n    bytes32 id;\n\n    function rep(uint8 v, uint256 n) internal pure returns (bytes memory out) {\n        out = new bytes(n);\n        for (uint256 i; i < n; ++i) out[i] = bytes1(v);\n    }\n\n    function setUp() public {\n        vm.etch(blob, bytes.concat(hex\"00\", rep(0x01, 20544)));\n        fee = new MiniToken(18);\n        verifier = new ArmedVerifier();\n        weth = new MiniWeth();\n        vault = new BunkerVault(address(fee), address(fee).codehash, dev, address(verifier),\n            address(verifier).codehash, address(weth), address(weth).codehash);\n        fee.mint(address(this), 1e18);\n        fee.approve(address(vault), 1e18);\n        BunkerVault.Registration memory reg = BunkerVault.Registration(rep(0x11, 1312), blob, blob.codehash,\n            address(this), address(this), block.timestamp + 1800, rep(0x42, 2420));\n        verifier.arm(blob, vault.registrationMessage(reg), reg.proofOfPossession);\n        id = vault.createVault(reg);\n        // Deposit two distinct tokens so the held-token list has length 2.\n        _depositNewToken();\n        _depositNewToken();\n        assertEq(vault.heldTokenCount(id), 2);\n    }\n\n    function _depositNewToken() internal {\n        MiniToken t = new MiniToken(18);\n        t.mint(address(this), 100);\n        t.approve(address(vault), 100);\n        bytes32 h = vault.assetConfigurationHash(address(t));\n        BunkerVault.Account memory acc = vault.account(id);\n        BunkerVault.Authorization memory a =\n            BunkerVault.Authorization(acc.nonce, block.timestamp + 1800, acc.keyEpoch, rep(0x42, 2420));\n        bytes32 params = keccak256(abi.encode(address(t), uint256(100), h, address(this)));\n        verifier.arm(acc.pkBlob,\n            vault.authorizationMessage(id, vault.DEPOSIT_ERC20(), params, a.nonce, a.expiresAt, a.keyEpoch),\n            a.signature);\n        vault.depositERC20(id, address(t), 100, h, a);\n    }\n\n    /// heldTokens documents \"Out-of-range pages are empty\" and `start + count` is clamped to the list\n    /// length, so start=1,count=type(uint256).max should return the one remaining token.\n    /// Current code computes `start + count` with checked arithmetic and reverts with Panic(0x11).\n    function test_HeldTokensClampsLargeCountInsteadOfOverflowing() public view {\n        // Sanity: start=0 with max count works because 0 + max does not overflow.\n        assertEq(vault.heldTokens(id, 0, type(uint256).max).length, 2);\n        // start=1: 1 + max overflows -> Panic(0x11) on current code. Expected: a one-element page.\n        address[] memory page = vault.heldTokens(id, 1, type(uint256).max);\n        assertEq(page.length, 1, \"page should hold the single remaining token\");\n        assertEq(page[0], vault.heldTokens(id, 1, 1)[0]);\n    }\n}","reproduction":"Register one vault, deposit two distinct ERC-20s so heldTokenCount(id) == 2. heldTokens(id, 0, type(uint256).max) returns 2 entries. heldTokens(id, 1, type(uint256).max): expected a one-element page holding the second token; actual: revert Panic(0x11) at line 298. Verified with `forge test --match-path test/scratch/Proof_207325c01b74.t.sol` -> [FAIL: panic: arithmetic underflow or overflow (0x11)].","severity":"low","snippet":"        uint256 end = start + count > all.length ? all.length : start + count;","title":"heldTokens paging reverts with Panic(0x11) for an in-range start and a large count instead of clamping to the list end (merged: permissions, economics, math, flow)"},{"citation":"resolved","description":"MARKED: extends accepted limitation L1 (open admission lets a mempool observer deny a victim's key); the vault's custody and nonce are never affected. What goes beyond section 9: L1 and L5 describe squatting only through createVault, where each burned key costs the squatter one registration fee (90% locked forever) and the one-time payer slot of a fresh address. rotateKey writes the same global usedPublicKeys mapping with no fee and no slot. Root cause: the new-key possession proof (line 280) is verified against rotation.pkBlob, which the caller chooses and which may be the vault's CURRENT blob, while rotation.publicKey is free 1312-byte input whose keccak is only checked for prior use (line 273) and then burned globally (line 287). A registered attacker therefore signs both the ROTATE_KEY authorization and the ROTATION_POP message with the single key they already hold and burns any key hash they like, about 2.8M gas per key, repeatably, without ever losing their own vault. Attack sequence: (1) attacker registers once with key K_A and blob B_A; (2) attacker watches the mempool for a victim createVault or rotateKey carrying public key K_V; (3) attacker front-runs with rotateKey(idA, KeyRotation(K_V, B_A, codehash(B_A), attacker, PoP by K_A), Authorization(nonce, expiry, epoch, sig by K_A)); (4) the victim's transaction reverts with PublicKeyAlreadyRegistered and the victim must generate a fresh key and deploy a fresh 20,545-byte blob (about 4.5M gas) for every retry, which the attacker can repeat for each retry. A vault owner that must rotate after suspecting key exposure can be kept from rotating indefinitely; funds remain withdrawable with the old key. Preconditions: one registered vault (one fee ever) and mempool visibility of the victim's key. Minimal fix options (design decision): (a) charge the registration fee (or a dedicated rotation fee) in rotateKey so burning a key costs the same as at registration; (b) require rotation.pkBlob to be a blob never used by any vault (track usedBlobs) so each burned key costs a 20,545-byte blob deployment; (c) commit-reveal for new keys in createVault and rotateKey (commit keccak256(publicKey, salt, msg.sender) one block earlier) which removes the front-running window entirely; (d) at minimum document rotation as a second, fee-free squatting path in CONTRACT.md section 9.","line":287,"path":"src/BunkerVault.sol","reproduction":"Judge scratch test test/scratch/JudgeRepro.t.sol::test_RotateKeyBurnsVictimKeyWithoutFee (passes on current code, demonstrating the behavior; verifier is an armed mock that only accepts messages signed for the attacker's blob). State: attacker registered vault idA with key 0x11*1312 and blob B_A; victim holds key K_V = 0x22*1312 and its own blob, not yet registered. Input: attacker calls rotateKey(idA, KeyRotation(K_V, B_A, B_A.codehash, attacker, sig), Authorization(0, now+600, 0, sig)). Actual: call succeeds, usedPublicKeys[keccak256(K_V)] == true, attacker fee balance and totalLockedFees unchanged; victim's createVault(Registration(K_V, blobVictim, ...)) reverts PublicKeyAlreadyRegistered; repeating with key 0x33*1312 and the same blob burns a second key for free; a victim who already owns a vault gets PublicKeyAlreadyRegistered on rotateKey to a burned key while its nonce stays untouched. Expected per section 9: squatting a key costs the squatter a registration fee and a payer slot.","severity":"low","snippet":"        usedPublicKeys[newKeyHash] = true; // Retired keys stay burned globally.","title":"rotateKey burns arbitrary public keys for gas only: new publicKey bytes are never tied to the blob that proves possession, so key squatting costs no fee and no payer slot (extends accepted risk L1; me"},{"citation":"resolved","description":"Boundary inconsistency, not a permission or accounting break. withdrawETH explicitly rejects recipient == address(weth) (line 398) because value sent there is lost, and _checkRecipient rejects address(0) and the vault itself. withdrawERC20 only applies _checkRecipient, so a signed withdrawal whose recipient is the token contract itself passes every exact-delta check (reserve -amount, balanceOf(token) +amount, totalSupply unchanged) and the tokens are permanently stuck inside the token contract. For WETH this is the ERC-20 twin of the case withdrawETH guards against. Only the signing vault's own balance is affected and the recipient is part of the PQ-signed parameters, so invariants 1 to 7 hold; this is a footgun the signer can avoid. Minimal fix: in withdrawERC20 add `if (recipient == token) revert InvalidRecipient();` (optionally also reject recipient == address(weth) for any token, mirroring withdrawETH).","line":349,"path":"src/BunkerVault.sol","reproduction":"Judge scratch test test/scratch/JudgeRepro.t.sol::test_WithdrawErc20ToTokenAddressSucceeds (passes on current code, demonstrating acceptance). Vault idA deposits 100 units of token T; controller submits withdrawERC20(idA, T, recipient = T, 40, assetHash, validAuth). Expected (by analogy with withdrawETH's WETH check): revert InvalidRecipient. Actual: success, T.balanceOf(T) == 40, publicBalanceOf[idA][T] == 60. Same with token = recipient = WETH after depositETH of 1 ether: withdrawERC20(idA, weth, weth, 0.5 ether, ...) succeeds and weth.balanceOf(weth) == 0.5 ether.","severity":"info","snippet":"        _checkRecipient(recipient);","title":"withdrawERC20 accepts recipient == token (including WETH to the WETH contract), stranding the signed amount inside the token contract"},{"citation":"resolved","description":"MARKED: restates an accepted risk (docs/CONTRACT.md section 8 'not supported: rebasing' and section 9 'harm must stay confined to that token'). withdrawERC20 (line 358) and depositERC20 (line 328) require token.balanceOf(vault) >= totalPublicLiability[token] before acting. For a well-behaved token the vault's balance only falls through its own transfers, so the check is a sound conservation guard and is what keeps invariant 3. For a token with privileged seizure, blacklisting or negative rebase, a shortfall of even 1 wei makes all withdrawals of that token by ALL vaults revert although the reserve still covers 199 of 200 units. The freeze is confined to that token and to the vaults that chose to hold it, other tokens and ETH are unaffected, and it is not permanent: any third-party transfer of the missing amount to the vault restores reserve >= liability and withdrawals resume. Recorded because the Economics focus asks about permanent freezes. No change recommended unless the authors want graceful degradation (e.g. permit withdrawals while reserve >= amount and let the last withdrawer bear the shortfall), which would weaken invariant 3; this is a design decision, not a defect.","line":358,"path":"src/BunkerVault.sol","reproduction":"Judge scratch test test/scratch/JudgeRepro.t.sol::test_ReserveShortfallFreezesAllVaultsOfThatToken (passes on current code, demonstrating the behavior). Vaults A and B each deposit 100 of a token with an admin seize(). seize(vault, other, 1) -> reserve 199 < liability 200. Both A's and B's withdrawERC20(…, 1, …) revert UnexpectedAssetBalance. After a 1-wei transfer from a third party to the vault, B withdraws its full 100 successfully.","severity":"info","snippet":"        if (reserveBefore < totalPublicLiability[token]) revert UnexpectedAssetBalance();","title":"A 1-wei reserve shortfall of a seizable or rebasing custody token freezes every vault's balance of that token until someone donates the difference (restates accepted risk: harm confined to that token)"}],"hash":"11b8d2766b6f83116afc388aa889926161e121df705acf2bff3462ac32262162","nodeId":"1705d722-972d-4bec-b312-b173a7149ea6","outcome":"completed","summary":"The review is complete. The findings file at the repository root holds four merged findings, all reproduced against the current code, and no source file was changed.\n\n**Kept findings**\n\n- **Low, heldTokens paging overflow** at `src/BunkerVault.sol:298`. Four specialists reported this one; merged into one. Both attached specialist proofs fail on the current code with an arithmetic panic. I attached the shorter proof and confirmed it passes against a patched copy with the one-line clamp fix.\n- **Low, fee-free key burning through rotateKey** at line 287. Three specialists reported it; merged. Reproduced with a scratch test: a registered attacker rotates to any 1312-byte key using their own existing blob, burns it globally with no fee and no payer slot, and the victim's createVault or rotateKey then reverts. Marked as extending accepted risk L1, since custody and nonces are never affected.\n- **Info, withdrawERC20 accepts recipient equal to the token** at line 349, including WETH sent to the WETH contract. Reproduced. Signer-avoidable footgun, no invariant broken.\n- **Info, 1-wei reserve shortfall freezes a seizable token for all vaults** at line 358. Reproduced, and reproduced that a donation of the missing amount unfreezes it. Marked as restating the accepted \"harm confined to that token\" risk.\n\n**Invariants checked and not broken**\n\n- Invariant 1 and 2: every state change passes through the single gate with controller check, nonce, epoch, expiry, blob pin and signature over a canonical static-type encoding bound to chain, contract, vault id, key hash and key code hash. Rotation captures both messages before mutating state and burns old keys, so stale authorizations die with the epoch bump.\n- Invariant 3: deposits and withdrawals move the per-vault ledger and the liability together; the vault never approves anyone, all mutators are nonReentrant, so a hostile token cannot touch another token's reserve.\n- Invariant 4: the fee token is rejected as a custody asset on every path, and only the developer share leaves inside registration.\n- Invariant 5 and 6: ETH is only in transit under the unwrapping flag, forced ETH is excluded by delta checks, and the real WETH9 2300-gas path is exercised by the existing suite. All failure paths revert before any marker is written.\n- Invariant 7: key size, blob size, prefix, code hash, uniqueness and proof of possession all run before the first fee-token call.\n\n**Limits of this review**\n\nThe vendored ML-DSA-44 verifier was checked only for return handling, pinning and blob layout, which match the vault's expectations. Its cryptographic correctness was not independently reviewed. The 19 ffi tests were not run.","treeHash":null,"usage":{"cachedInputTokens":1159807,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":30256,"runtime":"claude","turns":32,"wallClockMs":402303}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d11ea2b5e05fa7a8","findings":[{"citation":"resolved","description":"Marked: this restates the IMPACT of accepted limitation L1 (open admission lets someone deny a victim key) but through a path the spec does not mention and at a much lower cost. In rotateKey the possession proof is verified against rotation.pkBlob (line 280) while rotation.publicKey is free 1312-byte input whose keccak is only checked for prior use (line 273) and then burned globally (line 287). rotation.pkBlob may even be the vault's CURRENT blob, so a vault owner can sign both the ROTATE_KEY authorization and the ROTATION_POP message with the single key they already hold and still write usedPublicKeys[keccak(anyKey)] = true. Compared with the disclosed createVault squat (one fee token plus one never-reusable payer slot per burned key), rotation burns one key per call for gas only (about 2.8M gas), repeatably and without ever losing the attacker's own vault. Trust-gap seam (access x economics x asymmetry): createVault prices key admission with the registration fee and a one-time payer slot, while rotateKey writes the same global usedPublicKeys mapping with no economic cost and no stronger proof. Consequences: (1) a mempool observer can front-run any createVault or rotateKey whose public key bytes are visible and make the victim transaction revert with PublicKeyAlreadyRegistered, forcing the victim to generate a new key and deploy a new 20,545-byte blob (about 4.5M gas) each time; (2) a victim that must rotate (for example after suspecting key exposure) can be kept from rotating indefinitely by an attacker watching the public mempool, since each retry exposes the next key. Funds stay withdrawable with the old key, so this is denial of rotation and registration, not loss. Minimal fix options (design decision needed): (a) economic parity: charge the registration fee (or a dedicated rotation fee) in rotateKey so burning a key costs the same as in createVault; (b) commit-reveal for new keys in both createVault and rotateKey (commit keccak(publicKey, salt, msg.sender) in one block, reveal later), which removes the front-running window entirely; (c) at minimum document rotation as a second key-squatting path in CONTRACT.md section 9.","line":287,"path":"src/BunkerVault.sol","reproduction":"Scratch test test/scratch/RotationKeyBurn.t.sol (passes on current code, demonstrating the behavior). State: attacker registers vault idA with keyAttacker (0x11 x1312) and blobAttacker; victim holds keyVictim (0x22 x1312) with its own blobVictim and has NOT yet registered. Input: attacker calls rotateKey(idA, KeyRotation(publicKey=keyVictim, pkBlob=blobAttacker, keyCodeHash=blobAttacker.codehash, executor=attacker, proofOfPossession=sig), Authorization(nonce=0, expiresAt=now+600, keyEpoch=0, signature=sig)) where both signatures are produced by the attacker's own key (verifier armed only for blobAttacker). Actual: the call succeeds, usedPublicKeys[keccak(keyVictim)] == true, totalLockedFees and the attacker's fee balance are unchanged; the victim's createVault(Registration(keyVictim, blobVictim, ...)) then reverts PublicKeyAlreadyRegistered. Repeating with key 0x33 x1312 and the same blob burns a second key, again at zero fee. Expected: a key nobody proved possession of should not become permanently unusable for the party that actually holds it, or at least not at zero cost. Demonstration source:\n\n// SPDX-License-Identifier: MIT\npragma solidity ^0.8.30;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BunkerVault} from \"src/BunkerVault.sol\";\nimport {IMLDSAVerifier} from \"src/interfaces/IMLDSAVerifier.sol\";\n\n/// @dev Minimal fixed-supply ERC20 used as fee token and as custody tokens.\ncontract PlainToken {\n    uint8 public immutable decimals;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    constructor(uint8 d) { decimals = d; }\n    function mint(address to, uint256 v) external { balanceOf[to] += v; totalSupply += v; }\n    function approve(address s, uint256 v) external returns (bool) { allowance[msg.sender][s] = v; return true; }\n    function transfer(address to, uint256 v) external returns (bool) {\n        balanceOf[msg.sender] -= v; balanceOf[to] += v; return true;\n    }\n    function transferFrom(address f, address to, uint256 v) external returns (bool) {\n        allowance[f][msg.sender] -= v; balanceOf[f] -= v; balanceOf[to] += v; return true;\n    }\n}\n\n/// @dev Minimal WETH9 lookalike.\ncontract WethMock {\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    receive() external payable { deposit(); }\n    function deposit() public payable { balanceOf[msg.sender] += msg.value; }\n    function withdraw(uint256 w) external { balanceOf[msg.sender] -= w; payable(msg.sender).transfer(w); }\n    function totalSupply() external view returns (uint256) { return address(this).balance; }\n    function approve(address s, uint256 v) external returns (bool) { allowance[msg.sender][s] = v; return true; }\n    function transfer(address to, uint256 v) external returns (bool) {\n        balanceOf[msg.sender] -= v; balanceOf[to] += v; return true;\n    }\n    function transferFrom(address f, address to, uint256 v) external returns (bool) {\n        if (f != msg.sender) allowance[f][msg.sender] -= v;\n        balanceOf[f] -= v; balanceOf[to] += v; return true;\n    }\n}\n\n/// @dev Policy-only verifier: accepts exactly the (blob, message, signature) tuples it was armed with.\n///      The attacker only ever arms messages for the blob whose key it \"holds\".\ncontract ArmedVerifier is IMLDSAVerifier {\n    mapping(bytes32 => bool) private armed;\n    function arm(address blob, bytes memory message, bytes memory sig) external {\n        armed[keccak256(abi.encode(blob, keccak256(message), keccak256(sig)))] = true;\n    }\n    function verify(address blob, bytes calldata message, bytes calldata sig) external view returns (bool) {\n        return armed[keccak256(abi.encode(blob, keccak256(message), keccak256(sig)))];\n    }\n}\n\ncontract RotationKeyBurnTest is Test {\n    PlainToken fee;\n    WethMock weth;\n    ArmedVerifier verifier;\n    BunkerVault vault;\n\n    a","severity":"low","snippet":"        usedPublicKeys[newKeyHash] = true; // Retired keys stay burned globally.","title":"rotateKey burns arbitrary public keys for free: new publicKey is never tied to the blob that proves possession (extends accepted risk L1 to a fee-less, unbounded path)"},{"citation":"resolved","description":"heldTokens is documented as 'Out-of-range pages are empty' and is the on-chain enumeration clients are told to use instead of scanning logs. The clamp is computed as start + count in checked arithmetic. When start is in range (start < all.length, so the early return on line 297 is not taken) and count is large enough that start + count exceeds 2**256-1, the addition panics (0x11) instead of clamping to all.length. Any client or integrating contract that pages with count = type(uint256).max (a common 'everything from here' idiom) gets a revert for every start >= 1. View-only, no asset impact, but it breaks the stated contract of the function and can wedge an integrator that relies on it. Minimal fix: compute the end without overflow, e.g. `uint256 end = count > all.length - start ? all.length : start + count;` (all.length - start cannot underflow because start < all.length is already established).","line":298,"path":"src/BunkerVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.30;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BunkerVault} from \"src/BunkerVault.sol\";\nimport {IMLDSAVerifier} from \"src/interfaces/IMLDSAVerifier.sol\";\n\n/// @dev Minimal fixed-supply ERC20 used as fee token and as custody tokens.\ncontract PlainToken {\n    uint8 public immutable decimals;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    constructor(uint8 d) { decimals = d; }\n    function mint(address to, uint256 v) external { balanceOf[to] += v; totalSupply += v; }\n    function approve(address s, uint256 v) external returns (bool) { allowance[msg.sender][s] = v; return true; }\n    function transfer(address to, uint256 v) external returns (bool) {\n        balanceOf[msg.sender] -= v; balanceOf[to] += v; return true;\n    }\n    function transferFrom(address f, address to, uint256 v) external returns (bool) {\n        allowance[f][msg.sender] -= v; balanceOf[f] -= v; balanceOf[to] += v; return true;\n    }\n}\n\n/// @dev Minimal WETH9 lookalike.\ncontract WethMock {\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    receive() external payable { deposit(); }\n    function deposit() public payable { balanceOf[msg.sender] += msg.value; }\n    function withdraw(uint256 w) external { balanceOf[msg.sender] -= w; payable(msg.sender).transfer(w); }\n    function totalSupply() external view returns (uint256) { return address(this).balance; }\n    function approve(address s, uint256 v) external returns (bool) { allowance[msg.sender][s] = v; return true; }\n    function transfer(address to, uint256 v) external returns (bool) {\n        balanceOf[msg.sender] -= v; balanceOf[to] += v; return true;\n    }\n    function transferFrom(address f, address to, uint256 v) external returns (bool) {\n        if (f != msg.sender) allowance[f][msg.sender] -= v;\n        balanceOf[f] -= v; balanceOf[to] += v; return true;\n    }\n}\n\n/// @dev Policy-only verifier: accepts exactly the (blob, message, signature) tuples it was armed with.\n///      The attacker only ever arms messages for the blob whose key it \"holds\".\ncontract ArmedVerifier is IMLDSAVerifier {\n    mapping(bytes32 => bool) private armed;\n    function arm(address blob, bytes memory message, bytes memory sig) external {\n        armed[keccak256(abi.encode(blob, keccak256(message), keccak256(sig)))] = true;\n    }\n    function verify(address blob, bytes calldata message, bytes calldata sig) external view returns (bool) {\n        return armed[keccak256(abi.encode(blob, keccak256(message), keccak256(sig)))];\n    }\n}\n\ncontract HeldTokensPagingTest is Test {\n    PlainToken fee;\n    WethMock weth;\n    ArmedVerifier verifier;\n    BunkerVault vault;\n\n    address attacker = makeAddr(\"attacker\");\n    address victim = makeAddr(\"victim\");\n    address developer = makeAddr(\"developer\");\n    address blobAttacker = makeAddr(\"blobAttacker\");\n    address blobVictim = makeAddr(\"blobVictim\");\n    bytes32 blobHash;\n\n    function rep(uint8 v, uint256 n) internal pure returns (bytes memory out) {\n        out = new bytes(n);\n        for (uint256 i; i < n; ++i) out[i] = bytes1(v);\n    }\n    function sig() internal pure returns (bytes memory) { return rep(0x42, 2420); }\n    function keyAttacker() internal pure returns (bytes memory) { return rep(0x11, 1312); }\n    function keyVictim() internal pure returns (bytes memory) { return rep(0x22, 1312); }\n\n    function setUp() public {\n        bytes memory blobCode = bytes.concat(hex\"00\", rep(0x01, 20544));\n        vm.etch(blobAttacker, blobCode);\n        vm.etch(blobVictim, bytes.concat(hex\"00\", rep(0x02, 20544)));\n        blobHash = blobAttacker.codehash;\n        fee = new PlainToken(18);\n        weth = new WethMock();\n        verifier = new ArmedVerifier();\n        vault = new BunkerVault(address(fee), address(fee).codehash, developer, address(verifier),\n            address(verifier).codehash, address(weth), address(weth).codehash);\n        fee.mint(attacker, 10e18);\n        fee.mint(victim, 10e18);\n        vm.prank(attacker); fee.approve(address(vault), type(uint256).max);\n        vm.prank(victim); fee.approve(address(vault), type(uint256).max);\n    }\n\n    function register(address who, bytes memory key, address blob) internal returns (bytes32 id) {\n        BunkerVault.Registration memory r = BunkerVault.Registration(key, blob, blob.codehash, who, who,\n            block.timestamp + 600, sig());\n        verifier.arm(blob, vault.registrationMessage(r), sig());\n        vm.prank(who);\n        id = vault.createVault(r);\n    }\n\n    /// Finding: heldTokens(id, start, count) reverts with an arithmetic panic for start >= 1 and a large\n    /// count instead of returning the tail page as documented (\"Out-of-range pages are empty\").\n    function test_HeldTokensRevertsOnLargeCount() public {\n        bytes32 idA = register(attacker, keyAttacker(), blobAttacker);\n        PlainToken t1 = new PlainToken(6);\n        PlainToken t2 = new PlainToken(6);\n        t1.mint(attacker, 100); t2.mint(attacker, 100);\n        vm.startPrank(attacker);\n        t1.approve(address(vault), 100); t2.approve(address(vault), 100);\n        vm.stopPrank();\n        _deposit(idA, address(t1), 10, 0);\n        _deposit(idA, address(t2), 10, 1);\n        assertEq(vault.heldTokenCount(idA), 2);\n\n        // start = 1 is in range; count = max should return [t2], but start + count overflows.\n        address[] memory page = vault.heldTokens(idA, 1, type(uint256).max);\n        assertEq(page.length, 1);\n        assertEq(page[0], address(t2));\n    }\n\n    function _deposit(bytes32 id, address token, uint256 amount, uint256 nonce) internal {\n        bytes32 assetHash = vault.assetConfigurationHash(token);\n        BunkerVault.Authorization memory auth = BunkerVault.Authorization(nonce, block.timestamp + 600, 0, sig());\n        bytes32 params = keccak256(abi.encode(token, amount, assetHash, attacker));\n        verifier.arm(blobAttacker, vault.authorizationMessage(id, vault.DEPOSIT_ERC20(), params, nonce,\n            auth.expiresAt, 0), sig());\n        vm.prank(attacker);\n        vault.depositERC20(id, token, amount, assetHash, auth);\n    }\n}","reproduction":"State: vault idA has deposited two tokens t1 then t2, so _heldTokens[idA] = [t1, t2] and heldTokenCount(idA) == 2. Input: heldTokens(idA, 1, type(uint256).max). Expected: a 1-element page [t2]. Actual: revert with panic 0x11 (arithmetic overflow) from `start + count`. Proof test test/scratch/HeldTokensPaging.t.sol fails on current code and passes with the one-line clamp fix (verified locally).","severity":"low","snippet":"        uint256 end = start + count > all.length ? all.length : start + count;","title":"heldTokens(id, start, count) reverts with an arithmetic panic for large count instead of returning the tail page"}],"hash":"7b2d40068ad96dddf09732c2041584a670738af90ce7e73f2cf8b057cbec021d","nodeId":"7db2acab-9acc-4f70-950c-732bcdc06e1a","outcome":"completed","summary":"Findings file is written and both snippets match the source lines. The review of my assigned area is complete.\n\n## Outcome\n\nTwo Low findings, no Critical/High/Medium. Both are in `.imd-findings.json` with concrete reproductions; the heldTokens one carries a proof test that fails now and passes under the one-line fix (verified, source restored).\n\n**Findings**\n\n- **Low, rotateKey key burning at zero cost** (`src/BunkerVault.sol:287`). The rotation possession proof is checked against the attacker-chosen blob, which may be the vault's own current blob, while the new public key bytes are free input. A vault owner can therefore mark any key hash as used, repeatedly, for gas only, with no fee and no slot loss. This is marked as restating accepted risk L1 in impact (denial of a key), but it is a second, fee-less, unbounded path the spec does not mention, and it lets a mempool watcher block a victim's registration or rotation indefinitely. Assets are never at risk. Fix needs a design call: fee parity on rotation, or commit-reveal for new keys.\n- **Low, heldTokens overflow** (`src/BunkerVault.sol:298`). With start in range and a large count, the clamp arithmetic panics instead of returning the tail page, contrary to the documented \"out-of-range pages are empty\". Proof in `test/scratch/HeldTokensPaging.t.sol`.\n\n**Invariants checked and not broken (permissions lens)**\n\n- Invariant 1: every state writer except createVault goes through the single gate, which requires the controller as sender, exact nonce, exact epoch, bounded expiry, blob re-pin, verifier code-hash re-pin and a verified signature. No admin, initializer, upgrade, delegatecall, rescue or approval surface exists; receive only accepts from the pinned WETH while the unwrapping flag is set inside a nonReentrant call; fallback always reverts. The global assetConfig pin is the only cross-vault write, and it can only ever store the token's live code hash.\n- Invariant 2: messages bind domain, chain id, contract, vaultId, key hash, key code hash, operation constant, paramsHash, nonce, expiry and epoch via abi.encode of static types. CREATE_VAULT and ROTATION_POP constants are never accepted by the gate, so registration and possession proofs cannot be replayed as operations. The rotation PoP binds the old key's full rotation message. After rotation the key hash, epoch and nonce all change, so pre-signed old-key authorizations die.\n- Invariant 7: both registration and rotation check key length, blob length, prefix, code hash and key uniqueness, and run PoP before the fee pull. The copied-PoP, other-executor, other-chain and other-deployment replays are all rejected by message binding.\n- Controller compromise (L2) gives the attacker nothing beyond re-submitting the owner's own signed operation with identical effect.\n\n**Secondary scope, verifier**: return handling is fail-closed (false or revert both reject), the helper is pinned by content on every call, helper staticcalls are success-checked, the blob layout offsets match the 20,545-byte prefix plus tr, t1hat, Ahat layout. One lead, not reported: a vault owner who registers an all-zero blob makes their own vault's signatures trivially forgeable, but that needs the controller too and falls under the accepted \"cannot prove the blob came from honest KeyGen\".\n\n**Coverage limits**: Access Control, Trust Gap and Asymmetry passes were done in depth; Math, Economics and Control Flow were only touched where they intersect permissions. No ffi run (real-key end-to-end tests) was executed.","treeHash":null,"usage":{"cachedInputTokens":1475078,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":46495,"runtime":"claude","turns":37,"wallClockMs":667616}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d4b53d656a3fbe07","findings":[{"citation":"resolved","description":"Root cause: createVault (line 220/225) accepts any 1312-byte publicKey together with ANY blob whose PoP verifies, so usedPublicKeys[keccak(publicKey)] can be set by someone who does not hold that key. rotateKey then rejects the victim's new key at line 273. docs/CONTRACT.md L1 accepts this as 'denying that key; the victim must use a new key', but it does not state the economics, which favour the attacker: (1) the attacker re-uses ONE prepared blob for every squat because the blob is never tied to the publicKey bytes, so their marginal cost per squat is one registration fee (90% burned, 10% to the developer) plus ~1.5M gas and a fresh EOA; (2) the victim must regenerate a key AND deploy a fresh 20,545-byte blob each round, measured at 4,530,154 gas in the scratch test (plus the gas of the reverted rotateKey/createVault which already ran a 1.35M-gas verification before hitting the check in createVault, or in rotateKey the revert happens before verification). With a low-value fee token the attacker spends roughly a third of what the victim loses per round and can repeat indefinitely against the same vault's every rotation attempt (the victim never gets to rotate as long as the attacker watches the mempool). Custody is never at risk: the old key keeps working. Mark: this restates/extends accepted L1; report only the unstated cost asymmetry and the fact that rotations, not just registrations, are affected. Minimal fix (design decision needed): add a commit-reveal to registration and rotation (commit keccak256(publicKeyHash, payer, salt) in one transaction, reveal at least one block later), or bind the blob to the key on-chain by having the verifier check tr == SHAKE256(pk) on the blob's first 64 bytes at registration. At minimum document the mempool-privacy mitigation (private relay) and that rotation keys must not be revealed before submission.","line":273,"path":"src/BunkerVault.sol","reproduction":"test/scratch/Economics.t.sol::test_RotationSquatBlocksVictimRotation. State: alice owns vault V (key A, blobA). Alice broadcasts rotateKey(V, publicKey=C, pkBlob=newBlob). Attacker front-runs with createVault(Registration{publicKey=C, pkBlob=blobAtt, feePayer=executor=attacker, PoP under blobAtt}) paying exactly 1e18 fee units (assert attackerBefore - after == 1 ether). Expected (spec L1 scope): only registration of C denied. Actual: alice's rotateKey reverts with PublicKeyAlreadyRegistered() even though both her old-key signature and new-key PoP are valid; usedPublicKeys[keccak(C)] == true; attacker's blob reusable; victim blob deployment measured at 4,530,154 gas per round.","severity":"low","snippet":"        if (usedPublicKeys[newKeyHash]) revert PublicKeyAlreadyRegistered();","title":"Key squatting is cheaper for the attacker than for the victim and extends to every rotation attempt (extends accepted risk L1 with cost asymmetry)"},{"citation":"resolved","description":"The view promises 'Out-of-range pages are empty' (NatSpec line 294, docs section 6) but computes start + count in checked arithmetic before clamping. For any start >= 1 that is in range and count close to type(uint256).max the call reverts with Panic(0x11) rather than returning the remaining tokens. No funds or state are affected; it only breaks the documented view contract for clients that pass a 'give me everything' count. Fix: compute `uint256 end = count > all.length - start ? all.length : start + count;` (the subtraction is safe because start < all.length was already checked).","line":298,"path":"src/BunkerVault.sol","reproduction":"test/scratch/Economics.t.sol::test_HeldTokensPagingOverflowsOnLargeCount. Vault with two held tokens (one ERC-20 deposit, one depositETH). Call heldTokens(id, 1, type(uint256).max). Expected: a one-element page [weth]. Actual: revert Panic(0x11) arithmetic overflow.","severity":"info","snippet":"        uint256 end = start + count > all.length ? all.length : start + count;","title":"heldTokens paging panics instead of returning a clamped page when start + count overflows"},{"citation":"resolved","description":"withdrawERC20 and depositERC20 (line 328) require token.balanceOf(vault) >= totalPublicLiability[token] before acting. For a well-behaved token the vault's balance can only fall through its own transfers, so the check is a sound conservation guard. For tokens with privileged burn/seize, negative rebase or blacklisting (explicitly 'not supported' in docs section 8, and 'a malicious token can lie' in section 9) a shortfall of even 1 wei makes all withdrawals of that token by ALL vaults revert, although the reserve still covers 199 of 200 units. The harm stays confined to that token and to vaults that chose to hold it, so this only restates the accepted risk; it is recorded because the Economics focus asks about permanent freezes. Note the freeze is not strictly permanent: any third-party donation that restores reserve >= liability unfreezes withdrawals (shown in the test), and the design intentionally prefers fail-closed over pro-rata payouts. No change is recommended unless the authors want graceful degradation (e.g. allow withdrawals while reserve - amount >= 0 and let the last withdrawer bear the shortfall), which would weaken invariant 3; this is a design decision, not a defect.","line":358,"path":"src/BunkerVault.sol","reproduction":"test/scratch/Economics.t.sol::test_ReserveShortfallFreezesAllVaultsOfThatToken. Vaults A and B each deposit 100 of a SeizableToken (ERC-20 with admin burn). seize(vault, 1) -> reserve 199 < liability 200. Both A's and B's withdrawERC20(…, 1, …) revert UnexpectedAssetBalance(). Expected by a user: at least the covered portion withdrawable. Actual: nothing withdrawable until someone donates 1 wei to the vault (then A withdraws 100 successfully).","severity":"info","snippet":"        if (reserveBefore < totalPublicLiability[token]) revert UnexpectedAssetBalance();","title":"A 1-wei reserve shortfall of a custody token freezes every vault's balance of that token, including the fully covered remainder (restates accepted 'harm confined to that token')"}],"hash":"80d3f84fc2193e25a88c7565798c3a739a1789c670b528231ae0da43965fbd0c","nodeId":"64b1f229-0b72-4573-aa9e-d17b4d696788","outcome":"completed","summary":"The full suite passes with my scratch tests included (106 tests). Nothing in the tracked tree changed. The findings file at the repository root holds three entries.\n\n## Outcome\n\nNo critical or high defect found. The custody core holds up under the Economic Security, Invariant and Flow Gap passes. Three substantiated items are recorded in `.imd-findings.json`, each with a passing scratch reproduction in `test/scratch/Economics.t.sol`:\n\n- **Low. Key squatting economics extend to rotation.** An attacker who sees a pending rotateKey can register the announced new key first with their own blob, and the victim's rotation reverts at line 273. The spec's L1 accepts the denial, but not the cost asymmetry: the attacker reuses one blob per squat and pays one fee plus gas, while the victim deploys a fresh 20,545-byte blob every round. Measured cost below. Custody is never at risk. Marked as extending an accepted risk. Fix would be commit-reveal for key announcement, or binding the blob to the key on-chain.\n- **Info. heldTokens paging panics.** With an in-range start and a huge count the view reverts with an overflow panic instead of returning the clamped page the NatSpec promises. One-line fix at line 298.\n- **Info. One-wei reserve shortfall freezes a whole token.** A seizable or negatively rebasing token that drops the vault's balance under liability by 1 wei blocks all vaults' withdrawals of that token, not just the uncovered part. A 1-wei donation unfreezes it. This restates the accepted \"harm confined to that token\" position and is recorded as a design note, not a defect.\n\n| Measurement | Value |\n| --- | --- |\n| Attacker cost per squat | 1 fee token (90% burned) plus about 1.5M gas |\n| Victim blob redeployment per round | 4,530,154 gas |\n\n## Invariants checked and not broken\n\n1. **No state change without key plus controller.** Every mutating entry goes through `_consumeAuthorization`, which checks the controller before any signature work. Views are the only reentry surface during the ETH recipient call.\n2. **One signature, one operation, once.** All messages are canonical `abi.encode` of fixed-width words with distinct operation constants, chain id, contract, vault id, key hash, key code hash, nonce, expiry and epoch. Registration and rotation PoP domains differ from account operations, and PoP binds payer, executor and blob.\n3. **Ledger conservation.** Both ledger terms are written together on every path, including the two WETH paths. Exact-delta checks on vault, counterparty and total supply defeat fee-on-transfer, double-entry and lying tokens without letting one token's misbehavior touch another token's reserve.\n4. **Locked fees.** The fee token is rejected before authorization on both ERC-20 paths, cannot be WETH by construction, and the only outflow is the exact developer share after exact collection. The counter is only ever incremented.\n5. **No resident ETH.** Deposits forward the whole value into WETH. Withdrawals use balance deltas, so forced ETH is neither credited nor spendable. Receive accepts only WETH while unwrapping, fallback always reverts, and the other entry points are non-payable.\n6. **Atomic failure.** Nonce increments and asset pinning happen inside the same transaction as the token calls, so any revert undoes them. The existing 300-step randomized test confirms nonce rollback on every rejected step.\n7. **Registration and rotation ordering.** Key size, blob size, prefix, code hash and key uniqueness are checked before the PoP, and the PoP runs before the first fee-token call.\n\n## Coverage notes\n\nI ran the 103 mock-verifier tests plus my scratch file. I did not run the FFI suite with real keys, since it needs a Node install and the mock suite covers the application policy under review. For the verifier I checked return handling, exact blob-size handling, signature-length gating and content pinning of the Keccak helper, which all fail closed. I did not audit the ML-DSA mathematics itself, so a cryptographic verifi","treeHash":null,"usage":{"cachedInputTokens":1030180,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":39754,"runtime":"claude","turns":32,"wallClockMs":672850}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"cdeffb0cd839cbf7","findings":[{"citation":"resolved","description":"Math / boundary. heldTokens(id, start, count) is documented as a clamped pager (\"Out-of-range pages are empty\") and the comparison `start + count > all.length` is meant to clamp `end` to the list length. The sum is computed with checked arithmetic before the comparison, so for any in-range start >= 1 and count > type(uint256).max - start the addition overflows and the call reverts with Panic(0x11) rather than returning the remaining tokens. start = 0 with count = type(uint256).max happens to work (0 + max does not overflow), so a client that uses `count = type(uint256).max` to mean \"everything from here\" sees the first page succeed and every later page revert. Impact is availability of the enumeration view only (no asset or authorization state is involved); the finding is a correctness defect in the paging bounds, not an exploit. Root cause: the clamp is evaluated after the overflowing addition. Minimal fix: compute the clamp without the sum, e.g. `uint256 remaining = all.length - start; uint256 end = count >= remaining ? all.length : start + count;` (start < all.length is already guaranteed by the early return, so `all.length - start` cannot underflow and `start + count` is only evaluated when it is strictly below all.length).","line":298,"path":"src/BunkerVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.30;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BunkerVault} from \"src/BunkerVault.sol\";\nimport {IMLDSAVerifier} from \"src/interfaces/IMLDSAVerifier.sol\";\n\n/// Minimal ERC20 (18 decimals by default) used both as fee token and as custody asset.\ncontract MiniToken {\n    uint8 public decimals;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    constructor(uint8 d) { decimals = d; }\n    function mint(address to, uint256 v) external { balanceOf[to] += v; totalSupply += v; }\n    function approve(address s, uint256 v) external returns (bool) { allowance[msg.sender][s] = v; return true; }\n    function transfer(address to, uint256 v) external returns (bool) {\n        balanceOf[msg.sender] -= v; balanceOf[to] += v; return true;\n    }\n    function transferFrom(address f, address to, uint256 v) external returns (bool) {\n        allowance[f][msg.sender] -= v; balanceOf[f] -= v; balanceOf[to] += v; return true;\n    }\n}\n\n/// Minimal WETH9-like contract (only what the constructor needs).\ncontract MiniWeth is MiniToken {\n    constructor() MiniToken(18) {}\n    function deposit() external payable { balanceOf[msg.sender] += msg.value; }\n    function withdraw(uint256 w) external { balanceOf[msg.sender] -= w; payable(msg.sender).transfer(w); }\n}\n\n/// Programmable verifier: accepts exactly the (key, message, signature) it was armed with.\ncontract ArmedVerifier is IMLDSAVerifier {\n    address key; bytes32 msgHash; bytes32 sigHash;\n    function arm(address k, bytes calldata m, bytes calldata s) external { key = k; msgHash = keccak256(m); sigHash = keccak256(s); }\n    function verify(address k, bytes calldata m, bytes calldata s) external view returns (bool) {\n        return k == key && keccak256(m) == msgHash && keccak256(s) == sigHash;\n    }\n}\n\ncontract HeldTokensPagingOverflowTest is Test {\n    address blob = makeAddr(\"blob\");\n    address dev = makeAddr(\"dev\");\n    MiniToken fee;\n    ArmedVerifier verifier;\n    MiniWeth weth;\n    BunkerVault vault;\n    bytes32 id;\n\n    function rep(uint8 v, uint256 n) internal pure returns (bytes memory out) {\n        out = new bytes(n);\n        for (uint256 i; i < n; ++i) out[i] = bytes1(v);\n    }\n\n    function setUp() public {\n        vm.etch(blob, bytes.concat(hex\"00\", rep(0x01, 20544)));\n        fee = new MiniToken(18);\n        verifier = new ArmedVerifier();\n        weth = new MiniWeth();\n        vault = new BunkerVault(address(fee), address(fee).codehash, dev, address(verifier),\n            address(verifier).codehash, address(weth), address(weth).codehash);\n        fee.mint(address(this), 1e18);\n        fee.approve(address(vault), 1e18);\n        BunkerVault.Registration memory reg = BunkerVault.Registration(rep(0x11, 1312), blob, blob.codehash,\n            address(this), address(this), block.timestamp + 1800, rep(0x42, 2420));\n        verifier.arm(blob, vault.registrationMessage(reg), reg.proofOfPossession);\n        id = vault.createVault(reg);\n        // Deposit two distinct tokens so the held-token list has length 2.\n        _depositNewToken();\n        _depositNewToken();\n        assertEq(vault.heldTokenCount(id), 2);\n    }\n\n    function _depositNewToken() internal {\n        MiniToken t = new MiniToken(18);\n        t.mint(address(this), 100);\n        t.approve(address(vault), 100);\n        bytes32 h = vault.assetConfigurationHash(address(t));\n        BunkerVault.Account memory acc = vault.account(id);\n        BunkerVault.Authorization memory a =\n            BunkerVault.Authorization(acc.nonce, block.timestamp + 1800, acc.keyEpoch, rep(0x42, 2420));\n        bytes32 params = keccak256(abi.encode(address(t), uint256(100), h, address(this)));\n        verifier.arm(acc.pkBlob,\n            vault.authorizationMessage(id, vault.DEPOSIT_ERC20(), params, a.nonce, a.expiresAt, a.keyEpoch),\n            a.signature);\n        vault.depositERC20(id, address(t), 100, h, a);\n    }\n\n    /// heldTokens documents \"Out-of-range pages are empty\" and `start + count` is clamped to the list\n    /// length, so start=1,count=type(uint256).max should return the one remaining token.\n    /// Current code computes `start + count` with checked arithmetic and reverts with Panic(0x11).\n    function test_HeldTokensClampsLargeCountInsteadOfOverflowing() public view {\n        // Sanity: start=0 with max count works because 0 + max does not overflow.\n        assertEq(vault.heldTokens(id, 0, type(uint256).max).length, 2);\n        // start=1: 1 + max overflows -> Panic(0x11) on current code. Expected: a one-element page.\n        address[] memory page = vault.heldTokens(id, 1, type(uint256).max);\n        assertEq(page.length, 1, \"page should hold the single remaining token\");\n        assertEq(page[0], vault.heldTokens(id, 1, 1)[0]);\n    }\n}","reproduction":"Register one vault, deposit two distinct ERC-20s so heldTokenCount(id) == 2. Call heldTokens(id, 0, type(uint256).max): returns 2 entries (expected). Call heldTokens(id, 1, type(uint256).max): expected a 1-element page [second token]; actual: revert with Panic(0x11) (returndata 0x4e487b71...0011). Same for heldTokens(id, 1, type(uint256).max - 0). The proof test test/scratch/HeldTokensPagingOverflow.t.sol fails on current code with `panic: arithmetic underflow or overflow (0x11)`.","severity":"low","snippet":"        uint256 end = start + count > all.length ? all.length : start + count;","title":"heldTokens paging reverts with Panic(0x11) when start + count overflows instead of clamping"},{"citation":"resolved","description":"Boundary consistency. withdrawETH explicitly rejects recipient == address(weth) (line 398) because value sent there is lost to the signer, and _checkRecipient rejects address(0) and the vault itself. withdrawERC20 only applies _checkRecipient, so a withdrawal whose signed recipient is the token contract itself passes all exact-delta checks (reserve -amount, balanceOf(token) +amount, totalSupply unchanged) and the tokens are permanently stuck inside the token contract. For WETH this is the ERC-20 twin of the case withdrawETH guards against: withdrawERC20(id, weth, weth, amount, ...) succeeds and leaves `amount` WETH owned by the WETH contract forever. Only the signing vault's own balance is affected and the recipient is part of the PQ-signed parameters, so this is a footgun rather than a permission or accounting break; it does not violate invariants 1 to 7. Minimal fix: in withdrawERC20 add `if (recipient == token) revert InvalidRecipient();` (optionally also `recipient == address(weth)` when token is WETH, which the first check already covers).","line":349,"path":"src/BunkerVault.sol","reproduction":"Vault A deposits 100 units of token T (or 1 ether via depositETH). Controller submits withdrawERC20(idA, T, recipient = T, 40, assetHash, validAuth). Expected (by analogy with withdrawETH's weth check): InvalidRecipient. Actual: success, T.balanceOf(T) == 40, publicBalanceOf[idA][T] == 60, the 40 units are unrecoverable. Same with token = recipient = WETH: weth.balanceOf(weth) == 0.5 ether after withdrawERC20(idA, weth, weth, 0.5 ether, ...). Reproduced in test/scratch/MathProbe.t.sol::test_WithdrawErc20ToTokenAddress (passes on current code, demonstrating the acceptance).","severity":"info","snippet":"        _checkRecipient(recipient);\n        if (amount == 0) revert InvalidAmount();","title":"withdrawERC20 accepts recipient == token (including WETH to the WETH contract), which irreversibly strands the signed amount"}],"hash":"db051d7891c829a1469c934787662526cb813bba3eb2df26047f88ac8e510a28","nodeId":"38b3df85-ad52-46d5-ac54-471052fa9460","outcome":"completed","summary":"The review is complete. The findings file at `.imd-findings.json` holds two entries, and the proof test is under `test/scratch/`.\n\n**Findings**\n\n- **Low, `src/BunkerVault.sol:298`.** The held-token pager computes `start + count` with checked arithmetic before clamping. For any in-range start of 1 or more with a near-max count, the view reverts with Panic(0x11) instead of returning the remaining tokens, contradicting the documented \"out-of-range pages are empty\" behaviour. Start 0 with max count works, so a client using max count as \"everything\" sees page one succeed and page two revert. The self-contained proof `test/scratch/HeldTokensPagingOverflow.t.sol` fails on current code with the arithmetic panic. The fix is to compare count against `all.length - start` and only form the sum when it is below the length. I verified that logic passes the same assertions in an isolated scratch contract. No asset or authorization state is involved.\n- **Informational, `src/BunkerVault.sol:349`.** The ERC-20 withdrawal path accepts a recipient equal to the token itself, including WETH to the WETH contract, and all exact-delta checks pass, so the signed amount is stranded forever. The ETH path explicitly rejects WETH as recipient for the analogous reason. Only the signer's own vault is affected and the recipient is PQ-signed, so this is a consistency footgun, not an invariant break.\n\n**Invariants checked in the math and boundary area and not broken**\n\n- Fee split: for every decimals value 1 to 18, developer share times ten equals the fee, developer plus locked equals the fee, and on-chain balances and totalLockedFees match after registration. Decimals 0, 19 and 255 are rejected. No division other than the exact fee tenth exists.\n- Balance deltas: deposits require exact vault credit and exact payer debit with unchanged supply. Withdrawals debit the ledger first and require exact vault debit and exact recipient credit. The reserve-at-least-liability precondition means a well-behaved token's liability can never exceed the reserve, so one vault cannot shrink another's withdrawable balance. Donations and forced ETH are never credited.\n- Overflow and underflow: there are no unchecked blocks, narrowing casts or shifts in scope. Every subtraction is guarded by a preceding comparison, and overflow on additions reverts the whole operation including the nonce increment.\n- Expiry boundary: exactly one hour ahead is accepted, one second more and the current timestamp are both rejected.\n- ETH path: the receive guard reads a warm slot and passes under the real WETH9 2,300-gas transfer in the existing suite. Address balance deltas keep forced ETH out of accounting.\n- Encodings: every paramsHash and message uses static-typed `abi.encode` with a distinct operation constant, so no ambiguous encoding or cross-operation replay exists.\n\n**Secondary scope.** The verifier pins its Keccak helper by content on every call, checks the blob size exactly at 20,545 bytes, and the layout offsets for tr, t1hat and Ahat sum to the vault's pinned size. I found no path where it returns true without the key's blob.\n\n**Coverage limits.** Mock-verifier tests only; the ffi real-key suite was not run. Permissions, economics and control flow were checked only as far as they intersect the math, as assigned.","treeHash":null,"usage":{"cachedInputTokens":1097112,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":33787,"runtime":"claude","turns":35,"wallClockMs":540147}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"60ffae3cc413a0c6","findings":[{"citation":"resolved","description":"Root cause: `start + count` is computed with checked arithmetic before being compared to `all.length`. When `start > 0` and `count` is large enough that `start + count` exceeds 2**256-1, the addition panics (0x11) instead of clamping to the end of the list. The NatSpec and docs/CONTRACT.md promise 'Out-of-range pages are empty' and the natural client idiom 'give me everything from `start`' is `count = type(uint256).max`. The early return on line 297 only protects the `start >= length` case, so any in-range `start >= 1` with a max count reverts. Impact: view-only; no funds at risk; a client enumerating a vault's tokens with a max count gets a revert rather than a page. Minimal fix: compute `uint256 end = all.length - start; if (count < end) end = count; end += start;` or wrap the comparison as `count > all.length - start`.","line":298,"path":"src/BunkerVault.sol","reproduction":"Vault with at least two held tokens (e.g. one depositETH then one depositERC20, so heldTokenCount(id) == 2). Call heldTokens(id, 1, type(uint256).max). Expected per spec: a one-element page [token]. Actual: revert with panic 0x11 (arithmetic overflow) at line 298. Verified with a scratch Foundry test: deposit ETH then an ERC20 into vault A, then `vault.heldTokens(id, 1, type(uint256).max)` reverts; `heldTokens(id, 0, type(uint256).max)` and `heldTokens(id, 2, type(uint256).max)` do not.","severity":"low","snippet":"        uint256 end = start + count > all.length ? all.length : start + count;","title":"heldTokens paging reverts with an arithmetic panic for large count instead of clamping"},{"citation":"resolved","description":"Root cause: rotateKey accepts any 1312-byte `rotation.publicKey` and marks `usedPublicKeys[keccak256(rotation.publicKey)] = true` globally, while the only binding checked between that key and the blob is the ML-DSA possession proof against `rotation.pkBlob`, which the caller chooses. The contract cannot verify that the blob equals prepare(publicKey) (accepted limitation L1). Section 9 of docs/CONTRACT.md describes key squatting only through createVault, where the squatter pays a full registration fee and consumes their single payer slot per key (L1, L5). Through rotateKey, a single registered attacker (one fee, ever) can burn an unbounded number of victim public keys at only gas cost (~2.8M gas each), reusing the same blob every time, and can front-run both a victim's createVault and a victim's rotateKey seen in the mempool so that they revert with PublicKeyAlreadyRegistered. The victim's existing vault and assets are not affected (nonce not consumed, no state change on the victim vault), so the impact remains the denial of specific keys that L1 already states; what goes beyond the stated risk is the economics: the stated per-key cost (a registration fee and a payer slot) does not apply. Fix options that preserve the design: (a) in rotateKey, require that `rotation.pkBlob` differs from `principal.pkBlob` and has never been used by any vault (track `usedBlobs[pkBlob]`), which removes the zero-cost reuse of one blob for many keys and forces the attacker to deploy a 20,545-byte blob (~4.1M gas plus deployment) per burned key; or (b) charge the registration fee on rotation as well; or (c) document in section 9 that rotation also squats keys without fee.","line":287,"path":"src/BunkerVault.sol","reproduction":"1. Attacker bob registers a vault with key K_B and blob B (pays one fee). 2. Victim alice intends to register (or rotate to) key K_V = any 1312-byte value bob has observed, e.g. in the mempool. 3. bob calls rotateKey(bobId, KeyRotation(K_V, B, codehash(B), bob, PoP signed by B's key), Authorization(nonce, expiry, epoch, sig by B's key)). Both signatures are produced by the same key bob already holds, so the call succeeds; bob's fee token balance is unchanged (no fee is charged on rotation) and usedPublicKeys[keccak256(K_V)] becomes true. 4. alice's createVault(Registration(K_V, blobA, ...)) now reverts with PublicKeyAlreadyRegistered; the same holds for any rotateKey to K_V. Expected per section 9: squatting a key costs the squatter a registration fee and their payer slot. Actual: zero fee, repeatable for any number of keys from one vault. Verified with a scratch Foundry test using a programmable mock verifier.","severity":"low","snippet":"        usedPublicKeys[newKeyHash] = true; // Retired keys stay burned globally.","title":"rotateKey lets any registered controller burn arbitrary public keys with no registration fee (fee-free key squatting / front-running of createVault and rotateKey)"}],"hash":"ff455690f73bab66aa9fbbfc234caf25666cb6ff03e2f88054609706e45e4458","nodeId":"1218269a-0efd-4d61-b1df-acc070afb5d3","outcome":"completed","summary":"Review complete. The findings file holds two substantiated Low findings, both snippets match the source lines exactly, and nothing in `src/`, config, or `lib/` was changed.\n\n**Outcome.** No Critical, High, or Medium defect in the control flow of BunkerVault. Two Low findings are recorded in `.imd-findings.json`, each confirmed with a scratch Foundry test.\n\n**Findings**\n\n- **Low, `src/BunkerVault.sol:298`.** `heldTokens(id, start, count)` computes `start + count` with checked arithmetic before clamping. With two or more held tokens, `heldTokens(id, 1, type(uint256).max)` reverts with panic 0x11 instead of returning the documented empty or clamped page. View-only, no funds at risk. Fix: compare `count` against `all.length - start`.\n- **Low, `src/BunkerVault.sol:287`.** `rotateKey` marks any caller-chosen 1312-byte public key as used while only proving possession of the caller's own blob. A registered attacker can reuse their existing blob to burn unlimited victim keys at gas cost only, and can front-run a victim's `createVault` or `rotateKey` so it reverts with `PublicKeyAlreadyRegistered`. Impact is the key denial already accepted under L1, but the stated per-key cost (a registration fee and a payer slot) does not apply. Fix options: track used blobs so each burn needs a fresh 20,545-byte deployment, charge the fee on rotation, or document it in section 9.\n\n**Invariants checked and not broken, with reasoning**\n\n- **1 and 2 (signature gating, single use).** Every state-changing entry is `nonReentrant` and routes through `_consumeAuthorization`, which checks controller, exact nonce, epoch, expiry, blob code hash, and verifier code hash, then increments the nonce before any token or ETH call. All messages are fixed-width `abi.encode` of 11 static words with distinct operation constants, so registration, rotation PoP, and operation messages cannot collide across vaults, keys, epochs, contracts, or chains.\n- **3 (ledger conservation).** Ledgers are keyed per token, every token read is a `view` call (STATICCALL), the vault never grants approvals, and exact delta checks on vault, counterparty, and `totalSupply` bracket each transfer. A lying or reentrant token can only corrupt its own ledger. The verifier's helper wrapper and SafeERC20 both fail closed on short or non-`1` return data.\n- **4 (locked fees).** The fee token is rejected as a custody asset, and the only outbound fee-token transfer is the developer share inside `_collectRegistrationFee`, verified by exact reserve delta.\n- **5 (no resident ETH).** `receive()` accepts ETH only from WETH while `unwrapping`, `fallback()` always reverts, and `withdrawETH` checks ETH and WETH deltas, so forced ETH is never credited. The real WETH9 2300-gas `transfer` path is exercised by the existing `EthCustodyRealWeth9Test` suite and passes.\n- **6 (atomic failure).** Nonce increment, asset pinning in `_validateAsset`, and fee settlement all live in the same call frame as the operation, so any revert undoes them; existing rollback tests confirm this.\n- **7 (registration and rotation validation).** Key length, blob size, prefix, code hash, and key uniqueness are checked, and PoP verification precedes the first fee-token call.\n\n**Not reported as defects.** A hard-fork change of a pinned dependency's code hash (WETH or any admitted token) freezes that asset for every vault with no recovery. This is the stated design and already tested, so I note it only as a residual risk worth listing explicitly in section 9.\n\n**Coverage.** Primary scope fully traced, including `receive()`, `fallback()`, the unwrapping flag, and every token, WETH, and recipient interaction. Verifier reviewed only for return handling, helper pinning, and blob layout. The FFI real-key suite was not run (no network install performed), but the 103 non-FFI tests pass.","treeHash":null,"usage":{"cachedInputTokens":1882659,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":49077,"runtime":"claude","turns":40,"wallClockMs":738302}}],"verification":[]}