{"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":"41fa0208-9de7-4c8c-9ee6-32243c277e17","kind":"audit","nodes":[{"acceptedSubmissionHash":"296aa38d9cca9bb393eaa3a59741a86ea8ddb0aba5df47986dd3e9b865fce2c6","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":"fcc73e8b22b4c703412cbfc93f437360a9a05b165cb1b56447ea343f4b518990","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":"f188b9eadcba8d194c5b7460c610e47634d4566465433ba7bdcd88f835e8299c","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":"bab70cd05f9a9f89011bbcc48916a5e4439f66c7e9bfb014dce79b7b1d890602","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":"7498092f81b290361a4e7d3a51e7065c594cf7f1106dfd2095a840ed7c184697","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":"41fa0208-9de7-4c8c-9ee6-32243c277e17","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":"52210","feedbackHash":"b98b4478589dce8e69b40546e3bd8799d9f189a884b07187b0b346f17585e2be","nodeKey":"audit_economics","submissionHash":"296aa38d9cca9bb393eaa3a59741a86ea8ddb0aba5df47986dd3e9b865fce2c6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51041","feedbackHash":"ef91f4d020fd25ebe64769610069aa96a62214cfbf9d84bee0f3fbb91055d26f","nodeKey":"audit_flow","submissionHash":"fcc73e8b22b4c703412cbfc93f437360a9a05b165cb1b56447ea343f4b518990","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52216","feedbackHash":"b3b129804cbd97b5517b990e2ae048a0a5f1560622c467f51111bef9ce66009d","nodeKey":"audit_judge","submissionHash":"f188b9eadcba8d194c5b7460c610e47634d4566465433ba7bdcd88f835e8299c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52222","feedbackHash":"6b92e840a8bbba05faa917e99b7f63170e29e13bb02ac0bd0e1409726d01b074","nodeKey":"audit_math","submissionHash":"bab70cd05f9a9f89011bbcc48916a5e4439f66c7e9bfb014dce79b7b1d890602","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51141","feedbackHash":"9af8137512e4f55dd3a67a286d37fbd82bcc326e5523abede778b4d7b0caba3d","nodeKey":"audit_permissions","submissionHash":"7498092f81b290361a4e7d3a51e7065c594cf7f1106dfd2095a840ed7c184697","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"f4414563ba89b82147538b2966087519082bbf8360503f2d9b2d0452e85f88b6","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dff6c0d3de4aa913","findings":[{"citation":"resolved","description":"Nothing in the vault pins owner() to a TimelockController. Ownable2Step lets the current owner (the Timelock) queue transferOwnership(newOwner) to any address; after the 48h delay runs once and newOwner calls acceptOwnership(), every onlyOwner function (withdrawSeat, setSeatOperator, setRewardSink, rescueERC721, registerAgent-force) is callable by newOwner with no further delay. The deploy script's require(vault.owner() == address(timelock)) only checks the state at deployment. This is the documented design (NatSpec line 350-351 says ownership can be handed to a NEW Timelock) and not a bypass: the handover itself is a public 48h-delayed operation, so the public gets one notice window, after which the 'every exit is delayed' property depends entirely on what the new owner is. Reported as a trust assumption for the owner role, not as a defect; no change to the timelock design is proposed.","line":359,"path":"src/HiveSeatVault.sol","reproduction":"State: vault owned by a 48h Timelock, seat 1343 custodied. Timelock schedules and after 48h executes vault.transferOwnership(EOA). EOA calls vault.acceptOwnership(). Expected (per README 'every seat exit is queued publicly >=48h ahead'): still delayed. Actual: EOA immediately calls vault.withdrawSeat(1343, EOA) and seat.ownerOf(1343) == EOA in the same block. Verified with a Foundry test (transferOwnership -> acceptOwnership -> withdrawSeat succeeds with no warp).","severity":"info","snippet":"    function _transferOwnership(address newOwner) internal override {","title":"Trust assumption: the 48h delay is a property of the owner, not of the vault; an ownership handover to a non-timelock address makes withdrawSeat instant"},{"citation":"resolved","description":"OpenZeppelin TimelockController v5 grants CANCELLER_ROLE to every proposer, and the script passes a single proposer (HIVE_TIMELOCK_PROPOSER), executors = [address(0)] (open execution) and admin = address(0). Resulting role set: proposer = {P}, canceller = {P}, executor = anyone, DEFAULT_ADMIN = the timelock itself. If P is compromised (hot leak rather than loss), the attacker can schedule withdrawSeat(id, attacker) for every seat; the only on-chain action that stops it is cancel() by the same key P, and the attacker can re-schedule after every cancel. Changing roles requires a scheduled op by P. So the design's guarantee is exactly what README states, 'no seat can be moved without a public, on-chain delay': 48h of visibility in which the team can race-cancel or move seats via their own scheduled ops, not a veto held by an independent party. This is the requested design; documented so the requester weighs the proposer key as the real custody key. No change proposed.","line":40,"path":"script/DeployHiveSeatVault.s.sol","reproduction":"Deploy with the script's arguments (proposers=[P], executors=[address(0)], admin=address(0)). Observed on a TimelockController constructed the same way: hasRole(PROPOSER_ROLE,P)==true, hasRole(CANCELLER_ROLE,P)==true, hasRole(EXECUTOR_ROLE,address(0))==true, hasRole(DEFAULT_ADMIN_ROLE,P)==false, hasRole(DEFAULT_ADMIN_ROLE,timelock)==true. With P: schedule(vault, 0, withdrawSeat(1343, attacker), 0, salt, 48h); after 48h anyone may execute and the seat leaves. Expected by a reader of 'the team can't rug the seats' (test/HiveSeatVaultTimelock.t.sol:17): a second party must be able to block it. Actual: only P can cancel.","severity":"info","snippet":"        timelock = new TimelockController(MIN_DELAY, proposers, executors, address(0)); // admin renounced","title":"Trust assumption: as deployed, the proposer key is sole proposer AND sole canceller, executor is open, admin is renounced; the 48h delay is public notice, not an on-chain veto"},{"citation":"resolved","description":"onERC721Received is the only place SeatDeposited is emitted, and ERC-721 transferFrom (as opposed to safeTransferFrom) never calls the receiver hook. The live identity.md collection exposes transferFrom(address,address,uint256) (selector 0x23b872dd confirmed in its bytecode). A seat sent that way is fully custodied (ownerOf == vault, authorizeWorker/registerAgent/withdrawSeat all work) but off-chain indexers, keepers or the 'public notice' tooling that reconstruct the custody set from SeatDeposited/SeatWithdrawn will not see it. Custody invariants I1-I8 are unaffected; this is an observability gap only.","line":171,"path":"src/HiveSeatVault.sol","reproduction":"State: holder owns seat 1343. holder calls seat.transferFrom(holder, vault, 1343). Expected: a SeatDeposited(1343, holder) log, as for safeTransferFrom. Actual: no log with topic keccak256('SeatDeposited(uint256,address)') is emitted in the transaction; seat.ownerOf(1343) == vault; operator can then authorizeWorker for 1343 and isValidSignature returns 0x1626ba7e. Verified with vm.recordLogs in a Foundry test.","severity":"info","snippet":"    function onERC721Received(address, address from, uint256 tokenId, bytes calldata)","title":"Seats that arrive via plain transferFrom bypass onERC721Received and emit no SeatDeposited event"},{"citation":"resolved","description":"The ownership check calls seatCollection.ownerOf, which reverts for a non-existent token (the live collection has a TokenDoesNotExist() error, selector 0xceea21b6, in its bytecode). For a pairing whose seat has been burned, isValidSignature therefore reverts rather than returning the ERC-1271 invalid value, and authorizedTokenId (which omits the ownerOf check by design) still returns authorized=true. Every ERC-1271 consumer treats a revert as invalid, so no signature is wrongly accepted; the only effect is a revert surfaced to IMD's verifier and a stale keeper view. Reachability: the live identity.md bytecode has no burn(uint256) or other destroy selector, and the vault itself never approves or burns, so this state cannot be produced on mainnet today; it would become reachable only if the collection ever gained an owner-side burn/admin path. Filed for completeness of the I4/I8 analysis, not as a live defect.","line":279,"path":"src/HiveSeatVault.sol","reproduction":"Mock collection with a burn(uint256) (OpenZeppelin ERC721 _burn). State: seat 1343 custodied, operator authorizes a WorkerAuthorization for it (digest D). Then burn(1343). Expected: isValidSignature(D, '') == 0xffffffff. Actual: the call reverts with ERC721NonexistentToken(1343); authorizedTokenId(D) returns (true, 1343). Verified with a Foundry test against the mock.","severity":"info","snippet":"        if (seatCollection.ownerOf(p.tokenId) != address(this)) return ERC1271_INVALID; // not held","title":"isValidSignature reverts instead of returning 0xffffffff, and authorizedTokenId keeps reporting authorized=true, if a paired tokenId stops existing (not reachable on the live collection today)"}],"hash":"296aa38d9cca9bb393eaa3a59741a86ea8ddb0aba5df47986dd3e9b865fce2c6","nodeId":"174a8dd0-ed65-4dbd-8a1c-80caa3b8bcc5","outcome":"completed","summary":"The review is complete. The findings file holds four informational items and no exploitable defect; the tree is unchanged apart from that file.\n\n**Outcome.** I found no path that violates any of the six properties the brief asked about. All four filed findings are informational: two documented trust assumptions, one observability gap, and one unreachable edge case kept for completeness. None carries a proof test because none is high or critical.\n\n**What I verified, property by property**\n\n- **(1) ERC-1271 crux holds.** The pairing mapping is private and written only by `authorizeWorker`, which stores the digest it computes itself from a typed struct under a fixed domain (name, version, chain, collection) and typehash. The operator controls only free fields that are hashed inside that structure, so inserting a chosen hash would require a keccak second preimage. A Seaport or any EIP-712 marketplace digest is built under a different domain separator and typehash, so equality would be a keccak collision. I confirmed with a test that fifty operator-chosen pairings never validate a Seaport-style digest. The fork suite passed against mainnet, proving the digest is byte-identical to the audited reference. Defense in depth: the live collection bytecode has no permit, no domain separator and no signature-based approval, and the vault never approves anyone, so even a \"signed\" order could not move a seat.\n- **(2) Exit path.** The only non-view call on the collection is `safeTransferFrom` inside the owner-only withdraw. Rescue and sweep both revert on the collection address. No approve or setApprovalForAll exists anywhere, and after a live `registerAgent` on a fork, approvals on both the seat and the ERC-8004 registry remained empty.\n- **(3) Operator scope.** Operator powers are exactly pair, revoke and one register per seat. Worst case is griefing: revoking pairings, a poison agent URI the owner can correct, or long-lived pairings that a 48h rotation retires.\n- **(4) Sweep.** Destination is the stored sink only. A hostile token I wrote tried, from inside its transfer callback, to pull the seat with the vault's authority and to re-enter every entry point; all attempts failed and the seat stayed put.\n- **(5) and (6).** No delegatecall, selfdestruct or fallback. Reentrancy from the adapter or receiver hooks reaches only guarded or role-gated functions. The deposit hook accepts only the collection and holds no approval state to spoof.\n\n**Filed findings (all info)**\n\n- Ownership handover to a non-timelock address removes the delay after one public window. Design, documented as a trust assumption.\n- As deployed, the proposer key is sole proposer and sole canceller with open execution, so the delay is notice rather than veto. Design, documented.\n- Seats arriving via plain `transferFrom` emit no deposit event, so event-driven tooling can miss custodied seats.\n- `isValidSignature` reverts rather than returning invalid if a paired seat ceased to exist. Not reachable today: the live collection has no burn selector.\n\n**Open items I could not verify from this tree.** The live adapter exposes agent-wallet and metadata setters the vault has no passthrough for, and the vault cannot call the collection's identity-hash setter. Whether any reward or operation needs those while custodied depends on IMD semantics outside this repository.","treeHash":null,"usage":{"cachedInputTokens":1446607,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":40720,"runtime":"claude","turns":33,"wallClockMs":646458}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7d454e6cee88c390","findings":[{"citation":"resolved","description":"The vault is the ERC-721 owner of each custodied seat and, per the live adapter, the sole controller of every ERC-8004 agent it registers (adapter.isController(agentId, vault) == true; isController(agentId, operator) == false; a stranger calling the adapter gets NotController 0xa9d48768). Both the collection and the adapter gate several per-seat / per-agent functions on exactly that identity, but the vault only exposes two passthroughs: register and setAgentURI. Nothing else is reachable by anyone, including the Timelock, so for as long as a seat is custodied: (a) the collection's setIdentityHash(uint256,string,bool) can never be called for it; (b) the adapter's setAgentWallet / setMetadata / setMetadataBatch / rewriteBindingMetadata / unsetAgentWallet can never be called for its agent, so the ERC-8004 agentWallet metadata stays empty and the agent NFT (owned by the adapter, agentId bound to (0, collection, tokenId)) can never be given a payout wallet or metadata. Note also that even with an owner passthrough, the vault itself could never be the agentWallet: the registry's setAgentWallet requires the new wallet's EIP-712 signature under the registry's own domain, and by design (I4) isValidSignature only ever accepts WorkerAuthorization digests. Today setIdentityHash is globally disabled (identityAllowed() == false, the call reverts with 0x843743ee for everyone), so the identity-hash half is latent until IMD flips that switch; the agent half is live now. No seat or value is at risk; this is a functional gap in the 'seats keep running' promise that the requester should decide on consciously. Fixing it would mean adding onlyOwner (Timelock) passthroughs for the specific collection/adapter calls deemed needed, which widens the owner surface and so is a scope decision, not something to change silently.","line":20,"path":"src/HiveSeatVault.sol","reproduction":"Mainnet fork (block at review time). Deploy HiveSeatVault(timelock, 0x0000eC93127BAA929E58E97dd0095A2BFb38ec1D, 0xde152AfB7db5373F34876E1499fbD893A82dD336, 0, sink); prank 0x84b31CB3D205EfD2d20F29eA7ccaB1bc34326DdB and safeTransferFrom seat 1343 into the vault; prank operator and call vault.registerAgent(1343, \"ipfs://x\") -> agentId 52475, registry.ownerOf(52475) == adapter, adapter.isController(52475, vault) == true. Expected: the Timelock (vault owner) has some path to set the agent's wallet/metadata or the seat's identity hash. Actual: vault has no function that calls adapter.setAgentWallet / setMetadata or collection.setIdentityHash; calling adapter.setMetadata(52475,\"k\",0x01) from any non-vault address reverts NotController (0xa9d48768...); prank(vault) collection.setIdentityHash(1343,\"deadbeef\",false) reverts 0x843743ee (identityAllowed()==false); registry.getMetadata(52475,\"agentWallet\") == 0x. The state 'seat in vault, identityAllowed()==true' is unreachable to fix from the vault side.","severity":"low","snippet":"    function setAgentURI(uint256 agentId, string calldata agentURI) external;","title":"Custodied seats and their ERC-8004 agents lose every owner-gated power the vault does not pass through (setIdentityHash, setAgentWallet, setMetadata)"},{"citation":"resolved","description":"I7 ('the rules cannot change silently') holds for the vault bytecode: no delegatecall, no selfdestruct, no proxy, no assembly. It does not extend to the two external dependencies the vault pins by address. On mainnet the adapter 0xde152AfB7db5373F34876E1499fbD893A82dD336 is a 329-byte ERC-1967 proxy (implementation slot 0x3608...bbc = 0xa6d23f27d3b1780b12488482a008cb3c3787135f) whose implementation exposes upgradeToAndCall(address,bytes) and owner() = 0x03302Df40186D9B85faEA4fbb6cC5da028B23149 (a 345-byte contract, not Hive). The ERC-8004 registry behind it (0x8004A169FB4a3325136EB29fA0ceB6D2e539a432, impl 0x7274e874ca62410a93bd8bf61c69d8045e399c02) is likewise UUPS, owner 0x547289319C3e6aedB179C0b8e8aF0B5ACd062603. The vault's trust in them is, verified, bounded: the vault never approves the adapter (I2), so an upgraded adapter cannot move a seat; registerAgent is nonReentrant and the adapter is neither owner nor operator, so a re-entrant call from a hostile implementation can only reach permissionless or view functions; setAgentURI / setAgentURIById are owner-only and have no state after the external call. The worst a hostile or buggy upgrade can do is revert registration (DoS of the operator's third power), return a bogus agentId that the vault stores in agentIdOf, or spend the gas of the caller. This is a trust assumption to document next to the Timelock, not a defect in the vault, and it is reported separately as the assignment asks.","line":88,"path":"src/HiveSeatVault.sol","reproduction":"State: any deployment using the script's AGENT_ADAPTER constant. Input: cast storage 0xde152AfB7db5373F34876E1499fbD893A82dD336 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url <mainnet> returns 0x...a6d23f27d3b1780b12488482a008cb3c3787135f (non-zero implementation slot); cast code of that implementation contains selector 0x4f1ef286 (upgradeToAndCall). Expected by I7's wording: registerAgent behaviour is fixed at vault deploy. Actual: the adapter owner can replace the implementation at any time without a Timelock delay; e.g. an implementation whose register() reverts makes vault.registerAgent(1343, uri) revert for operator and owner alike, and one whose register() returns 0 leaves agentIdOf[1343] == 0 so the operator's once-per-seat guard never engages.","severity":"info","snippet":"    IImdAgentAdapter public immutable agentAdapter; // ERC-8004 registration","title":"agentAdapter is a UUPS proxy upgradeable by a third-party key, so the semantics of registerAgent / setAgentURI are not frozen by the vault's own non-upgradeability"},{"citation":"resolved","description":"sweepEarnings is callable by anyone with a caller-supplied token list; destination is the fixed rewardSink and the seat collection is rejected (CannotSweepSeats), so the custody question is answered in the negative: no path through sweepEarnings reaches the ERC-721 collection, and the collection itself exposes no transfer(address,uint256) selector (0xa9059cbb absent from its dispatcher) so even the selector SafeERC20 sends could not move a seat. Reentrancy from a hostile token is bounded: sweepEarnings, sweepETH and registerAgent share the ReentrancyGuard, every other state-changing function is onlyOwner / onlySeatOperator / onERC721Received-gated on the collection, and the vault holds no approvals, so a re-entering token can only call views or deposit its own seat. What a hostile token CAN do is lie: a contract whose balanceOf(vault) returns N and whose transfer(sink, N) returns true without moving anything passes SafeERC20's checks (return data 32 bytes == 1) and causes the vault to emit Swept(fakeToken, N, rewardSink) from the vault's own address. Any indexer, dashboard or earnings report that trusts the vault's Swept log as a record of real income can be polluted by an unprivileged caller with arbitrary token/amount pairs. No on-chain value or seat is affected. A fix, if the requester cares about log integrity, would be to filter off-chain by token address, or to restrict sweepEarnings to a known token list; the latter changes the economics and is explicitly out of scope here, so this is reported for awareness only.","line":306,"path":"src/HiveSeatVault.sol","reproduction":"Deploy FakeToken { function balanceOf(address) external pure returns (uint256) { return 1e30; } function transfer(address, uint256) external pure returns (bool) { return true; } }. From any address call vault.sweepEarnings([address(fake)]). Expected (naive reading of 'earnings swept'): nothing happens because the vault holds no such token. Actual: the call succeeds and the vault emits Swept(address(fake), 1e30, rewardSink); rewardSink's balances are unchanged. By contrast tokens=[seatCollection] reverts with CannotSweepSeats (0x1d12234e), tokens=[agentAdapter] reverts (no balanceOf), and tokens=[ERC-8004 registry] succeeds as a no-op because registry.balanceOf(vault) == 0 (agent NFTs are held by the adapter).","severity":"info","snippet":"                emit Swept(token, bal, sink);","title":"A hostile ERC-20 passed to the permissionless sweepEarnings can make the vault emit a Swept event for an arbitrary token and amount (its only effect beyond reverting its own sweep)"}],"hash":"7498092f81b290361a4e7d3a51e7065c594cf7f1106dfd2095a840ed7c184697","nodeId":"3d1fa571-a04c-4951-ae53-d41e807fe4b4","outcome":"completed","summary":"The audit is complete. The findings file is in place, the working tree is clean apart from the task-owned findings file, and no scratch files remain.\n\n## Outcome\n\n**No critical, high, or medium defects.** The six properties the brief asked about all hold, and I verified them against the live mainnet collection, adapter and ERC-8004 registry rather than only the mocks. The findings file holds one low and two informational items.\n\n**The crux, property (1), holds.** The pairing mapping is written at exactly one site, with the key computed inside the contract from a structured WorkerAuthorization under the fixed \"IdentityMD Worker\" v2 domain and typehash. The operator can mint unboundedly many digests but cannot target one. A Seaport digest is a keccak over a different domain separator, and any marketplace or permit digest under another domain or typehash differs in its preimage, so acceptance would require a keccak collision. The live collection has no permit, no burn, no ERC-1271 hook and no pause, so there is no signature-driven transfer path on the NFT side at all.\n\n**Custody and operator scope, properties (2) through (6), hold.** The only outgoing calls that can touch the seat collection are withdrawSeat and rescueERC721, and rescue rejects the collection. The vault never calls approve or setApprovalForAll, so no external contract, including the Timelock itself, can act with the vault's ERC-721 authority. The operator's three functions write only pairing and agent state. sweepEarnings has a fixed sink and rejects the collection, which also lacks a `transfer(address,uint256)` selector. There is no delegatecall, selfdestruct or assembly. onERC721Received is gated on the collection and stores nothing. Reentrancy through registerAgent, sweepEarnings or sweepETH is blocked by the shared guard, and every other state-changing entry is role-gated, so a re-entering adapter or token can reach only views or permissionless no-ops.\n\n## Findings written\n\n- **Low. Custodied seats lose owner-gated collection and agent powers.** The vault is the sole controller of its ERC-8004 agents and the ERC-721 owner of its seats, but exposes only register and setAgentURI. setIdentityHash on the collection and setAgentWallet or setMetadata on the adapter are unreachable while a seat is custodied. The identity-hash half is latent because the collection currently has identityAllowed set to false. The agent half is live now.\n- **Info. The adapter and registry are UUPS proxies owned by third-party keys.** The vault's own non-upgradeability does not freeze registerAgent semantics. Harm is bounded to reverting registration or storing a bogus agent id, because the vault never grants approvals.\n- **Info. A hostile ERC-20 can make the vault emit a spoofed Swept event.** That is the only thing it can do beyond reverting its own sweep. No value or seat moves.\n\n## Trust assumptions to document, not defects\n\n- A leaked operator key can pair unbounded devices with an unbounded expiry, revoke the owner's pairings, and register one poisoned agent URI per seat until the 48h rotation. This matches the author's stated grief-only model.\n- The deploy script leaves seatOperator unset and makes the single proposer the only canceller. A leaked proposer key cannot steal a seat, but it can sustain a cancel race against the honest team indefinitely.\n- What a valid WorkerAuthorization lets a worker do inside IMD's off-chain system, including where earnings are routed, is outside what this contract can enforce.","treeHash":null,"usage":{"cachedInputTokens":1499572,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":48977,"runtime":"claude","turns":33,"wallClockMs":775412}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"357c46e3781993d4","findings":[{"citation":"resolved","description":"Boundary: external call `IERC20(token).safeTransfer(sink, bal)`. Assumption: every earned ERC-20 (the NatSpec on line 296 says \"IMD + launch tokens\") is freely transferable to an arbitrary `rewardSink`. Actual: IMD launch tokens are TokenWorks/BaseStrategy ERC-20s. Their `_afterTokenTransfer` (verified source of the live IMD6900 token, the IMDSeatStrategy proxy 0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F, BaseStrategy.sol lines 398-448) reverts with `InvalidTransfer()` (0x2f352531) unless one side of the transfer is the Uniswap v4 poolManager with a hook-set transient allowance, a global distributor (router), or a local distributor. A plain `transfer(rewardSink, bal)` from the vault to a treasury / multisig / EOA sink satisfies none of these, so the sweep reverts for the full balance every time. The vault offers no partial sweep, no swap through a distributor, and no owner-level ERC-20 exit, and `setRewardSink` can only point at an address (which would have to be a third-party router to pass the check), so any launch-token balance that lands in the vault (paid in by a distributor, or bought to the vault address through a router) is stranded for the life of the contract. The mainnet IMD token itself (0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7, a LayerZero OFT) has no such restriction and sweeps correctly on a fork, so the impact is confined to launch tokens. A hostile ERC-20 cannot do anything worse than this (see the info finding), but a *legitimate* launch token is enough to lock value. Fixing it requires a scope decision the brief reserves to the requester (e.g. an owner-only, timelocked `sweepEarningsTo(token, amount, to)` so a stuck balance can be routed to a distributor/router, or routing launch-token sweeps through a whitelisted distributor); the finding is reported, not fixed.","line":305,"path":"src/HiveSeatVault.sol","reproduction":"Fork of mainnet (ETH_RPC_URL or https://ethereum-rpc.publicnode.com). Deploy HiveSeatVault(timelock, 0x0000eC93127BAA929E58E97dd0095A2BFb38ec1D, 0xde152AfB7db5373F34876E1499fbD893A82dD336, address(0), sink=makeAddr(\"sink\")). `deal(0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F, address(vault), 1000e18)`. Call `vault.sweepEarnings([0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F])` from any address. Expected (per NatSpec): 1000e18 IMD6900 arrive at `sink`, `Swept` emitted. Actual: the call reverts with `0x2f352531` = `InvalidTransfer()` and the vault still holds 1000e18; repeating with any other non-distributor sink gives the same result. Control: the same test with the IMD token 0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7 succeeds and the sink receives 1000e18. Ran as test/scratch/LaunchTokenSweepFork.t.sol and test/scratch/ImdTokenSweepFork.t.sol (forge test --match-path 'test/scratch/*Fork.t.sol' -vv): launch-token sweep `ok=false`, revert data 0x2f352531; IMD sweep `ok=true`, sink balance 1000000000000000000000.","severity":"medium","snippet":"                IERC20(token).safeTransfer(sink, bal);","title":"sweepEarnings cannot move IMD launch tokens (BaseStrategy ERC-20s revert on transfer to a non-distributor), so such earnings are stuck in the vault with no exit"},{"citation":"resolved","description":"The script comment (lines 18-20) and the vault's I1/I6 narrative state that with `admin = address(0)` \"no one can shorten the delay or re-grant roles after deploy\". OpenZeppelin v5.1 TimelockController's constructor always executes `_grantRole(DEFAULT_ADMIN_ROLE, address(this))` (lib/openzeppelin-contracts/contracts/governance/TimelockController.sol line 117) regardless of the optional admin, and `updateDelay` only requires `msg.sender == address(this)`. The proposer can therefore schedule a self-call `timelock.updateDelay(0)` (or `grantRole(EXECUTOR_ROLE/PROPOSER_ROLE/CANCELLER_ROLE, x)`), wait the 48h once, execute it, and from then on schedule+execute `withdrawSeat` for every custodied seat in the same block with zero delay. This is standard OZ behaviour and the first hostile step is still public for 48h, so the anti-rug guarantee degrades from \"every exit is visible 48h ahead\" to \"the first hostile operation is visible 48h ahead; everything after it is instant\". The brief asks not to change the timelock design, so the fix is a documentation / trust-assumption correction plus monitoring: state that `minDelay` and roles are mutable through 48h-delayed self-calls, and treat any scheduled operation whose target is the timelock itself as a red-flag exit signal alongside `withdrawSeat`.","line":40,"path":"script/DeployHiveSeatVault.s.sol","reproduction":"Deploy `new TimelockController(48 hours, [proposer], [address(0)], address(0))` exactly as the script does; deploy HiveSeatVault owned by it and mint seat 1343 to the vault. (1) `vm.prank(proposer); tl.schedule(address(tl), 0, abi.encodeCall(tl.updateDelay,(0)), 0, salt, 48 hours)`; warp +48h; `tl.execute(...)`. Expected per comment: revert (delay cannot be shortened). Actual: succeeds, `tl.getMinDelay() == 0`. (2) `vm.prank(proposer); tl.schedule(address(vault), 0, abi.encodeCall(HiveSeatVault.withdrawSeat,(1343, proposer)), 0, salt2, 0); tl.execute(...)` in the same block. Actual: `seat.ownerOf(1343) == proposer` with no delay. (3) `tl.schedule(address(tl), 0, abi.encodeCall(tl.grantRole,(tl.EXECUTOR_ROLE(), proposer)), 0, salt3, 0); tl.execute(...)`: `hasRole(EXECUTOR_ROLE, proposer) == true`. Ran as test/scratch/TimelockDelayClaim.t.sol (`forge test --match-path test/scratch/TimelockDelayClaim.t.sol`): all three assertions pass on this code.","severity":"low","snippet":"        timelock = new TimelockController(MIN_DELAY, proposers, executors, address(0)); // admin renounced","title":"Deploy script's \"no one can shorten the delay or re-grant roles\" claim is false: OZ TimelockController is self-admin, so the proposer can schedule updateDelay(0)/grantRole and make every later seat ex"},{"citation":"resolved","description":"Boundary: caller-supplied `tokens[i]`. `sweepEarnings` trusts the token's own `balanceOf` for `bal` and SafeERC20 accepts a `true` return, so a contract whose `balanceOf` returns any number and whose `transfer` returns true without moving anything causes the vault to emit `Swept(fakeToken, hugeAmount, rewardSink)`. Off-chain accounting or dashboards that index `Swept` by amount without filtering `token` against a known list can be fed arbitrary figures. This is also the complete answer to the brief's question on hostile ERC-20s: during the fake `transfer` the token re-entered `sweepEarnings`, `sweepETH` and `registerAgent` (all blocked by the shared ReentrancyGuard), `authorizeWorker` (NotSeatOperator), `withdrawSeat` / `rescueERC721` (OwnableUnauthorizedAccount), and called `seatCollection.transferFrom(vault, attacker, 1343)` and `setApprovalForAll` directly (the first reverts because msg.sender is the token, not the vault; the second only affects the token's own holdings; `isApprovedForAll(vault, attacker)` stays false). The seat and the vault's ETH were untouched. No vault state is written by the fake sweep, so this is informational: indexers should whitelist `token` values, nothing on-chain needs to change.","line":306,"path":"src/HiveSeatVault.sol","reproduction":"Deploy HiveSeatVault with an OZ ERC721 mock as seatCollection, mint seat 1343 to the vault, `vm.deal(vault, 1 ether)`. Deploy a HostileToken whose `balanceOf(address)` returns 1e30 and whose `transfer(address,uint256)` performs the re-entries listed above and returns true. `vm.prank(attacker); vault.sweepEarnings([address(hostile)])`. Expected (if events were trustworthy): either revert or an amount that actually moved. Actual: the call succeeds and emits `Swept(hostile, 1000000000000000000000000000000, sink)` while no token moved; every re-entry attempt returned false, `seat.ownerOf(1343) == vault`, `vault.balance == 1 ether`, `seat.isApprovedForAll(vault, attacker) == false`. Ran as test/scratch/HostileTokenBoundary.t.sol (`forge test --match-path test/scratch/HostileTokenBoundary.t.sol`), both tests pass on this code (the second shows `withdrawSeat` on an unheld tokenId reverts and does not persist the custodyEpoch bump).","severity":"info","snippet":"                emit Swept(token, bal, sink);","title":"Anyone can emit a bogus Swept(token, amount, rewardSink) event via a fake ERC-20 in sweepEarnings; it is the only effect a hostile token has"}],"hash":"bab70cd05f9a9f89011bbcc48916a5e4439f66c7e9bfb014dce79b7b1d890602","nodeId":"db55f1e9-0223-43fb-9b4d-3abdea121022","outcome":"completed","summary":"The findings file is written and validated against the tree. The audit is complete.\n\n## Outcome\n\nThe anti-rug core holds. I found no path by which the operator, a hostile token, or a re-entrant callback can make `isValidSignature` accept a foreign hash, move a seat, or grant an approval. Three findings are recorded in `.imd-findings.json`, none critical or high.\n\n**Findings**\n\n- **Medium, `sweepEarnings` strands launch tokens.** IMD launch tokens are BaseStrategy ERC-20s whose transfer hook reverts unless a distributor or the Uniswap v4 pool manager is on one side. The vault sends the full balance to a plain sink, so the sweep reverts every time and there is no partial sweep or owner-directed ERC-20 exit. Confirmed on a mainnet fork against the live IMD6900 token. The mainnet IMD token itself is a standard LayerZero OFT and sweeps fine, so the impact is limited to launch-token earnings.\n- **Low, deploy script overstates the timelock guarantee.** The comment says the delay can never be shortened with admin set to zero. OpenZeppelin's TimelockController always makes itself admin, so the proposer can schedule `updateDelay(0)` once, wait 48 hours, and then withdraw every seat with no delay. Reproduced with a Foundry test. The brief excludes design changes, so this is reported as a documentation and monitoring gap.\n- **Info, spoofable `Swept` event.** A fake ERC-20 can make the vault emit any amount. This is the only effect a hostile token has: all re-entries into the vault and all attempts to use the vault's authority on the collection failed in the test.\n\n**What was verified clean**\n\n- `authorizeWorker` is the sole writer of the pairing map and only stores the EIP-712 digest it computes itself. The domain binds to the seat collection, so a Seaport order digest cannot match without a keccak collision. The digest logic is byte-identical to the live IMDSeatStrategy and the vault adds stricter expiry and epoch checks.\n- The live collection is a plain Solady ERC-721 with no burn and no alternate transfer path. The vault never calls `approve` or `setApprovalForAll`, has no fallback, assembly, delegatecall, or selfdestruct. `rescueERC721` and `sweepEarnings` both reject the collection address.\n- `registerAgent` and `setAgentURI` match the live adapter's control checks, which gate on the vault owning the seat. The adapter keeps the agent NFT itself, so no stray NFT lands in the vault.\n- All boundary cases I exercised behaved correctly: unheld token withdrawal reverts without persisting the epoch bump, zero-address transfer reverts in the collection, and the epoch counters cannot realistically wrap.\n\n**Not substantiated, noted only**: a `rewardSink` that rejects ETH would hold `sweepETH` funds until the owner rotates the sink, which is an owner configuration matter rather than a defect. What a paired worker can do inside the IMD platform could not be verified from the chain and remains a trust assumption on IMD.\n\nScratch reproductions are in `test/scratch/`, and the existing 40 unit and timelock tests plus the 5 fork tests all pass.","treeHash":null,"usage":{"cachedInputTokens":2409658,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":50908,"runtime":"claude","turns":50,"wallClockMs":657440}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"47f3603854a893a3","findings":[{"citation":"resolved","description":"The NatSpec (line 296) says sweepEarnings pushes 'IMD + launch tokens' to rewardSink. The live launch token (IMDSeatStrategy proxy 0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F) only allows transfers that involve the v4 poolManager or a registered distributor/router, and reverts with InvalidTransfer() (0x2f352531) on any other transfer. A sweep to a treasury, multisig or EOA sink therefore always reverts for the whole balance. The vault has no partial sweep, no way to route through a distributor, and no owner-level ERC-20 exit. Pointing setRewardSink at a third-party router is the only address-level workaround, and it would hand the tokens to that router. So any launch-token balance a distributor pays to the vault is stranded. The mainnet IMD token (0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7) is not restricted and sweeps correctly, so only launch tokens are affected. Fixing it is a scope decision for the requester, for example a timelocked owner-only ERC-20 exit that can route to a distributor, or routing launch-token sweeps through a whitelisted distributor. Merged from audit_math.","line":305,"path":"src/HiveSeatVault.sol","reproduction":"Mainnet fork (https://ethereum-rpc.publicnode.com, 2026-10-08). Deploy HiveSeatVault(owner, 0x0000eC93127BAA929E58E97dd0095A2BFb38ec1D, 0xde152AfB7db5373F34876E1499fbD893A82dD336, address(0), sink=makeAddr(\"sink\")). deal(0x0000198C...d2F, vault, 1000e18) and deal(IMD 0xD34a...B7, vault, 1000e18). sweepEarnings([IMD]) succeeds and the sink receives 1000e18. Expected: sweepEarnings([0x0000198C...d2F]) moves 1000e18 to the sink. Actual: it reverts with 0x2f352531 (InvalidTransfer) and the vault still holds 1000e18. A plain EOA-to-EOA transfer of the same token also reverts, so the restriction is the token's, and the vault offers no route around it. Run as test/scratch/Fork.t.sol.","severity":"medium","snippet":"IERC20(token).safeTransfer(sink, bal);","title":"sweepEarnings cannot move IMD launch tokens (BaseStrategy ERC-20s revert InvalidTransfer on a plain transfer to rewardSink), so launch-token earnings are stuck in the vault"},{"citation":"resolved","description":"OpenZeppelin v5 TimelockController's constructor always grants DEFAULT_ADMIN_ROLE to address(this), whatever admin is passed. updateDelay only requires msg.sender == address(this). So the proposer can schedule a self-call updateDelay(0) or grantRole(...), execute it once 48h have passed, and then schedule and execute withdrawSeat for every seat in the same block. The guarantee that every exit is visible 48h ahead becomes: only the first hostile operation is visible 48h ahead. This is documentation and trust-assumption only, with no timelock design change proposed. The fix is to correct the comment and to monitor any scheduled operation that targets the timelock itself as an exit signal. Related trust assumption: OZ grants CANCELLER_ROLE only to proposers, so with the script's single proposer, admin=0 and open execution, that one key is also the only account that can cancel. If it leaks, nobody else can stop a queued withdrawSeat. This merges the duplicate canceller findings from audit_economics and audit_flow.","line":18,"path":"script/DeployHiveSeatVault.s.sol","reproduction":"Local test test/scratch/Local.t.sol::test_delay_can_be_zeroed. Build the timelock exactly as the script does: new TimelockController(48h, [P], [address(0)], address(0)). The vault is owned by it and holds seat 1343. P schedules (tl, updateDelay(0)), the test warps 48h, and anyone executes. Expected per the comment: impossible. Actual: getMinDelay()==0. P then schedules withdrawSeat(1343, P) with delay 0 and it executes in the same block: seat.ownerOf(1343)==P. The seat operator calling tl.cancel reverts, and hasRole(CANCELLER_ROLE, operator)==false. The test passes on the current code.","severity":"low","snippet":" *         - admin = address(0): no one can shorten the delay or re-grant roles after deploy.","title":"Deploy script claims nobody can shorten the timelock delay or re-grant roles; the OZ TimelockController administers itself, so the proposer can set the delay to 0 after one 48h wait and then withdraw "},{"citation":"resolved","description":"The token list is supplied by the caller, and the vault trusts the token's own balanceOf and transfer return values. A contract that reports any balance and returns true from transfer() makes the vault emit Swept(fake, N, rewardSink) while nothing moves. Any indexer that treats Swept as an earnings ledger without filtering token addresses can be polluted. Nothing else is reachable. The destination is fixed. The seat collection is rejected before any call is made. Re-entry into sweepEarnings, sweepETH or registerAgent hits the shared nonReentrant guard, every other mutator is gated by onlyOwner or onlySeatOperator, and the vault holds no approvals. No contract change is needed; indexers should filter by known tokens. Merged from audit_math, audit_flow and audit_permissions.","line":306,"path":"src/HiveSeatVault.sol","reproduction":"test/scratch/Local.t.sol::test_swept_spoof. Fake{balanceOf->1e30; transfer->true}. vm.prank(0xBAD); vault.sweepEarnings([fake]). Expected: no earnings event. Actual: the vault emits Swept(fake, 1e30, sink) and no tokens move.","severity":"info","snippet":"emit Swept(token, bal, sink);","title":"Anyone can make the vault emit a fabricated Swept(token, amount, rewardSink) event using a fake ERC-20; this is the only effect a hostile token has"},{"citation":"resolved","description":"Nothing in the vault pins owner() to a timelock. The deploy script's require only checks the state at deployment. The timelock can schedule transferOwnership(newOwner); after the single 48h delay and acceptOwnership(), every onlyOwner function, including withdrawSeat, can be called with no further delay. This is the documented design (lines 350-351), and the handover itself is a public 48h operation, so the timelock is not bypassed. Reported as a trust assumption only. From audit_economics.","line":359,"path":"src/HiveSeatVault.sol","reproduction":"The timelock queues vault.transferOwnership(EOA); after 48h it executes, and the EOA calls acceptOwnership(). The EOA then calls withdrawSeat(1343, EOA) in the same block and seat.ownerOf(1343)==EOA. This follows directly from Ownable2Step plus onlyOwner on line 329.","severity":"info","snippet":"function _transferOwnership(address newOwner) internal override {","title":"Trust assumption: the 48h delay belongs to the owner, not the vault; after a handover to a non-timelock owner, withdrawSeat is instant"},{"citation":"resolved","description":"I7 holds for the vault itself: grep finds no delegatecall, selfdestruct, approve or setApprovalForAll calls. The immutable adapter 0xde152AfB... is an ERC-1967 proxy, though, and its owner 0x03302Df40186D9B85faEA4fbb6cC5da028B23149 is a 345-byte contract outside Hive's control. The damage is bounded. The vault never approves the adapter. registerAgent is nonReentrant. The adapter is neither owner nor operator. So a hostile upgrade can only make registration revert (a denial of service on the operator's third power) or return a bogus agentId. If it returns 0, agentIdOf stays 0 and the once-per-seat guard never engages. List this next to the timelock and operator as a dependency assumption. Merged from audit_flow and audit_permissions.","line":223,"path":"src/HiveSeatVault.sol","reproduction":"cast storage 0xde152AfB7db5373F34876E1499fbD893A82dD336 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc returns implementation 0xa6d23f27d3b1780b12488482a008cb3c3787135f, and cast call owner() returns 0x03302Df4...3149, which has 345 bytes of code (checked 2026-10-08). If that owner upgrades to an implementation whose register() reverts, vault.registerAgent(1343, uri) reverts for both operator and owner.","severity":"info","snippet":"agentId = agentAdapter.register(0, address(seatCollection), tokenId, agentURI);","title":"Trust assumption: the agent adapter is a UUPS proxy upgradeable by a third-party key, so what registerAgent and setAgentURI actually do is not frozen by the vault's own non-upgradeability"},{"citation":"resolved","description":"For held seats the vault is the ERC-721 owner and the adapter controller, but it only passes through register and setAgentURI. Nobody, including the timelock, can call the adapter's setAgentWallet or setMetadata, or the collection's setIdentityHash, for a custodied seat. The identity-hash part is latent today: identityAllowed()==false on mainnet, so the call reverts for everyone. No seat or value is at risk. This is a functional gap; adding owner-only passthroughs is a scope decision. Downgraded from low because no material impact was shown. From audit_permissions.","line":20,"path":"src/HiveSeatVault.sol","reproduction":"The source has no function that calls setAgentWallet, setMetadata or setIdentityHash. Every external call on the adapter is at lines 223, 233 and 242 (register and setAgentURI). cast call 0x0000eC93...1D identityAllowed() returns false (2026-10-08).","severity":"info","snippet":"function setAgentURI(uint256 agentId, string calldata agentURI) external;","title":"Custodied seats and agents lose owner-gated controls the vault does not pass through (adapter setAgentWallet/setMetadata, collection setIdentityHash)"},{"citation":"resolved","description":"SeatDeposited is emitted only from the safeTransfer hook. A seat sent with transferFrom is fully custodied: it can be paired and withdrawn, and the custody invariants are unaffected. But off-chain tooling that rebuilds the custody set from SeatDeposited will miss it. This is an observability gap only. From audit_economics.","line":171,"path":"src/HiveSeatVault.sol","reproduction":"test/scratch/Local.t.sol::test_transferFrom_no_event. A holder calls seat.transferFrom(holder, vault, 7) with vm.recordLogs. Expected: a SeatDeposited log. Actual: no log has topic0 == SeatDeposited.selector, and ownerOf(7)==vault.","severity":"info","snippet":"function onERC721Received(address, address from, uint256 tokenId, bytes calldata)","title":"Seats deposited with plain transferFrom emit no SeatDeposited event"}],"hash":"f188b9eadcba8d194c5b7460c610e47634d4566465433ba7bdcd88f835e8299c","nodeId":"246f2719-2c39-4e27-add7-99aba5563fc4","outcome":"completed","summary":"The property you said deserves the hardest look holds: the hot operator key cannot make the vault accept an arbitrary hash, a Seaport order included. None of the custody rules (seats leave only through the owner, no approvals, operator scope, sweep, onlyOwner functions, no upgrades) could be broken either. I kept 7 findings, all reproduced, in `.imd-findings.json`: 1 medium, 1 low and 5 info. None is critical or high, so none carries a proof file.\n\n**Signature check (1), checked against the code:**\n- `authorizeWorker` only stores the EIP-712 digest it computes itself, after checking that the wallet is the vault, the authorization hasn't expired, and the vault owns the seat.\n- The only other function that writes that mapping, `revokeWorkerAuthorization`, can only delete entries.\n- A Seaport order hash is built under Seaport's own domain and type. Colliding it with a WorkerAuthorization digest would require a keccak preimage, so it can't happen.\n- The vault never calls `approve` or `setApprovalForAll`, and has no `delegatecall` or `selfdestruct`.\n- A hostile ERC-20 passed to `sweepEarnings` can't re-enter the sweep functions or `registerAgent`, which share one reentrancy guard. Every other state-changing function needs the owner or the operator.\n\n**Findings kept:**\n1. **Medium – launch-token earnings can't be swept** (`src/HiveSeatVault.sol:305`). I ran this on a mainnet fork. The launch token 0x0000198C… reverts with `InvalidTransfer()` when sent to an ordinary sink, so any balance a distributor pays to the vault is stuck. The IMD token sweeps fine. Fixing it is your call, since the vault has no other way to move ERC-20s out.\n2. **Low – the deploy script's comment is wrong** (`script/DeployHiveSeatVault.s.sol:18`). It says nobody can shorten the delay or re-grant roles. In fact the OpenZeppelin timelock administers itself: the proposer can schedule `updateDelay(0)` and, once the 48h pass, withdraw every seat in a single block. I folded in the duplicate finding that this proposer key is also the only one that can cancel.\n3. **Info – fake `Swept` events.** Anyone can make the vault emit `Swept` for a fake token, which only misleads off-chain indexers. Three specialists reported this; merged into one.\n4. **Info – ownership handover.** Handing ownership to a non-timelock address makes `withdrawSeat` instant. This is a trust assumption of the design, not a bypass.\n5. **Info – the agent adapter is upgradeable.** It's a UUPS proxy whose owner is a 345-byte contract outside Hive's control. The worst an upgrade can do is block registration or return a bogus agent ID. Two specialists reported this; merged.\n6. **Info – missing pass-through functions.** The vault doesn't pass through the adapter's `setAgentWallet`/`setMetadata` or the collection's `setIdentityHash`. I downgraded this from low because no real harm was shown, and `setIdentityHash` is switched off on mainnet today.\n7. **Info – no deposit event for `transferFrom`.** A seat sent with plain `transferFrom` is fully held but emits no `SeatDeposited` event.\n\n**Dropped:** I left out the finding that `isValidSignature` reverts for a burned seat. It does revert, but the live collection has no burn path, and ERC-1271 callers treat a revert as an invalid signature anyway.\n\n**How I checked:** the local tests and fork tests behind these results lived in `test/scratch/`. I deleted them afterwards, so no repository files were changed. The existing suite passes (40 tests). I didn't run `HiveSeatVaultFork.t.sol`.\n\nThe verifier has no network, so it can't re-run the medium finding's fork check. Its reproduction is written into the finding instead.","treeHash":null,"usage":{"cachedInputTokens":649213,"inputTokens":22,"model":"claude-opus-5-5","outputTokens":13314,"runtime":"claude","turns":11,"wallClockMs":146845}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3bed38612db34f32","findings":[{"citation":"resolved","description":"The brief asks whether a hostile ERC-20 passed to sweepEarnings can do anything beyond reverting its own sweep. Answer: one cosmetic thing. The token list is caller-supplied and the vault trusts the token's own balanceOf() and transfer() return values, so a contract that reports an arbitrary balance and returns true from transfer() causes the vault to emit Swept(token, <arbitrary amount>, rewardSink) with the vault as the emitting contract and msg.sender of the transfer call. Everything else is contained: balanceOf is a STATICCALL (no state change possible), re-entry into sweepEarnings/sweepETH during transfer() is blocked by the contract-wide nonReentrant guard, authorizeWorker/revokeWorkerAuthorization/registerAgent need the operator or owner, the seat collection is rejected before any call, and the destination is the fixed rewardSink. No seat, ETH or real token can be moved. Impact is limited to off-chain indexers that treat Swept events as an earnings ledger without filtering by token address. Fix would be a documentation note for indexers (filter Swept by known reward tokens); no contract change is needed.","line":303,"path":"src/HiveSeatVault.sol","reproduction":"Deploy a contract T with balanceOf(address) returning 1e30 and transfer(address,uint256) that attempts vault.sweepEarnings([T]) and vault.sweepETH() in try/catch and then returns true. From any EOA call vault.sweepEarnings([address(T)]). Expected (naive): revert or nothing. Actual: the vault emits Swept(T, 1e30, rewardSink); both re-entrant calls revert (ReentrancyGuardReentrantCall) and are swallowed; seat ownerOf and the vault's ETH balance are unchanged. Confirmed with a Foundry test (test_hostile_token_spoofs_swept_event_only) on this commit.","severity":"info","snippet":"            uint256 bal = IERC20(token).balanceOf(address(this));\n            if (bal != 0) {\n                IERC20(token).safeTransfer(sink, bal);\n                emit Swept(token, bal, sink);","title":"sweepEarnings lets any caller make the vault emit a fabricated Swept event via a hostile ERC-20 (no other effect)"},{"citation":"resolved","description":"Documented as a trust assumption, not a request to change the 48h timelock design. OpenZeppelin 5.1 TimelockController grants CANCELLER_ROLE to every proposer and to nobody else (lib/openzeppelin-contracts/contracts/governance/TimelockController.sol:126-127); the admin slot is address(0) and the executor is address(0) (open execution). With a single HIVE_TIMELOCK_PROPOSER, the vault's entire custody guarantee therefore reduces to: that one key is never compromised. If it is, the attacker schedules withdrawSeat(tokenId, attacker); the vault's owner is the Timelock so no other account can intervene; the operator, the reward sink, or the team (if they have lost the key) cannot cancel; after 48h anyone, including the attacker, executes it. The 48h delay is a public warning window only. The deploy script accepts any address for the proposer, including an EOA, and the README calls it a 'team key / multisig'. Expected operational posture: the proposer should be a multisig and, if desired, a separate canceller granted via a scheduled self-call on the timelock; the script does not enforce or document either.","line":40,"path":"script/DeployHiveSeatVault.s.sol","reproduction":"Deploy per the script with proposers=[P], executors=[address(0)], admin=address(0), vault owner = timelock, seat 1343 in the vault. Using P: timelock.schedule(vault, 0, abi.encodeCall(HiveSeatVault.withdrawSeat,(1343, thief)), 0, salt, 48h). From the seat operator or any other account: timelock.cancel(id) reverts with AccessControlUnauthorizedAccount; isOperationPending(id) stays true. warp +48h; from any account: timelock.execute(...) succeeds and seat.ownerOf(1343) == thief. Confirmed with a Foundry test (test_compromised_proposer_cannot_be_cancelled_by_anyone_else) against the vendored TimelockController.","severity":"info","snippet":"        timelock = new TimelockController(MIN_DELAY, proposers, executors, address(0)); // admin renounced","title":"Trust assumption: the Timelock proposer is also its only canceller, so a leaked proposer key can queue a seat exit that nobody else can stop"},{"citation":"resolved","description":"Live mainnet check (cast, 2026-10-08): 0xde152AfB...dD336 is an OpenZeppelin ERC1967Proxy whose implementation slot points to 0xa6d23f27d3b1780b12488482a008cb3c3787135f (Adapter8004, UUPSUpgradeable, OwnableUpgradeable) and whose owner() is the EOA 0x03302Df40186D9B85faEA4fbb6cC5da028B23149. The vault stores the adapter as an immutable and calls register() from registerAgent (src/HiveSeatVault.sol:223) and setAgentURI() from setAgentURI/setAgentURIById. Nothing in the vault's own rules can change silently (I7 holds: no proxy, no delegatecall, no selfdestruct), but what those three functions actually do on-chain is governed by an external upgrade key with no delay. The blast radius is bounded and was checked: the vault never grants the adapter any ERC-721 or ERC-20 approval; registerAgent is nonReentrant and shares its guard with both sweeps; setAgentURI/setAgentURIById are onlyOwner so a malicious adapter re-entering the vault reaches nothing privileged; a hostile upgrade can therefore only revert the operator's registration path or return a bogus agentId (agentId 0 would let the operator re-register, which is harmless). The current implementation mints the ERC-8004 agent to the adapter itself (Adapter8004._registerImpl step 4), so the vault's onERC721Received, which rejects foreign collections, does not block registration; the fork suite confirms this at the current implementation only. Report as a dependency trust assumption to list alongside the Timelock and operator; no vault change is proposed.","line":25,"path":"script/DeployHiveSeatVault.s.sol","reproduction":"cast storage 0xde152AfB7db5373F34876E1499fbD893A82dD336 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc returns 0x...a6d23f27d3b1780b12488482a008cb3c3787135f; cast call <adapter> 'owner()(address)' returns 0x03302Df40186D9B85faEA4fbb6cC5da028B23149 (an EOA, no code). State that triggers the concern: that key calls upgradeToAndCall(newImpl) on the proxy; from then on vault.registerAgent(1343, uri) executes whatever newImpl.register does. Expected under the README's I7 reading: operator's register/URI behaviour is fixed. Actual: fixed in the vault, but delegated to an upgradeable contract. Contained effects as described (revert or bogus id only; no approvals exist to abuse).","severity":"info","snippet":"    address constant AGENT_ADAPTER = 0xde152AfB7db5373F34876E1499fbD893A82dD336;","title":"Trust assumption: the ERC-8004 agent adapter is a UUPS proxy upgradeable by a third-party EOA; registerAgent/setAgentURI semantics can change without the Timelock"}],"hash":"fcc73e8b22b4c703412cbfc93f437360a9a05b165cb1b56447ea343f4b518990","nodeId":"4b10dbeb-d7e1-45f0-884c-7e6952c59b62","outcome":"completed","summary":"The review is complete and the findings file is written. Nothing in the tree was changed apart from creating the required findings file.\n\n**Outcome: no critical, high, medium or low defects found.** The core anti-rug property and all six custody invariants hold on this commit. The findings file holds three info-level trust notes, each with a concrete reproduction.\n\n**What I verified for each item in the brief**\n\n- **(1) ERC-1271 scope.** The pairing mapping has exactly one writer, and the key it writes is always the EIP-712 digest the contract computes itself from a validated struct with a fixed typehash, a fixed domain, wallet forced to the vault, a future expiry and a seat the vault owns. The operator controls only hash preimage fields, so inserting a chosen hash (a Seaport order digest, a permit, anything) would require a keccak256 preimage. The fork suite confirms the digest is byte-identical to the live IMDSeatStrategy. The vault version is strictly stricter than the reference (on-chain expiry, operator epoch, custody epoch).\n- **(2) Seat exit.** I pulled the verified source of the live collection: it is a plain Solady ERC-721 with no burn, no permit, no signature approvals and no admin transfer. Its owner is the CREATE2 deployer proxy, so its setters are permanently unreachable. The vault never calls approve or setApprovalForAll, and the only call that moves a seat is the owner-gated withdrawSeat. The rescue path rejects the collection.\n- **(3) Operator powers.** The three operator functions write a pairing, delete a pairing, or call the IMD adapter once per seat. None touch approvals, transfers or ETH. A leaked key can pair its own devices and poison a URI until the Timelock rotates it, which retires every pairing. Grief only.\n- **(4) sweepEarnings.** Caller supplies only the token list. Destination is the stored sink. The collection is rejected before any call. A hostile token's balanceOf runs under STATICCALL and its transfer cannot re-enter anything privileged. The one side effect is a fabricated Swept event, recorded as info.\n- **(5) Owner paths.** All config and exit functions are onlyOwner, renounce is disabled, ownership transfer is two-step and clean-slates pairings. The receiver hook is stateless and rejects foreign collections, so it can neither brick the vault nor fake an approval.\n- **(6) Immutability.** No delegatecall, selfdestruct or proxy in the vault. The adapter it calls is a third-party UUPS proxy, recorded as a trust note.\n\n**Checks run.** The 40 unit and timelock tests pass. The 5 mainnet fork tests pass with live RPC. Three scratch tests I wrote to confirm the info items passed and were removed afterwards. Slither was not run, as the task does not provide it.\n\n**Open items for the requester,** all info-level and in the findings file: filter Swept events by token in any indexer, treat the Timelock proposer key as the single point of failure it is (it is also the sole canceller), and list the upgradeable IMD adapter as an external dependency.","treeHash":null,"usage":{"cachedInputTokens":1903051,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":41519,"runtime":"claude","turns":38,"wallClockMs":604740}}],"verification":[]}