Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/contracts/contracts/compound/CErc20RetireDelegate.sol
Original file line number Diff line number Diff line change
@@ -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";
}
}
76 changes: 76 additions & 0 deletions packages/contracts/contracts/test/RetireFrozenMarketTest.t.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions packages/contracts/tasks/market/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
130 changes: 130 additions & 0 deletions packages/contracts/tasks/market/retire-frozen.ts
Original file line number Diff line number Diff line change
@@ -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<treasury>
*/
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.`
);
});
Loading