Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

C4: #35 #32, fixes rETH / cbETH / ankrETH ref unit and adds soft default checks #899

Merged
merged 12 commits into from
Aug 22, 2023
41 changes: 26 additions & 15 deletions contracts/plugins/assets/ankr/AnkrStakedEthCollateral.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,34 @@ import "./vendor/IAnkrETH.sol";

/**
* @title Ankr Staked Eth Collateral
* @notice Collateral plugin for Ankr ankrETH,
* @notice Collateral plugin for Ankr's ankrETH
* tok = ankrETH
* ref = ETH
* ref = ETH2
* tar = ETH
* UoA = USD
* @dev Not ready to deploy yet. Missing a {target/tok} feed from Chainlink.
*/
contract AnkrStakedEthCollateral is AppreciatingFiatCollateral {
using OracleLib for AggregatorV3Interface;
using FixLib for uint192;

// solhint-disable no-empty-blocks
/// @param config.chainlinkFeed Feed units: {UoA/ref}
constructor(CollateralConfig memory config, uint192 revenueHiding)
AppreciatingFiatCollateral(config, revenueHiding)
{}
AggregatorV3Interface public immutable targetPerTokChainlinkFeed; // {target/tok}
uint48 public immutable targetPerTokChainlinkTimeout;

// solhint-enable no-empty-blocks
/// @param config.chainlinkFeed {UoA/target} price of ETH in USD terms
/// @param _targetPerTokChainlinkFeed {target/tok} price of cbETH in ETH terms
constructor(
CollateralConfig memory config,
uint192 revenueHiding,
AggregatorV3Interface _targetPerTokChainlinkFeed,
uint48 _targetPerTokChainlinkTimeout
) AppreciatingFiatCollateral(config, revenueHiding) {
require(address(_targetPerTokChainlinkFeed) != address(0), "missing targetPerTok feed");
require(_targetPerTokChainlinkTimeout != 0, "targetPerTokChainlinkTimeout zero");

targetPerTokChainlinkFeed = _targetPerTokChainlinkFeed;
targetPerTokChainlinkTimeout = _targetPerTokChainlinkTimeout;
}

/// Can revert, used by other contract functions in order to catch errors
/// @return low {UoA/tok} The low price estimate
Expand All @@ -41,22 +52,22 @@ contract AnkrStakedEthCollateral is AppreciatingFiatCollateral {
uint192 pegPrice
)
{
uint192 pricePerRef = chainlinkFeed.price(oracleTimeout); // {UoA/ref}
uint192 targetPerTok = targetPerTokChainlinkFeed.price(targetPerTokChainlinkTimeout);

// {UoA/tok} = {UoA/ref} * {ref/tok}
uint192 p = pricePerRef.mul(_underlyingRefPerTok());
// {UoA/tok} = {UoA/target} * {target/tok}
uint192 p = chainlinkFeed.price(oracleTimeout).mul(targetPerTok);
uint192 err = p.mul(oracleError, CEIL);

low = p - err;
high = p + err;
low = p - err;
// assert(low <= high); obviously true just by inspection

pegPrice = targetPerRef(); // ETH/ETH
// {target/ref} = {target/tok} / {ref/tok}
pegPrice = targetPerTok.div(_underlyingRefPerTok());
}

/// @return {ref/tok} Quantity of whole reference units per whole collateral tokens
function _underlyingRefPerTok() internal view override returns (uint192) {
uint256 rate = IAnkrETH(address(erc20)).ratio();
return FIX_ONE.div(_safeWrap(rate), FLOOR);
return FIX_ONE.div(_safeWrap(IAnkrETH(address(erc20)).ratio()), FLOOR);
}
}
12 changes: 6 additions & 6 deletions contracts/plugins/assets/ankr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ This plugin allows the usage of [ankrETH](https://www.ankr.com/about-staking/) a

The `ankrETH` token represents the users staked ETH plus accumulated staking rewards. It is immediately liquid, which enables users to trade them instantly, or unstake them to redeem the original underlying asset.

User's balances in `ankrETH` remain constant, but the value of each ankrETH token grows over time. It is a reward-bearing token, meaning that the fair value of 1 ankrETH token vs. ETH increases over time as staking rewards accumulate. When possible, users will have the option to redeem ankrETH and unstake ETH with accumulated [staking rewards](https://www.ankr.com/docs/staking/liquid-staking/eth/overview/).
User's balances in `ankrETH` remain constant, but the value of each ankrETH token grows over time. It is a reward-bearing token, meaning that the fair value of 1 ankrETH token vs. ETH2 increases over time as staking rewards accumulate. When possible, users will have the option to redeem ankrETH and unstake ETH2 for ETH with accumulated [staking rewards](https://www.ankr.com/docs/staking/liquid-staking/eth/overview/).

## Implementation

### Units

| tok | ref | target | UoA |
| ------- | --- | ------ | --- |
| ankrETH | ETH | ETH | USD |
| tok | ref | target | UoA |
| ------- | ---- | ------ | --- |
| ankrETH | ETH2 | ETH | USD |

### Functions

#### refPerTok {ref/tok}

The exchange rate between ETH and ankrETH can be fetched using the ankrETH contract function `ratio()`. From this, we can obtain the inverse rate from ankrETH to ETH, and use that as `refPerTok`.
The exchange rate between ETH2 and ankrETH can be fetched using the ankrETH contract function `ratio()`. From this, we can obtain the inverse rate from ankrETH to ETH2, and use that as `refPerTok`.

This new ratio, increases over time, which means that the amount of ETH redeemable for each ankrETH token always increases.
This new ratio, increases over time, which means that the amount of ETH redeemable for each ankrETH token always increases, though redemptions sit behind a withdrawal queue.

`ratio()` returns the exchange rate in 18 decimals.
6 changes: 3 additions & 3 deletions contracts/plugins/assets/cbeth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ This plugin allows `CBETH` holders to use their tokens as collateral in the Rese

### Units

| tok | ref | target | UoA |
| ----- | --- | ------ | --- |
| cbeth | ETH | ETH | USD |
| tok | ref | target | UoA |
| ----- | ---- | ------ | --- |
| cbeth | ETH2 | ETH | USD |

### Functions

Expand Down
12 changes: 6 additions & 6 deletions contracts/plugins/assets/rocket-eth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ stake in the POS ETH2.0 consenus layer.

### Units

| tok | ref | target | UoA |
| ---- | --- | ------ | --- |
| rETH | ETH | ETH | USD |
| tok | ref | target | UoA |
| ---- | ---- | ------ | --- |
| rETH | ETH2 | ETH | USD |

### refPerTok()

Gets the exchange rate for `rETH` to `ETH` from the rETH token contract using the [getExchangeRate()](https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/token/RocketTokenRETH.sol#L66)
function. This is the rate used by rocket pool when converting between reth and eth and is closely followed by secondary markets.
While the value of ETH/rETH **should** be only-increasing, it is possible that slashing or inactivity events could occur for the rETH
Gets the exchange rate for `rETH` to `ETH2` from the rETH token contract using the [getExchangeRate()](https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/token/RocketTokenRETH.sol#L66)
function. This is the rate used by rocket pool when converting between reth and eth2 and is closely followed by secondary markets.
While the value of ETH2/rETH **should** be only-increasing, it is possible that slashing or inactivity events could occur for the rETH
validators. As such, `rETH` inherits `AppreciatingFiatCollateral` to allow for some amount of revenue-hiding. The amount of
revenue-hiding should be determined by the deployer, but can likely be quite high, as it is more likely that any dips, however large,
would be temporary, and, in particularly bad instances, be covered by the Rocket Pool protocol.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"test:coverage": "PROTO_IMPL=1 hardhat coverage --testfiles 'test/{libraries,plugins,scenario}/*.test.ts test/*.test.ts'",
"test:unit:coverage": "PROTO_IMPL=1 SLOW= hardhat coverage --testfiles 'test/*.test.ts test/libraries/*.test.ts test/plugins/*.test.ts'",
"eslint": "eslint test/",
"lint": "bash tools/lint && eslint test/ --cache",
"lint": "bash tools/lint && eslint test/",
"prettier": "prettier --ignore-path .gitignore --loglevel warn --write \"./**/*.{js,ts,sol,json,md}\"",
"size": "hardhat size-contracts",
"slither": "python3 tools/slither.py",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { ethers } from 'hardhat'
import { expect } from 'chai'
import { ContractFactory, BigNumberish, BigNumber } from 'ethers'
import {
ERC20Mock,
MockV3Aggregator,
MockV3Aggregator__factory,
TestICollateral,
Expand Down Expand Up @@ -33,13 +32,19 @@ import { whileImpersonating } from '../../../utils/impersonation'

interface AnkrETHCollateralFixtureContext extends CollateralFixtureContext {
ankreth: IAnkrETH
targetPerTokChainlinkFeed: MockV3Aggregator
}

interface AnkrETHCollateralOpts extends CollateralOpts {
targetPerTokChainlinkFeed?: string
targetPerTokChainlinkTimeout?: BigNumberish
}

/*
Define deployment functions
*/

export const defaultAnkrEthCollateralOpts: CollateralOpts = {
export const defaultAnkrETHCollateralOpts: AnkrETHCollateralOpts = {
erc20: ANKRETH,
targetName: ethers.utils.formatBytes32String('ETH'),
rewardERC20: ZERO_ADDRESS,
Expand All @@ -53,8 +58,24 @@ export const defaultAnkrEthCollateralOpts: CollateralOpts = {
revenueHiding: fp('0'),
}

export const deployCollateral = async (opts: CollateralOpts = {}): Promise<TestICollateral> => {
opts = { ...defaultAnkrEthCollateralOpts, ...opts }
export const deployCollateral = async (
opts: AnkrETHCollateralOpts = {}
): Promise<TestICollateral> => {
opts = { ...defaultAnkrETHCollateralOpts, ...opts }

if (opts.targetPerTokChainlinkFeed === undefined) {
// Use mock targetPerTok feed until Chainlink deploys a real one
const MockV3AggregatorFactory = <MockV3Aggregator__factory>(
await ethers.getContractFactory('MockV3Aggregator')
)
const targetPerTokChainlinkFeed = <MockV3Aggregator>(
await MockV3AggregatorFactory.deploy(18, targetPerTokChainlinkDefaultAnswer)
)
opts.targetPerTokChainlinkFeed = targetPerTokChainlinkFeed.address
}
if (opts.targetPerTokChainlinkTimeout === undefined) {
opts.targetPerTokChainlinkTimeout = ORACLE_TIMEOUT
}

const AnkrETHCollateralFactory: ContractFactory = await ethers.getContractFactory(
'AnkrStakedEthCollateral'
Expand All @@ -73,6 +94,8 @@ export const deployCollateral = async (opts: CollateralOpts = {}): Promise<TestI
delayUntilDefault: opts.delayUntilDefault,
},
opts.revenueHiding,
opts.targetPerTokChainlinkFeed,
opts.targetPerTokChainlinkTimeout,
{ gasLimit: 2000000000 }
)
await collateral.deployed()
Expand All @@ -85,14 +108,15 @@ export const deployCollateral = async (opts: CollateralOpts = {}): Promise<TestI
}

const chainlinkDefaultAnswer = bn('1600e8')
const targetPerTokChainlinkDefaultAnswer = fp('1.075118097902877192') // TODO

type Fixture<T> = () => Promise<T>

const makeCollateralFixtureContext = (
alice: SignerWithAddress,
opts: CollateralOpts = {}
opts: AnkrETHCollateralOpts = {}
): Fixture<AnkrETHCollateralFixtureContext> => {
const collateralOpts = { ...defaultAnkrEthCollateralOpts, ...opts }
const collateralOpts = { ...defaultAnkrETHCollateralOpts, ...opts }

const makeCollateralFixtureContext = async () => {
const MockV3AggregatorFactory = <MockV3Aggregator__factory>(
Expand All @@ -105,17 +129,22 @@ const makeCollateralFixtureContext = (

collateralOpts.chainlinkFeed = chainlinkFeed.address

const targetPerTokChainlinkFeed = <MockV3Aggregator>(
await MockV3AggregatorFactory.deploy(18, targetPerTokChainlinkDefaultAnswer)
)
collateralOpts.targetPerTokChainlinkFeed = targetPerTokChainlinkFeed.address
collateralOpts.targetPerTokChainlinkTimeout = ORACLE_TIMEOUT

const ankreth = (await ethers.getContractAt('IAnkrETH', ANKRETH)) as IAnkrETH
const rewardToken = (await ethers.getContractAt('ERC20Mock', ZERO_ADDRESS)) as ERC20Mock
const collateral = await deployCollateral(collateralOpts)

return {
alice,
collateral,
chainlinkFeed,
targetPerTokChainlinkFeed,
ankreth,
tok: ankreth,
rewardToken,
}
}

Expand All @@ -135,50 +164,78 @@ const mintCollateralTo: MintCollateralFunc<AnkrETHCollateralFixtureContext> = as
await mintAnkrETH(ctx.ankreth, user, amount, recipient)
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
const reduceTargetPerRef = async () => {}
const changeTargetPerRef = async (
ctx: AnkrETHCollateralFixtureContext,
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)
}
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
const increaseTargetPerRef = async () => {}
const reduceTargetPerRef = async (
ctx: AnkrETHCollateralFixtureContext,
pctDecrease: BigNumberish
) => {
await changeTargetPerRef(ctx, bn(pctDecrease).mul(-1))
}

const reduceRefPerTok = async (ctx: AnkrETHCollateralFixtureContext, pctDecrease: BigNumberish) => {
const increaseTargetPerRef = async (
ctx: AnkrETHCollateralFixtureContext,
pctDecrease: BigNumberish
tbrent marked this conversation as resolved.
Show resolved Hide resolved
) => {
await changeTargetPerRef(ctx, bn(pctDecrease))
}

const changeRefPerTok = async (ctx: AnkrETHCollateralFixtureContext, percentChange: BigNumber) => {
const ankrETH = (await ethers.getContractAt('IAnkrETH', ANKRETH)) as IAnkrETH

// Increase ratio so refPerTok decreases
// Move ratio in opposite direction as percentChange
const currentRatio = await ankrETH.ratio()
const newRatio: BigNumberish = currentRatio.add(currentRatio.mul(pctDecrease).div(100))
const newRatio: BigNumberish = currentRatio.add(currentRatio.mul(percentChange.mul(-1)).div(100))

// Impersonate AnkrETH Owner
await whileImpersonating(ANKRETH_OWNER, async (ankrEthOwnerSigner) => {
await ankrETH.connect(ankrEthOwnerSigner).updateRatio(newRatio)
})

{
const lastRound = await ctx.targetPerTokChainlinkFeed.latestRoundData()
const nextAnswer = lastRound.answer.add(lastRound.answer.mul(percentChange).div(100))
await ctx.targetPerTokChainlinkFeed.updateAnswer(nextAnswer)
}

{
const lastRound = await ctx.chainlinkFeed.latestRoundData()
const nextAnswer = lastRound.answer.add(lastRound.answer.mul(percentChange).div(100))
await ctx.chainlinkFeed.updateAnswer(nextAnswer)
}
}

const reduceRefPerTok = async (ctx: AnkrETHCollateralFixtureContext, pctDecrease: BigNumberish) => {
await changeRefPerTok(ctx, bn(pctDecrease).mul(-1))
}

const increaseRefPerTok = async (
ctx: AnkrETHCollateralFixtureContext,
pctIncrease: BigNumberish
) => {
const ankrETH = (await ethers.getContractAt('IAnkrETH', ANKRETH)) as IAnkrETH

// Decrease ratio so refPerTok increases
const currentRatio = await ankrETH.ratio()
const newRatio: BigNumberish = currentRatio.sub(currentRatio.mul(pctIncrease).div(100))

// Impersonate AnkrETH Owner
await whileImpersonating(ANKRETH_OWNER, async (ankrEthOwnerSigner) => {
await ankrETH.connect(ankrEthOwnerSigner).updateRatio(newRatio)
})
await changeRefPerTok(ctx, bn(pctIncrease))
}

const getExpectedPrice = async (ctx: AnkrETHCollateralFixtureContext): Promise<BigNumber> => {
const clData = await ctx.chainlinkFeed.latestRoundData()
const clDecimals = await ctx.chainlinkFeed.decimals()

const refPerTok = await ctx.collateral.refPerTok()
const clRptData = await ctx.targetPerTokChainlinkFeed.latestRoundData()
const clRptDecimals = await ctx.targetPerTokChainlinkFeed.decimals()

return clData.answer
.mul(bn(10).pow(18 - clDecimals))
.mul(refPerTok)
.mul(clRptData.answer.mul(bn(10).pow(18 - clRptDecimals)))
.div(fp('1'))
}

Expand All @@ -187,7 +244,19 @@ const getExpectedPrice = async (ctx: AnkrETHCollateralFixtureContext): Promise<B
*/

// eslint-disable-next-line @typescript-eslint/no-empty-function
const collateralSpecificConstructorTests = () => {}
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 = () => {}
Expand All @@ -212,13 +281,14 @@ const opts = {
increaseRefPerTok,
getExpectedPrice,
itClaimsRewards: it.skip,
itChecksTargetPerRefDefault: it.skip,
itChecksTargetPerRefDefault: it,
itChecksRefPerTokDefault: it,
itChecksPriceChanges: it,
itHasRevenueHiding: it,
resetFork,
collateralName: 'AnkrStakedETH',
chainlinkDefaultAnswer,
itIsPricedByPeg: true,
}

collateralTests(opts)