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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions common/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
24 changes: 24 additions & 0 deletions contracts/plugins/assets/meta-morpho/IMerklDistributor.sol
Original file line number Diff line number Diff line change
@@ -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;
}
34 changes: 34 additions & 0 deletions contracts/plugins/assets/meta-morpho/IMorphoVaultV2.sol
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
184 changes: 184 additions & 0 deletions contracts/plugins/assets/meta-morpho/MorphoAsset.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading