assetsIn invariantBanked: Cycle 194 (compound-finance/comet @ d5a30b0, PR #904 "Comet with extended asset list"). Class: lending accounting / storage-field-repurposing correctness (NOVEL beyond banked erc4626-invariant / aave-acl / generic-rounding patterns).
Stock Comet packs each user's "which collateral assets do I hold" into a single
uint16 assetsIn bitmap in UserBasic -> hard cap of 16 (effectively 15) collateral assets.
The new CometWithExtendedAssetList raises the cap to 24 assets by repurposing the
formerly-unused uint8 _reserved padding field of UserBasic as the high byte of the
bitmap (asset offsets 16..23 -> _reserved bits 0..7). Asset metadata itself moves out of
contract-immutables into a separate AssetList contract reached by external staticcall.
So the "is user in asset i" check becomes a dual bitmap:
- offset < 16 -> bit in assetsIn
- 16 <= offset < 24 -> bit in _reserved
(isInAsset / updateAssetsIn, CometWithExtendedAssetList.sol:590-629)
A storage-field-repurposing change like this creates a class of bug where the high byte is
handled in SOME paths but silently dropped in others. If even one solvency-relevant loop
reads assetsIn without also reading _reserved, collateral in slots 16-23 becomes
INVISIBLE to that path. Concrete failure modes to test:
isBorrowCollateralized / isLiquidatable MUST read AND pass _reserved into every
isInAsset call - otherwise high-slot collateral is ignored => either phantom
under-collateralization (DoS) or, worse, ignored DEBT-backing => bad-debt / free borrow.absorbInternal MUST (a) pass _reserved to the seize loop AND (b) zero BOTH assetsIn
and _reserved after absorb. A reset that zeroes only assetsIn leaves orphaned
_reserved bits => post-absorb accounting drift / re-absorb griefing.updateBasePrincipal, because
updateBasePrincipal writes back the WHOLE UserBasic struct (userBasic[account]=basic)
from an in-memory copy taken BEFORE the reset (CometWithExtendedAssetList.sol:652). Reset
before that call would be silently overwritten._reserved bit 7. isInAsset
guards assetOffset < 24. 16 (assetsIn) + 8 (_reserved) = exactly 24 bits, zero margin.
Any future bump past 24 with no new storage field is an immediate overflow/truncation bug._reserved is now meaningful. Any OTHER contract
(CometExt, CometRewards, migrators, off-chain indexers) that historically treated
UserBasic._reserved as zero padding, or that re-derives held-assets from the 16-bit
assetsIn alone, will MISS slots 16-23.All five checks PASS at HEAD. Every isInAsset call site (lines 391, 434, 1069) reads and
passes _reserved; absorb zeroes both fields (1095-1096) after updateBasePrincipal (1092);
boundary is exactly covered; CometExt/CometRewards never touch UserBasic._reserved
(only baseTrackingAccrued). The asset bit-packing in AssetList.sol:118-167 is a byte-for-byte
faithful port of stock Comet.getPackedAssetInternal. Verdict: clean walk on this surface.
When a protocol upgrade RAISES a hardcoded item-count cap (assets, markets, tokens) by
repurposing a previously-reserved/padding storage field as extra bitmap/index space, do NOT
audit the new high-range handling in isolation. Enumerate EVERY read site of the original
narrow field across the WHOLE codebase (incl. extension delegates, reward modules, migrators)
and prove each one was updated to also consult the new high field. The bug is never in the
one obvious new function; it is in the one OLD loop that nobody remembered also iterates the
asset set. Grep git log -p for the original field name; any read that survived the diff
unchanged is a prime suspect for a dropped-high-bits solvency bug.