# Audit report

> Audit Return v1 contracts at this exact revision.
>
> Repository: https://github.com/Return-Labs/v1-contracts.git
> Commit: 2568b30f405f90acee213930af411e01914d68e6
>
> Find 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.
>
> 1. Scope
>
> Primary targets:
> - src/ReturnERC20Loans.sol
> - src/NoteArt.sol
>
> Also 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.
>
> 2. Intended behavior to verify
>
> Return 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.
>
> The 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.
>
> The 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.
>
> Anyone may repay in full. Borrower repayment also returns collateral. Another payer gains no collateral rights. The borrower reclaims it separately.
>
> A 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.
>
> The 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.
>
> The 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.
>
> Verify these intended rules against the implementation. Report differences.
>
> 3. Math and accounting review
>
> Check partial-fill allocation, rounding, minimum fills, residual capacity, overfill prevention, and cancellation. Test extreme integer values, casts, packed storage, expiry boundaries, and maturity overflow.
>
> Check that complete fills allocate exactly the signed collateral and interest totals. Test fee changes from fill splitting. Distinguish expected rounding from an economic attack.
>
> For 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.
>
> Test 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.
>
> Use an independent accounting model for stateful tests.
>
> 4. Permissions and signatures review
>
> Check every signed Order field, EIP-712 domain name and version, chain ID, market address, maker counter, salt, and restricted taker.
>
> Test replay between orders, fills, markets, chains, and old domains. Check cancellation, counter changes, signature malleability, malformed signatures, zero addresses, and dirty calldata bits.
>
> Review EOA, ERC1271, Safe, and EIP7702 behavior. Test signature revocation, changed wallet owners or thresholds, missing handlers, and changes between partial fills.
>
> Check maker consent for public posting, the repayment helper's self-call restriction, owner operations, handover, payable calls, approvals, and note transfers.
>
> State required privileges. Separate intended powers from permission bypasses.
>
> 5. Control flow and settlement review
>
> Examine fill, repay, transferRepayment, reclaimCollateral, foreclose, collectRepayment, and withdrawSurplus as one state machine.
>
> Test 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.
>
> Test note transfers during settlement, contract holders, overlapping borrower/lender/treasury addresses, and self-transfers. Check the beneficiary.
>
> Test 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.
>
> Review 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.
>
> Test emergency extensions, token removal and re-admission, pause changes, exact deadline boundaries, and settlement after a long outage.
>
> 6. Token, economic, and metadata review
>
> The 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.
>
> Test 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.
>
> Assess front-running, transaction ordering, griefing, unbounded work, and denial of service within the stated design.
>
> Check 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.
>
> 7. Build and test
>
> Record the worker's Foundry version. The repository was tested with 1.8.1. Preserve Solidity 0.8.37, Cancun, optimizer settings, and via_ir.
>
> Run:
> sh script/install.sh
> forge fmt --check
> forge build --offline --sizes
> forge test --offline --no-match-contract 'Robinhood.*ForkTest'
>
> Add targeted proofs, fuzz tests, and stateful invariants. Record seeds, run counts, depth, commands, and results. Test recovery and final settlement.
>
> If a suitable Robinhood Chain RPC is available, set FORK_RPC_URL and run:
> forge test --match-contract 'Robinhood.*ForkTest'
>
> Use 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.
>
> List skipped checks and reasons. Never count skips as passes. Do not enable FFI, broaden file permissions, or weaken the worker sandbox.
>
> 8. Evidence and final report
>
> The judge must reproduce findings, merge duplicate root causes, and rank impact against realistic preconditions.
>
> For each finding, include:
> - Title, severity, and confidence.
> - Exact source file, function, and line references at the pinned commit.
> - Root cause and the violated rule.
> - Attacker capabilities and required state.
> - Transaction sequence and affected assets or claims.
> - Minimal Foundry proof, exact command, and observed result.
> - A focused remediation recommendation without changing the audited source.
>
> Use cheatcodes for stated setup, time control, or forks. Do not manufacture attacks with impossible state, hidden owner powers, or arbitrary storage writes.
>
> Separate confirmed defects, unconfirmed concerns, and trust or integration risks. Avoid generic checklists and unsupported severity claims.
>
> Include 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.

| | |
|---|---|
| Repository | https://github.com/Return-Labs/v1-contracts.git |
| Commit | `2568b30f405f90acee213930af411e01914d68e6` |
| Job | `3ec67246-5ea8-4475-99c4-a19504c31abc` |
| Judged | 2026-10-12 09:54 UTC |
| Findings | 2 low · 4 info |

Four agents audited the code as it is at `2568b30`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Low: 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

`src/ReturnERC20Loans.sol:448`

```
            if (gasleft() < gasBefore / 32) revert InsufficientGas();
```

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.

**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).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.37;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "solady/tokens/ERC20.sol";
import {ReturnERC20Loans} from "src/ReturnERC20Loans.sol";

/// @dev Loan token whose transfer to a frozen account fails by consuming all gas in the token's own frame
/// (the behaviour of Solidity 0.4 `throw` / pre-0.8 `assert`, and of any INVALID-opcode failure).
contract GasBurningFreezeToken is ERC20 {
    address public frozen;

    function name() public pure override returns (string memory) {
        return "Gas burning freeze token";
    }

    function symbol() public pure override returns (string memory) {
        return "GBF";
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }

    function setFrozen(address account) external {
        frozen = account;
    }

    function _beforeTokenTransfer(address, address to, uint256) internal view override {
        if (to != address(0) && to == frozen) {
            assembly {
                invalid()
            }
        }
    }
}

contract PlainToken is ERC20 {
    function name() public pure override returns (string memory) {
        return "Plain";
    }

    function symbol() public pure override returns (string memory) {
        return "PLN";
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

/// Expected: a frozen note holder can never block repayment; the payment enters custody for
/// {collectRepayment}. Actual: when the loan token fails by burning all gas in its own frame, the gas left
/// after the failed inner call is G/64 + (63/64)G/64 ~ G/32.25, which is below the `gasBefore / 32`
/// threshold at every gas limit, so {repay} always reverts with {InsufficientGas}. The borrower cannot
/// repay, the deadline passes and the loan is foreclosed.
contract GasBurningTokenBlocksRepaymentTest is Test {
    uint256 constant LENDER_KEY = 0xA11CE;
    address lender;
    address borrower = address(0xB0B);
    address treasury = address(0xFEE);
    GasBurningFreezeToken cash;
    PlainToken stock;
    ReturnERC20Loans loans;

    function setUp() public {
        vm.warp(1_800_000_000);
        lender = vm.addr(LENDER_KEY);
        cash = new GasBurningFreezeToken();
        stock = new PlainToken();
        loans = new ReturnERC20Loans(treasury, address(this));
        loans.setAsset(address(cash), true);
        loans.setAsset(address(stock), true);
        cash.mint(lender, 1e24);
        cash.mint(borrower, 1e24);
        stock.mint(borrower, 1e24);
        vm.prank(lender);
        cash.approve(address(loans), type(uint256).max);
        vm.startPrank(borrower);
        cash.approve(address(loans), type(uint256).max);
        stock.approve(address(loans), type(uint256).max);
        vm.stopPrank();
    }

    function testFrozenHolderWithGasBurningTokenCannotBlockRepayment() public {
        ReturnERC20Loans.Order memory o = ReturnERC20Loans.Order(
            ReturnERC20Loans.Side.Lend,
            lender,
            borrower,
            address(stock),
            2_000e18,
            address(cash),
            1_000e6,
            1_010e6,
            7 days,
            uint64(block.timestamp + 30 days),
            0,
            1,
            1_000e6
        );
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(LENDER_KEY, loans.orderDigest(o));
        vm.prank(borrower);
        bytes32 id = loans.fill(o, o.principal, abi.encodePacked(r, s, v));
        ReturnERC20Loans.Loan memory loan = loans.loanTerms(o, o.principal, 0, borrower);
        assertEq(loans.loanDigest(loan), id);

        // The issuer freezes the note holder in the loan token.
        cash.setFrozen(lender);

        // Expected: the repayment enters custody and the collateral returns to the borrower.
        vm.prank(borrower);
        loans.repay{gas: 30_000_000}(loan);
        assertEq(loans.heldRepayment(loan), loan.repayment);
        assertEq(loans.reserved(address(cash)), loan.repayment);
        assertEq(stock.balanceOf(borrower), 1e24);
        (, bool repaid, bool released) = loans.loanState(id);
        assertTrue(repaid && released);
    }
}
```

### 2. Low: An open emergency repayment window also stretches loans funded after it was declared, overriding the signed term of new loans

`src/ReturnERC20Loans.sol:647`

```
        if (until <= deadline) return deadline;
```

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.

**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().

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.37;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "solady/tokens/ERC20.sol";
import {ReturnERC20Loans} from "src/ReturnERC20Loans.sol";

contract WSToken is ERC20 {
    function name() public pure override returns (string memory) {
        return "WS";
    }

    function symbol() public pure override returns (string memory) {
        return "WS";
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

/// An emergency repayment window declared by the owner applies to loans funded AFTER the window was
/// declared. A borrower who fills a short-term order during an open window gets the loan's deadline
/// moved from maturity + 1 day to the window end (up to 7 days after the window was set), so the lender
/// cannot foreclose at the signed term and the borrower holds the principal for the whole window while
/// paying only the short term's interest.
contract WindowStretchTest is Test {
    uint256 constant LK = 0xA11CE;
    uint256 constant BK = 0xB0B;
    address lender;
    address borrower;
    address treasury = address(0xFEE);
    WSToken cash;
    WSToken stock;
    ReturnERC20Loans market;

    function setUp() public {
        vm.warp(1_800_000_000);
        lender = vm.addr(LK);
        borrower = vm.addr(BK);
        cash = new WSToken();
        stock = new WSToken();
        market = new ReturnERC20Loans(treasury, address(this));
        market.setAsset(address(cash), true);
        market.setAsset(address(stock), true);
        address[2] memory users = [lender, borrower];
        for (uint256 i; i < 2; ++i) {
            cash.mint(users[i], 1e30);
            stock.mint(users[i], 1e30);
            vm.startPrank(users[i]);
            cash.approve(address(market), type(uint256).max);
            stock.approve(address(market), type(uint256).max);
            vm.stopPrank();
        }
    }

    function _order(uint32 duration) internal view returns (ReturnERC20Loans.Order memory) {
        return ReturnERC20Loans.Order(
            ReturnERC20Loans.Side.Lend,
            lender,
            address(0),
            address(stock),
            200e18,
            address(cash),
            1000e6,
            1010e6,
            duration,
            type(uint64).max,
            0,
            1,
            1000e6
        );
    }

    function testLoanFundedDuringWindowKeepsSignedDeadline() public {
        // Owner opens a 7-day incident window for the cash token (an existing-loan relief measure).
        market.extendRepaymentWindow(address(cash), uint40(vm.getBlockTimestamp() + 7 days));

        // A lender posts a 1-hour loan; the borrower fills it one hour into the window.
        vm.warp(vm.getBlockTimestamp() + 1 hours);
        ReturnERC20Loans.Order memory o = _order(1 hours);
        (ReturnERC20Loans.Loan memory loan,) = market.previewFill(o, o.principal, borrower);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(LK, market.orderDigest(o));
        bytes memory sig = abi.encodePacked(r, s, v);
        vm.prank(borrower);
        market.fill(o, o.principal, sig);

        (uint40 maturity,,) = market.loanState(market.loanDigest(loan));
        uint256 signedDeadline = uint256(maturity) + market.GRACE_PERIOD();

        // Expected: a loan funded after the window was declared keeps its signed deadline.
        // Actual: the deadline is the window end, almost six days later than maturity + 1 day.
        assertEq(market.repaymentDeadline(loan), signedDeadline, "deadline stretched by pre-existing window");

        // Consequence: the lender cannot foreclose at the signed deadline.
        vm.warp(signedDeadline + 1);
        vm.prank(lender);
        market.foreclose(loan);
    }
}
```

### 3. Info: 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)

`src/ReturnERC20Loans.sol:609`

```
                || until <= repaymentWindow[token] || until > block.timestamp + MAX_REPAYMENT_EXTENSION
```

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.

**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).

### 4. Info: NoteArt labels a live note whose repayment is held in custody as 'Paid back' although its holder has not been paid

`src/NoteArt.sol:302`

```
        if ((state & _REPAID) != 0) return Status("Paid back", "#e4dbd0", "#0c0c09");
```

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.

**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).

### 5. Info: 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)

`src/ReturnERC20Loans.sol:733`

```
        return (uint256(principal) * FEE_BPS + 9_999) / 10_000;
```

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.

**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).

### 6. Info: 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)

`src/ReturnERC20Loans.sol:449`

```
            _pull(loan.loanToken, msg.sender, loan.repayment);
```

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.

**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).

---

Judge's submission `ace3149fb2a20bb0702ee5ea007a17a2f1e1ab3ea9825813c17c9bd410ebf3fa`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
