{"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":"0d6663af-e988-47ad-9f1e-42d0486086fe","kind":"audit","nodes":[{"acceptedSubmissionHash":"e27e99f141cda64d6b4db925ffba86f73f38676ce003a69f57d7bc9503ee7148","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":"8c0b307c8a7aa86c3958c4b1e89eb4604cc05c5bed2fb83893c1cf39252f162e","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":"a4234ea776329f76f27067f5b51a4fe198b7b6f21530fe8af46b549f32b4b355","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"0a99e042bacccdb195a653e0d5d4fbeac390e26caa7ae749af30cf1c154edcf7","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":"e3e9c9a881e023dd46e87ad352a0d611cf59265e87d940020b99536145868610","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 the HiveSeatVault contract in src/HiveSeatVault.sol: a non-upgradeable custody vault holding Project Hive's identity.md seat NFTs (ERC-721 collection 0x0000eC93127BAA929E58E97dd0095A2BFb38ec1D). The owner is a 48h OpenZeppelin TimelockController; a scoped seatOperator hot key may pair and run the seats but must never be able to move them. The ERC-1271 WorkerAuthorization pairing (authorizeWorker, revokeWorkerAuthorization, workerAuthorizationDigest, isValidSignature) is lifted verbatim from the audited IMDSeatStrategy so IMD accepts this contract as a seat's signer; the EIP-712 domain is 'IdentityMD Worker' version 2, bound to the seat collection. The security of the whole design rests on one property and it deserves the hardest look. (1) isValidSignature must return the ERC-1271 magic value ONLY for digests inserted by authorizeWorker, i.e. well-formed WorkerAuthorizations whose wallet equals address(this) and whose tokenId the vault owns. There must be NO path by which the seatOperator, a hot key that may be compromised, can cause isValidSignature to accept an arbitrary hash, in particular the hash of a Seaport order that would list or sell a seat. Confirm authorizeWorker only ever stores the EIP-712 digest it computes itself from a structured WorkerAuthorization, that an attacker-chosen digest cannot be inserted into the mapping, and that no Seaport or marketplace order hash can collide with a WorkerAuthorization digest. Then the custody invariants. (2) A seat NFT must leave the vault ONLY via withdrawSeat, which is onlyOwner (the Timelock): confirm there is no other path that transfers, approves (approve or setApprovalForAll), or lists a held seat, and that the vault never grants NFT approval to anyone. (3) The seatOperator's only powers are authorizeWorker, revokeWorkerAuthorization and registerAgent: confirm none of them can move value or a seat, and that a leaked operator key can at worst grief (stop pairing), never steal. (4) sweepEarnings must be unable to move a seat: it uses the ERC-20 interface, reverts if the token is the seat collection, and sends only to the fixed rewardSink. Confirm there is no caller-supplied destination and no way to reach the ERC-721 collection through it. (5) withdrawSeat, setSeatOperator, setRewardSink and setEnsName are all onlyOwner: confirm there is no privilege-escalation or reentrancy path around the Timelock, and that onERC721Received cannot be abused to brick the vault or spoof an approval. (6) The contract is non-upgradeable with no delegatecall and no selfdestruct: confirm the rules cannot change silently. Also assess reentrancy on registerAgent (external adapter call) and sweepEarnings (token transfers), and whether a hostile ERC-20 passed to sweepEarnings can do anything beyond reverting its own sweep. Report findings rather than fixing them. Do not propose changes to the 48h timelock design, the operator model, or the economics.","parentJobId":null,"planHash":"36ef30b523cebb787827b53fd38dd5bff0b381c53bb14beec654c0e334771653","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"0d6663af-e988-47ad-9f1e-42d0486086fe","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":"52272","feedbackHash":"c35c9937de461e2206f77103c9b38f5d8bf64ac33130c77ff22274ee1b6dbde4","nodeKey":"audit_economics","submissionHash":"e27e99f141cda64d6b4db925ffba86f73f38676ce003a69f57d7bc9503ee7148","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52255","feedbackHash":"c281ac1d21991a458d8841531113e5d458802c5da52ed2de12730270df200e14","nodeKey":"audit_flow","submissionHash":"8c0b307c8a7aa86c3958c4b1e89eb4604cc05c5bed2fb83893c1cf39252f162e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51227","feedbackHash":"4a9dd2529175d0bd5a8d254e62e1121d05697090763709ac89dd68146cc82995","nodeKey":"audit_judge","submissionHash":"a4234ea776329f76f27067f5b51a4fe198b7b6f21530fe8af46b549f32b4b355","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52265","feedbackHash":"6cc477ca91bc1c78a9a1eed90c9fad3887cd3c9cdb0f1ed3fcfc7e665a22662e","nodeKey":"audit_math","submissionHash":"0a99e042bacccdb195a653e0d5d4fbeac390e26caa7ae749af30cf1c154edcf7","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52264","feedbackHash":"46a796ab94e9cf6e684d4d6c070b802f0c562e0f0b3c2ac8cd0e31bdebd46e65","nodeKey":"audit_permissions","submissionHash":"e3e9c9a881e023dd46e87ad352a0d611cf59265e87d940020b99536145868610","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"9c66781e440b4760434c5f1ae8eed1fb12fcc09933c026d79dad90e7467460eb","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8db4eebdd7bd2744","findings":[{"citation":"resolved","description":"registerAgent is the leaked-operator-key mitigation story's weak point. The NatSpec on registerAgent/setAgentURI says the owner (Timelock) 'may force a re-registration, and can correct the URI via setAgentURI'. Both remedies key off a single slot, agentIdOf[tokenId], which registerAgent unconditionally overwrites. The live Adapter8004 (proxy 0xde152AfB7db5373F34876E1499fbD893A82dD336, impl 0xa6D23f27D3b1780B12488482a008cB3c3787135f) accepts any number of registrations for the same seat: on a mainnet fork, registering seat 1343 twice through the vault created ERC-8004 agents 52474 and 52475, both minted to the adapter and both bound to the seat. The adapter's setAgentURI is gated on collection.ownerOf(tokenId) == caller, i.e. only the vault can edit the URI of any agent bound to a custodied seat, and the vault's only call into it passes agentIdOf[tokenId]. So once the owner force re-registers, the earlier agent stays live in the public registry with whatever URI the leaked key set, and no path through the vault can ever correct it while the seat is custodied (withdrawing the seat to fix it from the treasury costs the 48h exit). The same slot design also means a seat that already carried an agent before deposit (registered by the treasury or the IMDSeatStrategy) is invisible to the vault: agentIdOf is 0, a leaked operator key can mint a duplicate agent for it, and setAgentURI reverts NotRegistered for the pre-existing one. Severity is low: no seat or token moves, and the harm is a permanently poisoned or duplicated public ERC-8004 identity record for a Hive seat. A minimal fix that keeps the design is to make the vault's correction path accept the agentId explicitly (owner-only setAgentURI(uint256 agentId, string uri), optionally still checking it belongs to a held seat via the adapter) and/or to record every agentId the vault creates per seat instead of only the last one.","line":222,"path":"src/HiveSeatVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {HiveSeatVault, IImdAgentAdapter, IEnsReverseRegistrar} from \"src/HiveSeatVault.sol\";\nimport {IERC721} from \"@openzeppelin/contracts/token/ERC721/IERC721.sol\";\nimport {ERC721} from \"@openzeppelin/contracts/token/ERC721/ERC721.sol\";\n\ncontract SeatMock is ERC721 {\n    constructor() ERC721(\"identity.md\", \"IDMD\") {}\n    function mint(address to, uint256 id) external { _mint(to, id); }\n}\n\n/// Mirrors the live Adapter8004: duplicate registrations for the same seat are allowed (verified on a\n/// mainnet fork: ids 52474 and 52475 were both created for seat 1343), setAgentURI is gated on the seat owner.\ncontract AdapterMock is IImdAgentAdapter {\n    IERC721 public immutable seat;\n    uint256 public n = 52473;\n    mapping(uint256 => string) public uriOf;\n    mapping(uint256 => uint256) public seatOf;\n    constructor(IERC721 s) { seat = s; }\n    function register(uint8, address, uint256 tokenId, string calldata uri) external returns (uint256) {\n        require(seat.ownerOf(tokenId) == msg.sender, \"not seat owner\");\n        uriOf[++n] = uri;\n        seatOf[n] = tokenId;\n        return n;\n    }\n    function setAgentURI(uint256 agentId, string calldata uri) external {\n        require(seat.ownerOf(seatOf[agentId]) == msg.sender, \"not seat owner\");\n        uriOf[agentId] = uri;\n    }\n}\n\ncontract OrphanProofTest is Test {\n    HiveSeatVault vault;\n    SeatMock seat;\n    AdapterMock adapter;\n    address timelock = makeAddr(\"timelock\");\n    address operator = makeAddr(\"operator\");\n    uint256 constant SEAT_ID = 1343;\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        seat = new SeatMock();\n        adapter = new AdapterMock(IERC721(address(seat)));\n        vault = new HiveSeatVault(timelock, IERC721(address(seat)), adapter, IEnsReverseRegistrar(address(0)), makeAddr(\"sink\"));\n        vm.prank(timelock);\n        vault.setSeatOperator(operator);\n        seat.mint(address(vault), SEAT_ID);\n    }\n\n    /// A leaked operator key registers agent #52474 with a poison URI. The owner (Timelock) uses the documented\n    /// remedy \"force a re-registration\", which creates #52475 and overwrites agentIdOf. The owner's only URI\n    /// correction path, setAgentURI(tokenId, ...), now reaches #52475 only. #52474 stays bound to the seat in\n    /// the registry with the poison URI, and the vault (the seat's owner, the only account the adapter accepts)\n    /// has no function that can reach it. Expected: the owner can correct every agent the vault created for the\n    /// seat. Actual: the first one is unreachable.\n    function test_owner_can_correct_every_agent_the_vault_created() public {\n        vm.prank(operator);\n        uint256 id1 = vault.registerAgent(SEAT_ID, \"ipfs://poison\");\n        vm.prank(timelock);\n        uint256 id2 = vault.registerAgent(SEAT_ID, \"ipfs://owner\");\n        assertEq(vault.agentIdOf(SEAT_ID), id2);\n        vm.prank(timelock);\n        vault.setAgentURI(SEAT_ID, \"ipfs://good\");\n        assertEq(adapter.uriOf(id2), \"ipfs://good\");\n        assertEq(adapter.uriOf(id1), \"ipfs://good\", \"first agent still carries the poison URI and is unreachable\");\n    }\n}","reproduction":"State: vault holds seat 1343; seatOperator = leaked hot key. (1) operator calls registerAgent(1343, \"ipfs://poison\") -> agent A (live: 52474), agentIdOf[1343] = A. (2) Owner uses the documented remedy and force re-registers: registerAgent(1343, \"ipfs://owner\") -> agent B (live: 52475), agentIdOf[1343] = B. (3) Owner calls setAgentURI(1343, \"ipfs://good\"). Expected: every agent the vault created for seat 1343 can be corrected. Actual: registry tokenURI(B) == \"ipfs://good\" but tokenURI(A) == \"ipfs://poison\" and A is unreachable from the vault. Verified on a mainnet fork (test/scratch/ForkDup.t.sol logged: 'live adapter allowed duplicate; id2 52475', 'id1 uri: ipfs://poison', 'id2 uri: ipfs://good'); the attached unit proof reproduces it with an adapter mock mirroring the live gating. Variant: seat already registered before deposit -> agentIdOf == 0 -> operator mints a duplicate agent; setAgentURI for the original reverts NotRegistered.","severity":"low","snippet":"        agentIdOf[tokenId] = agentId;","title":"registerAgent overwrites agentIdOf, so the owner's URI-correction remedy cannot reach earlier agents bound to the seat"},{"citation":"resolved","description":"sweepEarnings takes an arbitrary token address list. For a hostile contract the vault calls balanceOf (attacker-chosen return) and then transfer via SafeERC20, which only requires the call not to revert and to return true or nothing. The vault then emits Swept(token, bal, sink) with the attacker-chosen amount although nothing moved. This was checked as part of the 'hostile ERC-20' question: the hostile token cannot do anything else. During its transfer callback it cannot re-enter sweepEarnings/sweepETH/registerAgent (shared nonReentrant), cannot call authorizeWorker/revokeWorkerAuthorization/withdrawSeat (role-gated), cannot reach onERC721Received (msg.sender must be the seat collection), and cannot move the seat because msg.sender toward the collection is the token, not the vault, and the vault holds no approvals. Only the event is spoofable. Impact is limited to off-chain consumers that index Swept without an allow-list of token addresses (reward accounting dashboards, payout reconciliation). Mitigation if desired: index only known tokens off-chain, or emit Swept only when the token is in an owner-maintained allow-list; no on-chain value is at risk.","line":295,"path":"src/HiveSeatVault.sol","reproduction":"Attacker deploys HostileToken with balanceOf(address) returning 1_000_000e18 and transfer(address,uint256) returning true without moving anything. Attacker calls sweepEarnings([hostile]). Expected: no Swept event for a token that paid nothing. Actual: the vault emits Swept(hostile, 1000000e18, rewardSink). Reproduced in test/scratch/Sweep.t.sol with vm.expectEmit on the vault; in the same test every re-entry attempt from the hostile transfer (sweepEarnings, sweepETH, withdrawSeat, registerAgent, authorizeWorker, seat.transferFrom from the vault) reverted and seat 1343 stayed in the vault with no approval set.","severity":"info","snippet":"                emit Swept(token, bal, sink);","title":"sweepEarnings lets anyone emit a forged Swept event through a caller-supplied token"},{"citation":"resolved","description":"I8 presents setSeatOperator rotation as 'the real neutraliser' of a leaked operator key: after rotation, isValidSignature returns 0xffffffff for every prior pairing. That is true on-chain (tests confirm it). Whether it neutralises anything depends on the IMD relay re-checking the ERC-1271 signature on each session rather than only once at POST /pair/complete. If the relay checks once and then keeps the device paired off-chain, a device key paired by the leaked operator keeps running the seat after rotation, expiry and even after withdrawSeat, and the only effective remedies are off-chain (unpair on IMD's side). This is not a code defect in the vault and is outside the repository, so it is reported as an open trust assumption rather than a verified finding; it is listed because the contract's documentation attributes a security property to on-chain state that the vault alone cannot deliver. Suggested action: confirm with IMD how often the relay re-validates the signer, and document the result next to I8.","line":266,"path":"src/HiveSeatVault.sol","reproduction":"State: operator authorizes WorkerAuthorization W for seat 1343 with expiresAt = type(uint64).max and a device key the attacker controls; IMD pairs the device. Owner schedules and (48h later) executes setSeatOperator(newKey): on-chain isValidSignature(digest(W), \"\") now returns 0xffffffff (covered by test_rotating_operator_retires_pairings). Expected per I8: the leaked key's pairing is neutralised. Actual: only if IMD's relay re-queries isValidSignature after pairing; not verifiable from this repository.","severity":"info","snippet":"        if (p.authEpoch != authEpoch) return ERC1271_INVALID; // operator key was rotated (I8)","title":"On-chain pairing freshness (expiry, operator rotation, custody epoch) only protects if IMD re-queries isValidSignature after the initial pairing"}],"hash":"0a99e042bacccdb195a653e0d5d4fbeac390e26caa7ae749af30cf1c154edcf7","nodeId":"c26d51b9-33c3-4839-987d-c8ecab7765e5","outcome":"completed","summary":"The findings file is in place at the repository root (it is excluded from git by the harness's `.git/info/exclude`, which is expected), the scratch directory is removed, and no tracked file changed. Nothing further is needed from tools.\n\n## Result\n\nThe crux property holds. I found no path by which the seat operator, or anyone else, can make `isValidSignature` accept an attacker-chosen hash, move a seat, or grant an NFT approval. The audit produced one low and two informational findings, written to `.imd-findings.json`.\n\n**Findings**\n\n- **Low, `registerAgent` line 222.** The owner's documented remedy for a leaked operator key (force re-register, then correct the URI) does not work as described. `agentIdOf` holds only the latest agent id, so after the owner re-registers, the earlier agent stays bound to the seat in the public ERC-8004 registry with the leaked key's URI, and nothing in the vault can reach it while the seat is custodied. I verified on a mainnet fork that the live adapter allows duplicate registrations (agents 52474 and 52475 were both created for seat 1343) and gates `setAgentURI` on the seat's owner, which is the vault. A Foundry proof is attached that fails on the current code. No seat or token moves.\n- **Info, `sweepEarnings` line 295.** Anyone can emit a forged `Swept` event by passing a hostile token that reports a fake balance and returns true from `transfer`. I confirmed in a scratch test that such a token can do nothing else: every re-entry attempt reverted and the seat stayed put with no approval set.\n- **Info, `isValidSignature` line 266.** The on-chain expiry, rotation and custody-epoch checks only neutralise a leaked key if the IMD relay re-queries the signer after the initial pairing. That is an off-chain dependency I could not verify, reported as an open trust assumption.\n\n**What I confirmed, with evidence**\n\n- The pairing mapping is written only by `authorizeWorker`, which derives the key itself from a structured EIP-712 message after checking wallet, expiry and ownership. A Seaport or other marketplace digest would need a keccak collision to coincide. The fork test shows the digest is byte-identical to the live IMDSeatStrategy.\n- The live seat collection is a non-proxied Solady ERC-721 with no burn, pause, permit, or legacy `transfer(address,uint256)` selector. The only transfer paths are `transferFrom` and `safeTransferFrom`, and the vault never calls `approve` or `setApprovalForAll`. `sweepEarnings` only ever sends the `balanceOf` and `transfer` selectors to a caller-chosen target, and refuses the seat collection by address.\n- The full 32-test unit suite and all 5 fork tests pass against mainnet. The ERC-8004 agent token is minted to the adapter, not the vault, so the strict `onERC721Received` check does not break registration.\n- The contract contains no delegatecall or selfdestruct. `renounceOwnership` reverts, and the two-step transfer cannot drop the Timelock.\n- Arithmetic surface is minimal: unchecked `uint64` epoch increments cannot realistically wrap, and the expiry comparisons are consistently strict on both the write and read side.\n\n**Coverage limits.** I could not verify IMD's off-chain relay behaviour, and the collection's `setIdentityHash` feature is globally disabled on-chain, so the vault's lack of a passthrough for it is currently moot and was not reported. Slither was not run, as the task directs.","treeHash":null,"usage":{"cachedInputTokens":2388246,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":46709,"runtime":"claude","turns":38,"wallClockMs":659627}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e06554fd2816f9d7","findings":[{"citation":"resolved","description":"registerAgent (line 222) overwrites agentIdOf[tokenId] with the newest agentId, and setAgentURI (line 229) can only address that one id. The NatSpec at lines 210-211 and 226-227 offers the Timelock two remedies against a leaked operator key that registered a seat with a poison URI: 'force a re-registration' or 'correct the URI via setAgentURI'. Verified on a mainnet fork (2026-10-08, adapter 0xde152AfB7db5373F34876E1499fbD893A82dD336): the live Adapter8004 accepts a second register() for the same (collection, tokenId), minting a second ERC-8004 agent bound to the same seat, and its setAgentURI is gated on the CURRENT holder of the bound seat, so the vault is allowed to update the older agent directly. If the owner takes the re-register remedy, agentIdOf now points at the new agent; the earlier agent keeps the operator's poison URI in the public ERC-8004 registry, bound to a Hive seat, and the vault has no function that can reach it even though the adapter would permit the call. The same keying means a seat whose previous holder registered it before depositing (agentIdOf == 0) can never have its URI managed while custodied: setAgentURI reverts NotRegistered although the adapter accepts the vault as the current holder. Neither case moves a seat or value; the harm is a persistent attacker-chosen agentURI attributed to a vaulted seat, repairable only by withdrawing the seat through the 48h Timelock. Minimal fix that preserves the design: let the owner-only setAgentURI take the agentId explicitly (or keep a list per tokenId) and leave authorization to the adapter's own holder check, which already restricts it to the vault.","line":229,"path":"src/HiveSeatVault.sol","reproduction":"State: vault holds seat 1343; operator key leaked. (A) attacker calls registerAgent(1343, 'ipfs://poison') -> agentId A. Owner, via Timelock, calls registerAgent(1343, 'ipfs://clean') -> agentId B (allowed: msg.sender == owner); then setAgentURI(1343, 'ipfs://fixed'). Expected: the seat's agents no longer carry the poison URI. Actual: adapter.uriOf(B) == 'ipfs://fixed' but adapter.uriOf(A) == 'ipfs://poison' and no vault function can change A; a direct adapter.setAgentURI(A, ...) with msg.sender == vault succeeds, proving only the vault's keying blocks it. (B) treasury registers seat 1343 itself (agentId A), then deposits it; owner calls setAgentURI(1343, 'ipfs://new'). Expected: URI updated (vault is the holder the adapter checks). Actual: revert NotRegistered(). Scratch test test/scratch/AgentUriKeying.t.sol reproduces both with a mock mirroring the live adapter behaviour; the live behaviour itself was confirmed with a fork probe (owner re-register returned agentId 52475 after 52474; a fresh vault holding the seat set the URI of 52474 directly; a stranger's call reverted).","severity":"low","snippet":"        uint256 agentId = agentIdOf[tokenId];\n        if (agentId == 0) revert NotRegistered();","title":"setAgentURI is keyed by the vault's latest agentId per seat, so the documented owner 'force re-register' remedy orphans the poisoned agent and pre-deposit registrations cannot be corrected from custod"},{"citation":"resolved","description":"The adapter address is immutable here but the live contract at 0xde152AfB7db5373F34876E1499fbD893A82dD336 is a UUPS proxy (upgradeToAndCall 0x4f1ef286 and proxiableUUID 0x52d1902d present in the implementation 0xa6D23f27D3b1780B12488482a008cB3c3787135f, Ownable owner()). Its behaviour can change without the vault's consent. Traced consequences for this vault: the adapter is called with msg.sender == vault from registerAgent (nonReentrant) and setAgentURI (onlyOwner, not guarded). A hostile implementation can revert (registration/URI liveness only), consume gas, or call back into the vault; every state-changing re-entry target is either onlyOwner, onlySeatOperator, gated by the same ReentrancyGuard (sweepEarnings, sweepETH), or restricted to msg.sender == seatCollection (onERC721Received). The vault never grants ERC-721 approval to the adapter and never passes it the seat, so no upgrade can transfer, approve or list a seat. Recorded as the one external dependency whose rules can change silently; no change to the vault is required.","line":88,"path":"src/HiveSeatVault.sol","reproduction":"Deploy a mock adapter whose register() reverts or re-enters vault.sweepEarnings/sweepETH/registerAgent: registerAgent reverts (ReentrancyGuardReentrantCall or the mock's revert); seatCollection.ownerOf(tokenId) remains the vault in every case. Expected and actual agree; listed for the trust model, not as a failing input.","severity":"info","snippet":"    IImdAgentAdapter public immutable agentAdapter; // ERC-8004 registration","title":"agentAdapter is an IMD-owned UUPS proxy: an upgraded adapter can block or reenter registerAgent, but cannot reach a seat (trust assumption, no defect)"}],"hash":"8c0b307c8a7aa86c3958c4b1e89eb4604cc05c5bed2fb83893c1cf39252f162e","nodeId":"0509feda-781f-48dd-8fbb-be3a29fe75bd","outcome":"completed","summary":"The findings file is in place at the repository root, and the harness excludes it from git by design. The review is complete.\n\n**Outcome.** The six custody and signature properties the brief asked about all hold on the code as it stands. I found one low-severity defect and one documented trust assumption. No critical or high findings, so no proof tests were attached. `.imd-findings.json` holds both entries with line-aligned snippets.\n\n**What I verified for the crux, property (1).** The pairing mapping has exactly two writers: `authorizeWorker` stores a digest it computes itself from the typed struct, and `revokeWorkerAuthorization` deletes. The struct must carry the vault as wallet and a token the vault holds. The digest preimage is the EIP-712 prefix plus the fixed IdentityMD Worker domain plus a struct hash under a fixed typehash, so an operator cannot land on an attacker-chosen hash. A Seaport or other marketplace digest uses a different domain separator, so equality would need a keccak collision. On the live collection I confirmed there is no permit, no EIP-712 domain, no `transfer(address,uint256)` and no burn selector, and that its owner is the CREATE2 deployer, so there is no admin path around the vault either.\n\n**Custody properties (2) through (6).** The only `safeTransferFrom` calls on the seat collection are in `withdrawSeat`, which is owner-only. The source contains no approve, setApprovalForAll, delegatecall, selfdestruct or assembly. `sweepEarnings` uses the ERC-20 interface, blocks the collection address, and sends only to the stored sink. A hostile ERC-20 can only revert its own sweep or burn gas, since every reentry target is owner-gated, operator-gated, behind the shared reentrancy guard, or restricted to the collection as caller. Operator powers cannot move a seat or value. The fork suite also passes against the live adapter, which mints the ERC-8004 agent NFT to itself rather than into the vault.\n\n**Findings written.**\n- **Low, `src/HiveSeatVault.sol:229`.** URI correction is keyed by the vault's latest agentId per seat. On a mainnet fork the live adapter allowed a second registration for the same seat and let the current holder update any agent bound to it. So if the Timelock uses the documented \"force re-register\" remedy against a leaked operator key, the poisoned earlier agent stays poisoned and unreachable from the vault. Seats registered before deposit cannot have their URI managed from custody at all. No seat or value is at risk.\n- **Info, `src/HiveSeatVault.sol:88`.** The adapter is an IMD-owned UUPS proxy. An upgrade can block or reenter registration but cannot reach a seat, since the vault never grants it approval. Recorded as a trust assumption, not a defect.\n\n**Limits.** Slither was not run, as the task does not provide it. Live-adapter behaviour was checked once on 2026-10-08 and could change under a later upgrade. Scratch tests were deleted after use, as the brief allows.","treeHash":null,"usage":{"cachedInputTokens":1679637,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":34945,"runtime":"claude","turns":33,"wallClockMs":472327}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"11e4593175677f7e","findings":[{"citation":"resolved","description":"agentIdOf[tokenId] is a single slot that registerAgent (line 222) unconditionally overwrites and that setAgentURI (line 229) is the only reader of. The NatSpec at lines 210-211 and 226-227 gives the Timelock two remedies against a leaked operator key that registered a seat with a poison URI: 'force a re-registration' or 'correct the URI via setAgentURI'. The live Adapter8004 (proxy 0xde152AfB7db5373F34876E1499fbD893A82dD336) accepts a second register() for the same (collection, tokenId) and mints a second ERC-8004 agent bound to the same seat, and its setAgentURI is gated only on collection.ownerOf(tokenId) == msg.sender, i.e. the vault is the one account allowed to edit ANY agent bound to a custodied seat. Consequences: (A) if the owner takes the re-register remedy, agentIdOf now points at the new agent and the earlier agent keeps the operator's poison URI in the public registry, bound to a Hive seat, with no vault function able to reach it even though the adapter would permit the call; (B) a seat whose previous holder registered it before depositing has agentIdOf == 0, so setAgentURI reverts NotRegistered for the pre-existing agent while the operator's once-per-seat registerAgent mints a duplicate agent for it instead of reusing the first. Neither case moves a seat or any value; the harm is a persistent attacker-chosen agentURI attributed to a vaulted seat, repairable only by withdrawing the seat through the 48h Timelock. Merged from three specialist reports (audit_math, audit_flow, audit_economics) describing the same slot design. Minimal fix that keeps the design and the existing ABI: record every agentId the vault registers per seat (e.g. mapping(uint256 => uint256[])) and have the owner-only setAgentURI(tokenId, uri) update all of them, or have the owner's forced re-register first correct/retire the previous agent; an alternative is an owner-only setAgentURI(agentId, uri) that defers authorization to the adapter's holder check, which already restricts it to the vault.","line":222,"path":"src/HiveSeatVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {HiveSeatVault, IImdAgentAdapter, IEnsReverseRegistrar} from \"src/HiveSeatVault.sol\";\nimport {IERC721} from \"@openzeppelin/contracts/token/ERC721/IERC721.sol\";\nimport {ERC721} from \"@openzeppelin/contracts/token/ERC721/ERC721.sol\";\n\ncontract SeatMock is ERC721 {\n    constructor() ERC721(\"identity.md\", \"IDMD\") {}\n    function mint(address to, uint256 id) external { _mint(to, id); }\n}\n\n/// Mirrors the live Adapter8004: duplicate registrations for the same seat are allowed (verified on a\n/// mainnet fork: ids 52474 and 52475 were both created for seat 1343), setAgentURI is gated on the seat owner.\ncontract AdapterMock is IImdAgentAdapter {\n    IERC721 public immutable seat;\n    uint256 public n = 52473;\n    mapping(uint256 => string) public uriOf;\n    mapping(uint256 => uint256) public seatOf;\n    constructor(IERC721 s) { seat = s; }\n    function register(uint8, address, uint256 tokenId, string calldata uri) external returns (uint256) {\n        require(seat.ownerOf(tokenId) == msg.sender, \"not seat owner\");\n        uriOf[++n] = uri;\n        seatOf[n] = tokenId;\n        return n;\n    }\n    function setAgentURI(uint256 agentId, string calldata uri) external {\n        require(seat.ownerOf(seatOf[agentId]) == msg.sender, \"not seat owner\");\n        uriOf[agentId] = uri;\n    }\n}\n\ncontract OrphanProofTest is Test {\n    HiveSeatVault vault;\n    SeatMock seat;\n    AdapterMock adapter;\n    address timelock = makeAddr(\"timelock\");\n    address operator = makeAddr(\"operator\");\n    uint256 constant SEAT_ID = 1343;\n\n    function setUp() public {\n        vm.warp(1_700_000_000);\n        seat = new SeatMock();\n        adapter = new AdapterMock(IERC721(address(seat)));\n        vault = new HiveSeatVault(timelock, IERC721(address(seat)), adapter, IEnsReverseRegistrar(address(0)), makeAddr(\"sink\"));\n        vm.prank(timelock);\n        vault.setSeatOperator(operator);\n        seat.mint(address(vault), SEAT_ID);\n    }\n\n    /// A leaked operator key registers agent #52474 with a poison URI. The owner (Timelock) uses the documented\n    /// remedy \"force a re-registration\", which creates #52475 and overwrites agentIdOf. The owner's only URI\n    /// correction path, setAgentURI(tokenId, ...), now reaches #52475 only. #52474 stays bound to the seat in\n    /// the registry with the poison URI, and the vault (the seat's owner, the only account the adapter accepts)\n    /// has no function that can reach it. Expected: the owner can correct every agent the vault created for the\n    /// seat. Actual: the first one is unreachable.\n    function test_owner_can_correct_every_agent_the_vault_created() public {\n        vm.prank(operator);\n        uint256 id1 = vault.registerAgent(SEAT_ID, \"ipfs://poison\");\n        vm.prank(timelock);\n        uint256 id2 = vault.registerAgent(SEAT_ID, \"ipfs://owner\");\n        assertEq(vault.agentIdOf(SEAT_ID), id2);\n        vm.prank(timelock);\n        vault.setAgentURI(SEAT_ID, \"ipfs://good\");\n        assertEq(adapter.uriOf(id2), \"ipfs://good\");\n        assertEq(adapter.uriOf(id1), \"ipfs://good\", \"first agent still carries the poison URI and is unreachable\");\n    }\n}","reproduction":"Unit (mock mirrors the live adapter: duplicates allowed, setAgentURI gated on seat holder): vault holds seat 1343, operator = leaked key. (1) vm.prank(operator); registerAgent(1343, 'ipfs://poison') -> agent A, agentIdOf[1343] = A. (2) vm.prank(timelock); registerAgent(1343, 'ipfs://owner') -> agent B, agentIdOf[1343] = B. (3) vm.prank(timelock); setAgentURI(1343, 'ipfs://good'). Expected: every agent the vault created for seat 1343 carries the corrected URI. Actual: adapter.uriOf(B) == 'ipfs://good' but adapter.uriOf(A) == 'ipfs://poison' and no vault function can change A, while a direct adapter.setAgentURI(A, ...) with msg.sender == vault succeeds. Variant: treasury registers seat 2000 itself (agent A), deposits it; vm.prank(timelock); setAgentURI(2000, ...) reverts NotRegistered(); vm.prank(operator); registerAgent(2000, 'ipfs://poison') succeeds and binds a duplicate agent C != A. Live confirmation on a mainnet fork at 2026-10-08: registering seat 1343 twice through the vault against the real adapter returned agentIds 52475 then 52476; the vault (as holder) could call adapter.setAgentURI(52475, ...) directly, a stranger's call reverted, and vault.setAgentURI(1343, ...) only reached 52476. The attached proof fails on the current code with 'first agent still carries the poison URI and is unreachable: ipfs://poison != ipfs://good'.","severity":"low","snippet":"        agentIdOf[tokenId] = agentId;","title":"registerAgent/setAgentURI key one agentId per seat: the owner's documented remedies orphan a poisoned agent and cannot reach pre-deposit registrations"},{"citation":"resolved","description":"onlySeatOperator admits owner() as a second pairing key, so the Timelock can call authorizeWorker. The clean-slate mechanism (authEpoch, line 328) fires only inside setSeatOperator when the operator address changes. The inherited Ownable2Step transferOwnership/acceptOwnership path never touches authEpoch, so every pairing the OLD owner inserted stays VALID under the NEW owner for as long as its operator-chosen expiresAt (up to type(uint64).max). I8 describes a key change as retiring every live pairing; an owner handover is a key change that does not. This is a documentation/invariant gap, not a theft path: a pairing can never move a seat. Correction to the specialist's text: the new owner is NOT limited to rotating the operator to retire them; revokeWorkerAuthorization(digest) is available to the owner per digest (digests are emitted in WorkerAuthorized), so the gap is that the retirement is manual rather than automatic. Minimal fix if wanted: bump authEpoch in an override of _transferOwnership (or acceptOwnership), or document that owner-made pairings are not retired by an ownership handover.","line":144,"path":"src/HiveSeatVault.sol","reproduction":"State: owner = timelockA, vault holds seat 1343. (1) vm.prank(timelockA); authorizeWorker({wallet: vault, tokenId: 1343, expiresAt: type(uint64).max, ...}) -> digest D; isValidSignature(D, '') == 0x1626ba7e. (2) vm.prank(timelockA); transferOwnership(timelockB); vm.prank(timelockB); acceptOwnership(); owner() == timelockB. (3) isValidSignature(D, '') is still 0x1626ba7e (authEpoch unchanged, custodyEpoch unchanged, seat still held). Expected per I8's clean-slate wording: a change of a pairing key retires the pairings it made. Actual: D stays valid until timelockB calls revokeWorkerAuthorization(D) (verified to return 0xffffffff afterwards) or setSeatOperator with a new address. Reproduced in a scratch test (test_owner_pairings_survive_ownership_transfer).","severity":"low","snippet":"        if (msg.sender != seatOperator && msg.sender != owner()) revert NotSeatOperator();","title":"Pairings inserted by a previous owner survive transferOwnership/acceptOwnership (authEpoch only bumps on an operator change)"},{"citation":"resolved","description":"All custody guarantees (I1, I5, I6, I8's rotation remedy) reduce to 'onlyOwner is a 48h public delay', but the constructor only requires owner_ to be non-zero (Ownable). It does not check that owner_ has code, is a TimelockController, has getMinDelay() >= 48h, or that the timelock's admin role has been renounced (an un-renounced admin can call updateDelay(0) and then schedule+execute withdrawSeat in one block). The repository ships no deployment script, no post-deploy assertion and no test exercising a real TimelockController: every test pranks an EOA named 'timelock'. The same applies to the two-step hand-off: transferOwnership + acceptOwnership can move ownership to any address the current timelock approves, after which all owner powers are instant. Not a code defect and not a request to change the timelock design; recorded as an open deployment item and trust assumption. Suggested closure: a deploy script that deploys TimelockController(minDelay = 48h, proposers, executors, admin = address(0)) and passes it as owner_, plus an integration test asserting schedule -> execute(withdrawSeat) reverts before 48h and succeeds after.","line":149,"path":"src/HiveSeatVault.sol","reproduction":"new HiveSeatVault(owner_ = any EOA, seatCollection, adapter, ens, sink); deposit seat 1343. Expected per README/I1: a seat exit is queued publicly >= 48h ahead. Actual: vm.prank(owner_); vault.withdrawSeat(1343, anywhere) succeeds in the same block and seat.ownerOf(1343) == anywhere (scratch test test_eoa_owner_withdraws_instantly; the shipped test_owner_withdraws does the same with an EOA).","severity":"info","snippet":"        address owner_, // the TimelockController","title":"Nothing in the code binds owner_ to a 48h TimelockController: every custody delay is a deployment-time assumption"},{"citation":"resolved","description":"The vault itself is non-upgradeable (I7 verified: no delegatecall, no selfdestruct, no proxy, immutables only), but the live Adapter8004 it binds to (0xde152AfB7db5373F34876E1499fbD893A82dD336, from IMDSeatStrategy.IMD_AGENT_ADAPTER()) is an ERC-1967 proxy whose implementation slot holds 0xa6d23f27d3b1780b12488482a008cb3c3787135f; that implementation exposes upgradeToAndCall (0x4f1ef286) and proxiableUUID (0x52d1902d) and owner() returns 0x03302Df40186D9B85faEA4fbb6cC5da028B23149. An implementation change by IMD alters what registerAgent and setAgentURI do without any Timelock action. Verified damage bound: the adapter is called with msg.sender == vault only from registerAgent (nonReentrant) and setAgentURI (onlyOwner); a hostile implementation can revert, burn gas, register junk, or call back into the vault, but every state-changing re-entry target is onlyOwner, onlySeatOperator, gated by the shared ReentrancyGuard (sweepEarnings, sweepETH, registerAgent), or restricted to msg.sender == seatCollection (onERC721Received). The vault never grants ERC-721 approval to the adapter (isApprovedForAll(vault, adapter) == false and getApproved(seat) == 0 after a live registration), so no upgrade can transfer, approve or list a seat or insert a pairing digest. Merged from audit_flow and audit_permissions. Trust assumption, no change to the vault required.","line":88,"path":"src/HiveSeatVault.sol","reproduction":"On mainnet: cast storage 0xde152AfB7db5373F34876E1499fbD893A82dD336 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc -> 0x...a6d23f27d3b1780b12488482a008cb3c3787135f (non-zero: upgradeable proxy); the implementation bytecode contains selectors 4f1ef286 and 52d1902d. Expected per I7 wording ('the rules cannot change silently'): every code path the vault depends on is frozen; actual: the registration path is upgradeable by IMD. Bound confirmed by a scratch test in which a hostile callee attempts, during a vault call, sweepEarnings, sweepETH, registerAgent, withdrawSeat, setSeatOperator, onERC721Received, authorizeWorker and seat.transferFrom(vault, ...): all 8 revert and seat.ownerOf(1343) stays the vault with no approval set.","severity":"info","snippet":"    IImdAgentAdapter public immutable agentAdapter; // ERC-8004 registration","title":"agentAdapter is an IMD-owned UUPS proxy: its rules can change without the Timelock, bounded to reverting/junk registrations, never a seat"},{"citation":"resolved","description":"sweepEarnings is permissionless and takes an arbitrary token list; the only exclusion is the seat collection. For a hostile contract the vault reads balanceOf via STATICCALL (attacker-chosen return) and then calls transfer via SafeERC20, which only requires the call not to revert and to return true or nothing. The vault then emits Swept(token, bal, rewardSink) with the attacker-chosen amount although nothing moved; the amount is also the pre-transfer balance, so fee-on-transfer or rebasing tokens over-report. This answers the brief's 'hostile ERC-20' question: during its transfer callback the token cannot re-enter sweepEarnings/sweepETH/registerAgent (shared nonReentrant), cannot call authorizeWorker/revokeWorkerAuthorization/withdrawSeat/setSeatOperator (role-gated), cannot reach onERC721Received (msg.sender must be the seat collection), and cannot move the seat because its msg.sender toward the collection is the token, not the vault, and the vault holds no approvals. The seat collection cannot be reached through sweepEarnings (exact address check; the collection has no transfer(address,uint256)). Impact is limited to off-chain consumers that index Swept without an allow-list. Merged from audit_permissions and audit_math. Mitigation if desired: consumers verify Swept against the token's own Transfer event, or an owner-maintained token allow-list (a design change, not required).","line":295,"path":"src/HiveSeatVault.sol","reproduction":"Deploy EvilToken with balanceOf(address) returning 1e24 and transfer(address,uint256) returning true without moving anything. vm.prank(anyone); vault.sweepEarnings([evil]). Expected: no Swept event for a token that paid nothing. Actual: the vault emits Swept(evil, 1e24, rewardSink) (vm.expectEmit on the vault passes). In the same transaction the token's transfer attempts all 8 re-entries/seat moves listed above: all revert (counter == 8), seat.ownerOf(1343) == vault, getApproved(1343) == 0, isApprovedForAll(vault, evil) == false. Scratch test test_hostile_token_forges_Swept_but_nothing_else.","severity":"info","snippet":"                emit Swept(token, bal, sink);","title":"sweepEarnings emits Swept for any caller-supplied contract that answers balanceOf/transfer, so the event can be forged; a hostile token can do nothing else"},{"citation":"resolved","description":"onERC721Received accepts every token of the collection from any sender, and authorizeWorker/registerAgent gate only on ownerOf(tokenId) == address(this), not on a per-seat allow-list. A seat that an unrelated holder sends to the vault (by mistake) is immediately pairable and registerable by the seatOperator hot key and can leave only through a 48h Timelock withdrawSeat naming the sender (rescueERC721 refuses the seat collection). The vault itself is unharmed and the seat is custodied under the same rules as Hive's own seats; the deposit requires the holder's own safeTransferFrom, so it cannot be forced on them. Listed because the brief asks what a leaked operator key can reach: 'run a stranger's mistakenly deposited seat until the Timelock returns it' is within its reach. Open deposits are a stated design choice; no change is required beyond documenting it for depositors. Downgraded from the specialist's low to info for that reason.","line":173,"path":"src/HiveSeatVault.sol","reproduction":"State: vault holds Hive seat 1343; seatOperator = op. (1) Unrelated holder X calls seatCollection.safeTransferFrom(X, vault, 777): accepted. (2) vm.prank(op); authorizeWorker({wallet: vault, tokenId: 777, expiresAt: now + 10 days, ...}) -> isValidSignature == 0x1626ba7e; vm.prank(op); registerAgent(777, 'ipfs://poison') succeeds. (3) vm.prank(X); withdrawSeat(777, X) reverts (onlyOwner); vm.prank(timelock); rescueERC721(seat, 777, X) reverts CannotRescueSeats; only vm.prank(timelock); withdrawSeat(777, X) returns it. Scratch test test_third_party_seat_pairable_by_operator.","severity":"info","snippet":"        if (msg.sender != address(seatCollection)) revert NotSeatCollection();","title":"Open deposits let the operator pair and register any third-party seat sent to the vault, with no self-service exit for the depositor"},{"citation":"resolved","description":"The constructor explicitly permits ensReverseRegistrar == address(0) ('may be address(0) off-mainnet', line 89), but setEnsName performs a high-level call expecting a bytes32 return. Since solc 0.8.10 the extcodesize check is skipped when return data is expected, so the call to the empty account returns no data and ABI decoding reverts with no reason. A Timelock proposal would fail after its 48h delay with an opaque revert. Cosmetic, owner-only, no security impact. Minimal fix: revert with a named error when address(ensReverseRegistrar) == address(0), or disallow zero in the constructor.","line":357,"path":"src/HiveSeatVault.sol","reproduction":"new HiveSeatVault(timelock, seat, adapter, IEnsReverseRegistrar(address(0)), sink); vm.prank(timelock); address(vault).call(abi.encodeWithSelector(vault.setEnsName.selector, 'hive.eth')). Expected: a clear revert (or no-op) when ENS is not configured. Actual: ok == false and returndata.length == 0 (scratch test test_setEnsName_zero_registrar_reverts_empty).","severity":"info","snippet":"        return ensReverseRegistrar.setName(name);","title":"setEnsName reverts with empty revert data when ensReverseRegistrar was deployed as address(0)"}],"hash":"a4234ea776329f76f27067f5b51a4fe198b7b6f21530fe8af46b549f32b4b355","nodeId":"f737657e-0ede-4340-9264-d3276f4d7dc9","outcome":"completed","summary":"The findings file exists and is valid. Git hides it only because the repo's local exclude list names it, which is the harness's own setup. The scratch directory is gone and the tree holds no stray files.\n\n## Review outcome\n\nSeven findings are in `.imd-findings.json`, all reproduced against the code. The core property the brief cares about holds: no finding touches seat custody or ERC-1271 forgery.\n\n**What I confirmed clean**\n\n- The pairing mapping is written only by `authorizeWorker` with a digest the vault computes itself under the \"IdentityMD Worker\" v2 domain, so a Seaport or any other EIP-712 digest cannot land there without a keccak collision.\n- The live seat collection is a plain non-proxy ERC-721 with no permit or signature-based approval entry point, and the vault never calls approve. A forged ERC-1271 acceptance therefore could not move a seat even in principle.\n- A hostile ERC-20 in `sweepEarnings` can only forge an event. All eight re-entry and seat-move attempts from its transfer callback revert.\n- No delegatecall, selfdestruct or proxy in the vault.\n\n**Findings kept (merged from 12 specialist entries)**\n\n1. **Low, with proof.** `agentIdOf` is a single slot per seat. The owner's documented remedies against a leaked operator key orphan the poisoned agent and cannot reach agents registered before deposit. Verified on a mainnet fork: the live adapter accepts duplicate registrations and lets the vault edit the older agent directly, but the vault has no function to do so. The specialist's proof fails on this code for the stated reason, so I kept it.\n2. **Low.** Pairings the old owner inserted survive an ownership transfer, since `authEpoch` bumps only on operator rotation. I corrected the specialist's text: the new owner can revoke per digest, so the gap is manual rather than automatic retirement.\n3. **Info.** Nothing in the code binds the owner to a 48h timelock. Deployment item.\n4. **Info.** The adapter is an IMD-owned UUPS proxy. Verified on chain. Blast radius bounded to reverting or junk registrations.\n5. **Info.** `Swept` can be forged by any contract answering `balanceOf` and `transfer`.\n6. **Info.** Open deposits let the operator run a stranger's mistakenly deposited seat until the timelock returns it. Downgraded from low: stated design choice.\n7. **Info.** `setEnsName` reverts with empty data when the registrar is zero.\n\n**Dropped:** the claim that pairing freshness depends on IMD's relay re-querying `isValidSignature`. It cannot be reproduced against the code and describes off-chain behaviour outside the repository.","treeHash":null,"usage":{"cachedInputTokens":890402,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":35766,"runtime":"claude","turns":22,"wallClockMs":436358}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ef31844bb462de78","findings":[{"citation":"resolved","description":"All six custody invariants (I1, I5, I6, the anti-rug property I4's recovery path, operator rotation I8) reduce to 'onlyOwner is a 48h public delay'. The contract itself only requires owner_ to be a non-zero address: it does not check that owner_ has code, that it is an OpenZeppelin TimelockController, that getMinDelay() >= 48h, or that the timelock's TIMELOCK_ADMIN_ROLE has been renounced (an un-renounced admin can call updateDelay(0) and schedule+execute a withdrawSeat in one block). The repository ships no deployment script, no post-deploy assertion and no fork test that exercises the real TimelockController path: every test pranks an EOA named 'timelock'. This is not a code defect in the vault and is reported as an open deployment item, not a request to change the timelock design. The same applies to the two-step hand-off: transferOwnership + acceptOwnership can move ownership to any address the current timelock approves, after which all owner powers are instant.","line":149,"path":"src/HiveSeatVault.sol","reproduction":"State: new HiveSeatVault(owner_ = any EOA or an un-renounced-admin TimelockController, seatCollection, adapter, ens, sink). Deposit seat 1343. Expected per README/I1: a seat exit is queued publicly >= 48h ahead. Actual: vm.prank(owner_); vault.withdrawSeat(1343, attacker) succeeds in the same block (exactly what test_owner_withdraws does today with an EOA). A fork/integration test that deploys a TimelockController(minDelay = 48h, proposers, executors, admin = address(0)) as owner_ and asserts that schedule -> execute(withdrawSeat) reverts before 48h and succeeds after would close the gap.","severity":"info","snippet":"        address owner_, // the TimelockController","title":"Nothing in the repository binds owner_ to a 48h TimelockController; every custody guarantee is a deployment-time assumption"},{"citation":"resolved","description":"agentIdOf[tokenId] is written only by registerAgent, so it is 0 for any seat whose ERC-8004 agent was registered by the previous holder (e.g. the Hive treasury) before the seat was deposited. While custodied, the live Adapter8004 (0xde152AfB..., impl 0xa6D23f27...) treats ownerOf(seat) == caller as the agent's controller, so the vault is now the only address that may call setAgentURI/setMetadata/setAgentWallet for that pre-existing agent; the vault exposes none of those for an agent it did not register (setAgentURI reverts NotRegistered). The only on-chain route is registerAgent, and the adapter's _registerImpl has no 'already bound' check (verified in its verified source: it always mints a new registry id and writes a new binding), so the operator's once-per-seat registerAgent would bind a second, duplicate agent to the same seat rather than reuse the first. Operational limitation only; it cannot move a seat or any value. Documented intent ('scoped to this vault's own registrations') is preserved; this records the consequence for seats that arrive already registered.","line":230,"path":"src/HiveSeatVault.sol","reproduction":"State: treasury holds seat 1343 and has already called Adapter8004.register(0, collection, 1343, uriA) -> agent A bound to seat 1343. Treasury deposits 1343 into the vault. Then: (1) vm.prank(timelock); vault.setAgentURI(1343, uriB) -> reverts NotRegistered(), although the vault (as ownerOf) is the only address the adapter would accept for agent A. (2) vm.prank(operator); vault.registerAgent(1343, uriC) -> succeeds and the adapter emits AgentBound for a NEW agentId bound to the same seat; agentIdOf[1343] now points at the duplicate, agent A stays unmanageable until the seat is withdrawn (>= 48h). Expected per the function's purpose ('fix a URI set by a leaked operator key'): the owner can correct the seat's agent URI; actual: only for agents the vault itself created.","severity":"info","snippet":"        if (agentId == 0) revert NotRegistered();","title":"setAgentURI/registerAgent only know agents registered through this vault: a seat that already had an ERC-8004 agent before deposit cannot have it managed from custody, and the operator's registerAgent"}],"hash":"e27e99f141cda64d6b4db925ffba86f73f38676ce003a69f57d7bc9503ee7148","nodeId":"f3c63ed6-74e9-445e-9ccc-047ba4272134","outcome":"completed","summary":"Review complete. The findings file is written and the tree is clean apart from it.\n\n**Verdict: no critical, high or medium defects.** The custody and anti-rug properties hold. Two informational items are recorded in `.imd-findings.json`.\n\n**What was checked, and what held**\n\n- **I4 (the crux):** the only writer of the digest mapping is `authorizeWorker`, which computes the EIP-712 digest itself from a struct whose `wallet` must be the vault and whose `tokenId` the vault must own. An operator controls only `deviceKey`, `nonce`, `expiresAt` and `relayOrigin`, so inserting a chosen hash would need a keccak preimage. A Seaport digest is hashed under Seaport's own domain separator, so it cannot equal a WorkerAuthorization digest. The vault never approves any operator on the collection, so even an accepted signature could not move a seat. Digest parity with the audited IMDSeatStrategy was confirmed by running the repo's fork tests against mainnet, and the strategy's verified source matches the lifted code.\n- **I1/I2:** the only direct calls to the collection are `ownerOf` and `safeTransferFrom` in `withdrawSeat` and `rescueERC721`, both owner-only, and `rescueERC721` rejects the seat collection. No `approve` or `setApprovalForAll` exists anywhere.\n- **I3:** operator powers are pair, revoke and register. None moves value. Revocation is griefing only.\n- **I5:** `sweepEarnings` has no caller-supplied destination, rejects the collection address, and a hostile token can only revert its own call. `nonReentrant` blocks reentry into the three guarded functions, and no other function is reachable by a token or by the reward sink without a role. The live collection has no `transfer(address,uint256)` selector.\n- **I6/I7:** all config and exit functions are owner-only. No delegatecall, no selfdestruct, no proxy. Ownership cannot be renounced.\n- **registerAgent:** the fork trace shows the live adapter mints the ERC-8004 agent to itself, not to the vault, so `onERC721Received` is never hit and cannot be used to brick or spoof anything.\n- **Collection trust assumptions, verified on mainnet:** not a proxy, no burn, no permit, no marketplace auto-approval. Its owner is the dead CREATE2 deployer proxy, so its owner-gated hook setter can never be called. It does contain an approval-bypass path for a `univ4Hook` address, currently unset. That path is dormant and unsettable, and the vault's I1 depends on it staying so.\n\n**Findings recorded**\n\n1. **Info:** nothing in the repo binds `owner_` to a 48h TimelockController with a renounced admin role. Every test pranks an EOA named timelock. Open deployment item, not a code defect.\n2. **Info:** seats that already carry an ERC-8004 agent before deposit cannot have that agent managed from custody, and the operator's `registerAgent` would bind a duplicate agent because the live adapter has no dedupe.\n\n**Unverified lead, not reported as a finding:** the on-chain retirement of pairings on rotation or withdrawal only matters if IMD re-checks `isValidSignature` after pairing. If IMD checks once at pair-complete, a leaked key's device stays paired off-chain until IMD's own expiry. That is relay behaviour outside this repo.\n\n**Coverage limits:** no Slither or Mythril ran. Unit and fork suites both pass. Tool used for live checks was `cast` against a public RPC and the fork tests shipped with the repo.","treeHash":null,"usage":{"cachedInputTokens":3322323,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":48410,"runtime":"claude","turns":51,"wallClockMs":738958}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c97fd6a73e3737dc","findings":[{"citation":"resolved","description":"onlySeatOperator admits owner() as a second pairing key, but the clean-slate mechanism (authEpoch, line 328) only fires inside setSeatOperator when the operator address changes. transferOwnership/acceptOwnership (inherited Ownable2Step) do not touch authEpoch, so every pairing the OLD owner inserted via authorizeWorker stays VALID under the NEW owner for as long as its operator-chosen expiresAt (up to type(uint64).max). The new owner can only retire them by rotating the operator to a different address, which also kills the current operator's live pairings. This is a trust-role asymmetry, not a theft path (a pairing can never move a seat), but it contradicts the I8 statement that a key change gives a clean slate: an owner change is a key change that does not.","line":144,"path":"src/HiveSeatVault.sol","reproduction":"State: owner = timelockA, seatOperator = op, vault holds seat 1343. (1) timelockA calls authorizeWorker({wallet: vault, tokenId: 1343, expiresAt: type(uint64).max, ...}) -> digest D, isValidSignature(D) == 0x1626ba7e. (2) timelockA calls transferOwnership(timelockB); timelockB calls acceptOwnership(). (3) isValidSignature(D) is still 0x1626ba7e: authEpoch unchanged, custodyEpoch unchanged, seat still held. Expected: a handover of the owner key retires the pairings that key made (I8 'any change retires every live pairing'). Actual: they persist until timelockB calls setSeatOperator with a NEW address. Minimal fix (if wanted): bump authEpoch in an override of _transferOwnership, or document that owner-made pairings are not retired by an ownership handover.","severity":"low","snippet":"        if (msg.sender != seatOperator && msg.sender != owner()) revert NotSeatOperator();","title":"Pairings created by a previous owner survive an ownership transfer (authEpoch only bumps on operator change)"},{"citation":"resolved","description":"onERC721Received (line 173) accepts every token of the collection from any sender, and authorizeWorker / registerAgent gate only on ownerOf(tokenId) == address(this), not on a per-seat allow-list. A seat that a third party sends to the vault (by mistake, or an airdrop to the vault address) is therefore immediately pairable and registerable by the seatOperator hot key, and can leave only through a 48h Timelock withdrawSeat naming the sender. The vault itself is unharmed (the seat is custodied under the same rules as Hive's seats), so this is an asymmetry between the two depositor classes rather than a vault defect; it is listed because the brief asks what a leaked operator key can do, and 'run a stranger's seat for 48h+' is within its reach.","line":191,"path":"src/HiveSeatVault.sol","reproduction":"State: vault holds Hive seat 1343; attacker-controlled or leaked seatOperator = op. (1) Unrelated holder X calls seatCollection.safeTransferFrom(X, vault, 777). The deposit is accepted (SeatDeposited(777, X)). (2) op calls authorizeWorker({wallet: vault, tokenId: 777, expiresAt: now+10y, ...}) -> VALID; op calls registerAgent(777, 'ipfs://poison') -> succeeds (agent minted to the adapter). (3) X cannot retrieve 777: withdrawSeat is onlyOwner; rescueERC721 reverts CannotRescueSeats for the seat collection. Expected (by X): an unsolicited deposit is either rejected or returnable; actual: the seat is run by whoever holds the operator key until the Timelock queues and executes withdrawSeat(777, X) >= 48h later. No fix proposed: open deposits are a stated design choice; documenting it for depositors is sufficient.","severity":"low","snippet":"        if (seatCollection.ownerOf(auth.tokenId) != address(this)) revert NotNFTOwner();","title":"Open deposits let the operator pair and run any third-party seat sent to the vault, with no self-service exit for the depositor"},{"citation":"resolved","description":"The vault is itself non-upgradeable (I7 verified: no delegatecall, no selfdestruct, immutables only), but the live Adapter8004 it binds to (0xde152AfB7db5373F34876E1499fbD893A82dD336, read via IMDSeatStrategy.IMD_AGENT_ADAPTER() on mainnet) is a 329-byte ERC-1967 proxy whose implementation slot holds 0xa6d23f27d3b1780b12488482a008cb3c3787135f. An implementation change by IMD alters what registerAgent and setAgentURI do without any Timelock action. Verified bound on the damage: registerAgent is nonReentrant (blocks sweepEarnings/sweepETH/registerAgent re-entry), the adapter is never the operator or owner (so authorizeWorker/withdrawSeat/setSeatOperator are unreachable from it), onERC721Received only accepts the seat collection as msg.sender, and the vault never grants the adapter any approval (isApprovedForAll(vault, adapter) == false after registration). A hostile adapter can therefore only revert, burn gas, or register junk; it cannot move a seat or insert a pairing digest. Reported as a trust assumption, not a defect.","line":88,"path":"src/HiveSeatVault.sol","reproduction":"On mainnet: cast storage 0xde152AfB7db5373F34876E1499fbD893A82dD336 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc -> 0x...a6d23f27d3b1780b12488482a008cb3c3787135f (non-zero: upgradeable proxy). Expected per I7 wording ('the rules cannot change silently'): every code path the vault depends on is frozen. Actual: the registration path is upgradeable by IMD. Impact ceiling confirmed by a scratch test in which a malicious token/adapter-like callee attempts every re-entry and seat move during a vault call: all fail, seat ownership unchanged.","severity":"info","snippet":"    IImdAgentAdapter public immutable agentAdapter; // ERC-8004 registration","title":"agentAdapter immutable points at an ERC-1967 proxy that IMD can upgrade; the vault's blast radius is bounded but the dependency is not frozen"},{"citation":"resolved","description":"sweepEarnings is permissionless and takes an arbitrary address list; the only exclusion is the seat collection. A contract that returns a non-zero balanceOf and a truthy transfer makes the vault emit Swept(fakeToken, bal, sink) with no real value moved. Confirmed harmless on-chain (the call shape is fixed: balanceOf(vault) via staticcall, then transfer(rewardSink, bal); a hostile token re-entering the vault reaches only view functions because sweepEarnings/sweepETH/registerAgent share nonReentrant and every other state-changing entry is role- or collection-gated; the seat cannot be reached because no approval ever exists). The residual risk is off-chain: an indexer or dashboard that trusts Swept as proof of earnings can be fed fabricated amounts, and the Swept amount is the pre-transfer balance, so fee-on-transfer or rebasing tokens over-report.","line":295,"path":"src/HiveSeatVault.sol","reproduction":"Deploy T with balanceOf(address) returning 1e24 and transfer(address,uint256) returning true (no state). Anyone calls vault.sweepEarnings([T]). Expected: nothing noteworthy happened; actual: Swept(T, 1e24, rewardSink) is emitted from the vault. The scratch suite (test/scratch/Attacks.t.sol, EvilToken) also shows that the same token's attempts to re-enter sweepEarnings, sweepETH, registerAgent, withdrawSeat, onERC721Received, setSeatOperator, authorizeWorker, and seatCollection.transferFrom(vault, ...) all fail and the seat stays in the vault. Mitigation if desired: have consumers verify Swept against the token's own Transfer event, or restrict tokens to an owner-set allow-list (a design change; not required).","severity":"info","snippet":"                emit Swept(token, bal, sink);","title":"sweepEarnings emits Swept for any caller-supplied contract that answers balanceOf/transfer, so the event can be spoofed"},{"citation":"resolved","description":"The constructor explicitly permits ensReverseRegistrar == address(0) ('may be address(0) off-mainnet', line 89), but setEnsName performs a high-level call expecting a bytes32 return. Since solc 0.8.10 the extcodesize check is skipped when return data is expected, so the call to the empty account returns no data and ABI decoding reverts with no reason. The Timelock proposal fails after its 48h delay with an opaque revert. Cosmetic, owner-only; no security impact.","line":357,"path":"src/HiveSeatVault.sol","reproduction":"new HiveSeatVault(timelock, seat, adapter, IEnsReverseRegistrar(address(0)), sink); then vm.prank(timelock); vault.setEnsName('hive.eth'). Expected: a clear revert (or a no-op) when ENS is not configured; actual: revert with empty revert data (reproduced in test/scratch/Attacks.t.sol::test_setEnsName_with_zero_registrar_reverts). Minimal fix: revert with a named error when address(ensReverseRegistrar) == address(0), or disallow zero in the constructor.","severity":"info","snippet":"        return ensReverseRegistrar.setName(name);","title":"setEnsName reverts with empty data when ensReverseRegistrar was deployed as address(0)"}],"hash":"e3e9c9a881e023dd46e87ad352a0d611cf59265e87d940020b99536145868610","nodeId":"22f4d936-c66a-4462-942b-581909939042","outcome":"completed","summary":"The audit is complete and the findings file is written. No contract files were changed and the scratch tests are removed.\n\n**Outcome: the six properties the brief asked about all hold.** I found no critical, high, or medium defect. The findings file holds two lows and three info items, each with a concrete reproduction.\n\n**What I verified, with evidence**\n\n- **(1) ERC-1271 crux.** The pairing mapping is written only by authorizeWorker, which computes the digest itself from a validated struct. A fuzz over every operator-chosen field confirmed only the self-computed digest validates. Seaport and every marketplace wrap order hashes in their own EIP-712 domain, and the WorkerAuthorization digest carries a fixed typehash in its preimage, so a collision would need a keccak collision. The mainnet fork test proves the digest is byte-identical to the live IMDSeatStrategy.\n- **(2) Custody.** Only withdrawSeat and rescueERC721 call transfer functions, rescue refuses the seat collection, and no code path ever calls approve or setApprovalForAll. After every operator action, getApproved and isApprovedForAll on the seat remain empty. The live collection has no signature-based approval surface, its Uniswap v4 hook and pool are unset, and its owner is the CREATE2 factory, so no collection-level approval override exists.\n- **(3) Operator scope.** A hostile receiver that is also the operator cannot re-pair during the withdraw callback, and a re-deposit does not re-arm the old digest. The leaked-key ceiling is griefing plus running seats until rotation, as the brief expects.\n- **(4) sweepEarnings.** Fixed call shape, fixed sink, collection excluded. A hostile token that attempts every re-entry and a direct seat transfer during its own transfer call fails on all of them.\n- **(5) and (6).** All setters are onlyOwner, renounce is disabled, onERC721Received stores nothing. No delegatecall or selfdestruct exists. registerAgent is reachable on mainnet and the ERC-8004 agent NFT is minted to the adapter, not the vault.\n\n**Findings recorded in .imd-findings.json**\n\n- **Low:** pairings made by a previous owner survive transferOwnership and acceptOwnership, since authEpoch only bumps on an operator change. Reproduced with a scratch test.\n- **Low:** open deposits mean a stranger's seat can be paired and registered by the operator key, with no self-service exit for the depositor.\n- **Info:** the agent adapter is an ERC-1967 proxy upgradeable by IMD. Damage is bounded by nonReentrant and the absence of approvals, but the dependency is not frozen.\n- **Info:** the Swept event can be spoofed by a fake token, which matters only to off-chain consumers.\n- **Info:** setEnsName reverts with empty data when the registrar was deployed as address zero.\n\n**Coverage limits.** The ERC-8004 registry and adapter implementation were not source-audited, only observed on a mainnet fork. The TimelockController's own role configuration is outside this repository.","treeHash":null,"usage":{"cachedInputTokens":1169810,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":36374,"runtime":"claude","turns":34,"wallClockMs":528506}}],"verification":[]}