{"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":"a8e78e5e-eda9-4f4a-baa4-5c3633422dd1","kind":"audit","nodes":[{"acceptedSubmissionHash":"60a6fac38c5c4cb955d2b229589f95f4e32ee57f72d2c2b0d09c7f678cbfeb03","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":"a21c667a3d7d833b6bbb856e50e6200608755dfe4858efe8a80b02b4f94c8822","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":"f623dcb5edbc29a2b7a48a101afeb9eef1c06b17d4733f8562e4c449c03f7dc7","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":"a80563f90e67dd294989dee605c44fb66cd309bbe29c9f18367f266ee9dd118f","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":"a6055c340056280324f007f173c228160de850e355c3013d6cf5bd01f4841909","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 (rotateKey is a second, fee-free path to the same squatting; custody and nonce are unaffected). A token whose reserve falls below totalPublicLiability (seizure, blacklist, negative rebase) freezes withdrawals and deposits of that token only, until the shortfall is covered (invariant 3 is kept on purpose); issuer powers of USDC, USDT and WBTC are an external dependency. 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":"03b5ae82f67ded11420cac3ac0e7784cb5204a00d5a35eceddf14e755c10c8e7","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a8e78e5e-eda9-4f4a-baa4-5c3633422dd1","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":"52205","feedbackHash":"d81c36d9650ddd42dbe321d4d465d04f4427c2ba9b0d3da3e80f58c892405593","nodeKey":"audit_economics","submissionHash":"60a6fac38c5c4cb955d2b229589f95f4e32ee57f72d2c2b0d09c7f678cbfeb03","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51511","feedbackHash":"3347bc83acdf39d31ca8a11b92aed733d9d56e71b0d5db6bebdb7ba34e55baa4","nodeKey":"audit_flow","submissionHash":"a21c667a3d7d833b6bbb856e50e6200608755dfe4858efe8a80b02b4f94c8822","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50981","feedbackHash":"efa37de98526d427b94d7a8c61ca92611bac5b26cbf8133da37d2bd385ac751c","nodeKey":"audit_judge","submissionHash":"f623dcb5edbc29a2b7a48a101afeb9eef1c06b17d4733f8562e4c449c03f7dc7","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52162","feedbackHash":"207b6cadfc257f794a41c4076742f9d7aca8ec8baf48c2f94d027a00933a5972","nodeKey":"audit_math","submissionHash":"a80563f90e67dd294989dee605c44fb66cd309bbe29c9f18367f266ee9dd118f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52170","feedbackHash":"dc5b89908938e00e80cab458c5584fea3f390fe5334f7c78ff5a4aa704ba75bc","nodeKey":"audit_permissions","submissionHash":"a6055c340056280324f007f173c228160de850e355c3013d6cf5bd01f4841909","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"802a61ad0f7ed7a5e70e00e52e2be4ca756b5e7c2a30f3e9c92d1078b5e1810c","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fa5c50e7abe46571","findings":[{"citation":"resolved","description":"rotateKey validates the new blob only by size, prefix and code hash. It never requires rotation.pkBlob to differ from _accounts[id].pkBlob. The possession proof is then verified against the caller-chosen blob, which for the current blob is signed by the current key. The result is a vault whose account(id).publicKeyHash and KeyRotated event announce a new key K2, whose keyEpoch increments and whose usedPublicKeys[keccak(K2)] is burned globally, while the key material that actually authorizes operations is unchanged (the old blob). This is a rotation-specific form of accepted limitation L1 (the contract cannot prove blob == prepare(pk)); custody, nonce and other vaults are unaffected and only the controller plus the current key can trigger it, so it is reported as informational and marked as restating an accepted risk. The observable effect beyond L1 is that on-chain readers of publicKeyHash / KeyRotated are told a key change happened that did not. Minimal hardening: in rotateKey, after loading principal, revert InvalidPublicKey when rotation.pkBlob == principal.pkBlob (the same-address case is the only one that can be rejected on-chain; a different address holding the same bytes still needs the off-chain prepare(pk) check that L1 already requires).","line":274,"path":"src/BunkerVault.sol","reproduction":"State: vault idA registered with blobA (key K1). Input: rotateKey(idA, KeyRotation{publicKey: K2 (unused 1312 bytes), pkBlob: blobA, keyCodeHash: blobA.codehash, executor: controller, proofOfPossession: signed by K1 over rotationPossessionMessage}, Authorization{nonce 0, epoch 0, valid expiry, signature by K1 over rotationMessage}). Expected (per spec section 6, 'new-key consent' and 'possession of a validated new key'): revert. Actual: succeeds; account(idA).pkBlob == blobA, account(idA).publicKeyHash == keccak(K2), usedPublicKeys[keccak(K2)] == true, keyEpoch == 1. Foundry: test/scratch/Probe.t.sol::test_RotationToSameBlob passes on current code with the mock verifier armed for blobA on both messages.","severity":"info","snippet":"        _checkKeyBlob(rotation.pkBlob, rotation.keyCodeHash);","title":"rotateKey accepts the vault's current blob as the new blob, so a rotation can burn an arbitrary key hash while control does not change"},{"citation":"resolved","description":"AuthorizationMessage.encode and the DOMAIN constant (BunkerVault.Authorization.v1) are not referenced anywhere in src/. Their only consumer is test/mocks/AuthorizationHarness.sol. The library comment itself says the v1 encoding remains only for the original standalone test fixture. Shipping a second, weaker message format (no vaultId, no publicKeyHash) inside the production signing library is a footgun for client implementers who import the library and may sign the wrong format, and it enlarges the reviewed surface for no production benefit. No funds are at risk because the vault never verifies a v1 message. Fix: move encode/DOMAIN into the test harness (or delete them) so src/ exposes exactly one encoding, encodeAccount.","line":18,"path":"src/AuthorizationMessage.sol","reproduction":"grep -rn 'AuthorizationMessage.encode(' src/ returns nothing; grep -rn 'AuthorizationMessage.encode(' test/ returns only test/mocks/AuthorizationHarness.sol:31 and :35. Expected: production library contains only the encoding the vault verifies. Actual: it also contains the v1 encoding used by no production path.","severity":"info","snippet":"    function encode(","title":"Production library AuthorizationMessage keeps the unused v1 encode/DOMAIN that only a test harness consumes"},{"citation":"resolved","description":"The previous revision added recipient != token in withdrawERC20 (audit F3) so that tokens cannot be stranded in a contract that can never move them. The same class of sink is still reachable: a prepared public-key blob is a contract whose runtime begins with 0x00 (STOP), so a call with value to it succeeds and the ETH is locked forever; the same holds for the ML-DSA verifier, the Keccak helper and (for ERC-20 withdrawals) the fee token contract, the verifier and any blob. All of these are addresses the vault already knows (account(id).pkBlob, verifier, feeToken), so a cheap on-chain guard is possible. This is self-harm that requires a signed authorization naming the sink, so it is informational, consistent with the severity given to F3.","line":398,"path":"src/BunkerVault.sol","reproduction":"State: vault idA holds 1 ether of WETH credit. Input: withdrawETH(idA, blobB, 1 ether, valid authorization over (blobB, 1 ether, controller)). Expected (by analogy with the F3 fix): revert InvalidRecipient. Actual: succeeds, blobB.balance == 1 ether and nothing can ever move it. Foundry: test/scratch/Probe.t.sol::test_WithdrawEthToBlob passes on current code.","severity":"info","snippet":"        _checkRecipient(recipient);","title":"withdrawETH and withdrawERC20 accept data-only contracts (key blobs, 0x00 STOP code) and other protocol contracts as recipient, where value is irrecoverable"}],"hash":"60a6fac38c5c4cb955d2b229589f95f4e32ee57f72d2c2b0d09c7f678cbfeb03","nodeId":"09ab0168-83d4-4328-a0f7-595080d1a46b","outcome":"completed","summary":"The review is complete. The findings file holds three informational items and no Critical, High, Medium or Low defect. All 103 non-ffi tests pass, plus the three scratch probes I added under test/scratch.\n\n**What I found**\n\n- **Rotation to the current blob** (src/BunkerVault.sol:274). `rotateKey` never requires the new blob to differ from the current one, so the controller plus current key can burn any unused key hash and bump the epoch while the controlling key material stays the same. Readers of `publicKeyHash` and the `KeyRotated` event are told a key change happened that did not. Marked as restating accepted risk L1. Hardening: reject `rotation.pkBlob == principal.pkBlob`.\n- **Dead v1 encoding in the production library** (src/AuthorizationMessage.sol:18). The v1 `encode` and its domain are consumed only by a test harness. Move them out of src so clients cannot sign the weaker format.\n- **Irrecoverable withdrawal sinks** (src/BunkerVault.sol:398). Withdrawals still accept key blobs (STOP code), the verifier and the fee token as recipient. Same class the F3 fix addressed for `recipient == token`. Self-harm only, informational.\n\n**Invariants I tried to break and could not**\n\n1. **Signature plus controller.** Every state-changing path except `createVault` goes through the single gate, which checks vault existence, controller, nonce, epoch, expiry, blob and verifier before incrementing the nonce. `createVault` requires payer, executor and sender to be the same address. `receive` and `fallback` change no state.\n2. **One signature, one operation.** All messages are canonical `abi.encode` of static types over domain, chain id, vault address, vault id, key hash, key code hash, operation constant, params hash, nonce, expiry and epoch. Each operation has its own constant and param tuple. Rotation burns the old key globally and bumps the epoch. Cross-deployment and cross-chain replay are bound out.\n3. **Ledger conservation.** Deposits credit only after exact reserve, payer and supply deltas. Withdrawals debit before transferring and verify exact deltas after. A two-address token sharing one ledger keeps separate per-face liabilities and cannot withdraw more than it deposited (probe test). The guard is contract-wide, so a hostile token cannot re-enter any vault operation.\n4. **Locked fees.** Only `_collectRegistrationFee` moves the fee token, and the custody paths reject it. The split is exact for decimals 1 through 18 since the fee is a power of ten.\n5. **No resting ETH.** `receive` accepts ETH only from WETH while `unwrapping` is set, and that window contains exactly one external call. Balance deltas ignore forced ETH. Verified against the real WETH9 runtime and its 2300 gas stipend.\n6. **Atomic failure.** The nonce increment, asset pinning and fee settlement all sit inside the reverting frame.\n7. **Registration and rotation checks.** Key length, blob size, prefix, code hash, uniqueness and expiry are checked before the proof, and the proof before any fee token call. Both uniqueness checks run before verification, so a front-run victim pays little gas.\n\n**Verifier dependency.** Return handling is a strict `bool` decode under a pinned code hash. The Keccak helper is pinned by content on every call, and both helper call sites fail closed on a failed or short staticcall, so gas starvation yields a revert, never an acceptance. The blob layout (1 + 64 + 4096 + 16384 bytes) matches the verifier's offsets. A degenerate all-zero blob verifies without a key, but only for a vault whose registrant chose that blob, which is the documented L1 limitation.\n\n**Not run.** The ffi suite with real ML-DSA keys needs an npm install and was not executed. The mainnet fee token was not inspected for a second entry point.","treeHash":null,"usage":{"cachedInputTokens":1053980,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":41095,"runtime":"claude","turns":34,"wallClockMs":642364}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fbcdfc017217af1f","findings":[],"hash":"a21c667a3d7d833b6bbb856e50e6200608755dfe4858efe8a80b02b4f94c8822","nodeId":"bbb5175a-6d66-4c36-8496-ea6a9da13b56","outcome":"completed","summary":"The findings file stays at the empty list. The review is complete.\n\n**Result: no substantiated defect in the control flow of BunkerVault.** The findings file at the repository root holds `{\"findings\":[]}`. All 103 offline tests pass, and the extra probes I wrote under the scratch directory passed and were removed.\n\n**Invariants checked and not broken**\n\n- **Invariant 1 (controller + signature).** Every state-changing entry point other than `createVault` reaches storage only through `_consumeAuthorization`, which checks vault existence, controller, nonce, epoch, expiry, blob and signature before `++nonce`. `receive` and `fallback` write nothing. `createVault` writes only global registration state after the proof of possession and exact fee settlement.\n- **Invariant 2 (one signature, one operation).** All messages use canonical `abi.encode` of static types, distinct operation constants, chain id, contract address, vault id, key hash, key code hash, nonce, expiry and epoch. The rotation possession message embeds the hash of the old-key message and the vault id, so a victim's mempool proof cannot be lifted into an attacker's rotation. Deployment-level replay is covered by the existing tests.\n- **Invariant 3 (ledger ≤ reserve, no cross-token harm).** Withdrawals debit before transferring, deposits credit after exact delta checks, and every external call sits under `nonReentrant`. A token callback can only donate, force ETH, or call views. I confirmed a token that forces ETH mid-deposit and an ETH recipient that donates WETH mid-withdrawal leave liabilities exact and nothing credited.\n- **Invariant 4 (locked fees).** The fee token is excluded from custody, the only outbound fee transfer is the developer share, and settlement is checked by exact reserve, payer, developer and supply deltas.\n- **Invariant 5 (no resident ETH).** ETH is accepted only from WETH while `unwrapping` is set, and that flag is set and cleared around the single pinned WETH call. The recipient call happens after all delta checks and after the flag is cleared. Reentry from the recipient into `createVault` or any gated function hits the reentrancy guard.\n- **Invariant 6 (full revert).** No try/catch, no partial writes. Verifier revert, `false`, short return, or a non-bool word all revert the whole call. SafeERC20 5.6.1 bubbles failures and rejects `false`.\n- **Invariant 7 (registration and rotation checks).** Key length, blob size, prefix, code hash, key uniqueness and possession are all verified before the first fee-token call.\n\n**Verifier reliance (secondary scope).** The vault pins the verifier by code hash on every call. The verifier pins its Keccak helper by content, checks `ok` and exact `returndatasize` on every staticcall, and checks the exact 20,545-byte blob size. I found no path where a signature verifies without the key.\n\n**Observations that do not rise to findings.** Rotating to a new key label while keeping the same blob succeeds and marks the hash used; this is the accepted fee-free squatting path in section 9 of the specification. `withdrawERC20` lets the signer send tokens to the WETH contract, which is a signed choice, not a bypass.\n\n**Limits.** The ffi suite with real keys was not run, since its Node dependencies are not present offline. Coverage relies on the mock-verifier suite, the NIST-vector verifier tests, and my probes.","treeHash":null,"usage":{"cachedInputTokens":1344335,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":40770,"runtime":"claude","turns":31,"wallClockMs":568091}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fa99051b60a858d6","findings":[{"citation":"resolved","description":"The constructor rejects developerWallet_ only when it is zero or the vault itself. It accepts developerWallet_ == feeToken_, developerWallet_ == weth_ and developerWallet_ == verifier_. With such a configuration every createVault still succeeds: _collectRegistrationFee transfers the 10% developer share to a contract that has no way to move fee tokens (an ERC-20 crediting its own address, WETH9, or the verifier), so the delta checks at lines 537-542 pass and the developer share is permanently unrecoverable rather than received. This is a deployment-configuration guard gap, not a permission bypass: no vault asset, nonce or locked fee is affected, and constructor arguments are stated to come from reviewed deployment artifacts. Invariant 4 is kept (only the developer share leaves). Minimal fix: extend the InvalidConfiguration condition with `developerWallet_ == feeToken_ || developerWallet_ == weth_ || developerWallet_ == verifier_` (and reject a developer address with no code only if the design wants an EOA/multisig, which is optional). Existing test test_RejectsUnsafeConstructorConfiguration (test/Registration.t.sol:180) does not cover these three values.","line":163,"path":"src/BunkerVault.sol","reproduction":"Using test/utils/VaultFixture.sol: fixture(); CtorArgs a = ctorArgs; a.developer = address(token); BunkerVault other = deployVault(a); // expected: revert InvalidConfiguration; actual: deploys. Then token.approve(address(other), fee); verifier.configure(blobA, other.registrationMessage(registration), signature(), false); other.createVault(registration); // succeeds, and token.balanceOf(address(token)) increases by fee/10 (e.g. 1e17 for 18 decimals) with no path to recover it. a.developer = address(weth) and a.developer = address(verifier) also deploy without revert (verified with a scratch Foundry test under test/scratch/).","severity":"info","snippet":"|| developerWallet_ == address(0) || developerWallet_ == address(this)","title":"Constructor accepts the fee token, WETH or verifier contract as developerWallet, silently burning the developer share"}],"hash":"a6055c340056280324f007f173c228160de850e355c3013d6cf5bd01f4841909","nodeId":"cab3ea8e-640f-4cd7-bcfd-75761d2670c2","outcome":"completed","summary":"Findings file is written and validated against the source. The review of the assigned area is complete.\n\n## Outcome\n\nThe permissions layer of BunkerVault holds up. I found no Critical, High, Medium or Low defect in the access control, trust gap or asymmetry surface. One Informational configuration-guard gap is recorded in `.imd-findings.json`, with a concrete reproduction.\n\n**Finding (Informational)** at `src/BunkerVault.sol:163`. The constructor rejects the developer wallet only when it is zero or the vault itself. It accepts the fee token, WETH or verifier address as developer. Registration then succeeds and the 10% developer share is credited to a contract that can never move it, so it is burned rather than received. No vault asset, nonce or locked fee is affected. Fix: add those three addresses to the InvalidConfiguration condition. The existing constructor test does not cover these values.\n\n**Probed and confirmed to only restate accepted risk L1**, so not reported as defects:\n- A controller can rotate into the vault's current blob with any 1312-byte key, burning an arbitrary key hash for gas only.\n- Two vaults from different payers may share one blob address.\n\n## Invariants checked and not broken\n\n1. **No state change without key plus controller.** Every mutating entry point (five operations and rotateKey) routes through the single gate, which checks the controller before the signature. createVault requires executor and feePayer both equal msg.sender. receive and fallback change no state. No initializer, owner, upgrade, rescue or approval path exists.\n2. **One signature, one operation, once.** The message binds domain, chain id, vault address, vaultId, key hash, key code hash, operation constant, paramsHash, nonce, expiry and epoch, all as fixed 32-byte words via abi.encode. The nonce increments after the view-only verifier call and before any token or ETH interaction. The two PoP families use distinct operation constants and bind vaultId, so a registration PoP, rotation PoP and operation signature cannot be swapped across keys, vaults, operations or deployments.\n3. **Per-token ledger.** Each token has its own liability and reserve check. A lying token cannot touch another token's ledger. Donations stay unassigned.\n4. **Locked fees only grow.** The fee token is excluded from custody and the only outbound transfer is the developer share inside registration, delta-checked.\n5. **No ETH between calls.** All ETH checks use balance deltas. receive only accepts WETH during the unwrapping window, which is a slot warm from the same transaction, so the 2300-gas transfer path works and the real-WETH9 suite confirms it.\n6. **Atomic failure.** All effects are storage writes after checks. A failed verify, transfer or ETH send reverts nonce, admission and fee settlement together.\n7. **Registration and rotation validation.** Key length, blob size, 0x00 prefix, code hash, and global key uniqueness are checked before the PoP, and the PoP runs before any fee-token call.\n\nController-wallet compromise plus mempool control gives an attacker nothing beyond identical re-submission of the victim's own transaction or censorship, which is accepted risk L2.\n\n## Scope and limits\n\nI covered src/BunkerVault.sol, src/AuthorizationMessage.sol and the interfaces in full, and read the verifier's blob layout, exact size check, helper code-hash pin and z-norm and hint checks at the entry-point level. I did not re-derive the arithmetic inside Decode.sol, Ntt.sol or FastKeccak170.sol, so the verifier's cryptographic correctness relies on the vendored differential FFI tests, which I did not run. The baseline suite of 103 tests passes. A clean permissions review is not proof that no defects exist.","treeHash":null,"usage":{"cachedInputTokens":789047,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":38511,"runtime":"claude","turns":27,"wallClockMs":604725}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f9cb4fd544aa3c68","findings":[{"citation":"resolved","description":"The constructor guards the developer wallet against address(0) and address(this) (the latter because a self-transfer would break the exact reserve delta in _collectRegistrationFee), but it does not reject developerWallet_ == feeToken_. With that configuration every createVault still settles exactly (payer -fee, vault +lockedFee, 'developer' +developerFee, supply unchanged, all delta checks pass), but the 10% share is transferred to the fee-token contract itself, where no code path can move it. All constructor arguments are immutable, so the misconfiguration is permanent. Impact is limited to the developer's share (custody, locked fees and vault invariants 1-7 are unaffected), and the deployment artifacts are expected to be reviewed, hence informational. Minimal fix: add `|| developerWallet_ == feeToken_` (and, for symmetry, `|| developerWallet_ == weth_`) to the InvalidConfiguration condition.","line":163,"path":"src/BunkerVault.sol","reproduction":"Deploy BunkerVault(feeToken, feeToken.codehash, developerWallet = feeToken, verifier, verifier.codehash, weth, weth.codehash). Expected: revert InvalidConfiguration like the address(this) case. Actual: deployment succeeds; a subsequent createVault with an 18-decimal fee token moves 1e18 from the payer, leaves 9e17 in the vault (totalLockedFees = 9e17) and sends 1e17 to the token contract's own balance (token.balanceOf(token) increases by 1e17), which no function can ever withdraw. Verified with a scratch Foundry test (VaultFixture, deployVault with ctorArgs.developer = address(token), then createVault): token.balanceOf(address(token)) grew by fee / 10 and the deployment did not revert.","severity":"info","snippet":"                || developerWallet_ == address(0) || developerWallet_ == address(this)","title":"Constructor accepts developerWallet == feeToken, so every developer share is sent into the fee-token contract and is unrecoverable"}],"hash":"a80563f90e67dd294989dee605c44fb66cd309bbe29c9f18367f266ee9dd118f","nodeId":"2afb8ca8-d1c1-4fa3-8bbf-a7ce64ac6e3f","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds one informational finding. The math, boundary and numerical-gap passes found no exploitable defect in the assigned area.\n\n**What I checked and could not break**\n\n- **Fee split, decimals 1..18.** `10**d / 10` is exact for every d in range, so developer plus locked shares always sum to the fee. A scratch loop deployed the vault at each of the 18 decimals, registered, and confirmed exact payer debit, developer credit and locked reserve. Zero decimals is rejected before any division. Invariant 4 holds: the fee token is excluded from custody, nothing else moves it, and `totalLockedFees` is only ever incremented.\n- **Fee settlement deltas.** Both snapshots are ordered so the developer-equals-payer case settles exactly, and `reserveAfter == reserveBefore + lockedFee` holds because the developer cannot be the vault.\n- **Deposit and withdrawal deltas.** Every subtraction is preceded by an ordering check, so no underflow path is reachable. A deposit of the full uint256 supply succeeds and any overflow of the reserve sum reverts with the nonce restored. Ledger debits precede transfers and liability is bounded by the per-vault balance, so invariant 3 cannot be violated by one vault against another for a well-behaved token. Forced ETH and donated WETH are never credited and a withdrawal beside them stays exact.\n- **Paging bounds.** `heldTokens` was fuzzed over list sizes, start and count including `type(uint256).max`. It never reverts and returns exactly the expected slice.\n- **Expiry boundaries.** Equal to now is rejected, exactly one hour is accepted, one second over is rejected.\n- **Verifier key blob.** The vault's 20,545-byte pin equals the verifier's `PK_SIZE + 1` and all verifier reads stay within offset 1 to 20,545. Vault messages are a fixed 352 bytes, so the 800-byte SHAKE fallback path is never taken.\n\n**The one finding (informational)**\n\nThe constructor rejects a developer wallet equal to zero or to the vault, but not one equal to the fee token. With that configuration every registration passes all delta checks and sends the 10% share into the token contract, where it is unrecoverable forever since all arguments are immutable. Custody, locked fees and the seven invariants are unaffected. The fix is one more clause in the `InvalidConfiguration` condition.\n\n**Coverage notes.** The 103-test suite passes. Fee-split tests exercise only decimals 1, 6 and 18, though the arithmetic is provably exact for the rest. I did not run the FFI suite, which needs a network install, and I did not audit the verifier's lattice arithmetic beyond its blob layout and return handling.","treeHash":null,"usage":{"cachedInputTokens":1017591,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":26680,"runtime":"claude","turns":27,"wallClockMs":411999}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"5739ce0d803a43cd","findings":[{"citation":"resolved","description":"Merged from audit_math and audit_permissions (same root cause, same line). The constructor rejects developerWallet_ only when it is zero or the vault itself. It accepts developerWallet_ == feeToken_, developerWallet_ == weth_ and developerWallet_ == verifier_. With such a configuration every createVault still settles exactly: payer -fee, vault +lockedFee, 'developer' +developerFee, supply unchanged, so all delta checks in _collectRegistrationFee (lines 531-543) pass, but the 10% share is transferred to a contract with no code path to move fee tokens (an ERC-20 crediting its own address, WETH9, or the verifier) and is permanently unrecoverable. All constructor arguments are immutable, so the misconfiguration is permanent. Deployment-configuration gap only: custody, locked fees, nonces and invariants 1-7 are unaffected (invariant 4 is kept, only the developer share leaves), and constructor arguments are stated to come from reviewed deployment artifacts, hence informational. The existing test test_RejectsUnsafeConstructorConfiguration (test/Registration.t.sol:180) does not cover these values. Minimal fix: extend the InvalidConfiguration condition with `|| developerWallet_ == feeToken_ || developerWallet_ == weth_ || developerWallet_ == verifier_`.","line":163,"path":"src/BunkerVault.sol","reproduction":"Reproduced with a scratch Foundry test on test/utils/VaultFixture.sol: fixture(); for sink in [address(token), address(weth), address(verifier)] { CtorArgs a = ctorArgs; a.developer = sink; BunkerVault other = deployVault(a); } Expected: revert InvalidConfiguration, as for address(this). Actual: all three deploy. Then (18-decimal fee token) bob approves fee to `other`, the mock verifier is armed for other.registrationMessage(second), bob calls other.createVault(second): succeeds, token.balanceOf(sink) grows by exactly fee/10 = 1e17, token.balanceOf(other) == 9e17, and no function can ever move the 1e17 out of the sink.","severity":"info","snippet":"                || developerWallet_ == address(0) || developerWallet_ == address(this)","title":"Constructor accepts the fee token, WETH or verifier contract as developerWallet, so every developer share is sent to a contract that can never move it"},{"citation":"resolved","description":"From audit_economics; reproduced. rotateKey validates rotation.pkBlob only by size, prefix and code hash and never requires it to differ from _accounts[id].pkBlob. The possession proof is then verified against the caller-chosen blob, which for the current blob is produced by the current key. Result: account(id).publicKeyHash and the KeyRotated event announce a new key K2, keyEpoch increments, usedPublicKeys[keccak(K2)] is burned globally, but the blob that actually authorizes operations is unchanged. This is a rotation-specific instance of accepted limitation L1 (the contract cannot prove blob == prepare(pk); rotation is a documented fee-free squatting path): only the controller plus the current key can trigger it, custody and nonce are unaffected and other vaults are unaffected, so it is informational and marked as restating an accepted risk. The only effect beyond L1 is that on-chain readers of publicKeyHash / KeyRotated are told a key change happened that did not. Minimal hardening (the one case rejectable on-chain): in rotateKey, after loading principal, `if (rotation.pkBlob == principal.pkBlob) revert InvalidPublicKey();`. A different address holding the same bytes still needs the off-chain prepare(pk) check L1 already requires.","line":274,"path":"src/BunkerVault.sol","reproduction":"Scratch Foundry test on test/utils/VaultFixture.sol: fixture(); vault.createVault(registration) (vault idA, blobA, key hash keccak(publicKeyA)). Build KeyRotation r = {publicKey: rep(0x33,1312) (unused K2), pkBlob: blobA, keyCodeHash: codeHash, executor: alice, proofOfPossession: rep(0x43,2420)} and Authorization a = {nonce 0, expiresAt registration.expiresAt, keyEpoch 0, signature()}. Arm the mock: verifier.configure(blobA, vault.rotationMessage(id,r,a), signature(), false); verifier.configureAdditional(blobA, vault.rotationPossessionMessage(id,r,a), rep(0x43,2420)). Call vault.rotateKey(id, r, a). Expected per docs/CONTRACT.md section 6 ('possession of a validated new key'): revert. Actual: succeeds; account(id).pkBlob == blobA, account(id).publicKeyHash == keccak(K2), keyEpoch == 1, nonce == 1, usedPublicKeys[keccak(K2)] == true.","severity":"info","snippet":"        _checkKeyBlob(rotation.pkBlob, rotation.keyCodeHash);","title":"rotateKey accepts the vault's current blob as the new blob, so a rotation can burn an arbitrary key hash and bump the epoch while the authorizing key material does not change (restates accepted risk L"},{"citation":"resolved","description":"From audit_economics; reproduced with one correction. The previous revision added `recipient != token` to withdrawERC20 (audit F3) so tokens cannot be stranded in a contract that can never move them. The same class of sink remains reachable through addresses the vault already knows: a prepared public-key blob is a contract whose runtime begins with 0x00 (STOP), so an ETH call with value to it succeeds and the ETH is locked forever; WETH or any ERC-20 can likewise be withdrawn to a blob, to the verifier or to the fee-token contract. Correction to the specialist's claim: withdrawETH to the ML-DSA verifier does NOT succeed (the verifier has no receive/fallback, so the call fails and the vault reverts with ETHTransferFailed); only blobs and similar code-starting-with-STOP accounts swallow ETH. This is self-harm that requires a signed authorization naming the sink, so it is informational, consistent with the severity given to F3. Optional hardening: in _checkRecipient also reject account(id).pkBlob, address(verifier), address(feeToken) and, for ERC-20 withdrawals, address(weth).","line":398,"path":"src/BunkerVault.sol","reproduction":"Scratch Foundry test on test/utils/VaultFixture.sol with vault idA registered (blobA) and blobB etched with a 0x00-prefixed 20,545-byte blob: depositETH{value: 2 ether}(idA, auth(DEPOSIT_ETH, keccak(abi.encode(2 ether, alice)))); withdrawETH(idA, blobB, 1 ether, auth(WITHDRAW_ETH, keccak(abi.encode(blobB, 1 ether, alice)))). Expected by analogy with F3: revert InvalidRecipient. Actual: succeeds, blobB.balance == 1 ether and nothing can move it. withdrawETH(idA, address(verifier), 1 ether, ...) reverts ETHTransferFailed (specialist claim corrected). withdrawERC20(idA, weth, address(verifier), 0.5 ether, assetConfigurationHash(weth), ...) and the same to blobA both succeed: weth.balanceOf(verifier) == 0.5 ether, weth.balanceOf(blobA) == 0.5 ether.","severity":"info","snippet":"        _checkRecipient(recipient);","title":"withdrawETH and withdrawERC20 accept data-only contracts (key blobs whose code starts with STOP) and other protocol contracts as recipient, where the value is irrecoverable"},{"citation":"resolved","description":"From audit_economics; reproduced. AuthorizationMessage.encode and the DOMAIN constant (BunkerVault.Authorization.v1) are referenced nowhere in src/; their only consumer is test/mocks/AuthorizationHarness.sol (lines 31 and 35), and the library comment itself says the v1 encoding remains only for the original standalone test fixture. A second, weaker message format (no vaultId, no publicKeyHash) inside the production signing library is a footgun for client implementers who import the library, and it enlarges the reviewed surface for no production benefit. No funds are at risk because the vault never verifies a v1 message. Fix: move encode/DOMAIN into the test harness (or delete them) so src/ exposes exactly one encoding, encodeAccount.","line":18,"path":"src/AuthorizationMessage.sol","reproduction":"grep -rn 'AuthorizationMessage.encode(\\|DOMAIN' src/ test/ --include=*.sol: src/AuthorizationMessage.sol:7 (DOMAIN definition), :25 (used inside encode), test/mocks/AuthorizationHarness.sol:31 and :35 (only callers). No call in src/BunkerVault.sol. Expected: the production library contains only the encoding the vault verifies. Actual: it also contains the v1 encoding used by no production path.","severity":"info","snippet":"    function encode(","title":"Production library AuthorizationMessage ships the unused v1 encode/DOMAIN that only a test harness consumes"}],"hash":"f623dcb5edbc29a2b7a48a101afeb9eef1c06b17d4733f8562e4c449c03f7dc7","nodeId":"995dcfd8-328b-4c87-a764-bba90ec47688","outcome":"completed","summary":"Review complete. The findings file is written and validated, no tracked file was changed, and the full suite is green (103 project tests plus my 3 scratch reproductions, which are deleted before submission).\n\n**Outcome.** Four informational findings survive, no critical, high, medium or low. Each was reproduced against the code with a scratch Foundry test or a grep, and the reproduction is stated in the file.\n\n- **Constructor accepts the fee token, WETH or verifier as the developer wallet** (`src/BunkerVault.sol:163`). Merged from the math and permissions specialists, who reported the same root cause at the same line. Deployment passes and every registration strands the developer share in a contract that cannot move it. Custody and locked fees are unaffected.\n- **Rotation to the vault's own current blob** (`src/BunkerVault.sol:274`). Reproduced. It burns a fresh key hash and bumps the epoch while the authorizing key material stays the same. Marked as restating accepted risk L1.\n- **Withdrawals to data-only sinks** (`src/BunkerVault.sol:398`). Reproduced for ETH and WETH sent to a key blob and for WETH sent to the verifier. The specialist's claim that ETH can be sent to the verifier is wrong: the verifier has no payable path, so the vault reverts with ETHTransferFailed. Corrected in the finding. Self-harm requiring a signed authorization, so informational.\n- **Unused v1 encoding kept in the production library** (`src/AuthorizationMessage.sol:18`). Reproduced by grep: its only callers are in the test harness.\n\n**Invariants I checked and could not break.**\n\n1. Every state-changing path except createVault goes through `_consumeAuthorization`, which checks the controller, nonce, epoch, expiry, blob and signature before anything else. createVault requires the sender to be both payer and executor. receive accepts ETH only from WETH while unwrapping, and fallback always reverts.\n2. The message binds chain id, contract, vault id, key hash, key code hash, operation constant, parameter hash, nonce, expiry and epoch with plain abi.encode of fixed-size words. Every parameter hash includes msg.sender, and the registration and rotation possession messages use distinct operation constants, so no cross-replay exists.\n3. Each token has its own ledger, the vault never approves anyone, and exact balance and supply deltas bracket every transfer. A lying token can only lie about itself. Forced WETH and donations stay unassigned.\n4. The fee token leaves only in `_collectRegistrationFee`, only by the developer share. It is rejected as a custody asset and cannot be WETH. The only way around this would be a double-entry-point fee token, which the stated trust assumption excludes.\n5. ETH is held only inside withdrawETH, and the balance check uses deltas, so forced ETH is never credited.\n6. Nonce increment, asset pinning and fee settlement all sit inside the reverting transaction. The 300-step random test already asserts this and still passes.\n7. Key size, blob size, prefix and code hash, key uniqueness and proof of possession all precede the first fee-token call.\n\n**Limits of this review.** The ffi suite with real keys was not run because it needs an npm install. The vendored ML-DSA verifier was checked for return handling, helper pinning and the 20,545-byte layout, which are consistent with the vault's checks, but its lattice arithmetic was not reviewed for a forgery path. The hard-coded mainnet fee token has code, 18 decimals and an owner with transferOwnership. It would pass the constructor, and its owner powers fall under the separate fee-token review the specification names.","treeHash":null,"usage":{"cachedInputTokens":1636743,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":36298,"runtime":"claude","turns":36,"wallClockMs":540352}}],"verification":[]}