Compound V3 (Comet) Borrower Mechanics on Four Chains
Compound V3's isolated-base-asset design is a fundamentally different invariant from Aave V3's single-pool model. Borrow is implemented as withdrawing the base asset past zero; liquidations go through absorb(), not liquidationCall. Here is how the adapter wraps that.
Part of the Protocol coverage series.
Compound V3 (codenamed Comet) is not Compound V2 with a version bump. Aave V3 is not Compound V3 with a different address. Both are obvious to anyone who has integrated either. Both are easy to forget when you're building one adapter and squinting at the second.
This post walks the Comet invariant, the four-chain Compound V3 deployment Mayavi simulates today, and the dispatch shape that's deliberately different from Aave V3's — different because the protocol is different, not because we wanted variety.
The Comet invariant
Aave V3's mental model: one pool, many reserves, each reserve can be supplied and borrowed by different users. Earning interest as a supplier means lending USDC to other users who borrow USDC.
Comet's mental model: one base asset per pool, several allowed collateral assets. Collateral cannot earn interest. Only the base asset accrues. To borrow, you withdraw the base past zero (the protocol auto-creates a borrow position denominated in the base asset). To liquidate, the network calls absorb(absorber, accounts[]) and the protocol absorbs the bad debt onto its own balance sheet — the absorber gets a bonus, but there's no per-borrower liquidationCall like Aave V3's.
Practical consequences for the adapter:
- The dispatch key is
(chain_id, base_asset_symbol), not justchain_id. Each base asset has its own Comet instance on each chain. Mainnet alone runs Comet-USDC + Comet-USDT + Comet-WETH. supply(asset, amount)does double duty. Ifasset == baseToken, you're lending (or repaying a borrow). Ifassetis a registered collateral, you're depositing collateral.borrow(amount)is implemented aswithdraw(baseToken, amount)past the supplied balance.- Liquidation is a different opcode-sequence entirely.
The adapter's CompoundV3Account class lives in mayavi/protocols/compound_v3.py:
class CompoundV3Account:
def __init__(
self,
fork: Fork,
account: bytes,
*,
base_asset_symbol: str = "USDC",
) -> None:
self._comet_address = _resolve_comet(
chain_id=fork.config.chain_id,
base_asset_symbol=base_asset_symbol,
)_resolve_comet reads the (chain_id, base_asset_symbol) tuple from _COMPOUND_V3_COMET_BY_CHAIN. Constructing against an unsupported pair raises UnsupportedCompoundV3ChainError at construction time.
Four chains, four bundles
Phase 5 ships Compound V3 USDC Comets on mainnet, Arbitrum, Base, and Polygon. Each has its own bundle:
Optimism's Compound V3 deployment is deferred to Phase 6. The matrix gates _MIN_COMMITTED_ARTIFACTS = 21 ratcheted to include four Compound bundles, not six — the gate is honest about what's actually shipped, not aspirational.
Why supply and borrow share a code path
The original draft had two methods:
def supply_collateral(self, asset: bytes, amount: int) -> CallResult: ...
def supply_base(self, amount: int) -> CallResult: ...
def borrow_base(self, amount: int) -> CallResult: ...The 4-byte selector for all three is the same selector (supply(address,uint256) or withdraw(address,uint256)). On chain, Comet decides via asset == baseToken. The three-method API just re-implements that branching in Python — adding a surface area for a bug where the Python branch and the Comet branch disagree.
The shipped API is the minimal one Comet itself recognises:
def supply(self, asset: bytes, amount: int) -> CallResult: ...
def withdraw(self, asset: bytes, amount: int) -> CallResult: ...
def absorb(self, absorber: bytes, accounts: list[bytes]) -> CallResult: ...The semantic interpretation of supply(USDC, 1000) depends on whether USDC is the Comet's base token at runtime — which is the contract's own determination. The adapter doesn't second-guess it.
The price feed override
Comet has its own price oracle path. Aave V3 reads prices from AaveOracle which itself reads from Chainlink aggregators. Comet has getPrice(priceFeed) calls that route through an internal IPriceFeed interface. For the depeg-cascade scenarios that ship on Aave V3, we use mayavi.protocols.aave_oracle.OracleOverride — a per-step bytecode-stub that overrides the aggregator's reported price. The Comet equivalent is CompoundV3PriceFeedOverride, structurally identical but pointed at Comet's price-feed selectors.
We don't ship a Compound depeg-cascade scenario today because the bottleneck for Phase 5 was breadth (six Aave chains × two scenarios + four Compound chains × one scenario + ...). A Compound depeg cascade is one new YAML + the price-feed override class away. It's a future post, not a future protocol problem.
Where this leaves the protocol-coverage story
Two protocols down, two to go. Compound V3's dispatch pattern is the second variation on the same theme: per-chain registry, no silent fallback, fail-loud on misconfiguration. The next two are both single-mainnet protocols but illustrate two different reuse stories:
- Post 6 — SparkLend is almost trivially an Aave V3 fork. The adapter is a 30-line subclass. This is the cleanest "thin adapter" pattern in the codebase.
- Post 7 — Curve StableSwap is a genuinely new invariant — the stableswap curve, the N-coin variant, depeg cascade dynamics — and gets its own adapter from scratch.