diff --git a/common/configuration.ts b/common/configuration.ts index 7627b43df..78c49263c 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -119,6 +119,16 @@ export interface ITokens { Re7WETH?: string meUSD?: string + // Morpho Vault V2 + steakUSDCPrime?: string + sentoraPYUSD?: string + gauntletUSDCFrontier?: string + steakUSDTPrime?: string + galaxyUSDTQuality?: string + gauntletUSDCPrime?: string + galaxyUSDCQuality?: string + skyUSDTSavings?: string + pxETH?: string apxETH?: string @@ -313,7 +323,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { sUSDT: '0x38EA452219524Bb87e18dE1C24D3bB59510BD783', sETH: '0x101816545F6bd2b1076434B54383a1E633390A2E', astETH: '0x1982b2F5814301d4e9a8b0201555376e62F82428', - MORPHO: '0x9994e35db50125e0df82e4c2dde62496ce330999', + MORPHO: '0x58D97B57BB95320F9a05dC918Aef65434969c2B2', // new, transferable MORPHO SWISE: '0x48C3399719B582dD63eB5AADf12A40B4C3f52FA2', BTRFLY: '0xc55126051B22eBb829D00368f4B12Bde432de5Da', yvCurveUSDPcrvUSD: '0xF56fB6cc29F0666BDD1662FEaAE2A3C935ee3469', @@ -325,6 +335,14 @@ export const networkConfig: { [key: string]: INetworkConfig } = { steakPYUSD: '0xbEEF02e5E13584ab96848af90261f0C8Ee04722a', bbUSDT: '0x2C25f6C25770fFEC5959D34B94Bf898865e5D6b1', Re7WETH: '0x78Fc2c2eD1A4cDb5402365934aE5648aDAd094d0', + steakUSDCPrime: '0xbeef088055857739C12CD3765F20b7679Def0f51', + sentoraPYUSD: '0xb576765fB15505433aF24FEe2c0325895C559FB2', // PYUSD (Paypal USD Main) + gauntletUSDCFrontier: '0x9a1D6bd5b8642C41F25e0958129B85f8E1176F3e', + steakUSDTPrime: '0xbeef003C68896c7D2c3c60d363e8d71a49Ab2bf9', + galaxyUSDTQuality: '0x71ffB6a81786eC285D429d531Cf655107B9D878d', + gauntletUSDCPrime: '0x8c106EEDAd96553e64287A5A6839c3Cc78afA3D0', + galaxyUSDCQuality: '0x91600E31fBeDc72433d4a57F16639cfe661Be7d8', + skyUSDTSavings: '0x23f5E9c35820f4baB695Ac1F19c203cC3f8e1e11', sdUSDCUSDCPlus: '0x9bbF31E99F30c38a5003952206C31EEa77540BeF', USDe: '0x4c9edd5852cd905f086c759e8383e09bff1e68b3', sUSDe: '0x9D39A5DE30e57443BfF2A8307A4256c8797A3497', @@ -460,7 +478,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { sUSDT: '0x38EA452219524Bb87e18dE1C24D3bB59510BD783', sETH: '0x101816545F6bd2b1076434B54383a1E633390A2E', astETH: '0x1982b2F5814301d4e9a8b0201555376e62F82428', - MORPHO: '0x9994e35db50125e0df82e4c2dde62496ce330999', + MORPHO: '0x58D97B57BB95320F9a05dC918Aef65434969c2B2', // new, transferable MORPHO }, chainlinkFeeds: { RSR: '0x759bBC1be8F90eE6457C44abc7d443842a976d02', diff --git a/contracts/plugins/assets/meta-morpho/IMerklDistributor.sol b/contracts/plugins/assets/meta-morpho/IMerklDistributor.sol new file mode 100644 index 000000000..e07ce7af5 --- /dev/null +++ b/contracts/plugins/assets/meta-morpho/IMerklDistributor.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +/** + * @title IMerklDistributor + * @notice Minimal interface for the Merkl Distributor's operator permission system. + * + * Morpho migrated reward distribution from its legacy UniversalRewardsDistributor to Merkl in + * July 2025. The legacy contract's `claim()` had no access control, so anyone could claim on + * behalf of any account. Merkl's `_claim()` instead requires msg.sender to be the recipient, an + * approved operator, or a governor/guardian. + * + * Setting the operator to `address(0)` whitelists *anyone* to claim on the recipient's behalf, + * which restores the permissionless off-chain claiming these plugins rely on. + */ +interface IMerklDistributor { + /// @return 1 if `operator` may claim on behalf of `user`, else 0 + function operators(address user, address operator) external view returns (uint256); + + /// @notice Flips operator approval. Callable only by `user` themselves (or a governor). + /// @dev This is a TOGGLE -- calling it when already enabled would DISABLE it. Always read + /// `operators()` first and only call when currently 0. + function toggleOperator(address user, address operator) external; +} diff --git a/contracts/plugins/assets/meta-morpho/IMorphoVaultV2.sol b/contracts/plugins/assets/meta-morpho/IMorphoVaultV2.sol new file mode 100644 index 000000000..92fe4f561 --- /dev/null +++ b/contracts/plugins/assets/meta-morpho/IMorphoVaultV2.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +/** + * @title IMorphoVaultV2 + * @notice Minimal interface for the gate + abdication surface of a Morpho Vault V2. + * + * Gates are optional external contracts that can restrict share transfers and asset flows. + * A gate setter that has been "abdicated" can never be called again, so a gate that is + * currently unset (`address(0)`) with an abdicated setter is permanently disabled. + */ +interface IMorphoVaultV2 { + // === Gate getters === + function receiveSharesGate() external view returns (address); + + function sendSharesGate() external view returns (address); + + function receiveAssetsGate() external view returns (address); + + function sendAssetsGate() external view returns (address); + + /// @return Whether `selector` has been permanently abdicated (can no longer be called) + function abdicated(bytes4 selector) external view returns (bool); + + // === Gate setters === + // Declared only so their `.selector` can be referenced when checking abdication. + function setReceiveSharesGate(address newReceiveSharesGate) external; + + function setSendSharesGate(address newSendSharesGate) external; + + function setReceiveAssetsGate(address newReceiveAssetsGate) external; + + function setSendAssetsGate(address newSendAssetsGate) external; +} diff --git a/contracts/plugins/assets/meta-morpho/MetaMorphoFiatCollateral.sol b/contracts/plugins/assets/meta-morpho/MetaMorphoFiatCollateral.sol index a0e692e0c..9f4f0d59a 100644 --- a/contracts/plugins/assets/meta-morpho/MetaMorphoFiatCollateral.sol +++ b/contracts/plugins/assets/meta-morpho/MetaMorphoFiatCollateral.sol @@ -13,7 +13,7 @@ import { ERC4626FiatCollateral } from "../ERC4626FiatCollateral.sol"; * * Rewards need to be claimed manually, from off-chain. This can be done permissionlessly, * by anyone, on behalf of the RToken's Backing Manager address. - * For more information: https://docs.morpho.org/rewards/tutorials/claim-rewards/ + * For more information: https://docs.morpho.org/learn/concepts/rewards/ * */ contract MetaMorphoFiatCollateral is ERC4626FiatCollateral { diff --git a/contracts/plugins/assets/meta-morpho/MetaMorphoSelfReferentialCollateral.sol b/contracts/plugins/assets/meta-morpho/MetaMorphoSelfReferentialCollateral.sol index 1be02a9e6..934dde9f4 100644 --- a/contracts/plugins/assets/meta-morpho/MetaMorphoSelfReferentialCollateral.sol +++ b/contracts/plugins/assets/meta-morpho/MetaMorphoSelfReferentialCollateral.sol @@ -17,7 +17,7 @@ import { ERC4626FiatCollateral } from "../ERC4626FiatCollateral.sol"; * * Rewards will need to be claimed manually, off-chain. This can be done permissionlessly by anyone, * on behalf of the RToken's Backing Manager address. - * For more information: https://docs.morpho.org/rewards/tutorials/claim-rewards/ + * For more information: https://docs.morpho.org/learn/concepts/rewards/ * */ contract MetaMorphoSelfReferentialCollateral is ERC4626FiatCollateral { diff --git a/contracts/plugins/assets/meta-morpho/MorphoAsset.sol b/contracts/plugins/assets/meta-morpho/MorphoAsset.sol new file mode 100644 index 000000000..9a94c61e2 --- /dev/null +++ b/contracts/plugins/assets/meta-morpho/MorphoAsset.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +// solhint-disable-next-line max-line-length +import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { FixLib, CEIL, FLOOR, shiftl_toFix } from "../../../libraries/Fixed.sol"; +import { Asset } from "../Asset.sol"; +import { OracleLib } from "../OracleLib.sol"; +import { IUniswapV3Pool } from "./vendor/IUniswapV3Pool.sol"; +import { UniswapV3TwapLib } from "./vendor/UniswapV3TwapLib.sol"; + +/** + * @title MorphoAsset + * @notice Asset plugin for the MORPHO reward token, which has no {UoA} price feed on mainnet. + * + * tok = MORPHO + * UoA = USD + * + * MORPHO is earned as a reward by holding MetaMorpho / Morpho Vault V2 collateral. Rewards are + * claimed off-chain via a Merkle proof, permissionlessly, on behalf of the RToken's Backing + * Manager. This Asset exists only so the claimed MORPHO can be sold as revenue -- it is never + * used as backing. + * + * Pricing: {UoA/tok} = {UoA/quoteTok} * {quoteTok/tok} + * - {UoA/quoteTok} comes from `chainlinkFeed` (e.g. ETH/USD) + * - {quoteTok/tok} comes from an arithmetic-mean-tick TWAP over `uniswapV3Pool` + * + * ===================================================================================== + * DO NOT DEPLOY. NOT PRODUCTION READY. + * ===================================================================================== + * This plugin is deliberately not deployable: there are no deployment or Etherscan + * verification scripts for it, and it is not referenced by scripts/deploy.ts or + * scripts/verify_etherscan.ts. It is kept in-tree as a reference implementation only. + * Do NOT register it in an RToken's AssetRegistry. + * + * Reason 1 -- the price source is cheaply manipulable. + * All mainnet MORPHO liquidity is ~$126k, and the deepest TWAP-capable venue (the Uniswap V3 + * 0.30% MORPHO/WETH pool) holds only ~$62k. `docs/collateral.md` requires that an oracle not + * be manipulable *cheaply*; a 30-minute TWAP over a pool that thin does not clear that bar, + * even though it is not manipulable within a single block. + * + * Reason 2 -- `maxTradeVolume` does NOT bound true-value exposure, and makes it worse. + * `TradeLib.maxTradeSize()` sizes a lot as `maxTradeVolume / sellHigh`, i.e. denominated in + * *this plugin's own reported price*. If an attacker pushes the TWAP down by a factor k, the + * lot grows as 1/k, so the true value sold grows as 1/k -- while the minimum proceeds are + * `maxTradeVolume * (1 - oracleError) * (1 - maxTradeSlippage) / (1 + oracleError)`, which is + * INDEPENDENT of k. With $10k maxTradeVolume, 10% oracleError and 1% maxTradeSlippage that + * floor is ~$8.1k whether the price is honest, halved, or down 10x; only the quantity of + * MORPHO handed over grows. The effective ceiling is therefore the entire held balance, not + * maxTradeVolume. Revenue auctions are permissionless, so the attacker both moves the TWAP + * and bids. No parameter value fixes this: lowering maxTradeVolume scales both sides equally. + * + * Every other plugin is safe here because its price bottoms out in a Chainlink feed that a + * bidder cannot move, which is the assumption TradeLib's sizing relies on. This is a plugin + * violating that precondition, not a flaw in TradeLib. + * + * Before this could ship, sizing must stop depending on a manipulable price -- e.g. an + * oracle-independent cap on token quantity or aggregate exposure, or a price source that meets + * the "not cheaply manipulable" bar. + * + * Note: as of 2026-09, none of the eight Morpho Vault V2 vaults emit MORPHO at all, so nothing + * is currently forgone by not deploying this. + * + * Operational prerequisite (if the above is ever resolved): + * The pool's `observationCardinality` MUST retain `twapWindow` seconds of observations, or + * `observe()` reverts ("OLD") and this Asset becomes unpriced. Covering `twapWindow` needs + * `twapWindow / blockTime + 1` observations; grow it permissionlessly and with headroom via + * `increaseObservationCardinalityNext()` well in advance, as cardinality only rises as new + * observations are written. + */ +contract MorphoAsset is Asset { + using FixLib for uint192; + using OracleLib for AggregatorV3Interface; + using UniswapV3TwapLib for IUniswapV3Pool; + + /// The Uniswap V3 pool consulted for {quoteTok/tok} + IUniswapV3Pool public immutable uniswapV3Pool; + + /// The token the pool prices `erc20` against; `chainlinkFeed` must be {UoA/quoteToken} + address public immutable quoteToken; + + /// {s} The TWAP window over which the mean tick is taken + uint32 public immutable twapWindow; + + /// {qTok} One whole unit of `erc20`, used as the TWAP base amount + uint128 private immutable oneTok; + + /// The negated decimals of `quoteToken`, for converting the TWAP quote to a Fix + int8 private immutable quoteTokenDecimals; + + /// @param priceTimeout_ {s} The number of seconds over which savedHighPrice decays to 0 + /// @param chainlinkFeed_ Feed units: {UoA/quoteToken} -- e.g. ETH/USD + /// @param oracleError_ {1} The % the oracle feed can be off by + /// @param erc20_ The MORPHO ERC20 + /// @param maxTradeVolume_ {UoA} The max trade volume, in UoA + /// @param oracleTimeout_ {s} The number of seconds until the chainlinkFeed becomes invalid + /// @param uniswapV3Pool_ The Uniswap V3 pool holding the erc20/quoteToken pair + /// @param quoteToken_ The other token in the pool; must be the feed's base unit + /// @param twapWindow_ {s} The TWAP window; longer is more manipulation-resistant + constructor( + uint48 priceTimeout_, + AggregatorV3Interface chainlinkFeed_, + uint192 oracleError_, + IERC20Metadata erc20_, + uint192 maxTradeVolume_, + uint48 oracleTimeout_, + IUniswapV3Pool uniswapV3Pool_, + IERC20Metadata quoteToken_, + uint32 twapWindow_ + ) Asset(priceTimeout_, chainlinkFeed_, oracleError_, erc20_, maxTradeVolume_, oracleTimeout_) { + require(address(uniswapV3Pool_) != address(0), "missing pool"); + require(address(quoteToken_) != address(0), "missing quoteToken"); + require(address(quoteToken_) != address(erc20_), "quoteToken is erc20"); + require(twapWindow_ != 0, "twapWindow zero"); + + // The pool must hold exactly the {erc20, quoteToken} pair, in either order + address token0 = uniswapV3Pool_.token0(); + address token1 = uniswapV3Pool_.token1(); + require( + (token0 == address(erc20_) && token1 == address(quoteToken_)) || + (token1 == address(erc20_) && token0 == address(quoteToken_)), + "pool token mismatch" + ); + + uniswapV3Pool = uniswapV3Pool_; + quoteToken = address(quoteToken_); + twapWindow = twapWindow_; + oneTok = uint128(10**erc20_.decimals()); + quoteTokenDecimals = int8(uint8(quoteToken_.decimals())); + + // Fail closed if the pool cannot currently serve the requested window + uniswapV3Pool_.consult(twapWindow_); + } + + /// Can revert, used by other contract functions in order to catch errors + /// Should not return FIX_MAX for low + /// Should only return FIX_MAX for high if low is 0 + /// Should NOT be manipulable by MEV + /// @dev The third (unused) return value is only here for compatibility with Collateral + /// @return low {UoA/tok} The low price estimate + /// @return high {UoA/tok} The high price estimate + function tryPrice() + external + view + virtual + override + returns ( + uint192 low, + uint192 high, + uint192 + ) + { + // {UoA/quoteTok} + uint192 quoteTokenPrice = chainlinkFeed.price(oracleTimeout); + + // {quoteTok/tok} + uint192 quoteTokPerTok = _twapQuoteTokPerTok(); + + // {UoA/tok} = {UoA/quoteTok} * {quoteTok/tok} + uint192 p = quoteTokenPrice.mul(quoteTokPerTok); + uint192 err = p.mul(oracleError, CEIL); + // assert(low <= high); obviously true just by inspection + return (p - err, p + err, 0); + } + + // === Private === + + /// @return {quoteTok/tok} The TWAP price of one whole erc20 in quoteToken + function _twapQuoteTokPerTok() private view returns (uint192) { + int24 meanTick = uniswapV3Pool.consult(twapWindow); + + // {qQuoteTok} per one whole {tok} + uint256 quoteAmount = UniswapV3TwapLib.getQuoteAtTick( + meanTick, + oneTok, + address(erc20), + quoteToken + ); + + // {quoteTok/tok} = {qQuoteTok} shifted by the quote token's decimals + return shiftl_toFix(quoteAmount, -quoteTokenDecimals, FLOOR); + } +} diff --git a/contracts/plugins/assets/meta-morpho/MorphoV2FiatCollateral.sol b/contracts/plugins/assets/meta-morpho/MorphoV2FiatCollateral.sol new file mode 100644 index 000000000..9f3704045 --- /dev/null +++ b/contracts/plugins/assets/meta-morpho/MorphoV2FiatCollateral.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import { CollateralStatus } from "../../../interfaces/IAsset.sol"; +import { Asset, CollateralConfig, IRewardable } from "../AppreciatingFiatCollateral.sol"; +import { FixLib, FIX_MAX } from "../../../libraries/Fixed.sol"; +import { MetaMorphoFiatCollateral } from "./MetaMorphoFiatCollateral.sol"; +import { IMorphoVaultV2 } from "./IMorphoVaultV2.sol"; +import { IMerklDistributor } from "./IMerklDistributor.sol"; + +/** + * @title MorphoV2FiatCollateral + * @notice Collateral plugin for a Morpho Vault V2 with fiat collateral, like USDC, USDT or PYUSD + * Expected: {tok} != {ref}, {ref} is pegged to {target} unless defaulting, {target} == {UoA} + * + * Pricing is identical to {MetaMorphoFiatCollateral}. This plugin adds gate handling and Merkl + * reward-claim enablement. Mainnet only. + * + * === Gates === + * Morpho Vault V2 can install "gates" that restrict share transfers and asset flows. + * + * receiveSharesGate / sendSharesGate / receiveAssetsGate are CRITICAL: any of them could block + * the protocol (or any holder) from holding, trading, or exiting the collateral. These are + * checked once in the constructor, requiring each to be unset AND its setter abdicated. + * Abdication is permanent, so a single check is sufficient and costs no runtime gas. + * + * sendAssetsGate only gates deposits into the vault -- it can never trap existing shares. But if + * it is set, no new shares can be minted, so the collateral becomes sourceable only from a thin + * secondary market. A recollateralization needing to BUY this collateral would then pay a large + * premium. Its setter is NOT abdicated on most vaults, so it cannot be required in the + * constructor; instead refresh() marks the collateral IFFY while it is set. IFFY (rather than + * DISABLED) because a gate can be unset, and this impairs neither refPerTok nor the peg. + * + * === Rewards === + * Rewards are claimed off-chain via a Merkle proof. Since Morpho moved to Merkl (July 2025), + * that claim is permissioned. claimRewards() does not claim: it whitelists anyone to claim on + * behalf of the component holding the collateral (BackingManager / RevenueTrader), with funds + * always sent to that component. This restores the permissionless off-chain claiming these + * plugins rely on. See claimRewards() below. + * For more information: https://docs.morpho.org/learn/concepts/rewards/ + */ +contract MorphoV2FiatCollateral is MetaMorphoFiatCollateral { + using FixLib for uint192; + + /// Merkl Distributor PROXY on mainnet. + address public constant MERKL_DISTRIBUTOR = 0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae; + + /// @param config.erc20 must be a Morpho Vault V2 ERC4626 vault + /// @param config.chainlinkFeed Feed units: {UoA/ref} + /// @param revenueHiding {1} A value like 1e-6 that represents the maximum refPerTok to hide + constructor(CollateralConfig memory config, uint192 revenueHiding) + MetaMorphoFiatCollateral(config, revenueHiding) + { + IMorphoVaultV2 vault = IMorphoVaultV2(address(config.erc20)); + + // Each critical gate must be permanently disabled: unset AND its setter abdicated. + require( + vault.receiveSharesGate() == address(0) && + vault.abdicated(IMorphoVaultV2.setReceiveSharesGate.selector), + "receiveSharesGate not abdicated" + ); + require( + vault.sendSharesGate() == address(0) && + vault.abdicated(IMorphoVaultV2.setSendSharesGate.selector), + "sendSharesGate not abdicated" + ); + require( + vault.receiveAssetsGate() == address(0) && + vault.abdicated(IMorphoVaultV2.setReceiveAssetsGate.selector), + "receiveAssetsGate not abdicated" + ); + + // Not required to be abdicated (most vaults have not abdicated its setter), but it must + // not already be set at deployment. refresh() handles it being set later. + require(vault.sendAssetsGate() == address(0), "sendAssetsGate set"); + } + + /// Should not revert + /// Refresh exchange rates and update default status. + /// Should not revert + /// Refresh exchange rates and update default status. + /// @dev Reimplements AppreciatingFiatCollateral.refresh() rather than calling super, so the + /// sendAssetsGate check can be folded into the soft-default branch (same pattern as + /// CurveStableCollateral). Calling super would markStatus(SOUND) on every healthy + /// refresh, resetting _whenDefault and restarting the delayUntilDefault clock, so the + /// collateral would never reach DISABLED while being refreshed. + function refresh() public virtual override { + CollateralStatus oldStatus = status(); + + // Check for hard default + // must happen before tryPrice() call since `refPerTok()` returns a stored value + + // revenue hiding: do not DISABLE if drawdown is small + try this.underlyingRefPerTok() returns (uint192 underlyingRefPerTok_) { + // {ref/tok} = {ref/tok} * {1} + uint192 hiddenReferencePrice = underlyingRefPerTok_.mul(revenueShowing); + + // uint192(<) is equivalent to Fix.lt + if (underlyingRefPerTok_ < exposedReferencePrice) { + exposedReferencePrice = underlyingRefPerTok_; + markStatus(CollateralStatus.DISABLED); + } else if (hiddenReferencePrice > exposedReferencePrice) { + exposedReferencePrice = hiddenReferencePrice; + } + + // Check for soft default + save prices + try this.tryPrice() returns (uint192 low, uint192 high, uint192 pegPrice) { + // {UoA/tok}, {UoA/tok}, {target/ref} + // (0, 0) is a valid price; (0, FIX_MAX) is unpriced + + // Save prices if priced + if (high != FIX_MAX) { + savedLowPrice = low; + savedHighPrice = high; + savedPegPrice = pegPrice; + lastSave = uint48(block.timestamp); + } else { + // must be unpriced + assert(low == 0); + } + + // If the price is below the default-threshold price, default eventually + // uint192(+/-) is the same as Fix.plus/minus + // + // A set sendAssetsGate is also a soft default: no new shares can be minted, so + // the collateral is only obtainable on a thin secondary market and a + // recollateralization would buy it at a large premium. IFFY blocks rebalance() + // via isReady() and escalates to DISABLED after delayUntilDefault. + if ( + pegPrice < pegBottom || + pegPrice > pegTop || + low == 0 || + IMorphoVaultV2(address(erc20)).sendAssetsGate() != address(0) + ) { + markStatus(CollateralStatus.IFFY); + } else { + markStatus(CollateralStatus.SOUND); + } + } catch (bytes memory errData) { + // see: docs/solidity-style.md#Catching-Empty-Data + if (errData.length == 0) revert(); // solhint-disable-line reason-string + markStatus(CollateralStatus.IFFY); + } + } catch (bytes memory errData) { + // see: docs/solidity-style.md#Catching-Empty-Data + if (errData.length == 0) revert(); // solhint-disable-line reason-string + markStatus(CollateralStatus.DISABLED); + } + + CollateralStatus newStatus = status(); + if (oldStatus != newStatus) { + emit CollateralStatusChanged(oldStatus, newStatus); + } + } + + /// Enable permissionless off-chain reward claiming on behalf of the caller. + /// @dev Does NOT claim. Morpho rewards are claimed off-chain via a Merkle proof; since the + /// move to Merkl that claim is permissioned. Approving operator address(0) whitelists + /// ANY address to claim on our behalf, with funds always sent to us. + /// + /// toggleOperator() is a TOGGLE and only callable by the account itself, so we read + /// first and only ever turn it ON. This is delegatecalled by BackingManager / + /// RevenueTrader, so address(this) is that component -- which is both the reward + /// recipient and msg.sender for the call, satisfying Merkl's access control. + /// + /// Best-effort: RewardableLib reverts the whole multi-asset claim if this delegatecall + /// fails, so a Merkl outage must never brick claimRewards() for every other asset. + /// @custom:delegate-call + function claimRewards() external virtual override(Asset, IRewardable) { + IMerklDistributor merkl = IMerklDistributor(MERKL_DISTRIBUTOR); + + // solhint-disable no-empty-blocks + try merkl.operators(address(this), address(0)) returns (uint256 approved) { + if (approved == 0) { + try merkl.toggleOperator(address(this), address(0)) {} catch {} + } + } catch {} + // solhint-enable no-empty-blocks + } +} diff --git a/contracts/plugins/assets/meta-morpho/README.md b/contracts/plugins/assets/meta-morpho/README.md index e498e2d79..b59b8bb28 100644 --- a/contracts/plugins/assets/meta-morpho/README.md +++ b/contracts/plugins/assets/meta-morpho/README.md @@ -1,6 +1,6 @@ # MetaMorpho -Morpho Blue is a permisionless lending protocol. At the time of this writing (March 19th, 2024), the only way to deposit is through something called **MetaMorpho**: (somewhat) managed ERC4626 vaults. Our integration with these tokens is straightforward with the exception of reward claiming, which occurs via supplying a merkle proof. This can be done permisionlessly and without interacting with any of our contracts, so any interaction with rewards is omitted here. The expectation is -- _and this is important to emphasize_ -- **any MORPHO reward claiming is left up to the RToken community to cause**. +Morpho Blue is a permisionless lending protocol. At the time of this writing (March 19th, 2024), the only way to deposit is through something called **MetaMorpho**: (somewhat) managed ERC4626 vaults. Our integration with these tokens is straightforward with the exception of reward claiming, which occurs via supplying a merkle proof. This can be done permisionlessly and without interacting with any of our contracts, so any interaction with rewards is omitted here. The expectation is -- _and this is important to emphasize_ -- **any MORPHO reward claiming is left up to the RToken community to cause**. A [`MorphoAsset`](#morphoasset-reference-implementation----do-not-deploy) reference implementation exists for selling claimed MORPHO, but it is **not production ready and is not deployed** -- see that section. ## Up-only-ness @@ -8,13 +8,14 @@ MetaMorpho suffers from a similar to that of the Curve volatile pools which can ## Reward claiming -Rewards can be claimed permissionlessly by anyone from off-chain, following this detailed guide from the Morpho docs: +Rewards can be claimed permissionlessly by anyone from off-chain, following the Morpho docs: -https://docs.morpho.org/rewards/tutorials/claim-rewards/ +- Rewards concept: https://docs.morpho.org/learn/concepts/rewards/ +- Claiming via the Morpho app: https://help.morpho.org/en/articles/12032660-rewards-on-the-morpho-app -It requires the following steps: +It requires the following steps (see https://help.morpho.org/en/articles/12032660-rewards-on-the-morpho-app for a step-by-step walkthrough): -1. Querying the Morpho API with the holder address: https://rewards.morpho.org/v1/users/0xADDRESS/distributions +1. Querying the rewards for the holder address via the Morpho rewards portals: https://rewards-legacy.morpho.org/ and https://campaigns.morpho.org/ 2. Retrieving the distributor contract and sending a transaction to `claim()` the rewards with the following parameters (all obtained from the previous call): - `account`: the holder address - `reward`: the address of the reward token @@ -23,6 +24,24 @@ It requires the following steps: It is important to note that in the case of Rtokens, rewards will need to be claimed on behalf of the Backing Manager. +## `MorphoAsset` (reference implementation -- DO NOT DEPLOY) + +> **This plugin is not production ready and is intentionally not deployable.** There are no deployment or Etherscan verification scripts for it, and it is not referenced by `scripts/deploy.ts` or `scripts/verify_etherscan.ts`. It is kept in-tree as a reference implementation only. **Do not register it in an RToken's `AssetRegistry`.** + +MORPHO is earned by holding MetaMorpho / Vault V2 collateral and is claimed off-chain. Claiming only moves it into the Backing Manager -- without a registered `Asset` the protocol has no price for it and cannot sell it. `MorphoAsset` was written to close that gap (an `Asset`, not a Collateral -- MORPHO is never backing and is only ever sold), pricing it as `{UoA/tok} = ETH/USD x TWAP(MORPHO/WETH)` over the Uniswap V3 0.30% pool `0xc8219b876753A85025156b22176c2eDEA17aAC53`, since Chainlink's MORPHO/USD feed exists only on Base. + +It is not deployed for two reasons. + +**1. The price source is cheaply manipulable.** All mainnet MORPHO liquidity totals ~$126k, and the deepest TWAP-capable venue holds only ~$62k. (Uniswap V4 holds a comparable share, but V4 moved its oracle into hooks and every MORPHO pool there is hookless, so V3 is the only TWAP-capable venue at all.) `docs/collateral.md` requires that an oracle not be manipulable _cheaply_; a 30-minute TWAP over a pool that thin does not clear that bar, even though it is not manipulable within a single block. + +**2. `maxTradeVolume` does not bound true-value exposure -- it amplifies it.** `TradeLib.maxTradeSize()` sizes a lot as `maxTradeVolume / sellHigh`, denominated in this plugin's own reported price. Push the TWAP down by a factor `k` and the lot grows as `1/k`, so the true value sold grows as `1/k`, while the minimum proceeds are `maxTradeVolume * (1 - oracleError) * (1 - maxTradeSlippage) / (1 + oracleError)` -- **independent of `k`**. With $10k `maxTradeVolume`, 10% `oracleError` and 1% `maxTradeSlippage`, that floor is ~$8.1k whether the price is honest, halved, or down 10x; only the quantity of MORPHO handed over grows. The effective ceiling is the entire held balance. Revenue auctions are permissionless, so the same actor moves the TWAP and bids. No parameter value fixes this -- lowering `maxTradeVolume` scales both sides equally. + +Every other plugin is safe here because its price bottoms out in a Chainlink feed a bidder cannot move, which is the assumption `TradeLib`'s sizing relies on. This is a plugin violating that precondition, not a flaw in `TradeLib`. + +Shipping it would require sizing that does not depend on a manipulable price -- an oracle-independent cap on token quantity or aggregate exposure, or a price source meeting the "not cheaply manipulable" bar. Note that as of 2026-09 none of the eight Vault V2 vaults emit MORPHO, so nothing is currently forgone. + +If it is ever revisited, one operational prerequisite: the pool's `observationCardinality` must retain `twapWindow` seconds of observations (`twapWindow / blockTime + 1` of them) or `observe()` reverts `OLD` and the asset becomes unpriced. Grow it permissionlessly with headroom via `increaseObservationCardinalityNext()`, well in advance. + ## Target tokens **USD** @@ -39,7 +58,36 @@ It is important to note that in the case of Rtokens, rewards will need to be cla | -------- | ------- | ------------------------------------------ | --------------------------- | | Re7 WETH | Re7WETH | 0x78Fc2c2eD1A4cDb5402365934aE5648aDAd094d0 | USDC, SWISE, BTRFLY, MORPHO | +## Morpho Vault V2 + +[Morpho Vault V2](https://docs.morpho.org/learn/concepts/vault-v2/) fiat vaults use `MorphoV2FiatCollateral`, a thin subclass of `MetaMorphoFiatCollateral`. Pricing and default behavior are identical (V2 keeps the exact ERC-4626 surface — `convertToAssets`, `asset`, `decimals` — so `underlyingRefPerTok()` is unchanged); the subclass only adds a constructor guard on the vault's gates (see below). The V2-specific deviations were checked on-chain and found benign: + +| Check | Result | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **ERC-4626 / 2612** | Compliant. 18-decimal share over a 6-decimal asset; `convertToAssets` accounts for fees. | +| **Gates** | V2 adds optional gate contracts that can restrict share transfers / asset flows. The 3 critical gates (`receiveSharesGate`, `sendSharesGate`, `receiveAssetsGate`) could block the protocol (or any holder) from holding, trading, or exiting the collateral. `MorphoV2FiatCollateral`'s **constructor reverts** unless each is unset (`address(0)`) **and** its setter is abdicated (permanently disabled). Checked once at construction because abdication is permanent — no runtime check needed. `sendAssetsGate` is not required (it only gates future deposit/mint, not transfer or exit of existing shares). | +| **`max*` quirk** | V2 `maxDeposit`/`maxMint`/`maxWithdraw`/`maxRedeem` always return 0. Harmless: the protocol holds and trades the share token and never calls `vault.redeem()`. | +| **Fees & losses** | Performance/management fees and adapter losses flow through `convertToAssets`. Routine fee dips are absorbed by `revenueHiding`; a genuine loss correctly DISABLES the collateral. | + +Mainnet vaults validated against the live chain (all gates unset; fees 0 except PayPal USD Main, which has a ~1%/yr management fee). The Reward Tokens column reflects live campaigns as of 2026-09 -- **none of these vaults emit MORPHO**, and six have no reward campaign at all: + +| Name | Symbol | Address | Asset | Reward Tokens | +| ---------------------- | ------------------- | -------------------------------------------- | ----- | ------------------ | +| Steakhouse Prime USDC | steakUSDC | `0xbeef088055857739C12CD3765F20b7679Def0f51` | USDC | none | +| PayPal USD Main | senPYUSDmain | `0xb576765fB15505433aF24FEe2c0325895C559FB2` | PYUSD | PYUSD (~2.95% APR) | +| Gauntlet USDC Frontier | gtusdcf | `0x9a1D6bd5b8642C41F25e0958129B85f8E1176F3e` | USDC | none | +| Steakhouse Prime USDT | steakUSDT | `0xbeef003C68896c7D2c3c60d363e8d71a49Ab2bf9` | USDT | none | +| Galaxy USDT Quality | gUSDTq | `0x71ffB6a81786eC285D429d531Cf655107B9D878d` | USDT | none | +| Gauntlet USDC Prime | gtusdcp | `0x8c106EEDAd96553e64287A5A6839c3Cc78afA3D0` | USDC | none | +| Galaxy USDC Quality | gUSDCq | `0x91600E31fBeDc72433d4a57F16639cfe661Be7d8` | USDC | none | +| Sky.money USDT Savings | skyMoneyUsdtSavings | `0x23f5E9c35820f4baB695Ac1F19c203cC3f8e1e11` | USDT | USDT (~0.83% APR) | + +Campaigns are added and expire, so re-check before relying on this. Source: Morpho's GraphQL API (`vaultV2ByAddress`), cross-checked against `netApy - netApyExcludingRewards`. + +The claiming mechanism is unchanged from V1 (off-chain Merkle claim on behalf of the Backing Manager), and nothing on-chain claims automatically -- `claimRewards()` is a no-op on these plugins, so a keeper must submit the proof or the PYUSD/USDT rewards above never reach the Backing Manager. + ## Future Work -- Assets need to exist for each of the Reward Tokens, which requires oracles. Only USDC meets this bar; SWISE, BTRFLY, and MORPHO do not have oracles yet. +- Assets need to exist for each of the Reward Tokens, which requires oracles. USDC meets this bar and MORPHO is now covered by `MorphoAsset`; SWISE and BTRFLY still have no oracle. - The right reward token assets need to be registered for an RToken as a function of their collateral. This can be done using the above table. +- `MorphoAsset` is mainnet-only; on Base a plain `Asset` against Chainlink's MORPHO/USD feed would do. diff --git a/contracts/plugins/assets/meta-morpho/vendor/IUniswapV3Pool.sol b/contracts/plugins/assets/meta-morpho/vendor/IUniswapV3Pool.sol new file mode 100644 index 000000000..bcb5a0b4c --- /dev/null +++ b/contracts/plugins/assets/meta-morpho/vendor/IUniswapV3Pool.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +/// Minimal subset of the Uniswap V3 pool interface needed for TWAP consultation +interface IUniswapV3Pool { + /// @notice Returns the cumulative tick and liquidity as of each timestamp `secondsAgos` + /// @dev Reverts with "OLD" if the oldest stored observation is more recent than the largest + /// requested `secondsAgo`. The number of observations retained is governed by the pool's + /// observationCardinality, which can be grown permissionlessly by anyone via + /// increaseObservationCardinalityNext(). + function observe(uint32[] calldata secondsAgos) + external + view + returns ( + int56[] memory tickCumulatives, + uint160[] memory secondsPerLiquidityCumulativeX128s + ); + + function token0() external view returns (address); + + function token1() external view returns (address); + + /// @dev Uniswap V3 exposes no standalone getter for observationCardinality; it is packed + /// into slot0. Not used by this plugin at runtime -- only by the deployment script's + /// pre-flight check on the pool's observation buffer. + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint8 feeProtocol, + bool unlocked + ); +} diff --git a/contracts/plugins/assets/meta-morpho/vendor/TickMath.sol b/contracts/plugins/assets/meta-morpho/vendor/TickMath.sol new file mode 100644 index 000000000..7f273a477 --- /dev/null +++ b/contracts/plugins/assets/meta-morpho/vendor/TickMath.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.28; + +/** + * @title TickMath + * @notice Vendored from Uniswap V3 core, @uniswap/v3-core/contracts/libraries/TickMath.sol + * (commit 6562c52, MIT/GPL-2.0-or-later), ported from solidity 0.7.6 to 0.8.x. + * The only change is wrapping the body in `unchecked`, since the original relies on + * 0.7-style wrapping arithmetic. Only getSqrtRatioAtTick is vendored; + * getTickAtSqrtRatio is not needed by this plugin. + * @dev Computes sqrt price for ticks of size 1.0001, i.e. sqrt(1.0001^tick) as a Q64.96 + */ +library TickMath { + /// @dev The minimum tick that may be passed to getSqrtRatioAtTick + int24 internal constant MIN_TICK = -887272; + + /// @dev The maximum tick that may be passed to getSqrtRatioAtTick + int24 internal constant MAX_TICK = -MIN_TICK; + + error TickOutOfBounds(); + + /// @notice Calculates sqrt(1.0001^tick) * 2^96 + /// @dev Reverts if |tick| > MAX_TICK + /// @param tick The input tick + /// @return sqrtPriceX96 Q64.96 sqrt of the ratio token1/token0 at the given tick + function getSqrtRatioAtTick(int24 tick) internal pure returns (uint160 sqrtPriceX96) { + unchecked { + uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick)); + if (absTick > uint256(int256(MAX_TICK))) revert TickOutOfBounds(); + + uint256 ratio = absTick & 0x1 != 0 + ? 0xfffcb933bd6fad37aa2d162d1a594001 + : 0x100000000000000000000000000000000; + if (absTick & 0x2 != 0) ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128; + if (absTick & 0x4 != 0) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128; + if (absTick & 0x8 != 0) ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0) >> 128; + if (absTick & 0x10 != 0) ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644) >> 128; + if (absTick & 0x20 != 0) ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0) >> 128; + if (absTick & 0x40 != 0) ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861) >> 128; + if (absTick & 0x80 != 0) ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053) >> 128; + if (absTick & 0x100 != 0) ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4) >> 128; + if (absTick & 0x200 != 0) ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54) >> 128; + if (absTick & 0x400 != 0) ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3) >> 128; + if (absTick & 0x800 != 0) ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9) >> 128; + if (absTick & 0x1000 != 0) ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825) >> 128; + if (absTick & 0x2000 != 0) ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5) >> 128; + if (absTick & 0x4000 != 0) ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7) >> 128; + if (absTick & 0x8000 != 0) ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6) >> 128; + if (absTick & 0x10000 != 0) ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9) >> 128; + if (absTick & 0x20000 != 0) ratio = (ratio * 0x5d6af8dedb81196699c329225ee604) >> 128; + if (absTick & 0x40000 != 0) ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98) >> 128; + if (absTick & 0x80000 != 0) ratio = (ratio * 0x48a170391f7dc42444e8fa2) >> 128; + + if (tick > 0) ratio = type(uint256).max / ratio; + + // this divides by 1<<32 rounding up to go from a Q128.128 to a Q128.96. + // we then downcast because we know the result always fits within 160 bits due to + // our tick input constraint + // we round up in the division so getTickAtSqrtRatio of the output price is always + // consistent + sqrtPriceX96 = uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1)); + } + } +} diff --git a/contracts/plugins/assets/meta-morpho/vendor/UniswapV3TwapLib.sol b/contracts/plugins/assets/meta-morpho/vendor/UniswapV3TwapLib.sol new file mode 100644 index 000000000..5c3287b96 --- /dev/null +++ b/contracts/plugins/assets/meta-morpho/vendor/UniswapV3TwapLib.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import { IUniswapV3Pool } from "./IUniswapV3Pool.sol"; +import { TickMath } from "./TickMath.sol"; +import { mulDiv256 } from "../../../../libraries/Fixed.sol"; + +/** + * @title UniswapV3TwapLib + * @notice Arithmetic-mean-tick TWAP consultation for a Uniswap V3 pool. + * Equivalent to the Uniswap V3 periphery OracleLibrary's `consult` + `getQuoteAtTick`, + * re-expressed for solidity 0.8.x and using this repo's 512-bit `mulDiv256` in place of + * Uniswap's FullMath.mulDiv. + */ +library UniswapV3TwapLib { + /// @notice Arithmetic mean tick over the past `window` seconds + /// @dev Reverts ("OLD") if the pool has not retained `window` seconds of observations. + /// Observation capacity is the pool's observationCardinality, which anyone can grow + /// via increaseObservationCardinalityNext(). + /// @param pool The Uniswap V3 pool to consult + /// @param window {s} The TWAP window; must be nonzero + /// @return meanTick The time-weighted arithmetic mean tick over the window + function consult(IUniswapV3Pool pool, uint32 window) internal view returns (int24 meanTick) { + uint32[] memory secondsAgos = new uint32[](2); + secondsAgos[0] = window; + secondsAgos[1] = 0; + + (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos); + int56 delta = tickCumulatives[1] - tickCumulatives[0]; + + meanTick = int24(delta / int56(uint56(window))); + + // Always round towards negative infinity, matching Uniswap's OracleLibrary + if (delta < 0 && (delta % int56(uint56(window)) != 0)) meanTick--; + } + + /// @notice Value of `baseAmount` of base token, denominated in quote token, at `tick` + /// @dev Reverts on overflow of the intermediate ratio; callers wrap in try-catch + /// @param tick The tick at which to price + /// @param baseAmount {qBaseTok} The amount of base token to quote + /// @param baseToken The token being priced + /// @param quoteToken The token the price is denominated in + /// @return quoteAmount {qQuoteTok} The quote-token value of baseAmount of baseToken + function getQuoteAtTick( + int24 tick, + uint128 baseAmount, + address baseToken, + address quoteToken + ) internal pure returns (uint256 quoteAmount) { + uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick); + + // Calculate quoteAmount with better precision if it doesn't overflow when multiplied by + // itself + if (sqrtRatioX96 <= type(uint128).max) { + uint256 ratioX192 = uint256(sqrtRatioX96) * sqrtRatioX96; + quoteAmount = baseToken < quoteToken + ? mulDiv256(ratioX192, baseAmount, 1 << 192) + : mulDiv256(1 << 192, baseAmount, ratioX192); + } else { + uint256 ratioX128 = mulDiv256(sqrtRatioX96, sqrtRatioX96, 1 << 64); + quoteAmount = baseToken < quoteToken + ? mulDiv256(ratioX128, baseAmount, 1 << 128) + : mulDiv256(1 << 128, baseAmount, ratioX128); + } + } +} diff --git a/contracts/plugins/mocks/MockMetaMorpho4626.sol b/contracts/plugins/mocks/MockMetaMorpho4626.sol index 198947b93..4c8ea64b5 100644 --- a/contracts/plugins/mocks/MockMetaMorpho4626.sol +++ b/contracts/plugins/mocks/MockMetaMorpho4626.sol @@ -4,8 +4,9 @@ pragma solidity 0.8.28; import { IERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import "@openzeppelin/contracts/utils/Address.sol"; import "../../libraries/Fixed.sol"; +import { IMorphoVaultV2 } from "../assets/meta-morpho/IMorphoVaultV2.sol"; -// Simple pass-through wrapper for real MetaMorpho ERC4626 vaults +// Simple pass-through wrapper for real MetaMorpho / Morpho Vault V2 ERC4626 vaults // Allows settable asset count for testing contract MockMetaMorpho4626 { using FixLib for uint192; @@ -14,6 +15,11 @@ contract MockMetaMorpho4626 { uint192 public multiplier = FIX_ONE; + // Morpho Vault V2 gate test knobs (default: pass through to the wrapped vault) + address public receiveSharesGateOverride; // if nonzero, returned by receiveSharesGate() + address public sendAssetsGateOverride; // if nonzero, returned by sendAssetsGate() + bool public forceNotAbdicated; // if true, abdicated() returns false + // solhint-disable-next-line no-empty-blocks constructor(IERC4626 _actual) { actual = _actual; @@ -23,6 +29,43 @@ contract MockMetaMorpho4626 { multiplier = multiplier.mul(multiple); } + function setReceiveSharesGateOverride(address gate) external { + receiveSharesGateOverride = gate; + } + + function setSendAssetsGateOverride(address gate) external { + sendAssetsGateOverride = gate; + } + + function setForceNotAbdicated(bool value) external { + forceNotAbdicated = value; + } + + // === Morpho Vault V2 gate pass-throughs (only called for V2 collateral) === + + function receiveSharesGate() external view returns (address) { + if (receiveSharesGateOverride != address(0)) return receiveSharesGateOverride; + return IMorphoVaultV2(address(actual)).receiveSharesGate(); + } + + function sendSharesGate() external view returns (address) { + return IMorphoVaultV2(address(actual)).sendSharesGate(); + } + + function receiveAssetsGate() external view returns (address) { + return IMorphoVaultV2(address(actual)).receiveAssetsGate(); + } + + function sendAssetsGate() external view returns (address) { + if (sendAssetsGateOverride != address(0)) return sendAssetsGateOverride; + return IMorphoVaultV2(address(actual)).sendAssetsGate(); + } + + function abdicated(bytes4 selector) external view returns (bool) { + if (forceNotAbdicated) return false; + return IMorphoVaultV2(address(actual)).abdicated(selector); + } + // === Pass-throughs === function balanceOf(address account) external view returns (uint256) { diff --git a/contracts/plugins/mocks/MockUniswapV3Pool.sol b/contracts/plugins/mocks/MockUniswapV3Pool.sol new file mode 100644 index 000000000..12da3c17e --- /dev/null +++ b/contracts/plugins/mocks/MockUniswapV3Pool.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +/** + * @title MockUniswapV3Pool + * @notice Minimal Uniswap V3 pool stub with a settable arithmetic mean tick. + * + * `observe` synthesizes tickCumulatives such that, for any window, + * (tickCumulatives[1] - tickCumulatives[0]) / window == meanTick exactly. + * This lets tests drive the TWAP deterministically and exercise both token orderings. + */ +contract MockUniswapV3Pool { + /// Arbitrary base offset for the synthesized cumulative series + int56 private constant BASE = 1_000_000; + + address public token0; + address public token1; + + int24 public meanTick; + + /// If true, observe() reverts "OLD", mimicking a window longer than retained observations + bool public revertOld; + + constructor( + address token0_, + address token1_, + int24 meanTick_ + ) { + token0 = token0_; + token1 = token1_; + meanTick = meanTick_; + } + + function setMeanTick(int24 meanTick_) external { + meanTick = meanTick_; + } + + function setRevertOld(bool revertOld_) external { + revertOld = revertOld_; + } + + function observe(uint32[] calldata secondsAgos) + external + view + returns ( + int56[] memory tickCumulatives, + uint160[] memory secondsPerLiquidityCumulativeX128s + ) + { + require(!revertOld, "OLD"); + + tickCumulatives = new int56[](secondsAgos.length); + secondsPerLiquidityCumulativeX128s = new uint160[](secondsAgos.length); + + for (uint256 i = 0; i < secondsAgos.length; ++i) { + // tickCumulative(t) = meanTick * t, sampled at t = BASE - secondsAgo + tickCumulatives[i] = + int56(meanTick) * + (BASE - int56(uint56(secondsAgos[i]))); + } + } +} diff --git a/scripts/addresses/1-tmp-assets-collateral.json b/scripts/addresses/1-tmp-assets-collateral.json index 07229367d..43027df5f 100644 --- a/scripts/addresses/1-tmp-assets-collateral.json +++ b/scripts/addresses/1-tmp-assets-collateral.json @@ -57,7 +57,15 @@ "wOETH": "0xBFAc3e99263B7aE9704eC1c879f7c0a57C6b53e1", "pyUSD": "0x9A65173df5D5B86E26300Cc9cA5Ff378be6DAeA5", "saEthRLUSD": "0xb1e61f452CFcF6609C2F4088EC36B4c8dd1806b5", - "weETH": "0x9dc6cEFC09b0917c78a05148d45f6e6594e227de" + "weETH": "0x9dc6cEFC09b0917c78a05148d45f6e6594e227de", + "steakUSDCPrime": "0x3d4d6dC2bAB2f8D53f18e6b6859D7FE692C3a24A", + "sentoraPYUSD": "0x798D102Cb206b81b0C789C35Ff3744c8400a4c80", + "gauntletUSDCFrontier": "0xa16B5F3d3b449d6B486AF69672f2E311569Bdbc4", + "steakUSDTPrime": "0xf9C5ba0bB6D4Eb30c48705FE1896e29d891D3773", + "galaxyUSDTQuality": "0x7250e55Da1De164e9B951Ca9c3fB36dC8FA900D5", + "gauntletUSDCPrime": "0xD07FBabd47eBe5CB7CEE8F6EA63D7B2E061e719b", + "galaxyUSDCQuality": "0x54c1daAF88DBcC9240Ed9b55cbeCC5E70C33f7f2", + "skyUSDTSavings": "0xEcD85BdF2c9c769A9cB4F8026Ee05056d83Fb347" }, "erc20s": { "stkAAVE": "0x4da27a545c0c5B758a6BA100e3a049001de870f5", @@ -115,6 +123,14 @@ "pyUSD": "0x6c3ea9036406852006290770bedfcaba0e23a0e8", "saEthRLUSD": "0x4C813CE4e2FF315f0213563A994c20BBF4637444", "weETH": "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", - "KING": "0x8F08B70456eb22f6109F57b8fafE862ED28E6040" + "KING": "0x8F08B70456eb22f6109F57b8fafE862ED28E6040", + "steakUSDCPrime": "0xbeef088055857739C12CD3765F20b7679Def0f51", + "sentoraPYUSD": "0xb576765fB15505433aF24FEe2c0325895C559FB2", + "gauntletUSDCFrontier": "0x9a1D6bd5b8642C41F25e0958129B85f8E1176F3e", + "steakUSDTPrime": "0xbeef003C68896c7D2c3c60d363e8d71a49Ab2bf9", + "galaxyUSDTQuality": "0x71ffB6a81786eC285D429d531Cf655107B9D878d", + "gauntletUSDCPrime": "0x8c106EEDAd96553e64287A5A6839c3Cc78afA3D0", + "galaxyUSDCQuality": "0x91600E31fBeDc72433d4a57F16639cfe661Be7d8", + "skyUSDTSavings": "0x23f5E9c35820f4baB695Ac1F19c203cC3f8e1e11" } -} +} \ No newline at end of file diff --git a/scripts/deploy.ts b/scripts/deploy.ts index e2b90c3b7..b48eff634 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -88,6 +88,7 @@ async function main() { 'phase2-assets/collaterals/deploy_steakpyusd.ts', 'phase2-assets/collaterals/deploy_bbusdt.ts', 'phase2-assets/collaterals/deploy_re7weth.ts', + 'phase2-assets/collaterals/deploy_morpho_v2_vaults.ts', 'phase2-assets/collaterals/deploy_ethx_collateral.ts', 'phase2-assets/collaterals/deploy_apxeth.ts', 'phase2-assets/collaterals/deploy_USDe.ts', diff --git a/scripts/deployment/phase2-assets/collaterals/deploy_morpho_v2_vaults.ts b/scripts/deployment/phase2-assets/collaterals/deploy_morpho_v2_vaults.ts new file mode 100644 index 000000000..ab6a69812 --- /dev/null +++ b/scripts/deployment/phase2-assets/collaterals/deploy_morpho_v2_vaults.ts @@ -0,0 +1,161 @@ +import fs from 'fs' +import hre from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { networkConfig, ITokens } from '../../../../common/configuration' +import { fp } from '../../../../common/numbers' +import { expect } from 'chai' +import { CollateralStatus } from '../../../../common/constants' +import { + getDeploymentFile, + getAssetCollDeploymentFilename, + IAssetCollDeployments, + getDeploymentFilename, + fileExists, +} from '../../common' +import { + USDC_ORACLE_TIMEOUT, + USDC_ORACLE_ERROR, + USDC_USD_FEED, + USDT_ORACLE_TIMEOUT, + USDT_ORACLE_ERROR, + USDT_USD_FEED, + PYUSD_ORACLE_TIMEOUT, + PYUSD_ORACLE_ERROR, + PYUSD_USD_FEED, + PRICE_TIMEOUT, + DELAY_UNTIL_DEFAULT, +} from '../../../../test/plugins/individual-collateral/meta-morpho/constants' +import { MorphoV2FiatCollateral } from '../../../../typechain' +import { ContractFactory, BigNumber } from 'ethers' + +// Morpho Vault V2 collaterals. All are USD-pegged MetaMorpho ERC4626 vaults with +// no gates and 18-decimal shares over a 6-decimal asset (verified on-chain). +interface V2VaultDeployment { + tokenKey: keyof ITokens + feed: string + oracleTimeout: BigNumber + oracleError: BigNumber +} + +const VAULTS: V2VaultDeployment[] = [ + { + tokenKey: 'steakUSDCPrime', + feed: USDC_USD_FEED, + oracleTimeout: USDC_ORACLE_TIMEOUT, + oracleError: USDC_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'sentoraPYUSD', + feed: PYUSD_USD_FEED, + oracleTimeout: PYUSD_ORACLE_TIMEOUT, + oracleError: PYUSD_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'gauntletUSDCFrontier', + feed: USDC_USD_FEED, + oracleTimeout: USDC_ORACLE_TIMEOUT, + oracleError: USDC_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'steakUSDTPrime', + feed: USDT_USD_FEED, + oracleTimeout: USDT_ORACLE_TIMEOUT, + oracleError: USDT_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'galaxyUSDTQuality', + feed: USDT_USD_FEED, + oracleTimeout: USDT_ORACLE_TIMEOUT, + oracleError: USDT_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'gauntletUSDCPrime', + feed: USDC_USD_FEED, + oracleTimeout: USDC_ORACLE_TIMEOUT, + oracleError: USDC_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'galaxyUSDCQuality', + feed: USDC_USD_FEED, + oracleTimeout: USDC_ORACLE_TIMEOUT, + oracleError: USDC_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'skyUSDTSavings', + feed: USDT_USD_FEED, + oracleTimeout: USDT_ORACLE_TIMEOUT, + oracleError: USDT_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier +] + +async function main() { + // ==== Read Configuration ==== + const [deployer] = await hre.ethers.getSigners() + const chainId = await getChainId(hre) + + console.log(`Deploying Morpho Vault V2 collaterals to network ${hre.network.name} (${chainId}) + with burner account: ${deployer.address}`) + + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + // Get phase1 deployment + const phase1File = getDeploymentFilename(chainId) + if (!fileExists(phase1File)) { + throw new Error(`${phase1File} doesn't exist yet. Run phase 1`) + } + // Check previous step completed + const assetCollDeploymentFilename = getAssetCollDeploymentFilename(chainId) + const assetCollDeployments = getDeploymentFile(assetCollDeploymentFilename) + + const deployedCollateral: string[] = [] + + const MorphoV2FiatCollateralFactory: ContractFactory = await hre.ethers.getContractFactory( + 'MorphoV2FiatCollateral' + ) + + for (const v of VAULTS) { + const erc20 = networkConfig[chainId].tokens[v.tokenKey] + if (!erc20) { + throw new Error(`Missing token address for ${v.tokenKey} on chain ${chainId}`) + } + + const collateral = await MorphoV2FiatCollateralFactory.connect( + deployer + ).deploy( + { + priceTimeout: PRICE_TIMEOUT.toString(), + chainlinkFeed: v.feed, + oracleError: v.oracleError.toString(), + erc20: erc20, + maxTradeVolume: fp('1e6').toString(), + oracleTimeout: v.oracleTimeout.toString(), + targetName: hre.ethers.utils.formatBytes32String('USD'), + defaultThreshold: v.oracleError.add(fp('0.01')).toString(), // +1% buffer rule + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), + }, + fp('1e-4') // can have mild drawdowns + ) + await collateral.deployed() + + console.log(`Deployed ${v.tokenKey} to ${hre.network.name} (${chainId}): ${collateral.address}`) + await (await collateral.refresh({ gasLimit: 3_000_000 })).wait() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + assetCollDeployments.collateral[v.tokenKey] = collateral.address + assetCollDeployments.erc20s[v.tokenKey] = erc20 + deployedCollateral.push(collateral.address.toString()) + + fs.writeFileSync(assetCollDeploymentFilename, JSON.stringify(assetCollDeployments, null, 2)) + } + + console.log(`Deployed Morpho Vault V2 collaterals to ${hre.network.name} (${chainId}) + New deployments: ${deployedCollateral} + Deployment file: ${assetCollDeploymentFilename}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verification/collateral-plugins/verify_morpho.ts b/scripts/verification/collateral-plugins/verify_legacy_morpho.ts similarity index 91% rename from scripts/verification/collateral-plugins/verify_morpho.ts rename to scripts/verification/collateral-plugins/verify_legacy_morpho.ts index fe78771dc..f42d4fbe8 100644 --- a/scripts/verification/collateral-plugins/verify_morpho.ts +++ b/scripts/verification/collateral-plugins/verify_legacy_morpho.ts @@ -1,3 +1,6 @@ +// Verifies the Morpho AAVE V2 collateral plugins. These predate the MORPHO token migration and +// reward in the LEGACY token, hence LEGACY_MORPHO rather than networkConfig.tokens.MORPHO (which +// now points at the current, transferable MORPHO). import hre, { ethers } from 'hardhat' import { getChainId } from '../../../common/blockchain-utils' import { developmentChains, networkConfig } from '../../../common/configuration' @@ -8,6 +11,8 @@ import { IAssetCollDeployments, } from '../../deployment/common' import { combinedError, priceTimeout, verifyContract, revenueHiding } from '../../deployment/utils' +// eslint-disable-next-line max-len +import { LEGACY_MORPHO } from '../../../test/plugins/individual-collateral/morpho-aave/constants' let deployments: IAssetCollDeployments @@ -37,7 +42,7 @@ async function main() { rewardsDistributor: networkConfig[chainId].MORPHO_REWARDS_DISTRIBUTOR!, underlyingERC20: networkConfig[chainId].tokens.USDT!, poolToken: networkConfig[chainId].tokens.aUSDT!, - rewardToken: networkConfig[chainId].tokens.MORPHO!, + rewardToken: LEGACY_MORPHO, }, ], 'contracts/plugins/assets/morpho-aave/MorphoAaveV2TokenisedDeposit.sol:MorphoAaveV2TokenisedDeposit' diff --git a/scripts/verification/collateral-plugins/verify_morpho_v2_vaults.ts b/scripts/verification/collateral-plugins/verify_morpho_v2_vaults.ts new file mode 100644 index 000000000..91c37c431 --- /dev/null +++ b/scripts/verification/collateral-plugins/verify_morpho_v2_vaults.ts @@ -0,0 +1,137 @@ +import hre from 'hardhat' +import { getChainId } from '../../../common/blockchain-utils' +import { developmentChains, networkConfig, ITokens } from '../../../common/configuration' +import { fp } from '../../../common/numbers' +import { + getDeploymentFile, + getAssetCollDeploymentFilename, + IAssetCollDeployments, +} from '../../deployment/common' +import { + USDC_ORACLE_TIMEOUT, + USDC_ORACLE_ERROR, + USDC_USD_FEED, + USDT_ORACLE_TIMEOUT, + USDT_ORACLE_ERROR, + USDT_USD_FEED, + PYUSD_ORACLE_TIMEOUT, + PYUSD_ORACLE_ERROR, + PYUSD_USD_FEED, + PRICE_TIMEOUT, + DELAY_UNTIL_DEFAULT, +} from '../../../test/plugins/individual-collateral/meta-morpho/constants' +import { verifyContract } from '../../deployment/utils' +import { BigNumber } from 'ethers' + +let deployments: IAssetCollDeployments + +// Morpho Vault V2 collaterals — must match the args used in deploy_morpho_v2_vaults.ts +interface V2VaultVerification { + tokenKey: keyof ITokens + feed: string + oracleTimeout: BigNumber + oracleError: BigNumber +} + +const VAULTS: V2VaultVerification[] = [ + { + tokenKey: 'steakUSDCPrime', + feed: USDC_USD_FEED, + oracleTimeout: USDC_ORACLE_TIMEOUT, + oracleError: USDC_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'sentoraPYUSD', + feed: PYUSD_USD_FEED, + oracleTimeout: PYUSD_ORACLE_TIMEOUT, + oracleError: PYUSD_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'gauntletUSDCFrontier', + feed: USDC_USD_FEED, + oracleTimeout: USDC_ORACLE_TIMEOUT, + oracleError: USDC_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'steakUSDTPrime', + feed: USDT_USD_FEED, + oracleTimeout: USDT_ORACLE_TIMEOUT, + oracleError: USDT_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'galaxyUSDTQuality', + feed: USDT_USD_FEED, + oracleTimeout: USDT_ORACLE_TIMEOUT, + oracleError: USDT_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'gauntletUSDCPrime', + feed: USDC_USD_FEED, + oracleTimeout: USDC_ORACLE_TIMEOUT, + oracleError: USDC_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'galaxyUSDCQuality', + feed: USDC_USD_FEED, + oracleTimeout: USDC_ORACLE_TIMEOUT, + oracleError: USDC_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier + { + tokenKey: 'skyUSDTSavings', + feed: USDT_USD_FEED, + oracleTimeout: USDT_ORACLE_TIMEOUT, + oracleError: USDT_ORACLE_ERROR, + }, // eslint-disable-line prettier/prettier +] + +async function main() { + // ********** Read config ********** + const chainId = await getChainId(hre) + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + if (developmentChains.includes(hre.network.name)) { + throw new Error(`Cannot verify contracts for development chain ${hre.network.name}`) + } + + const assetCollDeploymentFilename = getAssetCollDeploymentFilename(chainId) + deployments = getDeploymentFile(assetCollDeploymentFilename) + + for (const v of VAULTS) { + /******** Verify Morpho Vault V2 collateral **************************/ + const collateralAddr = deployments.collateral[v.tokenKey] + if (!collateralAddr) { + throw new Error(`Missing deployed collateral for ${v.tokenKey} on chain ${chainId}`) + } + const erc20 = networkConfig[chainId].tokens[v.tokenKey] + if (!erc20) { + throw new Error(`Missing token address for ${v.tokenKey} on chain ${chainId}`) + } + + await verifyContract( + chainId, + collateralAddr, + [ + { + priceTimeout: PRICE_TIMEOUT.toString(), + chainlinkFeed: v.feed, + oracleError: v.oracleError.toString(), + erc20: erc20, + maxTradeVolume: fp('1e6').toString(), + oracleTimeout: v.oracleTimeout.toString(), + targetName: hre.ethers.utils.formatBytes32String('USD'), + defaultThreshold: v.oracleError.add(fp('0.01')).toString(), // +1% buffer rule + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), + }, + fp('1e-4'), // can have small drawdowns + ], + 'contracts/plugins/assets/meta-morpho/MorphoV2FiatCollateral.sol:MorphoV2FiatCollateral' + ) + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verify_etherscan.ts b/scripts/verify_etherscan.ts index dec65add7..69e44cb28 100644 --- a/scripts/verify_etherscan.ts +++ b/scripts/verify_etherscan.ts @@ -65,7 +65,7 @@ async function main() { 'collateral-plugins/verify_wsteth.ts', 'collateral-plugins/verify_cbeth.ts', 'collateral-plugins/verify_sdai.ts', - 'collateral-plugins/verify_morpho.ts', + 'collateral-plugins/verify_legacy_morpho.ts', 'collateral-plugins/verify_aave_v3_usdc.ts', 'collateral-plugins/verify_aave_v3_usdt.ts', 'collateral-plugins/verify_aave_v3_rlusd.ts', @@ -74,6 +74,7 @@ async function main() { 'collateral-plugins/verify_sfrax_eth.ts', 'collateral-plugins/verify_steakusdc.ts', 'collateral-plugins/verify_re7weth.ts', + 'collateral-plugins/verify_morpho_v2_vaults.ts', 'collateral-plugins/verify_ethx.ts', 'collateral-plugins/verify_apxeth.ts', 'collateral-plugins/verify_USDe.ts', diff --git a/test/integration/fork-block-numbers.ts b/test/integration/fork-block-numbers.ts index 123fbacf4..814d35cbc 100644 --- a/test/integration/fork-block-numbers.ts +++ b/test/integration/fork-block-numbers.ts @@ -11,7 +11,7 @@ const forkBlockNumber = { 'mainnet-3.4.0': 20328530, // Ethereum // TODO add all the block numbers we fork from to benefit from caching - default: 24190670, // Ethereum + default: 25250000, // Ethereum } export default forkBlockNumber diff --git a/test/monitor/FacadeMonitor.test.ts b/test/monitor/FacadeMonitor.test.ts index f532a1675..13d00ebd1 100644 --- a/test/monitor/FacadeMonitor.test.ts +++ b/test/monitor/FacadeMonitor.test.ts @@ -13,6 +13,7 @@ import { whileImpersonating } from '../utils/impersonation' import { pushOracleForward } from '../utils/oracles' import forkBlockNumber from '../integration/fork-block-numbers' +import { LEGACY_MORPHO } from '../plugins/individual-collateral/morpho-aave/constants' import { ATokenFiatCollateral, AaveV3FiatCollateral, @@ -1202,7 +1203,7 @@ describeFork(`FacadeMonitor - Integration - Mainnet Forking P${IMPLEMENTATION}`, morphoLens: networkConfig[chainId].MORPHO_AAVE_LENS!, underlyingERC20: networkConfig[chainId].tokens.USDC!, poolToken: networkConfig[chainId].tokens.aUSDC!, - rewardToken: networkConfig[chainId].tokens.MORPHO!, + rewardToken: LEGACY_MORPHO, }) const CollateralFactory = await hre.ethers.getContractFactory('MorphoFiatCollateral') diff --git a/test/plugins/individual-collateral/meta-morpho/MetaMorphoFiatCollateral.test.ts b/test/plugins/individual-collateral/meta-morpho/MetaMorphoFiatCollateral.test.ts index afd2c218a..d0a1dab39 100644 --- a/test/plugins/individual-collateral/meta-morpho/MetaMorphoFiatCollateral.test.ts +++ b/test/plugins/individual-collateral/meta-morpho/MetaMorphoFiatCollateral.test.ts @@ -10,7 +10,9 @@ import collateralTests from '../collateralTests' import { getResetFork } from '../helpers' import { CollateralOpts, CollateralFixtureContext } from '../pluginTestTypes' import { pushOracleForward } from '../../../utils/oracles' -import { MAX_UINT192 } from '#/common/constants' +import { MAX_UINT192, ZERO_ADDRESS } from '#/common/constants' +import { CollateralStatus } from '../pluginTestTypes' +import { advanceTime } from '../../../utils/time' import { DELAY_UNTIL_DEFAULT, FORK_BLOCK, @@ -31,6 +33,15 @@ import { eUSD_ORACLE_ERROR, PRICE_TIMEOUT, MEUSD, + FORK_BLOCK_V2, + STEAKUSDC_PRIME, + SENTORA_PYUSD, + GAUNTLET_USDC_FRONTIER, + STEAKUSDT_PRIME, + GALAXY_USDT_QUALITY, + GAUNTLET_USDC_PRIME, + GALAXY_USDC_QUALITY, + SKY_USDT_SAVINGS, } from './constants' import { mintCollateralTo } from './mintCollateralTo' @@ -38,6 +49,8 @@ interface MAFiatCollateralOpts extends CollateralOpts { defaultPrice?: BigNumberish defaultRefPerTok?: BigNumberish forkNetwork?: string + forkBlock?: number + factoryName?: string } const makeFiatCollateralTestSuite = ( @@ -48,7 +61,7 @@ const makeFiatCollateralTestSuite = ( opts = { ...defaultCollateralOpts, ...opts } const MetaMorphoCollateralFactory: ContractFactory = await ethers.getContractFactory( - 'MetaMorphoFiatCollateral' + opts.factoryName ?? 'MetaMorphoFiatCollateral' ) const collateral = await MetaMorphoCollateralFactory.deploy( { @@ -150,10 +163,181 @@ const makeFiatCollateralTestSuite = ( /* Define collateral-specific tests */ - // eslint-disable-next-line @typescript-eslint/no-empty-function - const collateralSpecificConstructorTests = () => {} - // eslint-disable-next-line @typescript-eslint/no-empty-function - const collateralSpecificStatusTests = () => {} + const collateralSpecificConstructorTests = () => { + // MorphoV2FiatCollateral enforces, at construction, that the vault's critical gates are + // permanently disabled (unset AND setter abdicated). Verify it reverts otherwise. + if (defaultCollateralOpts.factoryName !== 'MorphoV2FiatCollateral') return + + it('reverts if a critical gate setter is not abdicated', async () => { + const mockFactory = await ethers.getContractFactory('MockMetaMorpho4626') + const mock = await mockFactory.deploy(defaultCollateralOpts.erc20!) + await mock.setForceNotAbdicated(true) + await expect(deployCollateral({ erc20: mock.address })).to.be.revertedWith( + 'receiveSharesGate not abdicated' + ) + }) + + it('reverts if sendAssetsGate is already set', async () => { + const mockFactory = await ethers.getContractFactory('MockMetaMorpho4626') + const mock = await mockFactory.deploy(defaultCollateralOpts.erc20!) + await mock.setSendAssetsGateOverride('0x0000000000000000000000000000000000000001') + await expect(deployCollateral({ erc20: mock.address })).to.be.revertedWith( + 'sendAssetsGate set' + ) + }) + + it('reverts if a critical gate is set', async () => { + const mockFactory = await ethers.getContractFactory('MockMetaMorpho4626') + const mock = await mockFactory.deploy(defaultCollateralOpts.erc20!) + await mock.setReceiveSharesGateOverride('0x0000000000000000000000000000000000000001') + await expect(deployCollateral({ erc20: mock.address })).to.be.revertedWith( + 'receiveSharesGate not abdicated' + ) + }) + } + const collateralSpecificStatusTests = () => { + if (defaultCollateralOpts.factoryName !== 'MorphoV2FiatCollateral') return + + it('goes IFFY while sendAssetsGate is set, then DISABLED if sustained', async () => { + const [, alice] = await ethers.getSigners() + const ctx = await makeCollateralFixtureContext(alice, {})() + const { collateral } = ctx + const vault = await ethers.getContractAt('MockMetaMorpho4626', ctx.tok.address) + + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + // curator sets sendAssetsGate -> no new shares can be minted + await vault.setSendAssetsGateOverride('0x0000000000000000000000000000000000000001') + await expect(collateral.refresh()) + .to.emit(collateral, 'CollateralStatusChanged') + .withArgs(CollateralStatus.SOUND, CollateralStatus.IFFY) + expect(await collateral.status()).to.equal(CollateralStatus.IFFY) + + // sustained past delayUntilDefault -> DISABLED + await advanceTime(await collateral.delayUntilDefault()) + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + }) + + it('recovers to SOUND if sendAssetsGate is unset before delayUntilDefault', async () => { + const [, alice] = await ethers.getSigners() + const ctx = await makeCollateralFixtureContext(alice, {})() + const { collateral } = ctx + const vault = await ethers.getContractAt('MockMetaMorpho4626', ctx.tok.address) + + await collateral.refresh() + await vault.setSendAssetsGateOverride('0x0000000000000000000000000000000000000001') + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.IFFY) + + await vault.setSendAssetsGateOverride(ZERO_ADDRESS) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + }) + + it('repeated refreshes before the deadline do not extend it; still DISABLEs on time', async () => { + const [, alice] = await ethers.getSigners() + const ctx = await makeCollateralFixtureContext(alice, {})() + const { collateral } = ctx + const vault = await ethers.getContractAt('MockMetaMorpho4626', ctx.tok.address) + + await collateral.refresh() + await vault.setSendAssetsGateOverride('0x0000000000000000000000000000000000000001') + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.IFFY) + + // deadline latched on the first gated refresh + const deadline = await collateral.whenDefault() + + // refresh repeatedly inside the window: deadline must not move, and no spurious + // status-change events should fire + const delay = Number(await collateral.delayUntilDefault()) + for (let i = 0; i < 5; i++) { + await advanceTime(Math.floor(delay / 10)) + await expect(collateral.refresh()).to.not.emit(collateral, 'CollateralStatusChanged') + expect(await collateral.status()).to.equal(CollateralStatus.IFFY) + expect(await collateral.whenDefault()).to.equal(deadline) + } + + // crossing the ORIGINAL deadline DISABLEs, with no further refresh needed + await advanceTime(delay) + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + expect(await collateral.whenDefault()).to.equal(deadline) + }) + + it('stays DISABLED forever once the gate default completes, even if gate is unset', async () => { + const [, alice] = await ethers.getSigners() + const ctx = await makeCollateralFixtureContext(alice, {})() + const { collateral } = ctx + const vault = await ethers.getContractAt('MockMetaMorpho4626', ctx.tok.address) + + await collateral.refresh() + await vault.setSendAssetsGateOverride('0x0000000000000000000000000000000000000001') + await collateral.refresh() + await advanceTime(await collateral.delayUntilDefault()) + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + + // gate removed: must NOT recover, and must not emit a status change + await vault.setSendAssetsGateOverride(ZERO_ADDRESS) + await expect(collateral.refresh()).to.not.emit(collateral, 'CollateralStatusChanged') + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + + // and repeated refreshes keep it DISABLED + await collateral.refresh() + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + }) + + it('gate cannot pull a DISABLED collateral back to IFFY', async () => { + const [, alice] = await ethers.getSigners() + const ctx = await makeCollateralFixtureContext(alice, {})() + const { collateral } = ctx + const vault = await ethers.getContractAt('MockMetaMorpho4626', ctx.tok.address) + + // hard-default first, via refPerTok decrease + await collateral.refresh() + await reduceRefPerTok(ctx, 5) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + const whenDefault = await collateral.whenDefault() + + // now set the gate: markStatus(IFFY) must be a no-op, no event, no change to whenDefault + await vault.setSendAssetsGateOverride('0x0000000000000000000000000000000000000001') + await expect(collateral.refresh()).to.not.emit(collateral, 'CollateralStatusChanged') + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + expect(await collateral.whenDefault()).to.equal(whenDefault) + }) + + it('claimRewards() whitelists anyone to claim on our behalf via Merkl', async () => { + const collateral = await deployCollateral() + const merkl = await ethers.getContractAt( + 'IMerklDistributor', + await collateral.MERKL_DISTRIBUTOR() + ) + + // not approved to begin with + expect(await merkl.operators(collateral.address, ZERO_ADDRESS)).to.equal(0) + + await collateral.claimRewards() + expect(await merkl.operators(collateral.address, ZERO_ADDRESS)).to.equal(1) + + // idempotent: toggleOperator is a TOGGLE, so a second call must NOT turn it back off + await collateral.claimRewards() + expect(await merkl.operators(collateral.address, ZERO_ADDRESS)).to.equal(1) + }) + + it('claimRewards() does not revert if Merkl is unavailable', async () => { + const collateral = await deployCollateral() + const merklAddr = await collateral.MERKL_DISTRIBUTOR() + const saved = await ethers.provider.send('eth_getCode', [merklAddr, 'latest']) + + // replace Merkl with code that always reverts + await ethers.provider.send('hardhat_setCode', [merklAddr, '0x60006000fd']) + await expect(collateral.claimRewards()).to.not.be.reverted + + await ethers.provider.send('hardhat_setCode', [merklAddr, saved]) + }) + } // eslint-disable-next-line @typescript-eslint/no-empty-function const beforeEachRewardsTest = async () => {} @@ -176,7 +360,7 @@ const makeFiatCollateralTestSuite = ( itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, itHasRevenueHiding: it, - resetFork: getResetFork(FORK_BLOCK), + resetFork: getResetFork(defaultCollateralOpts.forkBlock ?? FORK_BLOCK), targetNetwork: defaultCollateralOpts.forkNetwork, collateralName, chainlinkDefaultAnswer: defaultCollateralOpts.defaultPrice!, @@ -192,7 +376,9 @@ const makeOpts = ( chainlinkFeed: string, oracleTimeout: BigNumber, oracleError: BigNumber, - forkNetwork: string + forkNetwork: string, + forkBlock?: number, + factoryName?: string ): MAFiatCollateralOpts => { return { targetName: ethers.utils.formatBytes32String('USD'), @@ -208,6 +394,8 @@ const makeOpts = ( erc20: vault, chainlinkFeed, forkNetwork, + forkBlock, + factoryName, } } @@ -232,3 +420,101 @@ makeFiatCollateralTestSuite( 'MetaMorphoFiatCollateral - meUSD', makeOpts(MEUSD, eUSD_USD_FEED, eUSD_ORACLE_TIMEOUT, eUSD_ORACLE_ERROR, 'base') ) + +// Morpho Vault V2 vaults — same plugin, USD-pegged, no gates (verified on-chain) +makeFiatCollateralTestSuite( + 'MetaMorphoFiatCollateral - Steakhouse Prime USDC (V2)', + makeOpts( + STEAKUSDC_PRIME, + USDC_USD_FEED, + USDC_ORACLE_TIMEOUT, + USDC_ORACLE_ERROR, + 'mainnet', + FORK_BLOCK_V2, + 'MorphoV2FiatCollateral' + ) // eslint-disable-line prettier/prettier +) +makeFiatCollateralTestSuite( + 'MetaMorphoFiatCollateral - PayPal USD Main (V2)', + makeOpts( + SENTORA_PYUSD, + PYUSD_USD_FEED, + PYUSD_ORACLE_TIMEOUT, + PYUSD_ORACLE_ERROR, + 'mainnet', + FORK_BLOCK_V2, + 'MorphoV2FiatCollateral' + ) // eslint-disable-line prettier/prettier +) +makeFiatCollateralTestSuite( + 'MetaMorphoFiatCollateral - Gauntlet USDC Frontier (V2)', + makeOpts( + GAUNTLET_USDC_FRONTIER, + USDC_USD_FEED, + USDC_ORACLE_TIMEOUT, + USDC_ORACLE_ERROR, + 'mainnet', + FORK_BLOCK_V2, + 'MorphoV2FiatCollateral' + ) // eslint-disable-line prettier/prettier +) +makeFiatCollateralTestSuite( + 'MetaMorphoFiatCollateral - Steakhouse Prime USDT (V2)', + makeOpts( + STEAKUSDT_PRIME, + USDT_USD_FEED, + USDT_ORACLE_TIMEOUT, + USDT_ORACLE_ERROR, + 'mainnet', + FORK_BLOCK_V2, + 'MorphoV2FiatCollateral' + ) // eslint-disable-line prettier/prettier +) +makeFiatCollateralTestSuite( + 'MetaMorphoFiatCollateral - Galaxy USDT Quality (V2)', + makeOpts( + GALAXY_USDT_QUALITY, + USDT_USD_FEED, + USDT_ORACLE_TIMEOUT, + USDT_ORACLE_ERROR, + 'mainnet', + FORK_BLOCK_V2, + 'MorphoV2FiatCollateral' + ) // eslint-disable-line prettier/prettier +) +makeFiatCollateralTestSuite( + 'MetaMorphoFiatCollateral - Gauntlet USDC Prime (V2)', + makeOpts( + GAUNTLET_USDC_PRIME, + USDC_USD_FEED, + USDC_ORACLE_TIMEOUT, + USDC_ORACLE_ERROR, + 'mainnet', + FORK_BLOCK_V2, + 'MorphoV2FiatCollateral' + ) // eslint-disable-line prettier/prettier +) +makeFiatCollateralTestSuite( + 'MetaMorphoFiatCollateral - Galaxy USDC Quality (V2)', + makeOpts( + GALAXY_USDC_QUALITY, + USDC_USD_FEED, + USDC_ORACLE_TIMEOUT, + USDC_ORACLE_ERROR, + 'mainnet', + FORK_BLOCK_V2, + 'MorphoV2FiatCollateral' + ) // eslint-disable-line prettier/prettier +) +makeFiatCollateralTestSuite( + 'MetaMorphoFiatCollateral - Sky.money USDT Savings (V2)', + makeOpts( + SKY_USDT_SAVINGS, + USDT_USD_FEED, + USDT_ORACLE_TIMEOUT, + USDT_ORACLE_ERROR, + 'mainnet', + FORK_BLOCK_V2, + 'MorphoV2FiatCollateral' + ) // eslint-disable-line prettier/prettier +) diff --git a/test/plugins/individual-collateral/meta-morpho/MorphoAsset.test.ts b/test/plugins/individual-collateral/meta-morpho/MorphoAsset.test.ts new file mode 100644 index 000000000..c0ba1ee78 --- /dev/null +++ b/test/plugins/individual-collateral/meta-morpho/MorphoAsset.test.ts @@ -0,0 +1,553 @@ +import { expect } from 'chai' +import { Wallet, ContractFactory, BigNumber } from 'ethers' +import hre, { ethers } from 'hardhat' +import { networkConfig } from '../../../../common/configuration' +import { getChainId } from '../../../../common/blockchain-utils' +import { advanceTime, getLatestBlockTimestamp, advanceToTimestamp } from '../../../utils/time' +import { ZERO_ADDRESS, MAX_UINT192 } from '../../../../common/constants' +import { bn, fp } from '../../../../common/numbers' +import { + expectDecayedPrice, + expectExactPrice, + expectPrice, + expectUnpriced, + setInvalidOracleAnsweredRound, + setInvalidOracleTimestamp, + setOraclePrice, +} from '../../../utils/oracles' +import { + ERC20Mock, + InvalidMockV3Aggregator, + MockUniswapV3Pool, + MockV3Aggregator, + MorphoAsset, +} from '../../../../typechain' +import { VERSION } from '../../../fixtures' +import { useEnv } from '#/utils/env' +import { + MORPHO, + WETH, + USDC, + WBTC, + MORPHO_WETH_POOL_030, + MORPHO_WETH_POOL_100, + ETH_USD_FEED, + ETH_ORACLE_TIMEOUT, + MORPHO_ORACLE_ERROR, + PRICE_TIMEOUT, + MORPHO_MAX_TRADE_VOLUME, + MORPHO_TWAP_WINDOW, + MORPHO_ASSET_FORK_BLOCK, + MORPHO_USD_AT_FORK_BLOCK, +} from './constants' + +let chainId: string + +const setup = async (blockNumber: number) => { + await hre.network.provider.request({ + method: 'hardhat_reset', + params: [ + { + forking: { + jsonRpcUrl: useEnv('MAINNET_RPC_URL'), + blockNumber: blockNumber, + }, + }, + ], + }) +} + +const describeFork = + useEnv('FORK') && useEnv('FORK_NETWORK') === 'mainnet' ? describe : describe.skip + +const DECAY_DELAY = ETH_ORACLE_TIMEOUT.add(310) + +describeFork('MorphoAsset #fast', () => { + let morpho: ERC20Mock + let morphoAsset: MorphoAsset + let wallet: Wallet + let MorphoAssetFactory: ContractFactory + let ethUsdOracle: MockV3Aggregator + let ethPrice: BigNumber + + // Deploy with overridable args, defaulting to the production configuration + const deployAsset = async ( + overrides: { + priceTimeout?: BigNumber + feed?: string + oracleError?: BigNumber + erc20?: string + maxTradeVolume?: BigNumber + oracleTimeout?: BigNumber + pool?: string + quoteToken?: string + twapWindow?: number + } = {} + ): Promise => { + return ( + await MorphoAssetFactory.deploy( + overrides.priceTimeout ?? PRICE_TIMEOUT, + overrides.feed ?? ethUsdOracle.address, + overrides.oracleError ?? MORPHO_ORACLE_ERROR, + overrides.erc20 ?? MORPHO, + overrides.maxTradeVolume ?? MORPHO_MAX_TRADE_VOLUME, + overrides.oracleTimeout ?? ETH_ORACLE_TIMEOUT, + overrides.pool ?? MORPHO_WETH_POOL_030, + overrides.quoteToken ?? WETH, + overrides.twapWindow ?? MORPHO_TWAP_WINDOW + ) + ) + } + + // Independently compute the pool's arithmetic mean tick, straight from observe() + const meanTickFromPool = async (pool: string, window: number): Promise => { + const p = await ethers.getContractAt('IUniswapV3Pool', pool) + const { tickCumulatives } = await p.observe([window, 0]) + const delta = tickCumulatives[1].sub(tickCumulatives[0]) + let tick = delta.div(window) + if (delta.lt(0) && !delta.mod(window).isZero()) tick = tick.sub(1) + return tick.toNumber() + } + + before(async () => { + await setup(MORPHO_ASSET_FORK_BLOCK) + ;[wallet] = (await ethers.getSigners()) as unknown as Wallet[] + chainId = await getChainId(hre) + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + }) + + beforeEach(async () => { + await setup(MORPHO_ASSET_FORK_BLOCK) + + morpho = await ethers.getContractAt('ERC20Mock', MORPHO) + + // Mirror the live ETH/USD feed into a mock so tests can drive it + const ethOracle = await ethers.getContractAt('AggregatorV3Interface', ETH_USD_FEED) + ethPrice = (await ethOracle.latestRoundData()).answer + + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + ethUsdOracle = await MockV3AggregatorFactory.deploy(8, ethPrice) + await ethUsdOracle.deployed() + await ethUsdOracle.updateAnswer(ethPrice) + + MorphoAssetFactory = await ethers.getContractFactory('MorphoAsset') + morphoAsset = await deployAsset() + await morphoAsset.deployed() + await morphoAsset.refresh() + }) + + describe('Deployment', () => { + it('sets up the MORPHO asset correctly', async () => { + expect(await morphoAsset.isCollateral()).to.equal(false) + expect(await morphoAsset.erc20()).to.equal(MORPHO) + expect(await morpho.decimals()).to.equal(18) + expect(await morphoAsset.erc20Decimals()).to.equal(18) + expect(await morphoAsset.version()).to.equal(VERSION) + expect(await morphoAsset.maxTradeVolume()).to.equal(MORPHO_MAX_TRADE_VOLUME) + expect(await morphoAsset.priceTimeout()).to.equal(PRICE_TIMEOUT) + expect(await morphoAsset.oracleError()).to.equal(MORPHO_ORACLE_ERROR) + expect(await morphoAsset.oracleTimeout()).to.equal(ETH_ORACLE_TIMEOUT) + expect(await morphoAsset.uniswapV3Pool()).to.equal(MORPHO_WETH_POOL_030) + expect(await morphoAsset.quoteToken()).to.equal(WETH) + expect(await morphoAsset.twapWindow()).to.equal(MORPHO_TWAP_WINDOW) + }) + + it('prices MORPHO at the expected level for the fork block', async () => { + await expectPrice( + morphoAsset.address, + MORPHO_USD_AT_FORK_BLOCK, + MORPHO_ORACLE_ERROR, + true, + bn('1e3') + ) + }) + + it('claimRewards is a no-op', async () => { + await expect(morphoAsset.claimRewards()).to.not.emit(morphoAsset, 'RewardsClaimed') + }) + + it('bal() reports whole-token balances', async () => { + expect(await morphoAsset.bal(wallet.address)).to.equal(0) + }) + }) + + describe('Constructor validation', () => { + // --- inherited from Asset --- + it('does not allow price timeout of zero', async () => { + await expect(deployAsset({ priceTimeout: bn(0) })).to.be.revertedWith('price timeout zero') + }) + + it('does not allow missing chainlink feed', async () => { + await expect(deployAsset({ feed: ZERO_ADDRESS })).to.be.revertedWith('missing chainlink feed') + }) + + it('does not allow missing erc20', async () => { + await expect(deployAsset({ erc20: ZERO_ADDRESS })).to.be.revertedWith('missing erc20') + }) + + it('does not allow zero oracleError', async () => { + await expect(deployAsset({ oracleError: bn(0) })).to.be.revertedWith( + 'oracle error out of range' + ) + }) + + it('does not allow FIX_ONE oracleError', async () => { + await expect(deployAsset({ oracleError: fp('1') })).to.be.revertedWith( + 'oracle error out of range' + ) + }) + + it('does not allow zero oracleTimeout', async () => { + await expect(deployAsset({ oracleTimeout: bn(0) })).to.be.revertedWith('oracleTimeout zero') + }) + + it('does not allow zero maxTradeVolume', async () => { + await expect(deployAsset({ maxTradeVolume: bn(0) })).to.be.revertedWith( + 'invalid max trade volume' + ) + }) + + // --- MorphoAsset-specific --- + it('does not allow a missing pool', async () => { + await expect(deployAsset({ pool: ZERO_ADDRESS })).to.be.revertedWith('missing pool') + }) + + it('does not allow a missing quoteToken', async () => { + await expect(deployAsset({ quoteToken: ZERO_ADDRESS })).to.be.revertedWith( + 'missing quoteToken' + ) + }) + + it('does not allow quoteToken == erc20', async () => { + await expect(deployAsset({ quoteToken: MORPHO })).to.be.revertedWith('quoteToken is erc20') + }) + + it('does not allow a zero twapWindow', async () => { + await expect(deployAsset({ twapWindow: 0 })).to.be.revertedWith('twapWindow zero') + }) + + it('rejects a pool that does not hold the erc20/quoteToken pair', async () => { + await expect(deployAsset({ quoteToken: USDC })).to.be.revertedWith('pool token mismatch') + }) + + it('rejects a pool that cannot serve the requested twapWindow', async () => { + // 100 years of observations are certainly not retained -> the constructor probe reverts + await expect(deployAsset({ twapWindow: 3153600000 })).to.be.reverted + }) + + it('accepts the erc20 in either token slot', async () => { + const MockPoolFactory = await ethers.getContractFactory('MockUniswapV3Pool') + + // Uniswap always orders a pool's tokens by address, so which slot MORPHO occupies is + // determined by the pair: MORPHO(0x58D9..) < WETH(0xC02a..) but > WBTC(0x2260..). + const asToken0 = await MockPoolFactory.deploy(MORPHO, WETH, -68382) + const asToken1 = await MockPoolFactory.deploy(WBTC, MORPHO, 335624) + await expect(deployAsset({ pool: asToken0.address })).to.not.be.reverted + await expect(deployAsset({ pool: asToken1.address, quoteToken: WBTC })).to.not.be.reverted + }) + }) + + describe('TWAP pricing', () => { + it('matches an independently computed TWAP from the live pool', async () => { + const meanTick = await meanTickFromPool(MORPHO_WETH_POOL_030, MORPHO_TWAP_WINDOW) + + // {WETH/MORPHO} = 1.0001^tick, since MORPHO is token0 and both tokens have 18 decimals + const wethPerMorpho = Math.pow(1.0001, meanTick) + const expected = fp(String(wethPerMorpho.toFixed(18))) + .mul(ethPrice) + .div(bn('1e8')) + + const [low, high] = await morphoAsset.price() + const mid = low.add(high).div(2) + expect(mid).to.be.closeTo(expected, expected.div(bn('1e4'))) // 1 part in 10k + }) + + it('scales linearly with the ETH/USD feed', async () => { + const [low0, high0] = await morphoAsset.price() + + await setOraclePrice(morphoAsset.address, ethPrice.mul(2)) + const [low1, high1] = await morphoAsset.price() + + expect(low1).to.be.closeTo(low0.mul(2), low0.div(bn('1e6'))) + expect(high1).to.be.closeTo(high0.mul(2), high0.div(bn('1e6'))) + }) + + it('applies oracleError symmetrically around the TWAP price', async () => { + const [low, high] = await morphoAsset.price() + const mid = low.add(high).div(2) + expect(low).to.be.closeTo(mid.sub(mid.mul(MORPHO_ORACLE_ERROR).div(fp('1'))), bn('1e12')) + expect(high).to.be.closeTo(mid.add(mid.mul(MORPHO_ORACLE_ERROR).div(fp('1'))), bn('1e12')) + }) + + it('the 1% pool prices MORPHO close to the 0.30% pool', async () => { + const other = await deployAsset({ pool: MORPHO_WETH_POOL_100 }) + const [lowA, highA] = await morphoAsset.price() + const [lowB, highB] = await other.price() + const midA = lowA.add(highA).div(2) + const midB = lowB.add(highB).div(2) + expect(midB).to.be.closeTo(midA, midA.div(20)) // within 5% + }) + + it('a longer window still prices within a narrow band of the shorter one', async () => { + const long = await deployAsset({ twapWindow: 3600 }) + expect(await long.twapWindow()).to.equal(3600) + const [lowA, highA] = await morphoAsset.price() + const [lowB, highB] = await long.price() + expect(lowB.add(highB).div(2)).to.be.closeTo( + lowA.add(highA).div(2), + lowA.add(highA).div(2).div(10) // within 10% + ) + }) + + it('handles a lower-address quote token and non-18-decimal quote', async () => { + // MORPHO(0x58D9..) > WBTC(0x2260..), so the pool is (WBTC, MORPHO) and getQuoteAtTick + // takes its inverse branch. WBTC also has 8 decimals, exercising the decimal shift. + const MockPoolFactory = await ethers.getContractFactory('MockUniswapV3Pool') + const tick = 335624 + const pool = await MockPoolFactory.deploy(WBTC, MORPHO, tick) + const asset = await deployAsset({ pool: pool.address, quoteToken: WBTC }) + + // 1.0001^tick = {qMORPHO/qWBTC}; so {WBTC/MORPHO} = (1e18 / ratio) / 1e8 + const ratio = Math.pow(1.0001, tick) + const wbtcPerMorpho = 1e18 / ratio / 1e8 + const expected = fp(wbtcPerMorpho.toFixed(18)).mul(ethPrice).div(bn('1e8')) + + const [low, high] = await asset.price() + const mid = low.add(high).div(2) + expect(mid).to.be.closeTo(expected, expected.div(bn('1e3'))) // 1 part in 1k + + // Raising the tick means more MORPHO per WBTC, i.e. MORPHO is cheaper + await pool.setMeanTick(tick + 6932) + const [low2, high2] = await asset.price() + expect(low2.add(high2).div(2)).to.be.closeTo(mid.div(2), mid.div(100)) + }) + + it('price tracks the mean tick', async () => { + const MockPoolFactory = await ethers.getContractFactory('MockUniswapV3Pool') + const pool = await MockPoolFactory.deploy(MORPHO, WETH, -68382) + const asset = await deployAsset({ pool: pool.address }) + + const [low0, high0] = await asset.price() + + // +6932 ticks ~ 2x price (1.0001^6932 ~= 2.0) + await pool.setMeanTick(-68382 + 6932) + const [low1, high1] = await asset.price() + + expect(low1).to.be.closeTo(low0.mul(2), low0.div(100)) + expect(high1).to.be.closeTo(high0.mul(2), high0.div(100)) + + // and downwards + await pool.setMeanTick(-68382 - 6932) + const [low2] = await asset.price() + expect(low2).to.be.closeTo(low0.div(2), low0.div(100)) + }) + + it('is unaffected by the ETH/USD feed when the pool moves, and vice versa', async () => { + const MockPoolFactory = await ethers.getContractFactory('MockUniswapV3Pool') + const pool = await MockPoolFactory.deploy(MORPHO, WETH, -68382) + const asset = await deployAsset({ pool: pool.address }) + + const [low0] = await asset.price() + + // Pool doubles, feed constant -> price doubles + await pool.setMeanTick(-68382 + 6932) + const [low1] = await asset.price() + expect(low1).to.be.closeTo(low0.mul(2), low0.div(100)) + + // Feed halves, pool constant -> back to ~original + await setOraclePrice(asset.address, ethPrice.div(2)) + const [low2] = await asset.price() + expect(low2).to.be.closeTo(low0, low0.div(100)) + }) + }) + + describe('TWAP unavailability', () => { + it('decays to unpriced if the pool can no longer serve the window', async () => { + const MockPoolFactory = await ethers.getContractFactory('MockUniswapV3Pool') + const pool = await MockPoolFactory.deploy(MORPHO, WETH, -68382) + const asset = await deployAsset({ pool: pool.address }) + await asset.refresh() + + const savedPrice = await asset.price() + + // Simulate observation history being evicted: observe() now reverts "OLD" + await pool.setRevertOld(true) + + // tryPrice reverts, so refresh() saves nothing and the saved price is retained + await expect(asset.tryPrice()).to.be.revertedWith('OLD') + await asset.refresh() + await expectExactPrice(asset.address, savedPrice) + + // After the decay delay the price band widens + await advanceTime(DECAY_DELAY.toString()) + await asset.refresh() + await expectDecayedPrice(asset.address) + + // After the full price timeout it is unpriced + await advanceTime(PRICE_TIMEOUT.toString()) + await expectUnpriced(asset.address) + }) + + it('refresh() does not revert when the pool reverts', async () => { + const MockPoolFactory = await ethers.getContractFactory('MockUniswapV3Pool') + const pool = await MockPoolFactory.deploy(MORPHO, WETH, -68382) + const asset = await deployAsset({ pool: pool.address }) + await asset.refresh() + + await pool.setRevertOld(true) + await expect(asset.refresh()).to.not.be.reverted + }) + + it('recovers if the pool becomes serviceable again', async () => { + const MockPoolFactory = await ethers.getContractFactory('MockUniswapV3Pool') + const pool = await MockPoolFactory.deploy(MORPHO, WETH, -68382) + const asset = await deployAsset({ pool: pool.address }) + await asset.refresh() + const before = await asset.price() + + await pool.setRevertOld(true) + await asset.refresh() + + await pool.setRevertOld(false) + await asset.refresh() + await expectExactPrice(asset.address, before) + }) + }) + + describe('Chainlink feed failure', () => { + it('remains at the saved price if the feed is stale', async () => { + const initialPrice = await morphoAsset.price() + await advanceTime(DECAY_DELAY.sub(12).toString()) + + await morphoAsset.refresh() + expect(await morphoAsset.lastSave()).to.not.equal(await getLatestBlockTimestamp()) + await expectExactPrice(morphoAsset.address, initialPrice) + }) + + it('remains at the saved price on an invalid timestamp', async () => { + const initialPrice = await morphoAsset.price() + await setInvalidOracleTimestamp(morphoAsset.address) + + await morphoAsset.refresh() + expect(await morphoAsset.lastSave()).to.not.equal(await getLatestBlockTimestamp()) + await expectExactPrice(morphoAsset.address, initialPrice) + }) + + it('remains at the saved price on an invalid answered round', async () => { + const initialPrice = await morphoAsset.price() + await setInvalidOracleAnsweredRound(morphoAsset.address) + + await morphoAsset.refresh() + expect(await morphoAsset.lastSave()).to.not.equal(await getLatestBlockTimestamp()) + await expectExactPrice(morphoAsset.address, initialPrice) + }) + + it('becomes unpriced if the feed reports zero', async () => { + const initPrice = await morphoAsset.price() + await setOraclePrice(morphoAsset.address, bn('0')) + await expectExactPrice(morphoAsset.address, initPrice) + + await advanceTime(DECAY_DELAY.add(1).toString()) + await setOraclePrice(morphoAsset.address, bn('0')) + await morphoAsset.refresh() + await expectDecayedPrice(morphoAsset.address) + + await advanceTime(PRICE_TIMEOUT.toString()) + await setOraclePrice(morphoAsset.address, bn('0')) + await expectUnpriced(morphoAsset.address) + }) + + it('reverts if the feed reverts or runs out of gas', async () => { + const InvalidMockV3AggregatorFactory = await ethers.getContractFactory( + 'InvalidMockV3Aggregator' + ) + const invalidFeed = ( + await InvalidMockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + const invalidAsset = await deployAsset({ feed: invalidFeed.address }) + + await invalidFeed.setSimplyRevert(true) + await expect(invalidAsset.price()).to.be.reverted + await expect(invalidAsset.refresh()).to.be.reverted + + await invalidFeed.setSimplyRevert(false) + await expect(invalidAsset.price()).to.be.reverted + await expect(invalidAsset.refresh()).to.be.reverted + }) + + it('bubbles up an explicit feed error', async () => { + const InvalidMockV3AggregatorFactory = await ethers.getContractFactory( + 'InvalidMockV3Aggregator' + ) + const invalidFeed = ( + await InvalidMockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + const invalidAsset = await deployAsset({ feed: invalidFeed.address }) + + await invalidFeed.setRevertWithExplicitError(true) + await expect(invalidAsset.tryPrice()).to.be.revertedWith('oracle explicit error') + }) + }) + + describe('Saved prices & decay', () => { + it('saves prices on refresh', async () => { + let currBlockTimestamp: number = await getLatestBlockTimestamp() + const [lowPrice, highPrice] = await morphoAsset.price() + expect(await morphoAsset.savedLowPrice()).to.equal(lowPrice) + expect(await morphoAsset.savedHighPrice()).to.equal(highPrice) + expect(await morphoAsset.lastSave()).to.equal(currBlockTimestamp) + + // Raise the feed; saved prices lag until refresh + await setOraclePrice(morphoAsset.address, ethPrice.mul(120).div(100)) + const [newLow, newHigh] = await morphoAsset.price() + expect(await morphoAsset.savedLowPrice()).to.be.lt(newLow) + expect(await morphoAsset.savedHighPrice()).to.be.lt(newHigh) + + await morphoAsset.refresh() + expect(await morphoAsset.savedLowPrice()).to.equal(newLow) + expect(await morphoAsset.savedHighPrice()).to.equal(newHigh) + currBlockTimestamp = await getLatestBlockTimestamp() + expect(await morphoAsset.lastSave()).to.equal(currBlockTimestamp) + expect(newLow).to.be.gt(lowPrice) + expect(newHigh).to.be.gt(highPrice) + }) + + it('decays the price band over priceTimeout', async () => { + await morphoAsset.refresh() + const [prevLow, prevHigh] = await morphoAsset.price() + + await setInvalidOracleTimestamp(morphoAsset.address) + + // No decay at first + const [low2, high2] = await morphoAsset.price() + expect(low2).to.equal(prevLow) + expect(high2).to.equal(prevHigh) + + await advanceTime(DECAY_DELAY.toString()) + const [low3, high3] = await morphoAsset.price() + expect(low3).to.be.lt(low2) + expect(high3).to.be.gt(high2) + + await advanceToTimestamp((await getLatestBlockTimestamp()) + 12) + const [low4, high4] = await morphoAsset.price() + expect(low4).to.be.lt(low3) + expect(high4).to.be.gt(high3) + + await advanceTime(PRICE_TIMEOUT.toNumber()) + const [low5, high5] = await morphoAsset.price() + expect(low5).to.equal(bn(0)) + expect(high5).to.equal(MAX_UINT192) + }) + + it('lotPrice (deprecated) equals price()', async () => { + const lotPrice = await morphoAsset.lotPrice() + const price = await morphoAsset.price() + expect(price.length).to.equal(2) + expect(lotPrice.length).to.equal(price.length) + expect(lotPrice[0]).to.equal(price[0]) + expect(lotPrice[1]).to.equal(price[1]) + }) + }) +}) diff --git a/test/plugins/individual-collateral/meta-morpho/constants.ts b/test/plugins/individual-collateral/meta-morpho/constants.ts index f432eb485..c65b92c1a 100644 --- a/test/plugins/individual-collateral/meta-morpho/constants.ts +++ b/test/plugins/individual-collateral/meta-morpho/constants.ts @@ -28,6 +28,16 @@ export const BBUSDT = networkConfig[chainId].tokens.bbUSDT! export const RE7WETH = networkConfig[chainId].tokens.Re7WETH! export const MEUSD = networkConfig[chainId].tokens.meUSD! +// Morpho Vault V2 (mainnet) +export const STEAKUSDC_PRIME = networkConfig[chainId].tokens.steakUSDCPrime! +export const SENTORA_PYUSD = networkConfig[chainId].tokens.sentoraPYUSD! +export const GAUNTLET_USDC_FRONTIER = networkConfig[chainId].tokens.gauntletUSDCFrontier! +export const STEAKUSDT_PRIME = networkConfig[chainId].tokens.steakUSDTPrime! +export const GALAXY_USDT_QUALITY = networkConfig[chainId].tokens.galaxyUSDTQuality! +export const GAUNTLET_USDC_PRIME = networkConfig[chainId].tokens.gauntletUSDCPrime! +export const GALAXY_USDC_QUALITY = networkConfig[chainId].tokens.galaxyUSDCQuality! +export const SKY_USDT_SAVINGS = networkConfig[chainId].tokens.skyUSDTSavings! + // USDC export const USDC_USD_FEED = networkConfig[chainId].chainlinkFeeds.USDC! export const USDC_ORACLE_TIMEOUT = bn('82800') // 23 hrs @@ -64,3 +74,55 @@ const FORK_BLOCKS: { [key: string]: number } = { } export const FORK_BLOCK = FORK_BLOCKS[chainId] + +// Morpho Vault V2 vaults post-date the V1 mainnet block above; keyed by network for consistency +const FORK_BLOCKS_V2: { [key: string]: number } = { + '1': 25250000, + '8453': 20454200, // no V2 vaults integrated yet + '42161': 193157126, // not used +} + +export const FORK_BLOCK_V2 = FORK_BLOCKS_V2[chainId] + +// ============================ MORPHO reward-token Asset ============================ +// MORPHO is the reward token earned by holding the MetaMorpho / Morpho Vault V2 collateral +// above. `MorphoAsset` exists so it can be sold as revenue; it is never used as backing. +// Mainnet-only: the MORPHO/WETH Uniswap V3 pools are mainnet-only, and no {UoA} feed for +// MORPHO exists there (Chainlink's MORPHO/USD is Base-only), hence the TWAP. The suite skips +// unless FORK_NETWORK=mainnet. +// Reuses ETH_USD_FEED, ETH_ORACLE_TIMEOUT and PRICE_TIMEOUT from above. + +// The new, transferable MORPHO. The legacy 0x9994E35D... is a different ERC20 and is NOT +// what rewards are paid in; see LEGACY_MORPHO in the morpho-aave constants. +export const MORPHO = '0x58D97B57BB95320F9a05dC918Aef65434969c2B2' + +export const WETH = networkConfig[chainId].tokens.WETH! +export const USDC = networkConfig[chainId].tokens.USDC! +// Lower-address token than MORPHO, used to exercise the inverse quote branch in tests +export const WBTC = networkConfig[chainId].tokens.WBTC! + +// Uniswap V3 MORPHO/WETH pools; both hold MORPHO as token0. +// The 0.30% pool is the deeper of the two and is the intended production source. +export const MORPHO_WETH_POOL_030 = '0xc8219b876753A85025156b22176c2eDEA17aAC53' +export const MORPHO_WETH_POOL_100 = '0x25b96761e765b9AC20db18fA57Fa91e3b617Ec6F' + +// Much wider than a normal ETH/USD error. Two sources of uncertainty stack here: the ETH/USD +// feed itself, and TWAP-vs-spot drift on a pool with only ~$126k of total MORPHO liquidity. +// 10% reflects that honestly rather than understating it. +export const MORPHO_ORACLE_ERROR = fp('0.10') // 10% + +// Deliberately far below the usual $1e6. All mainnet MORPHO liquidity is ~$126k, and the deepest +// priceable venue (the 0.30% pool) holds ~$62k of MORPHO against ~$95-122k of WETH. Since a +// manipulated-downwards TWAP lowers the DutchTrade floor, per-auction exposure -- not the oracle +// -- is the binding protection. Revisit if liquidity deepens. +export const MORPHO_MAX_TRADE_VOLUME = fp('1e4') // $10k + +// {s} TWAP window. Longer is more manipulation-resistant; the pool must retain this much history. +export const MORPHO_TWAP_WINDOW = 1800 // 30 min + +// The MORPHO/WETH pools post-date the blocks above +export const MORPHO_ASSET_FORK_BLOCK = 25832080 + +// Expected MORPHO price at MORPHO_ASSET_FORK_BLOCK, from the 0.30% pool's 30-min TWAP x ETH/USD. +// ETH/USD = $2479.93, TWAP = 0.00107240 WETH/MORPHO +export const MORPHO_USD_AT_FORK_BLOCK = fp('2.6592') diff --git a/test/plugins/individual-collateral/meta-morpho/mintCollateralTo.ts b/test/plugins/individual-collateral/meta-morpho/mintCollateralTo.ts index 5d5318e23..ab132b7a2 100644 --- a/test/plugins/individual-collateral/meta-morpho/mintCollateralTo.ts +++ b/test/plugins/individual-collateral/meta-morpho/mintCollateralTo.ts @@ -11,6 +11,16 @@ export const whales: { [key: string]: string } = { [networkConfig['31337'].tokens.bbUSDT!]: '0xc8E3C36a72B9AA4Af0a057eb4A11e1AFC16465bB', [networkConfig['31337'].tokens.Re7WETH!]: '0xd553294B42bdFEb49D8f5A64E8B2D3A65fc673A9', [networkConfig['8453'].tokens.meUSD!]: '0xF02ea73c7A3057649f09899aaE1606712758bE8b', + // Morpho Vault V2 share whales (mainnet, verified holding at block 25250000) + [networkConfig['31337'].tokens.steakUSDCPrime!]: '0x087e2aac2e2457a5107700a58543e35ff63391b4', + [networkConfig['31337'].tokens.sentoraPYUSD!]: '0xc5e0e2bd8b8663c621b5051d863d072295da9720', + [networkConfig['31337'].tokens.gauntletUSDCFrontier!]: + '0x3bd9248048df95db4fbd748c6cd99c1baa40bad0', + [networkConfig['31337'].tokens.steakUSDTPrime!]: '0xffff8e7c98fc62dde2081825e7556b05717159c9', + [networkConfig['31337'].tokens.galaxyUSDTQuality!]: '0xaac7519736532f7731d1b01b0221c8a0959dd0fe', + [networkConfig['31337'].tokens.gauntletUSDCPrime!]: '0xd8b444ac665b0de3a9135ad7be1a45f88ac97b3c', + [networkConfig['31337'].tokens.galaxyUSDCQuality!]: '0xff43c5727fbfc31cb96e605dfd7546eb8862064c', + [networkConfig['31337'].tokens.skyUSDTSavings!]: '0xb6a40866b80074e478d349c0aa5ddeb47405fd06', } /** diff --git a/test/plugins/individual-collateral/morpho-aave/MorphoAAVEFiatCollateral.test.ts b/test/plugins/individual-collateral/morpho-aave/MorphoAAVEFiatCollateral.test.ts index 4e96f1502..6a2e42ae6 100644 --- a/test/plugins/individual-collateral/morpho-aave/MorphoAAVEFiatCollateral.test.ts +++ b/test/plugins/individual-collateral/morpho-aave/MorphoAAVEFiatCollateral.test.ts @@ -13,6 +13,7 @@ import { CollateralOpts } from '../pluginTestTypes' import { pushOracleForward } from '../../../utils/oracles' import { DEFAULT_THRESHOLD, + LEGACY_MORPHO, DELAY_UNTIL_DEFAULT, FORK_BLOCK, ORACLE_ERROR, @@ -56,7 +57,7 @@ const makeAaveFiatCollateralTestSuite = ( morphoLens: networkConfigToUse.MORPHO_AAVE_LENS!, underlyingERC20: opts.underlyingToken!, poolToken: opts.poolToken!, - rewardToken: networkConfigToUse.tokens.MORPHO!, + rewardToken: LEGACY_MORPHO, }) opts.erc20 = wrapperMock.address } @@ -105,7 +106,7 @@ const makeAaveFiatCollateralTestSuite = ( morphoLens: networkConfigToUse.MORPHO_AAVE_LENS!, underlyingERC20: opts.underlyingToken!, poolToken: opts.poolToken!, - rewardToken: networkConfigToUse.tokens.MORPHO!, + rewardToken: LEGACY_MORPHO, }) const MockV3AggregatorFactory = ( @@ -213,7 +214,7 @@ const makeAaveFiatCollateralTestSuite = ( morphoLens: networkConfigToUse.MORPHO_AAVE_LENS!, underlyingERC20: defaultCollateralOpts.underlyingToken!, poolToken: defaultCollateralOpts.poolToken!, - rewardToken: networkConfigToUse.tokens.MORPHO!, + rewardToken: LEGACY_MORPHO, }) const morphoTokenOwner = '0xcBa28b38103307Ec8dA98377ffF9816C164f9AFa' @@ -222,11 +223,7 @@ const makeAaveFiatCollateralTestSuite = ( const vaultWithClaimableRewards = usdtVault.attach(claimer) await whileImpersonating(hre, morphoTokenOwner, async (signer) => { - const morphoTokenInst = await ethers.getContractAt( - 'IMorphoToken', - networkConfigToUse.tokens.MORPHO!, - signer - ) + const morphoTokenInst = await ethers.getContractAt('IMorphoToken', LEGACY_MORPHO, signer) await morphoTokenInst .connect(signer) @@ -275,9 +272,9 @@ const makeAaveFiatCollateralTestSuite = ( await advanceTime(hre, 86400 * 7) await advanceBlocks(hre, 7200 * 7) expect(await vaultWithClaimableRewards.connect(alice).claimRewards()) - expect( - await erc20Factory.attach(networkConfigToUse.tokens.MORPHO!).balanceOf(aliceAddress) - ).to.be.eq(bn('14162082619942089266')) + expect(await erc20Factory.attach(LEGACY_MORPHO).balanceOf(aliceAddress)).to.be.eq( + bn('14162082619942089266') + ) }) it('Frontrunning claiming rewards is not economical', async () => { const alice = hre.ethers.provider.getSigner(1) diff --git a/test/plugins/individual-collateral/morpho-aave/MorphoAAVENonFiatCollateral.test.ts b/test/plugins/individual-collateral/morpho-aave/MorphoAAVENonFiatCollateral.test.ts index 937ec99e7..020d86076 100644 --- a/test/plugins/individual-collateral/morpho-aave/MorphoAAVENonFiatCollateral.test.ts +++ b/test/plugins/individual-collateral/morpho-aave/MorphoAAVENonFiatCollateral.test.ts @@ -18,6 +18,7 @@ import { CollateralOpts } from '../pluginTestTypes' import { pushOracleForward } from '../../../utils/oracles' import { DEFAULT_THRESHOLD, + LEGACY_MORPHO, DELAY_UNTIL_DEFAULT, FORK_BLOCK, ORACLE_ERROR, @@ -54,7 +55,7 @@ const makeAaveNonFiatCollateralTestSuite = ( morphoLens: configToUse.MORPHO_AAVE_LENS!, underlyingERC20: opts.underlyingToken!, poolToken: opts.poolToken!, - rewardToken: configToUse.tokens.MORPHO!, + rewardToken: LEGACY_MORPHO, }) opts.erc20 = wrapperMock.address } @@ -104,7 +105,7 @@ const makeAaveNonFiatCollateralTestSuite = ( morphoLens: configToUse.MORPHO_AAVE_LENS!, underlyingERC20: opts.underlyingToken!, poolToken: opts.poolToken!, - rewardToken: configToUse.tokens.MORPHO!, + rewardToken: LEGACY_MORPHO, }) const MockV3AggregatorFactory = ( diff --git a/test/plugins/individual-collateral/morpho-aave/MorphoAAVESelfReferentialCollateral.test.ts b/test/plugins/individual-collateral/morpho-aave/MorphoAAVESelfReferentialCollateral.test.ts index f29d25b98..031895c1b 100644 --- a/test/plugins/individual-collateral/morpho-aave/MorphoAAVESelfReferentialCollateral.test.ts +++ b/test/plugins/individual-collateral/morpho-aave/MorphoAAVESelfReferentialCollateral.test.ts @@ -18,6 +18,7 @@ import { CollateralOpts } from '../pluginTestTypes' import { pushOracleForward } from '../../../utils/oracles' import { DELAY_UNTIL_DEFAULT, + LEGACY_MORPHO, FORK_BLOCK, ORACLE_ERROR, ORACLE_TIMEOUT, @@ -49,7 +50,7 @@ const deployCollateral = async (opts: MAFiatCollateralOpts = {}): Promise( diff --git a/test/plugins/individual-collateral/morpho-aave/MorphoAaveV2TokenisedDeposit.test.ts b/test/plugins/individual-collateral/morpho-aave/MorphoAaveV2TokenisedDeposit.test.ts index aa5a764dd..e872055fd 100644 --- a/test/plugins/individual-collateral/morpho-aave/MorphoAaveV2TokenisedDeposit.test.ts +++ b/test/plugins/individual-collateral/morpho-aave/MorphoAaveV2TokenisedDeposit.test.ts @@ -9,7 +9,7 @@ import { expect } from 'chai' import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' import { bn } from '#/common/numbers' import { getResetFork } from '../helpers' -import { FORK_BLOCK } from './constants' +import { FORK_BLOCK, LEGACY_MORPHO } from './constants' import { advanceTime } from '#/utils/time' type ITokenSymbol = keyof ITokens @@ -55,7 +55,7 @@ const execTestForToken = ({ const instances = { underlying: factories.ERC20Mock.attach(token.address), - morpho: factories.ERC20Mock.attach(networkConfigToUse.tokens.MORPHO!), + morpho: factories.ERC20Mock.attach(LEGACY_MORPHO), morphoAaveV2Controller: await ethers.getContractAt( 'IMorpho', networkConfigToUse.MORPHO_AAVE_CONTROLLER! @@ -65,7 +65,7 @@ const execTestForToken = ({ poolToken: poolToken.address, morphoController: networkConfigToUse.MORPHO_AAVE_CONTROLLER!, morphoLens: networkConfigToUse.MORPHO_AAVE_LENS!, - rewardToken: networkConfigToUse.tokens.MORPHO!, + rewardToken: LEGACY_MORPHO, }), } const underlyingDecimals = await instances.underlying.decimals() diff --git a/test/plugins/individual-collateral/morpho-aave/constants.ts b/test/plugins/individual-collateral/morpho-aave/constants.ts index f8435cdbe..e194ffd84 100644 --- a/test/plugins/individual-collateral/morpho-aave/constants.ts +++ b/test/plugins/individual-collateral/morpho-aave/constants.ts @@ -8,3 +8,9 @@ export const DEFAULT_THRESHOLD = ORACLE_ERROR.add(fp('0.01')) // 1% + ORACLE_ERR export const DELAY_UNTIL_DEFAULT = bn(86400) export const FORK_BLOCK = 19400000 + +// Morpho AAVE V2 predates the MORPHO token migration and distributes the LEGACY MORPHO token. +// Hardcoded here because networkConfig.tokens.MORPHO now points at the new, transferable token +// (0x58D97B57BB95320F9a05dC918Aef65434969c2B2). The two are convertible 1:1 via the Wrapper at +// 0x9D03bB2092270648d7480049d0E58d2FcF0E5123, but they are distinct ERC20s. +export const LEGACY_MORPHO = '0x9994E35Db50125E0DF82e4c2dde62496CE330999'