{"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":"3ec67246-5ea8-4475-99c4-a19504c31abc","kind":"audit","nodes":[{"acceptedSubmissionHash":"ac56321662c8ad4ce92a1af20f9bdad0be6bbe09eb7324b05fac54de0c78768a","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":"32a234124069e4ea1fc37ed39d6052332ef135138e579c2cee03e1e40d813265","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":"ace3149fb2a20bb0702ee5ea007a17a2f1e1ab3ea9825813c17c9bd410ebf3fa","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":"8a169ae35c88e689d736f734ddc8e2b97899085c96945fc40de71b3470c6e9a5","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":"2155368f3bf2256674abfe3f195a65b6a500005a76c4b1cc0ab952f3e24e71fa","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit Return v1 contracts at this exact revision.\n\nRepository: https://github.com/Return-Labs/v1-contracts.git\nCommit: 2568b30f405f90acee213930af411e01914d68e6\n\nFind reproducible defects that cause asset loss, unauthorized actions, incorrect settlement, or blocked valid claims. Create isolated proofs and mocks. Keep production source unchanged. Do not implement fixes, deploy contracts, or broadcast transactions.\n\n1. Scope\n\nPrimary targets:\n- src/ReturnERC20Loans.sol\n- src/NoteArt.sol\n\nAlso review script/Deploy.s.sol, foundry.toml, and dependency code that affects these targets. Verify the commit and keep the dependency pins in script/install.sh. Examine existing tests critically. Keep the audit within this repository and its dependencies.\n\n2. Intended behavior to verify\n\nReturn provides bilateral loans with fixed terms. Each fill of a signed order creates a loan and transferable ERC721 note. There is no oracle, lending pool, or price-based liquidation.\n\nThe Lend signer is the lender. The Borrow signer is the borrower. The borrower is fixed at funding. The current note holder owns repayment or collateral after default.\n\nThe fee is 30 basis points of each fill's principal, rounded up to one raw token unit. It reduces the borrower's proceeds. Collateral and interest use cumulative proportional allocation across fills.\n\nAnyone may repay in full. Borrower repayment also returns collateral. Another payer gains no collateral rights. The borrower reclaims it separately.\n\nA failed direct repayment may enter custody. This claim follows the note. Anyone may call foreclosure or collection, but payment goes to the current note holder.\n\nThe normal repayment deadline is maturity plus one day. Owner extensions must never put a loan's deadline beyond maturity plus eight days. Repayment is permitted at the deadline. Foreclosure starts after it.\n\nThe owner can admit assets, stop new loans, change treasury, withdraw surplus, and extend repayment within the cap. It must not take reserved assets or alter funded terms. Ownership uses a two-step handover.\n\nVerify these intended rules against the implementation. Report differences.\n\n3. Math and accounting review\n\nCheck partial-fill allocation, rounding, minimum fills, residual capacity, overfill prevention, and cancellation. Test extreme integer values, casts, packed storage, expiry boundaries, and maturity overflow.\n\nCheck that complete fills allocate exactly the signed collateral and interest totals. Test fee changes from fill splitting. Distinguish expected rounding from an economic attack.\n\nFor each token, reserved must equal unreleased collateral plus held repayments. Donations and fees must not create user claims. Check the same token across many loans and in both asset roles.\n\nTest shortfalls, surplus, recovery, deposits, and withdrawals. One loan must not consume another loan's funds. Failures must restore balances, reserves, fill capacity, notes, and claim flags.\n\nUse an independent accounting model for stateful tests.\n\n4. Permissions and signatures review\n\nCheck every signed Order field, EIP-712 domain name and version, chain ID, market address, maker counter, salt, and restricted taker.\n\nTest replay between orders, fills, markets, chains, and old domains. Check cancellation, counter changes, signature malleability, malformed signatures, zero addresses, and dirty calldata bits.\n\nReview EOA, ERC1271, Safe, and EIP7702 behavior. Test signature revocation, changed wallet owners or thresholds, missing handlers, and changes between partial fills.\n\nCheck maker consent for public posting, the repayment helper's self-call restriction, owner operations, handover, payable calls, approvals, and note transfers.\n\nState required privileges. Separate intended powers from permission bypasses.\n\n5. Control flow and settlement review\n\nExamine fill, repay, transferRepayment, reclaimCollateral, foreclose, collectRepayment, and withdrawSurplus as one state machine.\n\nTest repeated and reordered calls. Claims must settle once. Forged Loan fields must not access another loan. Cancellation must stop future fills without changing funded loans.\n\nTest note transfers during settlement, contract holders, overlapping borrower/lender/treasury addresses, and self-transfers. Check the beneficiary.\n\nTest reentrancy through every external token call, balance query, metadata call, ETH transfer, and receiver callback. Check cross-function and read-only reentrancy and transient guard behavior after success and failure.\n\nReview the repayment self-call, burn, catch path, and rollback before custody fallback. Test gasBefore/32 and EIP-150 boundaries. Try to force escrow, charge twice, block repayment, or change the beneficiary.\n\nTest emergency extensions, token removal and re-admission, pause changes, exact deadline boundaries, and settlement after a long outage.\n\n6. Token, economic, and metadata review\n\nThe intended admitted assets transfer exact raw amounts and do not rebase. Test callbacks, transfer fees, sender fees, false or malformed return data, silent no-op transfers, and failing or false balance reports.\n\nTest freezes, issuer burns, upgrades, rebases, and display multipliers. State when an attack requires an unsupported or compromised token. Check damage to unrelated claims or other assets.\n\nAssess front-running, transaction ordering, griefing, unbounded work, and denial of service within the stated design.\n\nCheck NoteArt for unsafe symbol handling, malformed metadata, JSON/XML injection, failed token queries, excessive return data, and gas limits. Confirm that metadata cannot change custody or settlement. Its Overdue label is based on maturity. repaymentDeadline controls foreclosure.\n\n7. Build and test\n\nRecord the worker's Foundry version. The repository was tested with 1.8.1. Preserve Solidity 0.8.37, Cancun, optimizer settings, and via_ir.\n\nRun:\nsh script/install.sh\nforge fmt --check\nforge build --offline --sizes\nforge test --offline --no-match-contract 'Robinhood.*ForkTest'\n\nAdd targeted proofs, fuzz tests, and stateful invariants. Record seeds, run counts, depth, commands, and results. Test recovery and final settlement.\n\nIf a suitable Robinhood Chain RPC is available, set FORK_RPC_URL and run:\nforge test --match-contract 'Robinhood.*ForkTest'\n\nUse chain ID 4663. Pin new fork proofs to a block. Record its number, hash, and relevant code versions. Explain RPC failures or changed external contracts.\n\nList skipped checks and reasons. Never count skips as passes. Do not enable FFI, broaden file permissions, or weaken the worker sandbox.\n\n8. Evidence and final report\n\nThe judge must reproduce findings, merge duplicate root causes, and rank impact against realistic preconditions.\n\nFor each finding, include:\n- Title, severity, and confidence.\n- Exact source file, function, and line references at the pinned commit.\n- Root cause and the violated rule.\n- Attacker capabilities and required state.\n- Transaction sequence and affected assets or claims.\n- Minimal Foundry proof, exact command, and observed result.\n- A focused remediation recommendation without changing the audited source.\n\nUse cheatcodes for stated setup, time control, or forks. Do not manufacture attacks with impossible state, hidden owner powers, or arbitrary storage writes.\n\nSeparate confirmed defects, unconfirmed concerns, and trust or integration risks. Avoid generic checklists and unsupported severity claims.\n\nInclude the commit, scope, tool versions, test summary, severity table, coverage gaps, and unresolved assumptions. Report honestly if no defects are confirmed. State review limits. Do not guarantee safety.","parentJobId":null,"planHash":"e3519d28fa3df0f4082673198b696096a15fb61209cd9add951def95efd005fb","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"3ec67246-5ea8-4475-99c4-a19504c31abc","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":"50994","feedbackHash":"52cc61744a8b745d9e3e1382b4f5cfe619edd61538aa7de9c5b222764d2dd331","nodeKey":"audit_economics","submissionHash":"ac56321662c8ad4ce92a1af20f9bdad0be6bbe09eb7324b05fac54de0c78768a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52642","feedbackHash":"5a5328f7561df0c37b8c8ed8d397f1f2a67304f25752b2820a2bcadced77cc30","nodeKey":"audit_flow","submissionHash":"32a234124069e4ea1fc37ed39d6052332ef135138e579c2cee03e1e40d813265","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50995","feedbackHash":"04c18fc3124bb18a0f5fe352ecd202b1aa33d0abb47b11d7d2378e4fd9caa667","nodeKey":"audit_judge","submissionHash":"ace3149fb2a20bb0702ee5ea007a17a2f1e1ab3ea9825813c17c9bd410ebf3fa","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52120","feedbackHash":"32b607f4b660bcdaf26e5a87ce3589b0606da9f8c40a8925fab57228392a0e73","nodeKey":"audit_math","submissionHash":"8a169ae35c88e689d736f734ddc8e2b97899085c96945fc40de71b3470c6e9a5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52263","feedbackHash":"13178560caf9b848327dfc910dfc3cc185728e4e22fdace1acf6b854c91dcffc","nodeKey":"audit_permissions","submissionHash":"2155368f3bf2256674abfe3f195a65b6a500005a76c4b1cc0ab952f3e24e71fa","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"f757af7103a4419cd75d8666a924a9483f0d3661f7aa90dcac034765c6a2816d","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6b16b4979d227241","findings":[{"citation":"resolved","description":"Seam: access x economics x asymmetry. extendRepaymentWindow (src/ReturnERC20Loans.sol:606-613) is documented as incident relief for funded loans, but _repaymentDeadline (lines 642-650) applies a token or global window to every loan whose tokens match, including loans funded AFTER the window was set. The window is bounded only by block.timestamp + 7 days at the time it is set and by maturity + 8 days per loan; nothing ties it to the loan's funding time. A loan funded during an open window therefore has maturity + duration < window end, so its deadline becomes the window end rather than maturity + GRACE_PERIOD. Unprivileged amplifier (race): any borrower who fills a short-term order while a window is open gets up to ~7 days of extra time at the signed short-term interest, and the lender cannot foreclose at the signed deadline (foreclose reverts NotInDefault until the window end). The lender signed a 1-hour term and receives a 7-day effective term with no additional interest; the borrower holds a free extension option. Rule violated: funded terms should not be altered beyond the stated incident relief; lender capital is locked beyond the signed term. Impact is bounded (max 7 days, no principal loss), and requires the owner to have declared a window and not paused new loans, so severity is Low. Remediation: have fill reject orders (or previewFill revert) while a window covering either token is open and later than the would-be deadline, or record the funding timestamp in the note's extra data and ignore windows set before funding (for example compare the window against a per-loan funding time, or store the window's set-time and apply it only to loans with maturity <= set-time + GRACE_PERIOD). Alternatively document that new fills during a window inherit the window and surface the effective deadline in previewFill.","line":647,"path":"src/ReturnERC20Loans.sol","proof":"// SPDX-License-Identifier: BUSL-1.1\npragma solidity 0.8.37;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"solady/tokens/ERC20.sol\";\nimport {ReturnERC20Loans} from \"src/ReturnERC20Loans.sol\";\n\ncontract WSToken is ERC20 {\n    function name() public pure override returns (string memory) {\n        return \"WS\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"WS\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// An emergency repayment window declared by the owner applies to loans funded AFTER the window was\n/// declared. A borrower who fills a short-term order during an open window gets the loan's deadline\n/// moved from maturity + 1 day to the window end (up to 7 days after the window was set), so the lender\n/// cannot foreclose at the signed term and the borrower holds the principal for the whole window while\n/// paying only the short term's interest.\ncontract WindowStretchTest is Test {\n    uint256 constant LK = 0xA11CE;\n    uint256 constant BK = 0xB0B;\n    address lender;\n    address borrower;\n    address treasury = address(0xFEE);\n    WSToken cash;\n    WSToken stock;\n    ReturnERC20Loans market;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        lender = vm.addr(LK);\n        borrower = vm.addr(BK);\n        cash = new WSToken();\n        stock = new WSToken();\n        market = new ReturnERC20Loans(treasury, address(this));\n        market.setAsset(address(cash), true);\n        market.setAsset(address(stock), true);\n        address[2] memory users = [lender, borrower];\n        for (uint256 i; i < 2; ++i) {\n            cash.mint(users[i], 1e30);\n            stock.mint(users[i], 1e30);\n            vm.startPrank(users[i]);\n            cash.approve(address(market), type(uint256).max);\n            stock.approve(address(market), type(uint256).max);\n            vm.stopPrank();\n        }\n    }\n\n    function _order(uint32 duration) internal view returns (ReturnERC20Loans.Order memory) {\n        return ReturnERC20Loans.Order(\n            ReturnERC20Loans.Side.Lend,\n            lender,\n            address(0),\n            address(stock),\n            200e18,\n            address(cash),\n            1000e6,\n            1010e6,\n            duration,\n            type(uint64).max,\n            0,\n            1,\n            1000e6\n        );\n    }\n\n    function testLoanFundedDuringWindowKeepsSignedDeadline() public {\n        // Owner opens a 7-day incident window for the cash token (an existing-loan relief measure).\n        market.extendRepaymentWindow(address(cash), uint40(vm.getBlockTimestamp() + 7 days));\n\n        // A lender posts a 1-hour loan; the borrower fills it one hour into the window.\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        ReturnERC20Loans.Order memory o = _order(1 hours);\n        (ReturnERC20Loans.Loan memory loan,) = market.previewFill(o, o.principal, borrower);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(LK, market.orderDigest(o));\n        bytes memory sig = abi.encodePacked(r, s, v);\n        vm.prank(borrower);\n        market.fill(o, o.principal, sig);\n\n        (uint40 maturity,,) = market.loanState(market.loanDigest(loan));\n        uint256 signedDeadline = uint256(maturity) + market.GRACE_PERIOD();\n\n        // Expected: a loan funded after the window was declared keeps its signed deadline.\n        // Actual: the deadline is the window end, almost six days later than maturity + 1 day.\n        assertEq(market.repaymentDeadline(loan), signedDeadline, \"deadline stretched by pre-existing window\");\n\n        // Consequence: the lender cannot foreclose at the signed deadline.\n        vm.warp(signedDeadline + 1);\n        vm.prank(lender);\n        market.foreclose(loan);\n    }\n}","reproduction":"Setup: owner calls extendRepaymentWindow(cash, now + 7 days). One hour later a lender's 1-hour Lend order (loanToken = cash) is filled by the borrower. Expected: repaymentDeadline(loan) == maturity + 1 day (now + 1h + 1h + 1d = 1800093600 in the test). Actual: repaymentDeadline(loan) == window end (1800604800), about 5.9 days later; foreclose(loan) at signedDeadline + 1 reverts with NotInDefault. Command: forge test --offline --match-path test/scratch/WindowStretch.t.sol -vv. Observed: [FAIL: deadline stretched by pre-existing window: 1800604800 != 1800093600].","severity":"low","snippet":"        if (until <= deadline) return deadline;\n        uint256 cap = deadline + MAX_REPAYMENT_EXTENSION;\n        return until < cap ? until : cap;","title":"Emergency repayment window stretches loans funded after it is declared, overriding the signed term for new loans"},{"citation":"resolved","description":"Trust assumption, not a permission bypass: extendRepaymentWindow only bounds `until` relative to block.timestamp and the previous window, never relative to a loan's current status. _repaymentDeadline (lines 642-650) then recomputes every matching loan's deadline as min(until, maturity + 8 days). A loan whose deadline has already passed (foreclosable, NotInDefault no longer reverts) becomes repayable again the moment the owner sets a window, and foreclose reverts with NotInDefault until the new deadline. The note holder's right to the collateral, which had vested at deadline + 1, is suspended for up to 7 days and can be extinguished by the borrower (an unprivileged actor) simply calling repay in the reopened window. This matches the intended 'settlement after a long outage' power and the existing test testEmergencyWindowKeepsRepaymentOpenAfterOutage, so it is reported as a documented owner trust assumption with an unprivileged beneficiary (the borrower), not as a defect. Lenders should be told that foreclosure must be executed promptly after the deadline, because an owner window can reverse a default that has not yet been foreclosed. If the design wants windows to apply only to loans that were not already in default, the owner's call could pass the loan or the renderer could reject windows for loans whose deadline < window set time (requires a new per-loan funding/set timestamp).","line":609,"path":"src/ReturnERC20Loans.sol","reproduction":"Fill a 1-hour loan; warp to repaymentDeadline + 6 days (loan is in default, foreclose would succeed). Owner calls extendRepaymentWindow(address(0), now + 7 days). Expected (from the lender's perspective): the vested foreclosure stays available. Actual: repaymentDeadline(loan) == original + 7 days, foreclose(loan) reverts NotInDefault, and borrower.repay(loan) succeeds, returning the collateral. Reproduced in test/scratch/Probe.t.sol::testOwnerRevivesDefaultedLoan (passes, demonstrating the behaviour): forge test --offline --match-path test/scratch/Probe.t.sol --match-test testOwnerRevivesDefaultedLoan -vv.","severity":"info","snippet":"                || until <= repaymentWindow[token] || until > block.timestamp + MAX_REPAYMENT_EXTENSION","title":"Owner window can retroactively reopen repayment on a loan already in default, removing the note holder's vested foreclosure right for up to 7 days"}],"hash":"2155368f3bf2256674abfe3f195a65b6a500005a76c4b1cc0ab952f3e24e71fa","nodeId":"9672afd3-432c-4df4-aeda-48a16c39e913","outcome":"completed","summary":"Review complete. Production source is unchanged, the findings file is written and validated against the tree, and the proof test fails on the current code as required.\n\n## Summary\n\n**Scope and setup.** Commit `2568b30f405f90acee213930af411e01914d68e6`, targets `src/ReturnERC20Loans.sol` and `src/NoteArt.sol`, plus `script/Deploy.s.sol`, `foundry.toml`, and the pinned Solady and forge-std dependencies installed by `script/install.sh`. The worker runs Foundry 1.8.3, not the 1.8.1 the repository was tested with. Solidity 0.8.37, Cancun, and via_ir were preserved.\n\n**Checks run.**\n\n| Command | Result |\n|---|---|\n| `sh script/install.sh` | pins verified |\n| `forge fmt --check` | clean |\n| `forge build --offline --sizes` | builds, lint warnings only |\n| `forge test --offline --no-match-contract 'Robinhood.*ForkTest'` | 114 passed, 0 failed |\n| Invariant suite, seed 0x1234, 32 runs x 256 depth | passed |\n| All fuzz tests, 1024 runs, seed 0x1234 | passed |\n| Fork tests (chain 4663) | skipped, no FORK_RPC_URL available |\n\n**Findings written to `.imd-findings.json`.**\n\n- **Low: emergency window stretches loans funded after it is declared.** The owner window in `_repaymentDeadline` is compared only against maturity, never against funding time. A borrower who fills a 1-hour order while a 7-day window is open gets a deadline at the window end, and the lender's foreclose reverts at the signed deadline. Proof at `test/scratch/WindowStretch.t.sol` fails on current code with the observed deadline about 5.9 days past the signed one.\n- **Info: owner window can reopen repayment on an already-defaulted loan** until it is foreclosed, suspending the note holder's vested foreclosure right for up to 7 days. This matches an existing intended-behavior test, so it is recorded as a trust assumption with the borrower as unprivileged beneficiary, not as a defect.\n\n**Permission model verified without defect.** Every state-changing entry point was mapped: maker-only `post` and `cancel`, signature-gated `fill` with taker restriction and counter, self-call-only `transferRepayment`, borrower-only `reclaimCollateral`, permissionless `repay`, `foreclose` and `collectRepayment` paying only the current note holder, and owner-only admission, pause, treasury, surplus and window functions. Owner cannot reach reserved balances or funded terms. All payable Ownable and ERC721 paths reject attached ETH. Two-step handover is the only ownership path. Signature handling rejects a zero maker, uses ERC1271 only for signers with code including EIP-7702 designators, and malleable signatures share one fill capacity. Loan IDs are unique per order offset, burns keep extra data so settled loans stay terminal, and the repay, reclaim, foreclose, collect state machine settles each claim once. Receiver hooks on note transfers can re-enter settlement only outside the transient guard, after the transfer has already completed, which is consistent.\n\n**Trust and integration risks, not defects.** A token-issuer burn that puts a token below `reserved` blocks the custody fallback for that token, so a borrower facing a frozen note holder cannot repay until the shortfall is topped up. A frozen treasury blocks all fills in that token until the owner changes treasury. Both require an unsupported or compromised token or admin state.\n\n**Coverage gaps.** No Robinhood Chain RPC, so the Safe, stock-token and launch-token fork suites did not run. No Slither. NoteArt was reviewed by reading plus the existing escaping and size tests, not by independent fuzzing of symbols.","treeHash":null,"usage":{"cachedInputTokens":2576083,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":59226,"runtime":"claude","turns":32,"wallClockMs":935458}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d73af718d55ba788","findings":[{"citation":"resolved","description":"repay() distinguishes a payer-side gas starvation from a genuine token failure purely by the gas ratio left after the inner this.transferRepayment call (EIP-150: an out-of-gas at depth 1 leaves ~1/64, a normal revert leaves almost everything, a gas-burning hook at depth 3 leaves ~1/22). A token whose own frame consumes all gas on failure (INVALID opcode: `assert`/`throw` in pre-0.8 Solidity tokens, or any compliance check implemented that way) fails at depth 2 and leaves ~0.031*gasBefore, which is below the 1/32 threshold for every gas limit (the retained amount is proportional to gasBefore). The catch branch therefore always reverts with InsufficientGas, the custody fallback (`_pull` of the repayment) is never reached, and neither the borrower nor any third party can repay while the holder is frozen. After maturity + 1 day the note holder forecloses on the collateral, violating the intended rule that 'a note holder can never block repayment' (repay() natspec) and 'Anyone may repay in full'. Required state: an admitted loan token with this failure mode and a frozen/blocked note holder. This is an integration risk rather than a logic bug reachable with the intended conforming assets (Solidity 0.8 tokens revert with REVERT and leave ample gas; the proxied-token gas sweep in the review never forced the fallback). Remediation (outside the audited source): treat the depth-2 all-gas case explicitly, e.g. forward a bounded gas stipend to the inner call and compare against that stipend rather than gasBefore/32, or let repay() accept an explicit `escrow` flag that skips the direct attempt when the holder is known to be frozen; alternatively document the token behaviour as an admission exclusion.","line":448,"path":"src/ReturnERC20Loans.sol","proof":"// SPDX-License-Identifier: BUSL-1.1\npragma solidity 0.8.37;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"solady/tokens/ERC20.sol\";\nimport {ReturnERC20Loans} from \"src/ReturnERC20Loans.sol\";\n\n/// Loan token whose failed transfer to a frozen recipient consumes all gas of the token frame\n/// (the behaviour of `assert`/`throw` in pre-0.8 Solidity tokens, or any INVALID-opcode failure).\ncontract AllGasRevertToken is ERC20 {\n    address public frozen;\n\n    function name() public pure override returns (string memory) {\n        return \"AllGas\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"AGAS\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n\n    function setFrozen(address a) external {\n        frozen = a;\n    }\n\n    function _beforeTokenTransfer(address, address to, uint256) internal view override {\n        if (to == frozen) {\n            assembly (\"memory-safe\") {\n                invalid()\n            }\n        }\n    }\n}\n\ncontract PlainToken is ERC20 {\n    function name() public pure override returns (string memory) {\n        return \"Plain\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"PLN\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// Fails on the current code: the borrower's repayment reverts with InsufficientGas at every gas limit,\n/// because the all-gas failure at call depth 2 leaves less than gasBefore/32 and is treated as a payer-side\n/// starvation. Passes once a frozen holder of such a token no longer blocks repayment (custody fallback).\ncontract AllGasRevertProofTest is Test {\n    ReturnERC20Loans market;\n    AllGasRevertToken cash;\n    PlainToken coll;\n    address treasury = makeAddr(\"treasury\");\n    uint256 lenderKey = 0xA11CE;\n    address lender;\n    address borrower = makeAddr(\"borrower\");\n\n    function setUp() public {\n        lender = vm.addr(lenderKey);\n        market = new ReturnERC20Loans(treasury, address(this));\n        cash = new AllGasRevertToken();\n        coll = new PlainToken();\n        market.setAsset(address(cash), true);\n        market.setAsset(address(coll), true);\n        cash.mint(lender, 1e24);\n        cash.mint(borrower, 1e24);\n        coll.mint(borrower, 1e24);\n        vm.prank(lender);\n        cash.approve(address(market), type(uint256).max);\n        vm.startPrank(borrower);\n        cash.approve(address(market), type(uint256).max);\n        coll.approve(address(market), type(uint256).max);\n        vm.stopPrank();\n        vm.warp(1_800_000_000);\n    }\n\n    function testBorrowerCanRepayWhenHolderFrozenByAllGasToken() public {\n        ReturnERC20Loans.Order memory o = ReturnERC20Loans.Order({\n            side: ReturnERC20Loans.Side.Lend,\n            maker: lender,\n            taker: address(0),\n            collateral: address(coll),\n            collateralAmount: 2e18,\n            loanToken: address(cash),\n            principal: 1e18,\n            repayment: 1.1e18,\n            duration: 30 days,\n            validUntil: uint64(block.timestamp + 1 days),\n            counter: 0,\n            salt: 1,\n            minFill: 1e18\n        });\n        (ReturnERC20Loans.Loan memory loan,) = market.previewFill(o, o.principal, borrower);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(lenderKey, market.orderDigest(o));\n        bytes memory sig = abi.encodePacked(r, s, v);\n        vm.prank(borrower);\n        market.fill(o, o.principal, sig);\n\n        // The issuer freezes the note holder. A conforming token would revert normally and the\n        // repayment would enter custody; this token consumes all gas in its own frame instead.\n        cash.setFrozen(lender);\n\n        uint256[4] memory gasLimits = [uint256(300_000), 2_000_000, 10_000_000, 29_000_000];\n        bool repaid;\n        for (uint256 i; i < gasLimits.length && !repaid; ++i) {\n            vm.prank(borrower);\n            (bool ok,) = address(market).call{gas: gasLimits[i]}(abi.encodeCall(market.repay, (loan)));\n            repaid = ok;\n        }\n        // Expected: repayment succeeds at some reasonable gas limit (direct or via custody fallback).\n        // Actual: every attempt reverts with InsufficientGas and the loan can only be foreclosed.\n        assertTrue(repaid, \"borrower cannot repay at any gas limit\");\n        assertEq(market.heldRepayment(loan), loan.repayment);\n        assertEq(coll.balanceOf(borrower), 1e24);\n    }\n}","reproduction":"Run `forge test --offline --match-path test/scratch/AllGasRevertProof.t.sol`. Setup: ReturnERC20Loans with an admitted loan token whose _beforeTokenTransfer executes `invalid()` when `to == frozen`, a plain collateral token, one Lend order (principal 1e18, repayment 1.1e18, collateral 2e18, 30 days) filled by the borrower. Issuer freezes the lender (note holder). Borrower calls repay(loan) with gas limits 300k, 2M, 10M, 29M. Expected: the direct payment fails and the repayment is held for collectRepayment (heldRepayment == 1.1e18) with collateral returned. Actual: every call reverts with InsufficientGas (selector 0x1c26714c); a third-party payer gets the same; after warp to deadline+1 foreclose(loan) sends the 2e18 collateral to the lender. Observed: `[FAIL: borrower cannot repay at any gas limit]`.","severity":"low","snippet":"            if (gasleft() < gasBefore / 32) revert InsufficientGas();","title":"Repayment is unreachable (InsufficientGas) when the loan token consumes all gas on a failed transfer to a frozen note holder; custody fallback never triggers and the borrower is foreclosed"},{"citation":"resolved","description":"_repaymentDeadline() is evaluated at call time from the current repaymentWindow values, with no requirement that the window was set before the loan's original deadline passed. If the owner calls extendRepaymentWindow(token or 0, until) while a loan is already past maturity + 1 day but not yet foreclosed, the loan's deadline moves to min(until, maturity + 8 days): foreclose() starts reverting with NotInDefault and repay() is accepted again. The 8-day cap is respected, so the stated extension rule is not violated, and the power is documented; but it is a stronger trust assumption than 'extend repayment' suggests: the owner can retroactively un-default any loan and delay a note holder's vested foreclosure right by up to 7 days, globally, with one call. Not a permission bypass; recorded as a trust/integration risk for the note holder side. Remediation suggestion (design decision): only apply a window to loans whose original deadline had not passed when the window was set (e.g. store the window's set-time and require maturity + GRACE_PERIOD >= setTime), or document the retroactive effect explicitly.","line":649,"path":"src/ReturnERC20Loans.sol","reproduction":"Run `forge test --offline --match-path test/scratch/Revive.t.sol -vv`. Fill a 30-day loan at t0; warp to repaymentDeadline + 2 days: repay(loan) reverts GracePeriodExpired (defaulted, foreclosure pending). Owner calls extendRepaymentWindow(address(0), now + 7 days). Expected per 'extension' semantics: a loan already in default stays foreclosable. Actual: repaymentDeadline(loan) becomes original + 7 days (= maturity + 8 days), foreclose(loan) reverts NotInDefault, and the borrower's repay(loan) succeeds and returns the collateral. Test passes (demonstration of behaviour).","severity":"info","snippet":"        return until < cap ? until : cap;","title":"Owner window set after a loan's deadline revives the defaulted loan and suspends a pending foreclosure for up to 7 days"},{"citation":"resolved","description":"After a direct payment fails (frozen holder) the repayment is escrowed, the _REPAID bit is set and the note stays outstanding so its holder (or a transferee) can call collectRepayment. _status() only inspects _REPAID, so tokenURI for such a live note reports Status 'Paid back' with the description 'its holder is paid', although the holder has not been paid and the note is the sole key to a claim worth loan.repayment. Metadata cannot change custody or settlement (confirmed), so this is a display defect only, but marketplaces and wallets will show a valuable, transferable claim as settled; the renderer can tell the two apart because the note contract still returns a non-zero ownerOf and heldRepayment for it. Suggested remediation: expose the held state in the packed extra data or have tokenURI pass an 'escrowed' flag (note exists && _REPAID) and render a distinct 'Repayment held' status.","line":302,"path":"src/NoteArt.sol","reproduction":"Run `forge test --offline --match-path test/scratch/Explore.t.sol --match-test testEscrowedNoteLabelledPaidBack -vv`. Fill a loan, freeze the lender in the loan token (normal revert), borrower calls repay(loan): heldRepayment(loan) == 1.1e18 and ownerOf(id) == lender (note alive). Decode market.tokenURI(id): attributes contain {\"trait_type\":\"Status\",\"value\":\"Paid back\"}. Expected: a status that reflects the outstanding claim (not 'Paid back').","severity":"info","snippet":"        if ((state & _REPAID) != 0) return Status(\"Paid back\", \"#e4dbd0\", \"#0c0c09\");","title":"NoteArt labels a note that still carries an outstanding held repayment as 'Paid back'"}],"hash":"32a234124069e4ea1fc37ed39d6052332ef135138e579c2cee03e1e40d813265","nodeId":"f9f58d0d-4dac-427b-acc4-727de4ad9ae0","outcome":"completed","summary":"Review complete. The findings file `.imd-findings.json` holds three substantiated entries, none above Low. No asset-loss, unauthorized-action, or settlement defect reachable with conforming tokens was found.\n\n**Scope and environment**\n\n- Commit 2568b30f405f90acee213930af411e01914d68e6, files `src/ReturnERC20Loans.sol`, `src/NoteArt.sol`, `script/Deploy.s.sol`, `foundry.toml`, all tests.\n- Foundry 1.8.3 (repository tested with 1.8.1), solc 0.8.37, Cancun, via_ir, optimizer settings unchanged.\n- The sandbox blocked github.com and codeload, so the pinned dependency commits could not be fetched. I built against solady 0.1.26 and forge-std 1.9.7 from npm, with two declaration-only patches to the local `Vm.sol` (one missing cheatcode, one `pure` qualifier). `script/install.sh` is unchanged. Behavioural differences in these library versions are an unresolved assumption.\n\n**Test results**\n\n| Run | Result |\n|---|---|\n| `forge fmt --check src script test/*.sol` | clean |\n| `forge build --offline --sizes` | ok, ReturnERC20Loans 18,311 B runtime |\n| `forge test --offline --no-match-contract 'Robinhood.*ForkTest'` | 114 passed, 0 failed |\n| Invariant, seed 0xC0FFEE, 128 runs x 128 depth | passed |\n| All fuzz tests, seed 0xC0FFEE, 1024 runs | passed |\n| Fork tests (chain 4663) | skipped, no RPC available |\n\n**Findings**\n\n| Severity | Finding | Anchor |\n|---|---|---|\n| Low | Loan token that consumes all gas on a failed transfer to a frozen holder makes `repay` revert with InsufficientGas at every gas limit. The custody fallback is unreachable and the borrower is foreclosed. Failing proof in `test/scratch/AllGasRevertProof.t.sol`. | `src/ReturnERC20Loans.sol:448` |\n| Info | A window set by the owner after a loan's deadline revives the defaulted loan and blocks a pending foreclosure for up to 7 days. Within the documented cap, recorded as a trust assumption. | `src/ReturnERC20Loans.sol:649` |\n| Info | A note with a held, uncollected repayment renders as \"Paid back\". Display only, no custody effect. | `src/NoteArt.sol:302` |\n\n**What was verified and held**\n\n- Fill allocation, fee rounding, minimum fills, residual capacity, overfill and cancellation match the stated rules. Fee splitting adds at most one raw unit per extra fill, which is expected rounding, not an attack.\n- Settlement state machine: repeated and reordered calls settle once, forged Loan fields reach no other loan, every flag combination is consistent across repay, reclaim, foreclose and collect.\n- Deadlines: repayment allowed at the deadline, foreclosure after it, extension capped at maturity plus eight days.\n- Signatures: all fields are in the typehash, domain binds chain and contract, ERC-1271 and EIP-7702 paths behave as documented.\n- Reentrancy: transient guard covers every state-changing path, note transfers are blocked during settlement, the self-call helper rejects outside callers.\n- Gas boundary: a sweep of 3,712 gas limits through a delegatecall-proxied token never forced the escrow path.\n\n**Coverage gaps**\n\n- Fork tests against live USDG, tokenized stocks and Safe were not run.\n- Exact pinned library revisions were not available; the pinned forge-std is newer than the one used.\n- Signature and order fields were reviewed against the suite's existing fuzzers rather than a new independent model, since the existing invariant already carries one.","treeHash":null,"usage":{"cachedInputTokens":3262478,"inputTokens":1122,"model":"claude-fable-5-1","outputTokens":68909,"runtime":"claude","turns":43,"wallClockMs":1267522}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a5c5e95a2ed07117","findings":[{"citation":"resolved","description":"repay() runs the direct payment in a separate frame (this.transferRepayment) and, when it fails, falls back to custody only if gasleft() >= gasBefore/32. The threshold assumes a genuine token revert returns most of the inner call's gas. That is false for tokens whose failed transfer consumes the whole frame (INVALID opcode: Solidity 0.4 `throw`, pre-0.8 `assert`, or any OOG inside the token frame). Under EIP-150 the leftover after such a failure is G/64 (kept by repay) + (63/64)G/64 (kept by transferRepayment) = 0.0310 G, which is always below G/32 = 0.03125 G regardless of how much gas the payer attaches. Consequently repay() reverts with InsufficientGas for every caller and every gas limit while the holder is frozen in the loan token, violating the stated rule that a note holder can never block repayment. Once the deadline passes, anyone can foreclose and the frozen holder receives the collateral (a different token that is not frozen). The check is intended to stop payers from forcing escrow with a low gas limit; it also rejects this legitimate failure class because the 1/32 cut sits between the depth-2 leftover (1/32.25) and the depth-3 leftover (about 1/22). Preconditions: the owner has admitted a token with this failure behaviour (outside the stated supported-asset profile, which is why severity is low) and the issuer freezes the current note holder. Remediation: do not rely on the leftover-gas ratio alone. For example, forward an explicit, bounded gas amount to transferRepayment and compare gasleft() after the call against a fixed stipend required by _pull, or measure the gas returned by the inner call relative to the gas given to it rather than to gasBefore, so that a full-frame burn at depth 2 is still distinguishable from a payer-chosen low gas limit.","line":448,"path":"src/ReturnERC20Loans.sol","proof":"// SPDX-License-Identifier: BUSL-1.1\npragma solidity 0.8.37;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"solady/tokens/ERC20.sol\";\nimport {ReturnERC20Loans} from \"src/ReturnERC20Loans.sol\";\n\n/// @dev Loan token whose transfer to a frozen account fails by consuming all gas in the token's own frame\n/// (the behaviour of Solidity 0.4 `throw` / pre-0.8 `assert`, and of any INVALID-opcode failure).\ncontract GasBurningFreezeToken is ERC20 {\n    address public frozen;\n\n    function name() public pure override returns (string memory) {\n        return \"Gas burning freeze token\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"GBF\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n\n    function setFrozen(address account) external {\n        frozen = account;\n    }\n\n    function _beforeTokenTransfer(address, address to, uint256) internal view override {\n        if (to != address(0) && to == frozen) {\n            assembly {\n                invalid()\n            }\n        }\n    }\n}\n\ncontract PlainToken is ERC20 {\n    function name() public pure override returns (string memory) {\n        return \"Plain\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"PLN\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// Expected: a frozen note holder can never block repayment; the payment enters custody for\n/// {collectRepayment}. Actual: when the loan token fails by burning all gas in its own frame, the gas left\n/// after the failed inner call is G/64 + (63/64)G/64 ~ G/32.25, which is below the `gasBefore / 32`\n/// threshold at every gas limit, so {repay} always reverts with {InsufficientGas}. The borrower cannot\n/// repay, the deadline passes and the loan is foreclosed.\ncontract GasBurningTokenBlocksRepaymentTest is Test {\n    uint256 constant LENDER_KEY = 0xA11CE;\n    address lender;\n    address borrower = address(0xB0B);\n    address treasury = address(0xFEE);\n    GasBurningFreezeToken cash;\n    PlainToken stock;\n    ReturnERC20Loans loans;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        lender = vm.addr(LENDER_KEY);\n        cash = new GasBurningFreezeToken();\n        stock = new PlainToken();\n        loans = new ReturnERC20Loans(treasury, address(this));\n        loans.setAsset(address(cash), true);\n        loans.setAsset(address(stock), true);\n        cash.mint(lender, 1e24);\n        cash.mint(borrower, 1e24);\n        stock.mint(borrower, 1e24);\n        vm.prank(lender);\n        cash.approve(address(loans), type(uint256).max);\n        vm.startPrank(borrower);\n        cash.approve(address(loans), type(uint256).max);\n        stock.approve(address(loans), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function testFrozenHolderWithGasBurningTokenCannotBlockRepayment() public {\n        ReturnERC20Loans.Order memory o = ReturnERC20Loans.Order(\n            ReturnERC20Loans.Side.Lend,\n            lender,\n            borrower,\n            address(stock),\n            2_000e18,\n            address(cash),\n            1_000e6,\n            1_010e6,\n            7 days,\n            uint64(block.timestamp + 30 days),\n            0,\n            1,\n            1_000e6\n        );\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(LENDER_KEY, loans.orderDigest(o));\n        vm.prank(borrower);\n        bytes32 id = loans.fill(o, o.principal, abi.encodePacked(r, s, v));\n        ReturnERC20Loans.Loan memory loan = loans.loanTerms(o, o.principal, 0, borrower);\n        assertEq(loans.loanDigest(loan), id);\n\n        // The issuer freezes the note holder in the loan token.\n        cash.setFrozen(lender);\n\n        // Expected: the repayment enters custody and the collateral returns to the borrower.\n        vm.prank(borrower);\n        loans.repay{gas: 30_000_000}(loan);\n        assertEq(loans.heldRepayment(loan), loan.repayment);\n        assertEq(loans.reserved(address(cash)), loan.repayment);\n        assertEq(stock.balanceOf(borrower), 1e24);\n        (, bool repaid, bool released) = loans.loanState(id);\n        assertTrue(repaid && released);\n    }\n}","reproduction":"Admit a Solady ERC20 whose _beforeTokenTransfer executes `invalid()` when `to == frozen` as the loan token and a plain ERC20 as collateral. Fill a 1,000e6 / 1,010e6 / 7-day Lend order (borrower posts 2,000e18 collateral). Call token.setFrozen(lender). As the borrower call loans.repay(loan) with 300k, 1M, 5M and 30M gas. Expected: the repayment is pulled into custody (heldRepayment == 1,010e6, reserved[cash] == 1,010e6) and the collateral is returned. Actual: every call reverts with InsufficientGas() (0x1c26714c). The failing test test/scratch/GasBurningTokenBlocksRepayment.t.sol reproduces it: `forge test --offline --match-path test/scratch/GasBurningTokenBlocksRepayment.t.sol` -> [FAIL: InsufficientGas()] testFrozenHolderWithGasBurningTokenCannotBlockRepayment() (gas: 29431014). The same token with a plain `revert(\"frozen\")` instead of `invalid()` enters custody as intended (testFrozenHolderEscrowWithRevertingToken in test/scratch/MathAudit.t.sol passes).","severity":"low","snippet":"            if (gasleft() < gasBefore / 32) revert InsufficientGas();","title":"Custody fallback in repay is unreachable when the loan token fails by consuming all gas: InsufficientGas at every gas limit, so a frozen note holder blocks repayment and the borrower is foreclosed"},{"citation":"resolved","description":"The fee is ceil(30 bps) of each fill's principal and is deducted from the borrower's proceeds. On a Borrow order the lender is the taker and chooses the fill sizes, so the borrower's total fee depends on a counterparty decision. Each fill can add at most one raw unit of rounding, so the aggregate overcharge is bounded by (number of fills - 1) raw units, which is at most principal/minFill - 1. The only guard on minFill is protocolFee(minFill) < minFill, which admits minFill = 2 where the rounded fee is 50% of the fill. This is expected rounding under the stated rule ('rounded up to one raw token unit' per fill) and the exposure is fully controlled by the borrower's own minFill, so it is reported for the record rather than as a defect. With any realistic minimum (for USDG, minFill >= 1e6 gives an exact 0.3% per fill) the amplification is at most a few raw units. Remediation if wanted: require minFill to be large enough that the rounding is immaterial (for example protocolFee(minFill) * 10_000 <= minFill * FEE_BPS * 11 / 10, or minFill >= 10_000 / FEE_BPS * k), or document that makers must choose minFill accordingly.","line":733,"path":"src/ReturnERC20Loans.sol","reproduction":"Borrow order: principal 1,000, repayment 1,500, collateralAmount 1,000, minFill 2, taker zero. The lender fills 500 times with amount 2. Expected for a single full fill: fee ceil(1000*30/10000) = 3, borrower receives 997. Actual: each fill charges ceil(2*30/10000) = 1, treasury receives 500 and the borrower receives 500 of the 1,000 principal. Reproduced by testFeeAmplificationBySplitting in test/scratch/MathAudit.t.sol (`forge test --offline --match-path test/scratch/MathAudit.t.sol --match-test testFeeAmplificationBySplitting -vv` logs 'treasury fee: 500', 'borrower received of 1000 principal: 500'). The on-chain multi-fill fuzz (testFuzzOnChainMultiFillAndSettle, 2000 runs, seed 20261012) confirms the bound totalFee <= protocolFee(principal) + fills - 1.","severity":"info","snippet":"        return (uint256(principal) * FEE_BPS + 9_999) / 10_000;","title":"Per-fill fee rounding lets the taker of a Borrow order multiply the borrower's fee by splitting into minimum fills (bounded by one raw unit per fill)"}],"hash":"8a169ae35c88e689d736f734ddc8e2b97899085c96945fc40de71b3470c6e9a5","nodeId":"29d79393-b212-4517-8db1-9c13e7e2923d","outcome":"completed","summary":"Everything needed is in hand: the required suite is green (114 passed), production source is untouched, and the findings file holds two entries. Here is the final report.\n\n## Audit report: Return v1 contracts, math and boundary review\n\n**Commit** `2568b30f405f90acee213930af411e01914d68e6`. Scope: `src/ReturnERC20Loans.sol`, `src/NoteArt.sol`, `script/Deploy.s.sol`, `foundry.toml`, Solady at `2afba69` and forge-std at `0258fe8` as pinned by `script/install.sh` (both verified at those revisions). Worker Foundry is 1.8.3 (repo was tested with 1.8.1); Solidity 0.8.37, Cancun, via_ir and optimizer settings preserved. The findings and the proof source are in `.imd-findings.json`.\n\n**Test summary**\n\n| Run | Result |\n|---|---|\n| `forge fmt --check`, `forge build --offline --sizes` | clean |\n| `forge test --offline --no-match-contract 'Robinhood.*ForkTest'` | 114 passed, 0 failed (invariant: 128 runs, depth 128) |\n| `test/scratch/MathAudit.t.sol` (9 tests, fuzz 2000 runs, seed 20261012) | all pass |\n| `test/scratch/GasBurningTokenBlocksRepayment.t.sol` (proof) | fails on current code with `InsufficientGas()` |\n| Fork tests (`Robinhood.*ForkTest`) | skipped: no `FORK_RPC_URL` on this worker, not counted as passing |\n\n**Findings**\n\n| Severity | Title | Location |\n|---|---|---|\n| Low | Custody fallback unreachable when the loan token fails by burning all gas | `src/ReturnERC20Loans.sol:448` |\n| Info | Per-fill fee rounding amplified by minimum-fill splitting on Borrow orders | `src/ReturnERC20Loans.sol:733` |\n\n- **Low, gas-ratio boundary in repay.** After a failed direct payment, the fallback runs only if `gasleft() >= gasBefore/32`. A token whose failed transfer consumes its whole frame (INVALID opcode: Solidity 0.4 `throw`, pre-0.8 `assert`) leaves exactly G/64 + 63G/4096, about G/32.25, so the check reverts at every gas limit. With such a token admitted and the note holder frozen by the issuer, nobody can repay, the deadline passes, and the frozen holder is paid the collateral by foreclosure. The proof fails at a 30M gas limit. This needs a token outside the stated supported profile, hence low. Suggested fix: compare leftover gas against a fixed stipend for the custody pull, or against the gas actually forwarded to the inner call, instead of the 1/32 ratio.\n- **Info, fee rounding by splitting.** The fee is ceil(30 bps) per fill. On a Borrow order the lender picks fill sizes, and with minFill of 2 raw units a 1,000-unit order filled 500 times charges 500 instead of 3. The overcharge is bounded by one raw unit per fill and the borrower controls it through minFill, so this is expected rounding, recorded for completeness.\n\n**Verified correct (no defect found)**, each backed by scratch tests with an independent model: cumulative pro-rata allocation sums exactly to the signed collateral and repayment over any valid fill sequence, every fill receives at least one unit of collateral and interest and deviates from exact pro-rata by under one unit, residual capacity is never left in (0, minFill), overfill reverts, fee is always below the fill amount, reserved equals custody after mixed repay and foreclose settlement, maturity at exactly `uint40.max` is accepted and one second later rejected, `validUntil` is inclusive, repayment is allowed at the deadline and foreclosure one second after, repeated owner extensions cap at maturity plus eight days and never shorten a deadline, extra-data bit layout fits in 74 of 96 bits, Solady burn preserves extra data so reclaim after a direct third-party repayment works, and every payable inherited entry point rejects ETH. NoteArt is view-only, escapes symbols for JSON and HTML separately, bounds symbol reads to 9 bytes under a 100k gas stipend, and renders in about 1.1M gas.\n\n**Trust and integration notes, not defects.** Owner powers (admission, pause, treasury, surplus, bounded extension) match the stated design. A note holder with a gas-burning receive hook forces the payer to attach roughly 1.5M gas or more ","treeHash":null,"usage":{"cachedInputTokens":1741739,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":58882,"runtime":"claude","turns":35,"wallClockMs":992953}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"60ffae3cc413a0c6","findings":[{"citation":"resolved","description":"repay() documents that a failed direct payment to the note holder (for example a holder frozen by the token issuer) falls back to custody so that 'a note holder can never block repayment'. The fallback is gated by `gasleft() >= gasBefore / 32` after the inner `this.transferRepayment` call. Under EIP-150 the inner frame receives 63/64 of the gas and the token frame receives 63/64 of that. If the token's failure path executes INVALID (as `assert`/`throw` compiled to before Solidity 0.8, and as some legacy SafeMath/freeze implementations still do), the token frame burns everything it was given, the inner frame returns with only (1/64)(63/64)G, and repay is left with G/64 + (63/64)(1/64)G = 0.03101G < G/32. The check therefore reverts with InsufficientGas at every gas limit, by every payer, so the loan cannot be repaid while the holder is frozen and is foreclosed at maturity + 1 day. The borrower loses collateral on a loan it was willing and able to repay, and the (frozen) holder, or whoever it sells the note to, collects collateral that is normally worth more than the repayment. Rule violated: 'blocked valid claims' / the stated guarantee that a holder's inability to receive never blocks repayment. Preconditions: an admitted ERC20 that is otherwise exact and non-rebasing but whose transfer failure consumes all gas (unsupported-token behavior under the stated admission policy; a `require`-style freeze is handled correctly, see baseline test in test/scratch/AuditEconomics.t.sol), and an issuer freeze of the current note holder before the deadline. The holder cannot trigger this with a plain token; it needs the issuer action. Remediation (not applied): do not rely on the 1/32 heuristic alone. Either forward an explicit bounded gas amount to the inner call and compare the remaining gas against that bound (so an all-gas-consuming token still leaves the outer frame with the remainder), or detect the out-of-gas case by checking returned data / a sentinel written by transferRepayment before the token call, and treat any inner failure that occurred after the burn as a token failure eligible for the custody path. Alternatively document the restriction and reject tokens whose failure mode is INVALID during admission review. Scope bound (verified): the gap is only 0.00024G wide, so one additional call frame changes the outcome. If the same INVALID-on-freeze token sits behind a delegatecall proxy (as upgradeable issuer tokens such as USDG are), the outer frame keeps about 0.046G and the custody fallback works (test testInvalidOpcodeFreezeBehindProxyReachesCustodyFallback passes). The defect therefore affects only non-proxied tokens whose failing transfer consumes all gas in the token frame itself; the repair is nevertheless worth making because the 1/32 heuristic sits within 0.8% of the EIP-150 floor for a single-frame failure.","line":448,"path":"src/ReturnERC20Loans.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.37;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"solady/tokens/ERC20.sol\";\nimport {ReturnERC20Loans} from \"src/ReturnERC20Loans.sol\";\n\n/// @dev Exact-amount, non-rebasing ERC20 with an issuer freeze list whose failed transfer executes INVALID\n/// (what `assert`/`throw` compiled to before Solidity 0.8), consuming all gas of the token frame.\ncontract InvalidOnFreezeToken is ERC20 {\n    address public frozen;\n\n    function name() public pure override returns (string memory) {\n        return \"Invalid freeze token\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"IFT\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n\n    function setFrozen(address value) external {\n        frozen = value;\n    }\n\n    function _beforeTokenTransfer(address from, address to, uint256) internal view override {\n        if (frozen != address(0) && (from == frozen || to == frozen)) {\n            assembly (\"memory-safe\") {\n                invalid()\n            }\n        }\n    }\n}\n\ncontract PlainToken is ERC20 {\n    function name() public pure override returns (string memory) {\n        return \"Plain\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"PLN\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// Expected (per the contract's own documentation of `repay`): when the direct payment to the note holder\n/// fails, the repayment enters custody and the borrower's collateral is released. Actual: when the token's\n/// failure consumes all gas, `gasleft() < gasBefore / 32` always holds after the inner call, so `repay`\n/// reverts with InsufficientGas at every gas limit and the loan can only be foreclosed.\ncontract InvalidOpcodeFreezeTest is Test {\n    uint256 internal constant LENDER_KEY = 0xA11CE;\n    address internal lender;\n    address internal borrower = address(0xB0B);\n    address internal treasury = address(0xFEE);\n    InvalidOnFreezeToken internal cash;\n    PlainToken internal stock;\n    ReturnERC20Loans internal market;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        lender = vm.addr(LENDER_KEY);\n        cash = new InvalidOnFreezeToken();\n        stock = new PlainToken();\n        market = new ReturnERC20Loans(treasury, address(this));\n        market.setAsset(address(cash), true);\n        market.setAsset(address(stock), true);\n        cash.mint(lender, 1e30);\n        cash.mint(borrower, 1e30);\n        stock.mint(borrower, 1e30);\n        vm.prank(lender);\n        cash.approve(address(market), type(uint256).max);\n        vm.startPrank(borrower);\n        cash.approve(address(market), type(uint256).max);\n        stock.approve(address(market), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function testBorrowerCanRepayIntoCustodyWhenHolderFreezeConsumesAllGas() public {\n        ReturnERC20Loans.Order memory o = ReturnERC20Loans.Order(\n            ReturnERC20Loans.Side.Lend,\n            lender,\n            borrower,\n            address(stock),\n            200e18,\n            address(cash),\n            1000e6,\n            1010e6,\n            7 days,\n            type(uint64).max,\n            0,\n            1,\n            1000e6\n        );\n        (ReturnERC20Loans.Loan memory loan,) = market.previewFill(o, o.principal, borrower);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(LENDER_KEY, market.orderDigest(o));\n        vm.prank(borrower);\n        market.fill(o, o.principal, abi.encodePacked(r, s, v));\n\n        // Issuer freezes the note holder. A `require` based freeze would route the payment into custody.\n        cash.setFrozen(lender);\n\n        // Generous gas: far above anything the custody path needs.\n        vm.prank(borrower);\n        (bool ok, bytes memory data) =\n            address(market).call{gas: 30_000_000}(abi.encodeCall(market.repay, (loan)));\n        assertTrue(ok, string.concat(\"repay reverted: \", vm.toString(data)));\n        assertEq(market.heldRepayment(loan), loan.repayment, \"repayment not held in custody\");\n        (, bool repaid, bool released) = market.loanState(market.loanDigest(loan));\n        assertTrue(repaid && released, \"loan not settled for the borrower\");\n    }\n}","reproduction":"Setup: admit InvalidOnFreezeToken (Solady ERC20 whose _beforeTokenTransfer executes `invalid()` when from or to is frozen) as loan token and a plain ERC20 as collateral. Lender signs a Lend order (principal 1000e6, repayment 1010e6, collateral 200e18, 7 days); borrower fills in full. Issuer calls setFrozen(lender). Borrower calls repay(loan) with gas 200k, 500k, 1M, 5M, 30M, 100M. Expected (per repay's documentation): the direct payment fails, the 1010e6 enters custody, collateral is returned to the borrower, note stays outstanding for collectRepayment. Actual: every call reverts with InsufficientGas (0x1c26714c); third-party repay reverts identically; after maturity + 1 day + 1 anyone can foreclose and the frozen lender receives 200e18 collateral. Command: forge test --offline --match-path test/scratch/InvalidOpcodeFreeze.t.sol (fails on this commit with `repay reverted: 0x1c26714c`). Breadth: forge test --offline --match-path test/scratch/AuditEconomics.t.sol --match-test testInvalidOpcodeFreezeBlocksRepaymentAtEveryGasLimit shows the revert at all six gas limits and the subsequent foreclosure; testRequireFreezeUsesCustodyFallback shows the same scenario working with a require-based freeze. Contrast: forge test --offline --match-path test/scratch/AuditEconomics.t.sol --match-test testInvalidOpcodeFreezeBehindProxyReachesCustodyFallback passes (same token behind a one-frame delegatecall proxy reaches custody).","severity":"low","snippet":"            if (gasleft() < gasBefore / 32) revert InsufficientGas();","title":"repay custody fallback is unreachable when the loan token's failed transfer consumes all gas, so a frozen note holder forces default"},{"citation":"resolved","description":"_repaymentDeadline recomputes the deadline from the current windows at call time and extendRepaymentWindow only requires `until` to be in the future, later than the current window, and at most 7 days ahead. Nothing excludes loans whose original deadline has already passed. A loan that is in default (block.timestamp > maturity + 1 day) but not yet foreclosed therefore becomes repayable again, and foreclose() reverts with NotInDefault, as soon as the owner sets any window covering its token (or the global zero-address window). This stays within the documented cap of maturity + 8 days and is consistent with the stated owner power to 'extend repayment within the cap', so it is reported as a trust/integration risk rather than a defect: a lender who delays foreclosure by a block can be front-run by an owner window that reopens repayment for up to 7 days, and the owner can do this for all loans at once with token = address(0). It is not reachable by non-owners. Suggested documentation or (optional) tightening: make windows apply only to loans whose deadline has not yet passed at the time the window is set, or state explicitly in integration docs that foreclosure should not be deferred because an incident window may reopen repayment.","line":647,"path":"src/ReturnERC20Loans.sol","reproduction":"Lend order 1000e6/1010e6, 7 days, filled by borrower at t0. Warp to repaymentDeadline(loan) + 1: repay reverts GracePeriodExpired (loan is in default, foreclose would succeed). Owner calls extendRepaymentWindow(address(0), now + 7 days). Expected by a lender: the defaulted loan remains foreclosable. Actual: repaymentDeadline(loan) becomes the original deadline + 7 days, foreclose reverts NotInDefault, and the borrower's repay succeeds. Repeated extensions never move the deadline beyond maturity + 8 days (cap verified). Command: forge test --offline --match-path test/scratch/AuditEconomics.t.sol --match-test testOwnerWindowRevivesDefaultedButUnforeclosedLoan (passes, demonstrating the behavior).","severity":"info","snippet":"        if (until <= deadline) return deadline;\n        uint256 cap = deadline + MAX_REPAYMENT_EXTENSION;\n        return until < cap ? until : cap;","title":"Owner repayment-window extension revives loans that are already past their deadline, blocking foreclosure for up to 7 days after default"}],"hash":"ac56321662c8ad4ce92a1af20f9bdad0be6bbe09eb7324b05fac54de0c78768a","nodeId":"b138d5f1-a67f-4e0b-81d0-4e961f4e920b","outcome":"completed","summary":"Everything is in place: the findings file holds two entries, the proof fails on this commit, production source is untouched, and the scratch files are intentionally excluded from git by the harness. Here is the report.\n\n## Audit result: Return v1 contracts at `2568b30f`\n\n**Outcome.** No critical or high defects were confirmed. One low-severity defect and one informational trust risk are recorded in `.imd-findings.json`. The economic model, allocation math, custody accounting, settlement state machine, and permission model all held up against targeted and randomized testing.\n\n**Scope and tooling.** Primary targets `src/ReturnERC20Loans.sol` and `src/NoteArt.sol`, plus `script/Deploy.s.sol`, `foundry.toml`, and the pinned Solady and forge-std dependencies installed through `script/install.sh` at their recorded revisions. Worker Foundry is 1.8.3 (the repository was written against 1.8.1). Solidity 0.8.37, Cancun, via-IR and the optimizer settings were preserved.\n\n**Checks run.**\n\n| Command | Result |\n|---|---|\n| `sh script/install.sh` | OK, pins verified |\n| `forge fmt --check src script test/*.sol` | clean |\n| `forge build --offline --sizes` | OK, lint warnings only |\n| `forge test --offline --no-match-contract 'Robinhood.*ForkTest'` | 114 passed, 0 failed |\n| project invariant at runs 256, depth 256, seed `0xA11CE` | passed, 65,536 calls, 0 reverts |\n| `test/scratch/AuditEconomics.t.sol`, 1000 and 2000 fuzz runs, seeds `0x5EED` and `0x1234` | 8 passed |\n| `test/scratch/InvalidOpcodeFreeze.t.sol` | fails on this commit as intended (proof) |\n\n**Skipped.** The Robinhood Chain fork suites did not run because no `FORK_RPC_URL` was available. Those tests cover the live USDG and stock-token contracts and the transient-storage guard on chain 4663. They are counted as not run, not as passing.\n\n**Confirmed finding (low).** The repay custody fallback is unreachable when the loan token's failed transfer consumes all gas. The guard at `src/ReturnERC20Loans.sol:448` requires more than 1/32 of the gas to remain after the inner call. When a non-proxied token fails a transfer to a frozen holder with the INVALID opcode, EIP-150 leaves the outer frame with about 0.0310 of its gas, just under 1/32. Every payer at every gas limit reverts with `InsufficientGas`, so the loan cannot be repaid while the holder is frozen and is foreclosed. This requires an unsupported token failure mode. A one-frame delegatecall proxy in front of the same token restores the fallback, which bounds the exposure to legacy, non-upgradeable tokens.\n\n**Informational (trust risk).** An owner repayment window revives loans already past their deadline. Repayment reopens and foreclosure is blocked for up to 7 days after default. This stays inside the documented cap of maturity plus eight days and is reachable only by the owner, so it is recorded as a trust assumption for lenders, not a defect.\n\n**Verified intended behavior.** Cumulative allocation sums exactly to the signed collateral and interest totals under random split sequences. Each fill receives at least one unit of collateral and interest, and positive borrower proceeds. Total fee across splits is bounded by the single-fill fee plus one unit per extra fill. The same token in both asset roles across loans keeps reserves exact through custody repayment, note sale to the borrower, surplus withdrawal, and foreclosure. Self-fill and treasury-as-lender net to exactly the fee. Repayment at the deadline and foreclosure after it are mutually exclusive. Loan IDs cannot collide or be forged, and signature validation covers every order field, the domain, and the counter.\n\n**Coverage gaps and limits.** No fork verification of live tokens or of transient storage on the target chain. ERC1271 behavior was tested against local wallets, not a deployed Safe. NoteArt was reviewed for injection and gas bounding by reading and by the existing tests, not by rendering output. This review does not guarantee the absence of defects.","treeHash":null,"usage":{"cachedInputTokens":2601732,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":58312,"runtime":"claude","turns":38,"wallClockMs":986795}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b5e3297a04468fd3","findings":[{"citation":"resolved","description":"Merged from three specialist reports (audit_flow, audit_math, audit_economics) with the same root cause. repay() runs the direct payment in a separate frame (this.transferRepayment) and, when it fails, only enters the custody fallback if gasleft() >= gasBefore/32 (src/ReturnERC20Loans.sol:440-450). The threshold assumes a genuine token failure returns most of the inner frame's gas. If the token's failure path executes INVALID (0xfe: Solidity <0.8 `assert`, 0.4 `throw`, old SafeMath `assert`, or any out-of-gas inside the token frame), the token frame burns everything it received. Under EIP-150 the outer frame keeps G/64, the inner frame keeps (63/64)*G/64 after the token burns its (63/64)^2 G, so repay() sees about 0.0310*G, which is below G/32 = 0.03125*G for every gas limit because the leftover scales with gasBefore. The catch branch therefore always reverts with InsufficientGas, the custody fallback (_pull of the repayment) is never reached, and neither the borrower nor any third party can repay while the holder is frozen. After maturity + 1 day anyone can foreclose and the frozen holder receives the collateral in a different, unfrozen token. This violates the documented guarantee in repay()'s natspec that 'a note holder can never block repayment' and the stated rule that anyone may repay in full. A holder cannot cause this alone: it needs an admitted token whose failure mode is INVALID plus an issuer freeze of the current holder. Modern tokens that fail with REVERT (require/revert, Solidity 0.8 panics) route into custody correctly (existing test testFrozenLenderCannotBlockRepayment), and a token behind a delegatecall proxy adds a frame and also reaches custody (verified by the audit_economics specialist), so the defect affects only non-proxied tokens whose failing transfer consumes the whole frame. Severity is low because the intended admitted assets are conforming modern tokens; it remains a reproducible blocked-valid-claim defect and an admission-policy risk. Remediation without changing intended behavior: forward an explicit, bounded gas amount to transferRepayment and compare gasleft() after the call against a fixed stipend needed by _pull (or measure the gas the inner call consumed relative to what it was given) instead of comparing against gasBefore/32, so a single-frame all-gas failure is distinguishable from a payer-chosen low gas limit; or document INVALID-on-failure tokens as excluded at admission review.","line":448,"path":"src/ReturnERC20Loans.sol","proof":"// SPDX-License-Identifier: BUSL-1.1\npragma solidity 0.8.37;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"solady/tokens/ERC20.sol\";\nimport {ReturnERC20Loans} from \"src/ReturnERC20Loans.sol\";\n\n/// @dev Loan token whose transfer to a frozen account fails by consuming all gas in the token's own frame\n/// (the behaviour of Solidity 0.4 `throw` / pre-0.8 `assert`, and of any INVALID-opcode failure).\ncontract GasBurningFreezeToken is ERC20 {\n    address public frozen;\n\n    function name() public pure override returns (string memory) {\n        return \"Gas burning freeze token\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"GBF\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n\n    function setFrozen(address account) external {\n        frozen = account;\n    }\n\n    function _beforeTokenTransfer(address, address to, uint256) internal view override {\n        if (to != address(0) && to == frozen) {\n            assembly {\n                invalid()\n            }\n        }\n    }\n}\n\ncontract PlainToken is ERC20 {\n    function name() public pure override returns (string memory) {\n        return \"Plain\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"PLN\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// Expected: a frozen note holder can never block repayment; the payment enters custody for\n/// {collectRepayment}. Actual: when the loan token fails by burning all gas in its own frame, the gas left\n/// after the failed inner call is G/64 + (63/64)G/64 ~ G/32.25, which is below the `gasBefore / 32`\n/// threshold at every gas limit, so {repay} always reverts with {InsufficientGas}. The borrower cannot\n/// repay, the deadline passes and the loan is foreclosed.\ncontract GasBurningTokenBlocksRepaymentTest is Test {\n    uint256 constant LENDER_KEY = 0xA11CE;\n    address lender;\n    address borrower = address(0xB0B);\n    address treasury = address(0xFEE);\n    GasBurningFreezeToken cash;\n    PlainToken stock;\n    ReturnERC20Loans loans;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        lender = vm.addr(LENDER_KEY);\n        cash = new GasBurningFreezeToken();\n        stock = new PlainToken();\n        loans = new ReturnERC20Loans(treasury, address(this));\n        loans.setAsset(address(cash), true);\n        loans.setAsset(address(stock), true);\n        cash.mint(lender, 1e24);\n        cash.mint(borrower, 1e24);\n        stock.mint(borrower, 1e24);\n        vm.prank(lender);\n        cash.approve(address(loans), type(uint256).max);\n        vm.startPrank(borrower);\n        cash.approve(address(loans), type(uint256).max);\n        stock.approve(address(loans), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function testFrozenHolderWithGasBurningTokenCannotBlockRepayment() public {\n        ReturnERC20Loans.Order memory o = ReturnERC20Loans.Order(\n            ReturnERC20Loans.Side.Lend,\n            lender,\n            borrower,\n            address(stock),\n            2_000e18,\n            address(cash),\n            1_000e6,\n            1_010e6,\n            7 days,\n            uint64(block.timestamp + 30 days),\n            0,\n            1,\n            1_000e6\n        );\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(LENDER_KEY, loans.orderDigest(o));\n        vm.prank(borrower);\n        bytes32 id = loans.fill(o, o.principal, abi.encodePacked(r, s, v));\n        ReturnERC20Loans.Loan memory loan = loans.loanTerms(o, o.principal, 0, borrower);\n        assertEq(loans.loanDigest(loan), id);\n\n        // The issuer freezes the note holder in the loan token.\n        cash.setFrozen(lender);\n\n        // Expected: the repayment enters custody and the collateral returns to the borrower.\n        vm.prank(borrower);\n        loans.repay{gas: 30_000_000}(loan);\n        assertEq(loans.heldRepayment(loan), loan.repayment);\n        assertEq(loans.reserved(address(cash)), loan.repayment);\n        assertEq(stock.balanceOf(borrower), 1e24);\n        (, bool repaid, bool released) = loans.loanState(id);\n        assertTrue(repaid && released);\n    }\n}","reproduction":"Setup: ReturnERC20Loans with an admitted Solady ERC20 whose _beforeTokenTransfer executes invalid() when to == frozen (loan token) and a plain ERC20 as collateral. Lender signs a Lend order (principal 1,000e6, repayment 1,010e6, collateral 2,000e18, 7 days); borrower fills in full. Issuer calls setFrozen(lender). Borrower calls repay(loan) with 30,000,000 gas (the specialists also tried 200k, 300k, 500k, 1M, 2M, 5M, 10M, 29M, 100M). Expected per repay()'s documentation: the direct payment fails, 1,010e6 is pulled into custody (heldRepayment == 1,010e6, reserved[cash] == 1,010e6), the collateral is returned to the borrower, and the note remains for collectRepayment. Actual: every call reverts with InsufficientGas() (selector 0x1c26714c); a third-party payer reverts identically; after the deadline anyone can foreclose and the frozen lender receives the collateral. Command: forge test --offline --match-path test/scratch/GasBurningTokenBlocksRepayment.t.sol. Observed: [FAIL: InsufficientGas()] testFrozenHolderWithGasBurningTokenCannotBlockRepayment() (gas: 29431014). Contrast: the same scenario with a require-based freeze passes through the custody path (existing test testFrozenLenderCannotBlockRepayment in test/ReturnERC20Loans.t.sol passes).","severity":"low","snippet":"            if (gasleft() < gasBefore / 32) revert InsufficientGas();","title":"repay() custody fallback is unreachable when the loan token's failed transfer consumes all gas (INVALID opcode): a frozen note holder blocks repayment and the borrower is foreclosed"},{"citation":"resolved","description":"Reported by the audit_permissions specialist; reproduced. extendRepaymentWindow (src/ReturnERC20Loans.sol:606-613) is documented as incident relief for existing loans, but _repaymentDeadline (lines 642-650) applies the token or global window to every loan whose tokens match, including loans funded after the window was set. The window is bounded only by block.timestamp + 7 days at the time it is set and by maturity + 8 days per loan; nothing ties it to a loan's funding time, and fill()/previewFill() neither reject nor surface the effective deadline while a window is open. A short-term loan funded during an open window therefore gets maturity + GRACE_PERIOD < window end, so its deadline becomes the window end. Consequence: a borrower who fills a short-term order while a window is open (owner has declared a window and has not paused new loans) receives up to about 7 days of extra time at the signed short-term interest, and the lender cannot foreclose at the signed deadline (foreclose reverts NotInDefault until the window end). The stated 8-day cap is respected, so this is not a cap violation, but it alters the effective funded term of a loan the window was never meant to cover, and the lender signed a fixed term with no way to see the extension in previewFill. Impact is bounded (at most 7 days, no principal loss) and requires an owner-declared window, so severity is low. Remediation options: reject fills (and previewFill) while a window covering either token is later than the would-be deadline; or record the funding time (for example in the note's packed extra data, which has 22 free bits, or by storing each window's set-time) and ignore windows set before funding; or at least surface the effective deadline in previewFill and document that new fills during a window inherit it.","line":647,"path":"src/ReturnERC20Loans.sol","proof":"// SPDX-License-Identifier: BUSL-1.1\npragma solidity 0.8.37;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"solady/tokens/ERC20.sol\";\nimport {ReturnERC20Loans} from \"src/ReturnERC20Loans.sol\";\n\ncontract WSToken is ERC20 {\n    function name() public pure override returns (string memory) {\n        return \"WS\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"WS\";\n    }\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// An emergency repayment window declared by the owner applies to loans funded AFTER the window was\n/// declared. A borrower who fills a short-term order during an open window gets the loan's deadline\n/// moved from maturity + 1 day to the window end (up to 7 days after the window was set), so the lender\n/// cannot foreclose at the signed term and the borrower holds the principal for the whole window while\n/// paying only the short term's interest.\ncontract WindowStretchTest is Test {\n    uint256 constant LK = 0xA11CE;\n    uint256 constant BK = 0xB0B;\n    address lender;\n    address borrower;\n    address treasury = address(0xFEE);\n    WSToken cash;\n    WSToken stock;\n    ReturnERC20Loans market;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        lender = vm.addr(LK);\n        borrower = vm.addr(BK);\n        cash = new WSToken();\n        stock = new WSToken();\n        market = new ReturnERC20Loans(treasury, address(this));\n        market.setAsset(address(cash), true);\n        market.setAsset(address(stock), true);\n        address[2] memory users = [lender, borrower];\n        for (uint256 i; i < 2; ++i) {\n            cash.mint(users[i], 1e30);\n            stock.mint(users[i], 1e30);\n            vm.startPrank(users[i]);\n            cash.approve(address(market), type(uint256).max);\n            stock.approve(address(market), type(uint256).max);\n            vm.stopPrank();\n        }\n    }\n\n    function _order(uint32 duration) internal view returns (ReturnERC20Loans.Order memory) {\n        return ReturnERC20Loans.Order(\n            ReturnERC20Loans.Side.Lend,\n            lender,\n            address(0),\n            address(stock),\n            200e18,\n            address(cash),\n            1000e6,\n            1010e6,\n            duration,\n            type(uint64).max,\n            0,\n            1,\n            1000e6\n        );\n    }\n\n    function testLoanFundedDuringWindowKeepsSignedDeadline() public {\n        // Owner opens a 7-day incident window for the cash token (an existing-loan relief measure).\n        market.extendRepaymentWindow(address(cash), uint40(vm.getBlockTimestamp() + 7 days));\n\n        // A lender posts a 1-hour loan; the borrower fills it one hour into the window.\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        ReturnERC20Loans.Order memory o = _order(1 hours);\n        (ReturnERC20Loans.Loan memory loan,) = market.previewFill(o, o.principal, borrower);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(LK, market.orderDigest(o));\n        bytes memory sig = abi.encodePacked(r, s, v);\n        vm.prank(borrower);\n        market.fill(o, o.principal, sig);\n\n        (uint40 maturity,,) = market.loanState(market.loanDigest(loan));\n        uint256 signedDeadline = uint256(maturity) + market.GRACE_PERIOD();\n\n        // Expected: a loan funded after the window was declared keeps its signed deadline.\n        // Actual: the deadline is the window end, almost six days later than maturity + 1 day.\n        assertEq(market.repaymentDeadline(loan), signedDeadline, \"deadline stretched by pre-existing window\");\n\n        // Consequence: the lender cannot foreclose at the signed deadline.\n        vm.warp(signedDeadline + 1);\n        vm.prank(lender);\n        market.foreclose(loan);\n    }\n}","reproduction":"Setup: owner calls extendRepaymentWindow(cash, now + 7 days) at t = 1,800,000,000. One hour later the borrower fills a lender's 1-hour Lend order (loanToken = cash, 1,000e6 / 1,010e6, 200e18 collateral). Expected: repaymentDeadline(loan) == maturity + 1 day == 1,800,093,600 and foreclose(loan) succeeds at that deadline + 1. Actual: repaymentDeadline(loan) == window end == 1,800,604,800 (about 5.9 days later), and foreclose(loan) at the signed deadline + 1 reverts with NotInDefault. Command: forge test --offline --match-path test/scratch/WindowStretch.t.sol -vv. Observed: [FAIL: deadline stretched by pre-existing window: 1800604800 != 1800093600] testLoanFundedDuringWindowKeepsSignedDeadline().","severity":"low","snippet":"        if (until <= deadline) return deadline;","title":"An open emergency repayment window also stretches loans funded after it was declared, overriding the signed term of new loans"},{"citation":"resolved","description":"Merged from three specialist reports (audit_flow, audit_permissions, audit_economics). extendRepaymentWindow only bounds `until` relative to block.timestamp and the previous window, never relative to a loan's status, and _repaymentDeadline (lines 642-650) recomputes each matching loan's deadline at call time as min(until, maturity + 8 days). A loan already past maturity + 1 day but not yet foreclosed becomes repayable again as soon as the owner sets a window covering its token or the global zero-address window: foreclose() reverts with NotInDefault and the borrower (an unprivileged actor) can extinguish the note holder's vested foreclosure right by repaying. The 8-day cap is respected and this is explicitly the intended outage relief (existing test testEmergencyWindowKeepsRepaymentOpenAfterOutage in test/Resilience.t.sol sets the window two days after the deadline), so it is recorded as a trust assumption for note holders rather than a defect: the owner can retroactively un-default every loan at once, and a lender who delays foreclosure by a block can lose the collateral claim to a window set in between. No permission bypass; requires the owner. If this is not desired, apply windows only to loans whose deadline had not passed when the window was set (store the window's set-time and require maturity + GRACE_PERIOD >= setTime), otherwise document that foreclosure should be executed promptly after the deadline.","line":609,"path":"src/ReturnERC20Loans.sol","reproduction":"Fill a 7-day Lend order (1,000e6 / 1,010e6) at t0. Warp to repaymentDeadline(loan) + 2 days: repay(loan) reverts GracePeriodExpired and foreclose would succeed. Owner calls extendRepaymentWindow(address(0), now + 7 days). Expected by a lender: the defaulted loan remains foreclosable. Actual: repaymentDeadline(loan) == original deadline + 7 days (= maturity + 8 days), foreclose(loan) reverts NotInDefault, and the borrower's repay(loan) succeeds and returns the 200e18 collateral. Command: forge test --offline --match-path test/scratch/JudgeInfo.t.sol --match-test testOwnerWindowRevivesDefaultedLoan -vv (passes, demonstrating the behaviour).","severity":"info","snippet":"                || until <= repaymentWindow[token] || until > block.timestamp + MAX_REPAYMENT_EXTENSION","title":"Owner window set after a loan's deadline revives the defaulted loan and suspends a pending foreclosure for up to 7 days (documented trust assumption)"},{"citation":"resolved","description":"Reported by the audit_flow specialist; reproduced. After a direct payment fails (for example a holder frozen by the token issuer) the repayment is escrowed, the _REPAID bit is set and the note stays outstanding so its holder, or a transferee, can call collectRepayment. _status() inspects only _REPAID, so tokenURI for such a live note reports Status 'Paid back' with the description 'its holder is paid', while the note is still the sole key to a claim worth loan.repayment. Metadata cannot change custody or settlement (confirmed: NoteArt is stateless and the market reads nothing from it), so this is a display defect only, but wallets and marketplaces will present a valuable transferable claim as settled, which can mislead a secondary buyer or cause a holder to discard the note. The market can tell the states apart (ownerOf non-zero and heldRepayment non-zero), but NoteArt only receives the packed state. Suggested remediation: pass an 'escrowed' flag (note exists && _REPAID) from tokenURI, or reserve one of the free bits in the packed extra data, and render a distinct 'Repayment held' status.","line":302,"path":"src/NoteArt.sol","reproduction":"Fill a loan; freeze the lender in a require-based loan token; borrower calls repay(loan): heldRepayment(loan) == 1,010e6 and ownerOf(id) == lender (note alive). Base64-decode market.tokenURI(id) and inspect the JSON. Expected: a status reflecting the outstanding claim. Actual: attributes contain {\"trait_type\":\"Status\",\"value\":\"Paid back\"}. Command: forge test --offline --match-path test/scratch/JudgeInfo.t.sol --match-test testEscrowedNoteRendersPaidBack -vv (passes, demonstrating the label).","severity":"info","snippet":"        if ((state & _REPAID) != 0) return Status(\"Paid back\", \"#e4dbd0\", \"#0c0c09\");","title":"NoteArt labels a live note whose repayment is held in custody as 'Paid back' although its holder has not been paid"},{"citation":"resolved","description":"Reported by the audit_math specialist; reproduced. The fee is ceil(30 bps) of each fill's principal and is deducted from the borrower's proceeds. On a Borrow order the lender is the taker and chooses fill sizes, so the borrower's total fee depends on the counterparty. Each fill adds at most one raw unit of rounding, so the aggregate overcharge is at most (fills - 1) raw units, i.e. at most principal/minFill - 1. The only guard on minFill is protocolFee(minFill) < minFill (line 363), which admits minFill = 2 where the rounded fee is 50% of the fill. This is the stated rounding rule ('rounded up to one raw token unit' per fill) and the exposure is fully controlled by the borrower's own minFill; with any realistic minimum (for a 6-decimal token minFill >= 1e6 gives an exact 0.3% per fill) the amplification is a few raw units, so it is recorded for the record and not as a defect. Optional hardening: require minFill large enough that the rounding is immaterial (for example minFill >= 10_000 / FEE_BPS * k) or document that makers must choose minFill accordingly.","line":733,"path":"src/ReturnERC20Loans.sol","reproduction":"Borrow order: principal 1,000, repayment 1,500, collateralAmount 1,000, minFill 2, taker zero. The lender fills 500 times with amount 2. Expected for a single full fill: fee ceil(1000*30/10000) = 3, borrower receives 997. Actual: each fill charges ceil(2*30/10000) = 1, the treasury receives 500 and the borrower receives 500 of the 1,000 principal. Command: forge test --offline --match-path test/scratch/JudgeInfo.t.sol --match-test testFeeAmplificationBySplitting -vv (passes with assertions treasury == 500, borrower proceeds == 500).","severity":"info","snippet":"        return (uint256(principal) * FEE_BPS + 9_999) / 10_000;","title":"Per-fill fee rounding lets the taker of a Borrow order with a tiny minFill multiply the borrower's fee (bounded by one raw unit per fill)"},{"citation":"resolved","description":"Judge's own probe of the brief's 'try to force escrow' item. repay() treats any failure of the inner this.transferRepayment call that leaves enough gas as a holder-side failure and pulls the repayment into custody. With a loan token that notifies the sender before a transfer (ERC777 tokensToSend or ERC1363-style hooks), a contract payer can revert selectively when `to` is the note holder and accept when `to` is the market, so the direct payment fails and the custody path is taken even though the holder is perfectly able to receive. The holder's claim is intact: the note stays outstanding with _REPAID set, heldRepayment equals loan.repayment, and anyone may call collectRepayment to deliver it, so there is no asset loss and no blocked claim, only one extra transaction and a note that renders as 'Paid back' in the meantime (see the NoteArt finding). It requires a sender-hook token, which is outside the stated supported-asset profile (exact-amount, non-rebasing ERC20 without callbacks). Recorded as an integration note; no change recommended beyond excluding hook tokens at admission or documenting that custody can be forced by a payer with such a token.","line":449,"path":"src/ReturnERC20Loans.sol","reproduction":"Setup: admit an ERC20 whose _beforeTokenTransfer calls tokensToSend(from, to, amount) on a registered sender contract. A Payer contract fills a 1,000e6 / 1,010e6 Lend order as borrower, then sets blocked = lender so its hook reverts only when to == lender. Payer calls market.repay(loan). Expected under the design's intent: the holder is paid directly and the note is burned. Actual: the direct payment fails, repay succeeds via custody with heldRepayment(loan) == 1,010e6 and ownerOf(id) == lender; a subsequent collectRepayment(loan) by anyone pays the lender 1,010e6. Command: forge test --offline --match-path test/scratch/JudgeHook.t.sol -vv (passes, demonstrating the forced custody and the intact claim).","severity":"info","snippet":"            _pull(loan.loanToken, msg.sender, loan.repayment);","title":"A payer whose sender-side transfer hook rejects the note holder can force the custody path on a healthy holder (holder must then collect in a second transaction)"}],"hash":"ace3149fb2a20bb0702ee5ea007a17a2f1e1ab3ea9825813c17c9bd410ebf3fa","nodeId":"ea369116-96f5-4799-b1c0-21b4413fcdff","outcome":"completed","summary":"Review complete. The findings file is written at `.imd-findings.json` with 6 entries, and production source is unchanged (`git diff` is empty).\n\n**Environment and baseline**\n\n| Item | Value |\n|---|---|\n| Commit | 2568b30f405f90acee213930af411e01914d68e6 |\n| Foundry | forge 1.8.3 (repo tested with 1.8.1) |\n| Solidity | 0.8.37, Cancun, optimizer 10000 runs, via_ir |\n| Dependencies | solady 2afba69, forge-std 0258fe8 (pinned by script/install.sh) |\n| Existing suite | 114 passed, 0 failed, 0 skipped; invariant 128 runs × 128 depth |\n| forge fmt --check | clean on the repository |\n| Fork tests | skipped: no FORK_RPC_URL available, not counted as passes |\n\n**Kept findings, after reproduction and merging**\n\n- **Low, with proof.** The repay custody fallback is unreachable when the loan token fails by consuming all gas (INVALID opcode). Three specialists reported this separately; merged into one finding at `src/ReturnERC20Loans.sol:448`. All three proofs fail with InsufficientGas at every gas limit, so a frozen holder forces foreclosure. Low because it needs a pre-0.8 style token outside the stated asset profile. A require-based freeze reaches custody correctly.\n- **Low, with proof.** An open emergency window also stretches loans funded after it was declared, at `src/ReturnERC20Loans.sol:647`. A 1-hour loan filled during a 7-day window gets the window end as its deadline and the lender cannot foreclose at the signed term. The proof fails as the specialist stated.\n- **Info.** Owner window revives already-defaulted loans. Three duplicates merged. The existing Resilience test shows this is intended, so it is recorded as a trust assumption for note holders.\n- **Info.** NoteArt renders a live note with an escrowed repayment as \"Paid back\" at `src/NoteArt.sol:302`. Reproduced by decoding the token URI.\n- **Info.** Fee amplification by splitting a Borrow order with minFill 2 into 500 fills. Reproduced; it is the stated per-fill rounding rule and controlled by the borrower's own minFill.\n- **Info, my own probe.** A payer whose sender-side token hook rejects the holder can force the custody path on a healthy holder. The claim stays collectable by anyone, so impact is one extra transaction. Requires a hook token outside the asset profile.\n\n**Dropped or not found**\n\nNothing was dropped: every specialist finding reproduced. My independent passes over signatures, replay, cancellation, allocation rounding, state-machine ordering, reentrancy guards, burn semantics, surplus withdrawal, and NoteArt escaping found no additional confirmed defect.\n\n**Coverage gaps**\n\nRobinhood Chain fork tests were not run. No Slither or long fuzz campaigns were run beyond the repository's own 256-run fuzz and 128×128 invariant configuration. Scratch tests live in `test/scratch/` and are not part of the deliverable.","treeHash":null,"usage":{"cachedInputTokens":1727922,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":32652,"runtime":"claude","turns":26,"wallClockMs":524155}}],"verification":[]}