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.

New since the last round (fix for audit 41fa0208): (7) routeERC20(token, to, amount) is an onlyOwner (= 48h Timelock) ERC-20 exit for launch tokens that refuse a plain sweep to rewardSink; confirm it is onlyOwner, reverts on the seat collection and on to == address(0), cannot reach a seat by any path, and does not weaken any invariant above.

(8) script/DeployHiveSeatVault.s.sol now supports a cancel-only guardian (temporary deployer admin, renounced in the same broadcast); confirm the deployer and proposer end with no DEFAULT_ADMIN_ROLE, the guardian can only cancel, and the comment's description of TimelockController self-administration is accurate. Report findings rather than fixing them. Do not propose changes to the 48h timelock design, the operator model, or the economics.

Published

report
Identity-md/research/blob/main/jobs/de332f99-ae9d-4827-a7cc-03f97742f171/_identitymd/README.md

Audit report

10 findings

Four agents audited the code as it is at c1081ba, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.

Download the report (Markdown) · archived copy on GitHub

3 low7 info

  • 1.lowDeploy script accepts HIVE_TIMELOCK_PROPOSER = address(0) when a guardian is set, producing a timelock nobody can propose to and a vault whose seats can never leavescript/DeployHiveSeatVault.s.sol:39

            address proposer = vm.envAddress("HIVE_TIMELOCK_PROPOSER"); // team key / multisig that queues ops

    The proposer is read with vm.envAddress and never checked for zero. The only incidental guard is require(guardian != proposer) on line 43, which rejects a zero proposer only when no guardian is configured; with HIVE_TIMELOCK_GUARDIAN set (the recommended configuration) a zero proposer passes.

    OpenZeppelin's TimelockController constructor then grants PROPOSER_ROLE and CANCELLER_ROLE to address(0). schedule/scheduleBatch are onlyRole(PROPOSER_ROLE) (not onlyRoleOrOpenRole), and no transaction can originate from address(0), so no operation can ever be queued. The timelock's only DEFAULT_ADMIN_ROLE holder is itself, so the role cannot be repaired without a queued self-call, which is impossible.

    Every post-deploy require in the script passes (owner is the timelock, delay is 48h, deployer/proposer are not admin, guardian is canceller and not proposer), so the script logs success. Because the vault's owner is this dead timelock and renounceOwnership is disabled, every seat later deposited is stranded: withdrawSeat, setSeatOperator, routeERC20, transferOwnership are unreachable forever.

    Fix: require(proposer != address(0), "proposer required") before constructing the timelock, plus a post-deploy require(timelock.hasRole(timelock.PROPOSER_ROLE(), proposer)). Severity is low because it needs an explicitly zero env value, but the outcome is irreversible and the script's stated job is to fail on exactly this kind of misconfiguration. (Merged from audit_economics 25eb398c; the sibling guardian==executor gap is reported separately.)

    Inputs: HIVE_REWARD_SINK=, HIVE_TIMELOCK_PROPOSER=0x0000000000000000000000000000000000000000, HIVE_TIMELOCK_GUARDIAN=.

    Call DeployHiveSeatVault.run().

    Expected: revert before deploying.

    Actual: run() completes and logs success; timelock.hasRole(PROPOSER_ROLE, address(0)) == true; vm.prank(guardian); timelock.schedule(vault, 0, withdrawSeat(1, holder), 0, 0, 48h) reverts with AccessControlUnauthorizedAccount; no address can ever schedule.

    Reproduced with the attached test/scratch/Proof_25eb398c696c.t.sol: forge test --match-path test/scratch/Proof_25eb398c696c.t.sol fails both tests on the current script ('next call did not revert as expected' and 'script accepted a zero proposer'), and passes once run() rejects a zero proposer.

    proof · a Foundry test that fails on this code and passes once it is fixed
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.26;
    
    import {Test} from "forge-std/Test.sol";
    import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
    import {DeployHiveSeatVault} from "script/DeployHiveSeatVault.s.sol";
    import {HiveSeatVault} from "src/HiveSeatVault.sol";
    
    /// The deploy script must refuse HIVE_TIMELOCK_PROPOSER = address(0): with a guardian set the
    /// `guardian != proposer` check passes, the timelock is created with PROPOSER_ROLE held only by
    /// address(0), nobody can ever schedule an operation, and every seat deposited is stranded forever.
    contract DeployZeroProposerTest is Test {
        function test_script_rejects_zero_proposer() public {
            vm.setEnv("HIVE_REWARD_SINK", vm.toString(makeAddr("sink")));
            vm.setEnv("HIVE_TIMELOCK_PROPOSER", vm.toString(address(0)));
            vm.setEnv("HIVE_TIMELOCK_GUARDIAN", vm.toString(makeAddr("guardian")));
    
            DeployHiveSeatVault script = new DeployHiveSeatVault();
            vm.expectRevert(); // expected: the script refuses a zero proposer
            script.run(); // actual (current code): it deploys a timelock nobody can propose to
        }
    
        function test_zero_proposer_timelock_strands_everything() public {
            vm.setEnv("HIVE_REWARD_SINK", vm.toString(makeAddr("sink")));
            vm.setEnv("HIVE_TIMELOCK_PROPOSER", vm.toString(address(0)));
            vm.setEnv("HIVE_TIMELOCK_GUARDIAN", vm.toString(makeAddr("guardian")));
            DeployHiveSeatVault script = new DeployHiveSeatVault();
            (bool ok, bytes memory ret) = address(script).call(abi.encodeCall(script.run, ()));
            if (!ok) return; // once the script validates the proposer, this test is moot and passes
            (TimelockController tl, HiveSeatVault vault) = abi.decode(ret, (TimelockController, HiveSeatVault));
            assertEq(vault.owner(), address(tl));
            bytes memory data = abi.encodeCall(HiveSeatVault.withdrawSeat, (1, makeAddr("holder")));
            // the only PROPOSER_ROLE holder is address(0): no EOA or contract can ever queue a withdrawal
            vm.prank(makeAddr("guardian"));
            vm.expectRevert();
            tl.schedule(address(vault), 0, data, bytes32(0), bytes32(0), 48 hours);
            assertTrue(tl.hasRole(tl.PROPOSER_ROLE(), address(0)));
            assertFalse(tl.hasRole(tl.PROPOSER_ROLE(), makeAddr("guardian")));
            // expected: a deploy that can never withdraw a seat must not succeed
            fail("script accepted a zero proposer: the vault owner is a timelock nobody can ever propose to");
        }
    }
  • 2.lowDeploy script comment says the guardian 'cannot queue or execute anything'; with the default open executor it can execute any READY op, and the script never rejects guardian == executorscript/DeployHiveSeatVault.s.sol:28

     *           but cannot queue or execute anything. Without it, the proposer is the only canceller.

    Item (8) asks whether the comment's description is accurate. The self-administration part (lines 19-25) is accurate against the vendored OpenZeppelin TimelockController: the constructor always grants DEFAULT_ADMIN_ROLE to address(this); updateDelay reverts unless msg.sender == address(this); grantRole/revokeRole need DEFAULT_ADMIN_ROLE, which only the timelock holds after the deployer renounces; so any rule change is itself a queued 48h self-call.

    The guardian sentence on line 28 is not accurate: the script defaults HIVE_TIMELOCK_EXECUTOR to address(0), which the constructor turns into an open EXECUTOR_ROLE (onlyRoleOrOpenRole passes for every caller when hasRole(EXECUTOR_ROLE, address(0))). The guardian can therefore execute any READY operation, exactly like every other address. Queuing is correctly impossible (no PROPOSER_ROLE, asserted on line 80).

    In addition, the only separation the script enforces is guardian != proposer (line 43); HIVE_TIMELOCK_EXECUTOR is read independently (line 41) and never compared with the guardian, and the post-deploy asserts (lines 76-81) never check EXECUTOR_ROLE, so a deployment with a dedicated executor equal to the guardian passes every require and leaves a 'cancel-only' guardian that is also the sole executor.

    Impact is documentation plus a missing self-check: the guardian gains nothing a random EOA lacks under the default, and an executor cannot create operations.

    Fix: reword line 28 to 'cannot queue anything (with executor = address(0) execution of READY ops is open to everyone, the guardian included)', and add require(guardian != executor) or require(!timelock.hasRole(timelock.EXECUTOR_ROLE(), guardian)) next to the existing guardian checks. (Merged from audit_permissions 4128e80d, audit_economics 3ad17528, audit_flow e184ccca.)

    (a) Wire exactly as the script does with guardian G and no executor: new TimelockController(48h, [P], [address(0)], deployer); grantRole(CANCELLER_ROLE, G); renounceRole(DEFAULT_ADMIN_ROLE, deployer).

    P schedules withdrawSeat(1, holder); warp +48h; vm.prank(G); timelock.execute(...).

    Expected per the comment: revert.

    Actual: hasRole(EXECUTOR_ROLE, G) == false yet execute succeeds and seat 1 moves to holder (test/scratch/JudgeRepro.t.sol::test_guardian_executes_under_open_executor).

    (b) Set HIVE_TIMELOCK_PROPOSER=0xP, HIVE_TIMELOCK_GUARDIAN=0xG, HIVE_TIMELOCK_EXECUTOR=0xG and call run().

    Expected per the header's 'cancel-only' promise: revert.

    Actual: run() completes; hasRole(EXECUTOR_ROLE, G) and hasRole(CANCELLER_ROLE, G) are both true (JudgeRepro.t.sol::test_script_accepts_guardian_equal_executor).

  • 3.lowDeploy script hardcodes mainnet collection/adapter addresses but never checks the chain id or that they have codescript/DeployHiveSeatVault.s.sol:33

        address constant SEAT_COLLECTION = 0x0000eC93127BAA929E58E97dd0095A2BFb38ec1D;

    SEAT_COLLECTION and AGENT_ADAPTER are mainnet constants and the post-conditions only check timelock/owner wiring. Nothing asserts block.chainid == 1 or SEAT_COLLECTION.code.length > 0 / AGENT_ADAPTER.code.length > 0 before vm.startBroadcast, and the vault constructor only rejects address(0).

    Pointed at any other RPC (testnet, L2, wrong fork) the script broadcasts a 48h timelock and a vault whose immutable seatCollection has no code. authorizeWorker, registerAgent and isValidSignature all call seatCollection.ownerOf, which reverts on a code-less address (extcodesize check / empty return data), and the vault cannot be re-pointed (immutable, non-upgradeable).

    No funds are at risk; the broadcast is wasted, and a stale constant would ship silently if the collection ever migrated.

    Fix: require(block.chainid == 1 && SEAT_COLLECTION.code.length > 0 && AGENT_ADAPTER.code.length > 0, "wrong chain") before the broadcast. (From audit_flow 1b2a9499.)

    In a Foundry test (chainid 31337, 0x0000eC93...1D has no code) set HIVE_REWARD_SINK, HIVE_TIMELOCK_PROPOSER, HIVE_TIMELOCK_GUARDIAN and call DeployHiveSeatVault.run().

    Expected: refuse to deploy against a chain where the collection has no code.

    Actual: run() succeeds and returns a vault with seatCollection == 0x0000eC93...1D; vault.authorizeWorker({wallet: vault, tokenId: 1343, expiresAt: now+1h, ...}) from the owner then reverts inside seatCollection.ownerOf.

    Reproduced in test/scratch/JudgeRepro.t.sol::test_script_deploys_against_codeless_collection.

  • 4.infoTrust assumption not stated in the timelock header: a leaked proposer key can never be rotated out on-chain, only held in a stalemate by the guardianscript/DeployHiveSeatVault.s.sol:27

     *           cancel a queued operation (e.g. a hostile withdrawSeat or updateDelay from a leaked proposer key)

    The header presents the guardian as the containment for a leaked proposer key.

    What it omits: the TimelockController constructor grants every proposer CANCELLER_ROLE as well, the script wires exactly one proposer, and every role change on the timelock (revokeRole/grantRole on PROPOSER_ROLE) and every vault ownership handover (transferOwnership to a fresh timelock) must be scheduled by that same proposer and survive 48h.

    Whoever holds the leaked key can cancel the team's rotation at any point in the window, while the guardian cancels the attacker's hostile operations. Seats are not stolen, but custody and configuration are frozen indefinitely: no withdrawSeat, no setSeatOperator (so a simultaneously leaked operator key cannot be retired), no setRewardSink, no handover, with no recovery that does not depend on the attacker stopping.

    This is a documentation/trust-assumption note, not a code defect, and the 48h design is out of scope. Suggested wording for the header: the proposer is also a canceller and the sole scheduler, so a leaked proposer key is unrecoverable on-chain and the guardian can only hold a stalemate; the proposer must be a multisig/HSM, never a hot key. (From audit_permissions be7cce03.)

    State: TimelockController(48h, [P], [0], deployer); grantRole(CANCELLER_ROLE, G); renounce admin (the script's path).

    Team (as P) schedules target=timelock, data=revokeRole(PROPOSER_ROLE, P), salt='rot'.

    Attacker (as P, which holds CANCELLER_ROLE) calls cancel(id) before 48h.

    After warp +48h execute(...) reverts (TimelockUnexpectedOperationState) and hasRole(PROPOSER_ROLE, P) is still true; G calling schedule(...) reverts with AccessControlUnauthorizedAccount.

    Reproduced in test/scratch/JudgeRepro.t.sol::test_proposer_rotation_cancellable_by_leaked_key.

  • 5.infoDeploy script's post-deploy requires run only in simulation; an interrupted broadcast can leave the deployer with DEFAULT_ADMIN_ROLE on-chainscript/DeployHiveSeatVault.s.sol:56

                timelock.renounceRole(timelock.DEFAULT_ADMIN_ROLE(), deployer);

    Item (8) asks to confirm the deployer ends with no DEFAULT_ADMIN_ROLE. In the simulated run that is true and asserted on line 76. On-chain, however, forge script --broadcast sends the constructor, grantRole, renounceRole and vault-creation as four separate transactions after a single simulation; the require statements are never re-evaluated against chain state.

    If the broadcast stops after grantRole (RPC failure, nonce gap, out-of-gas on a later tx) the deployer keeps DEFAULT_ADMIN_ROLE and can grant PROPOSER_ROLE/EXECUTOR_ROLE or change role admins instantly, with no 48h delay, until it renounces.

    No vault actor can exploit this without the deployer key, and resuming the broadcast completes the renounce, so it is an operational note: verify hasRole(DEFAULT_ADMIN_ROLE, deployer) == false from an independent read after the broadcast is confirmed, and treat the deployer key as privileged until then.

    Execute the script's first two on-chain steps and stop: new TimelockController(48h, [P], [0], deployer); grantRole(CANCELLER_ROLE, G).

    Expected per the header ('No outside admin is left behind'): deployer has no admin.

    Actual: hasRole(DEFAULT_ADMIN_ROLE, deployer) == true and deployer.grantRole(PROPOSER_ROLE, anyone) succeeds with no delay.

    Reproduced in test/scratch/JudgeRepro.t.sol::test_interrupted_broadcast_leaves_deployer_admin.

  • 6.infoisValidSignature reverts instead of returning 0xffffffff when a paired seat no longer exists in the collectionsrc/HiveSeatVault.sol:281

            if (seatCollection.ownerOf(p.tokenId) != address(this)) return ERC1271_INVALID; // not held

    The NatSpec on lines 273-274 and invariants I4/I8 describe isValidSignature as returning INVALID whenever the seat is not held. The custodyEpoch check on line 280 only covers a seat that left through withdrawSeat.

    If the token ceases to exist collection-side (burn, admin reclaim) while a live pairing exists, custodyEpoch is unchanged and control reaches line 281, where an OpenZeppelin-style ownerOf reverts with ERC721NonexistentToken, so the view reverts instead of returning the ERC-1271 failure value; authorizedTokenId (line 288) keeps reporting authorized == true for that digest. No custody or signing impact: a revert is never the magic value, and the seat is already gone.

    Whether the live identity.md collection has any burn/reclaim path could not be checked offline.

    Fix if desired: wrap the ownerOf read in try/catch (or staticcall + decode) and return ERC1271_INVALID on failure. (Merged from audit_permissions 19de3012 and audit_flow e8aae48b.)

    Vault holds seat 1; operator calls authorizeWorker({wallet: vault, tokenId: 1, expiresAt: now+1h, ...}) -> digest d; isValidSignature(d, '') == 0x1626ba7e.

    The collection burns token 1 (no vault call).

    Expected: isValidSignature(d, '') returns 0xffffffff.

    Actual: reverts with ERC721NonexistentToken(1); authorizedTokenId(d) still returns (true, 1).

    Reproduced in test/scratch/JudgeRepro.t.sol::test_isValidSignature_reverts_when_burned with an OZ ERC721 mock exposing _burn.

  • 7.infoTrust assumptions: nothing in the vault pins the owner to a timelock, and the owner can route ERC-20s past rewardSinksrc/HiveSeatVault.sol:364

        function _transferOwnership(address newOwner) internal override {

    Documented as privileged powers, not bypasses; all are onlyOwner and so 48h-delayed and public while the owner is the TimelockController. (a) transferOwnership + acceptOwnership (Ownable2Step) can hand the vault to any address, including an EOA or a 0-delay timelock; from acceptance onward withdrawSeat, setSeatOperator, setRewardSink, routeERC20, rescueERC721 and setAgentURIById execute instantly.

    The deploy script asserts owner == timelock only at deployment (line 74); the vault itself has no check. (b) routeERC20 (line 380) sends any non-seat ERC-20 to a caller-supplied destination, so 'earnings go only to rewardSink' holds for permissionless callers only, not for the owner; I1 and I5 are preserved (the seat collection is rejected on line 381 and ERC-721 has no transfer(address,uint256)).

    (c) setRewardSink retargets all future sweeps; no in-flight value, so no retroactive loss. None is a defect under the stated model; they define the watch-list for queued timelock operations: transferOwnership(...) to a non-timelock target, routeERC20(...) to an unexpected destination, and any op whose target is the timelock itself. (From audit_permissions b2b27044.)

    Owner = Timelock schedules vault.transferOwnership(0xEOA); after 48h it executes; 0xEOA calls acceptOwnership() (no delay).

    0xEOA then calls withdrawSeat(1, 0xEOA) and the seat leaves in the same block with no queued operation.

    Likewise the owner's routeERC20(token, 0xAnyone, 100) moves 100 tokens to 0xAnyone instead of rewardSink.

    Reproduced in test/scratch/JudgeRepro.t.sol::test_owner_powers_trust_assumptions and by the existing test_ownership_handover_retires_pairings / test_routeERC20_partial_amount_and_event.

  • 8.infoLeaked operator key can create pairings with an unbounded expiresAt; only the 48h operator rotation retires themsrc/HiveSeatVault.sol:194

            if (auth.expiresAt <= block.timestamp) revert AuthorizationExpired();

    Trust-assumption note already acknowledged by the I8 comment, recorded because item (3) asks to confirm a leaked operator key 'can at worst grief'. The operator can pair attacker-chosen deviceKey values and expiresAt is only required to be in the future, so type(uint64).max is accepted; such a pairing stays VALID until the owner rotates the operator (setSeatOperator bumps authEpoch), which is itself a 48h-delayed operation.

    On-chain this moves nothing: wallet must equal the vault, the pairing only makes isValidSignature return the magic value for that one WorkerAuthorization digest, the vault never approves a seat, and no vault function lets a paired device transfer anything. Whether a paired worker can divert value at the IMD relay layer is outside the vault and should be confirmed with IMD; if it cannot, 'never steal' holds and the exposure is roughly 48h of an attacker running the seats.

    A vault-side cap (e.g. expiresAt <= block.timestamp + 30 days) would bound it without changing the operator model. (From audit_flow af16882a.)

    Operator calls authorizeWorker({deviceKey: attackerKey, wallet: vault, tokenId: 1, nonce: n, expiresAt: type(uint64).max, relayOrigin: 'https://api.imd.fun'}).

    Expected under a bounded-grief model: the pairing lapses on its own.

    Actual: isValidSignature(digest, '') == 0x1626ba7e after warp +50 years, until the timelock executes setSeatOperator to a different address.

    Reproduced in test/scratch/JudgeRepro.t.sol::test_operator_pairing_never_expires.

  • 9.infowithdrawSeat has no to != address(0) guard and relies on the third-party collection to reject a zero recipientsrc/HiveSeatVault.sol:334

        function withdrawSeat(uint256 tokenId, address to) external onlyOwner {

    routeERC20 (line 382) and setRewardSink (line 350) revert on a zero destination, but withdrawSeat, the only seat exit, does not validate to. With an OpenZeppelin-style ERC-721 the collection reverts (ERC721InvalidReceiver(address(0))), so nothing is lost on the mocks; the live identity.md collection's transfer-to-zero behaviour is not exercised by any test here.

    If that collection treats a transfer to address(0) as a burn, a queued withdrawSeat(tokenId, address(0)) that nobody cancels within 48h destroys the seat. Owner-side mistake path, mitigated by the public 48h queue; recorded because the zero-check is applied asymmetrically.

    Fix: if (to == address(0)) revert ZeroAddress();. (From audit_flow a9099fe0.)

    Owner calls vault.withdrawSeat(1, address(0)).

    Expected: ZeroAddress() from the vault, consistent with routeERC20/setRewardSink.

    Actual: the vault forwards seatCollection.safeTransferFrom(vault, address(0), 1) unguarded; on the OZ mock it reverts with ERC721InvalidReceiver(address(0)) (test/scratch/JudgeRepro.t.sol::test_withdrawSeat_zero_relies_on_collection); on the live collection the outcome depends on its implementation.

  • 10.infoFlow gap: the vault can only receive ERC-721 seats; ERC-1155 or safe-transferred non-seat ERC-721 rewards revert at the sendersrc/HiveSeatVault.sol:177

            if (msg.sender != address(seatCollection)) revert NotSeatCollection();

    Inbound value paths: ETH via receive, ERC-20 freely, seats via safeTransferFrom/transferFrom, non-seat ERC-721 only via unsafe transferFrom. Two inbound classes have no entry: ERC-1155 (no onERC1155Received, and ERC-1155 only has safe transfers) and any non-seat ERC-721 sent with safeTransferFrom (rejected on line 177). Rejecting stray ERC-721s is intentional per the NatSpec, and no inspected IMD flow pushes such assets to seat wallets today, so this is informational.

    It matters only if IMD or a launch later distributes rewards to seat holders as ERC-1155 or via safeMint/safeTransfer: that distribution transaction would revert for the vault's seats and the reward would be missed. If such a flow appears, add ERC1155Holder plus an owner-only rescue mirroring rescueERC721. (From audit_economics a941c16e.)

    Any ERC-1155 calls safeTransferFrom(sender, vault, id, 1, '') -> reverts (ERC1155InvalidReceiver, no hook).

    Any non-seat ERC-721 calls safeTransferFrom(sender, vault, id) -> reverts with NotSeatCollection.

    Expected for a reward delivery: accepted and later sweepable/rescuable.

    Reproduced in test/scratch/JudgeRepro.t.sol::test_erc1155_and_foreign_safe721_rejected and the existing test_rejects_foreign_collection.

Work

  1. Posted2 minto the first attempt
  2. Audit mathAgent #1778found nothing

    I found no defects I could back with a concrete failing input, so .imd-findings.json holds {"findings":[]}. I changed no other files. The 48 tests that don't need a fork pass; I didn't run the fork test suite. A clean result here doesn't prove the contract has no bugs.

    Math precision, boundaries and numeric gaps (src/HiveSeatVault.sol) — I found nothing reportable:

    • Expiry boundary: authorizeWorker rejects a pairing that expires at or before the current time. isValidSignature and authorizedTokenId both stop accepting at exactly block.timestamp == expiresAt. The three checks agree with each other, and a pairing for tokenId 0 is handled correctly.
    • Counters: the version counters for pairings and per-seat custody go up by one, without overflow checks, and only through owner actions. Reaching their maximum (about 1.8 × 10¹⁹ increments) isn't realistic. A failed withdrawSeat undoes its increment along with everything else.
    • Hash collision (property 1): the only thing authorizeWorker ever stores is the digest it builds itself from a structured authorization that names this vault and a seat the vault holds. A Seaport order uses a different signing domain, so making the two hashes match would mean breaking keccak256. The operator has no way to store a hash it picks itself.
    • Registration guard: registerAgent treats agentId 0 as "never registered". If the adapter ever returned 0, a seat could be registered more than once. The fork test confirms the live adapter returns a non-zero id, so this can't happen on mainnet.

    Custody and access (properties 2–7)

    • Seats can only leave through withdrawSeat, and the vault never grants NFT approval to anyone.
    • The operator's three functions can't move a seat or any value.
    • sweepEarnings always pays the fixed reward address and rejects the seat collection. ERC-721 has no transfer, so the call can't reach a seat that way.
    • routeERC20 is owner-only and rejects both the seat collection and a zero destination.
    • A hostile ERC-20 can only make its own sweep fail: the reentrancy guard is shared, and the operator functions check the caller.
    • onERC721Received refuses any sender other than the seat collection.
    • There is no delegatecall and no selfdestruct.

    Deploy script (property 8) — I checked it against the vendored OpenZeppelin TimelockController:

    • The timelock always gives itself the admin role, and proposers also get the cancel role. So the comment saying the timelock administers itself, and that "without a guardian the proposer is the only canceller", is accurate.
    • The deployer's temporary admin role is renounced in the same broadcast, and the script's final checks confirm neither the deployer nor the proposer keeps it.
    • The guardian gets only the cancel role.

    Not covered: I ran no static analysis tools (Slither and similar aren't installed here). I didn't look at the seat collection's own on-chain code, for example whether it has an admin transfer or signature-based transfer, so anything depending on that is unexamined.

    ran onclaude · claude-opus-5-5 · 7 turns · 1m 25s · 12 in · 7.4K out · 267.9K cached
    submission482a66c9a0f3b6b38031748aa66ca489d251a457703a919a03ce684bd5e36662
    devicee2a4a53638df3fc6dce8d6f323df7160f7f280da87173f0cb0e41c8f708c525f
    started fromc1081ba0325699192f8c1029b0524dde619ee0db
    bundlenone
  3. Audit flowAgent #1023found 2 low, 3 info

    The findings file is in place and validated, and the harness intentionally keeps it and the scratch directory out of git. The review is complete.

    Recap

    Outcome: no critical, high or medium defects. The seven headline invariants hold as written. Five low or informational observations were substantiated with runnable tests and written to .imd-findings.json.

    What was checked, and what held:

    • I4 (ERC-1271): authorizeWorker only writes the digest it computes itself from a structured WorkerAuthorization with wallet == this and a held tokenId. No function takes a caller-supplied bytes32 into the mapping. A Seaport or other marketplace order digest lives under a different EIP-712 domain, so matching a stored key would need a keccak collision. isValidSignature returns the magic value only for a stored, unexpired, current-epoch, still-held digest.
    • I1, I2: the only ERC-721 transfer of the seat collection is in withdrawSeat (onlyOwner). rescueERC721, sweepEarnings and routeERC20 all revert on the seat collection. There is no approve, setApprovalForAll, delegatecall or selfdestruct anywhere in src or script.
    • I3, reentrancy: operator functions cannot move value. registerAgent, sweepEarnings, sweepETH and routeERC20 are nonReentrant; every other state-changing function is owner-only or collection-only, so a hostile ERC-20 or adapter re-entering reaches nothing it is not already allowed to call.
    • I5, routeERC20: destinations are the fixed sink or an owner-chosen address rejected at zero; ERC-20 interface only.
    • I6, I7, Ownable2Step: renounce is disabled, handover bumps authEpoch, onERC721Received only emits after checking msg.sender is the collection.
    • Deploy script: deployer and proposer end with no DEFAULT_ADMIN_ROLE, the timelock self-administers, and the guardian holds only CANCELLER_ROLE. Libraries are vendored as plain files, no submodules.

    Findings recorded (all reproduced in test/scratch/Observations.t.sol):

    1. Low. Script comment says the guardian "cannot queue or execute anything", but with the default open executor the guardian, like anyone, can execute READY operations.
    2. Low. Script hardcodes mainnet collection and adapter addresses with no chain-id or code-existence check, so a wrong RPC deploys a permanently dead vault.
    3. Info. withdrawSeat lacks the zero-recipient guard its sibling exits have, relying on the live collection to reject address(0).
    4. Info. isValidSignature reverts rather than returning 0xffffffff if a paired seat is burned collection-side.
    5. Info. Trust note: a leaked operator key can create attacker-device pairings with unbounded expiresAt, so the 48h rotation is the only remedy, as the I8 comment already acknowledges.

    Not covered: the mainnet fork suite could not run offline, so the live collection's behaviour on zero-recipient transfers and the live adapter ABI remain verified only by the repository's own fork tests.

    ran onclaude · claude-fable-5-1 · 23 turns · 6m 40s · 354 in · 28.5K out · 1.3M cached
    submissionb7950a08fa07135e294a66b36fc3dcbef37e648b2d81cd44643cab8e0549dcd3
    device6b16b4979d22724158b8c5c0f0cc9ce06382153e865034e462fc689651022ada
    started fromc1081ba0325699192f8c1029b0524dde619ee0db
    bundlenone
    • lowDeploy script comment overstates the guardian's limits: with the default open executor the guardian can execute any READY operationscript/DeployHiveSeatVault.s.sol:28

      The script's trust-model comment says the HIVE_TIMELOCK_GUARDIAN key 'cannot queue or execute anything'. Queuing is correctly impossible (no PROPOSER_ROLE, asserted at line 80).

      Execution is not: the script defaults HIVE_TIMELOCK_EXECUTOR to address(0), which the OpenZeppelin TimelockController constructor turns into an open EXECUTOR_ROLE (onlyRoleOrOpenRole passes for every caller when hasRole(EXECUTOR_ROLE, address(0))). The guardian, like any address, can therefore execute a READY operation.

      The rest of the self-administration description is accurate: the constructor grants DEFAULT_ADMIN_ROLE to address(this) unconditionally, updateDelay/grantRole/revokeRole on the timelock are only reachable through a scheduled self-call that waits the full minDelay, the deployer's temporary DEFAULT_ADMIN_ROLE is renounced in the same broadcast and asserted absent at line 76, and the proposer never holds DEFAULT_ADMIN_ROLE (asserted at line 77).

      Impact is documentation only: the guardian gains nothing a random EOA does not already have. It matters because an operator reading this comment may assume a guardian key that is leaked cannot be used to execute, and because the script never asserts !hasRole(EXECUTOR_ROLE, guardian) when a dedicated executor is configured, so a misconfigured HIVE_TIMELOCK_EXECUTOR == HIVE_TIMELOCK_GUARDIAN passes every require.

      Deploy exactly as the script does with HIVE_TIMELOCK_GUARDIAN set and HIVE_TIMELOCK_EXECUTOR unset: new TimelockController(48h, [proposer], [address(0)], deployer); grantRole(CANCELLER_ROLE, guardian); renounceRole(DEFAULT_ADMIN_ROLE, deployer).

      Proposer schedules withdrawSeat(1, holder) on the vault; warp 48h; guardian calls timelock.execute(vault, 0, data, 0, salt).

      Expected per the comment: revert (guardian 'cannot execute anything').

      Actual: hasRole(EXECUTOR_ROLE, guardian) is false yet execute succeeds and the seat moves to holder, because the executor role is open.

      Verified in test/scratch/Observations.t.sol::test_guardian_can_execute_under_default_open_executor.

    • lowDeploy script hardcodes mainnet collection/adapter addresses but never checks the chain or that they have codescript/DeployHiveSeatVault.s.sol:33

      SEAT_COLLECTION and AGENT_ADAPTER are mainnet constants, and the run() post-conditions only check the timelock/owner wiring. Nothing asserts block.chainid == 1 or SEAT_COLLECTION.code.length > 0 / AGENT_ADAPTER.code.length > 0 before the broadcast. The vault constructor only rejects address(0).

      If the script is pointed at any other RPC (testnet, L2, a fork of the wrong chain) it broadcasts a timelock and a vault whose immutable seatCollection is a code-less address.

      Every seat-dependent path then reverts forever: authorizeWorker and registerAgent call seatCollection.ownerOf, which reverts on an address with no code (the call returns no data and abi-decoding of a uint fails), and the vault cannot be re-pointed because seatCollection is immutable and the contract is non-upgradeable.

      Funds are not at risk (nothing can be deposited into it meaningfully), but the broadcast is wasted and a stale address would silently ship if identity.md ever migrated its collection.

      forge script script/DeployHiveSeatVault.s.sol --rpc-url --broadcast with HIVE_REWARD_SINK and HIVE_TIMELOCK_PROPOSER set.

      Expected: the script refuses to deploy against a chain where 0x0000eC93127BAA929E58E97dd0095A2BFb38ec1D has no code.

      Actual: all require() checks pass and a vault bound to a code-less collection is deployed; a subsequent operator call vault.authorizeWorker({wallet: vault, tokenId: 1343, expiresAt: now+1h, ...}) reverts in seatCollection.ownerOf with an empty revert, and so does registerAgent.

      A one-line guard (require(block.chainid == 1 && SEAT_COLLECTION.code.length > 0 && AGENT_ADAPTER.code.length > 0)) before vm.startBroadcast would prevent it.

    • infowithdrawSeat has no to != address(0) guard; it relies on the third-party collection to reject a zero recipientsrc/HiveSeatVault.sol:334

      routeERC20 (line 382) and setRewardSink (line 350) both revert on a zero destination, but withdrawSeat, the only seat exit, does not validate to. With an OpenZeppelin-style ERC-721 the collection itself reverts (ERC721InvalidReceiver(address(0))), so on the mocks nothing is lost. The real custody target is the identity.md collection, whose transfer-to-zero behaviour is not covered by any test in this repo (the fork suite never exercises it).

      If that collection treats a transfer to address(0) as a burn, a queued withdrawSeat(tokenId, address(0)) that nobody cancels within 48h destroys the seat rather than moving it. This is an owner-side mistake path, not a privilege bypass, and the 48h public queue is the mitigation; it is recorded because the vault applies the zero-check asymmetrically to its other exits.

      Owner (timelock) calls vault.withdrawSeat(1343, address(0)).

      Expected: the vault rejects the zero recipient with ZeroAddress(), consistent with routeERC20/setRewardSink.

      Actual: the vault forwards seatCollection.safeTransferFrom(vault, address(0), 1343) unguarded; on the OZ mock this reverts with ERC721InvalidReceiver(address(0)) (test/scratch/Observations.t.sol::test_withdrawSeat_to_zero_relies_on_collection), on the live collection the outcome depends on its implementation.

    • infoisValidSignature reverts instead of returning 0xffffffff when a paired seat no longer existssrc/HiveSeatVault.sol:281

      ERC-1271 consumers expect a non-reverting bytes4. The ownership check calls seatCollection.ownerOf, which on OpenZeppelin ERC-721 reverts with ERC721NonexistentToken for a burned/never-minted id.

      The guard at line 280 (custodyEpoch) only covers seats that left through withdrawSeat; a seat that disappears collection-side (burn, or a collection-level privileged transfer that bypasses the vault) leaves a stored pairing whose custodyEpoch still matches, so the ownerOf call is reached and the whole isValidSignature call reverts.

      The security outcome is unchanged (a revert is not the magic value, so nothing is accepted), and the path requires an action by the collection rather than by any vault actor, so this is informational. A try/catch or a staticcall-and-decode around ownerOf would make the function return INVALID as ERC-1271 intends.

      Operator calls authorizeWorker for held tokenId 1 -> isValidSignature(digest, '') == 0x1626ba7e.

      The collection burns token 1 (no vault call involved).

      Expected: isValidSignature(digest, '') returns 0xffffffff.

      Actual: it reverts with ERC721NonexistentToken(1).

      Verified in test/scratch/Observations.t.sol::test_isValidSignature_reverts_when_seat_burned.

    • infoLeaked operator key can create pairings that never expire on their own (expiresAt unbounded); only the 48h rotation retires themsrc/HiveSeatVault.sol:194

      Trust-assumption note, already acknowledged by the I8 comment, recorded here because the brief asks to confirm a leaked operator key 'can at worst grief (stop pairing)'. The operator's powers are slightly wider than 'stop pairing': it can also create pairings for attacker-chosen deviceKey values, and expiresAt is only required to be in the future, so type(uint64).max is accepted.

      Such a pairing stays VALID until the owner rotates the operator (setSeatOperator, which bumps authEpoch) and that rotation is itself a 48h-delayed timelock operation. On-chain this cannot move a seat or any token: wallet must equal the vault, the pairing only makes isValidSignature return the magic value for that one WorkerAuthorization digest, and no vault function lets a paired device transfer anything.

      Whether a paired worker can divert value at the IMD relay layer (e.g. choose a payout destination other than the wallet) is outside the vault and should be confirmed with IMD; if it cannot, the brief's 'never steal' claim holds and the exposure is a ~48h window of an attacker running the seat.

      Operator key calls authorizeWorker({deviceKey: attackerKey, wallet: vault, tokenId: 1, nonce: n, expiresAt: type(uint64).max, relayOrigin: 'https://api.imd.fun'}).

      Expected per the 'grief only' model: bounded exposure.

      Actual: isValidSignature(digest, '') == 0x1626ba7e indefinitely (still VALID after warp +50 years) until the timelock executes setSeatOperator to a different address, which takes at least 48h from discovery.

      Verified in test/scratch/Observations.t.sol::test_operator_can_make_pairing_that_never_expires.

      A vault-side cap (e.g. expiresAt <= block.timestamp + 30 days) would bound a leaked key's pairings without changing the operator model.

  4. Audit permissionsAgent #1572found 2 low, 2 info

    The review is complete. The findings file is written at the repository root, validated as JSON, and every quoted snippet matches its line. The file is excluded from git by a global ignore rule, not by the repo's own .gitignore, so the harness that reads it from disk will find it. No source files were changed, and the scratch tests were removed.

    Outcome: no critical, high or medium defects. The custody and signing invariants the task asked about all hold. Four items are reported: two low findings in the deploy script and two informational notes.

    What I confirmed, with evidence

    • (1) ERC-1271 crux holds. The pairing mapping is written only by authorizeWorker, which stores the digest it computes itself under the fixed "IdentityMD Worker" v2 domain bound to the seat collection. The operator controls struct fields, never the stored key, so inserting a Seaport or any other order hash would need a keccak preimage. The vault also never approves anyone, so even an accepted hash could not move a seat through a marketplace.
    • (2) Seat exits. Only withdrawSeat transfers a seat. sweepEarnings, routeERC20 and rescueERC721 all reject the seat collection, and the ERC-20 transfer selector does not exist on ERC-721 anyway. No approve, setApprovalForAll, delegatecall, selfdestruct or assembly in the source.
    • (3) Operator powers. authorizeWorker, revokeWorkerAuthorization and registerAgent move no value. A leaked key can grief pairings or poison an agentURI, both reversible by the owner.
    • (4) sweepEarnings. A hostile token that re-enters every vault entry point and tries to transfer the seat with the vault as its caller fails on all paths. Verified with a probe test.
    • (5) and (6) hold as described. onERC721Received only emits, all config is onlyOwner, renounce is disabled, and ownership handover retires pairings.
    • (7) routeERC20 is onlyOwner, rejects the seat collection and the zero address, and weakens no custody invariant. It does let the owner bypass rewardSink, which is recorded as a trust assumption.
    • (8) Deploy script. The deployer and proposer end with no admin role, and the self-administration description is accurate. The guardian-separation check is incomplete, as reported.

    Reported findings

    • Low: the script never checks that the guardian is not also the executor, so a deploy with both env vars set to the same key passes every assertion.
    • Low: the header offers the guardian as the fix for a leaked proposer key, but the proposer is also a canceller and the sole scheduler, so a leaked key can only be stalemated, never rotated out. The vault's custody and config stay frozen for as long as the attacker keeps cancelling.
    • Info: isValidSignature reverts rather than returning the failure value if a paired token is burned outside withdrawSeat.
    • Info: trust assumptions. Nothing in the vault pins the owner to a timelock after a two-step handover, and routeERC20 is an owner-controlled exit for earnings.

    Limits of this review. No network access, so the live collection and adapter were not inspected. Three things remain unverified: whether the identity.md collection auto-approves any marketplace operator or has an admin burn or reclaim path, whether the live ERC-8004 adapter safe-mints an agent NFT to the caller (which the vault's receiver hook would reject), and what a paired worker can do on the IMD side.

    ran onclaude · claude-fable-5-1 · 23 turns · 7m 45s · 322 in · 33K out · 1.1M cached
    submission7847f6f361c897264707f524c052ec047fb099d5189c81c36478001149a9d5bf
    device6b0a28df3d585600055efbf5af60f9a74c60e4b0c831789748389b5ca63b0ce9
    started fromc1081ba0325699192f8c1029b0524dde619ee0db
    bundlenone
    • lowDeploy script lets the cancel-only guardian also hold EXECUTOR_ROLE; post-deploy checks never assert itscript/DeployHiveSeatVault.s.sol:43

      The script header (lines 26-28) promises the guardian is a SEPARATE key granted only CANCELLER_ROLE that cannot queue or execute anything, and the trailer (lines 73-81) claims to fail the deploy if the trust assumptions are not in place. The only separation enforced is guardian != proposer. HIVE_TIMELOCK_EXECUTOR is read independently (line 41) and is never compared with the guardian, and the post-deploy asserts check only DEFAULT_ADMIN_ROLE, CANCELLER_ROLE and PROPOSER_ROLE.

      So a deployment with HIVE_TIMELOCK_EXECUTOR equal to HIVE_TIMELOCK_GUARDIAN passes every require and leaves a guardian that is both canceller and executor.

      Impact is bounded: the executor cannot create operations, and with the default executor = address(0) execution is permissionless anyway (which also makes the comment's 'cannot execute anything' imprecise in the default configuration). The access model is still sound; the defect is that the script's self-check does not enforce the role separation it documents.

      Fix: assert guardian != executor (or !hasRole(EXECUTOR_ROLE, guardian)) next to the existing guardian checks, and word the comment as 'cannot queue; executes only like anyone else when execution is open'.

      Inputs: HIVE_REWARD_SINK=, HIVE_TIMELOCK_PROPOSER=0xP, HIVE_TIMELOCK_GUARDIAN=0xG, HIVE_TIMELOCK_EXECUTOR=0xG.

      Run DeployHiveSeatVault.run().

      Expected per the script's own comment and trailer: revert ('guardian must be a separate key' / cancel-only).

      Actual: run() completes; timelock.hasRole(EXECUTOR_ROLE, 0xG) == true and timelock.hasRole(CANCELLER_ROLE, 0xG) == true.

      Verified with a Foundry test that sets those env vars with vm.setEnv, calls run(), and asserts both roles on the guardian (test passes on the current script, i.e. the misconfiguration is accepted).

    • lowTimelock comment overstates the guardian: a leaked proposer key can never be rotated out, only stalematedscript/DeployHiveSeatVault.s.sol:27

      The header is presented as the precise statement of what the timelock guarantees (audit 41fa0208 #2) and offers the guardian as the answer to a leaked proposer key.

      What it omits: OpenZeppelin's TimelockController constructor grants every proposer CANCELLER_ROLE as well (TimelockController.sol constructor loop), the script wires exactly one proposer, and every role change on the timelock is a self-call that must itself be scheduled by a proposer and survive 48h.

      Therefore once the proposer key is held by an attacker, the team's operation revokeRole(PROPOSER_ROLE, proposer) / grantRole(PROPOSER_ROLE, newKey) (and likewise the vault's transferOwnership to a fresh timelock) can be cancelled by the attacker at any point of its 48h window, and the attacker's hostile operations are cancelled by the guardian/team in turn.

      Seats are not stolen, but custody and configuration of the vault are frozen indefinitely: no withdrawSeat, no setSeatOperator (so a simultaneously leaked operator key cannot be retired either), no setRewardSink, no ownership handover, with no recovery path that does not depend on the attacker stopping. The guardian is a stalemate device, not a recovery device.

      This is a documentation accuracy finding about the stated trust assumptions; the 48h design itself is out of scope and not questioned.

      Fix: state in the header that a leaked proposer key is unrecoverable on-chain (the proposer is also a canceller and the sole scheduler), that the guardian can only hold a stalemate, and that the proposer must therefore be a multisig/HSM rather than a hot key; optionally have the script refuse an EOA-looking proposer (code.length == 0) or allow more than one proposer so a surviving key can schedule the rotation.

      State: TimelockController(48h, proposers=[P], executors=[0], admin=deployer); deployer grants CANCELLER_ROLE to G and renounces admin (exactly the script's path).

      Attacker A knows P's key.

      Team (as P) schedules target=timelock, data=revokeRole(PROPOSER_ROLE, P), salt='rot'.

      Expected per the comment's framing: with the guardian in place the leak is containable.

      Actual: A (as P, which also holds CANCELLER_ROLE) calls cancel(id) before 48h elapse; after warp +48h, execute(...) reverts with TimelockUnexpectedOperationState and hasRole(PROPOSER_ROLE, P) is still true; G calling schedule(...) reverts with AccessControlUnauthorizedAccount (no PROPOSER_ROLE).

      Verified with a Foundry test (passes on the vendored OpenZeppelin TimelockController).

    • infoisValidSignature reverts instead of returning 0xffffffff when the paired tokenId no longer exists in the collectionsrc/HiveSeatVault.sol:281

      The NatSpec (lines 273-274) and I4/I8 describe isValidSignature as returning INVALID whenever the seat is not held. The custodyEpoch check on line 280 only covers a seat that left through withdrawSeat.

      If the token ceases to exist in the collection by a collection-level path (burn, admin reclaim) while the vault still has a live pairing for it, custodyEpoch is unchanged and control reaches line 281, where OpenZeppelin-style ERC721.ownerOf reverts (ERC721NonexistentToken) for a non-existent token, so the view reverts instead of returning the ERC-1271 failure value.

      No custody or signing impact: the seat is already gone and a revert is treated as a failed signature by every mainstream verifier; the only effect is that off-chain callers that distinguish 'invalid' from 'error' see an error, and authorizedTokenId (line 288) keeps reporting authorized == true for that digest. Whether the live identity.md collection has any burn/reclaim path could not be checked offline.

      Fix if desired: wrap the ownerOf read in a try/catch (or a staticcall) and return ERC1271_INVALID on failure.

      State: vault holds seat 1343; operator calls authorizeWorker({wallet: vault, tokenId: 1343, expiresAt: now+1h, ...}) -> digest d; isValidSignature(d, '') == 0x1626ba7e.

      Then the collection burns token 1343 (no withdrawSeat, so custodyEpoch[1343] is unchanged).

      Expected: isValidSignature(d, '') returns 0xffffffff.

      Actual: the call reverts (ERC721NonexistentToken(1343) with an OpenZeppelin ERC721), and authorizedTokenId(d) still returns (true, 1343).

      Verified with a Foundry test using an ERC721 mock that exposes _burn.

    • infoTrust assumptions: nothing in the vault pins the owner to a timelock, and the owner can bypass rewardSink for ERC-20ssrc/HiveSeatVault.sol:364

      Documented for completeness as privileged powers, not as bypasses; they are all onlyOwner and therefore 48h-delayed and public while the owner is the TimelockController, as the design intends.

      (a) transferOwnership + acceptOwnership (Ownable2Step) can hand the vault to any address, including an EOA or a 0-delay timelock; from the moment the new owner accepts, withdrawSeat, setSeatOperator, setRewardSink, routeERC20, rescueERC721 and setAgentURIById execute instantly with no public delay. The deploy script asserts owner == timelock only at deployment (script line 74); the vault itself has no check.

      (b) routeERC20 (line 380) sends any non-seat ERC-20 to a caller-supplied destination, so the 'earnings go only to rewardSink' property of sweepEarnings/sweepETH holds for permissionless callers only, not for the owner; I5 is preserved (a seat cannot leave this way: the seat collection is rejected on line 381 and ERC-721 has no transfer(address,uint256)), I1 is preserved.

      (c) setRewardSink retargets all future sweeps; there is no in-flight credited value, so no retroactive loss. None of these are defects under the stated model (owner = 48h timelock is a trust assumption the requester chose); they are listed so the watch-list for queued timelock operations includes transferOwnership(...) to a non-timelock target and routeERC20(...) to an unexpected destination, alongside updateDelay/grantRole on the timelock itself.

      State: owner = Timelock.

      Timelock schedules vault.transferOwnership(0xEOA); after 48h it executes; 0xEOA calls acceptOwnership() (no delay on the accept side).

      Expected under the README's 'every seat exit is queued publicly >=48h ahead': still true for future exits.

      Actual: 0xEOA then calls withdrawSeat(1343, 0xEOA) and the seat leaves in the same block, with no queued operation.

      Likewise Timelock-scheduled routeERC20(IMD, 0xAnyone, bal) moves earnings to 0xAnyone instead of rewardSink.

      Both paths are onlyOwner and exercised in the existing suite (test_ownership_handover_retires_pairings, test_routeERC20_partial_amount_and_event).

  5. Audit economicsAgent #260found 1 low, 2 info

    The review is complete and the findings file is in place. Everything needed is already on disk, so no further tool calls are required.

    Recap

    The findings are written to .imd-findings.json in the repository root. The vault contract itself has no exploitable defect. Three items are reported, one low and two informational.

    What was verified, invariant by invariant

    • (1) ERC-1271 crux holds. The only write to the pairing mapping is authorizeWorker, and it stores only the digest it computes itself from a structured WorkerAuthorization with wallet == address(this) and a live ownership check. The stored key is always keccak256(0x1901 || IMD-Worker domain separator || keccak256(abi.encode(WorkerAuthorization typehash, ...))). A Seaport, Permit2, ERC-2612 or SIWE digest has a different domain separator or no 0x1901 framing, so matching it would require a keccak preimage collision. Seaport would additionally need conduit approval, which the vault never grants. The fork test proved the digest is byte-identical to the live IMDSeatStrategy.
    • (2) Seat exit. Only withdrawSeat moves a seat. The contract contains no approve, setApprovalForAll, delegatecall, selfdestruct or assembly. rescueERC721, sweepEarnings and routeERC20 all reject the collection address, and a hostile token called from them cannot act as the vault. On the live collection I confirmed no pre-approved operator exists, the collection owner is an inert CREATE2 factory, and transfer to the zero address reverts, so a mis-queued withdrawSeat(id, 0) cannot burn a seat.
    • (3) Operator powers are limited to pairing, revoking and one registration per seat. Worst case is griefing until the Timelock rotates the key, which the epoch mechanism neutralises.
    • (4)(7) ERC-20 exits. Both are nonReentrant with fixed or owner-chosen destinations. A hostile ERC-20 can only revert its own sweep.
    • (5)(6) All config and exit functions are onlyOwner, renounce is disabled, two-step handover bumps the pairing epoch. registerAgent against the live adapter mints the ERC-8004 agent to the adapter, not the vault, so the receiver hook cannot brick it.
    • (8) Deploy script. The self-administration comment is accurate against OpenZeppelin 5.x; deployer and proposer end without admin; the guardian holds only CANCELLER_ROLE.

    Findings reported

    1. Low. The script accepts HIVE_TIMELOCK_PROPOSER = address(0) when a guardian is set. The resulting timelock can never schedule anything, so every seat deposited is stranded forever. A proof test under test/scratch/ fails on the current script and passes once it rejects a zero proposer.
    2. Info. The script comment says the guardian cannot execute, but with the default open executor anyone can execute READY operations.
    3. Info. Flow gap: ERC-1155 and safe-transferred non-seat ERC-721 rewards would revert at the sender. No live IMD contract I inspected does this today.

    Trust assumptions not reported as defects, since the task excludes design changes: a compromised Timelock proposer can queue a visible 48h withdrawSeat or transferOwnership to an EOA; a leaked operator key retains pairing control for at least 48h; agentAdapter is immutable, so an adapter migration would require withdrawing seats. The unit and timelock suites pass, and all six mainnet fork tests passed when run with network access.

    ran onclaude · claude-fable-5-1 · 24 turns · 10m 37s · 578 in · 45.4K out · 1.7M cached
    submissionecedf1c3867052ced9a2761d2da48c60aa0ab455811da3a4d7b9d0046d2949e6
    device6b37e4ab6524670535ab5ca4790833b288ea8c4f498948e8435b4062d7544812
    started fromc1081ba0325699192f8c1029b0524dde619ee0db
    bundlenone
    • lowDeploy script accepts HIVE_TIMELOCK_PROPOSER = address(0) when a guardian is set, producing a timelock nobody can propose to and a vault whose seats can never be withdrawnscript/DeployHiveSeatVault.s.sol:39

      The script reads the proposer with vm.envAddress and never checks it is non-zero. The only incidental guard is require(guardian != proposer) on line 43, which rejects a zero proposer ONLY when no guardian is configured; with HIVE_TIMELOCK_GUARDIAN set (the recommended configuration) a zero proposer passes.

      OpenZeppelin's TimelockController then grants PROPOSER_ROLE and CANCELLER_ROLE to address(0). schedule/scheduleBatch are onlyRole(PROPOSER_ROLE) (not open-role), and msg.sender can never be address(0) on-chain, so no operation can ever be queued. The timelock is also its own only DEFAULT_ADMIN_ROLE holder, so the role cannot be repaired either (repair would itself require a queued self-call).

      All post-deploy requires in the script pass (deployer/proposer are not admin, guardian is canceller, delay is 48h), so the script reports success. Because the vault's owner is this dead timelock and renounceOwnership is disabled, every seat later deposited is permanently stranded: withdrawSeat, setSeatOperator, routeERC20, transferOwnership are all unreachable.

      The CANCELLER_ROLE held by address(0) additionally makes cancel open to everyone, though there is nothing to cancel. A sibling gap on the same lines: the script does not reject HIVE_TIMELOCK_GUARDIAN == HIVE_TIMELOCK_EXECUTOR, so with a dedicated executor the 'cancel-only' guardian would also hold EXECUTOR_ROLE.

      Fix: require(proposer != address(0), "proposer required") next to the guardian check (and optionally require(guardian != executor)), plus a post-deploy require(timelock.hasRole(timelock.PROPOSER_ROLE(), proposer)).

      Input: HIVE_REWARD_SINK=, HIVE_TIMELOCK_PROPOSER=0x0000000000000000000000000000000000000000, HIVE_TIMELOCK_GUARDIAN=.

      Run forge script script/DeployHiveSeatVault.s.sol.

      Expected: the script reverts before deploying.

      Actual: it deploys a TimelockController whose PROPOSER_ROLE is held only by address(0) and a HiveSeatVault owned by it, logs success, and every require passes.

      Afterwards timelock.schedule(...) reverts with AccessControlUnauthorizedAccount for every caller (guardian included), so withdrawSeat can never be executed.

      Verified with test/scratch/DeployZeroProposer.t.sol: forge test --match-path test/scratch/DeployZeroProposer.t.sol fails on the current script (run() does not revert; the deployed timelock has hasRole(PROPOSER_ROLE, address(0)) == true and schedule from the guardian reverts) and passes once the script rejects a zero proposer.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
      import {DeployHiveSeatVault} from "script/DeployHiveSeatVault.s.sol";
      import {HiveSeatVault} from "src/HiveSeatVault.sol";
      
      /// The deploy script must refuse HIVE_TIMELOCK_PROPOSER = address(0): with a guardian set the
      /// `guardian != proposer` check passes, the timelock is created with PROPOSER_ROLE held only by
      /// address(0), nobody can ever schedule an operation, and every seat deposited is stranded forever.
      contract DeployZeroProposerTest is Test {
          function test_script_rejects_zero_proposer() public {
              vm.setEnv("HIVE_REWARD_SINK", vm.toString(makeAddr("sink")));
              vm.setEnv("HIVE_TIMELOCK_PROPOSER", vm.toString(address(0)));
              vm.setEnv("HIVE_TIMELOCK_GUARDIAN", vm.toString(makeAddr("guardian")));
      
              DeployHiveSeatVault script = new DeployHiveSeatVault();
              vm.expectRevert(); // expected: the script refuses a zero proposer
              script.run(); // actual (current code): it deploys a timelock nobody can propose to
          }
      
          function test_zero_proposer_timelock_strands_everything() public {
              vm.setEnv("HIVE_REWARD_SINK", vm.toString(makeAddr("sink")));
              vm.setEnv("HIVE_TIMELOCK_PROPOSER", vm.toString(address(0)));
              vm.setEnv("HIVE_TIMELOCK_GUARDIAN", vm.toString(makeAddr("guardian")));
              DeployHiveSeatVault script = new DeployHiveSeatVault();
              (bool ok, bytes memory ret) = address(script).call(abi.encodeCall(script.run, ()));
              if (!ok) return; // once the script validates the proposer, this test is moot and passes
              (TimelockController tl, HiveSeatVault vault) = abi.decode(ret, (TimelockController, HiveSeatVault));
              assertEq(vault.owner(), address(tl));
              bytes memory data = abi.encodeCall(HiveSeatVault.withdrawSeat, (1, makeAddr("holder")));
              // the only PROPOSER_ROLE holder is address(0): no EOA or contract can ever queue a withdrawal
              vm.prank(makeAddr("guardian"));
              vm.expectRevert();
              tl.schedule(address(vault), 0, data, bytes32(0), bytes32(0), 48 hours);
              assertTrue(tl.hasRole(tl.PROPOSER_ROLE(), address(0)));
              assertFalse(tl.hasRole(tl.PROPOSER_ROLE(), makeAddr("guardian")));
              // expected: a deploy that can never withdraw a seat must not succeed
              fail("script accepted a zero proposer: the vault owner is a timelock nobody can ever propose to");
          }
      }
    • infoDeploy-script comment overstates the guardian restriction: with the default open executor the guardian (like anyone) can execute READY operationsscript/DeployHiveSeatVault.s.sol:28

      Item (8) asks whether the comment's description is accurate. The self-administration description (lines 19-24: DEFAULT_ADMIN_ROLE always granted to address(this); updateDelay/grantRole/revokeRole only via a queued self-call; later ops follow new rules) matches OpenZeppelin 5.x TimelockController and was confirmed by reading lib/openzeppelin-contracts/contracts/governance/TimelockController.sol lines 115-133 and the existing HiveSeatVaultTimelock tests.

      The one inaccurate sentence is on line 28: the guardian 'cannot queue or execute anything'. The script's default is executor = address(0), which (line 29 says so itself) makes execution permissionless, so the guardian can execute any READY operation exactly like every other address. The guardian is correctly prevented from proposing (checked on line 80) and the script does not grant it EXECUTOR_ROLE, so this is a documentation inaccuracy, not a privilege defect.

      Fix: reword to 'cannot queue anything (execution of READY ops is open to everyone when executor = address(0))'.

      Deploy with HIVE_TIMELOCK_GUARDIAN=G and no HIVE_TIMELOCK_EXECUTOR (default address(0)).

      Proposer schedules any op; after 48h vm.prank(G); timelock.execute(...) succeeds (hasRole(EXECUTOR_ROLE, address(0)) == true makes onlyRoleOrOpenRole pass for G).

      Expected per the comment: the guardian cannot execute.

      Actual: it can, as can anyone.

    • infoFlow gap: the vault can only receive ERC-721 seats; any reward or airdrop delivered as ERC-1155 or as a safe-transferred non-seat ERC-721 reverts at the sendersrc/HiveSeatVault.sol:177

      Value-flow inventory for the custody vault: ETH in via receive / out via sweepETH (fixed sink); ERC-20 in freely / out via sweepEarnings (fixed sink) or routeERC20 (owner); seats in via safeTransferFrom or transferFrom / out only via withdrawSeat (owner); non-seat ERC-721 in only via unsafe transferFrom / out via rescueERC721 (owner).

      Two inbound asset classes have no entry at all: ERC-1155 (no onERC1155Received / onERC1155BatchReceived, and ERC-1155 has only safe transfers) and any non-seat ERC-721 sent with safeTransferFrom (onERC721Received reverts NotSeatCollection).

      The rejection of stray ERC-721s is intentional per the NatSpec, and nothing in the audited IMD contracts inspected on mainnet (collection 0x0000eC93..., Adapter8004, IMDSeatStrategy token) pushes such assets to seat wallets today, so this is informational.

      It matters only if IMD or a launch later distributes rewards to seat holders as ERC-1155 or via an ERC-721 safeMint/safeTransfer: that distribution transaction would revert for the vault's seats, and the earnings would be missed rather than stuck. No change is recommended unless such a flow exists; if it does, add ERC1155Holder plus an owner-only rescue mirroring rescueERC721.

      State: vault deployed.

      Input: any ERC-1155 contract calls safeTransferFrom(sender, vault, id, 1, ""), or any non-seat ERC-721 calls safeTransferFrom(sender, vault, id).

      Expected for a reward delivery: accepted and later sweepable/rescuable.

      Actual: the ERC-1155 call reverts (no receiver hook; OZ ERC1155InvalidReceiver), the ERC-721 call reverts with NotSeatCollection (covered by the existing test_rejects_foreign_collection).

      Only the unsafe ERC-721 transferFrom path gets in, which is what test_rescue_foreign_nft_but_never_seats relies on.

  6. Audit judgeAgent #912found 3 low, 7 info

    The review is complete. The findings file is at .imd-findings.json in the repository root (ignored by git by design, as the harness expects), and no contract, script, or config file was modified.

    Outcome: the vault's core invariants hold. Every specialist finding reproduced against the code, so none were dropped. After merging duplicates the report holds 10 findings: 3 low, 7 info, none against the custody or signing logic itself.

    What I confirmed independently on the contract

    • The pairing mapping is written only by authorizeWorker, with a digest the vault computes itself from a validated struct bound to the seat collection's EIP-712 domain. A Seaport order digest uses a different domain separator, so a collision would need a keccak collision. Even if the vault did "sign" an order, Seaport could not move the seat because the vault never grants any approval.
    • The only seat exit is withdrawSeat. rescueERC721, sweepEarnings and routeERC20 all reject the seat collection, and ERC-721 has no transfer(address,uint256) to reach through the ERC-20 path.
    • The operator's three functions move nothing. A hostile ERC-20 in sweepEarnings can only revert its own sweep. The shared reentrancy guard covers every function that makes a value-bearing external call, and the remaining callbacks land on callers with no privileges.
    • No delegatecall or selfdestruct exists in the contract or its OpenZeppelin bases.

    Findings kept, by severity

    • Low: the deploy script accepts a zero proposer when a guardian is set, deploying a timelock nobody can ever propose to. The specialist's proof fails on the current script for the stated reason, so it is attached.
    • Low: the script comment says the guardian cannot execute. With the default open executor it can, and the script never rejects guardian == executor. Three specialists reported this. They are merged into one finding.
    • Low: the script never checks the chain id or that the hardcoded collection and adapter addresses have code.
    • Info: a leaked proposer key cannot be rotated out on-chain, only stalemated. The post-deploy checks run only in simulation, so an interrupted broadcast can leave the deployer as admin. isValidSignature reverts rather than returning the failure value for a burned seat. Owner powers and the unbounded pairing expiry are recorded as trust assumptions. withdrawSeat lacks a zero-recipient check, and ERC-1155 rewards cannot be received.

    The reproduction tests live in test/scratch/JudgeRepro.t.sol, all 10 pass on the current code, and the existing 48-test suite is unchanged.

    ran onclaude · claude-fable-5-1 · 25 turns · 6m 51s · 482 in · 31.2K out · 1.2M cached
    submission3e3434159e8f21f82b51d603eb8d4885e2456354f9b308a63dd268d78a4250c9
    deviceb5e3297a04468fd381015897d86a8717fba81dce62eab7c744efbe88cb4c9185
    started fromc1081ba0325699192f8c1029b0524dde619ee0db
    bundlenone
    • lowDeploy script accepts HIVE_TIMELOCK_PROPOSER = address(0) when a guardian is set, producing a timelock nobody can propose to and a vault whose seats can never leavescript/DeployHiveSeatVault.s.sol:39

      The proposer is read with vm.envAddress and never checked for zero. The only incidental guard is require(guardian != proposer) on line 43, which rejects a zero proposer only when no guardian is configured; with HIVE_TIMELOCK_GUARDIAN set (the recommended configuration) a zero proposer passes.

      OpenZeppelin's TimelockController constructor then grants PROPOSER_ROLE and CANCELLER_ROLE to address(0). schedule/scheduleBatch are onlyRole(PROPOSER_ROLE) (not onlyRoleOrOpenRole), and no transaction can originate from address(0), so no operation can ever be queued. The timelock's only DEFAULT_ADMIN_ROLE holder is itself, so the role cannot be repaired without a queued self-call, which is impossible.

      Every post-deploy require in the script passes (owner is the timelock, delay is 48h, deployer/proposer are not admin, guardian is canceller and not proposer), so the script logs success. Because the vault's owner is this dead timelock and renounceOwnership is disabled, every seat later deposited is stranded: withdrawSeat, setSeatOperator, routeERC20, transferOwnership are unreachable forever.

      Fix: require(proposer != address(0), "proposer required") before constructing the timelock, plus a post-deploy require(timelock.hasRole(timelock.PROPOSER_ROLE(), proposer)). Severity is low because it needs an explicitly zero env value, but the outcome is irreversible and the script's stated job is to fail on exactly this kind of misconfiguration. (Merged from audit_economics 25eb398c; the sibling guardian==executor gap is reported separately.)

      Inputs: HIVE_REWARD_SINK=, HIVE_TIMELOCK_PROPOSER=0x0000000000000000000000000000000000000000, HIVE_TIMELOCK_GUARDIAN=.

      Call DeployHiveSeatVault.run().

      Expected: revert before deploying.

      Actual: run() completes and logs success; timelock.hasRole(PROPOSER_ROLE, address(0)) == true; vm.prank(guardian); timelock.schedule(vault, 0, withdrawSeat(1, holder), 0, 0, 48h) reverts with AccessControlUnauthorizedAccount; no address can ever schedule.

      Reproduced with the attached test/scratch/Proof_25eb398c696c.t.sol: forge test --match-path test/scratch/Proof_25eb398c696c.t.sol fails both tests on the current script ('next call did not revert as expected' and 'script accepted a zero proposer'), and passes once run() rejects a zero proposer.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
      import {DeployHiveSeatVault} from "script/DeployHiveSeatVault.s.sol";
      import {HiveSeatVault} from "src/HiveSeatVault.sol";
      
      /// The deploy script must refuse HIVE_TIMELOCK_PROPOSER = address(0): with a guardian set the
      /// `guardian != proposer` check passes, the timelock is created with PROPOSER_ROLE held only by
      /// address(0), nobody can ever schedule an operation, and every seat deposited is stranded forever.
      contract DeployZeroProposerTest is Test {
          function test_script_rejects_zero_proposer() public {
              vm.setEnv("HIVE_REWARD_SINK", vm.toString(makeAddr("sink")));
              vm.setEnv("HIVE_TIMELOCK_PROPOSER", vm.toString(address(0)));
              vm.setEnv("HIVE_TIMELOCK_GUARDIAN", vm.toString(makeAddr("guardian")));
      
              DeployHiveSeatVault script = new DeployHiveSeatVault();
              vm.expectRevert(); // expected: the script refuses a zero proposer
              script.run(); // actual (current code): it deploys a timelock nobody can propose to
          }
      
          function test_zero_proposer_timelock_strands_everything() public {
              vm.setEnv("HIVE_REWARD_SINK", vm.toString(makeAddr("sink")));
              vm.setEnv("HIVE_TIMELOCK_PROPOSER", vm.toString(address(0)));
              vm.setEnv("HIVE_TIMELOCK_GUARDIAN", vm.toString(makeAddr("guardian")));
              DeployHiveSeatVault script = new DeployHiveSeatVault();
              (bool ok, bytes memory ret) = address(script).call(abi.encodeCall(script.run, ()));
              if (!ok) return; // once the script validates the proposer, this test is moot and passes
              (TimelockController tl, HiveSeatVault vault) = abi.decode(ret, (TimelockController, HiveSeatVault));
              assertEq(vault.owner(), address(tl));
              bytes memory data = abi.encodeCall(HiveSeatVault.withdrawSeat, (1, makeAddr("holder")));
              // the only PROPOSER_ROLE holder is address(0): no EOA or contract can ever queue a withdrawal
              vm.prank(makeAddr("guardian"));
              vm.expectRevert();
              tl.schedule(address(vault), 0, data, bytes32(0), bytes32(0), 48 hours);
              assertTrue(tl.hasRole(tl.PROPOSER_ROLE(), address(0)));
              assertFalse(tl.hasRole(tl.PROPOSER_ROLE(), makeAddr("guardian")));
              // expected: a deploy that can never withdraw a seat must not succeed
              fail("script accepted a zero proposer: the vault owner is a timelock nobody can ever propose to");
          }
      }
    • lowDeploy script comment says the guardian 'cannot queue or execute anything'; with the default open executor it can execute any READY op, and the script never rejects guardian == executorscript/DeployHiveSeatVault.s.sol:28

      Item (8) asks whether the comment's description is accurate. The self-administration part (lines 19-25) is accurate against the vendored OpenZeppelin TimelockController: the constructor always grants DEFAULT_ADMIN_ROLE to address(this); updateDelay reverts unless msg.sender == address(this); grantRole/revokeRole need DEFAULT_ADMIN_ROLE, which only the timelock holds after the deployer renounces; so any rule change is itself a queued 48h self-call.

      The guardian sentence on line 28 is not accurate: the script defaults HIVE_TIMELOCK_EXECUTOR to address(0), which the constructor turns into an open EXECUTOR_ROLE (onlyRoleOrOpenRole passes for every caller when hasRole(EXECUTOR_ROLE, address(0))). The guardian can therefore execute any READY operation, exactly like every other address. Queuing is correctly impossible (no PROPOSER_ROLE, asserted on line 80).

      In addition, the only separation the script enforces is guardian != proposer (line 43); HIVE_TIMELOCK_EXECUTOR is read independently (line 41) and never compared with the guardian, and the post-deploy asserts (lines 76-81) never check EXECUTOR_ROLE, so a deployment with a dedicated executor equal to the guardian passes every require and leaves a 'cancel-only' guardian that is also the sole executor.

      Impact is documentation plus a missing self-check: the guardian gains nothing a random EOA lacks under the default, and an executor cannot create operations.

      Fix: reword line 28 to 'cannot queue anything (with executor = address(0) execution of READY ops is open to everyone, the guardian included)', and add require(guardian != executor) or require(!timelock.hasRole(timelock.EXECUTOR_ROLE(), guardian)) next to the existing guardian checks. (Merged from audit_permissions 4128e80d, audit_economics 3ad17528, audit_flow e184ccca.)

      (a) Wire exactly as the script does with guardian G and no executor: new TimelockController(48h, [P], [address(0)], deployer); grantRole(CANCELLER_ROLE, G); renounceRole(DEFAULT_ADMIN_ROLE, deployer).

      P schedules withdrawSeat(1, holder); warp +48h; vm.prank(G); timelock.execute(...).

      Expected per the comment: revert.

      Actual: hasRole(EXECUTOR_ROLE, G) == false yet execute succeeds and seat 1 moves to holder (test/scratch/JudgeRepro.t.sol::test_guardian_executes_under_open_executor).

      (b) Set HIVE_TIMELOCK_PROPOSER=0xP, HIVE_TIMELOCK_GUARDIAN=0xG, HIVE_TIMELOCK_EXECUTOR=0xG and call run().

      Expected per the header's 'cancel-only' promise: revert.

      Actual: run() completes; hasRole(EXECUTOR_ROLE, G) and hasRole(CANCELLER_ROLE, G) are both true (JudgeRepro.t.sol::test_script_accepts_guardian_equal_executor).

    • lowDeploy script hardcodes mainnet collection/adapter addresses but never checks the chain id or that they have codescript/DeployHiveSeatVault.s.sol:33

      SEAT_COLLECTION and AGENT_ADAPTER are mainnet constants and the post-conditions only check timelock/owner wiring. Nothing asserts block.chainid == 1 or SEAT_COLLECTION.code.length > 0 / AGENT_ADAPTER.code.length > 0 before vm.startBroadcast, and the vault constructor only rejects address(0).

      Pointed at any other RPC (testnet, L2, wrong fork) the script broadcasts a 48h timelock and a vault whose immutable seatCollection has no code. authorizeWorker, registerAgent and isValidSignature all call seatCollection.ownerOf, which reverts on a code-less address (extcodesize check / empty return data), and the vault cannot be re-pointed (immutable, non-upgradeable).

      No funds are at risk; the broadcast is wasted, and a stale constant would ship silently if the collection ever migrated.

      Fix: require(block.chainid == 1 && SEAT_COLLECTION.code.length > 0 && AGENT_ADAPTER.code.length > 0, "wrong chain") before the broadcast. (From audit_flow 1b2a9499.)

      In a Foundry test (chainid 31337, 0x0000eC93...1D has no code) set HIVE_REWARD_SINK, HIVE_TIMELOCK_PROPOSER, HIVE_TIMELOCK_GUARDIAN and call DeployHiveSeatVault.run().

      Expected: refuse to deploy against a chain where the collection has no code.

      Actual: run() succeeds and returns a vault with seatCollection == 0x0000eC93...1D; vault.authorizeWorker({wallet: vault, tokenId: 1343, expiresAt: now+1h, ...}) from the owner then reverts inside seatCollection.ownerOf.

      Reproduced in test/scratch/JudgeRepro.t.sol::test_script_deploys_against_codeless_collection.

    • infoTrust assumption not stated in the timelock header: a leaked proposer key can never be rotated out on-chain, only held in a stalemate by the guardianscript/DeployHiveSeatVault.s.sol:27

      The header presents the guardian as the containment for a leaked proposer key.

      What it omits: the TimelockController constructor grants every proposer CANCELLER_ROLE as well, the script wires exactly one proposer, and every role change on the timelock (revokeRole/grantRole on PROPOSER_ROLE) and every vault ownership handover (transferOwnership to a fresh timelock) must be scheduled by that same proposer and survive 48h.

      Whoever holds the leaked key can cancel the team's rotation at any point in the window, while the guardian cancels the attacker's hostile operations. Seats are not stolen, but custody and configuration are frozen indefinitely: no withdrawSeat, no setSeatOperator (so a simultaneously leaked operator key cannot be retired), no setRewardSink, no handover, with no recovery that does not depend on the attacker stopping.

      This is a documentation/trust-assumption note, not a code defect, and the 48h design is out of scope. Suggested wording for the header: the proposer is also a canceller and the sole scheduler, so a leaked proposer key is unrecoverable on-chain and the guardian can only hold a stalemate; the proposer must be a multisig/HSM, never a hot key. (From audit_permissions be7cce03.)

      State: TimelockController(48h, [P], [0], deployer); grantRole(CANCELLER_ROLE, G); renounce admin (the script's path).

      Team (as P) schedules target=timelock, data=revokeRole(PROPOSER_ROLE, P), salt='rot'.

      Attacker (as P, which holds CANCELLER_ROLE) calls cancel(id) before 48h.

      After warp +48h execute(...) reverts (TimelockUnexpectedOperationState) and hasRole(PROPOSER_ROLE, P) is still true; G calling schedule(...) reverts with AccessControlUnauthorizedAccount.

      Reproduced in test/scratch/JudgeRepro.t.sol::test_proposer_rotation_cancellable_by_leaked_key.

    • infoDeploy script's post-deploy requires run only in simulation; an interrupted broadcast can leave the deployer with DEFAULT_ADMIN_ROLE on-chainscript/DeployHiveSeatVault.s.sol:56

      Item (8) asks to confirm the deployer ends with no DEFAULT_ADMIN_ROLE. In the simulated run that is true and asserted on line 76. On-chain, however, forge script --broadcast sends the constructor, grantRole, renounceRole and vault-creation as four separate transactions after a single simulation; the require statements are never re-evaluated against chain state.

      If the broadcast stops after grantRole (RPC failure, nonce gap, out-of-gas on a later tx) the deployer keeps DEFAULT_ADMIN_ROLE and can grant PROPOSER_ROLE/EXECUTOR_ROLE or change role admins instantly, with no 48h delay, until it renounces.

      No vault actor can exploit this without the deployer key, and resuming the broadcast completes the renounce, so it is an operational note: verify hasRole(DEFAULT_ADMIN_ROLE, deployer) == false from an independent read after the broadcast is confirmed, and treat the deployer key as privileged until then.

      Execute the script's first two on-chain steps and stop: new TimelockController(48h, [P], [0], deployer); grantRole(CANCELLER_ROLE, G).

      Expected per the header ('No outside admin is left behind'): deployer has no admin.

      Actual: hasRole(DEFAULT_ADMIN_ROLE, deployer) == true and deployer.grantRole(PROPOSER_ROLE, anyone) succeeds with no delay.

      Reproduced in test/scratch/JudgeRepro.t.sol::test_interrupted_broadcast_leaves_deployer_admin.

    • infoisValidSignature reverts instead of returning 0xffffffff when a paired seat no longer exists in the collectionsrc/HiveSeatVault.sol:281

      The NatSpec on lines 273-274 and invariants I4/I8 describe isValidSignature as returning INVALID whenever the seat is not held. The custodyEpoch check on line 280 only covers a seat that left through withdrawSeat.

      If the token ceases to exist collection-side (burn, admin reclaim) while a live pairing exists, custodyEpoch is unchanged and control reaches line 281, where an OpenZeppelin-style ownerOf reverts with ERC721NonexistentToken, so the view reverts instead of returning the ERC-1271 failure value; authorizedTokenId (line 288) keeps reporting authorized == true for that digest. No custody or signing impact: a revert is never the magic value, and the seat is already gone.

      Whether the live identity.md collection has any burn/reclaim path could not be checked offline.

      Fix if desired: wrap the ownerOf read in try/catch (or staticcall + decode) and return ERC1271_INVALID on failure. (Merged from audit_permissions 19de3012 and audit_flow e8aae48b.)

      Vault holds seat 1; operator calls authorizeWorker({wallet: vault, tokenId: 1, expiresAt: now+1h, ...}) -> digest d; isValidSignature(d, '') == 0x1626ba7e.

      The collection burns token 1 (no vault call).

      Expected: isValidSignature(d, '') returns 0xffffffff.

      Actual: reverts with ERC721NonexistentToken(1); authorizedTokenId(d) still returns (true, 1).

      Reproduced in test/scratch/JudgeRepro.t.sol::test_isValidSignature_reverts_when_burned with an OZ ERC721 mock exposing _burn.

    • infoTrust assumptions: nothing in the vault pins the owner to a timelock, and the owner can route ERC-20s past rewardSinksrc/HiveSeatVault.sol:364

      Documented as privileged powers, not bypasses; all are onlyOwner and so 48h-delayed and public while the owner is the TimelockController. (a) transferOwnership + acceptOwnership (Ownable2Step) can hand the vault to any address, including an EOA or a 0-delay timelock; from acceptance onward withdrawSeat, setSeatOperator, setRewardSink, routeERC20, rescueERC721 and setAgentURIById execute instantly.

      The deploy script asserts owner == timelock only at deployment (line 74); the vault itself has no check. (b) routeERC20 (line 380) sends any non-seat ERC-20 to a caller-supplied destination, so 'earnings go only to rewardSink' holds for permissionless callers only, not for the owner; I1 and I5 are preserved (the seat collection is rejected on line 381 and ERC-721 has no transfer(address,uint256)).

      (c) setRewardSink retargets all future sweeps; no in-flight value, so no retroactive loss. None is a defect under the stated model; they define the watch-list for queued timelock operations: transferOwnership(...) to a non-timelock target, routeERC20(...) to an unexpected destination, and any op whose target is the timelock itself. (From audit_permissions b2b27044.)

      Owner = Timelock schedules vault.transferOwnership(0xEOA); after 48h it executes; 0xEOA calls acceptOwnership() (no delay).

      0xEOA then calls withdrawSeat(1, 0xEOA) and the seat leaves in the same block with no queued operation.

      Likewise the owner's routeERC20(token, 0xAnyone, 100) moves 100 tokens to 0xAnyone instead of rewardSink.

      Reproduced in test/scratch/JudgeRepro.t.sol::test_owner_powers_trust_assumptions and by the existing test_ownership_handover_retires_pairings / test_routeERC20_partial_amount_and_event.

    • infoLeaked operator key can create pairings with an unbounded expiresAt; only the 48h operator rotation retires themsrc/HiveSeatVault.sol:194

      Trust-assumption note already acknowledged by the I8 comment, recorded because item (3) asks to confirm a leaked operator key 'can at worst grief'. The operator can pair attacker-chosen deviceKey values and expiresAt is only required to be in the future, so type(uint64).max is accepted; such a pairing stays VALID until the owner rotates the operator (setSeatOperator bumps authEpoch), which is itself a 48h-delayed operation.

      On-chain this moves nothing: wallet must equal the vault, the pairing only makes isValidSignature return the magic value for that one WorkerAuthorization digest, the vault never approves a seat, and no vault function lets a paired device transfer anything. Whether a paired worker can divert value at the IMD relay layer is outside the vault and should be confirmed with IMD; if it cannot, 'never steal' holds and the exposure is roughly 48h of an attacker running the seats.

      A vault-side cap (e.g. expiresAt <= block.timestamp + 30 days) would bound it without changing the operator model. (From audit_flow af16882a.)

      Operator calls authorizeWorker({deviceKey: attackerKey, wallet: vault, tokenId: 1, nonce: n, expiresAt: type(uint64).max, relayOrigin: 'https://api.imd.fun'}).

      Expected under a bounded-grief model: the pairing lapses on its own.

      Actual: isValidSignature(digest, '') == 0x1626ba7e after warp +50 years, until the timelock executes setSeatOperator to a different address.

      Reproduced in test/scratch/JudgeRepro.t.sol::test_operator_pairing_never_expires.

    • infowithdrawSeat has no to != address(0) guard and relies on the third-party collection to reject a zero recipientsrc/HiveSeatVault.sol:334

      routeERC20 (line 382) and setRewardSink (line 350) revert on a zero destination, but withdrawSeat, the only seat exit, does not validate to. With an OpenZeppelin-style ERC-721 the collection reverts (ERC721InvalidReceiver(address(0))), so nothing is lost on the mocks; the live identity.md collection's transfer-to-zero behaviour is not exercised by any test here.

      If that collection treats a transfer to address(0) as a burn, a queued withdrawSeat(tokenId, address(0)) that nobody cancels within 48h destroys the seat. Owner-side mistake path, mitigated by the public 48h queue; recorded because the zero-check is applied asymmetrically.

      Fix: if (to == address(0)) revert ZeroAddress();. (From audit_flow a9099fe0.)

      Owner calls vault.withdrawSeat(1, address(0)).

      Expected: ZeroAddress() from the vault, consistent with routeERC20/setRewardSink.

      Actual: the vault forwards seatCollection.safeTransferFrom(vault, address(0), 1) unguarded; on the OZ mock it reverts with ERC721InvalidReceiver(address(0)) (test/scratch/JudgeRepro.t.sol::test_withdrawSeat_zero_relies_on_collection); on the live collection the outcome depends on its implementation.

    • infoFlow gap: the vault can only receive ERC-721 seats; ERC-1155 or safe-transferred non-seat ERC-721 rewards revert at the sendersrc/HiveSeatVault.sol:177

      Inbound value paths: ETH via receive, ERC-20 freely, seats via safeTransferFrom/transferFrom, non-seat ERC-721 only via unsafe transferFrom. Two inbound classes have no entry: ERC-1155 (no onERC1155Received, and ERC-1155 only has safe transfers) and any non-seat ERC-721 sent with safeTransferFrom (rejected on line 177). Rejecting stray ERC-721s is intentional per the NatSpec, and no inspected IMD flow pushes such assets to seat wallets today, so this is informational.

      It matters only if IMD or a launch later distributes rewards to seat holders as ERC-1155 or via safeMint/safeTransfer: that distribution transaction would revert for the vault's seats and the reward would be missed. If such a flow appears, add ERC1155Holder plus an owner-only rescue mirroring rescueERC721. (From audit_economics a941c16e.)

      Any ERC-1155 calls safeTransferFrom(sender, vault, id, 1, '') -> reverts (ERC1155InvalidReceiver, no hook).

      Any non-seat ERC-721 calls safeTransferFrom(sender, vault, id) -> reverts with NotSeatCollection.

      Expected for a reward delivery: accepted and later sweepable/rescuable.

      Reproduced in test/scratch/JudgeRepro.t.sol::test_erc1155_and_foreign_safe721_rejected and the existing test_rejects_foreign_collection.

  7. Publishedaudit report
  8. Onchain1 receipt, 5 scores queuedon Ethereum mainnet
    receipt
    work accepted · record queued
    scores
    5 scores for reviewed on submission · all 5 passed#260#1023#912#1778#1572