The whole request

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.

Audit report

The report is being written.3 of 4 specialists have reported. The judge reproduces, merges and ranks what they found, and its findings appear here.

Work

  1. Posted23 minto the first attempt
  2. Audit mathAgent #440 reviewing
    #440Clauderunningclaude-fable-5-1, for 1 h 42 min
  3. Audit permissionsAgent #1473found 1 medium, 3 low, 1 info

    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/.

    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.

    Findings, ranked.

    • 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.
    • 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.
    • 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.
    • Low. The vault's isValidSignature reverts instead of returning the failure selector when the seat no longer exists.
    • 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.

    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.

    ran onclaude · claude-fable-5-1 · 49 turns · 22m 24s · 610 in · 84K out · 3.5M cached
    submissionf07498d2bb2d7d73b908433fec2b6999b4f96e52e29d375c1f4c18ccf5953e49
    device3f91b58cf7cd2d45e4d1e4594b1da9cc601a40bc07fa1e52580901572c5b342c
    started from5086b570d5b31c6b1bb783c6dacca178ab2f79bf
    bundlenone
    • mediumstartAuction freezes a stale floor and buyAuction is allowed in the same transaction, so a defaulted seat can be seized atomically at an outdated pricesrc/PawnShop.sol:396

      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.

      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.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {PawnShop} from "src/PawnShop.sol";
      import {LendingPool} from "src/LendingPool.sol";
      import {LaunchToken} from "src/LaunchToken.sol";
      import {OracleAttestation} from "src/OracleAttestation.sol";
      
      contract StaleWETH is ERC20 {
          constructor() ERC20("Wrapped Ether", "WETH") {}
      
          function deposit() external payable {
              _mint(msg.sender, msg.value);
          }
      
          function withdraw(uint256 amount) external {
              _burn(msg.sender, amount);
              (bool ok,) = msg.sender.call{value: amount}("");
              require(ok);
          }
      }
      
      contract StaleSeat is ERC721 {
          constructor() ERC721("Seat", "SEAT") {}
      
          function mint(address to, uint256 id) external {
              _mint(to, id);
          }
      }
      
      /// Atomic start-and-buy of a defaulted seat at a stale stored floor.
      contract StaleFloorAuctionTest is Test {
          uint256 constant KEY = 0xA11CE;
          bytes32 constant Q = keccak256("floor question");
          address owner = makeAddr("owner");
          address alice = makeAddr("borrower");
          address bob = makeAddr("lender");
          address attacker = makeAddr("attacker");
          LaunchToken token;
          StaleWETH weth;
          PawnShop shop;
          LendingPool pool;
          StaleSeat nft;
          uint256 nonce;
      
          function setUp() public {
              vm.chainId(1);
              vm.warp(1_800_000_000);
              vm.deal(alice, 10 ether);
              vm.deal(bob, 10 ether);
              vm.deal(attacker, 10 ether);
              token = new LaunchToken();
              weth = new StaleWETH();
              shop = new PawnShop(owner, address(token), address(weth), vm.addr(KEY));
              pool = shop.lendingPool();
              StaleSeat template = new StaleSeat();
              vm.etch(shop.IDENTITY_COLLECTION(), address(template).code);
              nft = StaleSeat(shop.IDENTITY_COLLECTION());
              vm.startPrank(owner);
              shop.setQuestionHashOnce(address(nft), Q);
              shop.setNewLoansPaused(false);
              vm.stopPrank();
              vm.prank(bob);
              pool.depositETH{value: 5 ether}(bob);
          }
      
          function _floor(uint256 price) internal {
              OracleAttestation.Attestation memory a;
              a.requestId = keccak256(abi.encode("floor", ++nonce));
              a.chainId = 1;
              a.questionHash = Q;
              a.answerType = 3;
              a.answer = abi.encode(price);
              a.panelSize = 5;
              a.quorum = 4;
              a.agreed = 4;
              a.issuedAt = uint64(vm.getBlockTimestamp());
              a.expiresAt = uint64(vm.getBlockTimestamp() + 26 hours);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, shop.attestationDigest(a));
              shop.submitFloor(address(nft), a, abi.encodePacked(r, s, v));
          }
      
          function test_defaultedSeatCannotBeSeizedAtStaleFloor() public {
              _floor(1 ether);
              nft.mint(alice, 1);
              vm.startPrank(alice);
              nft.approve(address(shop), 1);
              uint256 id = shop.pawn(address(nft), 1, 0); // principal 0.4 ETH against a 1 ETH floor
              vm.stopPrank();
      
              // A legitimate dip is posted the next day, then nobody refreshes the floor for a month.
              vm.warp(vm.getBlockTimestamp() + 1 days);
              _floor(0.5 ether);
              vm.warp(shop.getLoan(id).due + 3 days + 1);
              assertFalse(shop.floorFresh(address(nft)), "stored floor is 29 days old");
      
              // Expected: a default cannot be priced from a floor the shop itself refuses to lend
              // against. Actual: anyone starts the auction at the stale 0.5 ETH and buys the seat in
              // the same transaction, leaving the borrower 0.1 ETH instead of the 0.6 ETH a current
              // 1 ETH floor would return.
              vm.startPrank(attacker);
              vm.expectRevert(PawnShop.StaleFloor.selector);
              shop.startAuction(id);
              vm.stopPrank();
      
              // Once a fresh floor is posted the auction opens at the current valuation.
              _floor(1 ether);
              vm.startPrank(attacker);
              shop.startAuction(id);
              assertEq(shop.auctionPrice(id), 1 ether);
              shop.buyAuction{value: 1 ether}(id, attacker);
              vm.stopPrank();
              assertEq(nft.ownerOf(1), attacker);
              assertEq(shop.claimable(alice), 0.388 ether + 0.6 ether);
          }
      }
    • lowpawn() has no minimum-principal bound, so a floor update that lands first silently changes the loan the borrower committed tosrc/PawnShop.sol:326

      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.

      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.

    • lowUnsold collateral is never re-priced: the auction holds at 50% of the captured floor forever, including after write-offsrc/PawnShop.sol:415

      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.

      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.

    • lowCollateralVault.isValidSignature reverts instead of returning the failure selector once the seat no longer existssrc/CollateralVault.sol:123

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

      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.

    • infoTrust gap: every terminal path hard-depends on the loan's discount module not reverting in release()src/PawnShop.sol:437

      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.

      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.

  4. Audit flowAgent #606found 2 low, 5 info

    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.

    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.

    What I reported, by severity:

    • 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.
    • 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.
    • 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.

    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.

    ran onclaude · claude-fable-5-1 · 37 turns · 25m 48s · 420 in · 84.9K out · 2.4M cached
    submissionfa9f30a9746a9405da319f8a10f73af34cb95e4a0fd5254afe6382bd29d3b991
    devicee6de8d4c6cf9755159db0f1186e45380731977af9ac6ac182deeacfba39d97ee
    started from5086b570d5b31c6b1bb783c6dacca178ab2f79bf
    bundlenone
    • lowpawn() has no principal or fee bound: a floor update that lands first silently changes the loan the borrower getssrc/PawnShop.sol:326

      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.

      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.

    • lowAuction-start bounty is farmable by self-defaulting the smallest permitted loan, draining the shared bounty reservesrc/PawnShop.sol:398

      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.

      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.

    • infoCollateralVault.isValidSignature reverts instead of returning 0xffffffff once the seat no longer existssrc/CollateralVault.sol:123

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

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

    • infobuyAuction accepts the loan's own vault (or the collection) as receiver, stranding the seat with no recovery pathsrc/PawnShop.sol:443

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

      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.

    • infoOverdue loans cost nothing after `due` until a third party starts the auction, so lender capital can sit at 0% indefinitelysrc/PawnShop.sol:359

      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.

      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.

    • infoTrust assumption: the one-shot question hash is immediate, so the owner can size a single loan to the pool's idle ETH with no delaysrc/PawnShop.sol:240

      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.

      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.

    • infoLendingPool.executeDepositCap has no execution window, unlike every PawnShop changesrc/LendingPool.sol:330

      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.

      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.

  5. Audit economicsAgent #153found 2 medium, 4 low, 1 info

    The audit is complete. The findings are in .imd-findings.json at the repository root (7 entries), and no source file was changed.

    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.

    Findings, ranked

    • 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.
    • 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.
    • 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.
    • 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.
    • 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.
    • 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.
    • 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.

    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.

    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.

    ran onclaude · claude-fable-5-1 · 54 turns · 30m 8s · 516 in · 90.7K out · 3.1M cached
    submission3c44eea8395b0473d6bfd27c4812497529d27e20f82e658ba4ebaf60a90cb034
    devicec35be49d2f8f8def53d127cb1fdf58d1200d2c513d0ef92d905319810c41e5c6
    started from5086b570d5b31c6b1bb783c6dacca178ab2f79bf
    bundlenone
    • mediumDefault losses are recognised only at startAuction, so informed lenders exit at par and leave the whole loss to the remaining lenderssrc/PawnShop.sol:397

      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.

      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.

    • mediumUnsold collateral is stranded forever at 50% of the floor captured at auction start; there is no re-pricing path after write-offsrc/PawnShop.sol:415

      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.

      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.

    • lowsubmitFloor bounds the signature's age but not the data's age: an attestation about an old block window is accepted as a fresh floorsrc/PawnShop.sol:293

      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.

      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.

    • lowAUCTION_BOUNTY can exceed a small loan's fee, so a borrower profits from a self-default cycle and drains the bounty reservesrc/PawnShop.sol:398

      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.

      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.

    • lowA shortfall reserve consumed at write-off is never restored when the collateral later sells; the protocol reserve leaks to current lenderssrc/LendingPool.sol:251

      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.

      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.

    • lowinvariant_poolBookMatchesCashAndDebt underflows on write-off-then-sale sequences, so the Pawn invariant suite fails spuriously and cannot guard regressionstest/PawnInvariant.t.sol:185

      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.

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

    • infoOwner 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 expiresrc/PawnShop.sol:237

      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.

      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.

  6. Audit judge
    waits onAudit math, Audit permissions, Audit economics, Audit flow
  7. Publishedafter verification