From 09a828178ee1b47c2ff481911990e3558976511c Mon Sep 17 00:00:00 2001 From: Julian R Date: Wed, 8 Oct 2025 09:34:05 -0300 Subject: [PATCH 1/9] etherfi weeth plugin --- common/configuration.ts | 7 + contracts/plugins/assets/etherfi/README.md | 29 ++ .../assets/etherfi/WeEthCollateral.sol | 74 ++++ .../assets/etherfi/vendor/ILiquidityPool.sol | 13 + .../plugins/assets/etherfi/vendor/IWeETH.sol | 13 + contracts/plugins/mocks/WeETHMock.sol | 19 + .../etherfi/WeEthCollateral.test.ts | 381 ++++++++++++++++++ .../etherfi/constants.ts | 26 ++ .../individual-collateral/etherfi/helpers.ts | 34 ++ 9 files changed, 596 insertions(+) create mode 100644 contracts/plugins/assets/etherfi/README.md create mode 100644 contracts/plugins/assets/etherfi/WeEthCollateral.sol create mode 100644 contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol create mode 100644 contracts/plugins/assets/etherfi/vendor/IWeETH.sol create mode 100644 contracts/plugins/mocks/WeETHMock.sol create mode 100644 test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts create mode 100644 test/plugins/individual-collateral/etherfi/constants.ts create mode 100644 test/plugins/individual-collateral/etherfi/helpers.ts diff --git a/common/configuration.ts b/common/configuration.ts index 8fff48bd4..83f643643 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -134,6 +134,10 @@ export interface ITokens { USDS?: string sUSDS?: string + // Ether.fi + weETH?: string + eETH?: string + // RTokens eUSD?: string ETHPLUS?: string @@ -295,6 +299,8 @@ export const networkConfig: { [key: string]: INetworkConfig } = { USDS: '0xdC035D45d973E3EC169d2276DDab16f1e407384F', sUSDS: '0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD', wOETH: '0xDcEe70654261AF21C44c093C300eD3Bb97b78192', + weETH: '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee', + eETH: '0x35fA164735182de50811E8e2E824cFb9B6118ac2', }, chainlinkFeeds: { RSR: '0x759bBC1be8F90eE6457C44abc7d443842a976d02', @@ -326,6 +332,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { USDe: '0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961', USDS: '0xfF30586cD0F29eD462364C7e81375FC0C71219b1', OETHETH: '0x703118C4CbccCBF2AB31913e0f8075fbbb15f563', // OETH/ETH + weETH: '0x5c9C449BbC9a6075A2c061dF312a35fd1E05fF22', // weETH/ETH }, AAVE_INCENTIVES: '0xd784927Ff2f95ba542BfC824c8a8a98F3495f6b5', AAVE_EMISSIONS_MGR: '0xEE56e2B3D491590B5b31738cC34d5232F378a8D5', diff --git a/contracts/plugins/assets/etherfi/README.md b/contracts/plugins/assets/etherfi/README.md new file mode 100644 index 000000000..44595ebbd --- /dev/null +++ b/contracts/plugins/assets/etherfi/README.md @@ -0,0 +1,29 @@ +# Ether.fi weETH Collateral Plugin + +## Summary + +This plugin allows `weETH` holders to use their tokens as collateral in the Reserve Protocol. + +As described in the [Ether.fi Documentation](https://etherfi.gitbook.io/etherfi), Ether.fi is a decentralized, non-custodial liquid restaking protocol that consists of two tokens: `eETH` and `weETH`. + +Upon depositing ETH into the Ether.fi protocol, users receive `eETH` - a rebasing liquid staking token that earns staking and restaking rewards. The eETH token automatically rebases to reflect accrued rewards. Users can wrap their eETH into `weETH` (wrapped eETH), which is a non-rebasing token suitable for use in DeFi protocols and as collateral. + +`weETH` accrues revenue from **staking and restaking rewards** by **increasing** the exchange rate of `eETH` per `weETH`. This exchange rate grows over time as the Ether.fi protocol's validators earn consensus layer rewards and participate in restaking through EigenLayer. + +`eETH` contract: + +`weETH` contract: + +## Implementation + +### Units + +| tok | ref | target | UoA | +| ----- | ---- | ------ | --- | +| weETH | eETH | ETH | USD | + +### Functions + +#### refPerTok {ref/tok} + +This function returns the rate of `eETH/weETH`, obtained from the [getRate()](https://etherscan.io/address/0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee#readProxyContract) function in the weETH contract. diff --git a/contracts/plugins/assets/etherfi/WeEthCollateral.sol b/contracts/plugins/assets/etherfi/WeEthCollateral.sol new file mode 100644 index 000000000..37a907ea8 --- /dev/null +++ b/contracts/plugins/assets/etherfi/WeEthCollateral.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "../../../libraries/Fixed.sol"; +import "../AppreciatingFiatCollateral.sol"; +import "../OracleLib.sol"; +import "./vendor/IWeETH.sol"; + +/** + * @title weETH Collateral + * @notice Collateral plugin for Ether.fi weETH + * tok = weETH + * ref = eETH (pegged to ETH 1:1) + * tar = ETH + * UoA = USD + */ +contract WeEthCollateral is AppreciatingFiatCollateral { + using OracleLib for AggregatorV3Interface; + using FixLib for uint192; + + AggregatorV3Interface public immutable targetPerTokChainlinkFeed; + uint48 public immutable targetPerTokChainlinkTimeout; + + /// @param config.chainlinkFeed {UoA/target} price of ETH in USD terms + /// @param _targetPerTokChainlinkFeed {target/tok} price of weETH in ETH terms + constructor( + CollateralConfig memory config, + uint192 revenueHiding, + AggregatorV3Interface _targetPerTokChainlinkFeed, + uint48 _targetPerTokChainlinkTimeout + ) AppreciatingFiatCollateral(config, revenueHiding) { + require(config.defaultThreshold != 0, "defaultThreshold zero"); + require(address(_targetPerTokChainlinkFeed) != address(0), "missing targetPerTok feed"); + require(_targetPerTokChainlinkTimeout != 0, "targetPerTokChainlinkTimeout zero"); + + targetPerTokChainlinkFeed = _targetPerTokChainlinkFeed; + targetPerTokChainlinkTimeout = _targetPerTokChainlinkTimeout; + maxOracleTimeout = uint48(Math.max(maxOracleTimeout, _targetPerTokChainlinkTimeout)); + } + + /// Can revert, used by other contract functions in order to catch errors + /// @return low {UoA/tok} The low price estimate + /// @return high {UoA/tok} The high price estimate + /// @return pegPrice {target/ref} The actual price observed in the peg + function tryPrice() + external + view + override + returns ( + uint192 low, + uint192 high, + uint192 pegPrice + ) + { + uint192 targetPerTok = targetPerTokChainlinkFeed.price(targetPerTokChainlinkTimeout); + + // {UoA/tok} = {UoA/target} * {target/tok} + uint192 p = chainlinkFeed.price(oracleTimeout).mul(targetPerTok); + uint192 err = p.mul(oracleError, CEIL); + + high = p + err; + low = p - err; + // assert(low <= high); obviously true just by inspection + + // {target/ref} = {target/tok} / {ref/tok} + pegPrice = targetPerTok.div(underlyingRefPerTok()); + } + + /// @return {ref/tok} Quantity of whole reference units per whole collateral tokens + function underlyingRefPerTok() public view override returns (uint192) { + return _safeWrap(IWeETH(address(erc20)).getRate()); + } +} diff --git a/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol b/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol new file mode 100644 index 000000000..525879b49 --- /dev/null +++ b/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +// External interface for Ether.fi's LiquidityPool contract +interface ILiquidityPool { + function amountForShare(uint256 _share) external view returns (uint256); + + function sharesForAmount(uint256 _amount) external view returns (uint256); + + function getTotalPooledEther() external view returns (uint256); + + function rebase(int128 _accruedRewards) external; +} diff --git a/contracts/plugins/assets/etherfi/vendor/IWeETH.sol b/contracts/plugins/assets/etherfi/vendor/IWeETH.sol new file mode 100644 index 000000000..71f22b720 --- /dev/null +++ b/contracts/plugins/assets/etherfi/vendor/IWeETH.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// External interface for weETH +interface IWeETH is IERC20Metadata { + function getRate() external view returns (uint256); + + function getWeETHByeETH(uint256 _eETHAmount) external view returns (uint256); + + function getEETHByWeETH(uint256 _weETHAmount) external view returns (uint256); +} diff --git a/contracts/plugins/mocks/WeETHMock.sol b/contracts/plugins/mocks/WeETHMock.sol new file mode 100644 index 000000000..6e24708c9 --- /dev/null +++ b/contracts/plugins/mocks/WeETHMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "./ERC20Mock.sol"; + +contract WeEthMock is ERC20Mock { + uint256 private _rate; + + constructor() ERC20Mock("Mock WeETH", "WeEth") {} + + // Mock function for testing + function setRate(uint256 mockRate) external { + _rate = mockRate; + } + + function getRate() external view returns (uint256) { + return _rate; + } +} diff --git a/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts b/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts new file mode 100644 index 000000000..3002d90d2 --- /dev/null +++ b/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts @@ -0,0 +1,381 @@ +import collateralTests from '../collateralTests' +import { CollateralFixtureContext, CollateralOpts, MintCollateralFunc } from '../pluginTestTypes' +import { resetFork, mintWEETH, accrueRewards } from './helpers' +import hre, { ethers } from 'hardhat' +import { expect } from 'chai' +import { ContractFactory, BigNumberish, BigNumber } from 'ethers' +import { + ERC20Mock, + MockV3Aggregator, + MockV3Aggregator__factory, + TestICollateral, + IWeETH, + WeEthMock, + WETH9, +} from '../../../../typechain' +import { pushOracleForward } from '../../../utils/oracles' +import { advanceBlocks, advanceTime, getLatestBlockTimestamp } from '../../../utils/time' +import { bn, fp } from '../../../../common/numbers' +import { CollateralStatus, ZERO_ADDRESS, MAX_UINT48 } from '../../../../common/constants' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { + ETH_ORACLE_ERROR, + ETH_ORACLE_TIMEOUT, + PRICE_TIMEOUT, + MAX_TRADE_VOL, + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + WETH, + EETH, + WEETH, + ETH_USD_PRICE_FEED, + WEETH_ETH_PRICE_FEED, + WEETH_ORACLE_TIMEOUT, +} from './constants' + +/* + Define interfaces +*/ + +interface WeEthCollateralFixtureContext extends CollateralFixtureContext { + weth: WETH9 + eEth: ERC20Mock + weEth: IWeETH + targetPerTokChainlinkFeed: MockV3Aggregator +} + +interface WeEthCollateralFixtureContextMock extends WeEthCollateralFixtureContext { + weEthMock: WeEthMock +} + +interface WeEthCollateralOpts extends CollateralOpts { + targetPerTokChainlinkFeed?: string + targetPerTokChainlinkTimeout?: BigNumberish +} + +/* + Define deployment functions +*/ + +export const defaultWeEthCollateralOpts: WeEthCollateralOpts = { + erc20: WEETH, + targetName: ethers.utils.formatBytes32String('ETH'), + rewardERC20: ZERO_ADDRESS, + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: ETH_USD_PRICE_FEED, + oracleTimeout: ETH_ORACLE_TIMEOUT, + oracleError: ETH_ORACLE_ERROR, + maxTradeVolume: MAX_TRADE_VOL, + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + targetPerTokChainlinkFeed: WEETH_ETH_PRICE_FEED, + targetPerTokChainlinkTimeout: WEETH_ORACLE_TIMEOUT, + revenueHiding: fp('0'), +} + +export const deployCollateral = async ( + opts: WeEthCollateralOpts = {} +): Promise => { + opts = { ...defaultWeEthCollateralOpts, ...opts } + + const WeEthCollateralFactory: ContractFactory = await ethers.getContractFactory('WeEthCollateral') + + const collateral = await WeEthCollateralFactory.deploy( + { + erc20: opts.erc20, + targetName: opts.targetName, + priceTimeout: opts.priceTimeout, + chainlinkFeed: opts.chainlinkFeed, + oracleError: opts.oracleError, + oracleTimeout: opts.oracleTimeout, + maxTradeVolume: opts.maxTradeVolume, + defaultThreshold: opts.defaultThreshold, + delayUntilDefault: opts.delayUntilDefault, + }, + opts.revenueHiding, + opts.targetPerTokChainlinkFeed, + opts.targetPerTokChainlinkTimeout, + { gasLimit: 2000000000 } + ) + await collateral.deployed() + + // Push forward chainlink feed + await pushOracleForward(opts.chainlinkFeed!) + await pushOracleForward(opts.targetPerTokChainlinkFeed!) + + // sometimes we are trying to test a negative test case and we want this to fail silently + // fortunately this syntax fails silently because our tools are terrible + await expect(collateral.refresh()) + + return collateral +} + +const chainlinkDefaultAnswer = bn('1600e8') +const refPerTokChainlinkDefaultAnswer = fp('1.0283') + +type Fixture = () => Promise + +const makeCollateralFixtureContext = ( + alice: SignerWithAddress, + opts: WeEthCollateralOpts = {} +): Fixture => { + const collateralOpts = { ...defaultWeEthCollateralOpts, ...opts } + + const makeCollateralFixtureContext = async () => { + const MockV3AggregatorFactory = ( + await ethers.getContractFactory('MockV3Aggregator') + ) + + const chainlinkFeed = ( + await MockV3AggregatorFactory.deploy(8, chainlinkDefaultAnswer) + ) + + const targetPerTokChainlinkFeed = ( + await MockV3AggregatorFactory.deploy(18, refPerTokChainlinkDefaultAnswer) + ) + collateralOpts.chainlinkFeed = chainlinkFeed.address + collateralOpts.targetPerTokChainlinkFeed = targetPerTokChainlinkFeed.address + + const weth = (await ethers.getContractAt('WETH9', WETH)) as WETH9 + const eEth = (await ethers.getContractAt('ERC20Mock', EETH)) as ERC20Mock + const weEth = (await ethers.getContractAt('IWeETH', WEETH)) as IWeETH + const rewardToken = (await ethers.getContractAt('ERC20Mock', ZERO_ADDRESS)) as ERC20Mock + const collateral = await deployCollateral(collateralOpts) + + return { + alice, + collateral, + chainlinkFeed, + weth, + eEth, + weEth, + targetPerTokChainlinkFeed, + tok: weEth, + rewardToken, + } + } + + return makeCollateralFixtureContext +} + +const deployCollateralWeEthMockContext = async ( + opts: WeEthCollateralOpts = {} +): Promise => { + const collateralOpts = { ...defaultWeEthCollateralOpts, ...opts } + + const MockV3AggregatorFactory = ( + await ethers.getContractFactory('MockV3Aggregator') + ) + const chainlinkFeed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + collateralOpts.chainlinkFeed = chainlinkFeed.address + + const MockFactory = await ethers.getContractFactory('WeEthMock') + const erc20 = (await MockFactory.deploy()) as WeEthMock + const currentRate = await (await ethers.getContractAt('IWeETH', WEETH)).getRate() + await erc20.setRate(currentRate) + + const targetPerTokChainlinkFeed = ( + await MockV3AggregatorFactory.deploy(18, refPerTokChainlinkDefaultAnswer) + ) + collateralOpts.targetPerTokChainlinkFeed = targetPerTokChainlinkFeed.address + + const weth = (await ethers.getContractAt('WETH9', WETH)) as WETH9 + const eEth = (await ethers.getContractAt('ERC20Mock', EETH)) as ERC20Mock + const weEth = (await ethers.getContractAt('IWeETH', WEETH)) as IWeETH + + collateralOpts.erc20 = erc20.address + const collateral = await deployCollateral(collateralOpts) + + return { + weth, + collateral, + chainlinkFeed, + targetPerTokChainlinkFeed, + eEth, + weEth, + weEthMock: erc20, + tok: erc20, + } +} + +/* + Define helper functions +*/ + +const mintCollateralTo: MintCollateralFunc = async ( + ctx: WeEthCollateralFixtureContext, + amount: BigNumberish, + user: SignerWithAddress, + recipient: string +) => { + await mintWEETH(ctx.weEth, user, amount, recipient) +} + +const changeTargetPerRef = async (ctx: WeEthCollateralFixtureContext, percentChange: BigNumber) => { + // We leave the actual refPerTok exchange where it is and just change {target/tok} + { + const lastRound = await ctx.targetPerTokChainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.add(lastRound.answer.mul(percentChange).div(100)) + await ctx.targetPerTokChainlinkFeed.updateAnswer(nextAnswer) + } +} + +const reduceTargetPerRef = async ( + ctx: WeEthCollateralFixtureContext, + pctDecrease: BigNumberish +) => { + await changeTargetPerRef(ctx, bn(pctDecrease).mul(-1)) +} + +const increaseTargetPerRef = async ( + ctx: WeEthCollateralFixtureContext, + pctDecrease: BigNumberish +) => { + await changeTargetPerRef(ctx, bn(pctDecrease)) +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const reduceRefPerTok = async (ctx: WeEthCollateralFixtureContext, pctDecrease: BigNumberish) => { + await hre.network.provider.send('evm_mine', []) +} + +// prettier-ignore +const increaseRefPerTok = async ( + ctx: WeEthCollateralFixtureContext, + pctIncrease: BigNumberish +) => { + // Get current rate + const currentRate = await ctx.weEth.getRate() + + // Calculate reward amount needed to increase rate by pctIncrease + const rewardAmount = currentRate.mul(pctIncrease).div(100) + + // Accrue rewards through LiquidityPool.rebase() + await accrueRewards(rewardAmount) + + await advanceBlocks(86400 / 12) + await advanceTime(86400) + + // Push chainlink oracles forward so that tryPrice() still works + const latestRoundData = await ctx.chainlinkFeed.latestRoundData() + await ctx.chainlinkFeed.updateAnswer(latestRoundData.answer) + + // Adjust weETH/ETH chainlink price as well to reflect the new rate + const lastRound = await ctx.targetPerTokChainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.add(lastRound.answer.mul(pctIncrease).div(100)) + await ctx.targetPerTokChainlinkFeed.updateAnswer(nextAnswer) +} + +const getExpectedPrice = async (ctx: WeEthCollateralFixtureContext): Promise => { + const clData = await ctx.chainlinkFeed.latestRoundData() + const clDecimals = await ctx.chainlinkFeed.decimals() + + const clRptData = await ctx.targetPerTokChainlinkFeed.latestRoundData() + const clRptDecimals = await ctx.targetPerTokChainlinkFeed.decimals() + + return clData.answer + .mul(bn(10).pow(18 - clDecimals)) + .mul(clRptData.answer.mul(bn(10).pow(18 - clRptDecimals))) + .div(fp('1')) +} + +/* + Define collateral-specific tests +*/ + +const collateralSpecificConstructorTests = () => { + it('does not allow missing targetPerTok chainlink feed', async () => { + await expect( + deployCollateral({ targetPerTokChainlinkFeed: ethers.constants.AddressZero }) + ).to.be.revertedWith('missing targetPerTok feed') + }) + + it('does not allow targetPerTok oracle timeout at 0', async () => { + await expect(deployCollateral({ targetPerTokChainlinkTimeout: 0 })).to.be.revertedWith( + 'targetPerTokChainlinkTimeout zero' + ) + }) +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const collateralSpecificStatusTests = () => { + it('does revenue hiding correctly', async () => { + const { collateral, weEthMock } = await deployCollateralWeEthMockContext({ + revenueHiding: fp('0.01'), + }) + + const currentRate = await (await ethers.getContractAt('IWeETH', WEETH)).getRate() + + // Should remain SOUND after a 1% decrease + let refPerTok = await collateral.refPerTok() + const newRate = currentRate.sub(currentRate.div(100)) + await weEthMock.setRate(newRate) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + // refPerTok should be unchanged + expect(await collateral.refPerTok()).to.be.closeTo(refPerTok, refPerTok.div(bn('1e3'))) // within 1-part-in-1-thousand + + // Should become DISABLED if drops another 1% + refPerTok = await collateral.refPerTok() + await weEthMock.setRate(newRate.sub(newRate.div(100))) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + + // refPerTok should have fallen 1% + refPerTok = refPerTok.sub(refPerTok.div(100)) + expect(await collateral.refPerTok()).to.be.closeTo(refPerTok, refPerTok.div(bn('1e3'))) // within 1-part-in-1-thousand + }) + + it('enters DISABLED state when refPerTok() decreases', async () => { + const { collateral, weEthMock } = await deployCollateralWeEthMockContext({ + revenueHiding: fp('0.01'), + }) + + // Check initial state + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + expect(await collateral.whenDefault()).to.equal(MAX_UINT48) + await expect(collateral.refresh()).to.not.emit(collateral, 'CollateralStatusChanged') + + // Should default instantly after 10% drop (beyond revenue hiding threshold) + const currentRate = await weEthMock.getRate() + await weEthMock.setRate(currentRate.sub(currentRate.mul(10).div(100))) + await expect(collateral.refresh()).to.emit(collateral, 'CollateralStatusChanged') + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + expect(await collateral.whenDefault()).to.equal(await getLatestBlockTimestamp()) + }) +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const beforeEachRewardsTest = async () => {} + +/* + Run the test suite +*/ + +const opts = { + deployCollateral, + collateralSpecificConstructorTests, + collateralSpecificStatusTests, + beforeEachRewardsTest, + makeCollateralFixtureContext, + mintCollateralTo, + reduceTargetPerRef, + increaseTargetPerRef, + reduceRefPerTok, + increaseRefPerTok, + getExpectedPrice, + itClaimsRewards: it.skip, + itChecksTargetPerRefDefault: it, + itChecksTargetPerRefDefaultUp: it, + itChecksRefPerTokDefault: it.skip, // implemented in this file + itChecksPriceChanges: it, + itChecksNonZeroDefaultThreshold: it, + itHasRevenueHiding: it.skip, // implemented in this file + resetFork, + collateralName: 'WeETH', + chainlinkDefaultAnswer, + itIsPricedByPeg: true, +} + +collateralTests(opts) diff --git a/test/plugins/individual-collateral/etherfi/constants.ts b/test/plugins/individual-collateral/etherfi/constants.ts new file mode 100644 index 000000000..c6cca4717 --- /dev/null +++ b/test/plugins/individual-collateral/etherfi/constants.ts @@ -0,0 +1,26 @@ +import { bn, fp } from '../../../../common/numbers' +import { networkConfig } from '../../../../common/configuration' + +// Mainnet Addresses +export const RSR = networkConfig['31337'].tokens.RSR as string +export const ETH_USD_PRICE_FEED = networkConfig['31337'].chainlinkFeeds.ETH as string +export const WEETH = networkConfig['31337'].tokens.weETH as string +export const EETH = networkConfig['31337'].tokens.eETH as string + +export const WEETH_ETH_PRICE_FEED = networkConfig['31337'].chainlinkFeeds.weETH as string +export const WETH = networkConfig['31337'].tokens.WETH as string +export const WEETH_WHALE = '0xBdfa7b7893081B35Fb54027489e2Bc7A38275129' +export const LIQUIDITY_POOL = '0x308861A430be4cce5502d0A12724771Fc6DaF216' +export const MEMBERSHIP_MANAGER = '0x3d320286E014C3e1ce99Af6d6B00f0C1D63E3000' + +export const PRICE_TIMEOUT = bn('604800') // 1 week +export const ETH_ORACLE_TIMEOUT = bn(3600) // 1 hour in seconds +export const ETH_ORACLE_ERROR = fp('0.005') +export const WEETH_ORACLE_ERROR = fp('0.005') // 0.5% +export const WEETH_ORACLE_TIMEOUT = bn(86400) // 24h + +export const DEFAULT_THRESHOLD = fp('0.05') // 5% +export const DELAY_UNTIL_DEFAULT = bn(259200) // 72h +export const MAX_TRADE_VOL = bn(1000) + +export const FORK_BLOCK = 19868380 diff --git a/test/plugins/individual-collateral/etherfi/helpers.ts b/test/plugins/individual-collateral/etherfi/helpers.ts new file mode 100644 index 000000000..03a03a323 --- /dev/null +++ b/test/plugins/individual-collateral/etherfi/helpers.ts @@ -0,0 +1,34 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { IWeETH } from '../../../../typechain' +import { whileImpersonating } from '../../../utils/impersonation' +import { BigNumberish } from 'ethers' +import { FORK_BLOCK, WEETH_WHALE, LIQUIDITY_POOL, MEMBERSHIP_MANAGER } from './constants' +import { getResetFork } from '../helpers' +import { ethers } from 'hardhat' + +export const mintWEETH = async ( + weETH: IWeETH, + account: SignerWithAddress, + amount: BigNumberish, + recipient: string +) => { + // transfer from a weETH whale + await whileImpersonating(WEETH_WHALE, async (weEthWhale) => { + await weETH.connect(weEthWhale).transfer(recipient, amount) + }) +} + +/** + * Simulate reward accrual in the Ether.fi protocol + * This increases the weETH exchange rate by calling rebase() on the LiquidityPool + */ +export const accrueRewards = async (rewardAmount: BigNumberish) => { + const liquidityPool = await ethers.getContractAt('ILiquidityPool', LIQUIDITY_POOL) + + // Call rebase() as the MembershipManager to accrue rewards + await whileImpersonating(MEMBERSHIP_MANAGER, async (membershipManagerSigner) => { + await liquidityPool.connect(membershipManagerSigner).rebase(rewardAmount) + }) +} + +export const resetFork = getResetFork(FORK_BLOCK) From cc6b13f6886df0b18bace8f3fc2cdbd34d4decae Mon Sep 17 00:00:00 2001 From: Julian R Date: Wed, 8 Oct 2025 09:37:14 -0300 Subject: [PATCH 2/9] deploy and verif script --- scripts/deploy.ts | 3 +- .../phase2-assets/collaterals/deploy_weeth.ts | 95 +++++++++++++++++++ .../collateral-plugins/verify_weeth.ts | 63 ++++++++++++ scripts/verify_etherscan.ts | 3 +- 4 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts create mode 100644 scripts/verification/collateral-plugins/verify_weeth.ts diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 6d1fd0fd6..60ce708bf 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -99,7 +99,8 @@ async function main() { 'phase2-assets/assets/deploy_cvx.ts', 'phase2-assets/collaterals/deploy_pyusd.ts', 'phase2-assets/collaterals/deploy_sky_susds.ts', - 'phase2-assets/collaterals/deploy_origin_oeth.ts' + 'phase2-assets/collaterals/deploy_origin_oeth.ts', + 'phase2-assets/collaterals/deploy_weeth.ts' ) } else if (chainId == '8453' || chainId == '84531') { // Base L2 chains diff --git a/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts b/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts new file mode 100644 index 000000000..4c5f47ff8 --- /dev/null +++ b/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts @@ -0,0 +1,95 @@ +import fs from 'fs' +import hre from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { networkConfig } 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 { priceTimeout, combinedError } from '../../utils' +import { WeEthCollateral } from '../../../../typechain' +import { + ETH_ORACLE_ERROR, + ETH_ORACLE_TIMEOUT, + WEETH_ORACLE_ERROR, + WEETH_ORACLE_TIMEOUT, + DELAY_UNTIL_DEFAULT, +} from '../../../../test/plugins/individual-collateral/etherfi/constants' +import { ContractFactory } from 'ethers' + +async function main() { + // ==== Read Configuration ==== + const [deployer] = await hre.ethers.getSigners() + + const chainId = await getChainId(hre) + + console.log(`Deploying Collateral 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[] = [] + + /******** Deploy EtherFI weETH Collateral - weETH **************************/ + + const WeEthCollateralFactoryCollateralFactory: ContractFactory = + await hre.ethers.getContractFactory('WeEthCollateral') + + const oracleError = combinedError(ETH_ORACLE_ERROR, WEETH_ORACLE_ERROR) // 0.5% & 0.5% + + const collateral = await WeEthCollateralFactoryCollateralFactory.connect( + deployer + ).deploy( + { + priceTimeout: priceTimeout.toString(), + chainlinkFeed: networkConfig[chainId].chainlinkFeeds.ETH, + oracleError: oracleError.toString(), + erc20: networkConfig[chainId].tokens.weETH, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: ETH_ORACLE_TIMEOUT.toString(), // 1 hr, + targetName: hre.ethers.utils.formatBytes32String('ETH'), + defaultThreshold: fp('0.02').add(WEETH_ORACLE_ERROR).toString(), // 2.5% + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), // 72h + }, + fp('1e-4').toString(), // revenueHiding = 0.01% + networkConfig[chainId].chainlinkFeeds.weETH, // targetPerTokChainlinkFeed + WEETH_ORACLE_TIMEOUT.toString() // targetPerTokChainlinkTimeout - 24h + ) + await collateral.deployed() + await (await collateral.refresh()).wait() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + console.log(`Deployed WeETH to ${hre.network.name} (${chainId}): ${collateral.address}`) + + assetCollDeployments.collateral.weETH = collateral.address + assetCollDeployments.erc20s.weETH = networkConfig[chainId].tokens.weETH + deployedCollateral.push(collateral.address.toString()) + + fs.writeFileSync(assetCollDeploymentFilename, JSON.stringify(assetCollDeployments, null, 2)) + + console.log(`Deployed collateral 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_weeth.ts b/scripts/verification/collateral-plugins/verify_weeth.ts new file mode 100644 index 000000000..ccf2d00b3 --- /dev/null +++ b/scripts/verification/collateral-plugins/verify_weeth.ts @@ -0,0 +1,63 @@ +import hre from 'hardhat' +import { getChainId } from '../../../common/blockchain-utils' +import { developmentChains, networkConfig } from '../../../common/configuration' +import { fp } from '../../../common/numbers' +import { + getDeploymentFile, + getAssetCollDeploymentFilename, + IAssetCollDeployments, +} from '../../deployment/common' +import { + ETH_ORACLE_TIMEOUT, + ETH_ORACLE_ERROR, + DELAY_UNTIL_DEFAULT, + WEETH_ORACLE_ERROR, + WEETH_ORACLE_TIMEOUT, +} from '../../../test/plugins/individual-collateral/etherfi/constants' +import { priceTimeout, verifyContract, combinedError } from '../../deployment/utils' + +let deployments: IAssetCollDeployments + +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) + + /******** Verify WeETH - weETH **************************/ + const oracleError = combinedError(ETH_ORACLE_ERROR, WEETH_ORACLE_ERROR) // 0.5% & 0.5% + await verifyContract( + chainId, + deployments.collateral.weETH, + [ + { + priceTimeout: priceTimeout.toString(), + chainlinkFeed: networkConfig[chainId].chainlinkFeeds.ETH, + oracleError: oracleError.toString(), // 0.5% & 0.5%, + erc20: networkConfig[chainId].tokens.weETH, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: ETH_ORACLE_TIMEOUT.toString(), // 1 hr, + targetName: hre.ethers.utils.formatBytes32String('ETH'), + defaultThreshold: fp('0.02').add(WEETH_ORACLE_ERROR).toString(), // 2.5% + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), // 72h + }, + fp('1e-4'), // revenueHiding = 0.01% + networkConfig[chainId].chainlinkFeeds.weETH, // targetPerTokChainlinkFeed + WEETH_ORACLE_TIMEOUT.toString(), // targetPerTokChainlinkTimeout + ], + 'contracts/plugins/assets/etherfi/WeEthCollateral.sol:WeEthCollateral' + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verify_etherscan.ts b/scripts/verify_etherscan.ts index c0e966e95..291274c3c 100644 --- a/scripts/verify_etherscan.ts +++ b/scripts/verify_etherscan.ts @@ -85,7 +85,8 @@ async function main() { 'collateral-plugins/verify_USDe.ts', 'collateral-plugins/verify_pyusd.ts', 'collateral-plugins/verify_susds.ts', - 'collateral-plugins/verify_oeth.ts' + 'collateral-plugins/verify_oeth.ts', + 'collateral-plugins/verify_weeth.ts' ) } else if (chainId == '8453' || chainId == '84531') { // Base L2 chains From 84d3e1f4a6c4a64547d5711a589a9c3458cc5d25 Mon Sep 17 00:00:00 2001 From: Julian R Date: Thu, 9 Oct 2025 10:13:44 -0300 Subject: [PATCH 3/9] fix typo --- scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts b/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts index 4c5f47ff8..a5414007b 100644 --- a/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts +++ b/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts @@ -49,12 +49,12 @@ async function main() { /******** Deploy EtherFI weETH Collateral - weETH **************************/ - const WeEthCollateralFactoryCollateralFactory: ContractFactory = + const WeEthCollateralFactory: ContractFactory = await hre.ethers.getContractFactory('WeEthCollateral') const oracleError = combinedError(ETH_ORACLE_ERROR, WEETH_ORACLE_ERROR) // 0.5% & 0.5% - const collateral = await WeEthCollateralFactoryCollateralFactory.connect( + const collateral = await WeEthCollateralFactory.connect( deployer ).deploy( { From 2f1f9f4d7ecfb64ea13c22cd795047468af98882 Mon Sep 17 00:00:00 2001 From: Julian R Date: Tue, 25 Nov 2025 10:53:27 -0300 Subject: [PATCH 4/9] add king asset --- common/configuration.ts | 2 + .../plugins/assets/etherfi/KingAsset.sol | 75 +++ contracts/plugins/assets/etherfi/README.md | 6 + .../plugins/assets/etherfi/vendor/IKing.sol | 12 + .../plugins/mocks/UnpricedKingAssetMock.sol | 63 +++ .../etherfi/KingAsset.test.ts | 509 ++++++++++++++++++ .../etherfi/WeEthCollateral.test.ts | 3 +- .../etherfi/constants.ts | 4 +- 8 files changed, 672 insertions(+), 2 deletions(-) create mode 100644 contracts/plugins/assets/etherfi/KingAsset.sol create mode 100644 contracts/plugins/assets/etherfi/vendor/IKing.sol create mode 100644 contracts/plugins/mocks/UnpricedKingAssetMock.sol create mode 100644 test/plugins/individual-collateral/etherfi/KingAsset.test.ts diff --git a/common/configuration.ts b/common/configuration.ts index 83f643643..bcffe55ea 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -137,6 +137,7 @@ export interface ITokens { // Ether.fi weETH?: string eETH?: string + KING?: string // RTokens eUSD?: string @@ -301,6 +302,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { wOETH: '0xDcEe70654261AF21C44c093C300eD3Bb97b78192', weETH: '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee', eETH: '0x35fA164735182de50811E8e2E824cFb9B6118ac2', + KING: '0x8F08B70456eb22f6109F57b8fafE862ED28E6040', }, chainlinkFeeds: { RSR: '0x759bBC1be8F90eE6457C44abc7d443842a976d02', diff --git a/contracts/plugins/assets/etherfi/KingAsset.sol b/contracts/plugins/assets/etherfi/KingAsset.sol new file mode 100644 index 000000000..930340c34 --- /dev/null +++ b/contracts/plugins/assets/etherfi/KingAsset.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "../../../libraries/Fixed.sol"; +import "../Asset.sol"; +import "../OracleLib.sol"; +import "./vendor/IKing.sol"; + +/** + * @title KingAsset + * @notice Asset plugin for King token using ETH as intermediate pricing unit + * tok = KING + * UoA = USD + * Pricing: KING/USD = (ETH/KING from fairValueOf) * (USD/ETH from oracle) + */ +contract KingAsset is IAsset, Asset { + using FixLib for uint192; + using OracleLib for AggregatorV3Interface; + + /// @param priceTimeout_ {s} The number of seconds over which savedHighPrice decays to 0 + /// @param ethUsdChainlinkFeed_ {UoA/ref} ETH/USD price feed + /// @param oracleError_ {1} The % the oracle feed can be off by + /// @param erc20_ The King ERC20 token + /// @param maxTradeVolume_ {UoA} The max trade volume, in UoA + /// @param oracleTimeout_ {s} The number of seconds until the oracle becomes invalid + constructor( + uint48 priceTimeout_, + AggregatorV3Interface ethUsdChainlinkFeed_, + uint192 oracleError_, + IERC20Metadata erc20_, + uint192 maxTradeVolume_, + uint48 oracleTimeout_ + ) + Asset( + priceTimeout_, + ethUsdChainlinkFeed_, + oracleError_, + erc20_, + maxTradeVolume_, + oracleTimeout_ + ) + { + // Validation is handled by parent Asset contract + } + + /// 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 + /// @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 + ) + { + // Note: "ref" in this context refers to ETH, used as intermediate pricing unit + uint192 ethUsdPrice = chainlinkFeed.price(oracleTimeout); // {UoA/ref} + uint192 ethPerKing = _safeWrap(IKing(address(erc20)).fairValueOf(10**erc20Decimals)); // {ref/tok} + + // {UoA/tok} = {UoA/ref} * {ref/tok} + uint192 p = ethUsdPrice.mul(ethPerKing); + uint192 err = p.mul(oracleError, CEIL); + // assert(low <= high); obviously true just by inspection + return (p - err, p + err, 0); + } +} diff --git a/contracts/plugins/assets/etherfi/README.md b/contracts/plugins/assets/etherfi/README.md index 44595ebbd..712c1b425 100644 --- a/contracts/plugins/assets/etherfi/README.md +++ b/contracts/plugins/assets/etherfi/README.md @@ -14,6 +14,12 @@ Upon depositing ETH into the Ether.fi protocol, users receive `eETH` - a rebasin `weETH` contract: +### Rewards + +Rewards come in the form of KING tokens, which will be distributed via an off-chain procedure and sent to the BackingManager. + +KING token: `https://etherscan.io/address/0x8F08B70456eb22f6109F57b8fafE862ED28E6040` + ## Implementation ### Units diff --git a/contracts/plugins/assets/etherfi/vendor/IKing.sol b/contracts/plugins/assets/etherfi/vendor/IKing.sol new file mode 100644 index 000000000..5b4f9f05d --- /dev/null +++ b/contracts/plugins/assets/etherfi/vendor/IKing.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// External interface for King token +interface IKing is IERC20Metadata { + /// @notice Returns the fair value in ETH for a given amount of KING tokens + /// @param amount The amount of KING tokens + /// @return The ETH value of the given KING amount + function fairValueOf(uint256 amount) external view returns (uint256); +} diff --git a/contracts/plugins/mocks/UnpricedKingAssetMock.sol b/contracts/plugins/mocks/UnpricedKingAssetMock.sol new file mode 100644 index 000000000..e05c95fce --- /dev/null +++ b/contracts/plugins/mocks/UnpricedKingAssetMock.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "../assets/etherfi/KingAsset.sol"; +import "../assets/OracleLib.sol"; + +// Unpriced KingAsset mock for testing +contract UnpricedKingAssetMock is KingAsset { + using FixLib for uint192; + using OracleLib for AggregatorV3Interface; + + bool public unpriced = false; + + /// @param priceTimeout_ {s} The number of seconds over which savedHighPrice decays to 0 + /// @param chainlinkFeed_ Feed units: {UoA/tok} + /// @param oracleError_ {1} The % the oracle feed can be off by + /// @param maxTradeVolume_ {UoA} The max trade volume, in UoA + /// @param oracleTimeout_ {s} The number of seconds until a oracle value becomes invalid + constructor( + uint48 priceTimeout_, + AggregatorV3Interface chainlinkFeed_, + uint192 oracleError_, + IERC20Metadata erc20_, + uint192 maxTradeVolume_, + uint48 oracleTimeout_ + ) + KingAsset( + priceTimeout_, + chainlinkFeed_, + oracleError_, + erc20_, + maxTradeVolume_, + oracleTimeout_ + ) + {} + + /// tryPrice: mock unpriced by returning (0, FIX_MAX) + function tryPrice() + external + view + override + returns ( + uint192 low, + uint192 high, + uint192 + ) + { + // If unpriced is marked, return 0, FIX_MAX + if (unpriced) return (0, FIX_MAX, 0); + + uint192 ethUsdPrice = chainlinkFeed.price(oracleTimeout); // {UoA/ref} + uint192 ethPerKing = _safeWrap(IKing(address(erc20)).fairValueOf(10**erc20Decimals)); // {ref/tok} + uint192 p = ethUsdPrice.mul(ethPerKing); // {UoA/tok} + uint192 delta = p.mul(oracleError, CEIL); + return (p - delta, p + delta, 0); + } + + function setUnpriced(bool on) external { + unpriced = on; + } +} diff --git a/test/plugins/individual-collateral/etherfi/KingAsset.test.ts b/test/plugins/individual-collateral/etherfi/KingAsset.test.ts new file mode 100644 index 000000000..6609f536e --- /dev/null +++ b/test/plugins/individual-collateral/etherfi/KingAsset.test.ts @@ -0,0 +1,509 @@ +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, ONE_ADDRESS, MAX_UINT192 } from '../../../../common/constants' +import { bn, fp } from '../../../../common/numbers' +import { + expectDecayedPrice, + expectExactPrice, + expectPrice, + expectUnpriced, + setInvalidOracleAnsweredRound, + setInvalidOracleTimestamp, + setOraclePrice, +} from '../../../utils/oracles' +import { + Asset, + InvalidMockV3Aggregator, + KingAsset, + ERC20Mock, + UnpricedKingAssetMock, + MockV3Aggregator, +} from '../../../../typechain' +import { VERSION } from '../../../fixtures' +import { useEnv } from '#/utils/env' +import { + KING, + ETH_USD_PRICE_FEED, + ETH_ORACLE_ERROR, + ETH_ORACLE_TIMEOUT, + PRICE_TIMEOUT, + FORK_BLOCK, +} from './constants' + +let chainId: string + +// Setup test environment +const setup = async (blockNumber: number) => { + // Use Mainnet fork + await hre.network.provider.request({ + method: 'hardhat_reset', + params: [ + { + forking: { + jsonRpcUrl: useEnv('MAINNET_RPC_URL'), + blockNumber: blockNumber, + }, + }, + ], + }) +} + +const describeFork = useEnv('FORK') ? describe : describe.skip + +const MAX_TRADE_VOLUME = fp('1e6') +const DECAY_DELAY = ETH_ORACLE_TIMEOUT.add(310) + +describeFork('King Asset #fast', () => { + // Tokens + let king: ERC20Mock + + // Assets + let kingAsset: KingAsset + + // Main + let wallet: Wallet + + // Factory + let KingAssetFactory: ContractFactory + + // Oracle + let ethUsdOracle: MockV3Aggregator + + // ETH/USD price + let ethPrice: BigNumber + + before(async () => { + await setup(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(FORK_BLOCK) + + // Set King token + king = await ethers.getContractAt('ERC20Mock', KING) + + // Get ETH/USD price from oracle + const ethOracle = await ethers.getContractAt('AggregatorV3Interface', ETH_USD_PRICE_FEED) + ethPrice = (await ethOracle.latestRoundData()).answer + + // Deploy MockV3Aggregator + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + ethUsdOracle = await MockV3AggregatorFactory.deploy(8, ethPrice) + await ethUsdOracle.deployed() + + // Update answer to set fresh timestamp + await ethUsdOracle.updateAnswer(ethPrice) + + // Deploy KingAsset + KingAssetFactory = await ethers.getContractFactory('KingAsset') + kingAsset = ( + await KingAssetFactory.deploy( + PRICE_TIMEOUT, + ethUsdOracle.address, + ETH_ORACLE_ERROR, + king.address, + MAX_TRADE_VOLUME, + ETH_ORACLE_TIMEOUT + ) + ) + await kingAsset.deployed() + await kingAsset.refresh() + }) + + describe('Deployment', () => { + it('Deployment should setup King asset correctly', async () => { + // KING Asset + expect(await kingAsset.isCollateral()).to.equal(false) + expect(await kingAsset.erc20()).to.equal(king.address) + expect(await king.decimals()).to.equal(18) + expect(await kingAsset.version()).to.equal(VERSION) + expect(await kingAsset.maxTradeVolume()).to.equal(MAX_TRADE_VOLUME) + // price is approx $506 usd at block 23841545 + await expectPrice(kingAsset.address, fp('506.18'), ETH_ORACLE_ERROR, true, bn('1e4')) + await expect(kingAsset.claimRewards()).to.not.emit(kingAsset, 'RewardsClaimed') + }) + }) + + describe('Prices', () => { + it('Should increase price when ETH/USD price increases', async () => { + // Get initial prices + const [initialLow, initialHigh] = await kingAsset.price() + + // Increase Eth/USD Oracle price + const newEthPrice = ethPrice.mul(110).div(100) + await setOraclePrice(kingAsset.address, newEthPrice) + + // Get new prices + const [newLow, newHigh] = await kingAsset.price() + + // Verify prices increased (both low and high) + expect(newLow).to.be.gt(initialLow) + expect(newHigh).to.be.gt(initialHigh) + }) + + it('Should become unpriced if price is zero', async () => { + const kingInitPrice = await kingAsset.price() + + // Update values in Oracles to 0 + await setOraclePrice(kingAsset.address, bn('0')) + + // Fallback prices should be initial prices + await expectExactPrice(kingAsset.address, kingInitPrice) + + // Advance past oracle timeout + await advanceTime(DECAY_DELAY.add(1).toString()) + await setOraclePrice(kingAsset.address, bn('0')) + await kingAsset.refresh() + + // Prices should be decaying + await expectDecayedPrice(kingAsset.address) + + // After price timeout, should be unpriced + await advanceTime(PRICE_TIMEOUT.toString()) + await setOraclePrice(kingAsset.address, bn('0')) + + // Should be unpriced now + await expectUnpriced(kingAsset.address) + }) + + it('Should calculate trade min correctly', async () => { + // Check initial values + expect(await kingAsset.maxTradeVolume()).to.equal(MAX_TRADE_VOLUME) + + // Reduce price - maintains max size + await setOraclePrice(kingAsset.address, ethPrice.div(2)) // half + expect(await kingAsset.maxTradeVolume()).to.equal(MAX_TRADE_VOLUME) + }) + + it('Should remain at saved price if oracle is stale', async () => { + // Save initial price + const initialPrice = await kingAsset.price() + + await advanceTime(DECAY_DELAY.sub(12).toString()) + + // lastSave should not be block timestamp after refresh + await kingAsset.refresh() + expect(await kingAsset.lastSave()).to.not.equal(await getLatestBlockTimestamp()) + + // Check price is still at saved price + await expectExactPrice(kingAsset.address, initialPrice) + }) + + it('Should remain at saved price in case of invalid timestamp', async () => { + // Save initial price + const initialPrice = await kingAsset.price() + + await setInvalidOracleTimestamp(kingAsset.address) + + // lastSave should not be block timestamp after refresh + await kingAsset.refresh() + expect(await kingAsset.lastSave()).to.not.equal(await getLatestBlockTimestamp()) + + // Check price is still at saved price + await expectExactPrice(kingAsset.address, initialPrice) + }) + + it('Should remain at saved price in case of invalid answered round', async () => { + // Save initial price + const initialPrice = await kingAsset.price() + + await setInvalidOracleAnsweredRound(kingAsset.address) + + // lastSave should not be block timestamp after refresh + await kingAsset.refresh() + expect(await kingAsset.lastSave()).to.not.equal(await getLatestBlockTimestamp()) + + // Check price is still at saved price + await expectExactPrice(kingAsset.address, initialPrice) + }) + + it('Should be able to refresh saved prices', async () => { + // Check initial prices + let currBlockTimestamp: number = await getLatestBlockTimestamp() + let [lowPrice, highPrice] = await kingAsset.price() + expect(await kingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(highPrice) + expect(await kingAsset.lastSave()).to.equal(currBlockTimestamp) + + // Refresh saved prices again + await kingAsset.refresh() + + // Check values remain but timestamp was updated + const [lowPrice2, highPrice2] = await kingAsset.price() + expect(lowPrice2).to.equal(lowPrice) + expect(highPrice2).to.equal(highPrice) + expect(await kingAsset.savedLowPrice()).to.equal(lowPrice2) + expect(await kingAsset.savedHighPrice()).to.equal(highPrice2) + currBlockTimestamp = await getLatestBlockTimestamp() + expect(await kingAsset.lastSave()).to.equal(currBlockTimestamp) + + // Increase Eth/USD Oracle price + const newEthPrice = ethPrice.mul(120).div(100) + await setOraclePrice(kingAsset.address, newEthPrice) + + // Before calling refresh we still have the old saved values + ;[lowPrice, highPrice] = await kingAsset.price() + expect(await kingAsset.savedLowPrice()).to.be.lt(lowPrice) + expect(await kingAsset.savedHighPrice()).to.be.lt(highPrice) + + // Refresh prices - Should save new values + await kingAsset.refresh() + + // Check new prices were stored + ;[lowPrice, highPrice] = await kingAsset.price() + expect(await kingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(highPrice) + currBlockTimestamp = await getLatestBlockTimestamp() + expect(await kingAsset.lastSave()).to.equal(currBlockTimestamp) + + expect(lowPrice).to.be.gt(lowPrice2) + expect(highPrice).to.be.gt(highPrice2) + }) + + it('Should not save prices if try/price returns unpriced', async () => { + const UnpricedKingAssetFactory = await ethers.getContractFactory('UnpricedKingAssetMock') + const unpricedKingAsset: UnpricedKingAssetMock = ( + await UnpricedKingAssetFactory.deploy( + PRICE_TIMEOUT, + await kingAsset.chainlinkFeed(), + ETH_ORACLE_ERROR, + king.address, + MAX_TRADE_VOLUME, + ETH_ORACLE_TIMEOUT + ) + ) + + // Save prices + await unpricedKingAsset.refresh() + + // Check initial prices + let currBlockTimestamp: number = await getLatestBlockTimestamp() + let [lowPrice, highPrice] = await unpricedKingAsset.price() + expect(await unpricedKingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await unpricedKingAsset.savedHighPrice()).to.equal(highPrice) + expect(await unpricedKingAsset.lastSave()).to.be.equal(currBlockTimestamp) + + // Refresh saved prices + await unpricedKingAsset.refresh() + + // Check values remain but timestamp was updated + const [lowPrice2, highPrice2] = await unpricedKingAsset.price() + expect(lowPrice2).to.equal(lowPrice) + expect(highPrice2).to.equal(highPrice) + ;[lowPrice, highPrice] = await unpricedKingAsset.price() + expect(await unpricedKingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await unpricedKingAsset.savedHighPrice()).to.equal(highPrice) + currBlockTimestamp = await getLatestBlockTimestamp() + expect(await unpricedKingAsset.lastSave()).to.equal(currBlockTimestamp) + + // Set as unpriced so it returns 0,FIX MAX in try/price + await unpricedKingAsset.setUnpriced(true) + + // Check that now is unpriced + await expectUnpriced(unpricedKingAsset.address) + + // Refreshing would not save the new rates + await unpricedKingAsset.refresh() + expect(await unpricedKingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await unpricedKingAsset.savedHighPrice()).to.equal(highPrice) + expect(await unpricedKingAsset.lastSave()).to.equal(currBlockTimestamp) + }) + + it('Should not revert on refresh if stale', async () => { + // Check initial prices + const startBlockTimestamp: number = await getLatestBlockTimestamp() + const [prevLowPrice, prevHighPrice] = await kingAsset.price() + await expectPrice(kingAsset.address, fp('506.18'), ETH_ORACLE_ERROR, true, bn('1e4')) + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + + // Set invalid oracle + await setInvalidOracleTimestamp(kingAsset.address) + + // Check price - uses still previous prices + await kingAsset.refresh() + let [lowPrice, highPrice] = await kingAsset.price() + expect(lowPrice).to.equal(prevLowPrice) + expect(highPrice).to.equal(prevHighPrice) + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + + // Check price - no update on prices/timestamp + await kingAsset.refresh() + ;[lowPrice, highPrice] = await kingAsset.price() + expect(lowPrice).to.equal(prevLowPrice) + expect(highPrice).to.equal(prevHighPrice) + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + }) + + it('Reverts if Chainlink feed reverts or runs out of gas', async () => { + const InvalidMockV3AggregatorFactory = await ethers.getContractFactory( + 'InvalidMockV3Aggregator' + ) + const invalidChainlinkFeed: InvalidMockV3Aggregator = ( + await InvalidMockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + + const invalidKingAsset: Asset = ( + await KingAssetFactory.deploy( + PRICE_TIMEOUT, + invalidChainlinkFeed.address, + ETH_ORACLE_ERROR, + king.address, + MAX_TRADE_VOLUME, + ETH_ORACLE_TIMEOUT + ) + ) + + // Reverting with no reason + await invalidChainlinkFeed.setSimplyRevert(true) + await expect(invalidKingAsset.price()).to.be.reverted + await expect(invalidKingAsset.refresh()).to.be.reverted + + // Runnning out of gas (same error) + await invalidChainlinkFeed.setSimplyRevert(false) + await expect(invalidKingAsset.price()).to.be.reverted + await expect(invalidKingAsset.refresh()).to.be.reverted + }) + + it('Bubbles error up if Chainlink feed reverts for explicit reason', async () => { + // Applies to all collateral as well + const InvalidMockV3AggregatorFactory = await ethers.getContractFactory( + 'InvalidMockV3Aggregator' + ) + const invalidChainlinkFeed: InvalidMockV3Aggregator = ( + await InvalidMockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + + const invalidKingAsset: Asset = ( + await KingAssetFactory.deploy( + PRICE_TIMEOUT, + invalidChainlinkFeed.address, + ETH_ORACLE_ERROR, + king.address, + MAX_TRADE_VOLUME, + ETH_ORACLE_TIMEOUT + ) + ) + + // Reverting with reason + await invalidChainlinkFeed.setRevertWithExplicitError(true) + await expect(invalidKingAsset.tryPrice()).to.be.revertedWith('oracle explicit error') + }) + + it('Should handle price decay correctly', async () => { + await kingAsset.refresh() + + // Check prices + const startBlockTimestamp: number = await getLatestBlockTimestamp() + const [prevLowPrice, prevHighPrice] = await kingAsset.price() + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + + // Set invalid oracle + await setInvalidOracleTimestamp(kingAsset.address) + + // Check unpriced - uses still previous prices + const [lowPrice, highPrice] = await kingAsset.price() + expect(lowPrice).to.equal(prevLowPrice) + expect(highPrice).to.equal(prevHighPrice) + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + + // At first price doesn't decrease + const [lowPrice2, highPrice2] = await kingAsset.price() + expect(lowPrice2).to.eq(lowPrice) + expect(highPrice2).to.eq(highPrice) + + // Advance past oracleTimeout + await advanceTime(DECAY_DELAY.toString()) + + // Now price widens + const [lowPrice3, highPrice3] = await kingAsset.price() + expect(lowPrice3).to.be.lt(lowPrice2) + expect(highPrice3).to.be.gt(highPrice2) + + // Advance block, price keeps widening + await advanceToTimestamp((await getLatestBlockTimestamp()) + 12) + const [lowPrice4, highPrice4] = await kingAsset.price() + expect(lowPrice4).to.be.lt(lowPrice3) + expect(highPrice4).to.be.gt(highPrice3) + + // Advance blocks beyond PRICE_TIMEOUT; price should be [O, FIX_MAX] + await advanceTime(PRICE_TIMEOUT.toNumber()) + + // Lot price returns 0 once time elapses + const [lowPrice5, highPrice5] = await kingAsset.price() + expect(lowPrice5).to.be.lt(lowPrice4) + expect(highPrice5).to.be.gt(highPrice4) + expect(lowPrice5).to.be.equal(bn(0)) + expect(highPrice5).to.be.equal(MAX_UINT192) + }) + + it('lotPrice (deprecated) is equal to price()', async () => { + for (const asset of [kingAsset]) { + const lotPrice = await asset.lotPrice() + const price = await asset.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]) + } + }) + }) + + describe('Constructor validation', () => { + it('Should not allow price timeout to be zero', async () => { + await expect( + KingAssetFactory.deploy(0, ONE_ADDRESS, 0, ONE_ADDRESS, MAX_TRADE_VOLUME, 0) + ).to.be.revertedWith('price timeout zero') + }) + it('Should not allow missing chainlink feed', async () => { + await expect( + KingAssetFactory.deploy(1, ZERO_ADDRESS, 0, ONE_ADDRESS, MAX_TRADE_VOLUME, 1) + ).to.be.revertedWith('missing chainlink feed') + }) + it('Should not allow missing erc20', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, 1, ZERO_ADDRESS, MAX_TRADE_VOLUME, 1) + ).to.be.revertedWith('missing erc20') + }) + it('Should not allow 0 oracleError', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, 0, ONE_ADDRESS, MAX_TRADE_VOLUME, 1) + ).to.be.revertedWith('oracle error out of range') + }) + it('Should not allow FIX_ONE oracleError', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, fp('1'), ONE_ADDRESS, MAX_TRADE_VOLUME, 1) + ).to.be.revertedWith('oracle error out of range') + }) + it('Should not allow 0 oracleTimeout', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, 1, ONE_ADDRESS, MAX_TRADE_VOLUME, 0) + ).to.be.revertedWith('oracleTimeout zero') + }) + it('Should not allow maxTradeVolume to be zero', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, 1, ONE_ADDRESS, 0, 1) + ).to.be.revertedWith('invalid max trade volume') + }) + }) +}) diff --git a/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts b/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts index 3002d90d2..1f2261059 100644 --- a/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts +++ b/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts @@ -19,6 +19,7 @@ import { bn, fp } from '../../../../common/numbers' import { CollateralStatus, ZERO_ADDRESS, MAX_UINT48 } from '../../../../common/constants' import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' import { + KING, ETH_ORACLE_ERROR, ETH_ORACLE_TIMEOUT, PRICE_TIMEOUT, @@ -139,7 +140,7 @@ const makeCollateralFixtureContext = ( const weth = (await ethers.getContractAt('WETH9', WETH)) as WETH9 const eEth = (await ethers.getContractAt('ERC20Mock', EETH)) as ERC20Mock const weEth = (await ethers.getContractAt('IWeETH', WEETH)) as IWeETH - const rewardToken = (await ethers.getContractAt('ERC20Mock', ZERO_ADDRESS)) as ERC20Mock + const rewardToken = (await ethers.getContractAt('ERC20Mock', KING)) as ERC20Mock const collateral = await deployCollateral(collateralOpts) return { diff --git a/test/plugins/individual-collateral/etherfi/constants.ts b/test/plugins/individual-collateral/etherfi/constants.ts index c6cca4717..087889139 100644 --- a/test/plugins/individual-collateral/etherfi/constants.ts +++ b/test/plugins/individual-collateral/etherfi/constants.ts @@ -7,6 +7,8 @@ export const ETH_USD_PRICE_FEED = networkConfig['31337'].chainlinkFeeds.ETH as s export const WEETH = networkConfig['31337'].tokens.weETH as string export const EETH = networkConfig['31337'].tokens.eETH as string +export const KING = networkConfig['31337'].tokens.KING as string + export const WEETH_ETH_PRICE_FEED = networkConfig['31337'].chainlinkFeeds.weETH as string export const WETH = networkConfig['31337'].tokens.WETH as string export const WEETH_WHALE = '0xBdfa7b7893081B35Fb54027489e2Bc7A38275129' @@ -23,4 +25,4 @@ export const DEFAULT_THRESHOLD = fp('0.05') // 5% export const DELAY_UNTIL_DEFAULT = bn(259200) // 72h export const MAX_TRADE_VOL = bn(1000) -export const FORK_BLOCK = 19868380 +export const FORK_BLOCK = 23841545 From 54c48db1a47d0421788788d6703dee191ca78f66 Mon Sep 17 00:00:00 2001 From: Julian R Date: Tue, 25 Nov 2025 11:03:42 -0300 Subject: [PATCH 5/9] bump solidity --- contracts/plugins/assets/etherfi/KingAsset.sol | 2 +- contracts/plugins/assets/etherfi/WeEthCollateral.sol | 2 +- contracts/plugins/assets/etherfi/vendor/IKing.sol | 3 ++- contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol | 2 +- contracts/plugins/assets/etherfi/vendor/IWeETH.sol | 2 +- contracts/plugins/mocks/UnpricedKingAssetMock.sol | 2 +- contracts/plugins/mocks/WeETHMock.sol | 2 +- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/contracts/plugins/assets/etherfi/KingAsset.sol b/contracts/plugins/assets/etherfi/KingAsset.sol index 930340c34..c650eaf30 100644 --- a/contracts/plugins/assets/etherfi/KingAsset.sol +++ b/contracts/plugins/assets/etherfi/KingAsset.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; +pragma solidity 0.8.28; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; diff --git a/contracts/plugins/assets/etherfi/WeEthCollateral.sol b/contracts/plugins/assets/etherfi/WeEthCollateral.sol index 37a907ea8..c8f114368 100644 --- a/contracts/plugins/assets/etherfi/WeEthCollateral.sol +++ b/contracts/plugins/assets/etherfi/WeEthCollateral.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; +pragma solidity 0.8.28; import "@openzeppelin/contracts/utils/math/Math.sol"; import "../../../libraries/Fixed.sol"; diff --git a/contracts/plugins/assets/etherfi/vendor/IKing.sol b/contracts/plugins/assets/etherfi/vendor/IKing.sol index 5b4f9f05d..36cff0363 100644 --- a/contracts/plugins/assets/etherfi/vendor/IKing.sol +++ b/contracts/plugins/assets/etherfi/vendor/IKing.sol @@ -1,5 +1,6 @@ + // SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; +pragma solidity 0.8.28; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; diff --git a/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol b/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol index 525879b49..ae01c728d 100644 --- a/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol +++ b/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; +pragma solidity 0.8.28; // External interface for Ether.fi's LiquidityPool contract interface ILiquidityPool { diff --git a/contracts/plugins/assets/etherfi/vendor/IWeETH.sol b/contracts/plugins/assets/etherfi/vendor/IWeETH.sol index 71f22b720..5e334ddee 100644 --- a/contracts/plugins/assets/etherfi/vendor/IWeETH.sol +++ b/contracts/plugins/assets/etherfi/vendor/IWeETH.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; +pragma solidity 0.8.28; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; diff --git a/contracts/plugins/mocks/UnpricedKingAssetMock.sol b/contracts/plugins/mocks/UnpricedKingAssetMock.sol index e05c95fce..df8ecbed5 100644 --- a/contracts/plugins/mocks/UnpricedKingAssetMock.sol +++ b/contracts/plugins/mocks/UnpricedKingAssetMock.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; +pragma solidity 0.8.28; import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; diff --git a/contracts/plugins/mocks/WeETHMock.sol b/contracts/plugins/mocks/WeETHMock.sol index 6e24708c9..a3b288813 100644 --- a/contracts/plugins/mocks/WeETHMock.sol +++ b/contracts/plugins/mocks/WeETHMock.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; +pragma solidity 0.8.28; import "./ERC20Mock.sol"; From 29d2de46167da275040d999ae1191affa235bab8 Mon Sep 17 00:00:00 2001 From: Julian R Date: Tue, 25 Nov 2025 11:08:13 -0300 Subject: [PATCH 6/9] fix lint --- contracts/plugins/assets/etherfi/KingAsset.sol | 7 +++++-- contracts/plugins/assets/etherfi/vendor/IKing.sol | 3 +-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/contracts/plugins/assets/etherfi/KingAsset.sol b/contracts/plugins/assets/etherfi/KingAsset.sol index c650eaf30..4954ceee8 100644 --- a/contracts/plugins/assets/etherfi/KingAsset.sol +++ b/contracts/plugins/assets/etherfi/KingAsset.sol @@ -63,8 +63,11 @@ contract KingAsset is IAsset, Asset { ) { // Note: "ref" in this context refers to ETH, used as intermediate pricing unit - uint192 ethUsdPrice = chainlinkFeed.price(oracleTimeout); // {UoA/ref} - uint192 ethPerKing = _safeWrap(IKing(address(erc20)).fairValueOf(10**erc20Decimals)); // {ref/tok} + // {UoA/ref} + uint192 ethUsdPrice = chainlinkFeed.price(oracleTimeout); + + // {ref/tok} + uint192 ethPerKing = _safeWrap(IKing(address(erc20)).fairValueOf(10**erc20Decimals)); // {UoA/tok} = {UoA/ref} * {ref/tok} uint192 p = ethUsdPrice.mul(ethPerKing); diff --git a/contracts/plugins/assets/etherfi/vendor/IKing.sol b/contracts/plugins/assets/etherfi/vendor/IKing.sol index 36cff0363..e5d9e4d8b 100644 --- a/contracts/plugins/assets/etherfi/vendor/IKing.sol +++ b/contracts/plugins/assets/etherfi/vendor/IKing.sol @@ -1,4 +1,3 @@ - // SPDX-License-Identifier: BlueOak-1.0.0 pragma solidity 0.8.28; @@ -6,7 +5,7 @@ import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; // External interface for King token interface IKing is IERC20Metadata { - /// @notice Returns the fair value in ETH for a given amount of KING tokens + /// @notice Returns the fair value in ETH for an amount of KING tokens /// @param amount The amount of KING tokens /// @return The ETH value of the given KING amount function fairValueOf(uint256 amount) external view returns (uint256); From 3768288ad3b5a743143570ce3bf0355602427ab1 Mon Sep 17 00:00:00 2001 From: Julian R Date: Thu, 27 Nov 2025 09:55:49 -0300 Subject: [PATCH 7/9] add deploy and verif scripts for king --- scripts/deploy.ts | 3 +- .../phase2-assets/assets/deploy_king.ts | 70 +++++++++++++++++++ scripts/verification/assets/verify_king.ts | 49 +++++++++++++ scripts/verify_etherscan.ts | 3 +- 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 scripts/deployment/phase2-assets/assets/deploy_king.ts create mode 100644 scripts/verification/assets/verify_king.ts diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 195dc74be..8a3870816 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -96,7 +96,8 @@ async function main() { 'phase2-assets/collaterals/deploy_sky_susds.ts', 'phase2-assets/collaterals/deploy_origin_oeth.ts', 'phase2-assets/collaterals/deploy_pyusd.ts', - 'phase2-assets/collaterals/deploy_weeth.ts' + 'phase2-assets/collaterals/deploy_weeth.ts', + 'phase2-assets/assets/deploy_king.ts' ) } else if (chainId == '8453' || chainId == '84531') { // Base L2 chains diff --git a/scripts/deployment/phase2-assets/assets/deploy_king.ts b/scripts/deployment/phase2-assets/assets/deploy_king.ts new file mode 100644 index 000000000..339e2d65c --- /dev/null +++ b/scripts/deployment/phase2-assets/assets/deploy_king.ts @@ -0,0 +1,70 @@ +import fs from 'fs' +import hre from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { networkConfig } from '../../../../common/configuration' +import { fp } from '../../../../common/numbers' +import { + getDeploymentFile, + getDeploymentFilename, + getAssetCollDeploymentFilename, + IAssetCollDeployments, + fileExists, +} from '../../../deployment/common' +import { KingAsset } from '../../../../typechain' +import { priceTimeout } from '../../../deployment/utils' +import { ETH_ORACLE_TIMEOUT } from '../../../../test/plugins/individual-collateral/etherfi/constants' + +async function main() { + // ==== Read Configuration ==== + const [deployer] = await hre.ethers.getSigners() + const chainId = await getChainId(hre) + + console.log(`Deploying KING asset 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 deployedAssets: string[] = [] + + /******** Deploy KING asset **************************/ + + const KingAssetFactory = await hre.ethers.getContractFactory('KingAsset') + const kingAsset = await KingAssetFactory.connect(deployer).deploy( + priceTimeout, + networkConfig[chainId].chainlinkFeeds.ETH!, + fp('0.04').toString(), // 4% Oracle error - TODO: review + networkConfig[chainId].tokens.KING!, + fp('1e6').toString(), // $1m + ETH_ORACLE_TIMEOUT + ) + await kingAsset.deployed() + await (await kingAsset.refresh({ gasLimit: 3_000_000 })).wait() + + assetCollDeployments.assets.KING = kingAsset.address + assetCollDeployments.erc20s.KING = networkConfig[chainId].tokens.KING + deployedAssets.push(kingAsset.address) + + /**************************************************************/ + + fs.writeFileSync(assetCollDeploymentFilename, JSON.stringify(assetCollDeployments, null, 2)) + + console.log(`Deployed KING asset to ${hre.network.name} (${chainId}): + New deployments: ${deployedAssets} + Deployment file: ${assetCollDeploymentFilename}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verification/assets/verify_king.ts b/scripts/verification/assets/verify_king.ts new file mode 100644 index 000000000..0779ade48 --- /dev/null +++ b/scripts/verification/assets/verify_king.ts @@ -0,0 +1,49 @@ +import hre from 'hardhat' + +import { getChainId } from '../../../common/blockchain-utils' +import { developmentChains, networkConfig } from '../../../common/configuration' +import { + getAssetCollDeploymentFilename, + getDeploymentFile, + IAssetCollDeployments, +} from '../../deployment/common' +import { verifyContract } from '../../deployment/utils' +import { fp } from '../../../common/numbers' + +let deployments: IAssetCollDeployments + +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}`) + } + + deployments = getDeploymentFile(getAssetCollDeploymentFilename(chainId)) + + const kingAsset = await hre.ethers.getContractAt('KingAsset', deployments.assets.KING!) + + /** ******************** Verify KING Asset ****************************************/ + await verifyContract( + chainId, + deployments.assets.KING, + [ + (await kingAsset.priceTimeout()).toString(), + await kingAsset.chainlinkFeed(), + fp('0.04').toString(), // 4% oracle error + await kingAsset.erc20(), + (await kingAsset.maxTradeVolume()).toString(), + (await kingAsset.oracleTimeout()).toString(), + ], + 'contracts/plugins/assets/etherfi/KingAsset.sol:KingAsset' + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verify_etherscan.ts b/scripts/verify_etherscan.ts index d83990b96..be7056685 100644 --- a/scripts/verify_etherscan.ts +++ b/scripts/verify_etherscan.ts @@ -79,7 +79,8 @@ async function main() { 'collateral-plugins/verify_USDe.ts', 'collateral-plugins/verify_susds.ts', 'collateral-plugins/verify_oeth.ts', - 'collateral-plugins/verify_weeth.ts' + 'collateral-plugins/verify_weeth.ts', + 'assets/verify_king.ts' ) } else if (chainId == '8453' || chainId == '84531') { // Base L2 chains From 52742fa432e80caa6599596428881122b2b5d40b Mon Sep 17 00:00:00 2001 From: Julian R Date: Tue, 2 Dec 2025 09:27:18 -0300 Subject: [PATCH 8/9] introduce fixes --- contracts/plugins/assets/etherfi/KingAsset.sol | 3 ++- contracts/plugins/assets/etherfi/vendor/IKing.sol | 12 ++++++++---- contracts/plugins/mocks/UnpricedKingAssetMock.sol | 3 ++- .../deployment/phase2-assets/assets/deploy_king.ts | 4 ++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/contracts/plugins/assets/etherfi/KingAsset.sol b/contracts/plugins/assets/etherfi/KingAsset.sol index 4954ceee8..29b7c9dea 100644 --- a/contracts/plugins/assets/etherfi/KingAsset.sol +++ b/contracts/plugins/assets/etherfi/KingAsset.sol @@ -67,7 +67,8 @@ contract KingAsset is IAsset, Asset { uint192 ethUsdPrice = chainlinkFeed.price(oracleTimeout); // {ref/tok} - uint192 ethPerKing = _safeWrap(IKing(address(erc20)).fairValueOf(10**erc20Decimals)); + (uint256 ethValue, ) = IKing(address(erc20)).fairValueOf(10**erc20Decimals); + uint192 ethPerKing = _safeWrap(ethValue); // {UoA/tok} = {UoA/ref} * {ref/tok} uint192 p = ethUsdPrice.mul(ethPerKing); diff --git a/contracts/plugins/assets/etherfi/vendor/IKing.sol b/contracts/plugins/assets/etherfi/vendor/IKing.sol index e5d9e4d8b..29c754460 100644 --- a/contracts/plugins/assets/etherfi/vendor/IKing.sol +++ b/contracts/plugins/assets/etherfi/vendor/IKing.sol @@ -5,8 +5,12 @@ import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; // External interface for King token interface IKing is IERC20Metadata { - /// @notice Returns the fair value in ETH for an amount of KING tokens - /// @param amount The amount of KING tokens - /// @return The ETH value of the given KING amount - function fairValueOf(uint256 amount) external view returns (uint256); + /// @notice Returns the fair value in ETH and USD for an amount of KING tokens + /// @param vaultTokenShares The amount of KING tokens + /// @return ethValue The ETH value of the given KING amount + /// @return usdValue The USD value of the given KING amount + function fairValueOf(uint256 vaultTokenShares) + external + view + returns (uint256 ethValue, uint256 usdValue); } diff --git a/contracts/plugins/mocks/UnpricedKingAssetMock.sol b/contracts/plugins/mocks/UnpricedKingAssetMock.sol index df8ecbed5..592220a0f 100644 --- a/contracts/plugins/mocks/UnpricedKingAssetMock.sol +++ b/contracts/plugins/mocks/UnpricedKingAssetMock.sol @@ -51,7 +51,8 @@ contract UnpricedKingAssetMock is KingAsset { if (unpriced) return (0, FIX_MAX, 0); uint192 ethUsdPrice = chainlinkFeed.price(oracleTimeout); // {UoA/ref} - uint192 ethPerKing = _safeWrap(IKing(address(erc20)).fairValueOf(10**erc20Decimals)); // {ref/tok} + (uint256 ethValue, ) = IKing(address(erc20)).fairValueOf(10**erc20Decimals); + uint192 ethPerKing = _safeWrap(ethValue); // {ref/tok} uint192 p = ethUsdPrice.mul(ethPerKing); // {UoA/tok} uint192 delta = p.mul(oracleError, CEIL); return (p - delta, p + delta, 0); diff --git a/scripts/deployment/phase2-assets/assets/deploy_king.ts b/scripts/deployment/phase2-assets/assets/deploy_king.ts index 339e2d65c..0dacddd83 100644 --- a/scripts/deployment/phase2-assets/assets/deploy_king.ts +++ b/scripts/deployment/phase2-assets/assets/deploy_king.ts @@ -43,9 +43,9 @@ async function main() { const kingAsset = await KingAssetFactory.connect(deployer).deploy( priceTimeout, networkConfig[chainId].chainlinkFeeds.ETH!, - fp('0.04').toString(), // 4% Oracle error - TODO: review + fp('0.04').toString(), // 4% Oracle error networkConfig[chainId].tokens.KING!, - fp('1e6').toString(), // $1m + fp('1e5').toString(), // $100K ETH_ORACLE_TIMEOUT ) await kingAsset.deployed() From a501a380dcf644d1d6a26d4ad0b9895b7e670ec7 Mon Sep 17 00:00:00 2001 From: Julian R Date: Tue, 2 Dec 2025 12:26:56 -0300 Subject: [PATCH 9/9] add deployed addresses --- scripts/addresses/1-tmp-assets-collateral.json | 10 +++++++--- .../mainnet-4.2.0/1-tmp-assets-collateral.json | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/scripts/addresses/1-tmp-assets-collateral.json b/scripts/addresses/1-tmp-assets-collateral.json index 29076377d..c9b676370 100644 --- a/scripts/addresses/1-tmp-assets-collateral.json +++ b/scripts/addresses/1-tmp-assets-collateral.json @@ -3,7 +3,8 @@ "stkAAVE": "0xFDE702794298DB19e2a235782B82aD88053F7335", "COMP": "0xA32a92073fEB7ed31081656DeFF34518FB5194b9", "CRV": "0x69841bA9E09019acA0d16Ae9c9724D25d51F6956", - "CVX": "0x2635c3B92c8451F9D1e75BD61FCF87D1eCdf0ad0" + "CVX": "0x2635c3B92c8451F9D1e75BD61FCF87D1eCdf0ad0", + "KING": "0xe64ca4AC2401D6D57cEE942B9ee01494814803f1" }, "collateral": { "DAI": "0x7504ED02f3f151Df241ec2eb0bF1a9601fcb012a", @@ -55,7 +56,8 @@ "sUSDS": "0x4FD189996b5344Eb4CF9c749b97C7424D399d24e", "wOETH": "0xBFAc3e99263B7aE9704eC1c879f7c0a57C6b53e1", "pyUSD": "0x9A65173df5D5B86E26300Cc9cA5Ff378be6DAeA5", - "saEthRLUSD": "0xb1e61f452CFcF6609C2F4088EC36B4c8dd1806b5" + "saEthRLUSD": "0xb1e61f452CFcF6609C2F4088EC36B4c8dd1806b5", + "weETH": "0x9dc6cEFC09b0917c78a05148d45f6e6594e227de" }, "erc20s": { "stkAAVE": "0x4da27a545c0c5B758a6BA100e3a049001de870f5", @@ -111,6 +113,8 @@ "sUSDS": "0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD", "wOETH": "0xDcEe70654261AF21C44c093C300eD3Bb97b78192", "pyUSD": "0x6c3ea9036406852006290770bedfcaba0e23a0e8", - "saEthRLUSD": "0x4C813CE4e2FF315f0213563A994c20BBF4637444" + "saEthRLUSD": "0x4C813CE4e2FF315f0213563A994c20BBF4637444", + "weETH": "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", + "KING": "0x8F08B70456eb22f6109F57b8fafE862ED28E6040" } } \ No newline at end of file diff --git a/scripts/addresses/mainnet-4.2.0/1-tmp-assets-collateral.json b/scripts/addresses/mainnet-4.2.0/1-tmp-assets-collateral.json index cb75dca07..5c9f02a74 100644 --- a/scripts/addresses/mainnet-4.2.0/1-tmp-assets-collateral.json +++ b/scripts/addresses/mainnet-4.2.0/1-tmp-assets-collateral.json @@ -3,7 +3,8 @@ "stkAAVE": "0xFDE702794298DB19e2a235782B82aD88053F7335", "COMP": "0xA32a92073fEB7ed31081656DeFF34518FB5194b9", "CRV": "0x69841bA9E09019acA0d16Ae9c9724D25d51F6956", - "CVX": "0x2635c3B92c8451F9D1e75BD61FCF87D1eCdf0ad0" + "CVX": "0x2635c3B92c8451F9D1e75BD61FCF87D1eCdf0ad0", + "KING": "0xe64ca4AC2401D6D57cEE942B9ee01494814803f1" }, "collateral": { "DAI": "0x7504ED02f3f151Df241ec2eb0bF1a9601fcb012a", @@ -55,7 +56,8 @@ "sUSDS": "0x4FD189996b5344Eb4CF9c749b97C7424D399d24e", "wOETH": "0xBFAc3e99263B7aE9704eC1c879f7c0a57C6b53e1", "pyUSD": "0x9A65173df5D5B86E26300Cc9cA5Ff378be6DAeA5", - "saEthRLUSD": "0xb1e61f452CFcF6609C2F4088EC36B4c8dd1806b5" + "saEthRLUSD": "0xb1e61f452CFcF6609C2F4088EC36B4c8dd1806b5", + "weETH": "0x9dc6cEFC09b0917c78a05148d45f6e6594e227de" }, "erc20s": { "stkAAVE": "0x4da27a545c0c5B758a6BA100e3a049001de870f5", @@ -111,6 +113,8 @@ "sUSDS": "0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD", "wOETH": "0xDcEe70654261AF21C44c093C300eD3Bb97b78192", "pyUSD": "0x6c3ea9036406852006290770bedfcaba0e23a0e8", - "saEthRLUSD": "0x4C813CE4e2FF315f0213563A994c20BBF4637444" + "saEthRLUSD": "0x4C813CE4e2FF315f0213563A994c20BBF4637444", + "weETH": "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", + "KING": "0x8F08B70456eb22f6109F57b8fafE862ED28E6040" } }