{"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":"e4a761c2-59b8-4c34-84fc-54fade5f665a","kind":"audit","nodes":[{"acceptedSubmissionHash":"3c44eea8395b0473d6bfd27c4812497529d27e20f82e658ba4ebaf60a90cb034","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":"fa9f30a9746a9405da319f8a10f73af34cb95e4a0fd5254afe6382bd29d3b991","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":"fdee41842a28306e6933b04d016c4282b2848b6b103a318a21b5ab07cb4e9129","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"cf0340168a6e915d9e5d7e59c87f2e85c631c92f32ddee0cfa0c650a1032e37b","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":"f07498d2bb2d7d73b908433fec2b6999b4f96e52e29d375c1f4c18ccf5953e49","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 Pawn contracts at github.com/identity-md-launches/launch-1031-workflow-frontend-stage-context (main): PawnShop, CollateralVault, LendingPool, LockDiscount, MilestoneBurn and FloorRelay. Focus: can anyone take pool ETH or vault NFTs; oracle verification and the relay (replay, wrong signer, stale answers); the ERC-1271 worker-authorization scope on seats; auction math and the shortfall reserve; lock commitments and release; owner powers and their delays. Rank findings by severity with a concrete fix for each.","parentJobId":null,"planHash":"94f1280a580c4824a1e2083acdede70642b7370176203bb8c5d2b1224a6e9430","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"e4a761c2-59b8-4c34-84fc-54fade5f665a","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":"51022","feedbackHash":"6c023654beec0d568637c2d7661425771aba2cc63bab0a85213cc9f87d71381b","nodeKey":"audit_economics","submissionHash":"3c44eea8395b0473d6bfd27c4812497529d27e20f82e658ba4ebaf60a90cb034","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51024","feedbackHash":"e10c85d87ee4ad2c36a95c30cfec7cf1d241a139ed75070aa18fa51dd80e3a19","nodeKey":"audit_flow","submissionHash":"fa9f30a9746a9405da319f8a10f73af34cb95e4a0fd5254afe6382bd29d3b991","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52114","feedbackHash":"a9cbb8210b170cc836a3c7bc866c4fc1920c2be1da435aa7b3210f0784b7cc1d","nodeKey":"audit_judge","submissionHash":"fdee41842a28306e6933b04d016c4282b2848b6b103a318a21b5ab07cb4e9129","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50966","feedbackHash":"49ad04815667ca693c141a6e6aacc28ff6bc368dadcfa0aec8f9f7547acdc72c","nodeKey":"audit_math","submissionHash":"cf0340168a6e915d9e5d7e59c87f2e85c631c92f32ddee0cfa0c650a1032e37b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51481","feedbackHash":"e65098975c4794e6567b704515731193767007b9149ceef1520f21532335ea8b","nodeKey":"audit_permissions","submissionHash":"f07498d2bb2d7d73b908433fec2b6999b4f96e52e29d375c1f4c18ccf5953e49","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"012bba5044cdd4cf9d8ddcc089b3864db297177b7157da28dc8092e087ecfd59","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6ef494db85781eec","findings":[],"hash":"1ba67c2fc5be3ce72a3fb1d7433feb757d3a4b577f07d18eb2cc053dac8fc123","nodeId":"007adbc1-b865-4184-9c49-3883fe054f55","outcome":"failed","summary":"wall-clock budget exhausted","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"claude-fable-5-1","outputTokens":0,"runtime":"claude","turns":0,"wallClockMs":7048714}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c35be49d2f8f8def","findings":[{"citation":"resolved","description":"LendingPool.totalAssets() carries every Active loan at full principal. The first impairment of a defaulted loan is booked here, inside startAuction, which anyone may call only after due + 3 days (GRACE, line 393). Between the public floor collapse (submitFloor is permissionless and visible) and that call there is a window of at least three days, and for the whole term before it, in which the pool's share price ignores a loss that is already certain from public data. Any lender who watches floors[collection] withdraws at par during that window (maxWithdraw is bounded only by idleAssets), and the entire loss lands on whoever is left. The same gap exists after an auction has started, because expectedAuctionLoss is refreshed only when someone calls markAuctionLoss, and because extend() lets an underwater borrower keep a loan Active indefinitely for a 1% weekly fee with no LTV recheck, so the lagged valuation can persist for months. Victims are unprivileged lenders; the actor is any other lender; no admin action is needed.","line":397,"path":"src/PawnShop.sol","reproduction":"Setup (test/helpers/PawnTestBase): bob and buyer each deposit 5 ETH (pool 10 ETH). Floor 10 ETH is submitted; alice pawns token 1 at term 1 (principal 4 ETH). A new floor of 2 ETH is submitted. Warp to loan.due + 1 (overdue, inside GRACE). Observed: pool.totalAssets() = 10.034 ETH and pool.maxWithdraw(bob) = 5.017 ETH; bob withdraws 5.017 ETH at par. Warp to due + 3 days + 1 and call startAuction: expectedAuctionLoss becomes 2 ETH and pool.maxWithdraw(buyer) drops to 1.017 ETH; after the sale at the 1 ETH terminal price buyer's shares are worth 2.017 ETH. Expected: a 3 ETH loss already implied by public state is shared 1.5 / 1.5; actual: bob 0, buyer 3 ETH. Fix (keeps grace, fees and auction curve): (1) add a permissionless markOverdue(id) usable from loan.due onward that books principal - min(principal, floors[collection].price / 2) through the existing markAuctionLoss path (keyed by loan id, non-decreasing), so the loss is visible as soon as the loan is late; (2) add a short withdrawal cooldown (request, then withdraw after e.g. 24 h) in LendingPool so an exit cannot front-run a recognition that anyone can trigger; optionally (3) require principal <= 50% of the current fresh floor in extend() so underwater loans cannot be rolled at par. (2) and (3) change lender/borrower UX and need a scope decision.","severity":"medium","snippet":"        lendingPool.markAuctionLoss(id, loan.principal, auctionPrice(id));","title":"Default losses are recognised only at startAuction, so informed lenders exit at par and leave the whole loss to the remaining lenders"},{"citation":"resolved","description":"auctionPrice() is computed from loan.auctionFloor, which startAuction copies from floors[collection].price at that moment (line 396, with no freshness requirement). After ten days the price holds at 50% of that captured floor forever. If the collection's market falls more than 50% after the auction starts, or the captured floor was already stale and high, no rational buyer ever appears: writeOffAuction books the full principal as loss after 40 days, the PAWN commitment is released, and the seat stays locked in its vault with a price nobody will pay. Nothing can reset the curve: writeOffAuction leaves status = Auction and the same auctionFloor, and the vault only releases through buyAuction. The pool therefore recovers nothing from collateral that still has real market value, and the seat is dead for the ecosystem. No admin action is required; it only takes a floor drop after default.","line":415,"path":"src/PawnShop.sol","reproduction":"Setup from PawnTestBase. Submit floor 10 ETH, alice pawns token 1 at term 1 (principal 4 ETH). Warp to due + 3 days + 1 and startAuction (auctionFloor = 10 ETH, fresh at that moment). Submit floor 1 ETH. Warp 40 days and writeOffAuction: cumulativeLoss = 4 ETH. Observed: shop.auctionPrice(id) = 5 ETH immediately after write-off and still 5 ETH one year later; nft.ownerOf(1) is still the vault; buyAuction{value: 1 ether} (the real market price) reverts IncorrectPayment. Expected: after write-off the pool can still realise the ~1 ETH the seat is worth. Fix: after writeOffAuction (or once an auction has sat at the terminal price for N days) let anyone call restartAuction(id), which requires floorFresh(loan.collection), sets auctionStarted = block.timestamp and auctionFloor = floors[collection].price, and restarts the same curve; recoveries keep flowing through receiveRecovery. Alternatively keep declining past day 10 (e.g. linearly to 0 by day 40) so the terminal price cannot sit above any market forever.","severity":"medium","snippet":"        return Math.mulDiv(floor, 5000, 10000, Math.Rounding.Ceil);","title":"Unsold collateral is stranded forever at 50% of the floor captured at auction start; there is no re-pricing path after write-off"},{"citation":"resolved","description":"Freshness is enforced only on a.issuedAt / a.expiresAt (when the oracle signed) and never on a.fromBlock / a.toBlock (what the answer is about). floorFresh() then treats the price as current for 26 hours. The project's own docs (docs/review-notes.md item 2) state that the pinned questionHash commits to the resolved block window, so every attestation that matches the pinned hash necessarily reports the floor as of that historical window; a freshly issued signature over an old window passes all checks. Even with a relative-window question, a re-signed or late-delivered answer with toBlock thousands of blocks in the past is accepted. Lending and auction-start prices can therefore be based on a floor that is days or weeks old while the contract reports it as fresh. Any oracle purchaser can submit it; the borrower side benefits when the stale price is higher than the market.","line":293,"path":"src/PawnShop.sol","reproduction":"Using PawnTestBase._attestation with FLOOR_QUESTION, set a.fromBlock = 1, a.toBlock = 2, a.issuedAt = block.timestamp, a.expiresAt = issuedAt + 26 hours, answer = 10 ether, sign with the shop's signer and call submitFloor: it succeeds, floorFresh() returns true, and pawn() lends 4 ETH against it. The live vector in test/helpers/LiveRelayVectors.sol (toBlock 26147885, questionHash 0x71ed...) would likewise be accepted at any later date as long as it is re-signed with a new issuedAt. Expected: an answer whose closing block is older than the freshness window is rejected. Fix: add `|| a.fromBlock > a.toBlock || a.toBlock + MAX_BLOCK_AGE < block.number` (MAX_BLOCK_AGE about 26 hours of blocks, 7800 on mainnet) to the InvalidAttestation condition, and document that the pinned question must be a standing relative-window question so the hash does not freeze a window; if the oracle cannot provide that, keep new loans paused as docs/review-notes.md already advises.","severity":"low","snippet":"                || block.timestamp - a.issuedAt > FLOOR_MAX_AGE || a.issuedAt <= floors[collection].issuedAt","title":"submitFloor bounds the signature's age but not the data's age: an attestation about an old block window is accepted as a fresh floor"},{"citation":"resolved","description":"startAuction pays 0.002 ETH to msg.sender from bountyReserve. The borrower may be that caller, and in buyAuction the surplus price - principal is credited back to the borrower, so a borrower who buys back their own seat pays net exactly principal. The whole cycle (pawn, claim, let it lapse, startAuction, buyAuction at any price, claim) therefore nets AUCTION_BOUNTY - fee for the borrower and leaves them with the seat. For term 1 (100 bps) that is positive whenever principal < 0.2 ETH, i.e. collection floor < 0.5 ETH; at MIN_LOAN the fee is 0.0001 ETH against a 0.002 ETH bounty. The reserve is protocol income (15% of fees) and voluntary fundBounties contributions, and the fee waterfall refills it only at 0.000015 ETH per such loan, so repeated cycles across many seats or collections empty it and starve honest keepers. Not profitable today for the identity collection (floor about 1.9 ETH gives a 0.0076 ETH fee) but profitable for any owner-listed collection with a floor under 0.5 ETH, or after a floor crash.","line":398,"path":"src/PawnShop.sol","reproduction":"Setup from PawnTestBase; shop.fundBounties{value: 0.2 ether}(); submit floor 0.025 ETH. alice: approve, pawn(nft, 1, 1) (principal 0.01 ETH, fee 0.0001 ETH), claim; warp to due + 3 days + 1; startAuction(id); buyAuction{value: auctionPrice(id)}(id, alice); claim. Observed: alice's ETH balance is 0.0019 ETH higher than before the cycle, nft.ownerOf(1) == alice, bountyReserve fell from 0.2 to 0.198 ETH. Expected: a defaulting borrower should never end a cycle with more ETH than they started with. Fix: in startAuction pay the bounty only if msg.sender != loan.borrower, and cap it at the loan's last paid fee or at a fraction of principal (e.g. min(AUCTION_BOUNTY, principal / 100)); alternatively deduct the bounty from the borrower's surplus in buyAuction.","severity":"low","snippet":"        _payBounty(msg.sender, AUCTION_BOUNTY);","title":"AUCTION_BOUNTY can exceed a small loan's fee, so a borrower profits from a self-default cycle and drains the bounty reserve"},{"citation":"resolved","description":"writeOffAuction settles the full principal at zero, and _settle covers the gap from shortfallReserve first. When the seat later sells, buyAuction routes min(price, principal) to receiveRecovery, which vests the money to lenders via _receiveIncome; nothing credits shortfallReserve back, even though the loss the reserve paid for has now been recovered in full. The reserve (built from the protocol's 15% fee share, i.e. feeRecipient's income) thus becomes permanent lender profit, and the next fees are diverted again to refill it, so the fee recipient pays for a loss that never materialised. It also weakens protection for the next default because the reserve stays empty until it is refilled from new fees.","line":251,"path":"src/LendingPool.sol","reproduction":"PawnTestBase setup; shop forwards 0.3 ETH to pool.addReserve (shortfallReserve = 0.3). alice pawns token 1 at term 1 (principal 0.4 ETH). Warp past due + 3 days, startAuction, warp 40 days, writeOffAuction: shortfallReserve = 0, cumulativeLoss = 0.1 ETH. buyAuction{value: 0.5 ether}: cumulativeRecoveries = 0.4 ETH, shortfallReserve still 0; after 7 days pool.totalAssets() = 5.3034 ETH versus 5 ETH deposited plus 0.0034 ETH fees, i.e. the 0.3 ETH reserve is now lender value. Expected: recovery first repays what the reserve covered (0.3 ETH), then the remaining 0.1 ETH vests to lenders. Fix: track reserveUsed[id] in settleAuction (covered amount per loan) and in receiveRecovery (give it the loan id) move min(msg.value, reserveUsed[id]) back into shortfallReserve before vesting the remainder.","severity":"low","snippet":"    function receiveRecovery() external payable onlyShop nonReentrant {","title":"A shortfall reserve consumed at write-off is never restored when the collateral later sells; the protocol reserve leaks to current lenders"},{"citation":"resolved","description":"The expected-totalAssets expression subtracts unvestedDonations and cumulativeLoss before adding cumulativeRecoveries. After a loan is written off (cumulativeLoss += principal) and then sold (cumulativeRecoveries += principal, which is also unvested for seven days), the running value goes below zero and the checked subtraction panics, even though the pool's book is exactly right. forge test on this tree fails: 104 passed, 1 failed, with the shrunk sequence advanceAndPublish, pawn, pawn, advanceAndPublish, repayOrAuction(…, true, 1800003600), repayOrAuction(…, true, …). Replaying it shows cash 3.0438, totalBorrowed 0, unvested 1.99, cumulativeLoss 3.98, cumulativeRecoveries 1.99, totalAssets 1.0538 ETH: 5.0338 - 1.99 - 3.98 underflows before + 1.99 is applied. A red invariant test gives no regression protection for the accounting it is meant to pin, and the suite cannot be used as a gate.","line":185,"path":"test/PawnInvariant.t.sol","reproduction":"Run `forge test --match-contract PawnInvariantTest` (default config, fail_on_revert = true): invariant_poolBookMatchesCashAndDebt reports `panic: arithmetic underflow or overflow (0x11)` after a write-off followed by a sale; the other two invariants pass. Expected: the formula evaluates and equals pool.totalAssets() (1.0538 ETH in the shrunk case). Fix: reorder the expression so all additions come first: 5 ether + deposits + donations + fees + pool.cumulativeRecoveries() - withdrawals - unvested - cumulativeLoss - (expectedAuctionLoss > reserve ? … : 0).","severity":"low","snippet":"            5 ether + handler.ghostDeposits() + handler.ghostDonations() + pool.cumulativeLoanFees()\n                - handler.ghostWithdrawals() - pool.unvestedDonations() - pool.cumulativeLoss()","title":"invariant_poolBookMatchesCashAndDebt underflows on write-off-then-sale sequences, so the Pawn invariant suite fails spuriously and cannot guard regressions"},{"citation":"resolved","description":"Documented for the record, not as a bypass: no path was found that changes a timelocked setting without the delay. Immediate owner powers: setNewLoansPaused, disableCollection (also cancels that collection's queued change), cancelChange, and setQuestionHashOnce for a collection whose hash is still zero (the identity collection at launch). The question hash is the single input that prices all collateral, so lenders who deposit before it is set trust the owner to pin a sound question; a crafted question with a large genuine answer lets a loan be sized to the whole idle balance (maxShareBps 10000 for seats). 48-hour, 7-day-window powers: terms (bounded 7-90 days, 50-1000 bps), collection config incl. LTV (<= 4000), share, seat flag and hash rotation (rotation also zeroes floor expiry, so lending pauses until a new matching floor), attester (any address incl. an owner-controlled ERC-1271 contract; MilestoneBurn mirrors it), fee recipient, discount module (validated for pawnShop/pawnToken only; a module whose release() reverts blocks repay, buyAuction and writeOffAuction for loans that used it). LendingPool owner: queueDepositCap raises only, executed after 48 h with no execution window and no cancel, so a dormant queued cap can be executed much later without fresh notice. Vault control is limited to the borrower and the immutable shop; the live identity collection is fully minted (2000/2000), non-upgradeable, has no pause or seizure, and its Ownable owner is the CREATE2 deployer proxy, so its owner functions are effectively unreachable.","line":237,"path":"src/PawnShop.sol","reproduction":"State: fresh deployment, lenders have deposited, newLoansPaused = false, collections[IDENTITY].questionHash == 0. Owner calls setQuestionHashOnce(IDENTITY, H) in one transaction with no delay; the next submitFloor matching H sets the price used by pawn() in the same block. For the pool: owner calls queueDepositCap(1e30); after 48 h executeDepositCap() succeeds at any later time (no expiry) and cannot be cancelled. Suggested hardening that preserves the design: route the first question hash through the same 48 h queue (executeCollection already supports it), and give executeDepositCap the same 7-day window plus a cancel.","severity":"info","snippet":"    function setQuestionHashOnce(address collection, bytes32 hash) external onlyOwner {","title":"Owner powers and delays (trust assumptions): collateral valuation can be set once with no delay, every other valuation lever is 48 h delayed, pool cap raises never expire"}],"hash":"3c44eea8395b0473d6bfd27c4812497529d27e20f82e658ba4ebaf60a90cb034","nodeId":"1d754c03-4c4f-4b19-8bc5-aae0b7c877e9","outcome":"completed","summary":"The audit is complete. The findings are in `.imd-findings.json` at the repository root (7 entries), and no source file was changed.\n\n**Outcome.** I found no way for an unprivileged party to take pool ETH or vault NFTs, no oracle replay or wrong-signer path, and the ERC-1271 worker scope is tight. The defects are economic: lagged loss recognition and permanently stranded collateral. No finding reached high or critical, so no proof tests were attached.\n\n**Findings, ranked**\n\n- **Medium, PawnShop.sol:397.** Losses are booked only when `startAuction` runs, at least three days after a public floor collapse. A watching lender withdraws at par first. Reproduced: two lenders at 5 ETH each, 4 ETH loan, floor 10 to 2; the early exiter leaves with 5.017 ETH and the remaining lender ends at 2.017 ETH instead of both sharing the 3 ETH loss. Fix: permissionless overdue impairment from `due`, plus a short withdrawal cooldown.\n- **Medium, PawnShop.sol:415.** The auction terminal price is 50% of the floor captured at auction start, forever. After write-off nothing can re-price, so a seat worth 1 ETH sits priced at 5 ETH indefinitely and the pool recovers nothing. Fix: a `restartAuction` that re-captures a fresh floor after write-off.\n- **Low, PawnShop.sol:293.** Floor freshness checks the signature's age, never the answer's block window. The docs confirm the pinned question hash freezes a window, so re-signed old data passes as fresh. Fix: bound `toBlock` against `block.number`.\n- **Low, PawnShop.sol:398.** The 0.002 ETH auction bounty exceeds the fee on loans under 0.2 ETH. A borrower who self-defaults and buys back nets the difference and keeps the seat; reproduced at 0.0019 ETH profit per cycle. Fix: no bounty to the borrower, or cap it by fee.\n- **Low, LendingPool.sol:251.** A reserve consumed at write-off is never restored by a later full recovery; reproduced with 0.3 ETH of protocol reserve becoming lender value. Fix: track per-loan reserve use and refill before vesting.\n- **Low, test/PawnInvariant.t.sol:185.** The project's own invariant formula underflows on write-off-then-sale sequences. The suite is currently red (104 passed, 1 failed), so it cannot gate regressions. Fix: reorder the expression.\n- **Info, PawnShop.sol:237.** Owner powers documented as trust assumptions: the first question hash has no delay, everything else is 48-hour delayed, and pool cap raises never expire or cancel.\n\n**Verified clean.** The pool's `receive` fits real WETH9's 2300-gas stipend. The live identity collection is a plain Solady ERC-721, fully minted, with no pause or seizure, and its owner is the CREATE2 deployer proxy. The vault's `callFor` cannot close its own loan through reentry. FloorRelay's calldata parsing, domain binding and ECDSA checks held up against the live vectors.\n\n**Not covered.** No fork test against mainnet WETH, no Slither, and the off-chain IMD worker service's handling of ERC-1271 was not examined.","treeHash":null,"usage":{"cachedInputTokens":3089834,"inputTokens":516,"model":"claude-fable-5-1","outputTokens":90744,"runtime":"claude","turns":54,"wallClockMs":1808133}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"759c614fdc84ff66","findings":[{"citation":"resolved","description":"auctionPrice() returns 0 whenever CollateralVault.holdsCollateral() is false at that instant (ownerOf reverts or reports another address). buyAuction() then accepts msg.value = 0 from anyone, sets status = Sold, settles the whole principal as a loss (settleAuction with value 0), releases the PAWN commitment and calls vault.release(), which sets released = true and skips delivery. Nothing can ever move the NFT out of that vault again: release() is one-shot, the loan is no longer in Auction, and the vault has no other exit path. If the collection's ownership report is only transiently wrong (a revocation that is later reversed, a paused/upgrading proxy whose ownerOf reverts for a few blocks, a seizure returned to the holder), the seat comes back to a vault that can never release it. The borrower loses all surplus, the pool books the full principal although the collateral still exists, and the seat is bricked. writeOffAuction() already provides the financial settlement path for missing collateral without finalizing custody, so the zero-price purchase is not needed for liveness.","line":405,"path":"src/PawnShop.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 {LaunchToken} from \"src/LaunchToken.sol\";\nimport {PawnShop} from \"src/PawnShop.sol\";\nimport {LendingPool} from \"src/LendingPool.sol\";\nimport {OracleAttestation} from \"src/OracleAttestation.sol\";\n\ncontract ZWETH is ERC20 {\n    constructor() ERC20(\"Wrapped Ether\", \"WETH\") {}\n\n    function deposit() external payable {\n        _mint(msg.sender, msg.value);\n    }\n\n    function withdraw(uint256 amount) external {\n        _burn(msg.sender, amount);\n        (bool ok,) = msg.sender.call{value: amount}(\"\");\n        require(ok);\n    }\n}\n\n/// Seat collection whose issuer can temporarily reassign (e.g. a revocation later reversed).\ncontract ZSeat is ERC721 {\n    constructor() ERC721(\"Seat\", \"SEAT\") {}\n\n    function mint(address to, uint256 id) external {\n        _mint(to, id);\n    }\n\n    function seize(uint256 id, address to) external {\n        _update(to, id, address(0));\n    }\n}\n\ncontract ZeroPriceStrandTest is Test {\n    uint256 internal constant KEY = 0xA11CE;\n    bytes32 internal constant Q = keccak256(\"floor question\");\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    address internal bob = makeAddr(\"bob\");\n    address internal griefer = makeAddr(\"griefer\");\n    address internal buyer = makeAddr(\"buyer\");\n    address internal issuer = makeAddr(\"issuer\");\n    PawnShop internal shop;\n    LendingPool internal pool;\n    ZSeat internal nft;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        vm.deal(bob, 100 ether);\n        vm.deal(buyer, 100 ether);\n        LaunchToken token = new LaunchToken();\n        ZWETH weth = new ZWETH();\n        shop = new PawnShop(owner, address(token), address(weth), vm.addr(KEY));\n        pool = shop.lendingPool();\n        vm.etch(shop.IDENTITY_COLLECTION(), address(new ZSeat()).code);\n        nft = ZSeat(shop.IDENTITY_COLLECTION());\n        vm.prank(owner);\n        shop.setQuestionHashOnce(address(nft), Q);\n        OracleAttestation.Attestation memory a;\n        a.requestId = keccak256(\"r1\");\n        a.chainId = 1;\n        a.questionHash = Q;\n        a.answerType = 3;\n        a.answer = abi.encode(uint256(1 ether));\n        a.panelSize = 5;\n        a.quorum = 4;\n        a.agreed = 4;\n        a.issuedAt = uint64(block.timestamp);\n        a.expiresAt = uint64(block.timestamp + 26 hours);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, shop.attestationDigest(a));\n        shop.submitFloor(address(nft), a, abi.encodePacked(r, s, v));\n        vm.prank(owner);\n        shop.setNewLoansPaused(false);\n        vm.prank(bob);\n        pool.depositETH{value: 5 ether}(bob);\n    }\n\n    function test_transientlyUnreportedSeatCanStillBeAuctionedOnceItReturns() public {\n        nft.mint(alice, 1);\n        vm.startPrank(alice);\n        nft.approve(address(shop), 1);\n        uint256 id = shop.pawn(address(nft), 1, 0);\n        vm.stopPrank();\n        address vault = shop.getLoan(id).vault;\n\n        vm.warp(block.timestamp + 30 days + 3 days + 1);\n        shop.startAuction(id);\n\n        // The collection briefly reports another owner (revocation later reversed, upgrade, etc.).\n        nft.seize(1, issuer);\n        // Anyone can \"buy\" the auction for zero during that window; today this finalizes the sale.\n        vm.prank(griefer);\n        try shop.buyAuction(id, griefer) {} catch {}\n        // Ownership is restored to the vault.\n        nft.seize(1, vault);\n        assertEq(nft.ownerOf(1), vault);\n\n        // Expected: the seat is still saleable at the auction curve and the pool recovers principal.\n        uint256 price = shop.auctionPrice(id); // currently reverts NotAuctioning: loan is already Sold\n        assertGt(price, 0);\n        vm.prank(buyer);\n        shop.buyAuction{value: price}(id, buyer);\n        assertEq(nft.ownerOf(1), buyer);\n    }\n}","reproduction":"Floor 1 ETH, pool 5 ETH from bob. alice pawns seat #1 (term 0, principal 0.4 ETH). Warp due + 3 days + 1s; startAuction(1). Collection reports ownerOf(1) = issuer (MockNFT-style seize). griefer calls buyAuction(1, griefer) with 0 ETH: succeeds, emits CollateralUnavailable, pool records loss 0.4 ETH, loan status Sold, vault.released = true. Collection then reports ownerOf(1) = vault again. Expected: the seat is still saleable on the curve (auctionPrice(1) > 0, a buyer paying it receives the NFT and the pool recovers 0.4 ETH). Actual: auctionPrice(1) reverts NotAuctioning(), buyAuction reverts, and the NFT is held forever by a released vault. Fix: in buyAuction revert when !vault.holdsCollateral() (or when price == 0 because collateral is missing); keep writeOffAuction as the immediate financial settlement for missing collateral, so a later return of the seat can still be sold on the original curve and recovered via receiveRecovery. The existing test_seizedNFTZeroPriceBooksGapWithoutPayingIssuer would need to use writeOffAuction instead.","severity":"medium","snippet":"        if (!CollateralVault(payable(loan.vault)).holdsCollateral()) return 0;","title":"Zero-price auction 'sale' while ownerOf misreports permanently strands the seat in its vault and books the full principal as loss"},{"citation":"resolved","description":"PawnShop.markAuctionLoss() is permissionless and marks loss = principal - auctionPrice(). If the collateral is momentarily unreported, auctionPrice() is 0 and the full principal is marked. LendingPool.markAuctionLoss() then forbids any decrease (line 220, `if (loss < auctionLoss[id]) revert`), so the over-marked allowance stays in totalAssets() even after the seat is back and saleable. When the seat is later bought at a positive price, settleAuction() removes the whole allowance and adds the recovered ETH in the same transaction, so totalAssets() jumps up immediately instead of vesting like fees, donations and late recoveries. Anyone can deposit just before buyAuction (or bundle deposit + buyAuction + redeem) and take part of the recovery that belongs to lenders who bore the marked loss; lenders who exited while the allowance was overstated were also paid too little.","line":227,"path":"src/LendingPool.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 {LaunchToken} from \"src/LaunchToken.sol\";\nimport {PawnShop} from \"src/PawnShop.sol\";\nimport {LendingPool} from \"src/LendingPool.sol\";\nimport {OracleAttestation} from \"src/OracleAttestation.sol\";\n\ncontract ZWETH is ERC20 {\n    constructor() ERC20(\"Wrapped Ether\", \"WETH\") {}\n\n    function deposit() external payable {\n        _mint(msg.sender, msg.value);\n    }\n\n    function withdraw(uint256 amount) external {\n        _burn(msg.sender, amount);\n        (bool ok,) = msg.sender.call{value: amount}(\"\");\n        require(ok);\n    }\n}\n\n/// Seat collection whose issuer can temporarily reassign (e.g. a revocation later reversed).\ncontract ZSeat is ERC721 {\n    constructor() ERC721(\"Seat\", \"SEAT\") {}\n\n    function mint(address to, uint256 id) external {\n        _mint(to, id);\n    }\n\n    function seize(uint256 id, address to) external {\n        _update(to, id, address(0));\n    }\n}\n\ncontract RatchetedLossReleaseTest is Test {\n    uint256 internal constant KEY = 0xA11CE;\n    bytes32 internal constant Q = keccak256(\"floor question\");\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    address internal bob = makeAddr(\"bob\");\n    address internal griefer = makeAddr(\"griefer\");\n    address internal buyer = makeAddr(\"buyer\");\n    address internal issuer = makeAddr(\"issuer\");\n    PawnShop internal shop;\n    LendingPool internal pool;\n    ZSeat internal nft;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        vm.deal(bob, 100 ether);\n        vm.deal(buyer, 100 ether);\n        LaunchToken token = new LaunchToken();\n        ZWETH weth = new ZWETH();\n        shop = new PawnShop(owner, address(token), address(weth), vm.addr(KEY));\n        pool = shop.lendingPool();\n        vm.etch(shop.IDENTITY_COLLECTION(), address(new ZSeat()).code);\n        nft = ZSeat(shop.IDENTITY_COLLECTION());\n        vm.prank(owner);\n        shop.setQuestionHashOnce(address(nft), Q);\n        OracleAttestation.Attestation memory a;\n        a.requestId = keccak256(\"r1\");\n        a.chainId = 1;\n        a.questionHash = Q;\n        a.answerType = 3;\n        a.answer = abi.encode(uint256(1 ether));\n        a.panelSize = 5;\n        a.quorum = 4;\n        a.agreed = 4;\n        a.issuedAt = uint64(block.timestamp);\n        a.expiresAt = uint64(block.timestamp + 26 hours);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, shop.attestationDigest(a));\n        shop.submitFloor(address(nft), a, abi.encodePacked(r, s, v));\n        vm.prank(owner);\n        shop.setNewLoansPaused(false);\n        vm.prank(bob);\n        pool.depositETH{value: 5 ether}(bob);\n    }\n\n    function test_ratchetedFullLossReleasesUnvestedOnSale() public {\n        nft.mint(alice, 1);\n        vm.startPrank(alice);\n        nft.approve(address(shop), 1);\n        uint256 id = shop.pawn(address(nft), 1, 0);\n        vm.stopPrank();\n        address vault = shop.getLoan(id).vault;\n        vm.warp(block.timestamp + 30 days + 3 days + 1);\n        shop.startAuction(id);\n        emit log_named_uint(\"reserve\", pool.shortfallReserve());\n        nft.seize(1, issuer);\n        shop.markAuctionLoss(id); // full principal marked\n        nft.seize(1, vault);\n        emit log_named_uint(\"expectedLoss\", pool.expectedAuctionLoss());\n        emit log_named_uint(\"assetsBefore\", pool.totalAssets());\n        address jit = makeAddr(\"jit\");\n        vm.deal(jit, 4 ether);\n        vm.prank(jit);\n        uint256 shares = pool.depositETH{value: 4 ether}(jit);\n        uint256 price = shop.auctionPrice(id);\n        vm.prank(buyer);\n        shop.buyAuction{value: price}(id, buyer);\n        emit log_named_uint(\"assetsAfter\", pool.totalAssets());\n        uint256 out = pool.previewRedeem(shares);\n        emit log_named_uint(\"jitRedeemable\", out);\n        vm.prank(jit);\n        pool.redeemETH(shares, jit, jit);\n        assertLe(out, 4 ether, \"same-block depositor captured the released allowance\");\n    }\n}","reproduction":"Same setup (bob 5 ETH, alice's 0.4 ETH loan, reserve 0 because protocol fees fill the bounty reserve first). After startAuction, the collection briefly reports another owner; anyone calls shop.markAuctionLoss(1) -> expectedAuctionLoss = 0.4 ETH, totalAssets = 4.6102 ETH. Ownership returns to the vault. In one block a JIT account calls depositETH{4 ETH}, the buyer calls buyAuction at the curve price (~1 ETH; pool recovers 0.4 ETH), totalAssets becomes 9.0102 ETH, and the JIT account redeems all its shares for 4.185826113214559475 ETH. Expected: <= 4 ETH (no same-block gain). Actual: +0.1858 ETH taken from bob. Fix: when a sale or write-off realizes less loss than the allowance, route the released difference through _vest() (as receiveRecovery does) instead of releasing it at once; and/or let markAuctionLoss lower the allowance when collateral is held again (only enforce monotonicity while holdsCollateral() is true), so a transient zero reading cannot stick.","severity":"medium","snippet":"        expectedAuctionLoss -= auctionLoss[id];","title":"Ratcheted full-principal loss allowance is released in one step on sale, letting a same-block depositor capture the recovery from existing lenders"},{"citation":"resolved","description":"burn() accepts any valid attestation with answer >= 1e24 issued up to 26 hours earlier, and consumes the entire vault balance permanently. The configured question (web/src/oracle.ts CAP_QUESTION, docs/SETUP-AND-KEEPER.md) prices FDV from the Uniswap v4 pool's spot price at answer time. One momentary reading is enough: anyone can push the thin PAWN/ETH pool's spot price for the block the oracle reads, buy an answer, and submit it within the next 26 hours, even after the price has fallen back. The floor feed uses the same 26-hour window, but there a stale value is replaced the next day; here the action cannot be undone.","line":53,"path":"src/MilestoneBurn.sol","reproduction":"At T the spot price is pushed so the signed FDV answer is 1_000_000e18 (attestation issuedAt = T, expiresAt = T + 26h, valid panel). At T + 25h59m the FDV is back at 600_000e18. burn(a, sig) succeeds and moves the whole PAWN balance to 0x...dEaD. Expected: the milestone requires a current, sustained FDV >= $1M. Actual: a 26-hour-old single spot reading is accepted. Fix: shorten the accepted age for burn (e.g. <= 1 hour) and pin a question that uses a time-weighted price (or require two consumed answers separated by a minimum interval that both meet the milestone).","severity":"low","snippet":"                || a.agreed > a.panelSize || a.issuedAt > block.timestamp || block.timestamp - a.issuedAt > 26 hours","title":"MilestoneBurn fires its irreversible one-shot burn on a single spot-price FDV reading up to 26 hours old"}],"hash":"cf0340168a6e915d9e5d7e59c87f2e85c631c92f32ddee0cfa0c650a1032e37b","nodeId":"007adbc1-b865-4184-9c49-3883fe054f55","outcome":"completed","summary":"I found three defects, all written to `.imd-findings.json`: two medium and one low. None is critical or high: in the areas reviewed, I found no way for an outsider to take pool ETH or vault NFTs under normal collection behaviour. No repository files were changed; the scratch tests were deleted after running.\n\n1. **Medium: a zero-price auction \"sale\" can lock a seat in its vault forever** (`src/PawnShop.sol:405`). If the seat collection's `ownerOf` is briefly wrong (it reverts or names another owner), the auction price drops to 0. Anyone can then call `buyAuction` for free. That marks the loan sold, books the full principal as a pool loss, and releases the vault. If the seat later comes back to the vault, it can never leave: the auction can no longer be bought. The borrower loses any surplus and the pool loses a loan the seat could still cover.\n   - **Fix:** make `buyAuction` revert when the vault doesn't hold the collateral, and leave missing collateral to `writeOffAuction`, which already settles it immediately. The existing test `test_seizedNFTZeroPriceBooksGapWithoutPayingIssuer` would need to use `writeOffAuction` instead.\n\n2. **Medium: lenders can lose a recovery to a same-block depositor** (`src/LendingPool.sol:227`). In that same brief window, anyone can call the permissionless `markAuctionLoss`, which then records the whole principal as an expected loss. That figure can never be lowered. When the seat later sells, the whole allowance comes off in one step instead of being spread over 7 days like fees and recoveries. In the test, the existing lender has 5 ETH in the pool and the borrower took a 0.4 ETH loan. A depositor who put in 4 ETH just before the sale redeemed 4.1858 ETH right after, taking about 0.186 ETH of the existing lender's value.\n   - **Fix:** pass the released difference through the same 7-day vesting that recoveries use, and/or allow the allowance to fall again once the vault holds the collateral.\n\n3. **Low: the one-time burn can fire on one stale, manipulable reading** (`src/MilestoneBurn.sol:53`). `burn()` accepts a single market-cap answer up to 26 hours old. The configured question prices it from the Uniswap v4 pool's spot price at answer time. Pushing that price for the block the oracle reads is enough to trigger a burn that can't be undone, even after the price falls back.\n   - **Fix:** accept a much shorter age for this answer and use a time-weighted price question.\n\nBoth medium findings depend on the seat collection briefly misreporting ownership. I couldn't check whether the real IdentityMD collection ever does this. Each medium finding has its own Foundry proof in the JSON. Both proofs fail on the current code for the reason stated, and should pass once the fix is in.\n\n**Other areas checked, no defect found:**\n- **Oracle and relay:** replay, wrong signer, wrong domain or chain, the 26-hour freshness window and the relay's handling of malformed or truncated signature data all held up.\n- **Seat worker authorization:** the vault accepts only the one registered digest, which can't be reused across vaults, and it stops working at auction start or loan close.\n- **Pool maths:** the share-price formula, maximum withdraw and redeem amounts, the vesting checkpoints, and the bookkeeping that keeps the pool's cash covering its reserve and unvested income.\n- **Auction and reserve:** the price curve and its rounding, reserve and expected-loss settlement (no loss is charged twice), and the ETH-in/ETH-out balance in `pawn`, `extend` and `buyAuction`.\n- **PAWN lock commitments:** commit and release, and the counts for each discount tier.\n- **Owner powers and delays:** the timelock queue, replacing or cancelling a queued change, and the execution window.\n\nThe README already documents as accepted design: stale floor prices at auction start, the fresh-floor requirement for extensions, the owner's one-time question-hash setting with no delay, and late recoveries going to current lenders. I didn't report those. I did","treeHash":null,"usage":{"cachedInputTokens":1812558,"inputTokens":32,"model":"claude-opus-5-5","outputTokens":64529,"runtime":"claude","turns":19,"wallClockMs":1026415}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f91b58cf7cd2d45","findings":[{"citation":"resolved","description":"pawn() and extend() refuse to act on a floor older than FLOOR_MAX_AGE (26 h), but startAuction() copies floors[collection].price with no freshness check, and buyAuction() can be called in the same block at elapsed == 0, i.e. at exactly 100% of that stored number. Floors only move when someone pays for a new oracle answer, so the stored price routinely lags the market. Whoever notices that the stored floor is below the current market can, after due + GRACE, call startAuction and buyAuction back to back and take the seat at the stale price with no bidding window for anyone else. The loss falls on the borrower (surplus above principal is price - principal) and, when the stale floor is below principal, on the pool, which books the gap as a loss. The README says the auction 'freezes the last stored floor, even if stale', but it does not say a third party can turn that into an instant arbitrage against the borrower, and the shop's own lending rule treats the same number as unusable. Fix (design-preserving): add `if (!floorFresh(loan.collection)) revert StaleFloor();` to startAuction so a default is priced from a floor the shop would lend against; anyone can refresh it for the existing 0.001 ETH bounty. If the requester prefers no oracle liveness dependency on auctions, the alternative is to open at max(stored floor, principal * 10000 / ltv) when the floor is stale, and to forbid buyAuction in the block that started the auction so there is at least one block of public visibility.","line":396,"path":"src/PawnShop.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 {PawnShop} from \"src/PawnShop.sol\";\nimport {LendingPool} from \"src/LendingPool.sol\";\nimport {LaunchToken} from \"src/LaunchToken.sol\";\nimport {OracleAttestation} from \"src/OracleAttestation.sol\";\n\ncontract StaleWETH is ERC20 {\n    constructor() ERC20(\"Wrapped Ether\", \"WETH\") {}\n\n    function deposit() external payable {\n        _mint(msg.sender, msg.value);\n    }\n\n    function withdraw(uint256 amount) external {\n        _burn(msg.sender, amount);\n        (bool ok,) = msg.sender.call{value: amount}(\"\");\n        require(ok);\n    }\n}\n\ncontract StaleSeat is ERC721 {\n    constructor() ERC721(\"Seat\", \"SEAT\") {}\n\n    function mint(address to, uint256 id) external {\n        _mint(to, id);\n    }\n}\n\n/// Atomic start-and-buy of a defaulted seat at a stale stored floor.\ncontract StaleFloorAuctionTest is Test {\n    uint256 constant KEY = 0xA11CE;\n    bytes32 constant Q = keccak256(\"floor question\");\n    address owner = makeAddr(\"owner\");\n    address alice = makeAddr(\"borrower\");\n    address bob = makeAddr(\"lender\");\n    address attacker = makeAddr(\"attacker\");\n    LaunchToken token;\n    StaleWETH weth;\n    PawnShop shop;\n    LendingPool pool;\n    StaleSeat nft;\n    uint256 nonce;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        vm.deal(alice, 10 ether);\n        vm.deal(bob, 10 ether);\n        vm.deal(attacker, 10 ether);\n        token = new LaunchToken();\n        weth = new StaleWETH();\n        shop = new PawnShop(owner, address(token), address(weth), vm.addr(KEY));\n        pool = shop.lendingPool();\n        StaleSeat template = new StaleSeat();\n        vm.etch(shop.IDENTITY_COLLECTION(), address(template).code);\n        nft = StaleSeat(shop.IDENTITY_COLLECTION());\n        vm.startPrank(owner);\n        shop.setQuestionHashOnce(address(nft), Q);\n        shop.setNewLoansPaused(false);\n        vm.stopPrank();\n        vm.prank(bob);\n        pool.depositETH{value: 5 ether}(bob);\n    }\n\n    function _floor(uint256 price) internal {\n        OracleAttestation.Attestation memory a;\n        a.requestId = keccak256(abi.encode(\"floor\", ++nonce));\n        a.chainId = 1;\n        a.questionHash = Q;\n        a.answerType = 3;\n        a.answer = abi.encode(price);\n        a.panelSize = 5;\n        a.quorum = 4;\n        a.agreed = 4;\n        a.issuedAt = uint64(vm.getBlockTimestamp());\n        a.expiresAt = uint64(vm.getBlockTimestamp() + 26 hours);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, shop.attestationDigest(a));\n        shop.submitFloor(address(nft), a, abi.encodePacked(r, s, v));\n    }\n\n    function test_defaultedSeatCannotBeSeizedAtStaleFloor() public {\n        _floor(1 ether);\n        nft.mint(alice, 1);\n        vm.startPrank(alice);\n        nft.approve(address(shop), 1);\n        uint256 id = shop.pawn(address(nft), 1, 0); // principal 0.4 ETH against a 1 ETH floor\n        vm.stopPrank();\n\n        // A legitimate dip is posted the next day, then nobody refreshes the floor for a month.\n        vm.warp(vm.getBlockTimestamp() + 1 days);\n        _floor(0.5 ether);\n        vm.warp(shop.getLoan(id).due + 3 days + 1);\n        assertFalse(shop.floorFresh(address(nft)), \"stored floor is 29 days old\");\n\n        // Expected: a default cannot be priced from a floor the shop itself refuses to lend\n        // against. Actual: anyone starts the auction at the stale 0.5 ETH and buys the seat in\n        // the same transaction, leaving the borrower 0.1 ETH instead of the 0.6 ETH a current\n        // 1 ETH floor would return.\n        vm.startPrank(attacker);\n        vm.expectRevert(PawnShop.StaleFloor.selector);\n        shop.startAuction(id);\n        vm.stopPrank();\n\n        // Once a fresh floor is posted the auction opens at the current valuation.\n        _floor(1 ether);\n        vm.startPrank(attacker);\n        shop.startAuction(id);\n        assertEq(shop.auctionPrice(id), 1 ether);\n        shop.buyAuction{value: 1 ether}(id, attacker);\n        vm.stopPrank();\n        assertEq(nft.ownerOf(1), attacker);\n        assertEq(shop.claimable(alice), 0.388 ether + 0.6 ether);\n    }\n}","reproduction":"State: lender deposited 5 ETH; floor 1 ETH signed; alice pawns seat #1 at term 0 (principal 0.4 ETH, due in 30 days). One day later a 0.5 ETH floor is posted and nobody refreshes it afterwards. At due + 3 days + 1 second floorFresh() is false (floor is 29 days old) and the market is back at 1 ETH. Attacker calls startAuction(id) then buyAuction{value: 0.5 ether}(id, attacker) in one transaction. Expected: the default cannot be priced from a floor the shop itself refuses to lend against (revert StaleFloor until a fresh floor is posted; with a fresh 1 ETH floor the auction opens at 1 ETH and alice is credited 0.6 ETH surplus). Actual: auctionPrice is 0.5 ETH at elapsed 0, the attacker receives the seat, the pool recovers 0.4 ETH and alice is credited only 0.1 ETH. With a stale floor of 0.3 ETH the same sequence recovers 0.3 ETH and the pool books 0.1 ETH loss. The attached test asserts the StaleFloor revert and fails on the current code.","severity":"medium","snippet":"        loan.auctionFloor = floors[loan.collection].price;","title":"startAuction freezes a stale floor and buyAuction is allowed in the same transaction, so a defaulted seat can be seized atomically at an outdated price"},{"citation":"resolved","description":"The principal is derived entirely from floors[collection].price at execution time and pawn(collection, tokenId, termId) carries no parameter that binds the borrower to the valuation they saw. submitFloor is permissionless and any keeper holding a newer attestation (anyone can buy one for 0.5 IMD) can land it in the same block before the borrower's transaction. The borrower's seat is then locked into a smaller loan than intended, the fee is taken, and the only way out is to repay immediately and forfeit the fee plus the gas of creating a vault. Fix: add a `uint256 minPrincipal` argument (revert if principal < minPrincipal), or alternatively a `uint64 floorIssuedAt` the caller expects (revert if floors[collection].issuedAt != floorIssuedAt). The frontend already reads the floor it displays and can pass it through.","line":326,"path":"src/PawnShop.sol","reproduction":"State: floor 1 ETH, alice sends pawn(nft, 1, 0) expecting 0.4 ETH. Before it is mined a keeper submits a valid attestation with price 0.5 ETH (issuedAt newer than the stored one). Expected: alice's transaction fails or returns at least the amount she agreed to. Actual: the loan is created with principal 0.2 ETH, fee ceil(0.2 * 3%) = 0.006 ETH, alice is credited 0.194 ETH, the seat is in a vault, and to unwind she must repay 0.2 ETH, a net loss of 0.006 ETH plus two transactions' gas including the vault deployment.","severity":"low","snippet":"        uint256 principal = Math.mulDiv(floors[collection].price, ltv, 10000);","title":"pawn() has no minimum-principal bound, so a floor update that lands first silently changes the loan the borrower committed to"},{"citation":"resolved","description":"auctionPrice reads loan.auctionFloor, which is set once in startAuction and never updated. After ten days the price is pinned at half of that number for the rest of time; writeOffAuction clears the debt but leaves the seat 'for sale at the original auction curve'. If the captured floor was above the market at the time (a stale high answer, or a market that fell afterwards), no rational buyer ever appears, the pool realises 100% of the principal as loss, and a seat that still has value sits in the vault with no path to liquidity. The 50% terminal level and the frozen floor are documented, but the combination means the shortfall reserve and lenders absorb a total loss on collateral that could have been sold at a lower but non-zero price. Fix options (both change economics, so the requester must choose): (a) after writeOffAuction let anyone call a `restartAuction(id)` that requires floorFresh and resets auctionStarted/auctionFloor from the current floor, so the curve re-runs from today's valuation; or (b) continue the decline below 50% at a slow slope after day ten instead of holding. Either keeps 'no owner rescue' intact because the proceeds still follow the existing recovery and surplus rules.","line":415,"path":"src/PawnShop.sol","reproduction":"State: floor 2 ETH signed, alice pawns seat #1 (principal 0.8 ETH). The market falls to 0.5 ETH but the last stored floor stays 2 ETH (no one refreshed it, or the oracle answered on an older window). After due + 3 days anyone calls startAuction: auctionFloor = 2 ETH. Expected: the seat eventually clears at a price buyers will pay (around 0.5 ETH), recovering most of the 0.8 ETH. Actual: auctionPrice is 2 ETH, 1.4 ETH at day three, 1 ETH from day ten onward, forever. No buyer pays 1 ETH for a 0.5 ETH seat; at day 40 writeOffAuction books the full 0.8 ETH loss (reserve first, then cumulativeLoss), and buyAuction still demands 1 ETH, so the seat is stranded in the vault indefinitely.","severity":"low","snippet":"        return Math.mulDiv(floor, 5000, 10000, Math.Rounding.Ceil);","title":"Unsold collateral is never re-priced: the auction holds at 50% of the captured floor forever, including after write-off"},{"citation":"resolved","description":"isValidSignature calls ownerOf directly. ERC-721 implementations (including the OpenZeppelin base the collection is modelled on) revert for a burned or nonexistent id, so the view bubbles ERC721NonexistentToken instead of returning 0xffffffff. ERC-1271 callers that treat a revert as a hard error rather than 'invalid' (not every off-chain verifier wraps the call) will see the vault as broken rather than as having no authorised worker. The contract already has holdsCollateral() for exactly this case in release()/auctionPrice(). Fix: replace the ownerOf comparison in isValidSignature with `!holdsCollateral()`.","line":123,"path":"src/CollateralVault.sol","reproduction":"State: alice pawns seat #1, calls authorizeWorker with a valid message, then the collection burns or seizes id 1 (in the local fixture nft.seize(1, address(0))). A staticcall to vault.isValidSignature(workerDigest, \"\") is expected to return 0xffffffff. Actual: the staticcall fails with ERC721NonexistentToken(1) (observed revert data 0x7e273289...0001). authorizeWorker, callFor and _requireActive revert by design in this state, but the ERC-1271 view is specified to answer, not to throw.","severity":"low","snippet":"                || IERC721(collection).ownerOf(tokenId) != address(this)","title":"CollateralVault.isValidSignature reverts instead of returning the failure selector once the seat no longer exists"},{"citation":"resolved","description":"repay (line 363), buyAuction on a non-written-off loan (line 454) and writeOffAuction (line 437) all make an unguarded external call to loan.module.release(id). The module is chosen by the owner through a 48-hour timelock and _validateModule only checks two view functions, so a module whose release() reverts (or consumes all gas) makes every loan that was opened under it impossible to repay, sell or write off: the seat stays in its vault with released == false, totalBorrowed and collectionDebt never decrease, and the only accounting that still works is markAuctionLoss. The README states 'a malicious discount module can still block its own release call', so this is a documented trust assumption rather than a permission bypass, and the stock LockDiscount cannot revert here (each loan's commitment is released exactly once). It is reported because the mitigation is cheap and preserves the agreed design: wrap release in `try IDiscountModule(loan.module).release(id) {} catch {}` on the three terminal paths (the commitment is the module's own bookkeeping; a stuck commitment only over-locks that borrower's PAWN, which is far less harmful than a stuck seat and unrecoverable pool principal). The owner's remaining immediate powers (setNewLoansPaused, disableCollection, cancelChange, and the one-shot setQuestionHashOnce that selects the valuation question for a configured collection with no delay) are all documented in the README; none of them moves lender ETH, vault NFTs, locked PAWN or credited balances directly, and all valuation-affecting powers except the one-shot question hash wait 48 hours with a 7-day execution window.","line":437,"path":"src/PawnShop.sol","reproduction":"State: owner queues and, after 48 h, executes a discount module whose release(uint256) reverts (the test suite's ExpensiveModule pattern with `revert()` in release). alice then pawns seat #1 under that module. Expected: alice can always get her seat back by paying principal, and the pool can always resolve the loan after default. Actual: repay{value: principal}(id) reverts inside release; after due + 3 days startAuction succeeds, but buyAuction reverts for every buyer and writeOffAuction reverts after 40 days, so the seat and the 0.4 ETH of principal are stranded permanently.","severity":"info","snippet":"        IDiscountModule(loan.module).release(id);","title":"Trust gap: every terminal path hard-depends on the loan's discount module not reverting in release()"}],"hash":"f07498d2bb2d7d73b908433fec2b6999b4f96e52e29d375c1f4c18ccf5953e49","nodeId":"5e887d39-6c36-490e-86da-2dcc28c1a743","outcome":"completed","summary":"The review is complete. The findings are in `.imd-findings.json` at the repository root, the working tree is unchanged, and the only files I created are the findings file and two scratch tests under `test/scratch/`.\n\n**Outcome.** No critical or high defect. Nobody can take pool ETH or vault NFTs: every value-moving entry point on PawnShop, LendingPool, CollateralVault and LockDiscount is guarded by the right caller check, the vault never approves its collateral, `callFor` blocks the collection and re-checks ownership, and the pull-payment ledgers reconcile. Oracle verification is sound: request ids are consumed per consumer, the FloorRelay layout checks are exact (I re-derived every offset), wrong-signer and wrong-domain inputs are refused, and the ERC-1271 worker scope is limited to one registered typed digest. The auction curve, loss allowance and shortfall-reserve accounting conserve value in every path I traced. The existing 105 tests pass, and a probe confirmed the pool's `receive` guard fits inside mainnet WETH9's 2300-gas `transfer` stipend, which the mock suite does not exercise.\n\n**Findings, ranked.**\n\n- **Medium.** `startAuction` freezes the stored floor with no freshness check, and `buyAuction` works in the same transaction at 100% of it. Anyone can seize a defaulted seat at a stale-low price with no bidding window; the borrower loses surplus and, below principal, the pool books the gap. Proof test attached: it asserts a `StaleFloor` revert, fails on this tree, and passes with a one-line `floorFresh` check that I verified by patching and restoring `src/PawnShop.sol`.\n- **Low.** `pawn` has no minimum-principal or floor binding, so a newer floor landing first changes the committed loan and costs the borrower the fee to unwind.\n- **Low.** An unsold seat holds at 50% of the captured floor forever, including after write-off, so a stale-high capture strands saleable collateral while lenders absorb a total loss. The fix changes economics, so it is flagged as a scope decision.\n- **Low.** The vault's `isValidSignature` reverts instead of returning the failure selector when the seat no longer exists.\n- **Info.** All three terminal paths hard-depend on the loan module's `release` not reverting. This is a documented owner trust assumption; a try/catch mitigation preserves the design. The same entry summarises the owner's undelayed powers.\n\n**Coverage limits.** The identity collection's bytecode, the IMD pairing service and mainnet state were not available, so collection-side transfer restrictions and off-chain enrollment caching remain trust assumptions rather than findings. Slither and a fork were not run.","treeHash":null,"usage":{"cachedInputTokens":3488211,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":84014,"runtime":"claude","turns":49,"wallClockMs":1344286}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e6de8d4c6cf97551","findings":[{"citation":"resolved","description":"pawn(collection, tokenId, termId) takes no minimum principal, maximum fee or expected-floor argument. Principal and the fee (1-10% of principal, non-refundable, paid out of the proceeds) are computed from whatever floors[collection].price is stored when the transaction mines. submitFloor is permissionless and any keeper holding a genuine, newer IMD attestation can land it in front of a pending pawn. The borrower then receives a loan at terms they never saw: if the floor fell, a small loan and a locked seat they must repay to get back; if the floor rose, a much larger loan whose fee is proportionally larger and is kept even if they repay in the next block. There is no slippage guard, and a deadline would not substitute for one. Fix: add `uint256 minPrincipal` (and optionally `uint256 maxFee`) to pawn() and revert with a new error if `principal < minPrincipal || fee > maxFee`; the frontend passes the values it quoted. This preserves every other rule.","line":326,"path":"src/PawnShop.sol","reproduction":"Setup from test/helpers/PawnTestBase.sol (floor 1 ETH, term 0 = 30 days / 300 bps). Alice approves the shop and intends to borrow 0.4 ETH for a 0.012 ETH fee. Before her pawn mines, a keeper submits a valid attestation with price 10 ETH (issuedAt later than the stored one). Alice's pawn(IDENTITY_COLLECTION, 1, 0) then creates a loan with principal 4 ETH and fee 0.12 ETH (she is credited 3.88 ETH). Expected: the transaction reverts or honours a borrower-supplied bound. Actual: the loan opens; repaying it next block costs 4 ETH, so she is 0.12 ETH out of pocket instead of 0.012. Reverse case verified in scratch test: with a 0.1 ETH floor landing first, pawn returns principal 0.04 ETH (fee 0.0012 ETH) instead of 0.4 ETH.","severity":"low","snippet":"        uint256 principal = Math.mulDiv(floors[collection].price, ltv, 10000);","title":"pawn() has no principal or fee bound: a floor update that lands first silently changes the loan the borrower gets"},{"citation":"resolved","description":"startAuction pays AUCTION_BOUNTY (0.002 ETH) from the shared bountyReserve to whoever calls it, with no relation to the loan's size or to who defaulted. A borrower can open the minimum loan (principal 0.01 ETH, term 1 = 7 days / 100 bps, discounted to 50 bps with 20M PAWN locked so the fee is 0.00005 ETH), let it lapse, call startAuction from their own address to take the bounty, wait to the terminal 50% price and buy the seat back themselves. The principal they borrowed covers the recovered part of the price and the surplus comes back to them as borrower credit, so the only costs are the 0.00005 ETH fee and gas. Each cycle nets about 0.00195 ETH before gas, and many seats can run in parallel. The reserve is filled from protocol income and fundBounties() donations and also pays the floor-update bounty, so honest keepers lose their incentive once it is drained. Fix (keeps the bounty design): make the defaulted loan pay for its own auction start. In buyAuction, before crediting the borrower surplus, route `min(AUCTION_BOUNTY, price - recovered)` back into bountyReserve so a self-buying defaulter refunds what startAuction paid; alternatively withhold AUCTION_BOUNTY from the borrower's net proceeds at origination as a refundable deposit returned on repay and paid to the starter on default.","line":398,"path":"src/PawnShop.sol","reproduction":"Measured in a scratch Foundry test on PawnTestBase: bountyReserve 0.2 ETH, floor 0.025 ETH, alice locks 20,000,000 PAWN, pawns token 7 with term 1 -> principal 0.01 ETH, fee 0.00005 ETH, pawn gas 2,074,994. Warp to due + 3 days + 1 and alice calls startAuction (gas 136,490): her claimable rises by 0.002 ETH. Warp 10 days, terminal price 0.0125 ETH, alice calls buyAuction{value: 0.0125 ETH}(id, alice) (gas 222,673): she owns token 7 again and claimable totals 0.01445 ETH. Net cash +0.00195 ETH per cycle excluding gas (about 2.43M gas total, so profitable below roughly 0.8 gwei), and bountyReserve dropped by 0.002 ETH with only 0.0000075 ETH of her fee flowing back. Expected: a borrower cannot extract the bounty from their own default. Actual: they can, and 100 cycles empty the reserve.","severity":"low","snippet":"        _payBounty(msg.sender, AUCTION_BOUNTY);","title":"Auction-start bounty is farmable by self-defaulting the smallest permitted loan, draining the shared bounty reserve"},{"citation":"resolved","description":"ERC-1271 expects isValidSignature to return the failure value for an invalid signature. The vault calls IERC721(collection).ownerOf(tokenId) unguarded, so once the token is burned or otherwise nonexistent the call reverts with ERC721NonexistentToken rather than answering. OpenZeppelin's SignatureChecker treats a revert as invalid, but other ERC-1271 clients (including an off-chain pairing service doing an eth_call) see an error, not a refusal, and the vault already has holdsCollateral() with the try/catch for exactly this. Scope of the authorisation itself is correct: only the single registered WorkerAuthorization digest for this vault and token, while the loan is active and the seat is held, returns the magic value. Fix: replace the unguarded ownerOf comparison with `!holdsCollateral()` in isValidSignature (and in _requireActive if the same semantics are wanted).","line":123,"path":"src/CollateralVault.sol","reproduction":"Scratch test on PawnTestBase: alice pawns token 1 (term 0), calls vault.authorizeWorker with wallet = vault, tokenId = 1, a 1-day expiry; vault.isValidSignature(digest, \"\") returns 0x1626ba7e. Then the mock collection burns token 1 (nft.seize(1, address(0))). Expected: isValidSignature returns 0xffffffff. Actual: the call reverts with ERC721NonexistentToken(1).","severity":"info","snippet":"                || IERC721(collection).ownerOf(tokenId) != address(this)","title":"CollateralVault.isValidSignature reverts instead of returning 0xffffffff once the seat no longer exists"},{"citation":"resolved","description":"The only receiver validation is non-zero. If a buyer passes the vault address, CollateralVault.release performs transferFrom(vault, vault, tokenId), marks released = true and the loan becomes Sold. After that nothing can move the seat: callFor requires an active loan, release is one-shot, the shop has no rescue function and the vault never approves anyone. The same happens with receiver = collection for most ERC-721s. This is self-inflicted (the buyer loses what they paid), so it is informational, but the guard is one line and the loss is permanent. Fix: `if (receiver == address(0) || receiver == loan.vault) revert InvalidRecipient();` (optionally also `receiver == loan.collection`).","line":443,"path":"src/PawnShop.sol","reproduction":"Scratch test on PawnTestBase: alice pawns token 1 (term 1); warp to due + 3 days + 1; startAuction(id); buyer calls buyAuction{value: auctionPrice(id)}(id, loan.vault). Expected: revert InvalidRecipient. Actual: succeeds; nft.ownerOf(1) == vault, vault.released() == true, loan status Sold, and alice's callFor(target, \"\") now reverts InactiveLoan. The seat is unrecoverable by anyone.","severity":"info","snippet":"        if (receiver == address(0)) revert InvalidRecipient();","title":"buyAuction accepts the loan's own vault (or the collection) as receiver, stranding the seat with no recovery path"},{"citation":"resolved","description":"repay accepts exactly the principal at any time while the loan is Active, including arbitrarily long after due + GRACE, and there is no late fee or accrual. Resolution depends on someone calling startAuction; that caller is paid only while bountyReserve holds 0.002 ETH, so once the reserve is empty (see the bounty-farming finding) the only parties with an incentive are lenders themselves, who must spend gas. Meanwhile the principal is counted at par in LendingPool.totalAssets and the borrower keeps using the seat through callFor. The README documents repayment after expiry; what it does not state is that the lender-side cost of a late borrower is zero. Design options rather than a strict defect: accrue a per-day late fee on repay after due + GRACE (routed through _distributeFee), or require the borrower to pay one more term fee to repay once the grace period has passed.","line":359,"path":"src/PawnShop.sol","reproduction":"Scratch test on PawnTestBase: alice pawns token 1 with term 1 (7 days, 1%). Warp to due + 300 days with nobody calling startAuction. repay{value: 0.4 ether}(id) succeeds and token 1 returns to alice. Expected (as a lender): some cost for holding 0.4 ETH of pool capital 300 days past maturity. Actual: the only fee ever paid was the 0.004 ETH origination fee.","severity":"info","snippet":"        if (msg.value != loan.principal) revert IncorrectPayment();","title":"Overdue loans cost nothing after `due` until a third party starts the auction, so lender capital can sit at 0% indefinitely"},{"citation":"resolved","description":"Documented in the README and recorded here as the actor and preconditions, not as a bypass. IDENTITY_COLLECTION is enabled at construction with 40% LTV and a 100% share limit; setQuestionHashOnce is onlyOwner with no timelock; submitFloor accepts any attestation signed by the configured signer (after the planned FloorRelay switch, any zero-consumer IMD attestation) whose questionHash matches. The owner can therefore pin a question whose genuine uint256 answer is about 2.5x the pool's totalAssets, submit the matching attestation, pawn any seat and borrow the entire idle balance in the same hour, before lenders can react. The 48-hour queues on attester, collection and module changes do not cover this path. The signer rotation (48h) is similarly the only guard on MilestoneBurn, which mirrors the shop's signer. If the requester wants the delay to cover it, route the initial hash through _queue (keccak256(abi.encode(\"collection\", collection)) kind) or keep newLoansPaused forced true for DELAY after any hash write; both change the agreed one-shot design and need a scope decision.","line":240,"path":"src/PawnShop.sol","reproduction":"State: pool holds 10 ETH idle, newLoansPaused false, collections[IDENTITY_COLLECTION].questionHash == 0. Owner calls setQuestionHashOnce(IDENTITY_COLLECTION, H) where H is the hash of a question answered 25e18 (chain 1, panel 5, quorum 4, agreed 4, 26h lifetime). Anyone submits that attestation via submitFloor. Owner pawns one seat with term 1: principal = 25e18 * 4000 / 10000 = 10 ETH <= totalAssets, borrow(10 ETH) succeeds, owner is credited 9.9 ETH. Expected by lenders: a 48-hour window to exit before a valuation change. Actual: none for the first hash.","severity":"info","snippet":"        c.questionHash = hash;","title":"Trust assumption: the one-shot question hash is immediate, so the owner can size a single loan to the pool's idle ETH with no delay"},{"citation":"resolved","description":"PawnShop._execute refuses a queued change after queuedAt + EXECUTION_WINDOW (7 days) so a stale payload must be re-queued and re-announced. The pool's cap raise only checks the lower bound, so a cap queued once can be executed by anyone at any later time, long after lenders stopped watching the queue. Cap increases are the pool owner's intended power and only raise the limit, so this is a consistency note. Fix: mirror the shop: `if (block.timestamp > pendingCapAt + EXECUTION_WINDOW) revert TimelockPending();` with EXECUTION_WINDOW = 7 days.","line":330,"path":"src/LendingPool.sol","reproduction":"Owner calls queueDepositCap(1000 ether) at T. Nobody executes. At T + 730 days anyone calls executeDepositCap(). Expected (by analogy with PawnShop.executeTerm, which reverts TimelockPending after T + 48h + 7d): revert. Actual: depositCap becomes 1000 ether.","severity":"info","snippet":"        if (pendingCapAt == 0 || block.timestamp < pendingCapAt) revert TimelockPending();","title":"LendingPool.executeDepositCap has no execution window, unlike every PawnShop change"}],"hash":"fa9f30a9746a9405da319f8a10f73af34cb95e4a0fd5254afe6382bd29d3b991","nodeId":"78db48da-a048-4ff6-be62-2c4c2c094f73","outcome":"completed","summary":"Review complete. The findings are written to `.imd-findings.json` (seven entries, all snippets verified against the tree), the scratch tests are removed, and no other file was touched.\n\n**Overall verdict.** I found no critical or high defect, so no proof tests are attached. Pool ETH only leaves through share redemptions bounded by idle cash or through `borrow`, which only the shop can call; vault seats only leave through the shop's one-shot `release` on repayment or sale. Oracle verification pins the question hash, chain, panel size, quorum, 26-hour age and signed lifetime, consumes request ids, and requires strictly newer timestamps, and the relay re-derives the caller's own EIP-712 domain before checking the IMD key with low-s recovery. The vault's ERC-1271 answers only the single registered worker digest for its own token while the loan is active and the seat is held. Auction pricing is continuous and monotone, losses are marked monotonically and settled against the reserve without double counting, lock commitments are released on every closing path, and every owner power except pause, disable, cancel and the first question hash waits 48 hours with a 7-day execution window.\n\n**What I reported, by severity:**\n- **Low: no principal or fee bound on `pawn()`.** A genuine newer attestation landing first changes the loan silently. Verified: a 0.1 ETH floor turned an expected 0.4 ETH loan into 0.04 ETH; a 10 ETH floor would charge a 0.12 ETH fee instead of 0.012. Fix is a `minPrincipal` and optional `maxFee` argument.\n- **Low: auction bounty farmable by self-default.** Measured: a 0.01 ETH loan with 20M PAWN locked costs 0.00005 ETH in fees and nets 0.00195 ETH of shared bounty reserve per cycle before gas. Fix is to refund the bounty out of the sale proceeds before the borrower's surplus.\n- **Info:** the vault's `isValidSignature` reverts rather than refusing once the seat is burned; `buyAuction` lets a buyer name the vault itself as receiver and strand the seat forever; overdue loans accrue nothing until someone starts the auction; the pool's cap raise has no execution window; and the immediate one-shot question hash, combined with the relay, lets the owner size one loan to all idle ETH with no delay. The last item is recorded as the trust assumption the README already states, with the actor and inputs spelled out.\n\n**Coverage and limits.** All six named contracts plus the shared oracle library and pull-payment base were read in full and walked through the entry-point, invariant, asymmetry, boundary, execution-trace and first-principles passes from the supplied guides. The build and my scratch tests ran under the project's Foundry profile. No static analyser ran. I confirmed the live identity collection on mainnet is a plain ERC-721 with 2000 seats, no burn, pause or lock selectors, and an unusable owner, which narrows the collateral-trust caveats but does not remove them. The oracle question-window compatibility question in the review notes remains an open operational item I could not settle from the code.","treeHash":null,"usage":{"cachedInputTokens":2412621,"inputTokens":420,"model":"claude-fable-5-1","outputTokens":84949,"runtime":"claude","turns":37,"wallClockMs":1548171}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"918f8261a6fd589c","findings":[{"citation":"resolved","description":"pawn() and extend() refuse a floor older than FLOOR_MAX_AGE (26 h), but startAuction() copies floors[collection].price with no freshness check and buyAuction() can be called in the same transaction at elapsed == 0, i.e. at 100% of that stored number. Floors only change when someone pays for a new oracle answer, so after a quiet month the stored price can sit far below the market. Whoever notices this can, strictly after due + GRACE, call startAuction and buyAuction back to back and take the seat at the stale price with no block of public visibility. The borrower loses the surplus a current floor would have returned (surplus = price - principal), and when the stale floor is below principal the pool books the gap as a loss. The README says the auction 'freezes the last stored floor, even if stale', but the shop's own lending rule treats that number as unusable, and the consequence is an instant third-party arbitrage against the borrower. Merged from audit_permissions. Fix (design-preserving): add `if (!floorFresh(loan.collection)) revert StaleFloor();` to startAuction, so a default is priced from a floor the shop would lend against; anyone can refresh it for the existing 0.001 ETH floor bounty. Trade-off: auctions then depend on oracle liveness; if the requester prefers no such dependency, open at max(stored floor, principal * 10000 / ltv) when the floor is stale and forbid buyAuction in the block that started the auction.","line":396,"path":"src/PawnShop.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 {PawnShop} from \"src/PawnShop.sol\";\nimport {LendingPool} from \"src/LendingPool.sol\";\nimport {LaunchToken} from \"src/LaunchToken.sol\";\nimport {OracleAttestation} from \"src/OracleAttestation.sol\";\n\ncontract StaleWETH is ERC20 {\n    constructor() ERC20(\"Wrapped Ether\", \"WETH\") {}\n\n    function deposit() external payable {\n        _mint(msg.sender, msg.value);\n    }\n\n    function withdraw(uint256 amount) external {\n        _burn(msg.sender, amount);\n        (bool ok,) = msg.sender.call{value: amount}(\"\");\n        require(ok);\n    }\n}\n\ncontract StaleSeat is ERC721 {\n    constructor() ERC721(\"Seat\", \"SEAT\") {}\n\n    function mint(address to, uint256 id) external {\n        _mint(to, id);\n    }\n}\n\n/// Atomic start-and-buy of a defaulted seat at a stale stored floor.\ncontract StaleFloorAuctionTest is Test {\n    uint256 constant KEY = 0xA11CE;\n    bytes32 constant Q = keccak256(\"floor question\");\n    address owner = makeAddr(\"owner\");\n    address alice = makeAddr(\"borrower\");\n    address bob = makeAddr(\"lender\");\n    address attacker = makeAddr(\"attacker\");\n    LaunchToken token;\n    StaleWETH weth;\n    PawnShop shop;\n    LendingPool pool;\n    StaleSeat nft;\n    uint256 nonce;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        vm.deal(alice, 10 ether);\n        vm.deal(bob, 10 ether);\n        vm.deal(attacker, 10 ether);\n        token = new LaunchToken();\n        weth = new StaleWETH();\n        shop = new PawnShop(owner, address(token), address(weth), vm.addr(KEY));\n        pool = shop.lendingPool();\n        StaleSeat template = new StaleSeat();\n        vm.etch(shop.IDENTITY_COLLECTION(), address(template).code);\n        nft = StaleSeat(shop.IDENTITY_COLLECTION());\n        vm.startPrank(owner);\n        shop.setQuestionHashOnce(address(nft), Q);\n        shop.setNewLoansPaused(false);\n        vm.stopPrank();\n        vm.prank(bob);\n        pool.depositETH{value: 5 ether}(bob);\n    }\n\n    function _floor(uint256 price) internal {\n        OracleAttestation.Attestation memory a;\n        a.requestId = keccak256(abi.encode(\"floor\", ++nonce));\n        a.chainId = 1;\n        a.questionHash = Q;\n        a.answerType = 3;\n        a.answer = abi.encode(price);\n        a.panelSize = 5;\n        a.quorum = 4;\n        a.agreed = 4;\n        a.issuedAt = uint64(vm.getBlockTimestamp());\n        a.expiresAt = uint64(vm.getBlockTimestamp() + 26 hours);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, shop.attestationDigest(a));\n        shop.submitFloor(address(nft), a, abi.encodePacked(r, s, v));\n    }\n\n    function test_defaultedSeatCannotBeSeizedAtStaleFloor() public {\n        _floor(1 ether);\n        nft.mint(alice, 1);\n        vm.startPrank(alice);\n        nft.approve(address(shop), 1);\n        uint256 id = shop.pawn(address(nft), 1, 0); // principal 0.4 ETH against a 1 ETH floor\n        vm.stopPrank();\n\n        // A legitimate dip is posted the next day, then nobody refreshes the floor for a month.\n        vm.warp(vm.getBlockTimestamp() + 1 days);\n        _floor(0.5 ether);\n        vm.warp(shop.getLoan(id).due + 3 days + 1);\n        assertFalse(shop.floorFresh(address(nft)), \"stored floor is 29 days old\");\n\n        // Expected: a default cannot be priced from a floor the shop itself refuses to lend\n        // against. Actual: anyone starts the auction at the stale 0.5 ETH and buys the seat in\n        // the same transaction, leaving the borrower 0.1 ETH instead of the 0.6 ETH a current\n        // 1 ETH floor would return.\n        vm.startPrank(attacker);\n        vm.expectRevert(PawnShop.StaleFloor.selector);\n        shop.startAuction(id);\n        vm.stopPrank();\n\n        // Once a fresh floor is posted the auction opens at the current valuation.\n        _floor(1 ether);\n        vm.startPrank(attacker);\n        shop.startAuction(id);\n        assertEq(shop.auctionPrice(id), 1 ether);\n        shop.buyAuction{value: 1 ether}(id, attacker);\n        vm.stopPrank();\n        assertEq(nft.ownerOf(1), attacker);\n        assertEq(shop.claimable(alice), 0.388 ether + 0.6 ether);\n    }\n}","reproduction":"Lender deposits 5 ETH; floor 1 ETH signed; alice pawns seat #1 at term 0 (principal 0.4 ETH, credited 0.388 ETH). One day later a 0.5 ETH floor is posted and nobody refreshes afterwards. At due + 3 days + 1 s floorFresh() is false (floor is 29 days old). Attacker calls startAuction(id) then buyAuction{value: 0.5 ether}(id, attacker) in one transaction. Expected: startAuction reverts StaleFloor until a fresh floor is posted; with a fresh 1 ETH floor the auction opens at 1 ETH and alice is credited 0.6 ETH surplus. Actual: auctionPrice is 0.5 ETH at elapsed 0, the attacker receives the seat, the pool recovers 0.4 ETH and alice is credited only 0.1 ETH. Reproduced with test/scratch/Proof_fba9bc1cb98d.t.sol (fails: 'next call did not revert as expected').","severity":"medium","snippet":"        loan.auctionFloor = floors[loan.collection].price;","title":"startAuction freezes a stale floor and buyAuction may follow in the same transaction, so a defaulted seat can be taken atomically at an outdated price"},{"citation":"resolved","description":"auctionPrice() reads loan.auctionFloor, which startAuction sets once from floors[collection].price (no freshness requirement) and nothing ever updates. After ten days the price is pinned at half of that number for the rest of time. writeOffAuction clears the debt but leaves status = Auction and the same auctionFloor, and the vault only releases through buyAuction, so if the collection's market falls more than 50% after the auction starts (or the captured floor was already stale and high) no rational buyer ever appears: the pool realises 100% of the principal as loss (reserve first, then cumulativeLoss), the PAWN commitment is released, and a seat that still has real market value sits in its vault with no path to liquidity and no owner rescue. Only a floor drop after default is needed; no admin action. Merged from audit_economics and audit_permissions. The README documents the 50% terminal level and 'remains for sale at the original auction curve' but not that the combination can strand recoverable value permanently. Fix (both options change economics and need a scope decision): (a) after writeOffAuction, or once an auction has sat at the terminal price for N days, let anyone call restartAuction(id), which requires floorFresh(loan.collection), sets auctionStarted = block.timestamp and auctionFloor = floors[collection].price, and re-runs the same curve, with proceeds still flowing through receiveRecovery / borrower surplus; or (b) keep declining past day ten (e.g. linearly to 0 by day 40) so the terminal price cannot sit above any market forever.","line":415,"path":"src/PawnShop.sol","reproduction":"PawnTestBase setup (bob 5 ETH). Submit floor 10 ETH, alice pawns token 1 at term 1 (principal 4 ETH). Warp to due + 3 days + 1 and startAuction (auctionFloor = 10 ETH, fresh at that moment). Submit floor 1 ETH. Warp 40 days and writeOffAuction: cumulativeLoss = 4 ETH. Observed in test/scratch/Judge.t.sol::test_strandedAt50pct: shop.auctionPrice(id) = 5 ETH immediately after write-off and still 5 ETH one year later; nft.ownerOf(1) is still the vault; buyAuction{value: 1 ether} (the real market price) reverts IncorrectPayment. Expected: after write-off the pool can still realise the roughly 1 ETH the seat is worth. Actual: the seat is unsellable forever and the pool recovers nothing.","severity":"medium","snippet":"        return Math.mulDiv(floor, 5000, 10000, Math.Rounding.Ceil);","title":"Unsold collateral is never re-priced: the auction holds at 50% of the floor captured at auction start forever, including after write-off"},{"citation":"resolved","description":"auctionPrice() returns 0 whenever CollateralVault.holdsCollateral() is false at that instant (ownerOf reverts or reports another address). buyAuction() then accepts msg.value = 0 from anyone, sets status = Sold, settles the whole principal as a loss, releases the PAWN commitment and calls vault.release(), which sets released = true and skips delivery. release() is one-shot, the loan is no longer in Auction, and the vault has no other exit path, so if the collection's ownership report was only transiently wrong (a revocation later reversed, a paused or upgrading proxy whose ownerOf reverts for a few blocks, a seizure returned to the holder) the seat comes back to a vault that can never release it. The borrower loses all surplus, the pool books the full principal although the collateral exists again, and the seat is bricked. The README documents zero-price settlement for missing collateral and lists collection ownership reporting as a trust assumption, and the live identity collection has no seizure path, so this is rated low: it needs a collection that can misreport, e.g. an owner-listed collection or a future upgrade. writeOffAuction() already provides the financial settlement path for missing collateral without finalising custody, so the zero-price purchase is not needed for liveness. Fix: in buyAuction revert when !vault.holdsCollateral() (equivalently when price == 0 because collateral is missing); keep writeOffAuction as the immediate financial settlement for missing collateral, so a later return of the seat can still be sold on the original curve and recovered via receiveRecovery. test_seizedNFTZeroPriceBooksGapWithoutPayingIssuer would then use writeOffAuction.","line":405,"path":"src/PawnShop.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 {LaunchToken} from \"src/LaunchToken.sol\";\nimport {PawnShop} from \"src/PawnShop.sol\";\nimport {LendingPool} from \"src/LendingPool.sol\";\nimport {OracleAttestation} from \"src/OracleAttestation.sol\";\n\ncontract ZWETH is ERC20 {\n    constructor() ERC20(\"Wrapped Ether\", \"WETH\") {}\n\n    function deposit() external payable {\n        _mint(msg.sender, msg.value);\n    }\n\n    function withdraw(uint256 amount) external {\n        _burn(msg.sender, amount);\n        (bool ok,) = msg.sender.call{value: amount}(\"\");\n        require(ok);\n    }\n}\n\n/// Seat collection whose issuer can temporarily reassign (e.g. a revocation later reversed).\ncontract ZSeat is ERC721 {\n    constructor() ERC721(\"Seat\", \"SEAT\") {}\n\n    function mint(address to, uint256 id) external {\n        _mint(to, id);\n    }\n\n    function seize(uint256 id, address to) external {\n        _update(to, id, address(0));\n    }\n}\n\ncontract ZeroPriceStrandTest is Test {\n    uint256 internal constant KEY = 0xA11CE;\n    bytes32 internal constant Q = keccak256(\"floor question\");\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    address internal bob = makeAddr(\"bob\");\n    address internal griefer = makeAddr(\"griefer\");\n    address internal buyer = makeAddr(\"buyer\");\n    address internal issuer = makeAddr(\"issuer\");\n    PawnShop internal shop;\n    LendingPool internal pool;\n    ZSeat internal nft;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        vm.deal(bob, 100 ether);\n        vm.deal(buyer, 100 ether);\n        LaunchToken token = new LaunchToken();\n        ZWETH weth = new ZWETH();\n        shop = new PawnShop(owner, address(token), address(weth), vm.addr(KEY));\n        pool = shop.lendingPool();\n        vm.etch(shop.IDENTITY_COLLECTION(), address(new ZSeat()).code);\n        nft = ZSeat(shop.IDENTITY_COLLECTION());\n        vm.prank(owner);\n        shop.setQuestionHashOnce(address(nft), Q);\n        OracleAttestation.Attestation memory a;\n        a.requestId = keccak256(\"r1\");\n        a.chainId = 1;\n        a.questionHash = Q;\n        a.answerType = 3;\n        a.answer = abi.encode(uint256(1 ether));\n        a.panelSize = 5;\n        a.quorum = 4;\n        a.agreed = 4;\n        a.issuedAt = uint64(block.timestamp);\n        a.expiresAt = uint64(block.timestamp + 26 hours);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, shop.attestationDigest(a));\n        shop.submitFloor(address(nft), a, abi.encodePacked(r, s, v));\n        vm.prank(owner);\n        shop.setNewLoansPaused(false);\n        vm.prank(bob);\n        pool.depositETH{value: 5 ether}(bob);\n    }\n\n    function test_transientlyUnreportedSeatCanStillBeAuctionedOnceItReturns() public {\n        nft.mint(alice, 1);\n        vm.startPrank(alice);\n        nft.approve(address(shop), 1);\n        uint256 id = shop.pawn(address(nft), 1, 0);\n        vm.stopPrank();\n        address vault = shop.getLoan(id).vault;\n\n        vm.warp(block.timestamp + 30 days + 3 days + 1);\n        shop.startAuction(id);\n\n        // The collection briefly reports another owner (revocation later reversed, upgrade, etc.).\n        nft.seize(1, issuer);\n        // Anyone can \"buy\" the auction for zero during that window; today this finalizes the sale.\n        vm.prank(griefer);\n        try shop.buyAuction(id, griefer) {} catch {}\n        // Ownership is restored to the vault.\n        nft.seize(1, vault);\n        assertEq(nft.ownerOf(1), vault);\n\n        // Expected: the seat is still saleable at the auction curve and the pool recovers principal.\n        uint256 price = shop.auctionPrice(id); // currently reverts NotAuctioning: loan is already Sold\n        assertGt(price, 0);\n        vm.prank(buyer);\n        shop.buyAuction{value: price}(id, buyer);\n        assertEq(nft.ownerOf(1), buyer);\n    }\n}","reproduction":"Floor 1 ETH, pool 5 ETH from bob. alice pawns seat #1 (term 0, principal 0.4 ETH). Warp due + 3 days + 1 s; startAuction(1). Collection reports ownerOf(1) = issuer (seize). griefer calls buyAuction(1, griefer) with 0 ETH: succeeds, emits CollateralUnavailable, pool records loss 0.4 ETH, loan status Sold, vault.released = true. Collection then reports ownerOf(1) = vault again. Expected: the seat is still saleable on the curve (auctionPrice(1) > 0; a buyer paying it receives the NFT and the pool recovers 0.4 ETH). Actual: auctionPrice(1) reverts NotAuctioning(), buyAuction reverts, and the NFT is held forever by a released vault. Reproduced with test/scratch/Proof_037673e06f2f.t.sol (fails: NotAuctioning()). Note for the fixer: this proof starts its auction 33 days after the last floor; if the stale-floor fix (fresh floor required by startAuction) is applied first, post a fresh floor before startAuction in the fixture, since the proof targets this finding's fix only.","severity":"low","snippet":"        if (!CollateralVault(payable(loan.vault)).holdsCollateral()) return 0;","title":"Zero-price auction 'sale' while ownerOf misreports permanently strands the seat in its vault and books the full principal as loss"},{"citation":"resolved","description":"PawnShop.markAuctionLoss() is permissionless and marks loss = principal - auctionPrice(). If the collateral is momentarily unreported, auctionPrice() is 0 and the full principal is marked. LendingPool.markAuctionLoss() then forbids any decrease (line 220, `if (loss < auctionLoss[id]) revert`), so the over-marked allowance stays deducted from totalAssets() even after the seat is back and saleable. When the seat is later bought at a positive price, settleAuction() removes the whole allowance and adds the recovered ETH in the same transaction, so totalAssets() jumps up immediately instead of vesting like fees, donations and late recoveries do. Anyone can deposit just before buyAuction (or bundle deposit + buyAuction + redeem) and take part of the recovery that belongs to the lenders who bore the marked loss; lenders who exited while the allowance was overstated were also paid too little. In the normal path the curve only declines, so the marked loss never exceeds the realised loss; the jump needs the same transient misreport as the zero-price finding, hence low. Fix: in settleAuction compute the realised loss (principal - msg.value) and, when the released allowance exceeds it, route the difference through _vest() instead of releasing it at once; and/or allow markAuctionLoss to lower the allowance while holdsCollateral() is true so a transient zero reading cannot stick.","line":227,"path":"src/LendingPool.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 {LaunchToken} from \"src/LaunchToken.sol\";\nimport {PawnShop} from \"src/PawnShop.sol\";\nimport {LendingPool} from \"src/LendingPool.sol\";\nimport {OracleAttestation} from \"src/OracleAttestation.sol\";\n\ncontract ZWETH is ERC20 {\n    constructor() ERC20(\"Wrapped Ether\", \"WETH\") {}\n\n    function deposit() external payable {\n        _mint(msg.sender, msg.value);\n    }\n\n    function withdraw(uint256 amount) external {\n        _burn(msg.sender, amount);\n        (bool ok,) = msg.sender.call{value: amount}(\"\");\n        require(ok);\n    }\n}\n\n/// Seat collection whose issuer can temporarily reassign (e.g. a revocation later reversed).\ncontract ZSeat is ERC721 {\n    constructor() ERC721(\"Seat\", \"SEAT\") {}\n\n    function mint(address to, uint256 id) external {\n        _mint(to, id);\n    }\n\n    function seize(uint256 id, address to) external {\n        _update(to, id, address(0));\n    }\n}\n\ncontract RatchetedLossReleaseTest is Test {\n    uint256 internal constant KEY = 0xA11CE;\n    bytes32 internal constant Q = keccak256(\"floor question\");\n    address internal owner = makeAddr(\"owner\");\n    address internal alice = makeAddr(\"alice\");\n    address internal bob = makeAddr(\"bob\");\n    address internal griefer = makeAddr(\"griefer\");\n    address internal buyer = makeAddr(\"buyer\");\n    address internal issuer = makeAddr(\"issuer\");\n    PawnShop internal shop;\n    LendingPool internal pool;\n    ZSeat internal nft;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        vm.deal(bob, 100 ether);\n        vm.deal(buyer, 100 ether);\n        LaunchToken token = new LaunchToken();\n        ZWETH weth = new ZWETH();\n        shop = new PawnShop(owner, address(token), address(weth), vm.addr(KEY));\n        pool = shop.lendingPool();\n        vm.etch(shop.IDENTITY_COLLECTION(), address(new ZSeat()).code);\n        nft = ZSeat(shop.IDENTITY_COLLECTION());\n        vm.prank(owner);\n        shop.setQuestionHashOnce(address(nft), Q);\n        OracleAttestation.Attestation memory a;\n        a.requestId = keccak256(\"r1\");\n        a.chainId = 1;\n        a.questionHash = Q;\n        a.answerType = 3;\n        a.answer = abi.encode(uint256(1 ether));\n        a.panelSize = 5;\n        a.quorum = 4;\n        a.agreed = 4;\n        a.issuedAt = uint64(block.timestamp);\n        a.expiresAt = uint64(block.timestamp + 26 hours);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, shop.attestationDigest(a));\n        shop.submitFloor(address(nft), a, abi.encodePacked(r, s, v));\n        vm.prank(owner);\n        shop.setNewLoansPaused(false);\n        vm.prank(bob);\n        pool.depositETH{value: 5 ether}(bob);\n    }\n\n    function test_ratchetedFullLossReleasesUnvestedOnSale() public {\n        nft.mint(alice, 1);\n        vm.startPrank(alice);\n        nft.approve(address(shop), 1);\n        uint256 id = shop.pawn(address(nft), 1, 0);\n        vm.stopPrank();\n        address vault = shop.getLoan(id).vault;\n        vm.warp(block.timestamp + 30 days + 3 days + 1);\n        shop.startAuction(id);\n        emit log_named_uint(\"reserve\", pool.shortfallReserve());\n        nft.seize(1, issuer);\n        shop.markAuctionLoss(id); // full principal marked\n        nft.seize(1, vault);\n        emit log_named_uint(\"expectedLoss\", pool.expectedAuctionLoss());\n        emit log_named_uint(\"assetsBefore\", pool.totalAssets());\n        address jit = makeAddr(\"jit\");\n        vm.deal(jit, 4 ether);\n        vm.prank(jit);\n        uint256 shares = pool.depositETH{value: 4 ether}(jit);\n        uint256 price = shop.auctionPrice(id);\n        vm.prank(buyer);\n        shop.buyAuction{value: price}(id, buyer);\n        emit log_named_uint(\"assetsAfter\", pool.totalAssets());\n        uint256 out = pool.previewRedeem(shares);\n        emit log_named_uint(\"jitRedeemable\", out);\n        vm.prank(jit);\n        pool.redeemETH(shares, jit, jit);\n        assertLe(out, 4 ether, \"same-block depositor captured the released allowance\");\n    }\n}","reproduction":"Same setup (bob 5 ETH, alice's 0.4 ETH loan, shortfallReserve 0 because protocol fees fill the bounty reserve first). After startAuction the collection briefly reports another owner; anyone calls shop.markAuctionLoss(1) -> expectedAuctionLoss = 0.4 ETH, totalAssets = 4.6102 ETH. Ownership returns to the vault. In one block a JIT account calls depositETH{4 ETH}, the buyer calls buyAuction at the curve price (1 ETH; pool recovers 0.4 ETH), totalAssets becomes 9.0102 ETH, and the JIT account redeems all its shares for 4.185826113214559475 ETH. Expected: <= 4 ETH (no same-block gain). Actual: +0.1858 ETH taken from bob. Reproduced with test/scratch/Proof_3d1b940e6a64.t.sol (fails: 4185826113214559475 > 4000000000000000000). Note for the fixer: this proof starts its auction 33 days after the last floor; if the stale-floor fix (fresh floor required by startAuction) is applied first, post a fresh floor before startAuction in the fixture, since the proof targets this finding's fix only.","severity":"low","snippet":"        expectedAuctionLoss -= auctionLoss[id];","title":"Ratcheted full-principal loss allowance is released in one step on sale, letting a same-block depositor capture the recovery from existing lenders"},{"citation":"resolved","description":"LendingPool.totalAssets() carries every Active loan at full principal. The first impairment of a defaulted loan is booked here, inside startAuction, which anyone may call only strictly after due + 3 days (GRACE). Between a public floor collapse (submitFloor is permissionless and visible) and that call there is a window of at least three days, and the whole remaining term before it, in which the share price ignores a loss that is already certain from public data. Any lender who watches floors[collection] withdraws at par during that window (maxWithdraw is bounded only by idleAssets), and the entire loss lands on whoever is left. The same gap persists after an auction has started, because expectedAuctionLoss is refreshed only when someone calls markAuctionLoss, and extend() lets an underwater borrower keep a loan Active indefinitely for a weekly fee with no LTV recheck. The README documents that active loans remain at principal and that an earlier withdrawal may avoid a later loss, so this is a known limitation rather than a bypass, and is rated low. Fix (keeps grace, fees and the curve): add a permissionless markOverdue(id) usable from loan.due onward that books principal - min(principal, floors[collection].price / 2) through the existing markAuctionLoss path (keyed by loan id, non-decreasing), so the loss is visible as soon as the loan is late. A short withdrawal cooldown in LendingPool and an LTV check in extend() would close the rest but change lender/borrower UX and need a scope decision.","line":397,"path":"src/PawnShop.sol","reproduction":"PawnTestBase: bob and buyer each deposit 5 ETH (pool 10 ETH). Floor 10 ETH; alice pawns token 1 at term 1 (principal 4 ETH). Floor 2 ETH is submitted. Warp to loan.due + 1 (overdue, inside GRACE). Observed in test/scratch/Judge.t.sol::test_lenderExitsAtParBeforeAuction: pool.totalAssets() = 10.034 ETH and pool.maxWithdraw(bob) = 5.016999999999999999 ETH; bob withdraws that at par. Warp to due + 3 days + 1 and startAuction: expectedAuctionLoss = 2 ETH and pool.maxWithdraw(buyer) = 1.017 ETH; after the sale at the 1 ETH terminal price buyer's shares are worth 2.017 ETH. Expected: the 3 ETH loss implied by public state is shared 1.5 / 1.5. Actual: bob 0, buyer 3 ETH.","severity":"low","snippet":"        lendingPool.markAuctionLoss(id, loan.principal, auctionPrice(id));","title":"Default losses are recognised only at startAuction, so a lender who watches the floor exits at par and leaves the whole loss to the remaining lenders"},{"citation":"resolved","description":"pawn(collection, tokenId, termId) carries no parameter binding the borrower to the valuation they saw. Principal and the non-refundable fee (1-10% of principal, taken out of the proceeds) are computed from whatever floors[collection].price is stored when the transaction mines. submitFloor is permissionless and any keeper holding a genuine, newer attestation (anyone can buy one for 0.5 IMD) can land it in front of a pending pawn. If the floor fell, the borrower gets a small loan and a locked seat they must repay to recover, forfeiting the fee and the vault-deployment gas; if the floor rose, they get a much larger loan whose fee is proportionally larger and is kept even if they repay next block. Merged from audit_flow and audit_permissions. Fix: add `uint256 minPrincipal` (and optionally `uint256 maxFee`) to pawn() and revert with a new error if `principal < minPrincipal || fee > maxFee`; or accept the `floorIssuedAt` the caller expects and revert if floors[collection].issuedAt differs. The frontend already reads the floor it displays and can pass it through. Every other rule is preserved.","line":326,"path":"src/PawnShop.sol","reproduction":"PawnTestBase (floor 1 ETH, term 0 = 30 days / 300 bps). Alice approves the shop intending to borrow 0.4 ETH for a 0.012 ETH fee. Before her pawn mines, a keeper submits a valid attestation with price 10 ETH (issuedAt later than the stored one). Observed in test/scratch/Judge.t.sol::test_pawnNoPrincipalBound: alice's pawn(IDENTITY_COLLECTION, 1, 0) creates a loan with principal 4 ETH and fee 0.12 ETH (she is credited 3.88 ETH). Expected: the transaction reverts or honours a borrower-supplied bound. Actual: the loan opens; repaying it next block costs 4 ETH, so she is 0.12 ETH out of pocket instead of 0.012. The reverse case (0.5 ETH floor landing first) yields principal 0.2 ETH, fee 0.006 ETH, and a net loss of 0.006 ETH plus two transactions' gas to unwind.","severity":"low","snippet":"        uint256 principal = Math.mulDiv(floors[collection].price, ltv, 10000);","title":"pawn() takes no minimum-principal or maximum-fee bound, so a floor update that lands first silently changes the loan the borrower committed to"},{"citation":"resolved","description":"startAuction pays 0.002 ETH from bountyReserve to msg.sender with no relation to the loan's size or to who defaulted. The borrower may be that caller, and in buyAuction the surplus price - principal is credited back to the borrower, so a borrower who buys back their own seat pays net exactly principal, which they already received at origination. The cycle (pawn, claim, let it lapse, startAuction, buyAuction at any price, claim) nets AUCTION_BOUNTY - fee and leaves them with the seat. For term 1 (100 bps) that is positive whenever principal < 0.2 ETH (floor < 0.5 ETH), or principal < 0.4 ETH with 20M PAWN locked (50% discount); at MIN_LOAN the fee is 0.0001 ETH against a 0.002 ETH bounty. The reserve is protocol income (15% of fees) plus fundBounties donations and also pays the floor-update bounty, so repeated cycles across many seats or an owner-listed low-floor collection empty it and starve honest keepers. Not profitable today for the identity collection at a floor near 1.9 ETH, but it becomes so after a floor crash or for any collection with a floor under 0.5 ETH. Merged from audit_flow and audit_economics. Fix (keeps the bounty design): pay the bounty in startAuction only if msg.sender != loan.borrower and cap it at min(AUCTION_BOUNTY, principal / 100); or in buyAuction route min(AUCTION_BOUNTY, price - recovered) back into bountyReserve before crediting the borrower surplus so a self-buying defaulter refunds what startAuction paid.","line":398,"path":"src/PawnShop.sol","reproduction":"PawnTestBase; shop.fundBounties{value: 0.2 ether}(); submit floor 0.025 ETH. alice: approve, pawn(nft, 1, 1) (principal 0.01 ETH, fee 0.0001 ETH), claim; warp to due + 3 days + 1; startAuction(id) from alice; buyAuction{value: auctionPrice(id)}(id, alice); claim. Observed in test/scratch/Judge.t.sol::test_bountyFarm: alice's ETH balance is 0.0019 ETH higher than before the cycle, nft.ownerOf(1) == alice, bountyReserve fell by 0.002 ETH. Expected: a defaulting borrower never ends a cycle with more ETH than they started with. Actual: +0.0019 ETH per cycle; about 100 cycles empty the reserve.","severity":"low","snippet":"        _payBounty(msg.sender, AUCTION_BOUNTY);","title":"AUCTION_BOUNTY can exceed a small loan's fee, so a borrower profits from a self-default cycle and drains the shared bounty reserve"},{"citation":"resolved","description":"Freshness is enforced only on a.issuedAt / a.expiresAt (when the oracle signed) and never on a.fromBlock / a.toBlock (what the answer is about). floorFresh() then treats the price as current for 26 hours. docs/review-notes.md item 2 states that the pinned questionHash commits to the resolved block window, so every attestation matching the pinned hash necessarily reports the floor as of that historical window; a freshly issued signature over an old window passes every check. Even with a relative-window question, a re-signed or late-delivered answer with toBlock thousands of blocks in the past is accepted. Lending and auction-start prices can then be based on a floor days or weeks old while the contract reports it as fresh; any oracle purchaser can submit it and the borrower side benefits when the stale price is above market. The docs already flag the window question as unresolved and advise keeping loans paused, so this is a hardening item rather than a bypass. Fix: add `|| a.fromBlock > a.toBlock || a.toBlock + MAX_BLOCK_AGE < block.number` (MAX_BLOCK_AGE about 26 hours of blocks, 7800 on mainnet) to the InvalidAttestation condition, and document that the pinned question must be a standing relative-window question so the hash does not freeze a window.","line":293,"path":"src/PawnShop.sol","reproduction":"PawnTestBase at block 30,000,000: build _attestation(FLOOR_QUESTION, 10 ether) with a.fromBlock = 1, a.toBlock = 2, a.issuedAt = block.timestamp, a.expiresAt = issuedAt + 26 hours, sign with the shop's signer and call submitFloor. Observed in test/scratch/Judge.t.sol::test_oldWindowAccepted: it succeeds, floorFresh() returns true, and pawn() lends 4 ETH against it. Expected: an answer whose closing block is older than the freshness window is rejected.","severity":"low","snippet":"                || block.timestamp - a.issuedAt > FLOOR_MAX_AGE || a.issuedAt <= floors[collection].issuedAt","title":"submitFloor bounds the signature's age but not the answer's block window, so an attestation about an old window is accepted as a fresh floor"},{"citation":"resolved","description":"writeOffAuction settles the full principal at zero and _settle covers the gap from shortfallReserve first. When the seat later sells, buyAuction routes min(price, principal) to receiveRecovery, which vests the money to lenders via _receiveIncome; nothing credits shortfallReserve back, even though the loss the reserve paid for has now been recovered in full. The reserve, built from the protocol's 15% fee share (the fee recipient's income), becomes permanent lender profit, the next fees are diverted again to refill it, and the pool is unprotected for the next default until then. The README sends recoveries to lenders by design, so this is an accounting inconsistency rather than a bypass. Fix: record reserveUsed[id] in settleAuction (the covered amount per loan), give receiveRecovery the loan id, and move min(msg.value, reserveUsed[id]) back into shortfallReserve before vesting the remainder.","line":251,"path":"src/LendingPool.sol","reproduction":"PawnTestBase; the shop forwards 0.3 ETH to pool.addReserve (shortfallReserve = 0.3). alice pawns token 1 at term 1 (principal 0.4 ETH). Warp past due + 3 days, startAuction, warp 40 days, writeOffAuction. Observed in test/scratch/Judge.t.sol::test_reserveNotRestored: shortfallReserve = 0, cumulativeLoss = 0.1 ETH. buyAuction{value: 0.5 ether}: cumulativeRecoveries = 0.4 ETH, shortfallReserve still 0; after 7 days pool.totalAssets() = 5.3034 ETH versus 5 ETH deposited plus 0.0034 ETH fees, i.e. the 0.3 ETH reserve is now lender value. Expected: recovery first repays what the reserve covered (0.3 ETH), then 0.1 ETH vests to lenders.","severity":"low","snippet":"    function receiveRecovery() external payable onlyShop nonReentrant {","title":"A shortfall reserve consumed at write-off is never restored when the collateral later sells; the protocol reserve leaks to current lenders"},{"citation":"resolved","description":"The expected-totalAssets expression subtracts unvestedDonations and cumulativeLoss before adding cumulativeRecoveries. After a loan is written off (cumulativeLoss += principal) and then sold (cumulativeRecoveries += principal, which is also unvested for seven days), the running value goes below zero and the checked subtraction panics even though the pool's book is right. forge test on this tree fails: 104 passed, 1 failed. A red invariant test gives no regression protection for the accounting it is meant to pin, and the suite cannot be used as a gate. Fix: reorder so all additions come first: 5 ether + deposits + donations + fees + pool.cumulativeRecoveries() - withdrawals - unvested - cumulativeLoss - (expectedAuctionLoss > reserve ? ... : 0).","line":186,"path":"test/PawnInvariant.t.sol","reproduction":"Run `forge test --match-contract PawnInvariantTest` (default config, fail_on_revert = true). Observed on this tree: invariant_poolBookMatchesCashAndDebt reports `panic: arithmetic underflow or overflow (0x11)` with a shrunk sequence advanceAndPublish, pawn, pawn, advanceAndPublish, repayOrAuction(..., true, ...), repayOrAuction(..., true, ...) (a write-off followed by a sale); the other two invariants pass. Expected: the formula evaluates and equals pool.totalAssets().","severity":"low","snippet":"                - handler.ghostWithdrawals() - pool.unvestedDonations() - pool.cumulativeLoss()","title":"invariant_poolBookMatchesCashAndDebt underflows on write-off-then-sale sequences, so the Pawn invariant suite fails spuriously and cannot guard regressions"},{"citation":"resolved","description":"burn() accepts any valid attestation with answer >= 1e24 issued up to 26 hours earlier and consumes the entire vault balance permanently. The configured question (web/src/oracle.ts CAP_QUESTION, docs/SETUP-AND-KEEPER.md) prices FDV from the Uniswap v4 pool's spot price at answer time, so one momentary reading is enough: push the thin PAWN/ETH pool for the block the oracle reads, buy an answer, and submit it any time in the next 26 hours, even after the price has fallen back. The floor feed uses the same window, but there a stale value is replaced the next day; here the action cannot be undone. Fix: shorten the accepted age for burn (e.g. <= 1 hour) and pin a question that uses a time-weighted price, or require two consumed answers separated by a minimum interval that both meet the milestone.","line":53,"path":"src/MilestoneBurn.sol","reproduction":"At T a signed FDV answer of 1_000_000e18 is issued (issuedAt = T, expiresAt = T + 26h, valid panel) and the vault holds 100 PAWN. At T + 25h 59m burn(a, sig) is called. Observed in test/scratch/Judge.t.sol::test_burnAcceptsOldAttestation: the burn succeeds and moves the whole balance to 0x...dEaD. Expected: the milestone requires a current FDV >= $1M, not a day-old single reading.","severity":"low","snippet":"                || a.agreed > a.panelSize || a.issuedAt > block.timestamp || block.timestamp - a.issuedAt > 26 hours","title":"MilestoneBurn fires its irreversible one-shot burn on a single FDV reading up to 26 hours old"},{"citation":"resolved","description":"PawnShop._execute refuses a queued change after queuedAt + EXECUTION_WINDOW (7 days) so a stale payload must be re-queued and re-announced, and the owner can cancel. The pool's cap raise only checks the lower bound and has no cancel, so a cap queued once can be executed by anyone at any later time, long after lenders stopped watching the queue. Cap increases are the pool owner's intended power and only raise the limit, so this is a consistency gap in the delay story rather than a bypass. Merged from audit_flow and audit_economics. Fix: mirror the shop: `if (block.timestamp > pendingCapAt + EXECUTION_WINDOW) revert TimelockPending();` with EXECUTION_WINDOW = 7 days, plus an onlyOwner cancelDepositCap().","line":330,"path":"src/LendingPool.sol","reproduction":"Owner calls queueDepositCap(1000 ether) at T. Nobody executes. At T + 730 days anyone calls executeDepositCap(). Observed in test/scratch/Judge.t.sol::test_capNoWindow: depositCap becomes 1000 ether. Expected (by analogy with PawnShop.executeTerm, which reverts TimelockPending after T + 48h + 7d): revert.","severity":"low","snippet":"        if (pendingCapAt == 0 || block.timestamp < pendingCapAt) revert TimelockPending();","title":"LendingPool.executeDepositCap has no execution window and no cancel, unlike every PawnShop change"},{"citation":"resolved","description":"ERC-1271 expects isValidSignature to return the failure value for an invalid signature. The vault calls IERC721(collection).ownerOf(tokenId) unguarded, so once the token is burned or nonexistent the call reverts with ERC721NonexistentToken rather than answering. OpenZeppelin's SignatureChecker treats a revert as invalid, but other ERC-1271 clients (including an off-chain pairing service doing an eth_call) see an error, not a refusal. The vault already has holdsCollateral() with the try/catch for exactly this. The authorisation scope itself is correct: only the single registered WorkerAuthorization digest for this vault and token, while the loan is active and the seat is held, returns the magic value. Merged from audit_flow and audit_permissions. Fix: replace the unguarded ownerOf comparison with `!holdsCollateral()` in isValidSignature.","line":123,"path":"src/CollateralVault.sol","reproduction":"PawnTestBase: alice pawns token 1 (term 0), calls vault.authorizeWorker with wallet = vault, tokenId = 1, a 1-day expiry; vault.isValidSignature(digest, \"\") returns 0x1626ba7e. Then the collection burns token 1 (nft.seize(1, address(0))). Observed in test/scratch/Judge.t.sol::test_isValidSignatureRevertsAfterBurn: a staticcall to isValidSignature fails with revert data 0x7e273289...0001 (ERC721NonexistentToken(1)). Expected: it returns 0xffffffff.","severity":"info","snippet":"                || IERC721(collection).ownerOf(tokenId) != address(this)","title":"CollateralVault.isValidSignature reverts instead of returning 0xffffffff once the seat no longer exists"},{"citation":"resolved","description":"The only receiver validation is non-zero. If a buyer passes the vault address, CollateralVault.release performs transferFrom(vault, vault, tokenId), marks released = true and the loan becomes Sold. After that nothing can move the seat: callFor requires an active loan, release is one-shot, the shop has no rescue function and the vault never approves anyone. The same happens with receiver = collection for most ERC-721s. Self-inflicted (the buyer loses what they paid), so informational, but the guard is one line and the loss is permanent. Fix: `if (receiver == address(0) || receiver == loan.vault) revert InvalidRecipient();` (optionally also `receiver == loan.collection`).","line":443,"path":"src/PawnShop.sol","reproduction":"PawnTestBase: alice pawns token 1 (term 1); warp to due + 3 days + 1; startAuction(id); buyer calls buyAuction{value: auctionPrice(id)}(id, loan.vault). Observed in test/scratch/Judge.t.sol::test_buyAuctionReceiverVault: succeeds; nft.ownerOf(1) == vault, vault.released() == true, loan status Sold. Expected: revert InvalidRecipient.","severity":"info","snippet":"        if (receiver == address(0)) revert InvalidRecipient();","title":"buyAuction accepts the loan's own vault as receiver, stranding the seat with no recovery path"},{"citation":"resolved","description":"Recorded as the actor and preconditions, not as a bypass; no path was found that changes a timelocked setting without its delay. Immediate owner powers: setNewLoansPaused, disableCollection (also cancels that collection's queued change), cancelChange, and setQuestionHashOnce for a collection whose hash is still zero (the identity collection at launch, enabled in the constructor with 40% LTV and a 100% share limit). The question hash is the single input that prices all collateral; submitFloor accepts any attestation signed by the configured signer (after the FloorRelay switch, any zero-consumer IMD attestation) whose questionHash matches. The owner can therefore pin a question whose genuine uint256 answer is about 2.5x the pool's totalAssets, submit the matching attestation, pawn any seat and borrow the entire idle balance in the same hour, before lenders can react. 48-hour, 7-day-window powers: terms (bounded 7-90 days, 50-1000 bps), collection config including LTV (<= 4000), share, seat flag and hash rotation (rotation zeroes floor expiry so lending pauses until a new matching floor), attester (any address including an owner-controlled ERC-1271 contract; MilestoneBurn mirrors it), fee recipient, discount module. Vault control is limited to the borrower and the immutable shop. The README documents all of this ('initial question selection has no delay'). Merged from audit_flow and audit_economics. Hardening that preserves the design, if the requester wants the delay to cover the first hash: route it through _queue (the keccak256(abi.encode(\"collection\", collection)) kind, which executeCollection already supports) or keep newLoansPaused forced true for DELAY after any hash write; both change the agreed one-shot design and need a scope decision.","line":240,"path":"src/PawnShop.sol","reproduction":"State: fresh PawnShop, pool holds 10 ETH idle, collections[IDENTITY].questionHash == 0. Owner calls setQuestionHashOnce(IDENTITY, H) and setNewLoansPaused(false) in one block; anyone submits an attestation for H answering 25e18 (chain 1, panel 5, quorum 4, agreed 4, 26 h lifetime). Owner pawns one seat with term 1. Observed in test/scratch/Judge.t.sol::test_oneShotHashSizesLoanToIdle: principal = 25e18 * 4000 / 10000 = 10 ETH, borrow(10 ETH) succeeds, pool.idleAssets() == 0, owner is credited 9.9 ETH. Expected by lenders: a 48-hour window to exit before a valuation change. Actual: none for the first hash.","severity":"info","snippet":"        c.questionHash = hash;","title":"Owner powers and delays (trust assumptions): the one-shot question hash is immediate and can size a single loan to the pool's entire idle ETH; all other valuation levers wait 48 h"},{"citation":"resolved","description":"repay (line 363), buyAuction on a non-written-off loan (line 454) and writeOffAuction (line 437) all make an unguarded external call to loan.module.release(id). The module is chosen by the owner through a 48-hour timelock and _validateModule only checks two view functions, so a module whose release() reverts (or consumes all gas) makes every loan opened under it impossible to repay, sell or write off: the seat stays in its vault with released == false, totalBorrowed and collectionDebt never decrease, and only markAuctionLoss still works. The README states 'a malicious discount module can still block its own release call', so this is a documented trust assumption rather than a permission bypass, and the stock LockDiscount cannot revert here (each loan's commitment is released exactly once). Reported because the mitigation is cheap and preserves the design: wrap release in `try IDiscountModule(loan.module).release(id) {} catch {}` on the three terminal paths; a stuck commitment only over-locks that borrower's PAWN, far less harmful than a stuck seat and unrecoverable pool principal.","line":437,"path":"src/PawnShop.sol","reproduction":"Owner queues and, after 48 h, executes a module whose release(uint256) reverts (commit returns the base fee). alice then pawns seat #1 under it (principal 0.4 ETH). Observed in test/scratch/Judge.t.sol::test_revertingModuleBlocksTerminalPaths: repay{value: 0.4 ether}(id) reverts inside release; after due + 3 days startAuction succeeds, but buyAuction reverts for every buyer and writeOffAuction reverts after 40 days. Expected: alice can always get her seat back by paying principal and the pool can always resolve the loan after default. Actual: seat and principal are stranded permanently.","severity":"info","snippet":"        IDiscountModule(loan.module).release(id);","title":"Trust gap: every terminal path hard-depends on the loan's discount module not reverting in release()"}],"hash":"fdee41842a28306e6933b04d016c4282b2848b6b103a318a21b5ab07cb4e9129","nodeId":"427ddc69-7831-4ce5-afd8-7ae93dee4acb","outcome":"completed","summary":"The review is complete. The findings file holds 16 verified findings, and the working tree is otherwise untouched.\n\n**Outcome.** All 22 specialist findings were reproduced against the code, either by running the attached proof or by a scratch Foundry test I wrote. None was dropped for failing to reproduce. Five duplicate pairs were merged, and one documented design choice was dropped as not a defect. No critical or high issue exists: no path lets an outsider take pool ETH or vault NFTs, oracle replay and signer checks hold, the ERC-1271 worker scope is correctly limited, and no timelocked setting can be changed without its delay.\n\n**Kept, ranked:**\n- **Medium:** startAuction freezes a stale floor and buyAuction can follow in the same transaction, so a third party takes a defaulted seat at an outdated price. Proof kept.\n- **Medium:** unsold collateral is pinned at 50% of the captured floor forever, including after write-off, so recoverable value is stranded with no re-pricing path.\n- **Low (8):** zero-price sale while ownerOf misreports permanently strands the seat (proof kept); over-marked loss allowance released in one step lets a same-block depositor capture recovery (proof kept); losses recognised only at startAuction so informed lenders exit at par; pawn has no principal bound; auction bounty farmable by self-default; attestation block window age unbounded; shortfall reserve never restored after recovery; the invariant suite underflows and currently fails; MilestoneBurn accepts a 26-hour-old reading; executeDepositCap has no window or cancel.\n- **Info (4):** isValidSignature reverts after burn; buyAuction accepts the vault as receiver; owner trust assumptions including the immediate one-shot question hash; a reverting discount module blocks all terminal paths.\n\n**Merges and drops.** The pawn-bound, bounty-farming, isValidSignature, stranded-collateral, and owner-powers findings each appeared twice and are now one entry. The \"overdue loans cost nothing\" item was dropped because the README documents repayment after expiry and no rule is violated.\n\n**Verification detail.** The three specialist proofs fail on the current code for the stated reasons. In a temporary copy outside the repository, each passes once only its own fix is applied. Applying the stale-floor fix alongside the other two makes those proofs' fixtures need a fresh floor before startAuction. That note is recorded on both findings. The existing suite shows the invariant failure the economics specialist reported.","treeHash":null,"usage":{"cachedInputTokens":1682796,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":43250,"runtime":"claude","turns":32,"wallClockMs":668069}}],"verification":[]}