{"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":"eae3f6bb-aa74-4c4f-96ea-d8802ffef11f","kind":"audit","nodes":[{"acceptedSubmissionHash":"656be5e22b3f3cf9191d1095d0106c871485c3f74efb51cf6d0abe9632770ace","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":"3b441d533e1a2ea7f0117b5b2e9fce74d12178a961adbda5c609701867dfbdfe","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":"8e18f67802160bacd6d2a1d859a8ea455ab2300729eba67f4379bd9fc3be46a5","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":"cabd5e8669e3af71202c38e92fc7c61cda6314146432cb52f7d9eae1fcf247cd","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":"b29e34b4a280e17fe50aeb71d75025c2b7e9392112890e48416a8fd2458f8417","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":"Basket (BASK) is an immutable index vault for Stock Tokens on Robinhood Chain (chain id 4663), deployed at 0x4e19d7472e650399b06eeaa5ccc29da9b8efbebd with nothing listed yet. A user deposits one or more listed tokens in one call, each priced by its feed, and receives BASK; redeem burns BASK for a pro-rata share of every held token, paid at once while at most directLimit (50) assets are held, otherwise booked as owed and collected with claim(tokens[], to). A deposit also needs, for every deposited and every held unretired token, its Uniswap v3 pool's 30-minute mean price (in quote tokens times the quote feed, with a mean-liquidity floor) within 3% of its feed; a token with no pool needs a feed under 26 hours old instead. The pool only blocks; it never sets the price. One owner and one guardian; owner changes are proposals that wait 2 days, then only the owner executes them, and they lapse 7 days later; the guardian can cancel any except its own replacement. Settings change only by proposal within fixed bounds. Trusted: the owner pairs each token with its true feed, pool and quote feed. The issuer can pause, block, burn or upgrade the Stock Tokens; feeds update only on weekdays. This is the fifth build, written fresh from the text. The fourth was audited; the changes since: a retired asset is skipped by every deposit check and left out of NAV even while it still holds tokens (managed > 0), and accepts only a Resync proposal; the direct gas rule uses 70,000 and directLimit starts at 50; a token with a pool set has no valid price when observe fails or mean liquidity is under minLiquidity (no fallback; the 26-hour rule applies only with no pool); flagDeficit clears the record when the asset is no longer short.\n\nLook hardest at:\n\n1. Redeem and claim can never be blocked or made to revert: not by the owner, the guardian, any in-bounds setting or combination of settings (balanceGas, payGas, directLimit, maxAssets), a paused, blacklisted, reverting, gas-burning, lying or upgraded token, a stale or wrong feed, pool or quote feed, retirement or removal. With maxAssets assets in any state a redeem must stay under 28,000,000 gas, on both the direct and the booked path. Check heldCount and the held bitmap (including removeRetired's swap-and-pop), the vault-only pay function and the owed and totalOwed accounting.\n\n2. The pool check in BaskOracle (pool, price, feed, read) and TickMath: token0/token1 orientation, 6-decimal USDG and 18-decimal WETH quotes, the harmonic-mean liquidity against minLiquidity, poolGas, overflow and rounding. Does a set pool that fails, is drained or is under its floor always block rather than fall back? Can a pool, quote feed or feed make deposit, depositStatus, previewDeposit or allAssets revert instead of returning a reason, or change the number of shares minted?\n\n3. Nobody can move assets out except redeem and claim paying the user, and nobody can mint BASK except deposit (plus the fee shares and the 1e15 dead shares on the first deposit). No fee may be charged while feeRecipient is unset; once set, exactly 0.5% in and 0.5% out. Look at every proposal action, execute, Resync (can it count owed tokens into managed, or be abused on a retired asset?), removeRetired, close, flagDeficit, recognizeLoss and reentrancy.\n\n4. Proposals: can anyone but the owner execute; can one skip the 2 days or escape the guardian's cancel; can a voided, expired or stale proposal execute after a retire, a removal and relisting, a later close (Reopen) or a NAV cap lowering; can a retired asset take any proposal except Resync; can a setting leave its bounds or break the two gas rules (maxAssets x (balanceGas + 60,000) and directLimit x (balanceGas + payGas + 70,000) at most 28,000,000); can the guardian become owner by any sequence.\n\n5. Deposit share math: rounding direction, first-deposit and donation attacks, managed versus balance, the rule that a deposited token's balance must cover totalOwed, retired assets left out of NAV and every deposit check, flagDeficit (recording and clearing) and recognizeLoss after an issuer burn or a recovery, and the inline assembly under via_ir (BaskOracle.read and balance, _tokenCall, the Transfer log, the self-calls in redeem and claim).\n\n6. Anything the code does that the text above does not say, or that the text says and the code does not do.\n\nAccepted by the owner, report only if worse than stated here: profit from feed lag within the 3% pool deviation; an asset with no pool has no 3% bound while its feed is under 26 hours; no per-asset limit (one stock may be any share of NAV); anyone can stop deposits by moving a thin pool; a held asset whose pool fails or is under its floor stops deposits until the pool recovers or a Pool proposal executes; a held token with no pool stops deposits at weekends; depositors after a retire share its tokens; no fee while the fee recipient is unset; tokens the issuer credits by raising balances stay outside managed until a Resync, and depositors during its 2-day wait share them; an unreadable balance during a shortfall books the leg from managed and claims are paid first come, first served; a complete loss leaves NAV at 0 and deposits stop; the two-step ownership handover takes effect at once; a token upgraded to debit more than the amount strands its claims; the guardian cannot cancel its own replacement; a retired asset that ever held tokens keeps a dust balance, so its slot is in practice not freed and it counts toward directLimit; one wei in each of more than directLimit assets sends every redemption to the booked path; a receiver that cannot call claim cannot collect a booked leg; redemption minimums are positional; BASK sent to the vault's own address is lost; a broken quote feed with an absurd answer makes pricing revert; a deposit with close to 250 held assets may not fit one transaction when pools are busy. Operating rules the owner follows: pause deposits before proposing a Resync and never resync before the first deposit; keep each pool's observation cardinality above poolWindow and poolGas at 150,000; fund a replacement before retiring the last held stock; call flagDeficit on an asset whose shortfall has recovered; pause deposits as soon as any asset is short.","parentJobId":null,"planHash":"c6fc59cd5524756e87fdd37387cddb8992c3168d8257c90fc04200634388533b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"eae3f6bb-aa74-4c4f-96ea-d8802ffef11f","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":"50952","feedbackHash":"0d74f445ade0b5634123b0c40d1e335a4037150a18cc422f08410a7c6ddd0802","nodeKey":"audit_economics","submissionHash":"656be5e22b3f3cf9191d1095d0106c871485c3f74efb51cf6d0abe9632770ace","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51018","feedbackHash":"5f67a17c357130239842784704fc3ce96e88c6ad7e5d34a880d5790cff3257f3","nodeKey":"audit_flow","submissionHash":"3b441d533e1a2ea7f0117b5b2e9fce74d12178a961adbda5c609701867dfbdfe","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52082","feedbackHash":"bdfae3b0318824acaa80cdfa068f3f2d868927ded5b96779ea2e7e93d24100ac","nodeKey":"audit_judge","submissionHash":"8e18f67802160bacd6d2a1d859a8ea455ab2300729eba67f4379bd9fc3be46a5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51742","feedbackHash":"0e11b8d8f1951072fa1d7a6839c4181f49f4657d3d6d52d104af76c45d1d7688","nodeKey":"audit_math","submissionHash":"cabd5e8669e3af71202c38e92fc7c61cda6314146432cb52f7d9eae1fcf247cd","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51337","feedbackHash":"2abb9189e3562601896596f5e23c56a131d32f380050e6f833c2b130670e59e6","nodeKey":"audit_permissions","submissionHash":"b29e34b4a280e17fe50aeb71d75025c2b7e9392112890e48416a8fd2458f8417","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"7a38a050924b7588e47e5f2ca17531269f1d97fed4bfa3d09e599eafbf783dbc","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"30a6c1a419ef4f9c","findings":[{"citation":"resolved","description":"Every state-changing entry point is protected by nonReentrant, but the view functions previewRedeem, previewDeposit, depositStatus and allAssets are not guarded and do not check the `entered` flag. redeem() reduces totalSupply (line 652) before the per-asset loop reduces managed[] one asset at a time and makes an external token transfer through pay() for each held asset. If a Stock Token's transfer (which the issuer can upgrade, per the brief) calls back into previewRedeem, the view divides the still-unreduced managed[] of the not-yet-processed assets by the already-reduced supply and reports an inflated per-share entitlement (about 2x for a 50% redemption). Symmetrically, during deposit() the token pulls (line 565) increase managed[] for earlier input tokens before any shares are minted, so previewDeposit/allAssets quoted from a transferFrom callback see an overstated NAV per share and under-quote shares. The vault's own accounting is not corrupted, so this does not affect the redeem/claim/deposit guarantees in the brief; the exposure is to any contract that prices BASK through these views (lending collateral, a secondary-market quoter, a keeper) and can be invoked inside a hooked Stock Token transfer. Minimal fix: add a view-side guard (`if (entered != 1) revert Reentrancy();`) to previewRedeem, previewDeposit, depositStatus and allAssets, or structure redeem so that supply and all managed reductions are applied before any external call.","line":794,"path":"src/BaskVault.sol","proof":"// SPDX-License-Identifier: GPL-2.0-or-later\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BaskVault} from \"src/BaskVault.sol\";\nimport {BaskTypes as T} from \"src/BaskTypes.sol\";\n\n/// @dev A Stock Token whose transfer calls back into a vault view (an issuer upgrade can do this).\ncontract HookedToken {\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    BaskVault public vault;\n    uint256 public observedLeg1;\n    bool public viewReverted;\n    bool public armed;\n\n    function setVault(BaskVault v) external {\n        vault = v;\n    }\n\n    function arm() external {\n        armed = true;\n    }\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        if (armed) {\n            armed = false;\n            // Mid-redeem view read: supply already reduced, later assets' managed not yet reduced.\n            try vault.previewRedeem(1e18) returns (uint256[] memory amounts, uint256) {\n                observedLeg1 = amounts[1];\n            } catch {\n                viewReverted = true;\n            }\n        }\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract PlainToken {\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract Feed {\n    uint8 public constant decimals = 8;\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 100e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract ReadOnlyReentrancyTest is Test {\n    BaskVault vault;\n    HookedToken hooked;\n    PlainToken plain;\n    PlainToken third;\n    address owner = makeAddr(\"owner\");\n    address guardian = makeAddr(\"guardian\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        vault = new BaskVault(owner, guardian);\n        hooked = new HookedToken();\n        plain = new PlainToken();\n        third = new PlainToken();\n        hooked.setVault(vault);\n        vm.startPrank(owner);\n        vault.genesisList(address(hooked), address(new Feed()), address(0), address(0), 0);\n        vault.genesisList(address(plain), address(new Feed()), address(0), address(0), 0);\n        vault.genesisList(address(third), address(new Feed()), address(0), address(0), 0);\n        vault.finalizeGenesis();\n        vm.stopPrank();\n        hooked.mint(alice, 10e18);\n        plain.mint(alice, 10e18);\n        address[] memory ts = new address[](2);\n        ts[0] = address(hooked);\n        ts[1] = address(plain);\n        uint256[] memory am = new uint256[](2);\n        am[0] = 10e18;\n        am[1] = 10e18;\n        vm.prank(alice);\n        vault.deposit(ts, am, alice, 0, block.timestamp);\n    }\n\n    /// Fails now: the view called from inside redeem returns ~2x the true entitlement for the\n    /// not-yet-processed asset. Passes once views are guarded against reentrancy (revert) or\n    /// kept consistent mid-flight.\n    function testViewIsConsistentOrGuardedDuringRedeem() public {\n        (uint256[] memory before,) = vault.previewRedeem(1e18);\n        uint256 shares = vault.balanceOf(alice) / 2;\n        hooked.arm();\n        vm.prank(alice);\n        vault.redeem(shares, alice, new uint256[](0), block.timestamp);\n        emit log_named_uint(\"plain leg per 1e18 shares before redeem\", before[1]);\n        emit log_named_uint(\"plain leg per 1e18 shares seen mid-redeem\", hooked.observedLeg1());\n        assertTrue(\n            hooked.viewReverted() || hooked.observedLeg1() == before[1],\n            \"previewRedeem must not report an inflated entitlement while a redemption is in flight\"\n        );\n    }\n}","reproduction":"State: three listed assets, alice deposited 10e18 of asset A (a token whose transfer calls vault.previewRedeem(1e18)) and 10e18 of asset B (plain). previewRedeem(1e18) reports 5e15 of B per 1e18 shares. alice calls redeem(totalBalance/2, alice, [], now). Inside the pay() self-call for asset A (index 0), the token's transfer calls previewRedeem(1e18): totalSupply has already been reduced by half but managed[B] is untouched, so the view returns 9999995000002499 (~2x) for B. Expected: a view invoked mid-redemption either reverts or returns the same 5e15 it returns before and after the transaction. Actual: it returns ~1e16. The scratch test test/scratch/ReadOnlyReentrancy.t.sol (testViewIsConsistentOrGuardedDuringRedeem) fails on the current code with exactly these numbers and passes once the views revert on reentrancy.","severity":"low","snippet":"    function previewRedeem(uint256 shares) external view returns (uint256[] memory amounts, uint256 fee) {","title":"Read-only reentrancy: previewRedeem/previewDeposit/allAssets report inconsistent values while redeem or deposit is in flight"}],"hash":"3b441d533e1a2ea7f0117b5b2e9fce74d12178a961adbda5c609701867dfbdfe","nodeId":"d40983ce-e7ca-4b18-8663-809431dc939c","outcome":"completed","summary":"Review complete. The findings file holds one substantiated low-severity finding; the rest of the audited surface held up under both reading and empirical probing.\n\n**What I examined**\n\nI read all five production files, the README, SECURITY.md, the full test suite, and the four pinned reference guides, then worked through each of the six focus areas with concrete traces. I ran the existing suite (78 tests, all pass) and wrote scratch probes under `test/scratch/` for the cases the suite leaves open.\n\n**What held up**\n\n- **Redeem and claim cannot be blocked.** Every revert path in redeem is user-controlled (deadline, zero receiver, insufficient shares, positional minima). Balance reads are gas-capped, payments run in the vault-only self-call with payGas, and failures book to owed. I measured five untested worst-case combinations at the gas-rule boundaries (350 held booked with fees and max managed, 50 held at 500k balanceGas, 47 direct at 500k payGas, 254 direct, and balanceGas-ceiling with payGas-floor). The tightest came in at 27.91M of the 28M budget. All passed.\n- **Pool check.** Orientation, 6- and 18-decimal quotes, harmonic-mean liquidity, wrapping cumulatives and tick rounding match Uniswap's OracleLibrary. A failing, drained or under-floor pool always returns the Pool reason. The only revert path is the accepted absurd-quote-feed overflow. I verified the token1 orientation with a 6-decimal quote independently (price within 0.1%).\n- **Minting and asset movement.** Only deposit mints; only pay (vault-only, reached from redeem and claim) moves assets. Fee is exactly ceil(0.5%) in and out, zero while unset. Resync excludes totalOwed. Proposal epoch, closeVersion and capVersion invalidation all hold, including retire, remove-and-relist, and the guardian-becomes-owner sequences.\n- **Share math and accounting.** Rounding favors the vault; managed-based NAV defeats donation attacks; the held bitmap and heldCount stay consistent through retire, full write-down, swap-and-pop removal, relist and redeposit (probed empirically). The inline assembly is memory-safe and correct.\n\n**The one finding (low)**\n\nThe view functions are not reentrancy-guarded. During redeem, totalSupply is reduced before the per-asset managed reductions, so a Stock Token whose transfer calls back into `previewRedeem` sees roughly double the true entitlement for assets the loop hasn't reached. Vault accounting is unaffected, but any external contract pricing BASK through these views could be misled by an issuer-upgraded token. The proof test fails on the current code and passes with a view-side `entered` check.\n\n**Not reported**\n\nEverything else I traced is either in the owner's accepted list (post-retire dilution, dust slots, FCFS claims, ZeroNAV lock, pre-first-deposit Resync) or a deliberate documented choice (idle unreadable assets block deposits). Scratch tests remain under `test/scratch/` and are not part of the submission.","treeHash":null,"usage":{"cachedInputTokens":3815698,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":101593,"runtime":"claude","turns":41,"wallClockMs":1556885}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ce6eaff570c608ab","findings":[{"citation":"resolved","description":"_newAsset() probes oraclePaused() once and stores the result in Asset.hasPause. Nothing ever recomputes it: the Feed, Centre, Pool and Reopen actions all keep the stored flag, and relisting (the only path that re-runs _newAsset) needs removeRetired, which needs managed == 0 and in practice never happens for an asset that was ever held because of dead-share dust. BaskOracle.price (src/libraries/BaskOracle.sol:93-97) then treats any failed, short or non-boolean read as OraclePaused. The brief states the issuer can upgrade the Stock Tokens. If an upgrade drops, renames, or widens oraclePaused() (for example an enum state 2 that means 'not paused' under the new semantics), every unretired holding of that token fails its price check and _snapshot returns OraclePaused for every deposit of every token, forever. The accepted list covers the analogous pool failure because a Pool proposal can clear it; here the only clearing action is Retire, which permanently drops the asset from NAV (new depositors share its tokens for free) and, if it was the only holding, leaves NAV at zero so deposits stop until a donation and Resync. The reverse direction also holds: a token listed before it exposed oraclePaused() keeps hasPause == false, so a later upgrade that adds the pause is ignored and deposits are priced off a feed the issuer has declared unreliable. Fix: recompute hasPause (the same O.word probe) inside execute() for the Feed, Centre and Pool actions (or add a dedicated action), so the owner can re-detect the interface after an upgrade without retiring the asset.","line":274,"path":"src/BaskVault.sol","reproduction":"State: three assets listed at genesis with MockToken (which exposes oraclePaused() returning false), so asset(token0).hasPause == true; alice deposits 10e18 of token0. Input: vm.etch(token0, NoPauseToken code) to model an upgrade whose implementation has no oraclePaused(). Then depositStatus([token1]) returns (Reason.OraclePaused, token0) because token0 is held and unretired. Execute a Feed proposal (new fresh feed), a Centre proposal and a Pool proposal (pool = 0) for token0: asset(token0).hasPause is still true and depositStatus([token1]) is still (OraclePaused, token0). removeRetired(token0) reverts InvalidAsset even after close + Retire because managed(token0) == 10e18. After Retire, depositStatus returns OK but previewDeposit([token1],[1e18]) reverts ZeroNAV because the retired asset was the only holding. Expected: an owner action that re-detects the pause interface after an upgrade, as a Pool proposal does for a broken pool. Actual: no such action; deposits are blocked until the asset is retired. Verified with test/scratch/Probe2.t.sol::testHasPauseFrozenAfterUpgrade on this tree.","severity":"low","snippet":"        a.hasPause = ok && paused <= 1;","title":"oraclePaused() detection is fixed at listing with no refresh path; an issuer upgrade that removes or changes the method blocks every deposit until the asset is retired"},{"citation":"resolved","description":"The brief describes the deposit checks as: feed price, pool mean within 3% of the feed, or a feed under 26 hours with no pool. The code adds further conditions that block deposits and are not in that text: (1) the feed answer must lie within [centre/band, centre*band] of the centre captured at listing (band = 4, changeable to 2..100), so a stock that moves more than 4x in either direction, including a split larger than 4:1, blocks every deposit until a Centre proposal executes two days later; (2) even with a pool the feed must be under maxAge (80 hours), so a three-day market closure blocks deposits until the feed updates; (3) an optional weekday trading-hours window and a freshCount of recently updated feeds; (4) a NAV cap (1,000,000e18 USD initially, raisable only by proposal to at most 10,000,000,000e18) that rejects deposits with CapExceeded; (5) close(), lowerNavCap() and pauseDeposits() take effect immediately without a proposal (lowerNavCap can go to zero), while the brief says owner changes are proposals that wait two days. None of these can block redeem or claim, and all are documented in README.md, so they are listed here as text-versus-code differences for the requester to confirm rather than as defects. Conversely every behaviour the text states was found implemented.","line":88,"path":"src/libraries/BaskOracle.sol","reproduction":"Band: with centre 100e8 (8-decimal feed) set at listing, feed.set(400e8 + 1, now) makes depositStatus return (Reason.Band, token) and deposit revert DepositUnavailable(Band); feed.set(25e8 - 1, now) does the same. Expected per the text: a deposit priced by its feed; actual: rejected. Cap: owner calls lowerNavCap(0) in one transaction; the next deposit of any amount reverts CapExceeded with no two-day delay. Age: with a pool configured and within 3%, feed.set(100e8, now - 80 hours - 1) returns (Reason.Feed, token) although the text gives the 26-hour rule only for pool-less tokens and no other age bound.","severity":"info","snippet":"            answer < a.centre / s.band || (answer / s.band > a.centre)","title":"Deposit gates and immediate owner actions that the specification text does not state: band around the listing-time centre, 80-hour feed age with a pool, trading-hours window, freshness count, NAV cap,"},{"citation":"resolved","description":"README.md says the token argument of a global action is zero, but _validate() skips the token for actions above Resync and propose() still stores epoch[token] for whatever address was passed. proposalValid() then requires p.epoch == epoch[p.token], so a global proposal created with a listed token's address is silently invalidated when that token is retired or removed, and a guardian who cannot cancel its own replacement can still void a Guardian proposal that was mistakenly keyed to a token the guardian can close and the owner then retires. No funds are at risk and the owner can simply re-propose, so this is informational; the minimal fix is to require token == address(0) for actions above Resync in _validate() (or to store epoch 0 for them).","line":296,"path":"src/BaskVault.sol","reproduction":"owner: propose(FeeRecipient, token0, abi.encode(alice)) -> proposalValid(id) == true. guardian: close(token0); owner: propose(Retire, token0) and execute it after two days (this increments epoch[token0]). Now proposalValid(id) == false and execute(id) at its readyAt reverts InvalidProposal, although nothing about the fee recipient changed. Expected: a global proposal is independent of any asset; actual: it is bound to the epoch of an arbitrary token. Verified with test/scratch/Probe2.t.sol::testGlobalProposalBoundToTokenEpoch on this tree.","severity":"info","snippet":"        proposals[id] = T.Proposal(action, token, data, block.timestamp + 2 days, epoch[token], version, false);","title":"Global proposals (Guardian, NavCap, FeeRecipient, Setting) accept any token address and are voided by that token's retire or removal"}],"hash":"656be5e22b3f3cf9191d1095d0106c871485c3f74efb51cf6d0abe9632770ace","nodeId":"4fb74fe7-9a4a-40ad-a4d2-14caf2acc28a","outcome":"completed","summary":"The review is complete. The findings file is written and validated: every path, line and snippet anchors to the tree, and the JSON parses.\n\n**Outcome: no critical, high or medium defect found.** One low finding and two informational notes are recorded in `.imd-findings.json`.\n\n**What I verified in depth** (all 98 project tests pass; my scratch probes under `test/scratch/` are disposable):\n\n- **Redeem and claim cannot be blocked.** No oracle read, no role check and no revertable arithmetic sits on either path. Legs are strictly below managed because the dead shares keep net below supply, so no zero crossing happens inside redeem. Worst-case gas probes combining the legal extremes (254 direct legs at 20k/20k, 26 direct legs at 500k/500k, 350 booked legs at 20k, fees on, 512-bit managed values, full minima array, tokens burning their whole allowance) peaked at 27.37M of the 28M bound.\n- **Held cache.** A 600-run randomized fuzz over deposits, redemptions, losses, resyncs, retirements, removals and relistings kept heldCount, the bitmap and managed in agreement, including swap-and-pop of a held last asset.\n- **Oracle.** Orientation, 6-decimal and 18-decimal quotes, harmonic-mean liquidity, rounding toward negative infinity, wrapped cumulatives and the overflow bounds all check out; a 6-decimal quote at a realistic tick priced within tick granularity of the feed. A failing, drained or under-floor pool only ever blocks.\n- **Value flows and governance.** Only `pay` (self-only) moves tokens out, only `deposit` mints, fees are zero until a recipient is set, Resync excludes owed, and every stale-proposal path (retire, removal and relisting, close, cap lowering) is voided. No sequence lets the guardian become owner.\n\n**Findings:**\n\n1. **Low.** The `oraclePaused()` detection is computed once at listing and nothing refreshes it. An issuer upgrade that drops or widens the method makes every deposit fail with OraclePaused until the asset is retired, and retirement drops it from NAV. Reproduced; fix is to re-detect in the Feed/Centre/Pool actions.\n2. **Info.** Deposit gates and instant owner actions not in the brief text (band around the listing centre, 80-hour age with a pool, hours window, freshness count, NAV cap, immediate close and cap lowering). All documented in the README and none affect redemption.\n3. **Info.** Global proposals store the epoch of whatever token address is passed, so retiring that token voids them. Harmless, cheap to tighten.\n\nOne note for the requester: writing the two scratch probes exposed a via-IR gotcha where `block.timestamp` is hoisted across `vm.warp` in tests; the project's own suite already avoids it by warping to the stored `readyAt`.","treeHash":null,"usage":{"cachedInputTokens":4353440,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":95589,"runtime":"claude","turns":45,"wallClockMs":1495316}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"56b13350bba65fd7","findings":[],"hash":"72d7c576043e24fde9340d3a8e25480e2841b4210aec97ad9ab5c97b23eb6585","nodeId":"42129ac4-53cc-4656-8ef2-b77cd1b4a858","outcome":"failed","summary":"runtime reported <synthetic>, not the required premium model claude-fable-5-1","treeHash":null,"usage":{"cachedInputTokens":516226,"inputTokens":194,"model":"<synthetic>","outputTokens":3294,"runtime":"claude","turns":20,"wallClockMs":4161488}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"077d2937780a81bc","findings":[{"citation":"resolved","description":"_newAsset probes oraclePaused() once and stores the result in Asset.hasPause; line 274 is the only assignment to that field. BaskOracle.price (src/libraries/BaskOracle.sol:93-97) then requires a valid false answer from oraclePaused() on every deposit price check for that asset, and _snapshot prices every unretired asset with managed > 0, not only the input tokens. The brief lists issuer upgrades of the Stock Tokens as a scenario. If an upgrade removes, renames, reverts or widens oraclePaused() on a held token, price() returns OraclePaused for it forever and every deposit, previewDeposit and depositStatus for any input token fails with DepositUnavailable(OraclePaused, token). No proposal re-runs the probe: Feed, Pool, Centre, Reopen and Resync all leave hasPause true (verified by execution of each). The accepted-risk list covers the analogous pool failure because a Pool proposal can clear it; here the only exit is Retire, which permanently drops the asset from NAV while its tokens stay in the vault, so every depositor after Retire shares them at existing holders' expense (reproduced: with 10e18 of token0 and token1 held, retiring token0 and depositing 10e18 of token1 yields a redemption of 5e18 token0 for the new depositor). If the retired asset was the only holding, NAV is zero and deposits stop with ZeroNAV. Redeem and claim are unaffected. The reverse direction also holds: a token listed before it exposed oraclePaused() keeps hasPause false, so a pause added by a later upgrade is ignored. Minimal fix preserving the design: re-run the two-line oraclePaused() probe from _newAsset when a Feed, Pool or Centre proposal executes for the asset (or add a dedicated timelocked action), giving the owner the same lever a Pool proposal gives for pool failures. Merged from audit_permissions (medium) and audit_economics (low).","line":274,"path":"src/BaskVault.sol","proof":"// SPDX-License-Identifier: GPL-2.0-or-later\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BaskVault} from \"src/BaskVault.sol\";\nimport {BaskTypes as T} from \"src/BaskTypes.sol\";\n\n/// @dev Minimal Stock Token with an optional oraclePaused() probe that the issuer can remove by upgrade.\ncontract StockToken {\n    uint8 public constant decimals = 18;\n    bool public probeRemoved;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function removeProbe() external {\n        probeRemoved = true;\n    }\n\n    function oraclePaused() external view returns (bool) {\n        require(!probeRemoved, \"function removed by upgrade\");\n        return false;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract Feed {\n    uint8 public constant decimals = 8;\n    int256 public answer;\n    uint256 public updatedAt;\n\n    constructor(int256 a) {\n        answer = a;\n        updatedAt = block.timestamp;\n    }\n\n    function set(int256 a, uint256 t) external {\n        answer = a;\n        updatedAt = t;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, answer, updatedAt, updatedAt, 1);\n    }\n}\n\n/// @notice Fails on the current code: once a held Stock Token's oraclePaused() probe stops answering,\n/// no owner proposal (Feed, Pool, Centre) re-detects the pause interface, so hasPause stays true and\n/// every deposit into the vault reverts with DepositUnavailable(OraclePaused). Passes once executing a\n/// Feed, Pool or Centre proposal re-probes oraclePaused() the way listing does.\ncontract StickyPauseProof is Test {\n    BaskVault private vault;\n    StockToken[3] private tokens;\n    Feed[3] private feeds;\n    address private owner = makeAddr(\"owner\");\n    address private guardian = makeAddr(\"guardian\");\n    address private alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        vault = new BaskVault(owner, guardian);\n        for (uint256 i; i < 3; ++i) {\n            tokens[i] = new StockToken();\n            feeds[i] = new Feed(100e8);\n            vm.prank(owner);\n            vault.genesisList(address(tokens[i]), address(feeds[i]), address(0), address(0), 0);\n            tokens[i].mint(alice, 100e18);\n            vm.prank(alice);\n            tokens[i].approve(address(vault), type(uint256).max);\n        }\n        vm.prank(owner);\n        vault.finalizeGenesis();\n    }\n\n    function _one(address t) private pure returns (address[] memory a) {\n        a = new address[](1);\n        a[0] = t;\n    }\n\n    function _amt(uint256 v) private pure returns (uint256[] memory a) {\n        a = new uint256[](1);\n        a[0] = v;\n    }\n\n    function _refresh() private {\n        for (uint256 i; i < 3; ++i) {\n            feeds[i].set(100e8, block.timestamp);\n        }\n    }\n\n    function _run(T.Action action, address token, bytes memory data) private {\n        vm.prank(owner);\n        uint256 id = vault.propose(action, token, data);\n        vm.warp(vault.proposal(id).readyAt);\n        _refresh();\n        vm.prank(owner);\n        vault.execute(id);\n    }\n\n    function testOwnerCanRecoverDepositsAfterIssuerRemovesPauseProbe() public {\n        address t0 = address(tokens[0]);\n        vm.prank(alice);\n        vault.deposit(_one(t0), _amt(10e18), alice, 0, block.timestamp);\n        assertTrue(vault.asset(t0).hasPause);\n\n        // Issuer upgrade: oraclePaused() no longer exists on the held token.\n        tokens[0].removeProbe();\n        (T.Reason reason, address fault) = vault.depositStatus(_one(address(tokens[1])));\n        assertEq(uint256(reason), uint256(T.Reason.OraclePaused));\n        assertEq(fault, t0);\n\n        // Every configuration proposal the owner has for a live asset.\n        _run(T.Action.Feed, t0, abi.encode(address(feeds[0])));\n        _run(T.Action.Pool, t0, abi.encode(address(0), address(0), uint128(0)));\n        _run(T.Action.Centre, t0, \"\");\n        _refresh();\n\n        // Expected: the probe is re-detected and deposits of other assets work again.\n        // Actual on current code: hasPause is still true and the deposit reverts with OraclePaused.\n        assertFalse(vault.asset(t0).hasPause, \"hasPause must be re-detected by an owner proposal\");\n        (reason,) = vault.depositStatus(_one(address(tokens[1])));\n        assertEq(uint256(reason), uint256(T.Reason.OK), \"deposits must be recoverable without retiring the asset\");\n        vm.prank(alice);\n        uint256 shares = vault.deposit(_one(address(tokens[1])), _amt(1e18), alice, 0, block.timestamp);\n        assertGt(shares, 0);\n    }\n}","reproduction":"State: three assets listed at genesis with a token exposing oraclePaused()=false, so asset(token0).hasPause == true; alice deposits 10e18 of token0 (managed > 0). Input: the token's oraclePaused() starts reverting (proof: removeProbe(); also verified with vm.etch of code lacking the function). Then depositStatus([token1]) returns (OraclePaused, token0) and deposit([token1],[1e18],alice,0,now) reverts DepositUnavailable(OraclePaused, token0). Owner executes Feed(token0, same feed), Pool(token0, 0,0,0), Centre(token0), close+Reopen(token0) and Resync(token0) after their 2-day waits: asset(token0).hasPause is still true and depositStatus still returns OraclePaused. Expected: an owner proposal re-detects the probe so deposits resume, as a Pool proposal does for a broken pool. Actual: deposits are halted until Retire. Proof test/scratch/Proof_765fb70ae8b5.t.sol fails on this code with 'hasPause must be re-detected by an owner proposal'; test/scratch/Probe.t.sol::testHasPauseStickyAndRetireDilutes and testRetireDilution confirm Reopen/Resync and the post-retire dilution.","severity":"medium","snippet":"        a.hasPause = ok && paused <= 1;","title":"hasPause is probed only at listing; an issuer upgrade that breaks oraclePaused() on a held asset halts all deposits and only Retire (which dilutes existing holders) can clear it"},{"citation":"resolved","description":"Every state-changing entry point is nonReentrant, but the view functions do not check the `entered` flag. redeem() reduces totalSupply (line 652) before the per-asset loop reduces managed[] one asset at a time and makes an external token transfer through pay() for each held asset. A Stock Token whose transfer calls back into previewRedeem (an issuer upgrade can do this) sees the reduced supply against the still-unreduced managed[] of later assets and is quoted roughly twice the true per-share entitlement for a 50% redemption. Symmetrically, during deposit() the token pulls (line 565) increase managed[] for earlier input tokens before shares are minted, so previewDeposit/allAssets quoted from a transferFrom callback see an overstated NAV per share. Vault accounting is not corrupted and the brief's redeem/claim/deposit guarantees hold; the exposure is to any contract that prices BASK through these views (lending collateral, a quoter, a keeper) and can be invoked inside a hooked token transfer. Minimal fix: revert in the views when entered != 1, or apply supply and all managed reductions before any external call. From audit_flow.","line":794,"path":"src/BaskVault.sol","proof":"// SPDX-License-Identifier: GPL-2.0-or-later\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BaskVault} from \"src/BaskVault.sol\";\nimport {BaskTypes as T} from \"src/BaskTypes.sol\";\n\n/// @dev A Stock Token whose transfer calls back into a vault view (an issuer upgrade can do this).\ncontract HookedToken {\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    BaskVault public vault;\n    uint256 public observedLeg1;\n    bool public viewReverted;\n    bool public armed;\n\n    function setVault(BaskVault v) external {\n        vault = v;\n    }\n\n    function arm() external {\n        armed = true;\n    }\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        if (armed) {\n            armed = false;\n            // Mid-redeem view read: supply already reduced, later assets' managed not yet reduced.\n            try vault.previewRedeem(1e18) returns (uint256[] memory amounts, uint256) {\n                observedLeg1 = amounts[1];\n            } catch {\n                viewReverted = true;\n            }\n        }\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract PlainToken {\n    uint8 public constant decimals = 18;\n    mapping(address => uint256) public balanceOf;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract Feed {\n    uint8 public constant decimals = 8;\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 100e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract ReadOnlyReentrancyTest is Test {\n    BaskVault vault;\n    HookedToken hooked;\n    PlainToken plain;\n    PlainToken third;\n    address owner = makeAddr(\"owner\");\n    address guardian = makeAddr(\"guardian\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        vault = new BaskVault(owner, guardian);\n        hooked = new HookedToken();\n        plain = new PlainToken();\n        third = new PlainToken();\n        hooked.setVault(vault);\n        vm.startPrank(owner);\n        vault.genesisList(address(hooked), address(new Feed()), address(0), address(0), 0);\n        vault.genesisList(address(plain), address(new Feed()), address(0), address(0), 0);\n        vault.genesisList(address(third), address(new Feed()), address(0), address(0), 0);\n        vault.finalizeGenesis();\n        vm.stopPrank();\n        hooked.mint(alice, 10e18);\n        plain.mint(alice, 10e18);\n        address[] memory ts = new address[](2);\n        ts[0] = address(hooked);\n        ts[1] = address(plain);\n        uint256[] memory am = new uint256[](2);\n        am[0] = 10e18;\n        am[1] = 10e18;\n        vm.prank(alice);\n        vault.deposit(ts, am, alice, 0, block.timestamp);\n    }\n\n    /// Fails now: the view called from inside redeem returns ~2x the true entitlement for the\n    /// not-yet-processed asset. Passes once views are guarded against reentrancy (revert) or\n    /// kept consistent mid-flight.\n    function testViewIsConsistentOrGuardedDuringRedeem() public {\n        (uint256[] memory before,) = vault.previewRedeem(1e18);\n        uint256 shares = vault.balanceOf(alice) / 2;\n        hooked.arm();\n        vm.prank(alice);\n        vault.redeem(shares, alice, new uint256[](0), block.timestamp);\n        emit log_named_uint(\"plain leg per 1e18 shares before redeem\", before[1]);\n        emit log_named_uint(\"plain leg per 1e18 shares seen mid-redeem\", hooked.observedLeg1());\n        assertTrue(\n            hooked.viewReverted() || hooked.observedLeg1() == before[1],\n            \"previewRedeem must not report an inflated entitlement while a redemption is in flight\"\n        );\n    }\n}","reproduction":"State: three listed assets; alice deposited 10e18 of asset A (a token whose transfer calls vault.previewRedeem(1e18)) and 10e18 of asset B (plain). previewRedeem(1e18) reports 5e15 of B per 1e18 shares. alice calls redeem(balance/2, alice, [], now). Inside pay() for asset A the hooked transfer calls previewRedeem(1e18): totalSupply is already halved but managed[B] is untouched, so the view returns 9999995000002499 (about 2x) for B. Expected: a view invoked mid-redemption reverts or returns the same 5e15 as before and after the transaction. Actual: about 1e16. Proof test/scratch/Proof_109e6b698e4c.t.sol fails on this code with 'previewRedeem must not report an inflated entitlement while a redemption is in flight'.","severity":"low","snippet":"    function previewRedeem(uint256 shares) external view returns (uint256[] memory amounts, uint256 fee) {","title":"Read-only reentrancy: previewRedeem, previewDeposit, depositStatus and allAssets report inconsistent values while redeem or deposit is in flight"},{"citation":"resolved","description":"The brief says a deposit checks 'every deposited and every held unretired token'. _snapshot additionally performs a bounded balanceOf read on every unretired asset, including assets with managed == 0 that are not in the deposit. For such an asset the only consumers of bal are `(wanted[i] && bal < debt)` (false, not wanted) and `available < m` (false, m == 0), so the read cannot change the solvency outcome; it only adds a failure mode. An issuer upgrade that makes balanceOf revert, return fewer than 32 bytes or cost more than balanceGas (50,000 by default) on a listed-but-never-deposited token blocks all deposits of every other asset until the owner retires it (close plus a 2-day Retire proposal) or raises balanceGas by proposal. The accepted list covers a held asset whose pool or balance fails, not an idle one. Existing tests testZeroManagedNonInputMustStillHaveReadableBalance and testDepositChecksIdleUnreadableButSkipsRetired assert the current behaviour, so this is a deliberate choice reported under item 6 (code does what the text does not say). Minimal fix: move the balance read inside `if (m != 0 || wanted[i])` and compute available/debt only there, leaving the retired skip and the idle freshness branch unchanged. From audit_math.","line":498,"path":"src/BaskVault.sol","reproduction":"Three assets, genesis finalized; alice deposits 1e18 of tokens[0]. tokens[2] (managed == 0, not an input) is upgraded so balanceOf reverts (MockToken.setModes(1, 0)). Expected per the brief: depositStatus([tokens[0]]) == (OK, 0) and a further deposit of tokens[0] succeeds, because tokens[2] is neither deposited nor held. Actual: depositStatus returns (BalanceUnreadable (7), tokens[2]) and deposit([tokens[0]],[1e18],alice,0,now) reverts DepositUnavailable(BalanceUnreadable, tokens[2]); redeem is unaffected. Reproduced in test/scratch/Probe.t.sol::testIdleUnreadableBlocksDeposit.","severity":"low","snippet":"            (bool readable, uint256 bal) = O.balance(token, config.balanceGas);","title":"Deposits require a readable balance on idle, never-held, non-input assets, which the brief does not ask for and solvency does not need"},{"citation":"resolved","description":"README.md says the token argument of a global action is zero, but _validate only inspects token for actions up to Resync, and propose stores epoch[token] for whatever address was passed. proposalValid then requires p.epoch == epoch[p.token], so a global proposal created with a listed token's address is silently invalidated when that token is retired or removed; a guardian who cannot cancel its own replacement can still void a Guardian proposal mistakenly keyed to a token the guardian can close and the owner then retires. Owner-only input, no funds at risk, re-proposing fixes it. Minimal fix: in _validate require token == address(0) for actions above Resync (or store epoch 0 for them). Merged from audit_economics and audit_permissions.","line":296,"path":"src/BaskVault.sol","reproduction":"owner: propose(FeeRecipient, tokens[0], abi.encode(bob)) -> proposalValid(id) == true. guardian: close(tokens[0]); owner: propose(Retire, tokens[0]) and execute it after two days (increments epoch[tokens[0]]). Now proposalValid(id) == false and execute(id) at readyAt reverts InvalidProposal although nothing about the fee recipient changed. Expected: a global proposal is independent of any asset. Actual: bound to an arbitrary token's epoch. Reproduced in test/scratch/Probe.t.sol::testGlobalProposalBoundToTokenEpoch.","severity":"info","snippet":"        proposals[id] = T.Proposal(action, token, data, block.timestamp + 2 days, epoch[token], version, false);","title":"Global proposals (Guardian, NavCap, FeeRecipient, Setting) accept any token address and are voided by that token's retire or removal"},{"citation":"resolved","description":"Item 6 asks for anything the code does that the text does not say. All of the following are documented in README.md and only ever block deposits (never redeem or claim), so none is a loss path, but the brief's deposit rules do not state them: (a) every priced asset's feed answer must lie within [centre/band, centre*band] (band 4, settable 2..100) where centre is the answer recorded at listing, Feed change or a Centre proposal, so a genuine move beyond 4x in either direction, including a 5:1 or 10:1 split, blocks every deposit of every token until a Centre proposal executes two days later and the brief's operating rules do not mention recentring before a split; (b) a pooled asset's feed must also be under maxAge (80 hours, src/libraries/BaskOracle.sol:84) independent of the 3% pool check, so a three-day market holiday weekend (about 89.5 hours between the Friday and Tuesday updates) blocks deposits until the feed updates even when the pool check would pass; (c) optional Hours (weekday window) and FreshCount settings can block deposits globally; (d) close(token), pauseDeposits() and lowerNavCap(cap) take effect immediately without the 2-day proposal path, lowerNavCap has no lower bound (0 is accepted and voids pending cap raises) and the guardian, not only the owner, can close any asset and pause deposits, while only the owner can unpause. These are trust assumptions on the owner and guardian rather than defects. Merged from audit_math, audit_economics and audit_permissions.","line":88,"path":"src/libraries/BaskOracle.sol","reproduction":"Band: VaultFixture (centre 100e8, band 4); feeds[0].set(25e8 - 1, block.timestamp) or feeds[0].set(400e8 + 1, block.timestamp). Expected per the brief: a valid feed answer under 26 hours with no pool is accepted. Actual: depositStatus([tokens[0]]) returns (Band, tokens[0]) and deposit reverts DepositUnavailable(Band, tokens[0]) (boundary shown by test/Prices.t.sol::testBandBoundariesAndOraclePause). Age: with a healthy pool within 3%, feed updatedAt = now - 80 hours - 1 returns (Feed, token) (boundary shown by testPoolTickZeroPriceLiquidityAndMaxAge). Cap: owner calls lowerNavCap(0) in one transaction; the next deposit of any amount reverts CapExceeded with no delay (mechanism shown by testNavCapLoweringVoidsRaisesAndCapApplies).","severity":"info","snippet":"            answer < a.centre / s.band || (answer / s.band > a.centre)","title":"Deposit blockers and immediate owner/guardian actions present in code but absent from the brief: price band around a stored centre, 80-hour feed age on pooled assets, trading hours, freshness count, N"},{"citation":"resolved","description":"The booked-path rule budgets 60,000 gas of vault overhead per asset. Measured overhead per held asset on the booked path is roughly 58,000 (two cold owed/totalOwed SSTOREs, managed SLOAD+SSTORE, assetTokens SLOAD, cold token account access, call stipend and the 512-bit mulDiv). At the tightest permitted configuration, maxAssets = 50 with balanceGas = 500,000, the suite's testGasMaximumBalance50Assets records 27,897,644 gas for the call with fee, maximal managed values and gas-burning balanceOf on every asset, which satisfies the requirement but leaves about 102,000 gas; a full 50-entry minAmountsOut array adds roughly 11,000 more. A real transaction also pays the 21,000 intrinsic cost plus calldata, so the end-to-end transaction is about 27,960,000: inside 28,000,000 on a Cancun-schedule EVM but with no margin for a gas-schedule difference on the target chain (for example an L1 data surcharge counted in gasUsed on an Orbit-style rollup). No code defect; recorded so the owner can decide whether to keep balanceGas below 500,000 operationally or widen the booked rule's per-asset allowance to 70,000 like the direct rule. Every direct-path boundary in the suite is lower (maximum 27,331,250 for 26 direct attempts with 500k allowances). From audit_math.","line":440,"path":"src/BaskVault.sol","reproduction":"Run `forge test --match-test testGasMaximumBalance50Assets -vv`: settings MaxAssets=50, BalanceGas=500000 (and PayGas=500000, DirectLimit=26 or 0 for the booked path), 50 pool-less tokens each held with near-uint256 managed via Resync, fee recipient set, every balanceOf consuming all forwarded gas, redeem(shares/2, receiver, [], now) called with exactly 28,000,000 gas. Expected: success under 28,000,000. Actual: success at 27,897,644 gas (observed on this tree), headroom 102,356.","severity":"info","snippet":"            s.maxAssets < assetTokens.length || s.maxAssets > 28_000_000 / (s.balanceGas + 60_000)","title":"Redemption gas headroom at the tightest in-bounds setting is about 100,000 gas (0.36%) on the booked path"}],"hash":"8e18f67802160bacd6d2a1d859a8ea455ab2300729eba67f4379bd9fc3be46a5","nodeId":"42129ac4-53cc-4656-8ef2-b77cd1b4a858","outcome":"completed","summary":"The review is complete. `.imd-findings.json` in the repository root holds six merged findings; no source file was changed, and `test/scratch/` holds only my throwaway probes plus copies of the two specialist proofs.\n\n**What I did**\n\n- Built the project and ran the full suite: 98 tests pass, including the 12 redemption gas boundary tests.\n- Read every production file (`BaskVault.sol`, `BaskTypes.sol`, `BaskOracle.sol`, `TickMath.sol`) against the six focus areas. I found no critical or high defect of my own: redeem and claim read no oracle and never revert on token or settings state, the held bitmap and swap-and-pop stay consistent, only `deposit` mints and only the self-only `pay` moves tokens, the fee is zero until the recipient is set, Resync subtracts owed tokens, the pool math cannot overflow for sane quote prices, and the proposal epoch and version checks hold across retire, removal, relisting, close and cap lowering.\n- Reproduced every specialist claim before keeping it. Both attached proofs fail on the current code for their stated reasons. My probe file confirmed the idle-balance block, the global-proposal epoch binding, that Reopen and Resync also leave `hasPause` set, and the post-retire dilution (a new depositor received 5e18 of the retired token).\n\n**Findings kept, merged across specialists**\n\n| Severity | Finding | Proof |\n|---|---|---|\n| medium | `hasPause` probed only at listing; a held token whose `oraclePaused()` breaks halts all deposits, and Retire is the only exit | yes, fails here |\n| low | Read-only reentrancy in the preview and status views during redeem or deposit | yes, fails here |\n| low | Deposits need a readable balance on idle, never-held, non-input assets, beyond what the brief asks | reproduced in probe |\n| info | Global proposals keyed to a non-zero token are voided by that token's retire or removal | reproduced in probe |\n| info | Code-versus-text list: band around a stored centre, 80-hour age on pooled assets, hours, freshness, NAV cap, immediate close/pause/lowerNavCap and guardian close | boundaries shown by existing suite tests |\n| info | Booked-path redemption headroom is about 102,000 gas at the tightest in-bounds setting | suite gas test, 27,897,644 observed |\n\n**Merges and judgement calls.** The two `hasPause` reports became one finding at medium, since the only clearing action transfers value to later depositors, which is worse than the accepted pool-failure analog. The three text-versus-code reports and the two global-proposal reports were each merged into one. Nothing was dropped: every specialist finding reproduced.\n\n**Not done.** No Slither or external tooling ran, as none is installed. I did not fork or test against the live chain; all reproductions use the project's mocks.","treeHash":null,"usage":{"cachedInputTokens":1297178,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":42732,"runtime":"claude","turns":23,"wallClockMs":764251}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6ef494db85781eec","findings":[{"citation":"resolved","description":"`_newAsset` probes `oraclePaused()` once and stores the result in `Asset.hasPause`; nothing else writes that field (grep: BaskVault.sol:274 is the only assignment). `BaskOracle.price` then requires a valid false answer from `oraclePaused()` on every deposit price check for that asset, and `_snapshot` price-checks every unretired asset with managed > 0, not just the input tokens. If the issuer upgrades a held Stock Token so that `oraclePaused()` reverts, is removed, or returns malformed data (the brief lists issuer upgrades as a scenario), `price()` returns `OraclePaused` for that asset forever and every `deposit`, `previewDeposit` and `depositStatus` for any input token fails with `DepositUnavailable(OraclePaused, token)`. The brief accepts the analogous pool failure because a `Pool` proposal can repair it; here no proposal re-runs the probe: executing Feed, Pool, Centre, Reopen or Resync for the asset leaves `hasPause` true (verified by execution). The only exit is `Retire`, which drops the asset from NAV while its tokens stay in the vault, so every depositor after that execution shares the retired tokens at existing holders' expense (an MEV deposit right after Retire executes captures roughly the asset's NAV weight minus the 1% round-trip fee). Redeem and claim are unaffected. Minimal fix that keeps the design: re-run the `oraclePaused()` probe (the same two lines as in `_newAsset`) when a Feed, Pool or Centre proposal executes for the asset, so the owner has a timelocked lever that matches the Pool proposal for pool failures.","line":274,"path":"src/BaskVault.sol","proof":"// SPDX-License-Identifier: GPL-2.0-or-later\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BaskVault} from \"src/BaskVault.sol\";\nimport {BaskTypes as T} from \"src/BaskTypes.sol\";\n\n/// @dev Minimal Stock Token with an optional oraclePaused() probe that the issuer can remove by upgrade.\ncontract StockToken {\n    uint8 public constant decimals = 18;\n    bool public probeRemoved;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function removeProbe() external {\n        probeRemoved = true;\n    }\n\n    function oraclePaused() external view returns (bool) {\n        require(!probeRemoved, \"function removed by upgrade\");\n        return false;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract Feed {\n    uint8 public constant decimals = 8;\n    int256 public answer;\n    uint256 public updatedAt;\n\n    constructor(int256 a) {\n        answer = a;\n        updatedAt = block.timestamp;\n    }\n\n    function set(int256 a, uint256 t) external {\n        answer = a;\n        updatedAt = t;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, answer, updatedAt, updatedAt, 1);\n    }\n}\n\n/// @notice Fails on the current code: once a held Stock Token's oraclePaused() probe stops answering,\n/// no owner proposal (Feed, Pool, Centre) re-detects the pause interface, so hasPause stays true and\n/// every deposit into the vault reverts with DepositUnavailable(OraclePaused). Passes once executing a\n/// Feed, Pool or Centre proposal re-probes oraclePaused() the way listing does.\ncontract StickyPauseProof is Test {\n    BaskVault private vault;\n    StockToken[3] private tokens;\n    Feed[3] private feeds;\n    address private owner = makeAddr(\"owner\");\n    address private guardian = makeAddr(\"guardian\");\n    address private alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        vault = new BaskVault(owner, guardian);\n        for (uint256 i; i < 3; ++i) {\n            tokens[i] = new StockToken();\n            feeds[i] = new Feed(100e8);\n            vm.prank(owner);\n            vault.genesisList(address(tokens[i]), address(feeds[i]), address(0), address(0), 0);\n            tokens[i].mint(alice, 100e18);\n            vm.prank(alice);\n            tokens[i].approve(address(vault), type(uint256).max);\n        }\n        vm.prank(owner);\n        vault.finalizeGenesis();\n    }\n\n    function _one(address t) private pure returns (address[] memory a) {\n        a = new address[](1);\n        a[0] = t;\n    }\n\n    function _amt(uint256 v) private pure returns (uint256[] memory a) {\n        a = new uint256[](1);\n        a[0] = v;\n    }\n\n    function _refresh() private {\n        for (uint256 i; i < 3; ++i) {\n            feeds[i].set(100e8, block.timestamp);\n        }\n    }\n\n    function _run(T.Action action, address token, bytes memory data) private {\n        vm.prank(owner);\n        uint256 id = vault.propose(action, token, data);\n        vm.warp(vault.proposal(id).readyAt);\n        _refresh();\n        vm.prank(owner);\n        vault.execute(id);\n    }\n\n    function testOwnerCanRecoverDepositsAfterIssuerRemovesPauseProbe() public {\n        address t0 = address(tokens[0]);\n        vm.prank(alice);\n        vault.deposit(_one(t0), _amt(10e18), alice, 0, block.timestamp);\n        assertTrue(vault.asset(t0).hasPause);\n\n        // Issuer upgrade: oraclePaused() no longer exists on the held token.\n        tokens[0].removeProbe();\n        (T.Reason reason, address fault) = vault.depositStatus(_one(address(tokens[1])));\n        assertEq(uint256(reason), uint256(T.Reason.OraclePaused));\n        assertEq(fault, t0);\n\n        // Every configuration proposal the owner has for a live asset.\n        _run(T.Action.Feed, t0, abi.encode(address(feeds[0])));\n        _run(T.Action.Pool, t0, abi.encode(address(0), address(0), uint128(0)));\n        _run(T.Action.Centre, t0, \"\");\n        _refresh();\n\n        // Expected: the probe is re-detected and deposits of other assets work again.\n        // Actual on current code: hasPause is still true and the deposit reverts with OraclePaused.\n        assertFalse(vault.asset(t0).hasPause, \"hasPause must be re-detected by an owner proposal\");\n        (reason,) = vault.depositStatus(_one(address(tokens[1])));\n        assertEq(uint256(reason), uint256(T.Reason.OK), \"deposits must be recoverable without retiring the asset\");\n        vm.prank(alice);\n        uint256 shares = vault.deposit(_one(address(tokens[1])), _amt(1e18), alice, 0, block.timestamp);\n        assertGt(shares, 0);\n    }\n}","reproduction":"State: three listed assets, token0 answers oraclePaused()=false at listing so asset(token0).hasPause == true; alice deposits 10e18 token0 so managed[token0] > 0. Input: issuer upgrade makes token0.oraclePaused() revert (vm.mockCallRevert or the proof's removeProbe()). Then depositStatus([token1]) returns (OraclePaused, token0) and deposit([token1],[1e18],alice,0,now) reverts DepositUnavailable(OraclePaused, token0). Owner executes Feed(token0, sameFeed), Pool(token0, 0,0,0), Centre(token0), close+Reopen(token0) and Resync(token0) after the 2-day waits: asset(token0).hasPause is still true and the same deposit still reverts. Expected: at least one owner proposal re-detects the probe so deposits resume. Actual: deposits are halted until Retire, which transfers value to post-retire depositors.","severity":"medium","snippet":"        a.hasPause = ok && paused <= 1;","title":"hasPause is detected only at listing; an issuer upgrade that breaks oraclePaused() on a held asset halts every deposit with no owner proposal able to clear it"},{"citation":"resolved","description":"The brief describes the deposit price checks as the 3% pool check and the 26-hour rule for pool-less tokens. The code additionally rejects any feed answer outside [centre/band, centre*band] with band = 4, where centre is the answer stored at listing or at the last Feed/Centre execution. Because `_snapshot` prices every held unretired asset, a single held stock whose feed moves more than 4x (a 10:1 or 5:1 stock split is the realistic case, and the README itself discusses splits) returns Reason.Band and blocks every deposit of every token until a Centre proposal executes, which takes at least 2 days. This is documented in README.md but not in the brief; the operating rules in the brief do not mention pausing or recentring around splits. Reported as information: either document it in the brief's deposit rules or note that the owner must propose Centre before a known split.","line":88,"path":"src/libraries/BaskOracle.sol","reproduction":"State: default settings (band 4), token0 listed with centre 100e8, alice holds 10e18 token0 via deposit. Input: feed0 reports 10e8 (10:1 split) at block.timestamp. depositStatus([token1]) returns (Band, token0) [verified: reason 10] and deposit of any token reverts DepositUnavailable(Band, token0). Expected per the brief's text: only the pool/26-hour checks apply. Actual: deposits blocked until Centre executes 2 days later.","severity":"info","snippet":"            answer < a.centre / s.band || (answer / s.band > a.centre)","title":"Undocumented band check: a feed move beyond 4x of the stored centre (e.g. a 10:1 split) blocks all deposits for at least two days"},{"citation":"resolved","description":"The brief states that a token with a pool is checked against its pool price and that the 26-hour rule applies only with no pool, implying pooled tokens have no feed-age rule. The code applies `maxAge` (default 80 hours) to every asset before the pool comparison. Feeds update only on weekdays: the last Friday update near 20:00 UTC to the first Tuesday update near 13:30 UTC after a Monday US market holiday is about 89.5 hours, so for every held pooled asset `price()` returns Reason.Feed and all deposits are blocked until the feed updates on Tuesday, even though the pool check would have passed. The owner can raise MaxAge by proposal (1 hour to 30 days). Reported as information because it is a documented default rather than a defect, but it is behaviour the brief's text does not state.","line":84,"path":"src/libraries/BaskOracle.sol","reproduction":"State: token0 held (managed > 0) with a configured pool whose observe is healthy and within 3% of the feed. Input: feed0.updatedAt = block.timestamp - 80 hours - 1 second (the existing test testPoolTickZeroPriceLiquidityAndMaxAge shows the boundary: 80h returns OK, 80h+1s returns Feed). depositStatus([token1]) returns (Feed, token0) and deposit reverts DepositUnavailable(Feed, token0). Expected per the brief: a pooled token is gated by the pool check only. Actual: the 80-hour feed age also gates it, which a holiday weekend (about 89.5 hours) exceeds.","severity":"info","snippet":"        (ok, answer, updatedAt) = feed(a.feed, s.feedGas, s.maxAge);","title":"Pooled assets still require the primary feed to be under maxAge (80 hours), which blocks deposits after a three-day market holiday weekend"},{"citation":"resolved","description":"The brief says settings change only by proposal within fixed bounds. Raising NAV_CAP goes through a 2-day proposal bounded by MAX_NAV_CAP, but `lowerNavCap` takes effect at once with no lower bound: `lowerNavCap(0)` makes every deposit revert CapExceeded immediately and voids every pending NavCap raise. Likewise `close` (owner or guardian) and `pauseDeposits` (owner or guardian) are immediate, and `unpauseDeposits` is owner-only, so the guardian can stop all deposits until the owner acts. All of these only affect deposits; redeem and claim ignore them. This matches README.md and is an intentional safety lever, so it is reported as an undocumented-in-brief power rather than a defect.","line":200,"path":"src/BaskVault.sol","reproduction":"State: finalized vault, NAV_CAP = 1_000_000e18, a pending NavCap proposal to 2_000_000e18. Input: owner calls lowerNavCap(0) in one transaction. Then NAV_CAP == 0, proposalValid(navCapId) == false, and deposit([token0],[1e18],alice,0,now) reverts CapExceeded with no delay (existing test testNavCapLoweringVoidsRaisesAndCapApplies shows the mechanism with cap 100e18). Expected per the brief: a 2-day proposal within bounds. Actual: immediate and unbounded downwards.","severity":"info","snippet":"    function lowerNavCap(uint256 cap) external onlyOwner nonReentrant {","title":"Owner can lower NAV_CAP to any value including zero immediately, outside the proposal process the brief describes for settings"},{"citation":"resolved","description":"`_validate` only inspects `token` for asset actions. For Guardian, NavCap, FeeRecipient and Setting the README says token must be zero, but `propose` accepts any address and stores `epoch[token]` for validity. If the owner passes a listed asset by mistake, the proposal becomes invalid as soon as that asset is retired or removed, and `AssetChanged`-style tooling that reads `p.token` sees a global action attributed to an asset. Owner-only and harmless to funds, but it is a one-line input check (`if (action > T.Action.Resync && token != address(0)) revert InvalidInput();`) that would make the documented interface strict.","line":301,"path":"src/BaskVault.sol","reproduction":"State: finalized vault. Input: owner calls propose(FeeRecipient, address(tokens[0]), abi.encode(recipient)); it succeeds. Owner then closes tokens[0], proposes Retire and executes it after 2 days. proposalValid(feeId) is now false (verified by execution), so the fee recipient proposal can never execute even though nothing about it changed. Expected: the call is rejected at propose for a non-zero token, or validity is independent of an unrelated asset. Actual: accepted and later voided.","severity":"info","snippet":"        if (action <= T.Action.Resync && action != T.Action.List) {","title":"Global actions accept a non-zero token and are then keyed to that asset's epoch, so retiring or removing the asset silently voids the global proposal"}],"hash":"b29e34b4a280e17fe50aeb71d75025c2b7e9392112890e48416a8fd2458f8417","nodeId":"452516d2-b9d8-417c-854e-57acb491752f","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds five findings and the source tree is unchanged from the pinned commit.\n\n**Outcome: one medium defect, four informational deviations from the brief, nothing critical or high.** The areas the brief weighted most heavily held up under both reading and execution.\n\n**What I verified and found clean**\n\n- **Redeem and claim cannot be blocked.** No owner, guardian, setting, token behaviour, feed, pool, retirement or removal path reaches a revert in `redeem` or `claim`. The held bitmap and `heldCount` stay consistent through deposit, resync, loss, retire and swap-and-pop removal. Dead shares guarantee a redeem never drives `managed` to zero, so no hidden storage cost appears on that path.\n- **Gas envelope.** I probed six worst-case combinations the suite does not cover (inflated 512-bit accounting, fees, full minima arrays, fresh receiver, gas-burning tokens). All stayed under the limit. The tightest was 27,908,870 gas for 50 deferred assets at the 500,000 balance allowance.\n- **Pool check.** Orientation, 6-decimal and 18-decimal quotes, harmonic-mean liquidity, wrapped cumulatives and tick rounding match Uniswap's OracleLibrary. Extreme ticks with USDG or WETH quotes return the Pool reason rather than reverting. An overflow revert needs a sub-6-decimal quote token worth over about $270 million, outside the stated quotes.\n- **Minting, custody, proposals, ownership.** Only `deposit` mints, only the self-only `pay` moves tokens out, fees are exactly 0.5% each way once set and zero before, Resync cannot count owed tokens, and no sequence lets the guardian become owner or a stale proposal execute.\n\n**Findings written**\n\n- **Medium, with proof test.** `hasPause` is probed only at listing and nothing ever rewrites it. If an issuer upgrade breaks `oraclePaused()` on a held token, every deposit of every token reverts with `OraclePaused`, and executing Feed, Pool, Centre, Reopen or Resync leaves the flag set. The only exit is Retire, which hands that asset's value to post-retire depositors. The proof fails on the current code and passes once a proposal re-runs the probe.\n- **Info.** The 4× band check blocks all deposits after a large split until a 2-day Centre proposal. The 80-hour feed age also gates pooled assets, which a holiday weekend exceeds. `lowerNavCap` is immediate and unbounded downward. Global actions accept a non-zero token and are then voided by that asset's retirement.\n\n**Scope note.** The scratch tests under `test/scratch/` are disposable. I temporarily patched one function to confirm the proof passes under a fix, then restored the file with git, and `git diff` against HEAD confirms `src/` is identical to the pinned commit.","treeHash":null,"usage":{"cachedInputTokens":4279431,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":95843,"runtime":"claude","turns":51,"wallClockMs":1634934}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ce319efac2b76da0","findings":[{"citation":"resolved","description":"The brief says a deposit checks 'every deposited and every held unretired token'. _snapshot additionally performs a bounded balanceOf read on every unretired asset, including assets with managed == 0 that are not in the deposit (idle slots). For such an asset the only consumers of `bal` are `(wanted[i] && bal < debt)` (false because not wanted) and `available < m` (false because m == 0), so the read cannot change the solvency outcome; it only adds a failure mode. An issuer upgrade that makes balanceOf cost more than balanceGas (50,000 by default), revert, or return fewer than 32 bytes on a listed-but-never-deposited token blocks all deposits of every other asset until the owner retires it (close + 2-day Retire proposal) or raises balanceGas by proposal. The accepted-risk list covers a *held* asset whose pool or balance fails; it does not cover an idle one. The existing tests testZeroManagedNonInputMustStillHaveReadableBalance and testDepositChecksIdleUnreadableButSkipsRetired assert the current behaviour, so this is a deliberate choice; it is reported under item 6 (code does what the text does not say). Minimal fix preserving design: move the balance read inside `if (m != 0 || wanted[i])` and compute `available`/`debt` only there, leaving the retired-skip and the idle freshness branch untouched.","line":498,"path":"src/BaskVault.sol","reproduction":"VaultFixture setup (3 assets, genesis finalized). alice deposits 1e18 of tokens[0]. Then tokens[2] (idle, managed == 0, not an input) is upgraded so balanceOf reverts: tokens[2].setModes(1, 0). Expected per brief text: depositStatus([tokens[0]]) == (OK, 0) and a further deposit of tokens[0] succeeds because tokens[2] is neither deposited nor held. Actual: depositStatus([tokens[0]]) returns (BalanceUnreadable, tokens[2]) and deposit([tokens[0]], [1e18], alice, 0, now) reverts with DepositUnavailable(BalanceUnreadable, tokens[2]); redeem is unaffected.","severity":"low","snippet":"            (bool readable, uint256 bal) = O.balance(token, config.balanceGas);\n            if (!readable) return (T.Reason.BalanceUnreadable, token, 0, answers);","title":"Deposits require a readable balance on idle, never-held, non-input assets, which the brief does not ask for and which solvency does not need"},{"citation":"resolved","description":"The booked-path rule budgets 60,000 gas of vault overhead per asset. Measured overhead per held asset on the booked path is roughly 58,000 (two cold owed/totalOwed SSTOREs at 22,100 each, managed SLOAD+SSTORE, assetTokens SLOAD, cold token account access, call stipend and the 512-bit mulDiv). At the tightest permitted configuration, maxAssets = 50 with balanceGas = 500,000, the suite's testGasMaximumBalance50Assets records 27,897,644. Adding the one cost source the suite leaves out there (a full 50-entry minAmountsOut array) gives 27,908,869 for the call alone, which still satisfies the requirement, but leaves only 91,131 gas. A real transaction also pays the 21,000 intrinsic cost plus about 1.8 KB of calldata (~30,000), so the end-to-end transaction is about 27,960,000: inside 28,000,000 on a Cancun-schedule EVM but with no margin for any gas-schedule difference on the target chain (for example an L1 data surcharge counted in gasUsed on an Orbit-style rollup). No code defect; recorded so the owner can decide whether to keep balanceGas below 500,000 operationally or widen the per-asset allowance to 70,000 on the booked rule as well.","line":440,"path":"src/BaskVault.sol","reproduction":"Settings via proposals: MaxAssets=50, DirectLimit=0, BalanceGas=500000, PayGas=500000, DirectLimit=26. List 50 tokens (no pool), deposit 1e18 of each, resync each after minting uint256.max-1e18 to the vault, execute FeeRecipient. Make every balanceOf consume all forwarded gas (MockToken mode 2), close all assets, pause deposits, cool all accounts. Call redeem(shares/2, receiver, new uint256[](50), now) with exactly 28,000,000 gas. Expected: success under 28,000,000. Actual: success, 27,908,869 gas measured (test/scratch/Probe.t.sol::testGasWorstBookedCombination). Headroom 91,131.","severity":"info","snippet":"            s.maxAssets < assetTokens.length || s.maxAssets > 28_000_000 / (s.balanceGas + 60_000)","title":"Redemption gas headroom at the tightest in-bounds setting is 91,131 gas (0.33%) once fee, max managed, full minima and gas-burning tokens are combined"},{"citation":"resolved","description":"Item 6 of the brief asks for anything the code does that the text does not say. All of the following are documented in README.md and only ever block deposits (never redeem or claim), so none is a loss path, but they are not in the brief: (a) every priced asset must have a feed answer within [centre/band, centre*band] (band = 4) where centre is the feed answer recorded at listing, feed change, or a Centre proposal; a genuine >4x move or a split-adjusted feed therefore blocks all deposits for at least the 2-day Centre proposal delay; (b) a pooled asset's feed must also be under maxAge = 80 hours, independent of the 3% pool check; (c) optional Hours (weekday window) and FreshCount settings can block deposits globally; (d) close(token), pauseDeposits(), lowerNavCap(cap), genesisList and finalizeGenesis take effect immediately without the 2-day proposal path, and the guardian (not just the owner) can close any asset immediately. These are trust assumptions on the owner/guardian rather than defects.","line":88,"path":"src/libraries/BaskOracle.sol","reproduction":"VaultFixture (centre = 100e8, band = 4). Set feeds[0].set(25e8 - 1, block.timestamp). Expected per brief: price is a valid feed answer under 26 hours with no pool, so depositStatus([tokens[0]]) is OK. Actual: depositStatus returns (Band, tokens[0]) and deposit reverts with DepositUnavailable(Band, tokens[0]) until a Centre proposal executes two days later (test/Prices.t.sol::testBandBoundariesAndOraclePause demonstrates the boundary).","severity":"info","snippet":"            answer < a.centre / s.band || (answer / s.band > a.centre)","title":"Deposit blockers present in code but absent from the brief: price band around a stored centre, feed maxAge on pooled assets, trading hours, freshness count, and immediate close/pause/cap-lowering"}],"hash":"cabd5e8669e3af71202c38e92fc7c61cda6314146432cb52f7d9eae1fcf247cd","nodeId":"d09b9059-202e-428b-8a5d-d04fc86e49e8","outcome":"completed","summary":"The review is complete. The findings file is at `.imd-findings.json` in the repository root with three entries, none above low severity. No source file was modified; only `test/scratch/` and the findings file were created.\n\n**What I checked**\n\n- Redeem and claim cannot be blocked. I traced every revert path in `redeem`, `claim`, and `pay`: fee transfer, net burn, bitmap loop, the bounded balance read, the self-call with `payGas`, and owed/totalOwed booking. Managed never reaches zero through redemption (dead shares guarantee net < supply), so no zero-crossing bitmap writes occur on that path. Reentrancy from tokens hits the guard on every entry point, and `pay` rejects any caller but the vault.\n- Gas. All twelve suite measurements reproduce. I added the one cost source the suite omits at the tightest legal setting (50 assets, 500k balance gas, fee on, max managed, full minima, gas-burning tokens) and measured 27,908,869 for the call. That satisfies the requirement with 91k to spare, recorded as an info note.\n- Pool check. Orientation, 6-decimal and 18-decimal quotes, the harmonic-mean liquidity formula, tick rounding toward negative infinity, and the wrapped cumulatives match Uniswap's OracleLibrary. Extreme ticks with USDG and WETH quotes return `Reason.Pool` and `allAssets` does not revert; overflow needs an absurd quote feed, which the owner has accepted. The pool never enters share math.\n- Asset and share conservation. Only `pay` moves tokens, only `_mint` in `deposit` creates BASK, fee is exactly ceil(x/200) when the recipient is set and zero before. Resync cannot count owed tokens, and the invariant `balance >= managed + totalOwed` holds for honest tokens across every operation.\n- Proposals. Timelock, expiry, epoch and version invalidation (retire, removal, relisting, reclose, cap lowering), re-validation at execution, bounds and both gas inequalities, and every guardian-to-owner sequence are enforced.\n- Deposit math. Rounding is in the vault's favour, the first-deposit lock works, donations and issuer credits stay outside NAV until Resync, and the assembly blocks are memory-safe and correct.\n\n**Findings written**\n\n- **Low.** Deposits also require a readable balance on idle, never-held, non-input assets. The read cannot change the solvency result for such an asset and only adds a failure mode the brief does not list. Reproduced in `test/scratch/IdleRepro.t.sol`.\n- **Info.** Redemption gas headroom at the tightest in-bounds setting is 91,131 gas once every cost source is combined.\n- **Info.** Deposit blockers present in code but not in the brief: the 4x price band around a stored centre, the 80-hour feed age on pooled assets, the hours and freshness settings, and the immediate close, pause and cap-lowering powers.\n\n**Not found**\n\nNo critical or high defect, so no proof tests were attached. The accepted-risk list was respected; nothing I traced is worse than stated there.","treeHash":null,"usage":{"cachedInputTokens":2116415,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":85672,"runtime":"claude","turns":39,"wallClockMs":1338586}}],"verification":[]}