{"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":"d5b12e13-efed-4950-b440-b544f6476fa3","kind":"audit","nodes":[{"acceptedSubmissionHash":"47f18bdea72499ca894fedee3b409dce1a8e4ff5aa83ba0fb4451eb2dcce1679","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":"5498e38b5e0496ddb13a289dee96133ceaa51175dcad28e553a1abc812229bf1","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":"f867c28a67f364cd1e912017dd1126508e17b0c9ad87cb5f6cf086ed1841376c","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":"c181a1df0696ccd33d8310a6de40388b85f2c9e0d5ff02a60671148ccf636880","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":"a606cada82412d1802d9543560e72a48dae53b7d4a2e9acf75bdddcaacf0b70e","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 the contracts in sudeepb02/imdLend-contracts-public as they are.","parentJobId":null,"planHash":"5a9f085a4946e871a772bc1656dbda7a6ca2aec228b3d028acd1d66be3f09c4f","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"d5b12e13-efed-4950-b440-b544f6476fa3","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":"51368","feedbackHash":"c016ef3c57d2ba411533d43e687ade4d6ab68e7f67387fbc787604ddc89a7f71","nodeKey":"audit_economics","submissionHash":"47f18bdea72499ca894fedee3b409dce1a8e4ff5aa83ba0fb4451eb2dcce1679","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51566","feedbackHash":"829414ab9d2c63d9f562e7f63b169db1a51582e4f4efc28b02996f5fd1d3e2b0","nodeKey":"audit_flow","submissionHash":"5498e38b5e0496ddb13a289dee96133ceaa51175dcad28e553a1abc812229bf1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51214","feedbackHash":"6de6fb7e13e20a6c61df649fbf4537d6d09a1ca1ef2307db06623d221e84fa96","nodeKey":"audit_judge","submissionHash":"f867c28a67f364cd1e912017dd1126508e17b0c9ad87cb5f6cf086ed1841376c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52323","feedbackHash":"080ec09a2de57953a02a11f0f5ef19ab72946cc9f8907f735274422cf738ed60","nodeKey":"audit_math","submissionHash":"c181a1df0696ccd33d8310a6de40388b85f2c9e0d5ff02a60671148ccf636880","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51245","feedbackHash":"798eb6d61c0c841d4eeddc0e796a4e8757d908d6734db47536fa73b3b7a600f6","nodeKey":"audit_permissions","submissionHash":"a606cada82412d1802d9543560e72a48dae53b7d4a2e9acf75bdddcaacf0b70e","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"a5e768249d25cec77f5c2fb9b13133f4eb0a6ca271ae48ac0419e583be3b4072","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bf4c5b8707945096","findings":[{"citation":"resolved","description":"releaseRewards(token, amount) (src/IMDLend.sol:259) is permissionless and takes any partial amount. _calculateSplit rounds the fee and the owner part down on every call and gives the operator the remainder. Splitting a balance into small releases therefore moves the rounding loss to the fee recipient and the owner each time: any release under 50 units pays no protocol fee (49*200/10000 = 0), and any release where (amount - fee) * ownerBps < 10000 pays the owner nothing. Nothing tracks the dust that was owed, so it is lost for good. Economics: each call costs about 45k gas and shifts at most about 1 unit of the token from the fee recipient and about 1 unit from the owner to the operator. For IMD (18 decimals, a plain LayerZero OFT ERC-20 on chain 1) that is about 1e-18 IMD per call against roughly 45k gas, so it does not pay at realistic gas prices. It only matters for a reward token with very few decimals or a very high unit value. It breaks the documented split ('2% to the fee recipient, then the owner's share of the rest') rather than putting meaningful funds at risk. Possible fixes: require a minimum release amount (for example amount >= BPS_PRECISION, or amount * PROTOCOL_FEE >= BPS_PRECISION unless it is the whole balance), or round the fee and owner parts up and give the operator the floored remainder. Either keeps partial releases for capped or taxed tokens.","line":453,"path":"src/IMDLend.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ERC721} from \"@openzeppelin/contracts/token/ERC721/ERC721.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {IERC721} from \"@openzeppelin/contracts/token/ERC721/IERC721.sol\";\n\nimport {IMDLendMarket} from \"src/IMDLendMarket.sol\";\nimport {IIMDLend} from \"src/interfaces/IIMDLend.sol\";\n\ncontract MockToken is ERC20 {\n    constructor() ERC20(\"Reward\", \"RWD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract MockNft is ERC721 {\n    constructor() ERC721(\"Seat\", \"SEAT\") {}\n}\n\ncontract SplitRoundingTest is Test {\n    address feeRecipient = makeAddr(\"feeRecipient\");\n    address owner = makeAddr(\"owner\");\n    address operator = makeAddr(\"operator\");\n\n    MockToken imd;\n    MockToken reward;\n    IMDLendMarket market;\n    IIMDLend vault;\n\n    function setUp() public {\n        imd = new MockToken();\n        reward = new MockToken();\n        MockNft nft = new MockNft();\n        market = new IMDLendMarket(block.chainid, IERC721(address(nft)), IERC20(address(imd)), feeRecipient);\n        vault = market.deployVault(owner, operator, 5_000); // owner 50% of the post-fee rewards\n    }\n\n    /// One release of 1000 units pays 20 / 490 / 490. The same 1000 units released by anyone in\n    /// 1-unit calls pay 0 / 0 / 1000: the fee and the owner share round to zero on every call.\n    function test_chunkedReleaseSkipsFeeAndOwnerShare() public {\n        reward.mint(address(vault), 1_000);\n\n        (uint256 expFee, uint256 expOwner,) = vault.getAvailableRewards(IERC20(address(reward)));\n        assertEq(expFee, 20);\n        assertEq(expOwner, 490);\n\n        vm.startPrank(operator);\n        for (uint256 i; i < 1_000; ++i) {\n            vault.releaseRewards(IERC20(address(reward)), 1);\n        }\n        vm.stopPrank();\n\n        // Holds once dust is not handed to the operator on every partial release\n        assertGe(reward.balanceOf(feeRecipient), expFee - 1, \"fee recipient lost its fee\");\n        assertGe(reward.balanceOf(owner), expOwner - 1, \"owner lost its share\");\n    }\n\n    /// Releases of 49 units never pay the protocol fee: 49 * 200 / 10000 = 0.\n    function test_releaseBelow50UnitsPaysNoFee() public {\n        reward.mint(address(vault), 49 * 100);\n        for (uint256 i; i < 100; ++i) {\n            vault.releaseRewards(IERC20(address(reward)), 49);\n        }\n        // 2% of 4900 is 98\n        assertGe(reward.balanceOf(feeRecipient), 97, \"protocol fee skipped\");\n    }\n}","reproduction":"Market on the current chain; vault = market.deployVault(owner, operator, 5000); mint 1000 units of an ERC-20 to the vault. Expected (one release, which getAvailableRewards also reports): fee recipient 20, owner 490, operator 490. Actual after 1000 calls of vault.releaseRewards(token, 1) (from the operator or anyone): fee recipient 0, owner 0, operator 1000. Likewise, 100 calls of releaseRewards(token, 49) on 4900 units pay 0 protocol fee instead of 98. Verified with the attached test: both cases fail with 'fee recipient lost its fee: 0 < 19' and 'protocol fee skipped: 0 < 97'.","severity":"info","snippet":"        feeAmount = amount * PROTOCOL_FEE / BPS_PRECISION;\n        ownerAmount = (amount - feeAmount) * ownerBps / BPS_PRECISION;\n        operatorAmount = amount - feeAmount - ownerAmount;","title":"Per-call rounding in the reward split lets any caller zero the protocol fee and the owner share by releasing in small chunks"}],"hash":"47f18bdea72499ca894fedee3b409dce1a8e4ff5aa83ba0fb4451eb2dcce1679","nodeId":"6cdc496d-2436-40cc-b665-b99ea8f0674a","outcome":"completed","summary":"I found no critical, high or medium issues in the economics of `IMDLend` and `IMDLendMarket`. There is one finding, rated info, in `.imd-findings.json`.\n\n**The finding: chunked releases skip the fee and the owner's share** (`src/IMDLend.sol:453`)\n\nAnyone can call `releaseRewards` with any amount. Each call rounds the 2% fee and the owner's share down, and the operator gets whatever is left over. So releasing in small pieces moves that rounding to the operator every time:\n- **Releases under 50 units** pay no protocol fee.\n- **Splitting 1,000 units into 1-unit calls** (vault where the owner gets 50% after the fee) pays the fee recipient 0, the owner 0 and the operator 1,000. One single release pays 20 / 490 / 490.\n\nI confirmed both cases with a Foundry test; it fails on the current code as described, and its source is in the finding. The money involved is tiny: each call costs about 45k gas and moves at most about 1 unit each from the fee recipient and the owner. IMD has 18 decimals, so this doesn't pay at any realistic gas price. It would only matter for a reward token with very few decimals or a very high unit value. Two possible fixes, either of which still allows partial releases: require a minimum release amount, or round the fee and owner parts up instead of down.\n\n**What I checked and found sound**\n- **Value conservation:** the three parts of a release always add up to the amount. The market never holds tokens, and the upfront payment goes straight to the fee recipient and the owner.\n- **The lending lock:** a lent NFT can't leave its vault. The rescue functions and `releaseRewards` refuse the IdentityMD collection, and the market has no way to move NFTs out of vaults.\n- **Listings:** each one is taken at most once (it's deleted before any transfer, under a reentrancy guard). Its terms are part of its id, so they can't change between viewing and taking. A listing can't be taken from the wrong wallet or vault.\n- **Pairing approvals** stop working when a lending ends or a new one starts, and a vault only ever has one operator, so one operator's approvals can never work for another.\n- **Live contracts on chain 1:** IMD is a standard LayerZero OFT token (18 decimals, no transfer fee, hooks or permit). The IdentityMD NFT is a standard ERC-721 whose plain transfers succeed. Neither has the functions the rescue paths call, so a rescue can't be used to pull out reward tokens.\n\n**Seen but not reported, because the code comments or README already state them**\n- After a lending ends, rewards keep being split with the operator until the owner withdraws the NFT.\n- An owner's other listings for the same NFT stay open after one is taken.\n- A payee that a token refuses to pay blocks that token's releases.\n- A new fee recipient also collects fees on rewards vaults received earlier.\n- The NFT's ERC-8004 registration is not checked when a listing is taken.\n- If the fee recipient's address can't receive IMD, every take fails; I treated this as a trust assumption about that role.\n\n**Limits:** I covered the Economic Security, Invariant and Flow Gap passes in depth and did not run the other Pashov specialty passes as separate reviews. No fuzzing or static analysis was run. How the IMD network pays rewards happens off chain, so I couldn't verify it. To run the test I downloaded forge-std into `/tmp`, outside the repository. Afterwards I deleted the scratch test and the build output; the repository is unchanged apart from `.imd-findings.json`.","treeHash":null,"usage":{"cachedInputTokens":1091201,"inputTokens":30,"model":"claude-opus-5-5","outputTokens":32537,"runtime":"claude","turns":18,"wallClockMs":363434}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d0653dc91b6e2259","findings":[{"citation":"resolved","description":"The market's only floor on a lending is minDuration >= 1 second (src/IMDLendMarket.sol:139) and the upfront amount is pricePerDay * duration / 1 days (src/IMDLendMarket.sol:227), so a take at the shortest duration an owner leaves open is close to free. Once a take lands, the seat moves into the vault for (owner, taker, ownerBps) and stays there after the lending is over: the vault splits every token it receives at the fixed terms regardless of whether a lending is running (src/IMDLend.sol:259-273, documented at src/IMDLend.sol:25-27), and takeListing accepts the seat in place only for that vault's operator (src/IMDLendMarket.sol:217-223). Three consequences follow from a single cheap take until the owner notices and calls withdrawNft: (1) every reward the network pays to the seat's holder is split with the taker at (1 - ownerBps) after the 2% fee, even though the taker paid for one second; (2) every other open listing of the owner for that seat reverts with NftNotAvailable for anyone but the taker, and listings with a different ownerBps or a named other operator are dead; (3) the taker can re-take any still-open listing with the same ownerBps in place, with no further action by the owner, including winning the race against withdrawNft at endsAt. The owner's protections are choosing a large minDuration and withdrawing promptly, neither of which the contract enforces. This is a trust-assumption gap rather than a permission bypass: the owner did consent to lend at the listed price per day, but the price-per-time model has no floor while the reward split has no end. Minimal fix that keeps the design: enforce a protocol floor on minDuration (for example 1 day) or require upfrontAmount > 0 in takeListing, and, if the requester wants it, let the owner mark a listing as not takeable in place. Routing post-lending rewards to the owner would be a larger design change with its own tradeoff (rewards for work done during the lending can arrive after endsAt).","line":139,"path":"src/IMDLendMarket.sol","reproduction":"Scenario (test/scratch/Repro.t.sol, test_minDurationTakeParksSeatAndKeepsSplit): owner mints seat 5, approves the market, creates two open listings L1 and L2 with ownerBps=5000, pricePerDay=1e18, minDuration=1, maxDuration=30 days. Operator A calls takeListing(L1, 1). Expected by the owner: a one-second lending worth 1 IMD/day. Actual: A pays 11,574,074,074,074 wei (0.0000116 IMD), seat 5 is now owned by vault(owner, A, 5000). Two seconds later isNftLent(5) is false but the seat is still in A's vault. Operator B's takeListing(L2, 1 days) reverts with NftNotAvailable(5, vault). 100e18 of a reward token sent to the vault and released pays A 49e18, owner 49e18, fee 2e18. A then calls takeListing(L2, 1) in place and re-locks the seat without any owner action.","severity":"low","snippet":"        if (minDuration == 0 || minDuration > maxDuration || maxDuration > MAX_DURATION) {","title":"One-second take parks a seat in the taker's vault for ~0 IMD, locks the owner's other listings to that taker and keeps splitting rewards after the lending ends"},{"citation":"resolved","description":"releaseRewards lets any caller choose amount (src/IMDLend.sol:259-262), and _calculateSplit rounds the fee and the owner share down and gives the remainder to the operator. The caller can therefore pick amounts for which the fee and the owner share truncate to zero. Loss per call is bounded below one unit of the token for each of the fee recipient and the owner and the call costs gas, so for 18-decimal tokens such as IMD this is uneconomic to farm; it is reported for completeness because the direction of rounding is caller-selectable. Minimal fix: enforce a minimum amount per release, or compute each payee's share from cumulative released totals so repeated small releases cannot bias the rounding.","line":453,"path":"src/IMDLend.sol","reproduction":"test/scratch/Repro.t.sol, test_partialReleaseRoundsFeeAndOwnerToZero: vault with ownerBps=5000 holds 49 units of a reward token. releaseRewards(token, 49): expected fee 0.98, owner 24.01, operator 24.01; actual fee 0, owner 24, operator 25. releaseRewards(token, 1): actual fee 0, owner 0, operator 1.","severity":"info","snippet":"        feeAmount = amount * PROTOCOL_FEE / BPS_PRECISION;\n        ownerAmount = (amount - feeAmount) * ownerBps / BPS_PRECISION;\n        operatorAmount = amount - feeAmount - ownerAmount;","title":"Partial releaseRewards amounts below 50 units pay no protocol fee and below 10000/ownerBps units pay no owner share; dust rounds to the operator"},{"citation":"resolved","description":"The repository holds contract sources only and the build instructions stop at forge build and forge fmt. There is no test directory, so none of the behaviours the README's security model promises are checked by a test: a lent seat cannot be withdrawn, the operator cannot move seats, isValidSignature only validates an approved WorkerAuthorization naming the vault within its lending and from the block after it starts, the three reward parts always sum to the amount, the market's approval can only move an owner's seat into that owner's own vault, and the same-second end-and-restart edge of the pairing approval binding. The README states the contracts are not audited and hold custody of NFTs. Minimal fix: add a Foundry suite covering those invariants plus the boundary cases at endsAt, availableUntil and expiresAt, and a fuzz test over _calculateSplit asserting fee + owner + operator == amount.","line":104,"path":"README.md","reproduction":"State: `ls test` in the repository root returns no such directory and `forge test` reports no tests. Expected for a custody contract before mainnet: a suite exercising the edges above. Actual: none.","severity":"info","snippet":"forge build","title":"Repository ships no test suite; custody, lending and pairing edges are unexercised"}],"hash":"5498e38b5e0496ddb13a289dee96133ceaa51175dcad28e553a1abc812229bf1","nodeId":"9516d1ae-965e-48ed-b2c2-ecf5ce8d86d2","outcome":"completed","summary":"The review is complete. The findings file is written and validated, and nothing in the source tree was changed.\n\n**Outcome: no critical or high defects.** The findings file holds one low and two informational items, each with a reproduction confirmed by a scratch Foundry test.\n\n**What I reviewed.** Both contracts, both interfaces and both libraries in full, against the Execution Trace, Periphery and First Principles passes first, then the remaining Pashov specialties, the x-ray threat profiles and the Trail of Bits entry-point inventory. Every state-changing entry point and callback was classified and its guard traced. I also fetched the verified sources of the two real dependencies named in the README. The IdentityMD seat is a plain Solady ERC-721 whose `transferFrom` runs no receiver hook and has no transfer restriction, so the market's take path and the vault's withdraw path work against it. The IMD token is a standard OpenZeppelin ERC-20 behind a LayerZero OFT, with no fee or hook.\n\n**What holds up.** Custody is sound: the market's approval can only move a seat into a vault owned by the same owner, the vault never grants approvals, and withdrawal cannot be blocked once a lending ends. Listings are consumed before any external call under a reentrancy guard. The ERC-1271 path only validates a digest the operator approved, rebuilt as a WorkerAuthorization naming that vault, for the approved seat and expiry, inside the current lending. The reward split always sums to the amount. The clone's immutable terms decode correctly and the implementation itself rejects every vault action.\n\n**Findings recorded:**\n- **Low.** A take at the one-second floor costs about 1.16e13 wei at 1 IMD per day, parks the seat in the taker's vault, locks the owner's other listings to that taker, and keeps splitting the seat's rewards with them until the owner withdraws. The README documents the post-lending split and in-place takes, so this is a trust-assumption gap, and the suggested fix is a protocol floor on duration or a nonzero upfront requirement.\n- **Info.** Caller-chosen partial release amounts can round the fee and owner share to zero, with dust going to the operator. The loss is under one token unit per call and gas-dominated.\n- **Info.** The repository ships no test suite, so none of the security-model promises are exercised.\n\n**Limitations.** No Slither or fuzzing ran, only the three scratch tests under `test/scratch/`. The network's actual WorkerAuthorization domain and signature encoding could not be checked against the library's constants. The same-second end-and-restart edge of pairing approvals did not reproduce as harmful in a realistic sequence and was left out.","treeHash":null,"usage":{"cachedInputTokens":1481221,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":41517,"runtime":"claude","turns":23,"wallClockMs":593715}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"911c99a95f538613","findings":[{"citation":"resolved","description":"releaseRewards is permissionless and accepts any partial `amount`. _calculateSplit rounds the 2% fee down, then rounds the owner share down, and gives the operator whatever remains. For any `amount` < 50 the fee is 0, and for small amounts the owner share also loses up to one unit per call; both go to the operator. Because the operator (or anyone acting for them) chooses `amount`, the same balance released in 49-unit pieces pays the fee recipient nothing where a single release would pay 2%. The economic formula is correct in isolation and the permissionless access is intended, but together they let the party favoured by the rounding pick the release granularity. For 18-decimal tokens the gain is dust and gas makes it unprofitable; it matters for low-decimal, high-value reward tokens (e.g. an 8-decimal token) on cheap chains, where 2% of the balance can be captured for a few thousand cheap calls. Fix while preserving the design: round the fee up (feeAmount = ceil(amount * PROTOCOL_FEE / BPS_PRECISION)) and/or refuse releases whose fee rounds to zero when the balance is larger than `amount`; the three parts still sum to `amount`.","line":453,"path":"src/IMDLend.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC721} from \"@openzeppelin/contracts/token/ERC721/ERC721.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\n\nimport {IMDLendMarket} from \"src/IMDLendMarket.sol\";\nimport {IIMDLend} from \"src/interfaces/IIMDLend.sol\";\n\ncontract MockNft is ERC721 {\n    constructor() ERC721(\"IDMD\", \"IDMD\") {}\n    function mint(address to, uint256 id) external { _mint(to, id); }\n}\n\ncontract MockToken is ERC20 {\n    constructor() ERC20(\"IMD\", \"IMD\") {}\n    function mint(address to, uint256 amt) external { _mint(to, amt); }\n}\n\n/// Releasing rewards in amounts below 50 units pays no protocol fee at all: the fee rounds\n/// down to zero and the operator collects it. Fails on the current code, passes once the fee\n/// is rounded up (or a release below the fee granularity is refused).\ncontract FeeRoundingTest is Test {\n    MockNft nft;\n    MockToken imd;\n    IMDLendMarket market;\n    address owner = makeAddr(\"owner\");\n    address alice = makeAddr(\"alice\");\n    address fee = makeAddr(\"fee\");\n\n    function setUp() public {\n        vm.warp(1_000_000);\n        nft = new MockNft();\n        imd = new MockToken();\n        market = new IMDLendMarket(block.chainid, nft, imd, fee);\n        nft.mint(owner, 1);\n        vm.prank(owner);\n        nft.setApprovalForAll(address(market), true);\n    }\n\n    function test_smallReleasePaysNoFee() public {\n        vm.prank(owner);\n        bytes32 id = market.createListing(1, 5000, 0, 1, 365 days, uint64(block.timestamp + 30 days), address(0));\n        vm.prank(alice);\n        IIMDLend vault = market.takeListing(id, 1 days);\n\n        // 980 units of rewards reach the vault; released whole they would pay 19 units of fee\n        imd.mint(address(vault), 980);\n        for (uint256 i = 0; i < 20; i++) {\n            vault.releaseRewards(IERC20(address(imd)), 49);\n        }\n\n        assertEq(imd.balanceOf(address(vault)), 0, \"all released\");\n        assertGt(imd.balanceOf(fee), 0, \"protocol fee never paid: every 49-unit release rounds the fee to zero\");\n    }\n}","reproduction":"Vault for (owner, alice, ownerBps=5000), any lent seat. Mint 980 units of an ERC-20 to the vault. Expected (one release of 980): fee 19, owner 480, operator 481. Actual (20 calls of releaseRewards(token, 49) by anyone): fee 0, owner 20*24 = 480, operator 20*25 = 500. The fee recipient receives 0 instead of 19 and the operator gains the 19 units. A single call with amount 49 pays fee 0 / owner 24 / operator 25 where a proportional split would be 0.98 / 24.01 / 24.01. Running test/scratch/FeeRounding.t.sol on the current code fails with 'protocol fee never paid'.","severity":"low","snippet":"        feeAmount = amount * PROTOCOL_FEE / BPS_PRECISION;","title":"releaseRewards fee rounds to zero on small releases, letting any caller route the protocol fee (and owner dust) to the operator"},{"citation":"resolved","description":"getPairingExpiry ties an approval to its lending by comparing the recorded `lendingStartedAt` to the current lending's `startedAt`. The comment in authorizePairing says a lending that starts in a later second never revives the approval. A lending started at T, ended early by the operator at T (endLending sets endsAt = T, so isNftLent is false at T), and re-started at T through a second open listing in the same block, has startedAt == T again, so the earlier approval validates in the new lending. Both lendings are necessarily to the same operator (the vault is per owner/operator/split), so no privilege crosses parties and this is a documentation / invariant gap rather than an exploitable one. If the invariant 'an approval belongs to exactly one lending' is wanted, key approvals to a per-token lending counter incremented in startLending instead of to startedAt.","line":358,"path":"src/IMDLend.sol","reproduction":"Owner lists seat 1 twice for alice (L1, L2). Block at time T: alice takes L1 for 1 day (startedAt T); alice calls authorizePairing(D, 1, T+10min); alice calls endLending(1); alice takes L2 for 1 day (startedAt T again). Expected per the authorizePairing comment: getPairingExpiry(D) == 0 in the new lending. Actual: getPairingExpiry(D) == T+10min and at T+1 isValidSignature(D, abi.encode(deviceKey, nonce, relayOrigin)) returns 0x1626ba7e (valid). Reproduced with a scratch Foundry test using OpenZeppelin ERC721/ERC20 mocks.","severity":"info","snippet":"        if (approval.lendingStartedAt != _lendings[approval.tokenId].startedAt) return 0;","title":"Pairing approval survives into a second lending that starts in the same second the previous one was ended early"},{"citation":"resolved","description":"onERC721Received refuses IdentityMD seats so they arrive only through the market, but the market itself moves seats with plain transferFrom, and so can anyone else: ERC-721 transferFrom never calls the receiver hook. A seat pushed into a vault by a third party (mis-paste of a vault address, or a seat sent to the vault for the wrong owner/operator/split) is not lent, so nothing locks it, but the only exit is withdrawNft, which is owner-only and always sends to OWNER(). The sender has no path to recover it; the vault owner receives it. This is a user-error surface rather than a permission bypass, and the market's own rescueNft has the same shape (fee recipient receives anything sent to the market). It is reported because the receiver guard suggests a protection that the plain-transfer path does not have. A minimal mitigation is documentation plus a front-end warning; an on-chain alternative would be an owner-only `returnNft(tokenId, to)` for seats never lent in this vault, which changes the security-model table and so needs a scope decision.","line":286,"path":"src/IMDLend.sol","reproduction":"Vault V = (owner, alice, 5000) exists. Bob holds seat 2 and calls NFT.transferFrom(bob, V, 2). Expected (from the receiver guard's intent): the transfer is refused or Bob can recover seat 2. Actual: the transfer succeeds, bob's withdrawNft(2) reverts NotOwner, and owner's withdrawNft(2) sends seat 2 to owner. Reproduced in a scratch Foundry test with an OpenZeppelin ERC721 mock.","severity":"info","snippet":"        if (msg.sender == address(NFT_CONTRACT)) revert LibError.UnexpectedNft(msg.sender, from, tokenId);","title":"An IdentityMD seat sent to a vault with plain transferFrom by a non-owner can only be recovered by the vault owner"},{"citation":"resolved","description":"createListing checks custody at creation (wallet, or the vault for this listing's operator and split) and takeListing re-checks it, accepting a seat already sitting in the taker's vault. An open listing (operator = 0) created while the seat was in the owner's wallet stays open for up to 90 days. After a named lending to alice ends and the seat remains in V(owner, alice, bps), only alice satisfies holder == vault for that open listing: she can re-take it in place, for up to its maxDuration, without any owner action, while any other operator's take reverts NftNotAvailable. The behaviour follows from documented rules ('the owner's other listings for the NFT stay open'; 'a take in place needs no approval') and the owner consented to the open terms, so it is a trust assumption on the owner keeping their listings tidy, not a bypass. It is reported because the open listing's effective taker set silently shrinks to the previous operator, which the owner may not expect. If undesired, takeListing could require holder == listing.owner when listing.operator == address(0), or createListing could cancel the owner's other open listings for the same tokenId.","line":221,"path":"src/IMDLendMarket.sol","reproduction":"Owner lists seat 1: L_open (operator 0, bps 5000, maxDuration 365d, availableUntil +30d) then L_named (operator alice, bps 5000). Alice takes L_named for 1 day. Bob takes L_open: reverts NftNotAvailable. One day later, alice takes L_open for 365 days: succeeds in place, vault.isNftLent(1) is true for a year, no new owner approval or signature involved. Expected by a reader of L_open: any operator may take it or none may once the seat left the wallet. Reproduced in a scratch Foundry test.","severity":"info","snippet":"        } else if (holder != address(vault)) {","title":"An earlier open listing can be re-taken in place by the same operator after a named lending ends, while every other operator is refused"},{"citation":"resolved","description":"The repository ships sources only (no test/ directory, no fuzz or invariant tests). The following edges in the permission model have no executable check in the tree and were verified only by this review's scratch tests: implementation-address calls revert NotAVault on every terms-reading path; startLending/endLending/authorizePairing/withdrawNft/rescue* reject every non-role caller including the fee recipient and the market's owner; a take of a listing whose seat sits in another operator's vault reverts; same-block end-and-restart of a lending; the ERC-6492 same-block pairing rule; release of partial amounts; the two-step fee recipient handover (propose zero cancels, accept by a non-pending address reverts); cross-chain behaviour off the identity chain (createListing/takeListing revert NotIdentityChain, deployVault still works). Adding a Foundry suite covering these keeps the documented security-model table enforced.","line":108,"path":"README.md","reproduction":"State: `ls test/` in the repository at the pinned commit. Expected: a Foundry test directory exercising the security-model table in README.md. Actual: no test directory exists; `forge test` runs zero project tests.","severity":"info","snippet":"Not audited. The vaults take custody of NFTs, so they need an independent audit before mainnet use.","title":"No test suite in the repository: the access-control and timing edges below are unexercised"}],"hash":"a606cada82412d1802d9543560e72a48dae53b7d4a2e9acf75bdddcaacf0b70e","nodeId":"0ea97228-3f5b-4a67-b58d-ace719f97b9a","outcome":"completed","summary":"The review is complete and the findings are in `.imd-findings.json`. The source tree is unchanged (verified with `git status`); only `test/scratch/FeeRounding.t.sol` remains as the proof file.\n\n**Outcome: no permission bypass found.** Every state-changing entry point in both contracts carries the guard its effect needs, and I confirmed each with scratch Foundry tests: implementation-address calls revert, non-role callers are rejected on every vault action, the market can only start lendings inside listings the owner created, the fee recipient's rescue paths use the market as `from` and so cannot spend lister or operator approvals, and a seat never leaves a vault while lent. The owner, operator and fee-recipient powers match the README's security-model table.\n\n**Five findings written, one low and four informational:**\n\n- **Low, `releaseRewards` fee rounding.** The fee rounds down, so any caller can split a balance into releases under 50 units and the operator captures the 2% fee and owner dust. Concrete: 980 units released as twenty 49-unit calls pays the fee recipient 0 instead of 19. Dust for 18-decimal tokens, material only for low-decimal, high-value reward tokens on cheap chains. The proof test fails on the current code and passes with ceil rounding, which I verified by patching and restoring the file.\n- **Info, pairing approval revival.** Ending a lending and re-taking a second listing in the same second gives the new lending the same `startedAt`, so an earlier approval validates again. Same operator both times, so no cross-party effect, but it contradicts the code comment.\n- **Info, plain-transfer seats.** A seat pushed into a vault by a third party with `transferFrom` bypasses the receiver guard and can only be withdrawn by the vault owner.\n- **Info, open-listing asymmetry.** After a named lending ends with the seat still in the vault, only that operator can re-take an earlier open listing in place, for up to its maximum duration, without owner action.\n- **Info, no test suite.** The repository ships sources only, so the access and timing edges above are unexercised in the tree.\n\nTrust assumptions documented and not reported as defects: the fee recipient can hand over its role and receives fees held in vaults from before a handover, and rewards arriving at a vault after a lending ends still split with the operator until the owner withdraws. Passes outside the assigned guides (deep token-behaviour economics, cross-chain deployment ordering against live chains) were covered only as far as the access model required.","treeHash":null,"usage":{"cachedInputTokens":900261,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":43793,"runtime":"claude","turns":23,"wallClockMs":652249}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3a720671678111bc","findings":[{"citation":"resolved","description":"releaseRewards(token, amount) is callable by anyone with any amount from 1 up to the balance, and _calculateSplit (src/IMDLend.sol:453-455) rounds both the protocol fee and the owner share down, giving the dust to the operator. Rounding per call is bounded by one unit each for the fee and the owner, but the number of calls is not bounded, so the operator (or anyone acting for it) chooses how much dust exists. With amount = 1: fee = 1*200/10000 = 0, owner = (1-0)*ownerBps/10000 = 0 for every ownerBps < 10000, operator = 1. Any amount below 50 pays no fee at all; with ownerBps = 1 any amount up to 10_203 pays the owner nothing (fee 204, (10_203-204)*1/10000 = 0). For an 18-decimal reward token such as IMD the extractable value per call is 1e-18 of a token, far below gas, so the economic impact is negligible there. The seam is boundary x invariant: getAvailableRewards(token) reports the split of the whole balance, while the sum of partial releases of that same balance pays a different split, and for a reward token whose smallest unit is worth more than a transaction (a low-decimal or high-value-per-unit token airdropped to the vault, which the contract explicitly supports splitting) the operator takes 100% of it. Fix that preserves the design (partial releases for capped or taxed tokens): require amount to be either the whole balance or at least BPS_PRECISION units, so that each share's rounding error is at most 1/10000 of the release; or, alternatively, bound the error by rounding fee and owner share up when amount is below BPS_PRECISION.","line":262,"path":"src/IMDLend.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {IERC721} from \"@openzeppelin/contracts/token/ERC721/IERC721.sol\";\n\nimport {IMDLendMarket} from \"src/IMDLendMarket.sol\";\nimport {IIMDLend} from \"src/interfaces/IIMDLend.sol\";\n\n/// @dev Minimal ERC-20 with 0 decimals: one unit is the smallest transferable amount.\ncontract UnitToken {\n    string public constant name = \"Unit\";\n    string public constant symbol = \"UNIT\";\n    uint8 public constant decimals = 0;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// @dev Placeholder NFT contract: only its address is needed for the market constructor.\ncontract DummyNft {\n    function ownerOf(uint256) external pure returns (address) {\n        return address(0);\n    }\n}\n\ncontract ReleaseRewardsDustTest is Test {\n    IMDLendMarket market;\n    IIMDLend vault;\n    UnitToken token;\n\n    address owner = makeAddr(\"owner\");\n    address operator = makeAddr(\"operator\");\n    address feeRecipient = makeAddr(\"feeRecipient\");\n    uint16 constant OWNER_BPS = 5000; // owner and operator split the post-fee rewards 50/50\n\n    function setUp() public {\n        DummyNft nft = new DummyNft();\n        token = new UnitToken();\n        market = new IMDLendMarket(block.chainid, IERC721(address(nft)), IERC20(address(token)), feeRecipient);\n        vault = market.deployVault(owner, operator, OWNER_BPS);\n    }\n\n    /// @dev 100 units of rewards split at once: fee 2, owner 49, operator 49.\n    /// Released one unit at a time by the operator on the current code: fee 0, owner 0, operator 100.\n    function test_oneUnitReleasesRouteEverythingToOperator() public {\n        token.mint(address(vault), 100);\n\n        (uint256 feeExpected, uint256 ownerExpected, uint256 operatorExpected) =\n            vault.getAvailableRewards(IERC20(address(token)));\n        assertEq(feeExpected, 2);\n        assertEq(ownerExpected, 49);\n        assertEq(operatorExpected, 49);\n\n        // Release one unit at a time. If the fix makes a 1-unit release revert (for example a\n        // minimum release amount), fall back to releasing the whole balance, which must split correctly.\n        vm.startPrank(operator);\n        for (uint256 i = 0; i < 100 && token.balanceOf(address(vault)) > 0; i++) {\n            try vault.releaseRewards(IERC20(address(token)), 1) {}\n            catch {\n                vault.releaseRewards(IERC20(address(token)), type(uint256).max);\n            }\n        }\n        vm.stopPrank();\n\n        assertEq(token.balanceOf(address(vault)), 0, \"vault drained\");\n        // These assertions fail on the current code: the operator ends with all 100 units.\n        assertEq(token.balanceOf(feeRecipient), feeExpected, \"fee recipient got its 2%\");\n        assertEq(token.balanceOf(owner), ownerExpected, \"owner got its share\");\n        assertEq(token.balanceOf(operator), operatorExpected, \"operator got only its share\");\n    }\n}","reproduction":"Deploy the market with any NFT address and a 0-decimal ERC-20 as the token; deployVault(owner, operator, 5000); mint 100 units of the token to the vault. getAvailableRewards(token) returns (2, 49, 49). As the operator call releaseRewards(token, 1) one hundred times. Expected: fee recipient 2, owner 49, operator 49 (what one releaseRewards(token, type(uint256).max) pays). Actual: fee recipient 0, owner 0, operator 100. Verified with test/scratch/ReleaseRewardsDust.t.sol: it fails on the current code with 'fee recipient got its 2%: 0 != 2'.","severity":"low","snippet":"        if (amount == 0) revert LibError.NoRewardsAvailable(address(token));","title":"releaseRewards lets the caller pick the split granularity, so 1-unit releases route every fee and owner share to the operator"},{"citation":"resolved","description":"createListing validates the expiry, the durations and ownerBps but accepts any uint256 pricePerDay. takeListing multiplies pricePerDay by duration in checked arithmetic before dividing by 1 days, so for pricePerDay > type(uint256).max / duration the take reverts with Panic(0x11) after the listing has been validated and deleted (the revert undoes the delete). The listing is created, emits ListingCreated and looks open to front ends, but no duration in [minDuration, maxDuration] above the overflow bound is takeable, and when pricePerDay * minDuration fits but pricePerDay * maxDuration does not, only the short end of the advertised range works. Only the listing's own owner is affected, and the product fits for every realistic price (the bound is about 3.7e69 wei per day for a 365-day take), so this is a boundary note rather than a loss. Fix: in createListing require pricePerDay <= type(uint256).max / maxDuration (or compute the per-day product once there), so an untakeable listing is rejected at creation rather than at take.","line":227,"path":"src/IMDLendMarket.sol","reproduction":"createListing(tokenId 1, ownerBps 5000, pricePerDay = type(uint256).max / 1 days + 1, minDuration 1 days, maxDuration 1 days, availableUntil now + 1 day, operator X) succeeds. X, holding and approving enough IMD, calls takeListing(id, 1 days). Expected: the listing is rejected at creation or the take succeeds. Actual: takeListing reverts with Panic(0x11) at the multiplication on line 227. Verified with a scratch test (test/scratch/PricePerDayOverflow.t.sol, expectRevert Panic 0x11 passes).","severity":"info","snippet":"        uint256 upfrontAmount = listing.pricePerDay * duration / 1 days;","title":"createListing does not bound pricePerDay, so a listing whose pricePerDay * duration overflows can never be taken"}],"hash":"c181a1df0696ccd33d8310a6de40388b85f2c9e0d5ff02a60671148ccf636880","nodeId":"40342bb9-6553-498a-b6ed-45b589b9fb32","outcome":"completed","summary":"The review is complete. The findings file holds two substantiated findings, and no source or configuration file was changed.\n\n**Outcome.** The math in these contracts is largely sound. All basis-point arithmetic multiplies before dividing, the three reward shares always sum exactly to the released amount, the upfront price is exact to the wei, every timestamp comparison is consistent at its boundary, and the only downcast is of the block timestamp. The one real precision defect is in how `releaseRewards` lets its caller choose the rounding.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Low, `src/IMDLend.sol:262`.** `releaseRewards` accepts any amount from 1 unit up, and the split rounds the fee and the owner share down with the dust going to the operator. Per call the loss is at most one unit each, but calls are unbounded, so the operator picks the granularity. Releasing 100 units of a 0-decimal token one unit at a time pays fee 0, owner 0, operator 100 where a single full release pays 2, 49, 49. For 18-decimal IMD the value is below gas, so the impact is confined to low-decimal or high-value-per-unit tokens that reach the vault. The attached proof test fails on the current code and tolerates a minimum-release-amount fix.\n- **Info, `src/IMDLendMarket.sol:227`.** `createListing` does not bound `pricePerDay`, so a price above the overflow bound creates a listing every take of which reverts with a checked-arithmetic panic. Only that owner's own listing is affected. Confirmed with a scratch test.\n\n**Checked and found consistent.** Fee and owner-share overflow thresholds match the in-code comment. Lending windows, listing expiry, pairing expiry and the same-block pairing guard are all consistent at their edges. A lending restarted in the same second as its predecessor can revive that lending's pairing approvals, but only inside the same vault for the same operator, so it has no victim. Upfront fees below 50 wei truncate to zero, but fee avoidance is already available by listing at price zero, so that is not a defect.\n\n**Not covered.** Nothing in the three assigned guides was skipped. Static analysis was not run, as the task provides none. The repository ships no tests, so test-coverage gaps could not be assessed beyond noting that none exist.","treeHash":null,"usage":{"cachedInputTokens":1054780,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":27613,"runtime":"claude","turns":25,"wallClockMs":388599}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dd2ee4882a1be950","findings":[{"citation":"resolved","description":"releaseRewards (src/IMDLend.sol:259-262) is permissionless and accepts any amount from 1 up to the balance. _calculateSplit rounds the protocol fee down, then rounds the owner share down, and gives the operator whatever remains. Each call loses at most one unit for the fee recipient and one for the owner, but the number of calls is unbounded and the caller chooses it, so the operator (or anyone acting for it) can release a balance in pieces for which the fee and the owner share truncate to zero: any release below 50 units pays no fee at all, and any release where (amount - fee) * ownerBps < 10000 pays the owner nothing. Nothing tracks the dust that was owed, so it is lost for good, and getAvailableRewards reports a split that a sequence of partial releases of the same balance does not pay. For 18-decimal tokens such as IMD the gain per call is 1e-18 of a token against roughly 45k gas, so it is uneconomic there; it matters for a low-decimal or high-unit-value reward token airdropped to the vault, which the contract explicitly supports splitting, and it breaks the documented rule that 2% of every reward goes to the fee recipient. Merged from four specialist reports (audit_flow, audit_economics, audit_permissions, audit_math) of the same mechanism. Minimal fix that keeps partial releases for capped or taxed tokens: in releaseRewards require amount to be either the whole balance or at least BPS_PRECISION units (so each share's rounding error is at most 1/10000 of the release), or compute each payee's share from cumulative released totals so repeated small releases cannot bias the rounding. Rounding the fee up alone is not a fix: with 1-unit releases it moves everything to the fee recipient instead.","line":453,"path":"src/IMDLend.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {IERC721} from \"@openzeppelin/contracts/token/ERC721/IERC721.sol\";\n\nimport {IMDLendMarket} from \"src/IMDLendMarket.sol\";\nimport {IIMDLend} from \"src/interfaces/IIMDLend.sol\";\n\n/// @dev Minimal ERC-20 with 0 decimals: one unit is the smallest transferable amount.\ncontract UnitToken {\n    string public constant name = \"Unit\";\n    string public constant symbol = \"UNIT\";\n    uint8 public constant decimals = 0;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// @dev Placeholder NFT contract: only its address is needed for the market constructor.\ncontract DummyNft {\n    function ownerOf(uint256) external pure returns (address) {\n        return address(0);\n    }\n}\n\ncontract ReleaseRewardsDustTest is Test {\n    IMDLendMarket market;\n    IIMDLend vault;\n    UnitToken token;\n\n    address owner = makeAddr(\"owner\");\n    address operator = makeAddr(\"operator\");\n    address feeRecipient = makeAddr(\"feeRecipient\");\n    uint16 constant OWNER_BPS = 5000; // owner and operator split the post-fee rewards 50/50\n\n    function setUp() public {\n        DummyNft nft = new DummyNft();\n        token = new UnitToken();\n        market = new IMDLendMarket(block.chainid, IERC721(address(nft)), IERC20(address(token)), feeRecipient);\n        vault = market.deployVault(owner, operator, OWNER_BPS);\n    }\n\n    /// @dev 100 units of rewards split at once: fee 2, owner 49, operator 49.\n    /// Released one unit at a time by the operator on the current code: fee 0, owner 0, operator 100.\n    function test_oneUnitReleasesRouteEverythingToOperator() public {\n        token.mint(address(vault), 100);\n\n        (uint256 feeExpected, uint256 ownerExpected, uint256 operatorExpected) =\n            vault.getAvailableRewards(IERC20(address(token)));\n        assertEq(feeExpected, 2);\n        assertEq(ownerExpected, 49);\n        assertEq(operatorExpected, 49);\n\n        // Release one unit at a time. If the fix makes a 1-unit release revert (for example a\n        // minimum release amount), fall back to releasing the whole balance, which must split correctly.\n        vm.startPrank(operator);\n        for (uint256 i = 0; i < 100 && token.balanceOf(address(vault)) > 0; i++) {\n            try vault.releaseRewards(IERC20(address(token)), 1) {}\n            catch {\n                vault.releaseRewards(IERC20(address(token)), type(uint256).max);\n            }\n        }\n        vm.stopPrank();\n\n        assertEq(token.balanceOf(address(vault)), 0, \"vault drained\");\n        // These assertions fail on the current code: the operator ends with all 100 units.\n        assertEq(token.balanceOf(feeRecipient), feeExpected, \"fee recipient got its 2%\");\n        assertEq(token.balanceOf(owner), ownerExpected, \"owner got its share\");\n        assertEq(token.balanceOf(operator), operatorExpected, \"operator got only its share\");\n    }\n}","reproduction":"Deploy the market with any NFT address and a 0-decimal ERC-20; vault = market.deployVault(owner, operator, 5000); mint 100 units of the token to the vault. getAvailableRewards(token) returns (2, 49, 49). As the operator call releaseRewards(token, 1) one hundred times. Expected: fee recipient 2, owner 49, operator 49, the split one releaseRewards(token, type(uint256).max) pays. Actual: fee recipient 0, owner 0, operator 100. Same with 18-decimal units: 1000 units released 1 at a time pay 0 / 0 / 1000 instead of 20 / 490 / 490, and twenty releases of 49 units out of 980 pay the fee recipient 0 instead of 19. Reproduced by running the attached test with forge test --match-path: it fails on this code with \"fee recipient got its 2%: 0 != 2\"; the two other specialist proofs fail on this code for the same reason (\"fee recipient lost its fee: 0 < 19\", \"protocol fee never paid\").","severity":"low","snippet":"        feeAmount = amount * PROTOCOL_FEE / BPS_PRECISION;\n        ownerAmount = (amount - feeAmount) * ownerBps / BPS_PRECISION;\n        operatorAmount = amount - feeAmount - ownerAmount;","title":"releaseRewards lets the caller pick the split granularity, so small partial releases route the protocol fee and the owner share to the operator"},{"citation":"resolved","description":"The market's only floor on a lending is minDuration >= 1 second (src/IMDLendMarket.sol:139) and the upfront amount is pricePerDay * duration / 1 days (src/IMDLendMarket.sol:227), so a take at the shortest duration the owner leaves open is close to free. Once the take lands the seat moves into the vault for (owner, taker, ownerBps) and stays there after the lending is over: the vault splits every token it receives at the fixed terms whether or not a lending is running (src/IMDLend.sol:259-273, documented at src/IMDLend.sol:25-27), and takeListing accepts a seat already in place only for that vault's operator (src/IMDLendMarket.sol:217-223). Until the owner withdraws, three things follow from one cheap take: (1) every reward the network pays the seat's holder is split with the taker at (1 - ownerBps) after the 2% fee, though the taker paid for one second; (2) every other open listing of the owner for that seat reverts with NftNotAvailable for anyone but the taker, and listings with a different ownerBps or naming another operator are dead; (3) the taker can re-take any still-open listing with the same ownerBps in place, with no owner approval or action, for up to that listing's maxDuration, including racing the owner's withdrawNft at endsAt. The owner has an exit, cancelling the remaining listings and then calling withdrawNft (reproduced), and consented to the listed terms, so this is a trust-assumption gap on the owner keeping listings tidy rather than a permission bypass. Merged from audit_flow (1-second take) and audit_permissions (open listing re-taken in place), which describe the same mechanism. Minimal fix that preserves the documented in-place re-take for a named operator: in takeListing accept the in-place path (holder == vault) only when listing.operator == msg.sender, so an open listing created while the seat was in the owner's wallet can be taken only from the wallet; optionally also enforce a protocol floor on minDuration (for example 1 day) or require upfrontAmount > 0. Routing post-lending rewards to the owner would change the documented design and needs a scope decision.","line":218,"path":"src/IMDLendMarket.sol","reproduction":"test/scratch/Repro.t.sol, test_minDurationTakeParksSeatAndKeepsSplit (passes on this code, showing the behaviour): owner mints seat 5, approves the market, creates two open listings L1 and L2 with ownerBps 5000, pricePerDay 1e18, minDuration 1, maxDuration 30 days. Alice calls takeListing(L1, 1). Expected by the owner: a one-second lending worth 1 IMD per day. Actual: Alice pays 11574074074074 wei, seat 5 is owned by vault(owner, alice, 5000). Two seconds later isNftLent(5) is false but the seat is still in the vault; bob's takeListing(L2, 1 days) reverts NftNotAvailable(5, vault); 100e18 of a reward token sent to the vault and released pays alice 49e18, owner 49e18, fee 2e18; alice then calls takeListing(L2, 1) in place and re-locks the seat with no owner action. test_ownerExitAfterParkedSeat shows the owner's exit: cancelListing(L2) then withdrawNft(5) returns the seat.","severity":"low","snippet":"        address holder = NFT_CONTRACT.ownerOf(listing.tokenId);\n        if (holder == listing.owner) {\n            NFT_CONTRACT.transferFrom(listing.owner, address(vault), listing.tokenId);\n        } else if (holder != address(vault)) {\n            revert LibError.NftNotAvailable(listing.tokenId, holder);\n        }","title":"A take at the listing's minimum duration parks the seat in the taker's vault: the owner's other open listings become takeable only by that operator, rewards keep splitting after the lending ends, and "},{"citation":"resolved","description":"getPairingExpiry ties an approval to its lending by comparing the recorded lendingStartedAt with the current lending's startedAt, and the comment in authorizePairing (src/IMDLend.sol:210) says a lending that starts in a later second never revives the approval. A lending started at T, ended early by the operator at T (endLending sets endsAt = T, so isNftLent is false at T) and restarted at T through a second listing in the same block has startedAt == T again, so the earlier approval validates in the new lending. Both lendings are to the same operator, since the vault is per owner, operator and split, so no privilege crosses parties; it is a gap between the stated invariant (an approval belongs to exactly one lending) and the code. If the invariant is wanted, key approvals to a per-token lending counter incremented in startLending instead of to startedAt.","line":358,"path":"src/IMDLend.sol","reproduction":"test/scratch/Repro.t.sol, test_pairingSurvivesSameSecondRestart: owner lists seat 5 twice for alice (L1, L2). In one block at time T alice takes L1 for 1 day (startedAt T), calls authorizePairing(D, 5, T + 10 min), calls endLending(5), then takes L2 for 1 day (startedAt T again). Expected per the authorizePairing comment: getPairingExpiry(D) == 0 in the new lending. Actual: getPairingExpiry(D) == T + 10 min, and at T + 1 isValidSignature(D, abi.encode(deviceKey, nonce, relayOrigin)) returns 0x1626ba7e.","severity":"info","snippet":"        if (approval.lendingStartedAt != _lendings[approval.tokenId].startedAt) return 0;","title":"Pairing approval survives into a second lending that starts in the same second the previous one was ended early"},{"citation":"resolved","description":"createListing validates the expiry, the durations and ownerBps but accepts any uint256 pricePerDay. takeListing multiplies pricePerDay by duration in checked arithmetic before dividing by 1 days, so for pricePerDay > type(uint256).max / duration the take reverts with Panic(0x11). The listing is created, emits ListingCreated and looks open, but no duration above the overflow bound is takeable; where pricePerDay * minDuration fits but pricePerDay * maxDuration does not, only the short end of the advertised range works. Only the listing's own owner is affected and the bound is about 3.7e69 wei per day for a 365-day take, so this is a boundary note. Fix: in createListing require pricePerDay <= type(uint256).max / maxDuration so an untakeable listing is rejected at creation.","line":227,"path":"src/IMDLendMarket.sol","reproduction":"test/scratch/Repro.t.sol, test_pricePerDayOverflowListingUntakeable: createListing(tokenId 5, ownerBps 5000, pricePerDay = type(uint256).max / 1 days + 1, minDuration 1 days, maxDuration 1 days, availableUntil now + 1 day, operator alice) succeeds. Alice calls takeListing(id, 1 days). Expected: the listing is rejected at creation or the take succeeds. Actual: takeListing reverts with Panic(0x11) at the multiplication on line 227.","severity":"info","snippet":"        uint256 upfrontAmount = listing.pricePerDay * duration / 1 days;","title":"createListing does not bound pricePerDay, so a listing whose pricePerDay * duration overflows can never be taken"},{"citation":"resolved","description":"onERC721Received refuses IdentityMD seats so they arrive only through the market, but the market moves seats with plain transferFrom and so can anyone else: ERC-721 transferFrom never calls the receiver hook (the deployed IdentityMD contract is a plain Solady ERC-721 with no transfer hook). A seat a third party pushes into a vault (a mis-pasted vault address, or the vault for the wrong owner, operator or split) is not lent, so nothing locks it, but the only exit is withdrawNft, which is owner-only and always sends to OWNER(). The sender has no path to recover it; the vault owner receives it. This is a user-error surface rather than a permission bypass, reported because the receiver guard suggests a protection the plain-transfer path does not give. Minimal mitigation is documentation and a front-end warning; an on-chain owner-only returnNft(tokenId, to) for seats never lent in the vault changes the security-model table and needs a scope decision.","line":286,"path":"src/IMDLend.sol","reproduction":"test/scratch/Repro.t.sol, test_thirdPartyPushOnlyOwnerRecovers: vault V = market.deployVault(owner, alice, 5000). Bob holds seat 2 and calls nft.transferFrom(bob, V, 2). Expected from the receiver guard's intent: the transfer is refused or bob can recover seat 2. Actual: the transfer succeeds, bob's withdrawNft(2) reverts NotOwner, and owner's withdrawNft(2) sends seat 2 to owner.","severity":"info","snippet":"        if (msg.sender == address(NFT_CONTRACT)) revert LibError.UnexpectedNft(msg.sender, from, tokenId);","title":"An IdentityMD seat pushed into a vault with plain transferFrom by a non-owner can only be recovered by the vault owner"},{"citation":"resolved","description":"The repository holds contract sources only, the build instructions stop at forge build and forge fmt, and there is no test directory, so none of the behaviours the README's security model promises is checked by a test: a lent seat cannot be withdrawn, the operator cannot move seats, isValidSignature validates only an approved WorkerAuthorization naming the vault within its lending and from the block after it starts, the three reward parts always sum to the amount, the market's approval can only move an owner's seat into that owner's own vault, the two-step fee recipient handover, the off-identity-chain behaviour (createListing and takeListing revert NotIdentityChain while deployVault works), and the boundary cases at endsAt, availableUntil and expiresAt. Merged from audit_flow and audit_permissions. Fix: add a Foundry suite covering those invariants and boundaries plus a fuzz test over _calculateSplit asserting fee + owner + operator == amount.","line":108,"path":"README.md","reproduction":"State: at the pinned commit `git ls-files test` prints nothing and `ls test` reports no such directory; `forge test` runs zero project tests. Expected for a custody contract before mainnet: a suite exercising the security-model table in README.md. Actual: none.","severity":"info","snippet":"Not audited. The vaults take custody of NFTs, so they need an independent audit before mainnet use.","title":"Repository ships no test suite; the custody, lending, pairing and split edges in the security model are unexercised"}],"hash":"f867c28a67f364cd1e912017dd1126508e17b0c9ad87cb5f6cf086ed1841376c","nodeId":"26536a87-baa3-497f-8056-fb8e5f4e07f5","outcome":"completed","summary":"Review complete. The findings file holds six substantiated findings, merged from the eleven specialist reports.\n\n**What I did**\n\n- Read all four reference guides and the whole contract tree, built the project, and inspected the real IDMD and IMD contracts on chain 1 through their verified sources. IDMD is a plain Solady ERC-721 with no transfer hook, and IMD is a plain LayerZero OFT ERC-20, so the market's plain transferFrom and upfront payment path work against the deployed dependencies.\n- Ran all three specialist proofs under test/scratch/. Each fails on the current code for the stated reason (fee and owner share round to zero on small releases).\n- Wrote my own scratch test reproducing the remaining claims: the one-second take and in-place re-take, the same-second pairing revival, the third-party push, and the price overflow. All reproduce. I also confirmed the owner can cancel open listings and withdraw, so the parked-seat case has an exit, which keeps it at low severity.\n\n**Findings kept, in .imd-findings.json**\n\n1. Low: partial releases let the caller pick the split granularity, routing fee and owner dust to the operator. Four specialist reports merged into one, with the 0-decimal-token proof attached. The fix I state is a minimum release size or cumulative accounting. Rounding the fee up alone is not a fix.\n2. Low: a minimum-duration take parks the seat in the taker's vault, dead-ends the owner's other listings, keeps splitting rewards, and allows in-place re-takes of open listings. Two specialist reports merged. Suggested fix is to accept the in-place path only for listings naming that operator.\n3. Info: pairing approval survives a same-second end and restart.\n4. Info: unbounded pricePerDay makes a listing untakeable.\n5. Info: a seat pushed in by a third party is recoverable only by the vault owner.\n6. Info: no test suite. Two specialist reports merged.\n\nNothing was dropped as non-reproducing. No file outside .imd-findings.json and test/scratch/ was changed.","treeHash":null,"usage":{"cachedInputTokens":1995714,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":31709,"runtime":"claude","turns":39,"wallClockMs":451603}}],"verification":[]}