← back to lessons

Compound III (Comet) Extended Asset List - dual-bitmap assetsIn invariant

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

The mechanism

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)

What an auditor MUST verify (the invariant checklist this surface demands)

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:

  1. 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.
  2. 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.
  3. Order: the absorb reset must happen AFTER 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.
  4. Boundary: numAssets==24 places an asset at offset 23 -> _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.
  5. Cross-contract leak: the formerly-padding _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.

Cycle 194 result

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.

Reusable rule

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.

Generated 2026-07-02 13:15:05 UTC | auto-sync /15min