From cd4b4d90761c2475ecaba21a5bd38780d0e5b3a7 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Thu, 12 Dec 2024 17:41:45 +0200 Subject: [PATCH] MidasRWA: mBTC integration --- .../plugins/assets/midas/MidasCollateral.sol | 150 ++++++++++++ contracts/plugins/assets/midas/README.md | 153 ++++++++++++ .../assets/midas/interfaces/IMToken.sol | 46 ++++ .../midas/interfaces/IMidasDataFeed.sol | 16 ++ .../individual-collateral/midas/constants.ts | 12 + .../midas/mbtc.fixture.ts | 110 +++++++++ .../individual-collateral/midas/mbtc.test.ts | 224 ++++++++++++++++++ .../midas/midasAggregator.ts | 4 + .../midas/mtbill.fixture.ts | 94 ++++++++ .../midas/mtbill.test.ts | 180 ++++++++++++++ 10 files changed, 989 insertions(+) create mode 100644 contracts/plugins/assets/midas/MidasCollateral.sol create mode 100644 contracts/plugins/assets/midas/README.md create mode 100644 contracts/plugins/assets/midas/interfaces/IMToken.sol create mode 100644 contracts/plugins/assets/midas/interfaces/IMidasDataFeed.sol create mode 100644 test/plugins/individual-collateral/midas/constants.ts create mode 100644 test/plugins/individual-collateral/midas/mbtc.fixture.ts create mode 100644 test/plugins/individual-collateral/midas/mbtc.test.ts create mode 100644 test/plugins/individual-collateral/midas/midasAggregator.ts create mode 100644 test/plugins/individual-collateral/midas/mtbill.fixture.ts create mode 100644 test/plugins/individual-collateral/midas/mtbill.test.ts diff --git a/contracts/plugins/assets/midas/MidasCollateral.sol b/contracts/plugins/assets/midas/MidasCollateral.sol new file mode 100644 index 000000000..d4ef87dfc --- /dev/null +++ b/contracts/plugins/assets/midas/MidasCollateral.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import { CollateralStatus, ICollateral, IAsset } from "../../../interfaces/IAsset.sol"; +import "../../../libraries/Fixed.sol"; +import "../AppreciatingFiatCollateral.sol"; +import "./interfaces/IMidasDataFeed.sol"; +import "./interfaces/IMToken.sol"; + +/** + * @title MidasCollateral + * @notice A collateral plugin for Midas tokens (mBTC, mTBILL, mBASIS). + * + * ## Scenarios + * + * - mBTC: + * {target}=BTC, {ref}=BTC => {target/ref}=1. + * Need {UoA/target}=USD/BTC from a Chainlink feed. + * Price(UoA/tok) = (USD/BTC)*1*(BTC/mBTC) = USD/mBTC + * + * - mTBILL/mBASIS: + * {target}=USD, {ref}=USDC(=USD) => {target/ref}=1 + * {UoA/target}=1 (hardcoded, stable USD) + * Price(UoA/tok)=1*1*(USDC/token)=USD/token + * + * The contract handles both: + * - If targetName="BTC", must provide a USD/BTC feed for {UoA/target}. + * - If targetName="USD", no feed needed; {UoA/target}=1. + * + * ## Behavior + * - Uses IMidasDataFeed for {ref/tok}. + * - For BTC target, uses chainlink feed to get USD/BTC. + * - For USD target, hardcodes {UoA/target}=1, no feed needed. + * - On pause: IFFY then DISABLED after delay. + * - On blacklist: DISABLED immediately. + * - If refPerTok() decreases: DISABLED (handled by AppreciatingFiatCollateral). + */ +contract MidasCollateral is AppreciatingFiatCollateral { + using FixLib for uint192; + using OracleLib for AggregatorV3Interface; + + error InvalidTargetName(); + + bytes32 public constant BLACKLISTED_ROLE = keccak256("BLACKLISTED_ROLE"); + + IMidasDataFeed public immutable refPerTokFeed; + IMToken public immutable mToken; + + AggregatorV3Interface public immutable uoaPerTargetFeed; // {UoA/target}, required if target=BTC + uint48 public immutable uoaPerTargetFeedTimeout; // {s}, only applicable if target=BTC + bytes32 public immutable collateralTargetName; + + /** + * @param config CollateralConfig + * @param refPerTokFeed_ IMidasDataFeed for {ref/tok} + * @param revenueHiding (1e-4 for 10 bps) + */ + constructor( + CollateralConfig memory config, + uint192 revenueHiding, + IMidasDataFeed refPerTokFeed_, + uint48 refPerTokTimeout_ + ) AppreciatingFiatCollateral(config, revenueHiding) { + require(address(refPerTokFeed_) != address(0), "invalid refPerTok feed"); + + mToken = IMToken(address(config.erc20)); + collateralTargetName = config.targetName; + uoaPerTargetFeed = config.chainlinkFeed; + uoaPerTargetFeedTimeout = config.oracleTimeout; + refPerTokFeed = refPerTokFeed_; + } + + + /// @return {ref/tok} + function underlyingRefPerTok() public view override returns (uint192) { + uint256 rawPrice = refPerTokFeed.getDataInBase18(); + if (rawPrice > uint256(FIX_MAX)) revert UIntOutOfBounds(); + return uint192(rawPrice); + } + + /// @return {target/ref}=1 always (BTC/BTC=1, USD/USDC=1) + function targetPerRef() public pure override returns (uint192) { + return FIX_ONE; + } + + /** + * @dev Calculate price(UoA/tok): + * price(UoA/tok) = (UoA/target) * (target/ref) * (ref/tok) = (chainlinkFeed price) * 1 * (underlyingRefPerTok()) + * + * For mBTC: {UoA/target}=USD/BTC, refPerTok=BTC/mBTC => USD/mBTC + * For mTBILL/mBASIS as mToken: {UoA/target}=USD/USD=1, refPerTok=USDC/mToken (treated as USD), => USD/mToken + */ + function tryPrice() + external + view + override + returns ( + uint192 low, + uint192 high, + uint192 pegPrice + ) + { + uint192 uoaPerTarget; + if (collateralTargetName == bytes32("BTC")) { + uoaPerTarget = uoaPerTargetFeed.price(uoaPerTargetFeedTimeout); + } else { + uoaPerTarget = FIX_ONE; + } + + uint192 refPerTok_ = underlyingRefPerTok(); + + uint192 p = uoaPerTarget.mul(refPerTok_); + uint192 err = p.mul(oracleError, CEIL); + + low = p - err; + high = p + err; + + pegPrice = FIX_ONE; + } + + /** + * @dev Checks pause/blacklist state before normal refresh. + * - Blacklisted => DISABLED + * - Paused => IFFY then eventually DISABLED + */ + function refresh() public override { + CollateralStatus oldStatus = status(); + + if (mToken.accessControl().hasRole(BLACKLISTED_ROLE, address(this))) { + markStatus(CollateralStatus.DISABLED); + } else if (mToken.paused()) { + markStatus(CollateralStatus.IFFY); + } else { + // Attempt to get refPerTok. If this fails, the feed is stale or invalid. + try this.underlyingRefPerTok() returns (uint192 /* refValue */) { + super.refresh(); + } catch (bytes memory errData) { + if (errData.length == 0) revert(); + markStatus(CollateralStatus.IFFY); + } + } + + CollateralStatus newStatus = status(); + if (oldStatus != newStatus) { + emit CollateralStatusChanged(oldStatus, newStatus); + } + } +} diff --git a/contracts/plugins/assets/midas/README.md b/contracts/plugins/assets/midas/README.md new file mode 100644 index 000000000..7808f5277 --- /dev/null +++ b/contracts/plugins/assets/midas/README.md @@ -0,0 +1,153 @@ +# Midas Collateral Plugin (mBTC, mTBILL, mBASIS) + +## Overview + +This collateral plugin integrates Midas tokens (mBTC, mTBILL, mBASIS) into the Reserve Protocol as collateral. It supports both BTC-based and USD-based targets: + +- **mBTC (BTC-based):** + - `{target}=BTC`, `{ref}=BTC`, so `{target/ref}=1`. + - A Chainlink feed provides `{UoA/target}=USD/BTC`. + - `price(UoA/tok) = (USD/BTC)*1*(BTC/mBTC) = USD/mBTC`. + +- **mTBILL, mBASIS (USD-based):** + - `{target}=USD`, `{ref}=USDC(≈USD)`, so `{target/ref}=1`. + - Since `{UoA}=USD` and `{target}=USD`, `{UoA/target}=1` directly, no external feed needed. + - `price(UoA/tok)=1*1*(USDC/mToken)=USD/mToken`. + +This plugin uses a Midas data feed (`IMidasDataFeed`) to obtain `{ref/tok}`, and leverages `AppreciatingFiatCollateral` to handle revenue hiding and immediate defaults if `refPerTok()` decreases. + +### Socials +- Telegram: https://t.me/midasrwa +- Twitter (X): https://x.com/MidasRWA + +## Units and Accounting + +### mBTC Units + +| | Unit | +|------------|---------| +| `{tok}` | mBTC | +| `{ref}` | BTC | +| `{target}` | BTC | +| `{UoA}` | USD | + +### mTBILL / mBASIS Units + +| | Unit | +|------------|------------------| +| `{tok}` | mTBILL or mBASIS | +| `{ref}` | USDC (≈USD) | +| `{target}` | USD | +| `{UoA}` | USD | + + +All scenarios: `{target/ref}=1`. + +## Key Points + +- For mBTC: Requires a Chainlink feed for `{UoA/target}` (USD/BTC). +- For mTBILL/mBASIS: `{UoA/target}=1`, no Chainlink feed needed. +- On pause: transitions collateral to `IFFY` then `DISABLED` after `delayUntilDefault`. +- On blacklist: immediately `DISABLED`. +- If `refPerTok()` ever decreases: immediately `DISABLED`. +- Uses `AppreciatingFiatCollateral` for smoothing small dips in `refPerTok()` (revenue hiding of 10 bps). + +## References + +The Midas Collateral plugin interacts with several Midas-specific contracts and interfaces + +### IMidasDataFeed +- **Purpose**: Provides the `{ref/tok}` exchange rate (scaled to 1e18) for Midas tokens. +- **Usage in Plugin**: The collateral plugin calls `getDataInBase18()` to fetch a stable reference rate. +- **Examples**: + - mBTC Data Feed: [0x9987BE0c1dc5Cd284a4D766f4B5feB4F3cb3E28e](https://etherscan.io/address/0x9987BE0c1dc5Cd284a4D766f4B5feB4F3cb3E28e) + - mTBILL Data Feed: [0xfCEE9754E8C375e145303b7cE7BEca3201734A2B](https://etherscan.io/address/0xfCEE9754E8C375e145303b7cE7BEca3201734A2B) + +### IMToken (mBTC, mTBILL) +- **Purpose**: Represents Midas tokens as ERC20 with additional pause/unpause features. +- **Examples**: + - mBTC: [0x007115416AB6c266329a03B09a8aa39aC2eF7d9d](https://etherscan.io/address/0x007115416AB6c266329a03B09a8aa39aC2eF7d9d) + - mTBILL: [0xDD629E5241CbC5919847783e6C96B2De4754e438](https://etherscan.io/address/0xDD629E5241CbC5919847783e6C96B2De4754e438) + +## Price Calculation + +`price(UoA/tok) = (UoA/target) * (target/ref) * (ref/tok)` + +- mBTC: `(UoA/target)=USD/BTC` (from Chainlink), `(ref/tok)=BTC/mBTC` → `USD/mBTC`. +- mTBILL/mBASIS: `(UoA/target)=1`, `(ref/tok)=USDC/mToken` (≈USD/mToken) → `USD/mToken`. + +## Pre-Implementation Q&A + +1. **Units:** + + - `{tok}`: Midas token + - `{ref}`: mBTC -> BTC, mTBILL/mBASIS -> USDC(≈USD) + - `{target}`: mBTC -> BTC, mTBILL/mBASIS -> USD + - `{UoA}`: USD + +2. **Wrapper needed?** + No. Midas tokens are non-rebasing standard ERC-20 tokens. No wrapper is required. + +3. **3 Internal Prices:** + + - `{ref/tok}` from `IMidasDataFeed` + - `{target/ref}=1` + - `{UoA/target}`: + - mBTC: from Chainlink (USD/BTC) + - mTBILL/mBASIS: 1 + +4. **Trust Assumptions:** + + - Rely on Chainlink feeds for USD/BTC (mBTC case). + - Assume stable `{UoA/target}=1` for USD-based tokens. + - Trust `IMidasDataFeed` for `refPerTok()`. + +5. **Protocol-Specific Metrics:** + + - Paused => IFFY => DISABLED after delay + - Blacklisted => DISABLED immediately + - `refPerTok()` drop => DISABLED + +6. **Unique Abstractions:** + + - One contract supports both BTC and USD targets with conditional logic. + - Revenue hiding to smooth tiny dips. + +7. **Revenue Hiding Amount:** + A small value like `1e-4` (10 bps) recommended and implemented in constructor parameters. + +8. **Rewards Claimable?** + None. Yield is through `refPerTok()` appreciation. + +9. **Pre-Refresh Needed?** + No, just `refresh()`. + +10. **Price Range <5%?** + Yes, controlled by `oracleError`. For USD tokens, it's trivial. For BTC tokens, depends on Chainlink feed quality. + +## Configuration Parameters + +When deploying `MidasCollateral` you must provide: + +- `CollateralConfig` parameters: + - `priceTimeout`: How long saved prices remain relevant before decaying. + - `chainlinkFeed` (for mBTC): The USD/BTC Chainlink aggregator. + - `oracleError`: Allowed % deviation in oracle price (0.5%). + - `erc20`: The Midas token’s ERC20 address. + - `maxTradeVolume`: Max trade volume in `{UoA}`. + - `oracleTimeout`: Staleness threshold for the `chainlinkFeed`. + - `targetName`: "BTC" or "USD" as bytes32. + - `defaultThreshold`: 0 + - `delayUntilDefault`: How long after `IFFY` state to become `DISABLED` without recovery. + +- `revenueHiding`: Small fraction to hide revenue (e.g., `1e-4` = 10 bps). +- `refPerTokFeed`: The `IMidasDataFeed` providing `{ref/tok}`. +- `refPerTokTimeout_`: Timeout for `refPerTokFeed` validity (e.g., 30 days). + + +## Testing + +```bash +yarn hardhat test test/plugins/individual-collateral/midas/mbtc.test.ts +yarn hardhat test test/plugins/individual-collateral/midas/mtbill.test.ts +``` diff --git a/contracts/plugins/assets/midas/interfaces/IMToken.sol b/contracts/plugins/assets/midas/interfaces/IMToken.sol new file mode 100644 index 000000000..9c8520357 --- /dev/null +++ b/contracts/plugins/assets/midas/interfaces/IMToken.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; + +/** + * @title IMToken + * @notice Interface for a Midas token (e.g., mTBILL, mBASIS, mBTC) + */ +interface IMToken is IERC20Upgradeable { + /** + * @notice Returns the MidasAccessControl contract used by this token + * @return The IAccessControlUpgradeable contract instance + */ + function accessControl() external view returns (IAccessControlUpgradeable); + + /** + * @notice Returns the pause operator role for mTBILL tokens + * @return The bytes32 role for mTBILL pause operator + */ + function M_TBILL_PAUSE_OPERATOR_ROLE() external view returns (bytes32); + + /** + * @notice Returns the pause operator role for mBTC tokens + * @return The bytes32 role for mBTC pause operator + */ + function M_BTC_PAUSE_OPERATOR_ROLE() external view returns (bytes32); + + /** + * @notice puts mTBILL token on pause. + * should be called only from permissioned actor + */ + function pause() external; + + /** + * @notice puts mTBILL token on pause. + * should be called only from permissioned actor + */ + function unpause() external; + + /** + * @dev Returns true if the contract is paused, and false otherwise. + */ + function paused() external view returns (bool); +} diff --git a/contracts/plugins/assets/midas/interfaces/IMidasDataFeed.sol b/contracts/plugins/assets/midas/interfaces/IMidasDataFeed.sol new file mode 100644 index 000000000..a070e2def --- /dev/null +++ b/contracts/plugins/assets/midas/interfaces/IMidasDataFeed.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +interface IMidasDataFeed { + /** + * @notice Fetches the answer from the underlying aggregator and converts it to base18 precision + * @return answer The fetched aggregator answer, scaled to 1e18 + */ + function getDataInBase18() external view returns (uint256 answer); + + /** + * @notice Returns the role identifier for the feed administrator + * @return The bytes32 role of the feed admin + */ + function feedAdminRole() external view returns (bytes32); +} diff --git a/test/plugins/individual-collateral/midas/constants.ts b/test/plugins/individual-collateral/midas/constants.ts new file mode 100644 index 000000000..62b8c1996 --- /dev/null +++ b/test/plugins/individual-collateral/midas/constants.ts @@ -0,0 +1,12 @@ +import { bn, fp } from '../../../../common/numbers' + +// Common constants for tests +export const PRICE_TIMEOUT = bn(604800) // 1 week +export const CHAINLINK_ORACLE_TIMEOUT = bn(86400) // 24 hours +export const MIDAS_ORACLE_TIMEOUT = bn(2592000) // 30 days +export const ORACLE_TIMEOUT_BUFFER = bn(300) // 5 min +export const ORACLE_ERROR = fp('0.005') +export const DEFAULT_THRESHOLD = fp('0') +export const DELAY_UNTIL_DEFAULT = bn(86400) // 24 hours +export const REVENUE_HIDING = fp('0.0001') // 10 bps +export const FORK_BLOCK = 21360000 diff --git a/test/plugins/individual-collateral/midas/mbtc.fixture.ts b/test/plugins/individual-collateral/midas/mbtc.fixture.ts new file mode 100644 index 000000000..337adb57e --- /dev/null +++ b/test/plugins/individual-collateral/midas/mbtc.fixture.ts @@ -0,0 +1,110 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' +import { Contract } from 'ethers' +import { parseEther, parseUnits } from 'ethers/lib/utils' +import { ethers } from 'hardhat' + +import { + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + ORACLE_ERROR, + CHAINLINK_ORACLE_TIMEOUT, + PRICE_TIMEOUT, + REVENUE_HIDING, + MIDAS_ORACLE_TIMEOUT, +} from './constants' +import { MidasAggregatorV3Abi } from './midasAggregator' + +import { DefaultFixture, getDefaultFixture, Fixture } from '../fixtures' + +import { whileImpersonating } from '#/test/utils/impersonation' +import { + AccessControlUpgradeable, + IMidasDataFeed, + IMToken, + MidasCollateral, + MockV3Aggregator, +} from '#/typechain' + +export interface MBTCFixtureContext extends DefaultFixture { + mToken: IMToken + accessControl: AccessControlUpgradeable + mbtcCollateral: MidasCollateral + mockBtcAgg: MockV3Aggregator + midasAggregator: Contract + midasDataFeed: IMidasDataFeed + INITIAL_BTC_PRICE: number + MTOKEN_ADMIN_ADDRESS: string +} + +const MBTC_ADDRESS = '0x007115416AB6c266329a03B09a8aa39aC2eF7d9d' +const MTOKEN_ADMIN_ADDRESS = '0x875c06A295C41c27840b9C9dfDA7f3d819d8bC6A' +const BTC_MBTC_MIDAS_AGGREGATOR_ADDRESS = '0xA537EF0343e83761ED42B8E017a1e495c9a189Ee' +const BTC_MBTC_MIDAS_FEED_ADDRESS = '0x9987BE0c1dc5Cd284a4D766f4B5feB4F3cb3E28e' + +export const deployMBTCCollateralFixture: Fixture = async function () { + const ctx = await loadFixture(await getDefaultFixture('mbtc-salt')) + + const mToken = await ethers.getContractAt('IMToken', MBTC_ADDRESS) + + const accessControlAddress = await mToken.accessControl() + const accessControl = await ethers.getContractAt('AccessControlUpgradeable', accessControlAddress) + + const INITIAL_BTC_PRICE = 100_000 * 1e8 // Set initial USD/BTC price to 100k USD per BTC + const INITIAL_MBTC_PRICE = parseUnits('1', 8) // Set initial BTC/mBTC price to 1 BTC per mBTC + + // Deploy a mock chainlink aggregator for USD/BTC + const MockV3AggFactory = await ethers.getContractFactory('MockV3Aggregator') + const mockBtcAgg = await MockV3AggFactory.deploy(8, INITIAL_BTC_PRICE.toString()) + await mockBtcAgg.deployed() + + const midasAggregator = await ethers.getContractAt( + MidasAggregatorV3Abi, + BTC_MBTC_MIDAS_AGGREGATOR_ADDRESS + ) + + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + const role = await midasAggregator.feedAdminRole() + const hasRole = await accessControl.hasRole(role, adminSigner.address) + + if (!hasRole) { + await accessControl.connect(adminSigner).grantRole(role, adminSigner.address) + } + await midasAggregator.connect(adminSigner).setRoundData(INITIAL_MBTC_PRICE) + }) + const midasDataFeed = await ethers.getContractAt('IMidasDataFeed', BTC_MBTC_MIDAS_FEED_ADDRESS) + + const config = { + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: mockBtcAgg.address, + oracleError: ORACLE_ERROR, + erc20: MBTC_ADDRESS, + maxTradeVolume: parseEther('1000000'), + oracleTimeout: CHAINLINK_ORACLE_TIMEOUT, + targetName: ethers.utils.formatBytes32String('BTC'), + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + } + + const MidasCollateralFactory = await ethers.getContractFactory('MidasCollateral') + const mbtcCollateral = await MidasCollateralFactory.deploy( + config, + REVENUE_HIDING, + BTC_MBTC_MIDAS_FEED_ADDRESS, + MIDAS_ORACLE_TIMEOUT + ) + + await mbtcCollateral.deployed() + + return { + ...ctx, + mToken, + accessControl, + mbtcCollateral, + mockBtcAgg, + midasAggregator, + midasDataFeed, + INITIAL_BTC_PRICE, + MTOKEN_ADMIN_ADDRESS, + BTC_MBTC_MIDAS_FEED_ADDRESS, + } +} diff --git a/test/plugins/individual-collateral/midas/mbtc.test.ts b/test/plugins/individual-collateral/midas/mbtc.test.ts new file mode 100644 index 000000000..6a502b14e --- /dev/null +++ b/test/plugins/individual-collateral/midas/mbtc.test.ts @@ -0,0 +1,224 @@ +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import { BigNumber, Contract } from 'ethers' +import { parseUnits } from 'ethers/lib/utils' + +import { + DELAY_UNTIL_DEFAULT, + FORK_BLOCK, + ORACLE_ERROR, + CHAINLINK_ORACLE_TIMEOUT, + ORACLE_TIMEOUT_BUFFER, +} from './constants' +import { deployMBTCCollateralFixture, MBTCFixtureContext } from './mbtc.fixture' + +import { getResetFork } from '../helpers' + +import { fp } from '#/common/numbers' +import { whileImpersonating } from '#/test/utils/impersonation' +import { MidasCollateral, MockV3Aggregator, IMToken, AccessControlUpgradeable } from '#/typechain' + +before(getResetFork(FORK_BLOCK)) + +describe('MidasCollateral (mBTC)', () => { + let mToken: IMToken + let accessControl: AccessControlUpgradeable + let mbtcCollateral: MidasCollateral + let mockBtcAgg: MockV3Aggregator + let midasAggregator: Contract + let MTOKEN_ADMIN_ADDRESS: string + + beforeEach(async () => { + const ctx: MBTCFixtureContext = await loadFixture(deployMBTCCollateralFixture) + + mToken = ctx.mToken + accessControl = ctx.accessControl + mbtcCollateral = ctx.mbtcCollateral + mockBtcAgg = ctx.mockBtcAgg + midasAggregator = ctx.midasAggregator + MTOKEN_ADMIN_ADDRESS = ctx.MTOKEN_ADMIN_ADDRESS + + await mbtcCollateral.refresh() + }) + + it('initially SOUND and transitions to IFFY/ DISABLED when Chainlink feed is stale', async () => { + // This test checks the baseline scenario where the Chainlink feed itself becomes outdated. + expect(await mbtcCollateral.status()).to.equal(0) // SOUND + + // Move time close to the Chainlink oracle timeout + buffer, but not past it + await time.increase(CHAINLINK_ORACLE_TIMEOUT.add(ORACLE_TIMEOUT_BUFFER).sub(BigNumber.from(5))) + await mbtcCollateral.refresh() + // Still SOUND since we haven't fully crossed the stale threshold + expect(await mbtcCollateral.status()).to.equal(0) + + // Advance time slightly to cross the stale threshold + await time.increase(BigNumber.from(10)) + await mbtcCollateral.refresh() + + // Now the Chainlink feed is considered stale: status is IFFY + expect(await mbtcCollateral.status()).to.equal(1) // IFFY + + // After waiting longer than delayUntilDefault without a feed update, status becomes DISABLED + await time.increase(DELAY_UNTIL_DEFAULT.add(BigNumber.from(10))) + await mbtcCollateral.refresh() + expect(await mbtcCollateral.status()).to.equal(2) // DISABLED + }) + + it('remains SOUND over multiple Chainlink updates but becomes IFFY due to Midas data feed staleness, then recovers after Midas update', async () => { + // This scenario tests the interaction between the two oracles: + // - The Chainlink feed (USD/BTC) is updated every 24 hours to remain fresh. + // - The Midas feed (BTC/mBTC) is never updated, eventually becoming stale after ~30 days. + // At that point, the collateral becomes IFFY. Once we update the Midas feed, it returns to SOUND. + + // Key insight: + // Even if Chainlink is perfectly fresh, if Midas feed is stale, collateral cannot be SOUND. + + const NEW_PRICE = 100_100 * 1e8 + for (let i = 1; i <= 30; i++) { + // Update Chainlink feed every 24 hours + await time.increase(24 * 60 * 60) + await mockBtcAgg.updateAnswer(NEW_PRICE + i) + await mbtcCollateral.refresh() + + const status = await mbtcCollateral.status() + if (i < 30) { + // Midas feed not stale yet, Chainlink fresh => SOUND + expect(status).to.equal(0) + } else { + // After 30 days, Midas feed stale => IFFY + expect(status).to.equal(1) + } + } + + // Now update Midas feed to restore normal conditions + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await midasAggregator.connect(adminSigner).setRoundData(1e8) // BTC/mBTC updated + }) + + // With both feeds fresh, collateral returns to SOUND + await mbtcCollateral.refresh() + expect(await mbtcCollateral.status()).to.equal(0) + }) + + it('remains SOUND over multiple Chainlink updates but becomes IFFY due to Midas staleness and eventually DISABLED without Midas update', async () => { + // Similar to the previous test, but we never update the Midas feed after it becomes IFFY. + // Without Midas data feed recovery, after delayUntilDefault passes, collateral goes DISABLED. + + const NEW_PRICE = 100_100 * 1e8 + for (let i = 1; i <= 30; i++) { + await time.increase(24 * 60 * 60) + await mockBtcAgg.updateAnswer(NEW_PRICE + i) + await mbtcCollateral.refresh() + + const status = await mbtcCollateral.status() + if (i < 30) { + expect(status).to.equal(0) // SOUND before Midas stale + } else { + expect(status).to.equal(1) // IFFY at day 30 + } + } + + // Approaching delayUntilDefault - still IFFY + await time.increase(DELAY_UNTIL_DEFAULT.sub(5)) + expect(await mbtcCollateral.status()).to.equal(1) + + // Updating Chainlink alone does nothing if Midas is stale + await mockBtcAgg.updateAnswer(NEW_PRICE + 31) + await mbtcCollateral.refresh() + expect(await mbtcCollateral.status()).to.equal(1) // Still IFFY + + // After delay passes without Midas fix, goes DISABLED + await time.increase(10) + expect(await mbtcCollateral.status()).to.equal(2) + }) + + it('price should change if feed updates', async () => { + const targetBtcPrice = 250_000 // USD/BTC + const parsedTargetBtcPrice = parseUnits(targetBtcPrice.toString(), 8) + await mockBtcAgg.updateAnswer(parsedTargetBtcPrice) + + const targetMbtcPrice = 3 // BTC/mBTC + const parsedTargetMbtcPrice = parseUnits(targetMbtcPrice.toString(), 8) + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await midasAggregator.connect(adminSigner).setRoundData(parsedTargetMbtcPrice) + }) + + await mbtcCollateral.refresh() + + // Scale from 8 decimals to 18 decimals + const DECIMAL_ADJ = BigNumber.from('10').pow(18 - 8) // 10^(18-8)=10^10 + const scaledBtcPrice = parsedTargetBtcPrice.mul(DECIMAL_ADJ) // {UoA/target} in 1e18 + const scaledMbtcPrice = parsedTargetMbtcPrice.mul(DECIMAL_ADJ) // {ref/tok} in 1e18 + + const ONE = fp('1') // 1e18 + const predictedPrice = scaledBtcPrice.mul(scaledMbtcPrice).div(ONE) // (UoA/target * ref/tok) with 1e18 scale + + const err = predictedPrice.mul(ORACLE_ERROR).div(ONE) + const predictedLow = predictedPrice.sub(err) + const predictedHigh = predictedPrice.add(err) + + const [low, high] = await mbtcCollateral.price() + + expect(low).to.equal(predictedLow) + expect(high).to.equal(predictedHigh) + }) + + it('collateral becomes IFFY and eventually DISABLED if chainlink returns zero price', async () => { + // A zero price is a direct sign of malfunctioning or worthless collateral. + // Immediately upon seeing zero, collateral is IFFY and if not rectified, it goes DISABLED. + + await mockBtcAgg.updateAnswer('0') + await mbtcCollateral.refresh() + expect(await mbtcCollateral.status()).to.equal(1) // IFFY + + await time.increase(DELAY_UNTIL_DEFAULT.add(BigNumber.from(1))) + await mbtcCollateral.refresh() + expect(await mbtcCollateral.status()).to.equal(2) // DISABLED + }) + + it('once DISABLED due to paused state, unpausing token does not restore collateral', async () => { + // If the token is paused, collateral goes IFFY and then after delayUntilDefault becomes DISABLED. + // Once DISABLED, nothing can restore it, not even unpausing the token. + + // Pause token + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + const role = await mToken.M_BTC_PAUSE_OPERATOR_ROLE() + await accessControl.connect(adminSigner).grantRole(role, adminSigner.address) + await mToken.connect(adminSigner).pause() + }) + + await mbtcCollateral.refresh() + expect(await mbtcCollateral.status()).to.equal(1) // IFFY + + await time.increase(DELAY_UNTIL_DEFAULT.add(BigNumber.from(1))) + await mbtcCollateral.refresh() + expect(await mbtcCollateral.status()).to.equal(2) // DISABLED + + // Unpausing won't help once disabled + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await mToken.connect(adminSigner).unpause() + }) + await mbtcCollateral.refresh() + expect(await mbtcCollateral.status()).to.equal(2) // Still DISABLED + }) + + it('refPerTok decreases below previous values => immediate DISABLED', async () => { + // Any decrease in refPerTok() should cause immediate DISABLED status, even if small. + // This simulates the scenario where underlying redemption value drops, indicating a clear default event. + + // Set refPerTok slightly above 1 initially + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await midasAggregator.connect(adminSigner).setRoundData(parseUnits('1.0001', 8)) + }) + await mbtcCollateral.refresh() + const oldRef = await mbtcCollateral.refPerTok() + expect(oldRef).to.be.closeTo(fp('1.0'), fp('0.0001')) + + // Now set refPerTok to a lower value - immediate DISABLE + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await midasAggregator.connect(adminSigner).setRoundData(parseUnits('0.9999', 8)) + }) + await mbtcCollateral.refresh() + expect(await mbtcCollateral.status()).to.equal(2) + }) +}) diff --git a/test/plugins/individual-collateral/midas/midasAggregator.ts b/test/plugins/individual-collateral/midas/midasAggregator.ts new file mode 100644 index 000000000..8ad592691 --- /dev/null +++ b/test/plugins/individual-collateral/midas/midasAggregator.ts @@ -0,0 +1,4 @@ +export const MidasAggregatorV3Abi = [ + 'function feedAdminRole() external view returns (bytes32)', + 'function setRoundData(int256 _data) external', +] diff --git a/test/plugins/individual-collateral/midas/mtbill.fixture.ts b/test/plugins/individual-collateral/midas/mtbill.fixture.ts new file mode 100644 index 000000000..0c5922c89 --- /dev/null +++ b/test/plugins/individual-collateral/midas/mtbill.fixture.ts @@ -0,0 +1,94 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' +import { Contract } from 'ethers' +import { parseEther, parseUnits } from 'ethers/lib/utils' +import { ethers } from 'hardhat' + +import { + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + ORACLE_ERROR, + CHAINLINK_ORACLE_TIMEOUT, + PRICE_TIMEOUT, + REVENUE_HIDING, + MIDAS_ORACLE_TIMEOUT, +} from './constants' +import { MidasAggregatorV3Abi } from './midasAggregator' + +import { DefaultFixture, getDefaultFixture, Fixture } from '../fixtures' + +import { whileImpersonating } from '#/test/utils/impersonation' +import { AccessControlUpgradeable, IMidasDataFeed, IMToken, MidasCollateral } from '#/typechain' + +export interface MTBILLFixtureContext extends DefaultFixture { + mToken: IMToken + accessControl: AccessControlUpgradeable + mtbillCollateral: MidasCollateral + midasAggregator: Contract + midasDataFeed: IMidasDataFeed + MTOKEN_ADMIN_ADDRESS: string +} + +const MTBILL_ADDRESS = '0xDD629E5241CbC5919847783e6C96B2De4754e438' +const MTOKEN_ADMIN_ADDRESS = '0x875c06A295C41c27840b9C9dfDA7f3d819d8bC6A' +const USD_MTBILL_MIDAS_AGGREGATOR_ADDRESS = '0x056339C044055819E8Db84E71f5f2E1F536b2E5b' +const USD_MTBILL_MIDAS_FEED_ADDRESS = '0xfCEE9754E8C375e145303b7cE7BEca3201734A2B' +const USD_USDC_CHAINLINK_FEED_ADDRESS = '0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6' + +export const deployMTBILLCollateralFixture: Fixture = async function () { + const ctx = await loadFixture(await getDefaultFixture('mtbill-salt')) + + const mToken = await ethers.getContractAt('IMToken', MTBILL_ADDRESS) + + const accessControlAddress = await mToken.accessControl() + const accessControl = await ethers.getContractAt('AccessControlUpgradeable', accessControlAddress) + + const INITIAL_MTBILL_PRICE = parseUnits('1', 8) // Set initial USD/mTBILL price to 1 USD per mTBILL + + const midasAggregator = await ethers.getContractAt( + MidasAggregatorV3Abi, + USD_MTBILL_MIDAS_AGGREGATOR_ADDRESS + ) + + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + const role = await midasAggregator.feedAdminRole() + const hasRole = await accessControl.hasRole(role, adminSigner.address) + + if (!hasRole) { + await accessControl.connect(adminSigner).grantRole(role, adminSigner.address) + } + await midasAggregator.connect(adminSigner).setRoundData(INITIAL_MTBILL_PRICE) + }) + const midasDataFeed = await ethers.getContractAt('IMidasDataFeed', USD_MTBILL_MIDAS_FEED_ADDRESS) + + const config = { + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: USD_USDC_CHAINLINK_FEED_ADDRESS, // AppreciatingFiatCollateral.sol -> Feed units: {UoA/ref} + oracleError: ORACLE_ERROR, + erc20: MTBILL_ADDRESS, + maxTradeVolume: parseEther('1000000'), + oracleTimeout: CHAINLINK_ORACLE_TIMEOUT, + targetName: ethers.utils.formatBytes32String('USD'), + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + } + + const MidasCollateralFactory = await ethers.getContractFactory('MidasCollateral') + const mtbillCollateral = await MidasCollateralFactory.deploy( + config, + REVENUE_HIDING, + USD_MTBILL_MIDAS_FEED_ADDRESS, + MIDAS_ORACLE_TIMEOUT + ) + + await mtbillCollateral.deployed() + + return { + ...ctx, + mToken, + accessControl, + mtbillCollateral, + midasAggregator, + midasDataFeed, + MTOKEN_ADMIN_ADDRESS, + } +} diff --git a/test/plugins/individual-collateral/midas/mtbill.test.ts b/test/plugins/individual-collateral/midas/mtbill.test.ts new file mode 100644 index 000000000..eb89a696b --- /dev/null +++ b/test/plugins/individual-collateral/midas/mtbill.test.ts @@ -0,0 +1,180 @@ +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import { BigNumber, Contract } from 'ethers' +import { parseUnits } from 'ethers/lib/utils' + +import { DELAY_UNTIL_DEFAULT, FORK_BLOCK, ORACLE_ERROR, MIDAS_ORACLE_TIMEOUT } from './constants' +import { deployMTBILLCollateralFixture, MTBILLFixtureContext } from './mtbill.fixture' + +import { getResetFork } from '../helpers' + +import { fp } from '#/common/numbers' +import { whileImpersonating } from '#/test/utils/impersonation' +import { MidasCollateral, IMToken, AccessControlUpgradeable } from '#/typechain' + +before(getResetFork(FORK_BLOCK)) + +describe('MidasCollateral (mTBILL)', () => { + let mToken: IMToken + let accessControl: AccessControlUpgradeable + let mtbillCollateral: MidasCollateral + let midasAggregator: Contract + let MTOKEN_ADMIN_ADDRESS: string + + beforeEach(async () => { + const ctx: MTBILLFixtureContext = await loadFixture(deployMTBILLCollateralFixture) + + mToken = ctx.mToken + accessControl = ctx.accessControl + mtbillCollateral = ctx.mtbillCollateral + midasAggregator = ctx.midasAggregator + MTOKEN_ADMIN_ADDRESS = ctx.MTOKEN_ADMIN_ADDRESS + + await mtbillCollateral.refresh() + }) + + it('initially SOUND and transitions to IFFY/DISABLED when Midas data feed is stale', async () => { + // Since {UoA/target}=1, we do not rely on chainlink updates. + // Collateral is initially SOUND + expect(await mtbillCollateral.status()).to.equal(0) // SOUND + + // Wait just before Midas feed times out (30 days) + // 30 days = MIDAS_ORACLE_TIMEOUT = 2592000 seconds + // We'll go close to that but not past it + // We don't use ORACLE_BUFFER here because we're not relying on chainlink + await time.increase(MIDAS_ORACLE_TIMEOUT.sub(BigNumber.from(5))) + await mtbillCollateral.refresh() + // Still SOUND since we haven't fully crossed the stale threshold + expect(await mtbillCollateral.status()).to.equal(0) + + // Advance time slightly to cross the Midas stale threshold + await time.increase(BigNumber.from(10)) + await mtbillCollateral.refresh() + + // Now the Midas feed is considered stale: status is IFFY + expect(await mtbillCollateral.status()).to.equal(1) // IFFY + + // After waiting longer than delayUntilDefault without a feed update, status becomes DISABLED + await time.increase(DELAY_UNTIL_DEFAULT.add(BigNumber.from(10))) + await mtbillCollateral.refresh() + expect(await mtbillCollateral.status()).to.equal(2) // DISABLED + }) + + it('IFFY due to Midas staleness and then recovers after Midas update', async () => { + // Keep waiting until Midas feed goes stale + await time.increase(MIDAS_ORACLE_TIMEOUT.add(BigNumber.from(10))) + await mtbillCollateral.refresh() + expect(await mtbillCollateral.status()).to.equal(1) // IFFY + + // Now update Midas feed + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + const newPrice = parseUnits('1.0001', 8) + await midasAggregator.connect(adminSigner).setRoundData(newPrice) + }) + + await mtbillCollateral.refresh() + // After update, should be SOUND again + expect(await mtbillCollateral.status()).to.equal(0) // SOUND + }) + + it('becomes IFFY due to Midas staleness and eventually DISABLED without update', async () => { + // Let the Midas feed become stale + await time.increase(MIDAS_ORACLE_TIMEOUT.add(BigNumber.from(10))) + await mtbillCollateral.refresh() + expect(await mtbillCollateral.status()).to.equal(1) // IFFY + + // Approaching delayUntilDefault - still IFFY + await time.increase(DELAY_UNTIL_DEFAULT.sub(5)) + expect(await mtbillCollateral.status()).to.equal(1) + + // Do nothing (no Midas update) + await time.increase(10) + await mtbillCollateral.refresh() + + // Should now be DISABLED + expect(await mtbillCollateral.status()).to.equal(2) // DISABLED + }) + + it('price changes if refPerTok (Midas feed) updates', async () => { + // Initially 1 USD per token + const targetMtbillPrice = 1.5 // USDC/mTBILL + const parsedTargetMtbillPrice = parseUnits(targetMtbillPrice.toString(), 8) + + // Update Midas aggregator to reflect higher refPerTok + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await midasAggregator.connect(adminSigner).setRoundData(parsedTargetMtbillPrice) + }) + + await mtbillCollateral.refresh() + + const ONE = fp('1') // 1e18 + // {UoA/target}=1, {target/ref}=1, so price(UoA/tok)=refPerTok + // scale {ref/tok} from 8 decimals to 18 decimals + const DECIMAL_ADJ = BigNumber.from('10').pow(10) // 10^(18-8)=10^10 + const scaledMtbillPrice = parsedTargetMtbillPrice.mul(DECIMAL_ADJ) + + const p = scaledMtbillPrice // {UoA/tok} + const err = p.mul(ORACLE_ERROR).div(ONE) + const predictedLow = p.sub(err) + const predictedHigh = p.add(err) + + const [low, high] = await mtbillCollateral.price() + + expect(low).to.equal(predictedLow) + expect(high).to.equal(predictedHigh) + }) + + it('collateral becomes IFFY and eventually DISABLED if Midas feed returns zero price', async () => { + // 0 price indicates no value + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await midasAggregator.connect(adminSigner).setRoundData(parseUnits('0', 8)) + }) + await mtbillCollateral.refresh() + expect(await mtbillCollateral.status()).to.equal(1) // IFFY + + await time.increase(DELAY_UNTIL_DEFAULT.add(BigNumber.from(1))) + await mtbillCollateral.refresh() + expect(await mtbillCollateral.status()).to.equal(2) // DISABLED + }) + + it('if token is paused => IFFY => DISABLED after delay. Unpausing does not restore collateral', async () => { + // Pause token + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + const role = await mToken.M_TBILL_PAUSE_OPERATOR_ROLE() + await accessControl.connect(adminSigner).grantRole(role, adminSigner.address) + await mToken.connect(adminSigner).pause() + }) + + await mtbillCollateral.refresh() + expect(await mtbillCollateral.status()).to.equal(1) // IFFY + + await time.increase(DELAY_UNTIL_DEFAULT.add(BigNumber.from(1))) + await mtbillCollateral.refresh() + expect(await mtbillCollateral.status()).to.equal(2) // DISABLED + + // Unpause + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await mToken.connect(adminSigner).unpause() + }) + await mtbillCollateral.refresh() + // Still DISABLED + expect(await mtbillCollateral.status()).to.equal(2) + }) + + it('refPerTok decreases => immediate DISABLED', async () => { + // Initially set refPerTok slightly above 1 + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await midasAggregator.connect(adminSigner).setRoundData(parseUnits('1.0001', 8)) + }) + await mtbillCollateral.refresh() + const oldRef = await mtbillCollateral.refPerTok() + expect(oldRef).to.be.closeTo(fp('1.0'), fp('0.0001')) + + await whileImpersonating(MTOKEN_ADMIN_ADDRESS, async (adminSigner) => { + await midasAggregator.connect(adminSigner).setRoundData(parseUnits('0.9999', 8)) + }) + await mtbillCollateral.refresh() + // Any drop triggers DISABLED + expect(await mtbillCollateral.status()).to.equal(2) + }) +})