{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"c71449d1-899c-4314-9c50-33730fb2dc09","kind":"skill:audit-imported-code","nodes":[{"acceptedSubmissionHash":"cfffb9f971a13799807ad526e835d2646d4bd24fe9acbf2ca9d8fe713c54e6da","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"9897597976b0e72440db7ca402d225aea20f3d37205ba53e7df2400ff80f29cc","skillId":"audit-imported-code","tools":[]},"key":"audit_imported_code","kind":"code","role":"review","skillHash":"9897597976b0e72440db7ca402d225aea20f3d37205ba53e7df2400ff80f29cc","skillId":"audit-imported-code","state":"accepted"}],"objective":"Audit the six contracts in this repository that were written in-house and have never had an independent review, and only those: src/SwarmRelay.sol (including the new relayAndLiquidate and relayAndMark), src/Treasury.sol, src/Governed.sol, src/Parameters.sol, src/Registry.sol, src/ParameterizedVault.sol. Also audit the change to src/CDPVault.sol that replaced the deployment-based fee index with the checkpointed one (indexCheckpoint, indexCheckpointAt, pokeIndex, and stabilityFeeOf). Everything else in src/ was built and audited by the swarm across launches 458, 493, 517, 519 and 586 and is out of scope. Report findings only; change no code. Specific questions to answer, each with the reasoning that settles it: (1) Can anyone cause Parameters to apply a change whose values were never validated, or validated against state that no longer holds? (2) pokeIndex is permissionless and Parameters._apply calls it before writing a new rate — is there any ordering, reentrancy or self-call path in which a rate change reaches time that has already elapsed, or in which debtIndex decreases and underflows the subtraction in stabilityFeeOf? (3) bindVault is permissionless and gated only on the candidate vault's immutable parameters() matching address(this) — can that check be satisfied by a contract that is not the real vault, and what could such a binding do? (4) Is every authority a governor can reach bounded by the constants in Parameters, and is there any path from Parameters or Registry to the price feeds, the attester, the collateral token, or anyone's position? (5) Does the 48-hour delay actually guarantee a borrower can exit at the terms they borrowed under, for every one of the five parameters? (6) In SwarmRelay, can a caller make the relay submit an attestation that a feed would refuse if submitted directly, or make relayMany leave some feeds updated and others not in a way a reader could exploit? (7) ParameterizedVault creates its own Parameters when passed a zero address, and that Parameters binds its creator in its own constructor WITHOUT the verification bindVault performs - can anything other than the real vault end up bound this way, and can a vault end up sharing or borrowing another vault's Parameters? (8) relayAndLiquidate holds a liquidator's stablecoin and the seized collateral for the length of one call, pulls exactly debtToRepay rather than the allowance, forwards the seizure as a balance DELTA, and asserts both deltas are zero before returning - can any caller, token or vault make it end a call holding either token, pay the seizure to the wrong party, strand a caller's stablecoin, or let one caller's funds cover another's liquidation? (9) In Treasury, can sync be made to record a receipt that never arrived, or can withdraw send more than arrived?","parentJobId":null,"planHash":"b3d5f2a0e937ac007febf5336e2cd8b1d023a7bf2919755f72f8ebda75bc05c9","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"c71449d1-899c-4314-9c50-33730fb2dc09","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"50959","feedbackHash":"d049b5680915cac7c740c764fdb9f7c3db21fb2058118a2cc9d9e9c7a1f70372","nodeKey":"audit_imported_code","submissionHash":"cfffb9f971a13799807ad526e835d2646d4bd24fe9acbf2ca9d8fe713c54e6da","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"bbc12e87152cb0d49eef1121679ab1392f503c55f2d942c1430b45d124562f6a","state":"completed","submissions":[{"artifacts":[],"attempt":2,"bundleHash":null,"device":"468e82a89b9bfe18","findings":[{"citation":"resolved","description":"Every production feed (PriceFeed, NhiFeed, SpotFeed) pins ATTESTATION_RELAYER = SwarmRelay as its sole submitter. SwarmRelay.relay/relayMany/relayAndMark/relayAndLiquidate forward any attestation from any msg.sender, so the feeds' relayer check `msg.sender != relayer` is satisfied by everyone. DeploymentConfig.sol (ATTESTATION_RELAYER) and SwarmFeed.submitAttestation both document that a nonzero relayer is the ONLY thing covering 'the unseeded first value and stale re-anchors', because questionHash binds a moving block window and the feed cannot verify which question a signed figure answers. The oracle signs whatever question a paying requester poses, under whatever consumer domain the request names (oracle/price-oracle-quote.json carries the question text verbatim; oracle/preflight-oracle.mjs shows consumer.verifyingContract is a requester-supplied field). Therefore anyone can obtain an attester signature for the price feed's EIP-712 domain over a figure of their choosing (e.g. a question whose answer is 1), with chainId 1, answerType 3 and a panel >= 25/15, and push it through the relay. The feed accepts it: all remaining guards (signature, replay, freshness, panel floors) pass by construction. While the feed is fresh the figure is bounded by maxDeviationBps per update (2000 in launch.json, 5000 in DeployGoverned) but updates with equal issuedAt may be chained in one relayMany call; once the feed is stale (no update for maxAge, 24h) the deviation bound is off and the first accepted figure re-anchors the feed to any value. Whoever sets the price can liquidate every position (price down) or mint unbacked COMP (price up). The relay's own comment says 'the trust is the same as a zero relayer'; DeploymentConfig says a zero relayer 'would mean permissionless relay, which ... is unsafe'. Both cannot be true. Answers question (6): yes, a caller makes the relay submit an attestation the feed would refuse from that caller directly, which is exactly the attestation the relayer role existed to filter. relayMany itself is atomic (all-or-nothing), so partial updates are not the issue; the caller choosing which feeds to update is.","line":37,"path":"src/SwarmRelay.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {SwarmFeed} from \"src/SwarmFeed.sol\";\nimport {SwarmRelay} from \"src/SwarmRelay.sol\";\n\n/// @dev Test leaf for the abstract feed: the production artifacts pin their authorities, so the only\n/// way to point a feed at a relay deployed inside the test is a leaf that takes them as arguments.\ncontract TestFeed is SwarmFeed {\n    constructor(address attester_, address relayer_, address reporter_)\n        SwarmFeed(attester_, relayer_, 1, 3, reporter_, address(0), address(0), 1, 1 days, 2000)\n    {}\n}\n\n/// @notice Finding: SwarmRelay is the pinned relayer of every feed and has no caller restriction, so\n/// the feeds' relayer guard is gone. DeploymentConfig.ATTESTATION_RELAYER documents that guard as the\n/// one thing covering \"the unseeded first value and stale re-anchors\", because questionHash cannot\n/// tell the feed WHICH question an attestation answers. The oracle signs any question a paying\n/// requester poses, under whatever consumer domain the request names (oracle/*.json carries the\n/// question text and `consumer.verifyingContract` verbatim). So anyone can obtain an attester\n/// signature for the price feed's domain over a figure of their choosing and push it through the\n/// relay, which the feed would refuse from them directly.\ncontract PermissionlessRelayTest is Test {\n    uint256 private constant ATTESTER_KEY = 0xA11CE;\n    address private constant REPORTER = address(0xBEEF);\n    address private constant STRANGER = address(0x5747);\n\n    SwarmRelay private relay;\n    TestFeed private priceFeed;\n\n    function setUp() public {\n        vm.chainId(11155111);\n        vm.warp(10 days);\n        relay = new SwarmRelay();\n        priceFeed = new TestFeed(vm.addr(ATTESTER_KEY), address(relay), REPORTER);\n        vm.prank(REPORTER);\n        priceFeed.report(1 ether);\n    }\n\n    /// @dev The feed is stale (no update for > maxAge), so the deviation bound does not apply and the\n    /// next accepted figure re-anchors it. The attestation answers an unrelated question (a different\n    /// questionHash) but is validly signed for this feed's domain. Direct submission by the stranger\n    /// is refused; the same bytes through the relay are accepted and the price becomes 1 wei.\n    function test_aStrangerCannotReanchorAStaleFeedThroughTheRelay() public {\n        vm.warp(block.timestamp + 2 days);\n        assertTrue(priceFeed.isStale());\n\n        SwarmFeed.OracleAttestation memory a = _attestation(keccak256(\"anyone's question\"), 1);\n        bytes memory sig = _sign(a);\n\n        // Refused directly: the relayer guard is doing its job.\n        vm.prank(STRANGER);\n        vm.expectRevert(SwarmFeed.UnauthorizedRelayer.selector);\n        priceFeed.submitAttestation(a, sig);\n\n        // Expected: the relay enforces at least the same restriction on who may relay, so this\n        // reverts too. Actual: accepted, and the price feed now reads 1 wei per IMD.\n        vm.prank(STRANGER);\n        vm.expectRevert();\n        relay.relay(priceFeed, a, sig);\n\n        (uint256 value,) = priceFeed.latestValue();\n        assertEq(value, 1 ether, \"the stale feed was re-anchored by an unprivileged caller\");\n    }\n\n    function _attestation(bytes32 questionHash, uint256 figure)\n        private\n        view\n        returns (SwarmFeed.OracleAttestation memory a)\n    {\n        a.requestId = keccak256(abi.encode(questionHash, figure));\n        a.chainId = 1;\n        a.questionHash = questionHash;\n        a.answerType = 3;\n        a.answer = abi.encode(figure);\n        a.figure = figure;\n        a.fromBlock = 100;\n        a.toBlock = 200;\n        a.blockHash = keccak256(\"b\");\n        a.panelJobId = keccak256(\"panel\");\n        a.panelSize = 60;\n        a.quorum = 20;\n        a.agreed = 40;\n        a.issuedAt = uint64(block.timestamp);\n        a.expiresAt = uint64(block.timestamp + 1 hours);\n    }\n\n    function _sign(SwarmFeed.OracleAttestation memory a) private view returns (bytes memory) {\n        bytes32 body = keccak256(\n            bytes.concat(\n                abi.encode(\n                    priceFeed.ATTESTATION_TYPEHASH(),\n                    a.requestId,\n                    a.chainId,\n                    a.questionHash,\n                    a.answerType,\n                    keccak256(a.answer),\n                    a.figure\n                ),\n                abi.encode(\n                    a.fromBlock,\n                    a.toBlock,\n                    a.blockHash,\n                    a.panelJobId,\n                    a.panelSize,\n                    a.quorum,\n                    a.agreed,\n                    a.issuedAt,\n                    a.expiresAt\n                )\n            )\n        );\n        (uint8 v, bytes32 r, bytes32 s) =\n            vm.sign(ATTESTER_KEY, keccak256(abi.encodePacked(\"\\x19\\x01\", priceFeed.DOMAIN_SEPARATOR(), body)));\n        return abi.encodePacked(r, s, v);\n    }\n}","reproduction":"State: PriceFeed pinned to relayer = SwarmRelay, last value 1e18, no update for > maxAge (isStale() true). Input: an attestation signed by ORACLE_ATTESTER for the feed's DOMAIN_SEPARATOR with questionHash = any unrelated question, figure = 1, chainId 1, answerType 3, panelSize 60, agreed 40, issuedAt = now. Call 1: STRANGER -> priceFeed.submitAttestation(a, sig): reverts UnauthorizedRelayer (expected). Call 2: STRANGER -> relay.relay(priceFeed, a, sig): succeeds; priceFeed.latestValue() == 1 (actual) where the design says the relayer guard should have refused it. Every position in the vault is now liquidatable at a price of 1 wei. Proof test: test/scratch/PermissionlessRelay.t.sol (fails: 'next call did not revert as expected').","severity":"high","snippet":"        feed.submitAttestation(attestation, signature);","title":"SwarmRelay makes relaying permissionless, removing the only guard that decides WHICH question an attestation answers"},{"citation":"resolved","description":"bindVault is permissionless and its only check is that the candidate answers parameters() == address(this). Any 10-line contract does that; the check does not establish that the candidate is a CDPVault, let alone the one the deployer meant. DeployGoverned.s.sol sends `new Parameters(0)`, `new ParameterizedVault(..., params)` and `params.bindVault(vault)` as three separate transactions, so an attacker who sees the first land can call bindVault(impostor) before the third. Because ParameterizedVault.parameters is immutable and Parameters.vault is one-shot, the real vault is then permanently governed by a Parameters whose `vault` is attacker code. Consequences, each reachable: (a) the deployer's bindVault reverts AlreadyBound, so the stack must be redeployed (griefing at the cost of one transaction); (b) if the impostor's pokeIndex reverts, applyPending reverts forever and the vault is frozen at the shipped constants; (c) if the impostor's pokeIndex is a no-op and totalDebt() returns 0, every rate change applies to the real vault WITHOUT a checkpoint: a rise reprices every elapsed second at the new rate, a cut makes debtIndex() fall below debtIndexOf[owner] for any borrower who accrued mid-period and the subtraction in stabilityFeeOf underflows, reverting debtOf/repay/withdraw/liquidate/mint for that position; and a ceiling below the real outstanding debt passes validation. This answers questions (1) and (3): a change can be validated against state that is not the vault's, and the bindVault check is satisfiable by a non-vault. It is also a post-deployment configuration step (an initializer in all but name), which the launch forbids; the self-contained path (parameters_ = 0) needs no such call and is immune to the race. Fix direction: drop the standalone bind path or gate bindVault on the governor; a check that the candidate is a real vault cannot be made from its return values.","line":112,"path":"src/Parameters.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {Parameters, ICheckpointedVault} from \"src/Parameters.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR, STABILITY_FEE_BPS} from \"src/DeploymentConfig.sol\";\n\n/// @dev Minimal controllable feed, so this file depends on nothing under test/.\ncontract Feed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\n/// @notice Ten lines satisfy the only check bindVault performs. It is not a vault.\ncontract Impostor {\n    address public immutable parameters;\n\n    constructor(address parameters_) {\n        parameters = parameters_;\n    }\n\n    /// @dev Reports whatever debt is convenient: zero lets any ceiling through the live-debt check.\n    function totalDebt() external pure returns (uint256) {\n        return 0;\n    }\n\n    /// @dev The real vault is never poked. (Reverting here instead would freeze governance forever.)\n    function pokeIndex() external {}\n}\n\n/// @notice Finding: bindVault is permissionless and accepts any contract whose parameters() returns\n/// address(this). An attacker who lands bindVault(impostor) between `new Parameters(0)` and the\n/// deployer's own bindVault (DeployGoverned.s.sol sends them as separate transactions) binds the\n/// real vault's Parameters to a contract it controls. The real vault's `parameters` is immutable,\n/// so the link can never be repaired.\ncontract BindHijackTest is Test {\n    address private constant BORROWER = address(0xB0B);\n    address private constant ATTACKER = address(0xBAD);\n\n    MockIMD private imd;\n    Feed private price;\n    Feed private spot;\n    Feed private nhi;\n\n    function setUp() public {\n        vm.warp(10 days);\n        imd = new MockIMD();\n        price = new Feed(1 ether);\n        spot = new Feed(1 ether);\n        nhi = new Feed(0.9 ether);\n    }\n\n    /// @dev Deployer tx 1 and 2 as in DeployGoverned.run(); the attacker's bind lands before tx 3.\n    /// Both binds are sent as raw calls so that a fix which makes either one revert is tolerated.\n    function _deployStackWithRace() private returns (Parameters params, ParameterizedVault vault, Impostor impostor) {\n        params = new Parameters(ICheckpointedVault(address(0)));\n        vault = new ParameterizedVault(\n            address(imd), address(0), address(0), address(price), address(nhi), address(spot), params\n        );\n        vm.startPrank(ATTACKER);\n        impostor = new Impostor(address(params));\n        (bool attackerBound,) = address(params).call(abi.encodeCall(params.bindVault, (ICheckpointedVault(address(impostor)))));\n        vm.stopPrank();\n        attackerBound; // silence the warning; what matters is read back below\n        // Deployer tx 3, from the operator.\n        vm.prank(APPROVED_OPERATOR);\n        (bool ok,) = address(params).call(abi.encodeCall(params.bindVault, (ICheckpointedVault(address(vault)))));\n        ok;\n    }\n\n    /// @dev Expected: only the vault that names this Parameters can be bound, so after the deployer's\n    /// own bindVault parameters.vault() == vault. Actual: the impostor is bound (one call, no\n    /// authority) and the deployer's bindVault reverts AlreadyBound.\n    function test_aStrangerCannotBindAContractThatIsNotTheVault() public {\n        (Parameters params, ParameterizedVault vault,) = _deployStackWithRace();\n        assertEq(address(params.vault()), address(vault), \"parameters must be bound to the vault that names them\");\n    }\n\n    /// @dev What the misbinding buys: a rate change applies to the real vault without the real vault\n    /// being poked, so the new rate reaches every second already elapsed. With a rate CUT the index\n    /// falls below the borrower's recorded index and every debt read on the real vault reverts.\n    function test_misboundParametersRepriceOrFreezeTheRealVault() public {\n        (Parameters params, ParameterizedVault vault,) = _deployStackWithRace();\n\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 1_000 ether);\n        vm.startPrank(BORROWER);\n        imd.approve(address(vault), type(uint256).max);\n        vault.depositCollateral(1_000 ether);\n        vault.mintCOMP(100 ether);\n        vm.stopPrank();\n        assertEq(STABILITY_FEE_BPS, 200, \"shipped rate is 2%\");\n\n        // Half a year in the borrower touches the position (1 wei repayment), recording\n        // debtIndexOf = 1.01e18 and banking ~1 COMP of fees; then another half year.\n        vm.warp(block.timestamp + 182 days);\n        vm.prank(BORROWER);\n        vault.repayCOMP(1);\n        vm.warp(block.timestamp + 183 days);\n\n        // Governor cuts the rate to zero. Honest binding: pokeIndex freezes ~2 COMP of fees, nothing\n        // more accrues. Hijacked binding: the real vault is never poked.\n        vm.prank(APPROVED_OPERATOR);\n        params.propose(Parameters.ParamSet(type(uint256).max, 3_333, 0, 500, 1_000));\n        vm.warp(block.timestamp + params.TIMELOCK());\n        uint256 owedBefore = vault.debtOf(BORROWER); // 100 + ~2 COMP, the instant before the change\n        params.applyPending();\n        assertEq(vault.stabilityFeeBps(), 0);\n\n        // On the code as it is: debtIndex() is indexCheckpoint (1e18) + 0, below the ~1.01e18\n        // recorded for the borrower, the subtraction in stabilityFeeOf underflows (panic 0x11) and\n        // the position cannot be read, repaid, withdrawn or liquidated. (A borrower who never\n        // re-accrued instead sees the year's fees vanish; a rate RISE reprices the whole year.)\n        uint256 owedAfter = vault.debtOf(BORROWER);\n        assertEq(owedAfter, owedBefore, \"a forward-only rate change leaves accrued fees unchanged\");\n        vm.prank(BORROWER);\n        vault.repayCOMP(1 ether);\n    }\n}","reproduction":"Tx1 (deployer): params = new Parameters(0). Tx2 (deployer): vault = new ParameterizedVault(imd, 0, 0, price, nhi, spot, params). Tx3 (attacker, before deployer's bind): impostor = new Impostor(params) with parameters() returning params, totalDebt() returning 0, pokeIndex() a no-op; params.bindVault(impostor) succeeds. Tx4 (deployer): params.bindVault(vault) reverts AlreadyBound; params.vault() == impostor, vault.parameters() == params. Then: borrower mints 100 COMP at 2%, repays 1 wei at day 182 (debtIndexOf = ~1.00997e18, ~1 COMP banked), at day 365 governor proposes stabilityFeeBps 0, after 48h anyone applies: impostor.pokeIndex() does nothing, vault.debtIndex() becomes 1e18 < debtIndexOf, vault.debtOf(borrower) reverts with panic 0x11 (expected: 102.01 COMP still owed and readable). Proof test: test/scratch/BindHijack.t.sol (two tests fail: wrong vault bound; arithmetic underflow).","severity":"medium","snippet":"        address named = IParameterized(address(vault_)).parameters();\n        if (named != address(this)) revert NotOurVault(named);","title":"Parameters.bindVault accepts any contract whose parameters() returns address(this); a front-run binds the real vault's governance to an impostor forever"},{"citation":"resolved","description":"The constructor stores any non-zero Parameters without checking that parameters_.vault() is zero (to be bound to this vault) or already this vault. Parameters' own constructor (src/Parameters.sol:93 `vault = vault_;`) likewise binds whatever address it is handed, not msg.sender, so `new Parameters(X)` for arbitrary X is possible from any deployer. The result is a vault whose stabilityFeeBps() comes from a Parameters that calls pokeIndex on a DIFFERENT contract. Parameters._apply's checkpoint-before-write ordering (question 2) is correct only when Parameters.vault is the vault reading the rate; for any other vault the rate change reaches time that has already elapsed: a rise reprices the whole uncheckpointed interval at the new rate, a cut lowers debtIndex() below debtIndexOf[owner] for every borrower who accrued after the last checkpoint, and stabilityFeeOf's subtraction underflows (panic 0x11), reverting debtOf, repayCOMP, withdrawCollateral (with debt), mintCOMP, markUnderwaterFor and liquidate for those positions until the index catches up (never, at rate 0). The ceiling validation also reads the OTHER vault's totalDebt. Answers question (7): yes, anything can be bound via the constructor argument, and a vault can borrow another vault's Parameters by naming it; the existing test only asserts that the self-created path does not share. This is an operator footgun (a second market deployed on 'the' Parameters) rather than an attacker path, hence medium. Fix: in the constructor require parameters_.vault() to be address(0) or address(this), and in Parameters' constructor require vault_ == address(0) || vault_ == msg.sender.","line":36,"path":"src/ParameterizedVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {Parameters, ICheckpointedVault} from \"src/Parameters.sol\";\nimport {ParameterizedVault} from \"src/ParameterizedVault.sol\";\nimport {ISwarmFeed} from \"src/interfaces/ISwarmFeed.sol\";\nimport {APPROVED_OPERATOR} from \"src/DeploymentConfig.sol\";\n\ncontract Feed is ISwarmFeed {\n    uint256 public constant maxAge = 1 days;\n    uint256 private value;\n    uint64 private updatedAt;\n\n    constructor(uint256 v) {\n        value = v;\n        updatedAt = uint64(block.timestamp);\n    }\n\n    function latestValue() external view returns (uint256, uint64) {\n        return (value, updatedAt);\n    }\n\n    function isStale() external pure returns (bool) {\n        return false;\n    }\n}\n\n/// @notice Finding: ParameterizedVault's constructor accepts any Parameters, including one already\n/// bound to a different vault, and Parameters' constructor binds whatever address it is handed. A\n/// second vault reading a Parameters that pokes a different vault gets every rate change applied to\n/// time that has already elapsed, and a rate cut makes debtIndex() fall below the borrowers'\n/// recorded index so every debt read on the second vault reverts.\ncontract SharedParametersTest is Test {\n    address private constant BORROWER = address(0xB0B);\n\n    MockIMD private imd;\n    Feed private price;\n    Feed private spot;\n    Feed private nhi;\n    Parameters private params;\n    ParameterizedVault private vaultA;\n\n    function setUp() public {\n        vm.warp(10 days);\n        imd = new MockIMD();\n        price = new Feed(1 ether);\n        spot = new Feed(1 ether);\n        nhi = new Feed(0.9 ether);\n        params = new Parameters(ICheckpointedVault(address(0)));\n        vaultA = new ParameterizedVault(\n            address(imd), address(0), address(0), address(price), address(nhi), address(spot), params\n        );\n        vm.prank(APPROVED_OPERATOR); // permissionless today; pranked so a governor-gated fix still sets up\n        params.bindVault(ICheckpointedVault(address(vaultA)));\n    }\n\n    /// @dev Expected: a vault refuses a Parameters that is already bound to another vault (or that\n    /// names a vault other than itself), because that Parameters can never checkpoint it.\n    /// Actual: construction succeeds and vaultB.parameters() == params while params.vault() == vaultA.\n    function test_aVaultRefusesParametersBoundToAnotherVault() public {\n        vm.expectRevert();\n        new ParameterizedVault(\n            address(imd), address(0), address(0), address(price), address(nhi), address(spot), params\n        );\n    }\n\n    /// @dev The consequence, on the code as it is: a borrower in vaultB is frozen by a rate cut.\n    /// Once construction is refused there is nothing to demonstrate and the test passes.\n    function test_aRateCutFreezesEveryPositionInTheSecondVault() public {\n        ParameterizedVault vaultB;\n        try new ParameterizedVault(\n            address(imd), address(0), address(0), address(price), address(nhi), address(spot), params\n        ) returns (ParameterizedVault b) {\n            vaultB = b;\n        } catch {\n            return;\n        }\n        assertEq(address(vaultB.parameters()), address(params));\n        assertEq(address(params.vault()), address(vaultA), \"params still poke vaultA only\");\n\n        vm.prank(APPROVED_OPERATOR);\n        imd.mint(BORROWER, 1_000 ether);\n        vm.startPrank(BORROWER);\n        imd.approve(address(vaultB), type(uint256).max);\n        vaultB.depositCollateral(1_000 ether);\n        vaultB.mintCOMP(100 ether);\n        vm.stopPrank();\n\n        // Half a year in, the borrower touches the position (1 wei repayment), which records\n        // debtIndexOf[BORROWER] = 1.01e18 and banks ~1 COMP of fees. Then another half year.\n        vm.warp(block.timestamp + 182 days);\n        vm.prank(BORROWER);\n        vaultB.repayCOMP(1);\n        vm.warp(block.timestamp + 183 days);\n\n        vm.prank(APPROVED_OPERATOR);\n        params.propose(Parameters.ParamSet(type(uint256).max, 3_333, 0, 500, 1_000));\n        vm.warp(block.timestamp + params.TIMELOCK());\n        uint256 owed = vaultB.debtOf(BORROWER); // the instant before the change\n        assertGt(owed, 100 ether, \"2% accrued over a year\");\n        params.applyPending();\n\n        // vaultA was poked and is fine; vaultB was not: its debtIndex() is back to 1e18, below the\n        // 1.01e18 recorded for the borrower.\n        vaultA.debtOf(BORROWER);\n        assertEq(vaultB.debtIndex(), 1e18, \"vaultB's index fell\");\n        assertGt(vaultB.debtIndexOf(BORROWER), 1e18, \"below what the borrower has recorded\");\n        // Expected: the fee accrued at the old rate is still owed and the position is readable.\n        // Actual: panic 0x11 (underflow) in stabilityFeeOf, and repayCOMP / withdrawCollateral /\n        // liquidate / mintCOMP all revert the same way, for every position that accrued mid-period.\n        assertEq(vaultB.debtOf(BORROWER), owed, \"accrued fees survive a forward-only rate change\");\n        vm.prank(BORROWER);\n        vaultB.repayCOMP(1 ether);\n    }\n}","reproduction":"params = new Parameters(0); vaultA = new ParameterizedVault(..., params); params.bindVault(vaultA). vaultB = new ParameterizedVault(..., params) succeeds (expected: revert); vaultB.parameters() == params, params.vault() == vaultA. Borrower deposits 1000 IMD in vaultB, mints 100 COMP at 2%; at day 182 repays 1 wei (debtIndexOf ~1.00997e18); at day 365 governor proposes stabilityFeeBps 0; 48h later applyPending pokes vaultA only. vaultB.debtIndex() == 1e18 while vaultB.debtIndexOf(borrower) > 1e18; vaultB.debtOf(borrower) reverts panic 0x11 (expected 102.01 COMP). vaultA is unaffected. Proof test: test/scratch/SharedParameters.t.sol (two tests fail: construction not refused; arithmetic underflow).","severity":"medium","snippet":"        parameters = address(parameters_) == address(0)\n            ? new Parameters(ICheckpointedVault(address(this)))\n            : parameters_;","title":"ParameterizedVault accepts a Parameters bound to another vault (and Parameters' constructor binds any address), so a vault can read a rate it is never checkpointed for"},{"citation":"resolved","description":"withdraw clamps lastSynced down to the post-withdrawal balance without first crediting the difference between the current balance and lastSynced. Revenue that landed between the last sync and the withdrawal is therefore never added to totalReceived: the running total the contract exists to answer ('what has the protocol earned') under-reports permanently. No funds are lost, only the record. Answers question (9): sync cannot record a receipt that never arrived for an honest token (balanceOf is read directly, and a fake token only corrupts its own key), and withdraw cannot send more than the contract holds (safeTransfer reverts); the defect is the reverse, a receipt that did arrive and is never recorded. Fix: run the sync logic at the top of withdraw.","line":75,"path":"src/Treasury.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {Treasury} from \"src/Treasury.sol\";\nimport {MockIMD} from \"src/MockIMD.sol\";\nimport {APPROVED_OPERATOR} from \"src/DeploymentConfig.sol\";\n\n/// @notice Finding: Treasury.withdraw lowers `lastSynced` to the post-withdrawal balance without\n/// first crediting what arrived since the last sync, so a receipt that landed between sync and\n/// withdraw is never added to `totalReceived`.\ncontract TreasuryLostReceiptTest is Test {\n    Treasury private treasury;\n    MockIMD private token;\n\n    function setUp() public {\n        treasury = new Treasury();\n        token = new MockIMD();\n    }\n\n    function _receive(uint256 amount) private {\n        vm.prank(APPROVED_OPERATOR);\n        token.mint(address(treasury), amount);\n    }\n\n    /// @dev 100 arrives and is synced. 30 more arrives. The operator withdraws 60 before anyone\n    /// syncs. Expected: totalReceived == 130 once synced (130 did arrive). Actual: 100, forever.\n    function test_aReceiptBetweenSyncAndWithdrawIsCounted() public {\n        IERC20 t = IERC20(address(token));\n        _receive(100 ether);\n        treasury.sync(t);\n        assertEq(treasury.totalReceived(t), 100 ether);\n\n        _receive(30 ether); // a liquidation's protocol cut lands, unsynced\n\n        vm.prank(APPROVED_OPERATOR);\n        treasury.withdraw(t, address(0xD0), 60 ether);\n        // balance is 70, lastSynced was clamped from 100 down to 70, the 30 is gone from the record\n\n        treasury.sync(t);\n        assertEq(treasury.totalReceived(t), 130 ether, \"everything that arrived must be in the running total\");\n    }\n}","reproduction":"100 IMD arrives; sync -> totalReceived 100, lastSynced 100. 30 IMD arrives (unsynced). Operator withdraws 60 -> balance 70, lastSynced clamped 100 -> 70. sync -> credited 0. totalReceived stays 100; 130 arrived. Proof test: test/scratch/TreasuryLostReceipt.t.sol (fails: 100e18 != 130e18).","severity":"low","snippet":"        if (lastSynced[token] > balance) lastSynced[token] = balance;","title":"Treasury.withdraw drops any receipt that arrived since the last sync from totalReceived"},{"citation":"resolved","description":"grep over src/ finds no reader of Registry. The feeds pin ATTESTATION_RELAYER as an immutable, CDPVault pins FEE_RECIPIENT as a constant and `oracle` as an immutable. A Registry proposal that passes its 48-hour delay changes three storage words that nothing consumes: 'replacing the relayer' does not change which address the feeds accept, 'replacing the treasury' does not redirect a wei of revenue, 'replacing the work oracle' does not change what mintFromWork consults. The docstring presents these as replaceable counterparties, so an operator relying on it will believe a rotation happened when it did not. Answers question (4) for Registry: there is no path from it to feeds, attester, collateral or positions, because there is no path from it to anything. Either wire it (out of scope to say where) or remove it before launch so the manifest does not ship a governance surface that is inert.","line":43,"path":"src/Registry.sol","reproduction":"Deploy Registry(treasury, oracle). Governor proposes Addresses(relayer=0xAAAA, treasury=0xBBBB, workOracle=0xCCCC); 48h later applyPending succeeds; registry.relayer() == 0xAAAA. priceFeed.relayer() is still ATTESTATION_RELAYER; the 0xAAAA address calling submitAttestation gets UnauthorizedRelayer; a liquidation's protocolCut still goes to FEE_RECIPIENT; vault.oracle() unchanged. Expected by the docstring: the protocol now sends to / asks the new addresses. Actual: nothing changes.","severity":"low","snippet":"        _current = Addresses({relayer: ATTESTATION_RELAYER, treasury: treasury_, workOracle: workOracle_});","title":"Registry governs nothing: no contract reads relayer, treasury or workOracle from it"},{"citation":"resolved","description":"The contract's rationale is that FEE_RECIPIENT names it, making revenue 'institutionally separate from the person operating the protocol'. In the tree FEE_RECIPIENT (DeploymentConfig.sol) is the literal 0x5167...3281, the same address as APPROVED_OPERATOR, the governor and the Treasury withdrawer. CDPVault.liquidate sends protocolCut and _payDebt mints stability fees to FEE_RECIPIENT, so every protocol cut lands in the operator's wallet; the Treasury holds only what someone chooses to send it, and DeployGoverned.verify's check `treasury.totalReceived(comp) == 0` is vacuously true. Together with the previous finding this means the 'treasury' leg of the design (Registry.treasury, Treasury.sync/withdraw) is disconnected from the revenue it is described as holding. Relevant to question (4): the governor who sets protocolBonusShareBps (up to the entire bonus) and stabilityFeeBps (up to 10%) is also the address those flows are paid to, bounded by the constants but self-interested, with no contract keeping the two apart.","line":10,"path":"src/Treasury.sol","reproduction":"Deploy via DeployGoverned; borrower underwater; keeper liquidates 10 COMP of debt at price 1e18 with PROTOCOL_BONUS_SHARE_BPS 3333: protocolCut = floor(1 IMD * 3333/10000) = 0.3333 IMD. imd.balanceOf(APPROVED_OPERATOR) rises by 0.3333; imd.balanceOf(treasury) is unchanged; treasury.sync(imd) returns 0. Expected per Treasury's docstring: the cut lands in Treasury.","severity":"low","snippet":"/// @dev CDPVault is immutable, so whatever FEE_RECIPIENT names at deployment collects every protocol","title":"Treasury is not the fee recipient: protocol revenue is routed to the operator's address, so Treasury receives nothing by itself"},{"citation":"resolved","description":"The live-debt check runs again at application, and applyPending reverts (restoring pending) whenever totalDebt exceeds the proposed ceiling. Minting is permissionless up to the CURRENT ceiling (unlimited by default), so any borrower with collateral can front-run applyPending with mintCOMP to keep totalDebt above the proposed figure, and can do so each time anyone tries. The governor's only options are cancel or a higher ceiling. The whole five-value payload is held hostage, not just the ceiling (fee, divergence and share changes travel in the same struct). Answers part of question (1): validation at application is correct in direction but makes the proposal's success depend on a value third parties control. Cost to the blocker: gas plus the fee on debt they can repay next block (repay is not ceiling-gated).","line":174,"path":"src/Parameters.sol","reproduction":"totalDebt 1000 COMP, ceiling unlimited. Governor proposes ceiling 5000. During the 48h a borrower with 20000 IMD mints 4500 COMP (totalDebt 5500). applyPending -> CeilingBelowDebt(5000, 5500); pending remains. Borrower repays 4500 the next block and repeats on the next attempt. Expected: a validated proposal applies after the delay. Actual: it never applies while anyone cares to block it.","severity":"low","snippet":"            if (next.debtCeiling < outstanding) revert CeilingBelowDebt(next.debtCeiling, outstanding);","title":"A proposed debt ceiling can be blocked indefinitely by anyone who keeps totalDebt above it"},{"citation":"resolved","description":"applyPending only requires block.timestamp >= eta. Nothing bounds how long after eta the payload may sit, and the governor cannot be forced to apply. For question (5): the guarantee a borrower actually gets is 'these five values will not change for at least 48 hours after Proposed', not 'they change at eta'. A fee rise nobody is incentivised to apply can be applied by the governor weeks later at a chosen moment, which is the 'choosing its moment' the Governed docstring says permissionless application prevents. Exit at the old terms is still possible at any time before application (repayCOMP reads no feed and no parameter but the rate; withdrawal of debt-free collateral reads nothing), so the borrower guarantee holds for all five parameters as long as the index is checkpointed (see the two medium findings for when it is not). Consider an application window after which the proposal lapses.","line":75,"path":"src/Governed.sol","reproduction":"Governor proposes fee 1000 at T. Nobody applies. At T+60 days the governor calls applyPending; it succeeds. A borrower who checked pendingSet() at T+49h, saw a matured proposal, and assumed it was being applied has been accruing at 200 bps meanwhile and now faces 1000 bps forward with no fresh 48h signal.","severity":"info","snippet":"        if (block.timestamp < eta) revert TooEarly(eta);","title":"A pending proposal never expires; the delay is a floor and the terms can land at any later moment without new notice"},{"citation":"resolved","description":"The bound is markerShareBps + protocolBonusShareBps <= 10000, so protocolBonusShareBps = 10000 is valid. At that setting a liquidator who is not the marker receives collateralSeized - protocolCut - markerCut = exactly the principal: zero reward for bringing stablecoin, inventory risk and gas, so liquidations stop and bad debt accumulates; the protocol cut goes to FEE_RECIPIENT, which is the governor's own address. markerShareBps = 10000 has the same effect for liquidators who did not mark. This is bounded by a constant, visible for 48h and the borrower's loss is unchanged, so it is a trust assumption to document for question (4) rather than a bypass: every authority the governor reaches is bounded (fee <= 1000, divergence in [100, 2000], shares <= bonus, ceiling >= debt), Parameters reaches the vault only through totalDebt() and the permissionless pokeIndex(), and neither Parameters nor Registry names a feed, the attester, the collateral token or a position. The one bound that is economically empty is the share bound.","line":162,"path":"src/Parameters.sol","reproduction":"Governor proposes ParamSet(max, 10000, 200, 500, 0); valid. After 48h, applyPending. Liquidate 10 COMP at price 1e18: collateralSeized 11 IMD, bonus 1 IMD, protocolCut 1 IMD, liquidator receives 10 IMD for 10 COMP burned, FEE_RECIPIENT (= governor) receives 1 IMD. Expected by the design note: the governor 'cannot widen its own authority'. Actual: within the bound it can take 100% of the bonus.","severity":"info","snippet":"        if (next.markerShareBps + next.protocolBonusShareBps > BPS) {","title":"Governor can route the entire liquidation bonus to itself and zero the incentive for non-marker liquidators"},{"citation":"resolved","description":"markUnderwaterFor lets any caller name any beneficiary. A keeper who names address(relay) (or a griefer who front-runs the honest keeper's mark, since a live mark cannot be replaced) makes the relay the marker. A later DIRECT liquidation then transfers markerCut (10% of the bonus, 1% of repaid debt at the shipped share) to the relay, which has no owner and no sweep: the relay's balance-delta accounting deliberately leaves it there forever. A liquidation THROUGH the relay instead pays the marker cut to that keeper (relay == marker branch), so the only loss is to a griefer's own reward or to IMD that is burned by stranding. For question (8) otherwise: the relay cannot end a call holding either token (both deltas asserted), pulls exactly debtToRepay from msg.sender only, pays the delta to msg.sender only, is nonReentrant so no two callers' funds overlap in one call, and a hostile `vault` argument can only move tokens the caller itself supplied; no path pays another party or covers another's liquidation.","line":103,"path":"src/SwarmRelay.sol","reproduction":"Griefer calls vault.markUnderwaterFor(borrower, address(relay)) when the position is underwater. Honest keeper later calls vault.liquidate(borrower, 10e18) directly: imd.balanceOf(relay) rises by markerCut = 0.1 IMD and can never be moved. Expected: a mark reward reaches a party that can use it or is refused (as a zero beneficiary is). Actual: stranded.","severity":"info","snippet":"        if (collateral.balanceOf(address(this)) != collateralBefore) revert CollateralRetained();","title":"Marker share paid to the relay is stranded: anyone may name SwarmRelay as a mark beneficiary and the relay has no sweep"},{"citation":"resolved","description":"No code reads deployedAt since accrual moved to indexCheckpoint/indexCheckpointAt. The checkpoint change itself is correct on the honest path (question 2): _apply pokes before writing _current, pokeIndex reads stabilityFeeBps() which still returns the old rate at that moment, debtIndex() is monotone within a rate regime, and debtIndexOf is only ever set to a current debtIndex(), so debtIndex() - debtIndexOf[owner] cannot underflow when the Parameters that changes the rate is the one that pokes this vault. Reentrancy via pokeIndex is harmless (Governed clears pending before _apply; propose/cancel are governor-only). The two medium findings are the only paths to a decreasing index. Rounding: Math.mulDiv floors both the per-second index increment and the per-position fee, in the borrower's favour by at most 1 wei per accrual.","line":75,"path":"src/CDPVault.sol","reproduction":"Not a failure; a leftover immutable the adapter should remove so the ABI does not advertise a timestamp nothing uses.","severity":"info","snippet":"    uint256 public immutable deployedAt = block.timestamp;","title":"deployedAt is dead after the checkpointed index replaced the deployment-based one"}],"hash":"cfffb9f971a13799807ad526e835d2646d4bd24fe9acbf2ca9d8fe713c54e6da","nodeId":"fe9e9e15-073b-4ea2-818c-57ab8fd30571","outcome":"completed","summary":"The review is complete. Both required outputs are in place and left untracked, no source file was changed, and the tree's own suite still passes (270 tests).\n\n**Deliverables**\n- `.imd-findings.json`: 11 findings, every path, line and snippet verified byte-for-byte against the tree. Four carry Foundry proofs.\n- `artifacts/audit.md`: the report, with coverage table, findings, and a direct answer to each of the nine questions.\n- `test/scratch/`: the four proof files. Each fails on the current code and passes on a patched copy I built in a temp directory, so the adapter can use them as fix gates.\n\n**What I found, most severe first**\n- **High, SwarmRelay.** The relay is every feed's pinned relayer and has no caller check, so the relayer guard that the feeds' own docs call the only defence for stale re-anchors is gone. Anyone who buys an attestation under the price feed's domain for a question of their choosing can push any figure through the relay. The feed refuses the same bytes from them directly.\n- **Medium, Parameters.bindVault.** Any contract returning the right address from `parameters()` can be bound, with no authority. A front-run between the deploy script's second and third transactions binds the real vault's governance to attacker code forever. Reproduced: a later rate cut then underflows the fee arithmetic and freezes every borrower who accrued mid-period. It is also a post-deploy initializer, which the launch forbids.\n- **Medium, ParameterizedVault constructor.** It accepts a Parameters already bound to another vault, and Parameters' constructor binds any address passed. A vault reading a rate from a Parameters that pokes a different vault gets retroactive repricing on a rise and the same underflow freeze on a cut.\n- **Low.** Treasury.withdraw drops unsynced receipts from the running total. Registry is read by nothing on chain. Treasury is not the fee recipient (the operator's own address is). A proposed debt ceiling can be blocked indefinitely by anyone minting ahead of each application.\n- **Info.** Pending proposals never expire. The governor can route the whole liquidation bonus to itself within the bounds. A marker cut paid to the relay is stranded. `deployedAt` is dead code.\n\n**Settled as clean**\nOn the honest binding, the checkpointed index is correct: poke precedes the rate write, the index cannot decrease, and reentry finds nothing to do. The relay's custody in `relayAndLiquidate` holds: exact pull from the caller, delta paid to the caller, both balances asserted, no cross-caller path. The 48-hour delay does let a borrower exit at the old terms for all five parameters, since repayment reads no feed and no parameter but the rate.","treeHash":null,"usage":{"cachedInputTokens":2422102,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":67406,"runtime":"claude","turns":44,"wallClockMs":1151524}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"0256823ae36e7900","findings":[],"hash":"e0f716075daae0a97bf36ca063216823d9f87db73299e74bad2a92ade6fcf1d1","nodeId":"fe9e9e15-073b-4ea2-818c-57ab8fd30571","outcome":"failed","summary":"This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/settings/organization/status-and-access before retrying.","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"gpt-6-astra","outputTokens":0,"runtime":"codex","turns":3,"wallClockMs":590243}}],"verification":[]}