diff --git a/packages/contracts/contracts/compound/CErc20RetireDelegate.sol b/packages/contracts/contracts/compound/CErc20RetireDelegate.sol new file mode 100644 index 000000000..5d6ef0e62 --- /dev/null +++ b/packages/contracts/contracts/compound/CErc20RetireDelegate.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +import "./CErc20Delegate.sol"; + +/** + * @title CErc20RetireDelegate + * @notice One-shot remediation delegate used to retire a frozen/abandoned market. + * + * A market whose interest-rate model returns a per-block borrow rate above + * `borrowRateMaxMantissa` while `cash > totalFees` reverts every accruing entrypoint with + * `"!borrowRate"` (see CTokenFirstExtension._accrueInterestHypothetical). That freezes + * `mint`/`redeem`/`borrow`/`repay` and even `_setInterestRateModel`/`_withdraw*Fees`, which all + * call `accrueInterest()` first. The only admin lever that does not accrue is + * `CErc20Delegator._setImplementationSafe`, whose `_becomeImplementation(data)` hook runs during + * the upgrade. This delegate uses that hook to repair such a market. + * + * `_becomeImplementation` sweeps the entire underlying balance to a treasury address and zeroes + * the accounting (reserves, admin/ionic fees, borrows). With `totalBorrows == 0` utilization is 0, + * so the borrow rate drops to the base rate, `accrueInterest` stops reverting, and the market can + * then be unlisted via `Comptroller._unsupportMarket` (which requires `totalSupply() == 0`). + * + * Intended single-use flow (target market only — see tasks/market/retire-frozen.ts): + * 1. FeeDistributor owner: `_setCErc20DelegateExtensions(thisDelegate, [CTokenFirstExtension, thisDelegate])` + * 2. Pool admin: `market._setImplementationSafe(thisDelegate, abi.encode(treasury))` + * 3. Pool admin: `comptroller._unsupportMarket(market)` + * 4. (optional) Pool admin: `market._upgrade()` to restore the standard delegate. + * + * WARNING: this writes down protocol accounting and moves funds. It must NEVER be registered as + * the latest delegate for any delegateType, and must only be applied to a market that has been + * confirmed abandoned (totalSupply == 0). Validate the full sequence on a fork first. + */ +contract CErc20RetireDelegate is CErc20Delegate { + event MarketRetired(address indexed underlyingToken, address indexed treasury, uint256 sweptAmount); + + /** + * @notice One-shot retirement hook invoked by the delegator during `_setImplementationSafe`. + * @param data abi.encode(address treasury) — recipient of the swept underlying. + */ + function _becomeImplementation(bytes memory data) public override { + require(msg.sender == address(this) || hasAdminRights(), "!self || !admin"); + + address treasury = abi.decode(data, (address)); + require(treasury != address(0), "!treasury"); + + // Sweep the full underlying balance to the treasury using the inherited safe-transfer path. + uint256 sweptAmount = getCashInternal(); + if (sweptAmount > 0) { + doTransferOut(treasury, sweptAmount); + } + + // Zero the degenerate accounting so utilization (and therefore the borrow rate) returns to a + // sane value; with totalBorrows == 0 the rate is the base rate and accrueInterest no longer + // reverts with "!borrowRate". totalBorrows is written off because the market is being retired. + totalReserves = 0; + totalAdminFees = 0; + totalIonicFees = 0; + totalBorrows = 0; + accrualBlockNumber = block.number; + + emit MarketRetired(underlying, treasury, sweptAmount); + } + + function contractType() external pure override returns (string memory) { + return "CErc20RetireDelegate"; + } +} diff --git a/packages/contracts/contracts/test/RetireFrozenMarketTest.t.sol b/packages/contracts/contracts/test/RetireFrozenMarketTest.t.sol new file mode 100644 index 000000000..cab5d85df --- /dev/null +++ b/packages/contracts/contracts/test/RetireFrozenMarketTest.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import { UpgradesBaseTest } from "./UpgradesBaseTest.sol"; +import { DiamondExtension } from "../ionic/DiamondExtension.sol"; +import { ICErc20 } from "../compound/CTokenInterfaces.sol"; +import { IonicComptroller } from "../compound/ComptrollerInterface.sol"; +import { EIP20Interface } from "../compound/EIP20Interface.sol"; +import { CErc20RetireDelegate } from "../compound/CErc20RetireDelegate.sol"; + +/** + * @notice Fork test for retiring the frozen `ionmsUSD` market on Base. + * + * The market `0x5BE1Cb6CB3C9bfd16Db43ed4f6c081FA9783dd1C` reverts every accruing call with + * `"!borrowRate"`: its reserves+fees grew to ~= cash+borrows, collapsing the utilization + * denominator so the IRM returns a per-block rate far above `borrowRateMaxMantissa`. `totalSupply` + * is 0 (no suppliers). This test proves the retirement sequence works end-to-end against live state: + * upgrade to `CErc20RetireDelegate` (sweep + zero accounting, no accrual) → unfreeze → unlist. + * + * Run with the Base fork: + * TEST_RUN_CHAINID=8453 BASE_MAINNET_RPC_URL=... forge test --mc RetireFrozenMarketTest -vvv + */ +contract RetireFrozenMarketTest is UpgradesBaseTest { + ICErc20 internal constant ionmsUSD = ICErc20(0x5BE1Cb6CB3C9bfd16Db43ed4f6c081FA9783dd1C); + + function testRetireFrozenIonmsUSD() public fork(BASE_MAINNET) { + address treasury = makeAddr("retireTreasury"); + EIP20Interface underlyingToken = EIP20Interface(ionmsUSD.underlying()); + IonicComptroller pool = ionmsUSD.comptroller(); + + // 1. Pre-conditions: abandoned (no suppliers) and frozen on every accruing call. + assertEq(ionmsUSD.totalSupply(), 0, "expected no suppliers (abandoned market)"); + uint256 cashBefore = underlyingToken.balanceOf(address(ionmsUSD)); + assertGt(cashBefore, 0, "expected underlying cash to sweep"); + + vm.expectRevert(bytes("!borrowRate")); + ionmsUSD.accrueInterest(); + + // 2. Deploy the one-shot retire delegate and register its extensions (FeeDistributor owner). + CErc20RetireDelegate retireImpl = new CErc20RetireDelegate(); + DiamondExtension[] memory exts = new DiamondExtension[](2); + exts[0] = marketExt; // shared CTokenFirstExtension (deployed in UpgradesBaseTest.afterForkSetUp) + exts[1] = retireImpl; + vm.prank(ffd.owner()); + ffd._setCErc20DelegateExtensions(address(retireImpl), exts); + + // 3. Upgrade the market to the retire delegate (pool/ionic admin). Runs _becomeImplementation + // WITHOUT accruing: sweeps the underlying to treasury and zeroes the accounting. + vm.prank(address(ffd)); + ionmsUSD._setImplementationSafe(address(retireImpl), abi.encode(treasury)); + + // 4. Post-conditions: funds swept, accounting zeroed, market no longer frozen. + assertEq(underlyingToken.balanceOf(treasury), cashBefore, "treasury should receive all swept cash"); + assertEq(underlyingToken.balanceOf(address(ionmsUSD)), 0, "market underlying balance should be 0"); + assertEq(ionmsUSD.totalReserves(), 0, "totalReserves should be zeroed"); + assertEq(ionmsUSD.totalAdminFees(), 0, "totalAdminFees should be zeroed"); + assertEq(ionmsUSD.totalIonicFees(), 0, "totalIonicFees should be zeroed"); + assertEq(ionmsUSD.totalBorrows(), 0, "totalBorrows should be written off"); + // accrueInterest must now succeed (no "!borrowRate" revert). + assertEq(ionmsUSD.accrueInterest(), 0, "accrueInterest should succeed after retirement"); + + // 5. Unlist the market from the pool (requires totalSupply == 0, still true). + vm.prank(pool.admin()); + uint256 err = pool._unsupportMarket(ionmsUSD); + assertEq(err, 0, "unsupportMarket should succeed"); + assertFalse(_isListed(pool, ionmsUSD), "market should be unlisted"); + } + + function _isListed(IonicComptroller pool, ICErc20 market) internal view returns (bool) { + ICErc20[] memory all = pool.getAllMarkets(); + for (uint256 i = 0; i < all.length; i++) { + if (address(all[i]) == address(market)) return true; + } + return false; + } +} diff --git a/packages/contracts/tasks/market/index.ts b/packages/contracts/tasks/market/index.ts index 60eaaeba3..d38ad4505 100644 --- a/packages/contracts/tasks/market/index.ts +++ b/packages/contracts/tasks/market/index.ts @@ -7,6 +7,7 @@ import "./deploy-dynamic-rewards-market"; import "./deploy-static-rewards-market"; import "./deploy"; import "./borrow"; +import "./retire-frozen"; import { Address } from "viem"; import { HardhatRuntimeEnvironment } from "hardhat/types"; diff --git a/packages/contracts/tasks/market/retire-frozen.ts b/packages/contracts/tasks/market/retire-frozen.ts new file mode 100644 index 000000000..a83a74134 --- /dev/null +++ b/packages/contracts/tasks/market/retire-frozen.ts @@ -0,0 +1,130 @@ +import { task, types } from "hardhat/config"; +import { Address, encodeAbiParameters, Hash, parseAbiParameters } from "viem"; +import { prepareAndLogTransaction } from "../../chainDeploy/helpers/logging"; + +/** + * Retire a frozen/abandoned cToken market that reverts every accruing call with "!borrowRate". + * + * Such a market cannot be repaired through any accruing entrypoint (mint/redeem/borrow/repay, + * _setInterestRateModel, _withdraw*Fees all accrue first). The only non-accruing admin lever is + * `_setImplementationSafe`, so we upgrade the market to a one-shot `CErc20RetireDelegate` whose + * `_becomeImplementation(abi.encode(treasury))` sweeps the underlying to `treasury` and zeroes the + * accounting (reserves/fees/borrows). That unfreezes `accrueInterest`, after which the market can be + * unlisted via `_unsupportMarket` (requires totalSupply == 0). + * + * SAFETY: refuses to run unless `totalSupply == 0` (no suppliers). Validate on a fork first + * (see contracts/test/RetireFrozenMarketTest.t.sol). + * + * Example: + * yarn hardhat market:retire-frozen --network base \ + * --market 0x5BE1Cb6CB3C9bfd16Db43ed4f6c081FA9783dd1C \ + * --treasury 0x + */ +export default task("market:retire-frozen", "Retire a frozen/abandoned market: sweep funds, zero state, unlist") + .addParam("market", "Address of the frozen cToken market", undefined, types.string) + .addParam("treasury", "Recipient of the swept underlying", undefined, types.string) + .addOptionalParam("unsupport", "Whether to unlist the market from the pool after retiring", true, types.boolean) + .setAction(async ({ market, treasury, unsupport }, { viem, deployments, getNamedAccounts }) => { + const { deployer } = await getNamedAccounts(); + const publicClient = await viem.getPublicClient(); + + const cToken = await viem.getContractAt("ICErc20", market as Address); + + // --- Safety guard: only retire abandoned markets (no suppliers) --- + const totalSupply = await cToken.read.totalSupply(); + if (totalSupply !== 0n) { + throw new Error(`Refusing to retire ${market}: totalSupply is ${totalSupply} (expected 0 — has suppliers).`); + } + + const underlying = await cToken.read.underlying(); + const comptrollerAddress = await cToken.read.comptroller(); + const cash = await (await viem.getContractAt("ICErc20", underlying)).read.balanceOf([market as Address]); + console.log(`Retiring market ${market} (underlying ${underlying}); sweeping ${cash} to ${treasury}`); + + const feeDistributor = await viem.getContractAt( + "FeeDistributor", + (await deployments.get("FeeDistributor")).address as Address + ); + const cTokenFirstExtension = (await deployments.get("CTokenFirstExtension")).address as Address; + + // 1. Deploy the one-shot retire delegate. + const retire = await deployments.deploy("CErc20RetireDelegate", { + from: deployer, + args: [], + log: true, + waitConfirmations: 1 + }); + if (retire.transactionHash) await publicClient.waitForTransactionReceipt({ hash: retire.transactionHash as Hash }); + const retireDelegate = retire.address as Address; + console.log(`CErc20RetireDelegate: ${retireDelegate}`); + + // 2. Register the retire delegate's extensions in the FeeDistributor (owner). Mirrors the + // canonical [delegate, CTokenFirstExtension] registration in 03-deploy-ctokens-set-extensions. + const ffdOwner = await feeDistributor.read.owner(); + const currentExts = await feeDistributor.read.getCErc20DelegateExtensions([retireDelegate]); + const extsArgs: [Address, Address[]] = [retireDelegate, [retireDelegate, cTokenFirstExtension]]; + if (currentExts.length < 2 || currentExts[0] !== retireDelegate || currentExts[1] !== cTokenFirstExtension) { + if (ffdOwner.toLowerCase() !== deployer.toLowerCase()) { + await prepareAndLogTransaction({ + contractInstance: feeDistributor, + functionName: "_setCErc20DelegateExtensions", + args: extsArgs, + description: "Register CErc20RetireDelegate extensions", + inputs: [ + { internalType: "address", name: "cErc20Delegate", type: "address" }, + { internalType: "address[]", name: "extensions", type: "address[]" } + ] + }); + } else { + const tx = await feeDistributor.write._setCErc20DelegateExtensions(extsArgs); + await publicClient.waitForTransactionReceipt({ hash: tx }); + console.log(`Registered extensions for retire delegate: ${tx}`); + } + } else { + console.log(`Retire delegate extensions already registered`); + } + + // 3. Upgrade the market to the retire delegate (pool/ionic admin). This sweeps + zeroes state. + const becomeData = encodeAbiParameters(parseAbiParameters("address"), [treasury as Address]); + const cTokenDelegator = await viem.getContractAt("CErc20Delegator", market as Address); + const comptroller = await viem.getContractAt("IonicComptroller", comptrollerAddress as Address); + const poolAdmin = await comptroller.read.admin(); + if (poolAdmin.toLowerCase() !== deployer.toLowerCase()) { + await prepareAndLogTransaction({ + contractInstance: cTokenDelegator, + functionName: "_setImplementationSafe", + args: [retireDelegate, becomeData], + description: `Retire market ${market}: set CErc20RetireDelegate (sweep to ${treasury})`, + inputs: [ + { internalType: "address", name: "implementation_", type: "address" }, + { internalType: "bytes", name: "implementationData", type: "bytes" } + ] + }); + } else { + const tx = await cTokenDelegator.write._setImplementationSafe([retireDelegate, becomeData]); + await publicClient.waitForTransactionReceipt({ hash: tx }); + console.log(`Retired market (swept + zeroed) via _setImplementationSafe: ${tx}`); + } + + // 4. Unlist the market from the pool (admin). Requires totalSupply == 0 (already enforced). + if (unsupport) { + if (poolAdmin.toLowerCase() !== deployer.toLowerCase()) { + await prepareAndLogTransaction({ + contractInstance: comptroller, + functionName: "_unsupportMarket", + args: [market as Address], + description: `Unlist retired market ${market}`, + inputs: [{ internalType: "address", name: "cToken", type: "address" }] + }); + } else { + const tx = await comptroller.write._unsupportMarket([market as Address]); + await publicClient.waitForTransactionReceipt({ hash: tx }); + console.log(`Unsupported (unlisted) market: ${tx}`); + } + } + + console.log( + `Done. If multisig transactions were queued, execute them in order: ` + + `(1) register extensions, (2) _setImplementationSafe, (3) _unsupportMarket.` + ); + });