{"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":"24f0a52b-05e5-4f64-9781-97b23c84b8e3","kind":"audit","nodes":[{"acceptedSubmissionHash":"d5a93311941e8295a3287c7789bacdc897273c953bf5394e354ff51106c0d6ab","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"34d8fa8caa303257deaba572cd28ff4d4e01b380804d04665d2b00a91195658e","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"8e0c8e4dbae52f810b6e1869a5b49431dac89c6d10b595e3691da59d883396d6","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"a9738a1d3eaf273eab3628a6cd6a545e85ba700f2c1d1c3a52a21ef6a60de9d5","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"bd58c01853823727772a70e16dcbd1551609e7b633fd8b497e92b0687df419cd","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Security audit of Tally v0: credit lines for AI agents paying over x402, settled in USDC. CreditPool is an ERC-4626 vault that funds payments; TabManager executes agent-signed EIP-712 payment intents against owner collateral and mandates (per-payment cap, budget, expiry, Merkle seller allowlist), bills weekly statements and liquidates past-due lines; sponsored lines let one sponsor deposit back many agent lines, each with its own cap (NO_CAP, or 0 to freeze), and liquidation draws on the sponsor deposit; Escrow holds larger payments for review and dispute; ScoreRegistry stores one Merkle root of agent credit scores per daily epoch and verifies proofs on chain. Focus on: loss of lender or owner funds, accounting drift between pool.outstanding and line balances, signature replay across lines/epochs/chains, mandate budget bypass, sponsor accounting (outstanding vs. the sum of its lines, withdrawals that leave lines unbacked, cap bypass), ScoreRegistry epoch ordering, revocation and stale-record handling, escrow states that lock funds, rounding in fees and ERC-4626 share math, and reentrancy through USDC or ERC-1271 wallets. Prior reviews and fixed findings are in audits/. Report each finding with severity, location, a concrete exploit path and a fix.","parentJobId":null,"planHash":"c00483659e3974db0fa08d5f697fec6d486dc27e336cbe3fd79bf89a8cb96738","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"24f0a52b-05e5-4f64-9781-97b23c84b8e3","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":"53937","feedbackHash":"cf1d808b12029e14abe33a6f91be41edd303e7278ec0d0bd16209ed2dbf202f6","nodeKey":"audit_economics","submissionHash":"d5a93311941e8295a3287c7789bacdc897273c953bf5394e354ff51106c0d6ab","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53932","feedbackHash":"ac81251c11ea8442fe0cbe9d55a2866c39b30e34df69efb84a741edfb3df79d4","nodeKey":"audit_flow","submissionHash":"34d8fa8caa303257deaba572cd28ff4d4e01b380804d04665d2b00a91195658e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51473","feedbackHash":"215bea2cb373220ff2909b38be84b25efce3b23e76cb2d0197aade999f7e43fb","nodeKey":"audit_judge","submissionHash":"8e0c8e4dbae52f810b6e1869a5b49431dac89c6d10b595e3691da59d883396d6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51332","feedbackHash":"825512e682f900e4637d4ca13f13f9c1a11a386389a96812e01f8d4f33859bc0","nodeKey":"audit_math","submissionHash":"a9738a1d3eaf273eab3628a6cd6a545e85ba700f2c1d1c3a52a21ef6a60de9d5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52265","feedbackHash":"315f20206857a5ff4ff1a916fd4142dfb60f50451b720dd9b5cef0a0ffec7b14","nodeKey":"audit_permissions","submissionHash":"bd58c01853823727772a70e16dcbd1551609e7b633fd8b497e92b0687df419cd","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"7f22963f66f0e36b6b3c3a1d5e6bb054c88f213b7217fd099fb78e13a4a7f3d9","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8109087d965ddf6c","findings":[{"citation":"resolved","description":"publish() requires epoch > latestEpoch and epoch <= today, and revoke() leaves latestEpoch untouched. Once the publisher has published today's epoch and the admin revokes it (the documented remedy for a bad snapshot), publish(today, newRoot) reverts EpochNotNewer(today, today) and publish(today+1, ...) reverts EpochInFuture. Consumers that need a live score are left with no live snapshot for up to 24 hours, and every record() made under the revoked root reads as missing via scoreOf() with nothing able to replace them that day. Simply clearing `revoked` on republish would be wrong: scoreOf() only checks _epochs[r.epoch].revoked, so records made under the bad root would read as live again. Fix that keeps the day-number epoch and the leaked-key guarantee: add a `uint32 generation` to Epoch and to Recorded; publish() accepts epoch == latestEpoch when _epochs[epoch].revoked, writes the new root with revoked=false and generation+1; record() stores e.generation; scoreOf() and the stale-record check treat a record whose generation != the epoch's current generation as revoked. Epochs still never move backwards and a revoked publication's proofs and records stay dead.","line":83,"path":"src/ScoreRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ScoreRegistry} from \"src/ScoreRegistry.sol\";\n\n/// Revoking the snapshot published for today leaves `latestEpoch` at today, so the publisher cannot\n/// publish a corrected root for today (EpochNotNewer) and cannot publish tomorrow's (EpochInFuture).\n/// Expected: a corrected snapshot can replace a revoked one for the same day.\ncontract ProofF1 is Test {\n    ScoreRegistry reg;\n    address admin = makeAddr(\"admin\");\n    address publisher = makeAddr(\"publisher\");\n\n    function setUp() public {\n        vm.warp(1_790_000_000);\n        reg = new ScoreRegistry(admin, publisher);\n    }\n\n    function test_CorrectedSnapshotCanReplaceRevokedToday() public {\n        uint64 today = uint64(block.timestamp / 1 days);\n        vm.prank(publisher);\n        reg.publish(today, bytes32(uint256(1)), uint64(block.timestamp), 10, 1, \"ipfs://bad\");\n        vm.prank(admin);\n        reg.revoke(today);\n\n        // Fails on current code with EpochNotNewer(today, today).\n        vm.prank(publisher);\n        reg.publish(today, bytes32(uint256(2)), uint64(block.timestamp), 10, 1, \"ipfs://fixed\");\n\n        ScoreRegistry.Epoch memory e = reg.getEpoch(today);\n        assertEq(e.root, bytes32(uint256(2)), \"corrected root published\");\n        assertFalse(e.revoked, \"replacement is live\");\n    }\n}","reproduction":"Day D (vm.warp(1_790_000_000), D = 20717). publisher: publish(D, root1, now, 10, 1, uri). admin: revoke(D). publisher: publish(D, root2, now, 10, 1, uri2). Expected: corrected snapshot accepted. Actual: revert EpochNotNewer(20717, 20717); publish(D+1, ...) reverts EpochInFuture(20718). latestEpoch stays D and getEpoch(D).revoked == true, so no live snapshot exists until day D+1. Proof: test/scratch/ProofF1.t.sol fails with EpochNotNewer on current code.","severity":"low","snippet":"        if (epoch <= latestEpoch) revert EpochNotNewer(epoch, latestEpoch);","title":"ScoreRegistry: revoking today's snapshot blocks any corrected snapshot until the next UTC day; a revoked epoch can never be republished"},{"citation":"resolved","description":"The stale-record guard is skipped entirely when the currently recorded epoch is revoked. It does not require the replacement to be the newest live epoch, nor even newer than the epoch the subject was recorded under before the revoked one. A permissionless caller (the subject, or anyone who benefits from their score looking good) can therefore pick the oldest live epoch in which the subject had a high score and record it; scoreOf() then returns found=true with that score until someone else records a newer live epoch. Because record() is permissionless the honest party can overwrite it, but every on-chain consumer that reads scoreOf() in between (and does not enforce its own asOf freshness bound, which the contract leaves optional) acts on a score the Score engine has since superseded. Fix: keep a short history per subject (store the previous live Recorded alongside the current one, updated on each accepted record) and on revocation fall back to the previous record deterministically; or, cheaper, when r.epoch is revoked still require epoch > prevEpoch where prevEpoch is the last non-revoked epoch recorded for the subject. Document that scoreOf() consumers must bound asOf regardless.","line":120,"path":"src/ScoreRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ScoreRegistry} from \"src/ScoreRegistry.sol\";\n\n/// After the recorded epoch is revoked, `record` accepts ANY older live epoch, so anyone can roll a subject\n/// back to an arbitrarily old, more favourable score even though a newer live epoch exists.\n/// Expected: an epoch older than a newer live epoch that holds the subject is rejected (StaleRecord) or\n/// the registry falls back deterministically to the previous live record.\ncontract ProofF2 is Test {\n    ScoreRegistry reg;\n    address admin = makeAddr(\"admin\");\n    address publisher = makeAddr(\"publisher\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    function _leaf(address who, uint16 score) internal pure returns (ScoreRegistry.ScoreLeaf memory) {\n        return ScoreRegistry.ScoreLeaf(who, score, 200, 150, 100, 120, 200, 0, true, 12345, 1_780_000_000);\n    }\n\n    function _hash(uint64 epoch, ScoreRegistry.ScoreLeaf memory l) internal pure returns (bytes32) {\n        return keccak256(bytes.concat(keccak256(abi.encode(epoch, l))));\n    }\n\n    function _publishTwo(uint64 epoch, ScoreRegistry.ScoreLeaf memory a, ScoreRegistry.ScoreLeaf memory b)\n        internal\n        returns (bytes32[] memory pa)\n    {\n        bytes32 ha = _hash(epoch, a);\n        bytes32 hb = _hash(epoch, b);\n        bytes32 root = ha < hb ? keccak256(abi.encode(ha, hb)) : keccak256(abi.encode(hb, ha));\n        vm.prank(publisher);\n        reg.publish(epoch, root, uint64(block.timestamp), 2, 1, \"ipfs://x\");\n        pa = new bytes32[](1);\n        pa[0] = hb;\n    }\n\n    function setUp() public {\n        vm.warp(1_790_000_000);\n        reg = new ScoreRegistry(admin, publisher);\n    }\n\n    function test_RevokedRecord_DoesNotAllowArbitraryOldEpoch() public {\n        bytes32[] memory p1 = _publishTwo(1, _leaf(alice, 950), _leaf(bob, 300));\n        vm.warp(block.timestamp + 1 days);\n        bytes32[] memory p2 = _publishTwo(2, _leaf(alice, 400), _leaf(bob, 300));\n        vm.warp(block.timestamp + 1 days);\n        bytes32[] memory p3 = _publishTwo(3, _leaf(alice, 350), _leaf(bob, 300));\n        reg.record(2, _leaf(alice, 400), p2);\n        reg.record(3, _leaf(alice, 350), p3);\n        vm.prank(admin);\n        reg.revoke(3);\n\n        // Epoch 2 is live, newer than epoch 1, and was recorded for alice. Recording epoch 1 must not win.\n        // Fails on current code: record(1) succeeds and scoreOf reports 950 from epoch 1.\n        vm.expectRevert();\n        reg.record(1, _leaf(alice, 950), p1);\n        (, uint16 score,, uint64 ep,) = reg.scoreOf(alice);\n        assertTrue(ep != 1, \"subject must not be rolled back to epoch 1\");\n        assertTrue(score != 950, \"stale favourable score must not be readable\");\n    }\n}","reproduction":"Epochs 1, 2, 3 published on consecutive days with alice's scores 950, 400, 350. record(2, alice) then record(3, alice): scoreOf(alice) = (true, 350, epoch 3). admin revoke(3). Attacker calls record(1, leaf(alice, 950), proof1). Expected: rejected (StaleRecord) because epoch 2 is live, newer than 1 and already recorded for alice, or scoreOf falls back to epoch 2. Actual: record(1) succeeds and scoreOf(alice) returns (true, 950, agentLikely, epoch 1, asOf of epoch 1). Proof: test/scratch/ProofF2.t.sol fails with 'next call did not revert as expected' on current code.","severity":"low","snippet":"        if (epoch <= r.epoch && !_epochs[r.epoch].revoked) revert StaleRecord(epoch, r.epoch);","title":"ScoreRegistry.record: once the recorded epoch is revoked, any older live epoch is accepted, so anyone can roll a subject back to an arbitrarily old favourable score"},{"citation":"resolved","description":"setMandate, revokeMandate and setAgent each do ++epoch, and the NatSpec and README state that this 'invalidates older signed intents'. The epoch is only a sequential counter checked for equality, so an agent (or anyone holding the agent key) can sign intents today for epoch+1, epoch+2, ... with a long deadline. Each future owner action lands on exactly one of those values and the matching pre-signed intents become payable under the new mandate. The new mandate's cap, budget, expiry and seller list still apply, so this does not bypass the mandate; the gap is that the owner's documented kill-switch for already-signed intents does not hold for setMandate/revokeMandate when the agent key is kept, and an owner who disables a line with revokeMandate and re-enables it later will see intents signed before the pause execute immediately. Fix: bind the signature to the mandate's content rather than a counter, e.g. include keccak256(abi.encode(maxPerPayment, budget, validUntil, sellersRoot, mandateVersion)) in the signed struct, or make the epoch unpredictable (e.g. epoch = uint32(uint256(keccak256(abi.encode(lineId, oldEpoch, block.prevrandao, msg.sender))))). cancelNonce remains available as a per-intent fallback and should be documented as the only reliable kill-switch while the counter design stays.","line":372,"path":"src/TabManager.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CreditPool} from \"src/CreditPool.sol\";\nimport {TabManager} from \"src/TabManager.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract USDCMock is ERC20 {\n    constructor() ERC20(\"USD Coin\", \"USDC\") {}\n    function decimals() public pure override returns (uint8) { return 6; }\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\n/// The line epoch is a predictable counter, so an agent can pre-sign intents for epoch+2 today.\n/// `revokeMandate` then `setMandate` lands on exactly that epoch and the pre-signed intent is accepted,\n/// contradicting \"invalidates older signed intents\" for both calls.\n/// Expected: an intent signed before a mandate change is never payable after it.\ncontract ProofF3 is Test {\n    function test_IntentSignedBeforeMandateChange_IsRejected() public {\n        address admin = makeAddr(\"admin\");\n        USDCMock usdc = new USDCMock();\n        CreditPool pool = new CreditPool(usdc, admin, 0);\n        TabManager tabs = new TabManager(usdc, pool, admin, makeAddr(\"treasury\"));\n        bytes32 mgr = pool.MANAGER_ROLE();\n        vm.prank(admin);\n        pool.grantRole(mgr, address(tabs));\n        address lp = makeAddr(\"lp\");\n        usdc.mint(lp, 1_000_000e6);\n        vm.startPrank(lp);\n        usdc.approve(address(pool), type(uint256).max);\n        pool.deposit(1_000_000e6, lp);\n        vm.stopPrank();\n\n        uint256 agentPk = 0xA11CE;\n        address owner = makeAddr(\"owner\");\n        usdc.mint(owner, 10_000e6);\n        vm.startPrank(owner);\n        usdc.approve(address(tabs), type(uint256).max);\n        uint256 lineId = tabs.openLine(vm.addr(agentPk));\n        tabs.depositCollateral(lineId, 1_000e6);\n        tabs.setMandate(lineId, 100e6, 500e6, uint64(block.timestamp + 30 days), bytes32(0)); // epoch 1\n        vm.stopPrank();\n\n        // Agent pre-signs for epoch 3 while the current epoch is 1.\n        TabManager.PaymentIntent memory p = TabManager.PaymentIntent({\n            lineId: lineId, epoch: 3, seller: makeAddr(\"seller\"), amount: 50e6,\n            resource: keccak256(\"r\"), escrowed: false, nonce: 7, deadline: block.timestamp + 365 days\n        });\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentPk, tabs.hashIntent(p));\n        bytes memory sig = abi.encodePacked(r, s, v);\n\n        vm.startPrank(owner);\n        tabs.revokeMandate(lineId); // epoch 2\n        tabs.setMandate(lineId, 100e6, 500e6, uint64(block.timestamp + 30 days), bytes32(0)); // epoch 3\n        vm.stopPrank();\n\n        // Fails on current code: pay succeeds and the line owes 50 USDC from an intent signed two changes ago.\n        vm.expectRevert();\n        tabs.pay(p, sig, new bytes32[](0));\n        assertEq(tabs.outstandingOf(lineId), 0, \"pre-signed intent must not spend\");\n    }\n}","reproduction":"Line with epoch 1 and a live mandate (100 USDC cap, 500 USDC budget). Agent signs PaymentIntent{epoch: 3, amount: 50e6, nonce: 7, deadline: now+365d}. Owner calls revokeMandate (epoch 2) and later setMandate(...) (epoch 3). Anyone submits the old signature: pay() succeeds and outstandingOf(line) = 50e6. Expected per NatSpec of setMandate/revokeMandate: an intent signed before either change is rejected (StaleEpoch). Proof: test/scratch/ProofF3.t.sol fails with 'next call did not revert as expected' on current code.","severity":"low","snippet":"        if (p.epoch != l.epoch) revert StaleEpoch();","title":"TabManager.pay: the line epoch is a predictable counter, so intents pre-signed for a future epoch survive revokeMandate/setMandate"},{"citation":"resolved","description":"Escrow stores rulingEndsAt at flag time (fixed after the previous audit) but TabManager still derives the statement due date from the live paymentWindow in _roll (line 456) and the liquidation threshold from the live gracePeriod in liquidate (line 546). Only DEFAULT_ADMIN_ROLE can trigger it, so this is a trust assumption rather than a bypass, but it is one the README's 'card statement' promise does not mention: a parameter change intended for future cycles also moves the deadline of every open statement. Fix: snapshot paymentWindow into statementDue when the cycle closes (already the case once set) and store a per-line liquidationAt = statementDue + gracePeriod at that moment; liquidate compares against the stored value.","line":546,"path":"src/TabManager.sol","reproduction":"Line spends 100 USDC at t0; cycle closes t0+7d, statementDue t0+10d. At t0+11d (inside the 2-day grace) admin calls setParams(10000, 100, 7 days, 3 days, 0). Expected: the open statement keeps its agreed t0+12d liquidation time. Actual: liquidate(lineId) succeeds immediately at t0+11d (block.timestamp > statementDue + 0) and seizes 100 USDC of collateral.","severity":"info","snippet":"        if (!(l.billed > 0 && block.timestamp > uint256(l.statementDue) + gracePeriod)) revert NotPastDue();","title":"liquidate and _roll read live gracePeriod/paymentWindow, so an admin setParams call retroactively changes due dates and can make lines liquidatable in the same block"},{"citation":"resolved","description":"Escrow(usdc_, manager_, admin) stores both addresses unchecked; TabManager.setEscrow can be called exactly once (EscrowAlreadySet) and Escrow.manager is immutable. If the deploy wiring passes the wrong manager (or address(0)) the TabManager points at an Escrow whose open() always reverts OnlyManager, every intent with escrowed=true reverts forever, and neither side can be re-pointed without redeploying TabManager (which also abandons all collateral accounting). Deploy.s.sol wires it correctly today; this hardens against the manual deployments the README describes. Fix: in the Escrow constructor revert on address(0) for usdc_ and manager_; in TabManager.setEscrow require IEscrow(escrow_).manager() == address(this) (expose manager() on IEscrow).","line":71,"path":"src/Escrow.sol","reproduction":"new Escrow(usdc, ITabManager(address(0)), admin); tabs.setEscrow(address(escrow)). Expected: deployment rejected. Actual: both calls succeed; afterwards pay(p with escrowed=true) reverts OnlyManager from Escrow.open for every intent, and setEscrow(correct) reverts EscrowAlreadySet.","severity":"info","snippet":"        manager = manager_;","title":"Escrow constructor does not validate usdc/manager, and TabManager.setEscrow is one-shot, so a mis-wired Escrow permanently disables escrowed payments"},{"citation":"resolved","description":"The NatSpec says 'Resets the budget counter', so this is intended, but the only way to extend a mandate's expiry, change its seller list or lower its per-payment cap is to call setMandate, and every such call also zeroes spent. An owner who has watched the agent consume 480 of a 500 USDC budget and extends validUntil by a week unknowingly authorises another 500 USDC. This is operator-facing rather than exploitable by outsiders. Fix: add a keepSpent flag or a separate setMandateExpiry(lineId, validUntil) that bumps the epoch without resetting spent, and make the README explicit that setMandate re-arms the budget.","line":273,"path":"src/TabManager.sol","reproduction":"Mandate (100 cap, 500 budget, validUntil T). Agent spends 480 USDC. Owner calls setMandate(lineId, 100e6, 500e6, T + 7 days, root) to extend expiry. Expected (owner intent): 20 USDC of budget remains. Actual: mandates[lineId].spent == 0 and the agent can spend another 500 USDC against collateral.","severity":"info","snippet":"        mandates[lineId] = Mandate(maxPerPayment, budget, 0, validUntil, sellersRoot);","title":"setMandate always resets spent to 0, so extending only validUntil re-grants the agent the full budget"}],"hash":"34d8fa8caa303257deaba572cd28ff4d4e01b380804d04665d2b00a91195658e","nodeId":"ce76d510-5b76-46f3-8da9-f6fe0d64edfe","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds the final results.\n\n**Outcome.** No critical, high or medium defects were substantiated. The core money paths hold up under concrete tracing: `pool.outstanding` moves in lockstep with every line's billed + unbilled + held through pay, repay, refund, release and liquidation. Sponsor totals track the sum of their lines, and `sponsorFreeCollateral` rounds against the lines so a withdrawal cannot leave them unbacked. At the capped 100% LTV no liquidation can realise a loss, so lender principal is safe. Intents bind line, epoch, nonce, chain and contract. ERC-1271 checks use staticcall, USDC has no hooks, and the escrow state machine always has an exit (verifier, anyone after the review window, arbiter, or expiry). All 82 project tests pass after installing the pinned OpenZeppelin v5.1.0 and forge-std v1.9.4.\n\n**Findings written, six total.**\n\n- **Low, ScoreRegistry line 83.** Revoking today's snapshot leaves `latestEpoch` in place, so a corrected root for the same day reverts and tomorrow's is still in the future. No live snapshot exists until the next UTC day. Proof test fails with EpochNotNewer.\n- **Low, ScoreRegistry line 120.** When the recorded epoch is revoked, `record` accepts any older live epoch, so anyone can roll a subject back to its most favourable historical score even though a newer live epoch exists. Proof test fails.\n- **Low, TabManager line 372.** The line epoch is a predictable counter. An agent can pre-sign intents for epoch+2, and `revokeMandate` followed by `setMandate` lands exactly there, so the pre-signed intent pays. The new mandate still bounds it, but the documented kill-switch does not hold. Proof test fails.\n- **Info, TabManager line 546.** `liquidate` and `_roll` read the live grace and payment-window parameters, so an admin `setParams` call retroactively moves open deadlines. Trust assumption, documented with a concrete state.\n- **Info, Escrow line 71.** The constructor accepts a zero or wrong manager and `setEscrow` is one-shot, so a mis-wired deployment permanently disables escrowed payments.\n- **Info, TabManager line 273.** `setMandate` always zeroes `spent`, so extending only the expiry re-arms the full budget.\n\nEach of the three low findings carries a self-contained Foundry proof under `test/scratch/` that fails on the current code and encodes the expected behaviour. Suggested fixes are in the file, including a generation counter so a revoked epoch can be republished without resurrecting records made under the bad root.\n\n**Not covered.** No Slither or long fuzz runs beyond the project's own 512-run fuzz and 128x64 invariant suite. Deployment on a live network and the Deploy script's keystore behaviour were read but not executed.","treeHash":null,"usage":{"cachedInputTokens":1114246,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":46977,"runtime":"claude","turns":31,"wallClockMs":751730}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dc34db8e17664ebd","findings":[{"citation":"resolved","description":"publish() requires epoch > latestEpoch and epoch <= block.timestamp / 1 days, and revoke() leaves latestEpoch unchanged. Once today's epoch is published and revoked (the documented recovery for a bad snapshot or one pushed by a leaked publisher key), the publisher cannot publish a corrected root for the same day (EpochNotNewer) nor tomorrow's (EpochInFuture). Until 00:00 UTC verify() is false for every leaf of that day, record() for that day is impossible, scoreOf() reports every subject whose record points at the revoked epoch as found=false, and latestEpoch points at a dead root. A holder of a leaked publisher key can repeat this at the start of every UTC day until the role is rotated, so the NatSpec claim that 'a leaked publisher key can't block future snapshots' holds only for later days. Merged from four specialists (audit_math, audit_permissions, audit_flow, audit_economics). Fix: allow publish() to overwrite an epoch whose record is revoked (accept epoch == latestEpoch when _epochs[epoch].revoked), and to keep records made under the bad root dead, add a generation counter to Epoch and Recorded (publish bumps it; record() stores it; scoreOf() and the stale-record check treat a record whose generation differs from the epoch's current generation as revoked). Alternatively have revoke() of the latest epoch roll latestEpoch back to the highest non-revoked epoch.","line":83,"path":"src/ScoreRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ScoreRegistry} from \"src/ScoreRegistry.sol\";\n\n/// Revoking the snapshot published for today leaves `latestEpoch` at today, so the publisher cannot\n/// publish a corrected root for today (EpochNotNewer) and cannot publish tomorrow's (EpochInFuture).\n/// Expected: a corrected snapshot can replace a revoked one for the same day.\ncontract ProofF1 is Test {\n    ScoreRegistry reg;\n    address admin = makeAddr(\"admin\");\n    address publisher = makeAddr(\"publisher\");\n\n    function setUp() public {\n        vm.warp(1_790_000_000);\n        reg = new ScoreRegistry(admin, publisher);\n    }\n\n    function test_CorrectedSnapshotCanReplaceRevokedToday() public {\n        uint64 today = uint64(block.timestamp / 1 days);\n        vm.prank(publisher);\n        reg.publish(today, bytes32(uint256(1)), uint64(block.timestamp), 10, 1, \"ipfs://bad\");\n        vm.prank(admin);\n        reg.revoke(today);\n\n        // Fails on current code with EpochNotNewer(today, today).\n        vm.prank(publisher);\n        reg.publish(today, bytes32(uint256(2)), uint64(block.timestamp), 10, 1, \"ipfs://fixed\");\n\n        ScoreRegistry.Epoch memory e = reg.getEpoch(today);\n        assertEq(e.root, bytes32(uint256(2)), \"corrected root published\");\n        assertFalse(e.revoked, \"replacement is live\");\n    }\n}","reproduction":"vm.warp(1_790_000_000) (day 20717). publisher: publish(20717, root1, now, 10, 1, uri) succeeds, latestEpoch = 20717. admin: revoke(20717). publisher: publish(20717, root2, now, 10, 1, uri2). Expected: corrected snapshot accepted. Actual: revert EpochNotNewer(20717, 20717); publish(20718, ...) reverts EpochInFuture(20718); getEpoch(20717).revoked == true and no live root exists for the day. Reproduced with the attached test, which fails on current code with EpochNotNewer(20717, 20717).","severity":"low","snippet":"        if (epoch <= latestEpoch) revert EpochNotNewer(epoch, latestEpoch);","title":"ScoreRegistry: a revoked epoch can never be republished, so revoking today's snapshot leaves no live root until the next UTC day"},{"citation":"resolved","description":"The stale-record guard is skipped entirely when the subject's currently recorded epoch is revoked. It does not require the replacement to be the newest live epoch, nor newer than the epoch the subject was recorded under before the revoked one. record() is permissionless, so the subject (or anyone benefiting from their score looking good) can pick the oldest live epoch in which the subject had a high score and record it; scoreOf() then returns found=true with that score until someone records a newer live epoch. Any on-chain consumer that reads scoreOf() without enforcing its own asOf bound acts on a score the Score engine superseded long ago. Fix: when r.epoch is revoked, still require epoch to be greater than the last non-revoked epoch recorded for the subject (keep the previous live Recorded alongside the current one and fall back to it deterministically on revocation), and document that scoreOf() consumers must bound asOf regardless.","line":120,"path":"src/ScoreRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ScoreRegistry} from \"src/ScoreRegistry.sol\";\n\n/// After the recorded epoch is revoked, `record` accepts ANY older live epoch, so anyone can roll a subject\n/// back to an arbitrarily old, more favourable score even though a newer live epoch exists.\n/// Expected: an epoch older than a newer live epoch that holds the subject is rejected (StaleRecord) or\n/// the registry falls back deterministically to the previous live record.\ncontract ProofF2 is Test {\n    ScoreRegistry reg;\n    address admin = makeAddr(\"admin\");\n    address publisher = makeAddr(\"publisher\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    function _leaf(address who, uint16 score) internal pure returns (ScoreRegistry.ScoreLeaf memory) {\n        return ScoreRegistry.ScoreLeaf(who, score, 200, 150, 100, 120, 200, 0, true, 12345, 1_780_000_000);\n    }\n\n    function _hash(uint64 epoch, ScoreRegistry.ScoreLeaf memory l) internal pure returns (bytes32) {\n        return keccak256(bytes.concat(keccak256(abi.encode(epoch, l))));\n    }\n\n    function _publishTwo(uint64 epoch, ScoreRegistry.ScoreLeaf memory a, ScoreRegistry.ScoreLeaf memory b)\n        internal\n        returns (bytes32[] memory pa)\n    {\n        bytes32 ha = _hash(epoch, a);\n        bytes32 hb = _hash(epoch, b);\n        bytes32 root = ha < hb ? keccak256(abi.encode(ha, hb)) : keccak256(abi.encode(hb, ha));\n        vm.prank(publisher);\n        reg.publish(epoch, root, uint64(block.timestamp), 2, 1, \"ipfs://x\");\n        pa = new bytes32[](1);\n        pa[0] = hb;\n    }\n\n    function setUp() public {\n        vm.warp(1_790_000_000);\n        reg = new ScoreRegistry(admin, publisher);\n    }\n\n    function test_RevokedRecord_DoesNotAllowArbitraryOldEpoch() public {\n        bytes32[] memory p1 = _publishTwo(1, _leaf(alice, 950), _leaf(bob, 300));\n        vm.warp(block.timestamp + 1 days);\n        bytes32[] memory p2 = _publishTwo(2, _leaf(alice, 400), _leaf(bob, 300));\n        vm.warp(block.timestamp + 1 days);\n        bytes32[] memory p3 = _publishTwo(3, _leaf(alice, 350), _leaf(bob, 300));\n        reg.record(2, _leaf(alice, 400), p2);\n        reg.record(3, _leaf(alice, 350), p3);\n        vm.prank(admin);\n        reg.revoke(3);\n\n        // Epoch 2 is live, newer than epoch 1, and was recorded for alice. Recording epoch 1 must not win.\n        // Fails on current code: record(1) succeeds and scoreOf reports 950 from epoch 1.\n        vm.expectRevert();\n        reg.record(1, _leaf(alice, 950), p1);\n        (, uint16 score,, uint64 ep,) = reg.scoreOf(alice);\n        assertTrue(ep != 1, \"subject must not be rolled back to epoch 1\");\n        assertTrue(score != 950, \"stale favourable score must not be readable\");\n    }\n}","reproduction":"Epochs 1, 2, 3 published on consecutive days with alice's scores 950, 400, 350 (two-leaf trees). record(2, alice) then record(3, alice): scoreOf(alice) = (true, 350, epoch 3). admin: revoke(3). Anyone: record(1, leaf(alice, 950), proof1). Expected: rejected (StaleRecord) because epoch 2 is live, newer than 1 and already recorded for alice, or scoreOf falls back to epoch 2. Actual: record(1) succeeds and scoreOf(alice) returns (true, 950, agentLikely, epoch 1, asOf of epoch 1). Reproduced with the attached test, which fails on current code with 'next call did not revert as expected'.","severity":"low","snippet":"        if (epoch <= r.epoch && !_epochs[r.epoch].revoked) revert StaleRecord(epoch, r.epoch);","title":"ScoreRegistry.record: once the recorded epoch is revoked, any older live epoch is accepted, so anyone can roll a subject back to an arbitrarily old favourable score"},{"citation":"resolved","description":"setMandate, revokeMandate and setAgent each do ++epoch, and their NatSpec plus the README state that this 'invalidates older signed intents'. The epoch is a sequential counter checked for equality only, so an agent (or whoever holds the agent key) can sign intents today for epoch+1, epoch+2, ... with a long deadline. Each future owner action lands on exactly one of those values and the matching pre-signed intents become payable under the new mandate. The new mandate's cap, budget, expiry and seller list still apply, so this is not a mandate bypass; the gap is that the owner's documented kill-switch for already-signed intents does not hold for setMandate/revokeMandate while the agent key is kept. An owner who pauses a line with revokeMandate and re-enables it later sees intents signed before the pause execute immediately. Fix: bind the signature to the mandate content, e.g. include mandateVersion (already tracked) or keccak256(abi.encode(maxPerPayment, budget, validUntil, sellersRoot)) in the signed struct, or derive the new epoch unpredictably (hash of lineId, old epoch and block.prevrandao). Document cancelNonce as the only reliable per-intent kill-switch while the counter design stays.","line":372,"path":"src/TabManager.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CreditPool} from \"src/CreditPool.sol\";\nimport {TabManager} from \"src/TabManager.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract USDCMock is ERC20 {\n    constructor() ERC20(\"USD Coin\", \"USDC\") {}\n    function decimals() public pure override returns (uint8) { return 6; }\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\n/// The line epoch is a predictable counter, so an agent can pre-sign intents for epoch+2 today.\n/// `revokeMandate` then `setMandate` lands on exactly that epoch and the pre-signed intent is accepted,\n/// contradicting \"invalidates older signed intents\" for both calls.\n/// Expected: an intent signed before a mandate change is never payable after it.\ncontract ProofF3 is Test {\n    function test_IntentSignedBeforeMandateChange_IsRejected() public {\n        address admin = makeAddr(\"admin\");\n        USDCMock usdc = new USDCMock();\n        CreditPool pool = new CreditPool(usdc, admin, 0);\n        TabManager tabs = new TabManager(usdc, pool, admin, makeAddr(\"treasury\"));\n        bytes32 mgr = pool.MANAGER_ROLE();\n        vm.prank(admin);\n        pool.grantRole(mgr, address(tabs));\n        address lp = makeAddr(\"lp\");\n        usdc.mint(lp, 1_000_000e6);\n        vm.startPrank(lp);\n        usdc.approve(address(pool), type(uint256).max);\n        pool.deposit(1_000_000e6, lp);\n        vm.stopPrank();\n\n        uint256 agentPk = 0xA11CE;\n        address owner = makeAddr(\"owner\");\n        usdc.mint(owner, 10_000e6);\n        vm.startPrank(owner);\n        usdc.approve(address(tabs), type(uint256).max);\n        uint256 lineId = tabs.openLine(vm.addr(agentPk));\n        tabs.depositCollateral(lineId, 1_000e6);\n        tabs.setMandate(lineId, 100e6, 500e6, uint64(block.timestamp + 30 days), bytes32(0)); // epoch 1\n        vm.stopPrank();\n\n        // Agent pre-signs for epoch 3 while the current epoch is 1.\n        TabManager.PaymentIntent memory p = TabManager.PaymentIntent({\n            lineId: lineId, epoch: 3, seller: makeAddr(\"seller\"), amount: 50e6,\n            resource: keccak256(\"r\"), escrowed: false, nonce: 7, deadline: block.timestamp + 365 days\n        });\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentPk, tabs.hashIntent(p));\n        bytes memory sig = abi.encodePacked(r, s, v);\n\n        vm.startPrank(owner);\n        tabs.revokeMandate(lineId); // epoch 2\n        tabs.setMandate(lineId, 100e6, 500e6, uint64(block.timestamp + 30 days), bytes32(0)); // epoch 3\n        vm.stopPrank();\n\n        // Fails on current code: pay succeeds and the line owes 50 USDC from an intent signed two changes ago.\n        vm.expectRevert();\n        tabs.pay(p, sig, new bytes32[](0));\n        assertEq(tabs.outstandingOf(lineId), 0, \"pre-signed intent must not spend\");\n    }\n}","reproduction":"Line with epoch 1 and a live mandate (100 USDC cap, 500 USDC budget). Agent signs PaymentIntent{epoch: 3, amount: 50e6, nonce: 7, deadline: now+365d}. Owner calls revokeMandate (epoch 2) and later setMandate(...) (epoch 3). Anyone submits the old signature: pay() succeeds and outstandingOf(line) == 50e6. Expected per NatSpec of setMandate/revokeMandate: an intent signed before either change is rejected (StaleEpoch). Reproduced with the attached test, which fails on current code with 'next call did not revert as expected'.","severity":"low","snippet":"        if (p.epoch != l.epoch) revert StaleEpoch();","title":"TabManager.pay: the line epoch is a predictable counter, so intents pre-signed for a future epoch survive revokeMandate/setMandate"},{"citation":"resolved","description":"A statement stores only statementDue (fixed at roll time); the grace that follows is added from the live gracePeriod in liquidate() (line 546) and isPastDue() (line 636), and paymentWindow is read live in _roll() (line 456) when the cycle closes rather than when it opened. setParams (DEFAULT_ADMIN_ROLE) may set gracePeriod anywhere in [0, 14 days], so one admin transaction retroactively moves the deadline of every open statement: lowering it makes statements that are 1 second past due liquidatable by anyone immediately, seizing billed plus unbilled from owners and sponsors still inside the window they were told they had; raising it postpones liquidation of lines already in default. The same retroactivity class was fixed in Escrow after the 2026-10-22 review (rulingEndsAt snapshotted at flag time), so TabManager is now asymmetric with Escrow. Admin-triggered, so a trust assumption rather than an outsider exploit, but the contract does not enforce the grace promise it documents. Merged from four specialists. Fix: when the statement is issued in _roll, store the full deadline (e.g. uint64 liquidatableAt = cycleEnd + paymentWindow + gracePeriod, set only when statementDue is set) and compare block.timestamp against it in liquidate()/isPastDue(); use the live parameters only for cycles opened after the change.","line":546,"path":"src/TabManager.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CreditPool} from \"src/CreditPool.sol\";\nimport {TabManager} from \"src/TabManager.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract USDCMock is ERC20 {\n    constructor() ERC20(\"USD Coin\", \"USDC\") {}\n    function decimals() public pure override returns (uint8) { return 6; }\n    function mint(address to, uint256 a) external { _mint(to, a); }\n}\n\n/// liquidate() adds the live `gracePeriod` to a statement's stored `statementDue`, so an admin\n/// setParams() call retimes every statement already issued. A statement issued under a 2-day grace\n/// must keep that grace: after the admin lowers gracePeriod to 0, liquidate() must still revert\n/// NotPastDue until the deadline that applied when the statement was issued.\ncontract ProofGrace is Test {\n    function test_IssuedStatementKeepsItsGrace() public {\n        vm.warp(1_800_000_000);\n        address admin = makeAddr(\"admin\");\n        USDCMock usdc = new USDCMock();\n        CreditPool pool = new CreditPool(usdc, admin, 0);\n        TabManager tabs = new TabManager(usdc, pool, admin, makeAddr(\"treasury\"));\n        bytes32 mgr = pool.MANAGER_ROLE();\n        vm.prank(admin);\n        pool.grantRole(mgr, address(tabs));\n        address lp = makeAddr(\"lp\");\n        usdc.mint(lp, 1_000_000e6);\n        vm.startPrank(lp);\n        usdc.approve(address(pool), type(uint256).max);\n        pool.deposit(1_000_000e6, lp);\n        vm.stopPrank();\n\n        uint256 agentPk = 0xA11CE;\n        address owner = makeAddr(\"owner\");\n        usdc.mint(owner, 10_000e6);\n        vm.startPrank(owner);\n        usdc.approve(address(tabs), type(uint256).max);\n        uint256 lineId = tabs.openLine(vm.addr(agentPk));\n        tabs.depositCollateral(lineId, 1_000e6);\n        tabs.setMandate(lineId, 100e6, 500e6, uint64(block.timestamp + 30 days), bytes32(0));\n        vm.stopPrank();\n\n        uint256 t0 = block.timestamp;\n        TabManager.PaymentIntent memory p = TabManager.PaymentIntent({\n            lineId: lineId, epoch: tabs.getLine(lineId).epoch, seller: makeAddr(\"seller\"), amount: 100e6,\n            resource: keccak256(\"r\"), escrowed: false, nonce: 1, deadline: block.timestamp + 1 hours\n        });\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentPk, tabs.hashIntent(p));\n        tabs.pay(p, abi.encodePacked(r, s, v), new bytes32[](0));\n\n        // Cycle closes at t0+7d, statement due t0+10d, grace to t0+12d. We are 1 second past due.\n        vm.warp(t0 + 10 days + 1);\n        vm.expectRevert(TabManager.NotPastDue.selector);\n        tabs.liquidate(lineId);\n\n        // Admin lowers the grace period for future statements.\n        vm.prank(admin);\n        tabs.setParams(10_000, 100, 7 days, 3 days, 0);\n\n        // Fails on current code: liquidate succeeds immediately and seizes 100 USDC of collateral.\n        vm.expectRevert(TabManager.NotPastDue.selector);\n        tabs.liquidate(lineId);\n        assertEq(tabs.getLine(lineId).collateral, 1_000e6, \"collateral seized 2 days early\");\n    }\n}","reproduction":"Default params (billingPeriod 7d, paymentWindow 3d, gracePeriod 2d). Line pays 100 USDC direct at t0; cycleEnd = t0+7d; statementDue = t0+10d. Warp to t0+10d+1s: isPastDue(lineId) == false and liquidate(lineId) reverts NotPastDue (owner is inside the 2-day grace). Admin calls setParams(10000, 100, 7 days, 3 days, 0). Same block, anyone calls liquidate(lineId): Expected: revert NotPastDue until t0+12d. Actual: succeeds, seized == 100e6, collateral drops from 1,000 to 900 USDC. Reproduced with the attached test, which fails on current code with 'next call did not revert as expected'.","severity":"low","snippet":"        if (!(l.billed > 0 && block.timestamp > uint256(l.statementDue) + gracePeriod)) revert NotPastDue();","title":"liquidate() and _roll() read gracePeriod/paymentWindow live, so an admin setParams call retimes statements already issued and can make lines liquidatable in the same block"},{"citation":"resolved","description":"Lenders' only return is poolFeeShareBps of each seller fee, which _distributeFee() transfers to the pool as a plain USDC transfer inside pay() (and inside Escrow release via onRelease). CreditPool.totalAssets() = idle + outstanding, so the tpUSDC share price jumps at that instant and the gain is split pro rata among whoever holds shares in that block. ERC-4626 deposit and redeem have no cooldown, entry/exit fee or yield smoothing, and redemption is limited only by idle cash, so a searcher who sees a large direct payment (or a claim()/pass() of a large hold after reviewEndsAt) in the mempool can deposit immediately before it and redeem immediately after, capturing a share of the lender fee proportional to the capital it adds while bearing none of the lock-up or default risk. Impact per sandwich is bounded by poolFeeShareBps * sellerFeeBps of the payment (0.5% by default) times the attacker's share of the pool, so this is a yield leak, not a principal loss. Merged from audit_math and audit_economics. Fix: accrue the pool's fee share over time (e.g. linear unlocking of received fees into totalAssets with a lastFeeTime/lockedFees schedule), or impose a minimum share holding period / withdrawal cooldown.","line":485,"path":"src/TabManager.sol","reproduction":"Pool holds 1,000,000 USDC from LP. A line with 200,000 USDC collateral and a mandate allowing 100,000 USDC per payment has a signed direct intent for 100,000 USDC pending (fee 1,000 USDC at 100 bps, 500 USDC to the pool). Attacker deposits 1,000,000 USDC (now ~50% of shares), pay() executes, attacker redeems all shares in the same block. Expected: a zero-duration depositor earns nothing (gets back 1,000,000 USDC). Actual: attacker receives 1,000,249.999999 USDC (logged 1000249999999 base units), i.e. 249.999999 USDC of the 500 USDC lender fee; LP's yield for that payment is halved. Reproduced in test/scratch/Repro.t.sol::test_feeSandwich during this review.","severity":"low","snippet":"        if (toPool > 0) usdc.safeTransfer(address(pool), toPool);","title":"Pool fee share is donated to CreditPool in the same transaction, so a transient depositor can sandwich large payments and take lenders' yield with no exposure"},{"citation":"resolved","description":"withdrawCollateral / withdrawSponsorCollateral are restricted to the owner, but depositCollateral (line 244) and depositSponsorCollateral (line 299) only require that the id exists. Line and sponsor ids are sequential (nextLineId++, nextSponsorId++), so an owner who opens a line in one transaction and deposits in a second transaction using the id they expect (nextLineId read off chain or from a simulation) can be front-run: an attacker calls openLine first and takes that id, the victim's openLine gets id+1, and the victim's depositCollateral(id, amount) succeeds into the attacker's line. The attacker, as that line's owner, withdraws the collateral with withdrawCollateral(id, amount, attacker) because the line has no balance. The same holds for sponsors. The contract offers no deposit-at-open path or owner check that would make the misdirected transfer fail. Fix: require msg.sender == owner in the deposit functions, or add an expectedOwner parameter that must match (revert on mismatch), or let openLine/openSponsor take an initial collateral amount so the common path is atomic.","line":245,"path":"src/TabManager.sol","reproduction":"expected = nextLineId(). Attacker: openLine(attacker) -> returns expected. Victim: openLine(victim) -> returns expected+1. Victim: approve, depositCollateral(expected, 1000e6) -> succeeds (only UnknownLine and SponsoredLine are checked). Attacker: withdrawCollateral(expected, 1000e6, attacker) -> succeeds; attacker USDC balance == 1000e6. Expected: a deposit into a line the caller does not own is rejected or cannot be misdirected by id prediction. Actual: victim loses the 1,000 USDC deposit. Reproduced in test/scratch/Repro.t.sol::test_depositFrontRun during this review.","severity":"low","snippet":"        if (_lines[lineId].owner == address(0)) revert UnknownLine();","title":"depositCollateral and depositSponsorCollateral accept deposits from anyone into any id, so a front-run openLine/openSponsor diverts a deposit sent by predicted id to an attacker who can withdraw it"},{"citation":"resolved","description":"fee = ceil(amount * sellerFeeBps / 10000). At the default 100 bps every amount in [1, 100] pays a 1-unit fee, so the effective rate is 1/amount: 100% at amount = 1, 2% at 50. At amount = 1 the seller receives amount - fee = 0 while pay() succeeds, emits Paid, consumes the nonce, charges the mandate budget and bills the owner 1 unit; the direct branch transfers 0 USDC to the seller, which USDC accepts. The 1-unit fee then splits floor(1 * 5000 / 10000) = 0 to the pool and 1 to the treasury. x402 is a micro-payment protocol, so prices of 0.0001 USDC and below are plausible for metered endpoints. The prior review flagged the opposite rounding (fee to zero); the fix moved the asymmetry onto the seller. No third-party loss, bounded to dust, so informational. Merged from three specialists. Fix: revert when amount - fee == 0 (or require amount >= BPS / sellerFeeBps), or round the fee down and accept zero-fee dust; document the minimum economic payment size.","line":384,"path":"src/TabManager.sol","reproduction":"Default params (sellerFeeBps 100, poolFeeShareBps 5000), line with a mandate allowing the amounts. pay() with p.amount = 1, escrowed = false: Expected: seller receives a positive amount or the call reverts. Actual: fee = 1, seller balance stays 0, feesAccrued == 1, outstandingOf(lineId) == 1, nonce consumed, pool receives 0. pay() with p.amount = 50: fee = 1, seller receives 49 (2% fee instead of 1%). Reproduced in test/scratch/Repro.t.sol::test_dustFee during this review.","severity":"info","snippet":"        uint256 fee = Math.mulDiv(p.amount, sellerFeeBps, BPS, Math.Rounding.Ceil);","title":"Seller fee rounds up to a whole base unit, so payments under 100 base units pay up to a 100% fee and a 1-unit payment pays the seller nothing"},{"citation":"resolved","description":"Asymmetric initialization validation. ScoreRegistry rejects a zero admin; CreditPool (CreditPool.sol:43), TabManager (TabManager.sol:203) and Escrow (Escrow.sol:72) grant DEFAULT_ADMIN_ROLE to address(0) without reverting, leaving a deployment no one can administer (the pool can never grant MANAGER_ROLE, TabManager can never setEscrow/setParams, Escrow can never get a verifier or arbiter). Escrow also stores usdc_ and manager_ unchecked, and because Escrow.manager is immutable and TabManager.setEscrow can be called exactly once (EscrowAlreadySet) with no check that the target's manager is this contract, a TabManager pointed at an Escrow with the wrong manager has every escrowed=true intent revert OnlyManager forever and cannot be re-pointed without redeploying TabManager (abandoning all collateral accounting). Deploy.s.sol passes correct values today; this hardens the manual/alternate deployment paths the README describes. Funds are not at risk. Merged from audit_permissions and audit_flow. Fix: revert ZeroAddress() on a zero admin in all three constructors and on zero usdc_/manager_ in Escrow; in setEscrow require IEscrow(escrow_).manager() == address(this) (expose manager() on IEscrow).","line":69,"path":"src/Escrow.sol","reproduction":"new CreditPool(usdc, address(0), 0): hasRole(DEFAULT_ADMIN_ROLE, address(0)) == true and no other address holds any role. new TabManager(usdc, pool, address(0), treasury) likewise deploys. new Escrow(IERC20(address(0)), ITabManager(address(0)), address(0)) deploys with usdc() == address(0). Separately: bad = new Escrow(usdc, ITabManager(address(0)), admin); tabs.setEscrow(bad) succeeds; tabs.setEscrow(correct) then reverts EscrowAlreadySet; pay() of an intent with escrowed=true reverts OnlyManager from Escrow.open. Expected: deployment rejected at construction / wiring. Actual: deploys with an unrecoverable role set or a permanently broken escrow path. Reproduced in test/scratch/Repro.t.sol::test_zeroAdmin and ::test_escrowMiswire during this review.","severity":"info","snippet":"    constructor(IERC20 usdc_, ITabManager manager_, address admin) {","title":"Escrow, CreditPool and TabManager constructors do not validate admin (and Escrow not usdc/manager), and setEscrow is one-shot with no wiring check, so a mis-wired deployment is dead and unrecoverable"},{"citation":"resolved","description":"Documented privileged powers, not bypasses, recorded so the requester can weigh them against the README statement that depositors never lose principal in the secured design. (1) CreditPool.fund sends idle USDC to any `to` for a MANAGER_ROLE holder, and DEFAULT_ADMIN_ROLE (the deploy-time ADMIN, also the Escrow arbiter per Deploy.s.sol) can grant MANAGER_ROLE to an arbitrary EOA with no delay; that EOA can call fund(attacker, idle) up to the utilization cap (90% of totalAssets by default; the same admin can set it to 100%), and the principal never returns. Depositor principal is therefore exactly as safe as the admin key. (2) TabManager admin may setTreasury to any non-zero address, setPoolFeeShare in 0..100% (affecting in-flight escrow holds whose split is decided at release), setParams to retime open statements (separate finding), and setEscrow once with no validation. (3) Escrow ARBITER_ROLE may resolve() a Held hold at any time, refunding a delivered job or releasing an undelivered one before the review window ends, without a flag. None of these need a code change if the roles are held by the intended Safe; a timelock on CreditPool's DEFAULT_ADMIN_ROLE, or renouncing the role admin of MANAGER_ROLE after wiring, would make the 'depositors never lose principal' property hold independently of the admin key.","line":90,"path":"src/CreditPool.sol","reproduction":"Pool holds 1,000,000 USDC idle, outstanding 0. admin: pool.grantRole(MANAGER_ROLE, attackerEOA). attackerEOA: pool.fund(attackerEOA, 900_000e6) succeeds (idle check passes, 900k <= 90% of 1,000k); attacker balance == 900_000e6 and pool.outstanding == 900_000e6 with no line backing it. Depositors can withdraw only the remaining 100k idle. Expected per README: depositors never lose principal. Actual: one privileged key moves 90% of deposits in one block with no delay. Reproduced in test/scratch/Repro.t.sol::test_adminDrain during this review.","severity":"info","snippet":"    function fund(address to, uint256 amount) external onlyRole(MANAGER_ROLE) {","title":"Trust assumptions: CreditPool DEFAULT_ADMIN can grant MANAGER_ROLE to any address and drain idle cash with fund(); TabManager admin controls treasury, fee split, LTV and grace with no timelock; Escrow"}],"hash":"8e0c8e4dbae52f810b6e1869a5b49431dac89c6d10b595e3691da59d883396d6","nodeId":"44e37505-f171-494a-a11f-26010663542e","outcome":"completed","summary":"The review is complete. `lib/` is gitignored, so the dependency install for checking left the tracked tree untouched, and `git status` shows only the findings file and my scratch tests.\n\n## Outcome\n\n`.imd-findings.json` holds 9 findings: 6 low and 3 info. No critical, high or medium defects reproduced. Every finding was reproduced against the code with Foundry, duplicates across the four specialists were merged, and one specialist item was dropped.\n\n**Kept, with proofs that fail on current code (4):**\n- **ScoreRegistry revoked epoch cannot be republished** (`src/ScoreRegistry.sol:83`). Revoking today's root leaves no live snapshot until the next UTC day. Four specialists reported this; merged into one.\n- **ScoreRegistry record rollback after revoke** (`src/ScoreRegistry.sol:120`). Anyone can re-record an arbitrarily old favourable score once the recorded epoch is revoked.\n- **Predictable line epoch** (`src/TabManager.sol:372`). Pre-signed intents for epoch+2 become payable after revokeMandate then setMandate, contradicting the documented kill-switch.\n- **Live gracePeriod read in liquidate** (`src/TabManager.sol:546`). An admin setParams call makes a statement one second past due liquidatable immediately. Four specialists reported this; merged. I wrote this proof myself since none was attached.\n\n**Kept without proof (5):** same-block fee donation sandwich (low), deposit into any line by predicted id (low), ceil fee pays sellers zero on 1-unit payments (info), constructor and setEscrow wiring validation gaps (info, merged from two specialists), and privileged-role trust assumptions (info).\n\n**Dropped:** \"setMandate always resets spent\". The NatSpec documents that behaviour explicitly, so it is intended design rather than a defect.\n\n**Own checks that found nothing:** sponsor accounting across two sponsored lines with escrow refund after liquidation and a second liquidation kept pool.outstanding and sponsor.outstanding exactly consistent. Liquidation can never produce a pool loss because collateral is always at least the line's outstanding at 100% maximum LTV. The existing 82-test suite passes under OpenZeppelin v5.1.0 as the README pins.\n\nScratch tests live under `test/scratch/`, which is not kept.","treeHash":null,"usage":{"cachedInputTokens":1196852,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":35840,"runtime":"claude","turns":24,"wallClockMs":612756}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b12a68c6f8a8ce99","findings":[{"citation":"resolved","description":"publish() requires epoch > latestEpoch and revoke() does not lower latestEpoch, so once an epoch is published it can never carry a root again, even after it is revoked. Any wrong root for epoch = today (publisher bug, or a leaked publisher key that front-runs the real publish right after 00:00 UTC) leaves the protocol with no live snapshot until the next UTC day: verify() returns false for every leaf and scoreOf() reports every subject recorded in that epoch as missing. The README's safety claim that 'a leaked publisher key can't block future snapshots' holds only for later days; the current day is blockable and a hostile key holder can repeat the block every day until the role is rotated. Epoch-ordering boundary: latestEpoch is a strict high-water mark that the revocation path never resets. Fix: let publish() accept epoch == latestEpoch when _epochs[epoch].revoked is true (overwrite the record and clear revoked), or let revoke() of the latest epoch roll latestEpoch back to the previous live epoch so the day can be republished. Keep the forward-only rule for non-revoked epochs.","line":83,"path":"src/ScoreRegistry.sol","reproduction":"State: block.timestamp = 1_800_000_000, today = 20833. 1) publisher calls publish(20833, keccak256('bad'), now, 1, 1, '') -> ok, latestEpoch = 20833. 2) admin calls revoke(20833). 3) publisher calls publish(20833, keccak256('good'), now, 1, 1, '') -> expected: accepted (the epoch is revoked and has no live root); actual: reverts EpochNotNewer(20833, 20833). verify(20833, leaf, proof) is false for every leaf and scoreOf() returns found=false for every subject whose record points at 20833 until epoch 20834 can be published, at least until the next UTC midnight. Confirmed with a Foundry test (Probe.test_scoreRegistry_sameDayRepublishBlocked).","severity":"low","snippet":"        if (epoch <= latestEpoch) revert EpochNotNewer(epoch, latestEpoch);","title":"ScoreRegistry: a revoked epoch cannot be re-published, so one bad or hostile publish blocks the day's scores"},{"citation":"resolved","description":"_distributeFee() transfers the pool's share of every seller fee straight into CreditPool, which raises totalAssets() and therefore the tpUSDC share price in the same transaction. CreditPool has no deposit lock, no entry/exit fee and no yield smoothing, so a depositor who enters immediately before a large pay() and redeems immediately after takes a pro-rata slice of the fee while having lent nothing and borne no default or lock-up risk. Lenders who actually funded the outstanding principal are diluted by exactly that slice. Payment intents are submitted by the facilitator and are visible in the mempool (and are predictable for big escrow releases via Escrow.claim after reviewEndsAt), so the opportunity is observable. Impact is bounded by poolFeeShareBps * sellerFeeBps of the payment (default 0.5%) per sandwich, so this is a yield-leak rather than a principal loss. Fix: accrue pool fees over time (stream them into totalAssets, e.g. a lastFeeTime/unlockedFees schedule like ERC-4626 vaults with 'profit unlocking'), or add a short minimum holding period / withdrawal cooldown on tpUSDC.","line":485,"path":"src/TabManager.sol","reproduction":"State: lp holds 1,000,000 USDC of shares; line has mandate maxPerPayment = budget = 1,000 USDC; sellerFeeBps = 100, poolFeeShareBps = 5000. 1) lp2 deposits 1,000,000 USDC (same block, before the payment). 2) facilitator calls pay() for 1,000 USDC: fee = 10 USDC, toPool = 5 USDC transferred to the pool. 3) lp2 immediately redeems all shares. Expected: lp2 gets back 1,000,000 USDC (no time at risk, no yield). Actual: lp2 receives 1,000,002.499999 USDC, i.e. 2.499999 USDC of the 5 USDC pool fee, and lp's share of that fee drops from 5 to about 2.5 USDC. Confirmed with a Foundry test (Probe.test_feeSandwich logs 2499999 base units gained).","severity":"low","snippet":"        if (toPool > 0) usdc.safeTransfer(address(pool), toPool);","title":"Pool fee share is paid out instantly, so a same-block deposit captures lender yield with no exposure"},{"citation":"resolved","description":"A statement stores only statementDue; the grace period is added at liquidation time from the current gracePeriod, and paymentWindow is read in _roll() when the cycle closes rather than fixed when the cycle opened. setParams() may therefore shorten the window of a statement that an owner is already relying on: lowering gracePeriod to 0 makes every line whose statement is past statementDue liquidatable in the same block, and lowering paymentWindow before a cycle rolls shortens the due date of spending already made under the longer window. The prior review found the same pattern in Escrow.expire (live rulingWindow) and it was fixed by snapshotting rulingEndsAt at flag time; TabManager still has the live read. This is an admin trust assumption (DEFAULT_ADMIN_ROLE is a Safe), not an outsider exploit, but it is a boundary where the stored deadline and the enforced deadline diverge, and it lets a compromised or careless admin trigger liquidation of fully collateralized lines (collateral goes to the pool, owner loses the line's working balance) with no notice. Fix: store the full deadline on the line at roll time (statementDue = cycleEnd + paymentWindow and a liquidateAfter = statementDue + gracePeriod, or a per-line snapshot of gracePeriod), and have liquidate()/isPastDue() compare against the stored value, as Escrow now does.","line":546,"path":"src/TabManager.sol","reproduction":"State: line with 1,000 USDC collateral, billingPeriod 7d, paymentWindow 3d, gracePeriod 2d. 1) pay() 100 USDC at t0; cycleEnd = t0+7d. 2) warp to t0+7d+3d+1s (1 second past due, inside the 2-day grace). isPastDue(lineId) == false and liquidate(lineId) reverts NotPastDue, as expected. 3) admin calls setParams(10000, 100, 7 days, 3 days, 0). 4) anyone calls liquidate(lineId) in the same block: expected (from the owner's view) revert until t0+12d; actual: succeeds, seizes 100 USDC, collateral drops from 1,000 to 900 USDC. Confirmed with a Foundry test (Probe.test_graceReadLive).","severity":"low","snippet":"        if (!(l.billed > 0 && block.timestamp > uint256(l.statementDue) + gracePeriod)) revert NotPastDue();","title":"liquidate() and _roll() read gracePeriod / paymentWindow live, so an admin parameter change moves the deadline of statements already issued"},{"citation":"resolved","description":"fee rounds up, which is the right direction for the protocol, but with sellerFeeBps = 100 every amount in 1..100 base units pays a 1-unit fee, so the effective rate is 1/amount: 100% at amount = 1, 2% at 50, 1.01% at 99. At amount = 1 the seller receives amount - fee = 0 while pay() succeeds, emits Paid and bills the owner 1 unit, so an x402 resource priced at 1 micro-USDC is 'paid' with a zero transfer. The same 1-unit fee is then split floor(1 * 5000 / 10000) = 0 to the pool and 1 to the treasury, so the pool earns nothing on these payments. Boundary x precision seam, no third-party loss (the owner pays the fee and the seller's loss is capped at 1 unit), so informational. Fix options: require p.amount - fee > 0 (reject payments the fee would consume) or floor the fee and accept zero-fee dust, and document the minimum economic payment size for sellers.","line":384,"path":"src/TabManager.sol","reproduction":"State: default params (sellerFeeBps 100, poolFeeShareBps 5000), line with mandate allowing the amounts. 1) pay() with p.amount = 1: fee = mulDiv(1, 100, 10000, Ceil) = 1; seller balance stays 0; feesAccrued = 1; outstandingOf(lineId) = 1; pool received 0. 2) pay() with p.amount = 50: fee = 1, seller receives 49 (2% fee instead of 1%). Confirmed with a Foundry test (Probe.test_tinyPaymentSellerGetsZero).","severity":"info","snippet":"        uint256 fee = Math.mulDiv(p.amount, sellerFeeBps, BPS, Math.Rounding.Ceil);","title":"Ceil-rounded seller fee makes the effective fee up to 100% on micro-payments; a 1-unit payment pays the seller nothing"}],"hash":"a9738a1d3eaf273eab3628a6cd6a545e85ba700f2c1d1c3a52a21ef6a60de9d5","nodeId":"fd95f7ba-4fdc-492e-a550-36ad3b5ff3bf","outcome":"completed","summary":"Findings file is written and validated. Wrapping up with the summary.\n\nReview complete. The findings file at `.imd-findings.json` holds four substantiated items, none above low severity.\n\n**What I checked.** All four contracts in `src/`, with the Math Precision, Boundary and Numerical Gap guides as the checklist. I traced fee rounding, ERC-4626 share and cap math, the utilization check, collateral and sponsor limits in both rounding directions, the billing roll and due-date boundaries, liquidation seizure math, escrow state transitions, ScoreRegistry epoch ordering and revocation, EIP-712 replay surfaces, and reentrancy through ERC-1271 agents. The repo ships no `lib/`, so I built in a scratch workspace outside the tree with OpenZeppelin v5.1.0 and forge-std, where the existing 82 tests pass. Each finding below was confirmed with a Foundry probe there. The repository tree is unchanged.\n\n**Findings.**\n- **Low, ScoreRegistry.publish:** a revoked epoch can never be republished because `latestEpoch` is a strict high-water mark that `revoke` never resets. A wrong root for today, including one front-run by a leaked publisher key, leaves no live snapshot until the next UTC day. The README's \"can't block future snapshots\" claim only covers later days.\n- **Low, TabManager._distributeFee:** the pool's fee share lands in CreditPool instantly with no deposit lock or yield smoothing. A same-block deposit before a 1,000 USDC payment and redeem after captured about 2.5 USDC of the 5 USDC pool fee with zero exposure, diluting the lenders who actually funded the loan.\n- **Low, TabManager.liquidate and _roll:** `gracePeriod` and `paymentWindow` are read live, so an admin lowering grace to zero makes a fully collateralized line liquidatable in the same block, one second past due. Escrow already had the same pattern fixed by snapshotting deadlines. Reported as an admin trust boundary.\n- **Info, TabManager.pay:** the ceil-rounded fee makes a 1-unit payment pay the seller nothing while still succeeding and billing the owner, and gives the pool zero on sub-2-unit fees.\n\n**What held up.** Pool `outstanding` stays equal to the sum of line balances across pay, repay, refund and liquidation. Sponsor `outstanding` matches its lines, and the free-collateral ceil rounding is consistent with the limit floor rounding, so no withdrawal leaves lines unbacked. Cap zero freezes correctly. Realized loss is unreachable while LTV stays at or below 100%. Intents carry chain, contract, line, epoch and nonce. ERC-1271 reentry during `pay` reaches only owner-only or harmless paths. ERC-4626 `maxWithdraw`, `maxRedeem` and `maxMint` cannot overshoot `idle` or the cap. The known donation-fills-cap lead from the prior audit remains as documented and I found no new path on it.\n\nNo high or critical findings, so no proof tests were added under `test/scratch/`.","treeHash":null,"usage":{"cachedInputTokens":1347121,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":38089,"runtime":"claude","turns":26,"wallClockMs":636058}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8db4eebdd7bd2744","findings":[{"citation":"resolved","description":"Access x asymmetry seam. A statement's due date is frozen at roll time (statementDue), but the grace that follows it is read from the live gracePeriod storage variable in liquidate() and isPastDue(). setParams (DEFAULT_ADMIN_ROLE) may set gracePeriod to any value in [0, 14 days] at any moment, so one admin transaction retroactively re-times every open statement: shortening grace makes statements that are 1 second past due liquidatable by anyone immediately, lengthening it postpones a liquidation the keeper was about to run. The same class of defect was already fixed in Escrow (rulingEndsAt is now fixed at flag time instead of reading rulingWindow live, audits/2026-10-22 finding 2), so TabManager is now asymmetric with Escrow. Impact is bounded because liquidation seizes exactly the owed amount with no penalty, and the admin is a trusted Safe; it is reported because the protocol's promise to owners ('two days of grace after the due date') is not enforced by the contract and because statementDue already demonstrates the intended fixed-at-roll pattern. Fix: at roll time store the full deadline, e.g. add uint64 liquidatableAt = cycleEnd + paymentWindow + gracePeriod to Line (set only when statementDue is set), and have liquidate/isPastDue compare against it; keep gracePeriod only for computing future deadlines.","line":546,"path":"src/TabManager.sol","reproduction":"Default params (billingPeriod 7d, paymentWindow 3d, gracePeriod 2d). Owner line pays 100 USDC direct; warp to cycleEnd so the statement closes with statementDue = cycleEnd + 3d. Warp to statementDue + 1s: liquidate(lineId) reverts NotPastDue (expected, owner is inside the 2-day grace). Admin calls setParams(10000, 100, 7 days, 3 days, 0). Same block, anyone calls liquidate(lineId): it succeeds and seizes 100 USDC of collateral. Expected: the statement stays non-liquidatable until the deadline that applied when it was issued (statementDue + 2 days). Actual: the deadline moved with the admin write. Confirmed with a Foundry test (test_GraceReadLive_MovesLiquidationDeadline) during this review.","severity":"low","snippet":"        if (!(l.billed > 0 && block.timestamp > uint256(l.statementDue) + gracePeriod)) revert NotPastDue();","title":"liquidate reads gracePeriod live: an admin parameter change moves the liquidation deadline of every existing statement"},{"citation":"resolved","description":"publish() requires epoch > latestEpoch and epoch <= block.timestamp / 1 days, and revoke() leaves latestEpoch unchanged. Once today's epoch is published and then revoked (the documented recovery for a bad snapshot, including one made with a leaked publisher key), the publisher can neither re-publish today's epoch (EpochNotNewer) nor publish tomorrow's (EpochInFuture). Until 00:00 UTC every subject whose latest recorded score was in the revoked epoch reads as found=false from scoreOf, verify() returns false for every proof of that day, and no corrected root can be put on chain. A holder of a leaked publisher key can repeat this at the start of every UTC day until the admin rotates the role, so the README claim that 'a leaked publisher key can't block future snapshots' holds only for days after the rotation; the current day is always lost. Fix: let publish overwrite an epoch whose record is revoked (if _epochs[epoch].revoked, allow epoch == latestEpoch or any revoked epoch and reset revoked=false with the new root), or make revoke roll latestEpoch back to the highest non-revoked epoch so the day can be re-published.","line":83,"path":"src/ScoreRegistry.sol","reproduction":"Deploy ScoreRegistry(admin, publisher); today = block.timestamp / 1 days. publisher.publish(today, root1, now, 10, 1, uri) succeeds. admin.revoke(today). publisher.publish(today, root2, now, 10, 1, uri2) reverts EpochNotNewer(today, today). publisher.publish(today + 1, root2, ...) reverts EpochInFuture(today + 1). getEpoch(today).revoked == true and no live root exists for the day. Expected: a corrected root for the same day can be published after a revoke. Actual: the day's snapshot is unrecoverable until the next day. Confirmed with a Foundry test (test_RevokedTodayCannotBeReplacedToday) during this review.","severity":"low","snippet":"        if (epoch <= latestEpoch) revert EpochNotNewer(epoch, latestEpoch);","title":"ScoreRegistry: a revoked snapshot cannot be replaced until the next UTC day, so a bad or hostile publish blanks every score for the rest of the day"},{"citation":"resolved","description":"Access gap between paired functions: withdrawCollateral / withdrawSponsorCollateral are restricted to the owner, but depositCollateral (line 244) and depositSponsorCollateral (line 299) only require that the id exists. Line and sponsor ids are sequential (nextLineId++, nextSponsorId++), so an owner who opens a line in one transaction and deposits in a second transaction using the id they expect (nextLineId read off chain, or the id from a simulation) can be front-run: an attacker calls openLine first and takes that id, the victim's openLine gets id+1, and the victim's depositCollateral(id, amount) succeeds into the attacker's line. The attacker, as that line's owner, withdraws the collateral with withdrawCollateral(id, amount, attacker) since the line has no balance. The same holds for sponsors. The contract offers no deposit-at-open or owner check that would make the misdirected transfer fail. Fix: either require msg.sender == _lines[lineId].owner (resp. sponsor owner) in the deposit functions, or add an optional owner parameter that must match (depositCollateralFor(lineId, expectedOwner, amount) reverting on mismatch), or let openLine take an initial collateral amount so the common path is atomic.","line":245,"path":"src/TabManager.sol","reproduction":"Attacker: openLine(attacker) -> returns N (the id the victim expected). Victim: openLine(victim) -> returns N+1. Victim: approve, depositCollateral(N, 1000e6) -> succeeds (only UnknownLine and SponsoredLine are checked). Attacker: withdrawCollateral(N, 1000e6, attacker) -> succeeds, freeCollateral(N) == 1000e6 because the line has no outstanding balance. Expected: a deposit from a non-owner into a line it does not own is rejected (or at least cannot be made by id prediction). Actual: attacker balance +1000 USDC, victim loses the deposit. Confirmed with a Foundry test (test_DepositIntoSomeoneElsesLine_IsWithdrawableByThem) during this review.","severity":"low","snippet":"        if (_lines[lineId].owner == address(0)) revert UnknownLine();","title":"depositCollateral and depositSponsorCollateral accept deposits from anyone into any id, so a front-run openLine/openSponsor diverts a deposit sent by predicted id to an attacker who can withdraw it"},{"citation":"resolved","description":"The fee is ceil(amount * sellerFeeBps / 10000). For any amount in [1, 99] at the default 100 bps the fee is 1 unit, i.e. between 1% and 100% of the payment; at the maximum 500 bps every amount in [1, 19] also pays 1 unit. The seller receives amount - fee, which is 0 for amount == 1, while the owner is still billed the full amount and the mandate budget is still consumed. The direct branch transfers 0 USDC to the seller, which Circle's USDC accepts, so the payment 'succeeds' with no value delivered. The prior review flagged the opposite rounding (fee to zero); the fix moved the asymmetry onto the seller. x402 prices of 0.0001 USDC and below hit this. Fix: either enforce a minimum amount so that fee < amount (e.g. revert if amount - fee == 0, or require amount >= BPS / sellerFeeBps), or round the fee down and accept zero-fee dust payments as the earlier design did.","line":384,"path":"src/TabManager.sol","reproduction":"Default sellerFeeBps = 100. Agent signs a direct PaymentIntent with amount = 1 (0.000001 USDC). pay() succeeds: fee = ceil(1 * 100 / 10000) = 1, seller receives 1 - 1 = 0 USDC, feesAccrued == 1, outstandingOf(lineId) == 1, mandate spent += 1. Expected: a successful payment delivers a positive amount to the seller or is rejected. Actual: seller receives 0. Confirmed with a Foundry test (test_FeeCeil_SellerGetsZero) during this review.","severity":"info","snippet":"        uint256 fee = Math.mulDiv(p.amount, sellerFeeBps, BPS, Math.Rounding.Ceil);","title":"Seller fee rounds up to a whole base unit, so payments under 100 base units pay up to a 100% fee and a 1-unit payment pays the seller nothing"},{"citation":"resolved","description":"Asymmetric initialization validation. TabManager's constructor rejects a zero usdc, pool and treasury but not a zero admin; CreditPool (CreditPool.sol:43) and Escrow (Escrow.sol:72) check nothing, including a zero usdc or manager for Escrow. ScoreRegistry does reject admin == 0. A deployment with admin == address(0) grants DEFAULT_ADMIN_ROLE to the zero address, which no one controls: the pool can never grant MANAGER_ROLE (so it can never fund a tab), TabManager can never setEscrow or setParams, and Escrow can never get a verifier or arbiter. Funds are not at risk (nothing can be lent), but the deployment is dead and must be redone. The current Deploy script passes the broadcaster so this is a hardening item for the manual/alternate deployment paths described in the README. Fix: add if (admin == address(0)) revert ZeroAddress(); to the three constructors (and zero checks for usdc_ and manager_ in Escrow), mirroring ScoreRegistry.","line":203,"path":"src/TabManager.sol","reproduction":"new CreditPool(usdc, address(0), 0): hasRole(DEFAULT_ADMIN_ROLE, address(0)) == true and no other address holds any role; grantRole(MANAGER_ROLE, tabs) is impossible for anyone. new TabManager(usdc, pool, address(0), treasury) and new Escrow(IERC20(address(0)), ITabManager(address(0)), address(0)) likewise deploy without reverting. Expected: revert on a zero admin as ScoreRegistry does. Actual: deploys with an unrecoverable role set. Confirmed with a Foundry test (test_ZeroAdmin_NoRoleHolder) during this review.","severity":"info","snippet":"        _grantRole(DEFAULT_ADMIN_ROLE, admin);","title":"CreditPool, TabManager and Escrow constructors accept address(0) as admin, deploying a contract with no role holder (ScoreRegistry checks this, the others do not)"},{"citation":"resolved","description":"Documented powers, not bypasses; recorded so the requester can weigh them against the README statement that 'Depositors never lose principal in the secured design'. (1) CreditPool.fund sends idle USDC to any `to` for the MANAGER_ROLE holder, and DEFAULT_ADMIN_ROLE (the deploy-time ADMIN, also the Escrow arbiter per Deploy.s.sol) can grant MANAGER_ROLE to an arbitrary EOA with no delay; that EOA can then call fund(attacker, idle) up to the utilization cap (90% of totalAssets by default, and the same admin can set it to 100%). Depositor principal is therefore only as safe as the admin key. (2) TabManager admin may setTreasury to any non-zero address (including the pool or itself), setPoolFeeShare to 0..100% affecting in-flight escrow holds whose fee split is decided at release, setParams to change gracePeriod live (see the separate finding), and setEscrow once with no validation that the target's manager is this contract. (3) Escrow ARBITER_ROLE may call resolve() on a Held hold at any time, refunding a delivered job or releasing an undelivered one before the review window ends, with no flag required. None of these need a code change if the roles are held by the intended Safe; a timelock on DEFAULT_ADMIN_ROLE of CreditPool (or making MANAGER_ROLE admin-less after wiring, e.g. renouncing the role admin) would make the 'depositors never lose principal' property hold independently of the admin key.","line":90,"path":"src/CreditPool.sol","reproduction":"State: pool holds 1,000,000 USDC idle, outstanding 0, admin key compromised or malicious. admin: pool.grantRole(MANAGER_ROLE, attackerEOA). attackerEOA: pool.fund(attackerEOA, 900_000e6) succeeds (idle check passes, 900k <= 90% of 1,000k); pool.outstanding becomes 900k and never returns. Depositors can withdraw only the remaining 100k idle. Expected per README: depositors never lose principal. Actual: a single privileged key can move 90% (100% after setMaxUtilization(10000)) of deposits in one block with no delay.","severity":"info","snippet":"    function fund(address to, uint256 amount) external onlyRole(MANAGER_ROLE) {","title":"Trust assumptions: pool DEFAULT_ADMIN can grant MANAGER_ROLE to any address and drain idle cash with fund(); TabManager admin controls treasury, fee split and LTV with no timelock; Escrow arbiter can "}],"hash":"bd58c01853823727772a70e16dcbd1551609e7b633fd8b497e92b0687df419cd","nodeId":"1e14a33f-27c2-46af-aff0-53f4ec646956","outcome":"completed","summary":"The file is valid and every snippet matches its line. The review is complete and the findings are recorded in `.imd-findings.json`.\n\n**Scope covered.** All five contracts in `src/`, the deploy script, the prior audit, and the test suite (82 tests, all passing on a fresh build with OpenZeppelin 5.1.0 and forge-std 1.9.4). Review passes applied: full entry-point inventory with guards, the Access Control, Asymmetry and Trust Gap checklists, then the task's named concerns one by one.\n\n**What held up.** I could not break any of the core properties, and each attempt was closed by a specific guard:\n\n- **Lender loss and accounting drift.** Every path that changes a line balance changes `pool.outstanding` by the same amount, and sponsor collateral always covers sponsor outstanding, so `realizeLoss` is unreachable at LTV at or below 100%.\n- **Replay.** The signed intent binds line id, epoch, nonce, chain id and contract; ERC-1271 checks are staticcalls, so no reentrancy.\n- **Budget, cap and sponsor withdrawal.** Held amounts count toward outstanding everywhere, refunds restore budget only under the same mandate version, and free-collateral math rounds against the withdrawer.\n- **Escrow states.** Every state has a permissionless exit except a blacklisted seller on an unflagged hold, which the README already documents as arbiter-dependent.\n\n**Defects found.** Six findings, none above low, each reproduced with a scratch Foundry test:\n\n1. **Low.** `liquidate` reads `gracePeriod` live, so an admin `setParams` call retroactively moves every open statement's liquidation deadline. Escrow already fixed the same pattern for `rulingWindow`.\n2. **Low.** ScoreRegistry cannot re-publish a revoked epoch for the current UTC day, so a bad or hostile publish blanks that day's scores until midnight.\n3. **Low.** `depositCollateral` and `depositSponsorCollateral` accept deposits from anyone by sequential id, so a front-run `openLine` diverts a deposit into an attacker-owned line they can withdraw.\n4. **Info.** Fee rounds up to a whole unit, so a 1-unit payment pays the seller nothing while still billing the owner.\n5. **Info.** CreditPool, TabManager and Escrow constructors accept a zero admin, unlike ScoreRegistry.\n6. **Info.** Trust assumptions: the pool admin can grant `MANAGER_ROLE` and drain idle cash with no timelock; the arbiter can resolve unflagged holds.\n\n**Not done.** No fuzzing beyond the project's own suite, no Slither (not permitted), and no high or critical proof files since nothing reached that bar. The scratch test is under `test/scratch/` and is not part of the submission.","treeHash":null,"usage":{"cachedInputTokens":872708,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":49736,"runtime":"claude","turns":25,"wallClockMs":758891}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"33bef35e96ccb653","findings":[{"citation":"resolved","description":"publish() requires epoch > latestEpoch and epoch <= today, and revoke() leaves latestEpoch unchanged. So once today's epoch is published and then revoked (bad snapshot, or a snapshot pushed by a leaked publisher key), the publisher cannot publish a corrected root for the same day: publish(today, ...) reverts with EpochNotNewer(today, today) and publish(today+1) reverts with EpochInFuture. For up to 24 hours the registry has no live root for the current day, record() for that day is impossible, every subject whose Recorded.epoch is the revoked epoch reads as not found in scoreOf(), and latestEpoch points at a dead root so integrators that read getEpoch(latestEpoch) see revoked=true with no pointer to the last live epoch. This also weakens the README claim that a leaked publisher key cannot block snapshots: the leaked key only has to publish today's epoch first each day; the admin's revoke then cannot be followed by a legitimate publish for that day. Fix: let publish() overwrite an epoch that is revoked (e.g. `if (epoch <= latestEpoch && !(epoch == latestEpoch && _epochs[epoch].revoked)) revert EpochNotNewer(...)`, clearing revoked and resetting the record), or have revoke() of the latest epoch roll latestEpoch back to the previous live epoch; and consider tracking a separate latestLiveEpoch for readers.","line":83,"path":"src/ScoreRegistry.sol","reproduction":"Deploy ScoreRegistry(admin, publisher). today = block.timestamp / 1 days. publisher: publish(today, root1, asOf=now, count=10, version=1, uri) succeeds. admin: revoke(today). publisher: publish(today, root2, now, 10, 1, uri). Expected: a corrected snapshot for today is accepted (or at least some live root exists). Actual: revert EpochNotNewer(today, today); publish(today+1, ...) reverts EpochInFuture; latestEpoch == today and getEpoch(today).revoked == true; scoreOf(anySubjectRecordedToday) returns found=false until the next UTC day. Reproduced in test/scratch/Leads.t.sol::test_RevokedEpochBurnsTheDay.","severity":"low","snippet":"        if (epoch <= latestEpoch) revert EpochNotNewer(epoch, latestEpoch);","title":"ScoreRegistry: revoking the latest epoch burns that UTC day; no corrected root can be published until tomorrow"},{"citation":"resolved","description":"Lenders' only return is poolFeeShareBps of each seller fee, which _distributeFee sends to the pool as a plain USDC transfer in the same transaction as pay() (and as Escrow release). CreditPool.totalAssets() = idle + outstanding, so the share price jumps at that instant and the gain is split pro rata among whoever holds tpUSDC at that block. ERC-4626 deposit and redeem have no cooldown and withdrawals are only limited by idle cash, so a searcher who sees a large direct payment (or a claim()/pass() on a large hold) in the mempool can deposit immediately before it and redeem immediately after, capturing a share of the lender fee proportional to the capital it adds, while bearing none of the lock-up or default risk the pool is designed to compensate. On Base this costs a few cents of gas per sandwich; the leak scales with payment size and the ratio of attacker capital to existing TVL. Fix: accrue the pool's fee share over time instead of instantly (e.g. a linear vesting of unlocked fees into totalAssets as in many ERC-4626 lending vaults), or impose a minimum deposit age / withdrawal cooldown on shares.","line":485,"path":"src/TabManager.sol","reproduction":"Pool has 1,000,000 USDC from LP. A line with 200,000 USDC collateral has a signed direct intent for 100,000 USDC pending (fee 1,000 USDC at 100 bps, 500 USDC to the pool). Attacker deposits 1,000,000 USDC (now 50% of shares), then pay() executes, then the attacker redeems all shares in the same block. Expected: a zero-duration depositor earns nothing. Actual: the attacker receives 1,000,249.999999 USDC back, i.e. 249.999999 USDC of the 500 USDC lender fee, and the LP's yield for that payment is halved. Reproduced in test/scratch/Sandwich.t.sol::test_TransientDepositorCapturesFeeYield.","severity":"low","snippet":"        if (toPool > 0) usdc.safeTransfer(address(pool), toPool);","title":"CreditPool fee yield is a same-block donation, so a transient depositor can sandwich large payments and take lenders' share of the fee"},{"citation":"resolved","description":"A statement's statementDue is fixed when the cycle rolls, but the grace period that decides when anyone may seize collateral is read from storage at liquidation time. setParams allows gracePeriod from 0 to 14 days, so lowering it (e.g. 2 days -> 0) makes every statement that is past due but still inside its original grace immediately liquidatable, seizing the full billed + unbilled balance (the open cycle included) from owners and sponsors who were still inside the window they were told they had. Raising it likewise delays liquidation of statements already in default, keeping pool cash lent against defaulted lines longer than lenders expect. The same retroactivity bug class was fixed in Escrow (prior audit finding 2, rulingEndsAt fixed at flag time) but not here. This is admin-triggered, so it is a trust assumption, but it is also an avoidable footgun with a one-line fix. Fix: snapshot the deadline when the statement is issued, e.g. store `l.liquidatableAt = l.cycleEnd + paymentWindow + gracePeriod` in _roll and compare block.timestamp against it in liquidate()/isPastDue(), or apply new gracePeriod only to statements issued after the change.","line":546,"path":"src/TabManager.sol","reproduction":"Default params (billingPeriod 7d, paymentWindow 3d, gracePeriod 2d). Line pays 100 USDC at t0; cycleEnd = t0+7d; at cycleEnd+3d+1s the statement is 1 second past due. liquidate(lineId) reverts NotPastDue (expected, grace runs until cycleEnd+5d). Admin calls setParams(10000, 100, 7 days, 3 days, 0). liquidate(lineId) now succeeds in the same second and seizes 100 USDC of collateral. Expected: a statement issued with a 2-day grace keeps it. Actual: liquidation 2 days early. Reproduced in test/scratch/Leads.t.sol::test_GraceChangeAppliesToIssuedStatement.","severity":"low","snippet":"        if (!(l.billed > 0 && block.timestamp > uint256(l.statementDue) + gracePeriod)) revert NotPastDue();","title":"liquidate() reads the live gracePeriod, so an admin setParams call retroactively changes when already-issued statements become liquidatable"},{"citation":"resolved","description":"With sellerFeeBps = 100 the fee is ceil(amount/100), so any amount below 100 base units (0.0001 USDC) pays 1 unit of fee: for amount = 1 the fee is 100% and the seller's transfer is 0, yet the intent is consumed, the mandate budget is charged and the owner is billed the full amount. x402 is explicitly a micro-payment protocol, so one-unit payments are plausible for metered endpoints. There is no loss to lenders and the effect is bounded to dust, so this is informational; the prior review noted the opposite (round-down to zero fee) and the fix moved the rounding error onto sellers. Fix: either round down and accept zero fee on dust, or require amount > fee (revert when amount - fee == 0), or set a minimum payment in the mandate.","line":384,"path":"src/TabManager.sol","reproduction":"Line with any mandate allowing amount 1. pay() with p.amount = 1, escrowed = false. Expected: seller receives at least part of the payment or the call reverts. Actual: fee = 1, seller balance unchanged (receives 0), feesAccrued += 1, outstandingOf(lineId) == 1, nonce consumed. Reproduced in test/scratch/Leads.t.sol::test_DustFee.","severity":"info","snippet":"        uint256 fee = Math.mulDiv(p.amount, sellerFeeBps, BPS, Math.Rounding.Ceil);","title":"Seller fee rounds up, so sub-100-unit payments pay a fee of 100% and the seller receives nothing while the owner is still billed"}],"hash":"d5a93311941e8295a3287c7789bacdc897273c953bf5394e354ff51106c0d6ab","nodeId":"7bc13390-8a63-4919-a54a-9eeb2d435640","outcome":"completed","summary":"All four finding snippets verify against the tree, and the file is complete. The untracked dotfiles in `git status` pre-existed in the workspace and are not mine.\n\n## Recap\n\n**Result:** 4 substantiated findings written to `.imd-findings.json`: 3 Low, 1 Info. No Critical or High, so no proof tests are attached. The core economics hold up well.\n\n**What I verified as sound** (traced by hand and against the existing invariant suite, which I ran: 82 tests pass):\n- Pool `outstanding` tracks the sum of line balances through pay, repay, refund, release and liquidation. Liquidation always records `seized + loss == owed`.\n- Lender loss is unreachable: own-collateral and sponsor collateral are both kept at or above outstanding by `freeCollateral`, `sponsorFreeCollateral` and `_available`, with rounding directions consistent (ceil on needed, floor on limit), including after LTV changes.\n- Sponsor accounting stays in step with its lines; cap 0 freezes, `NO_CAP` defers to the sponsor limit, caps count held amounts.\n- Signature replay is blocked across chains, contracts, lines, epochs and nonces. ERC-1271 validation is a staticcall in OpenZeppelin 5.1, so wallet reentrancy is impossible. USDC has no hooks.\n- Budget restoration on refund is keyed to the mandate version, so budget cannot be regained across mandate changes.\n- Every escrow state has an exit: Held via pass, claim or arbiter; Flagged via arbiter or expire. A blacklisted seller still needs the arbiter, which the README documents.\n\n**Findings:**\n- **Low, ScoreRegistry:** revoking today's epoch leaves `latestEpoch` pointing at it, so no corrected root can be published until the next UTC day. This also undercuts the \"leaked key cannot block snapshots\" claim.\n- **Low, CreditPool yield:** the pool's fee share lands as an instant donation. A depositor can sandwich a large payment and take a pro-rata slice of the lender fee with zero lock-up. Reproduced: 250 of a 500 USDC fee captured.\n- **Low, TabManager liquidate:** `gracePeriod` is read live, so an admin `setParams` retroactively changes when already-issued statements are liquidatable. Same bug class as the fixed Escrow ruling-window finding.\n- **Info:** fee rounds up, so a 1-unit payment pays a 100% fee and the seller gets nothing while the owner is billed.\n\n**Leads tested and dropped:** early due-date merging via parameter change does not reproduce because due date and next cycle end are set in the same call. The costless blacklisted-seller hold lock reproduces but was already a documented lead with an accepted arbiter path, so I did not refile it.\n\n**Coverage limits:** dependencies were cloned to a temp directory and passed via CLI remappings, since `lib/` is absent and off-limits. No fuzzing beyond the project's own 512-run and 128×64 invariant configuration was added. Scratch tests live in `test/scratch/` and are disposable.","treeHash":null,"usage":{"cachedInputTokens":1925414,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":62805,"runtime":"claude","turns":30,"wallClockMs":974427}}],"verification":[]}