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

TRUST H2 #1013

Merged
merged 12 commits into from
Nov 30, 2023
16 changes: 12 additions & 4 deletions contracts/plugins/assets/erc20/RewardableERC20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,21 @@ abstract contract RewardableERC20 is IRewardable, ERC20, ReentrancyGuard {
accumulatedRewards[account] = _accumulatedRewards;
}

function _rewardTokenBalance() internal view virtual returns (uint256) {
return rewardToken.balanceOf(address(this));
}

function _distributeReward(address account, uint256 amt) internal virtual {
rewardToken.safeTransfer(account, amt);
}

function _claimAndSyncRewards() internal virtual {
uint256 _totalSupply = totalSupply();
if (_totalSupply == 0) {
return;
}
_claimAssetRewards();
uint256 balanceAfterClaimingRewards = rewardToken.balanceOf(address(this));
uint256 balanceAfterClaimingRewards = _rewardTokenBalance();

uint256 _rewardsPerShare = rewardsPerShare;
uint256 _previousBalance = lastRewardBalance;
Expand Down Expand Up @@ -113,17 +121,17 @@ abstract contract RewardableERC20 is IRewardable, ERC20, ReentrancyGuard {

claimedRewards[account] = accumulatedRewards[account];

uint256 currentRewardTokenBalance = rewardToken.balanceOf(address(this));
uint256 currentRewardTokenBalance = _rewardTokenBalance();

// This is just to handle the edge case where totalSupply() == 0 and there
// are still reward tokens in the contract.
uint256 nonDistributed = currentRewardTokenBalance > lastRewardBalance
? currentRewardTokenBalance - lastRewardBalance
: 0;

rewardToken.safeTransfer(account, claimableRewards);
_distributeReward(account, claimableRewards);

currentRewardTokenBalance = rewardToken.balanceOf(address(this));
currentRewardTokenBalance = _rewardTokenBalance();
lastRewardBalance = currentRewardTokenBalance > nonDistributed
? currentRewardTokenBalance - nonDistributed
: 0;
Expand Down
50 changes: 45 additions & 5 deletions contracts/plugins/assets/morpho-aave/MorphoTokenisedDeposit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,23 @@ struct MorphoTokenisedDepositConfig {
}

abstract contract MorphoTokenisedDeposit is RewardableERC4626Vault {
struct MorphoTokenisedDepositRewardsAccountingState {
uint256 totalAccumulatedBalance;
uint256 totalPaidOutBalance;
uint256 pendingBalance;
uint256 availableBalance;
uint256 lastSync;
}

uint256 private constant PAYOUT_PERIOD = 7 days;

IMorphoRewardsDistributor public immutable rewardsDistributor;
IMorpho public immutable morphoController;
address public immutable poolToken;
address public immutable underlying;

MorphoTokenisedDepositRewardsAccountingState private state;

constructor(MorphoTokenisedDepositConfig memory config)
RewardableERC4626Vault(
config.underlyingERC20,
Expand All @@ -32,16 +44,44 @@ abstract contract MorphoTokenisedDeposit is RewardableERC4626Vault {
morphoController = config.morphoController;
poolToken = address(config.poolToken);
rewardsDistributor = config.rewardsDistributor;
state.lastSync = uint48(block.timestamp);
}

function rewardTokenBalance(address account) external returns (uint256 claimableRewards) {
function sync() external {
_claimAndSyncRewards();
_syncAccount(account);
claimableRewards = accumulatedRewards[account] - claimedRewards[account];
}

// solhint-disable-next-line no-empty-blocks
function _claimAssetRewards() internal virtual override {}
function _claimAssetRewards() internal override {
// First pay out any pendingBalances, over a 7200 block period
uint256 timeDelta = block.timestamp - state.lastSync;
if (timeDelta == 0) {
return;
}
if (timeDelta > PAYOUT_PERIOD) {
timeDelta = PAYOUT_PERIOD;
}
uint256 amtToPayOut = (state.pendingBalance * ((timeDelta * 1e18) / PAYOUT_PERIOD)) / 1e18;
state.pendingBalance -= amtToPayOut;
state.availableBalance += amtToPayOut;

// If we detect any new balances add it to pending and reset payout period
uint256 totalAccumulated = state.totalPaidOutBalance + rewardToken.balanceOf(address(this));
uint256 newlyAccumulated = totalAccumulated - state.totalAccumulatedBalance;
state.totalAccumulatedBalance = totalAccumulated;
state.pendingBalance += newlyAccumulated;

state.lastSync = block.timestamp;
}

function _rewardTokenBalance() internal view override returns (uint256) {
return state.availableBalance;
}

function _distributeReward(address account, uint256 amt) internal override {
state.totalPaidOutBalance += uint256(amt);
state.availableBalance -= uint256(amt);
SafeERC20.safeTransfer(rewardToken, account, amt);
}

function getMorphoPoolBalance(address poolToken) internal view virtual returns (uint256);

Expand Down
17 changes: 17 additions & 0 deletions test/plugins/RewardableERC20.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,23 @@ for (const wrapperName of wrapperNames) {
})
})

it('Cannot frontrun claimRewards by inflating your shares', async () => {
await rewardableAsset.connect(bob).approve(rewardableVault.address, MAX_UINT256)
await rewardableAsset.mint(bob.address, initBalance.mul(100))
await rewardableVault.connect(alice).deposit(initBalance, alice.address)
await rewardableAsset.accrueRewards(rewardAmount, rewardableVault.address)

// Bob 'flashloans' 100x the current balance of the vault and claims rewards
await rewardableVault.connect(bob).deposit(initBalance.mul(100), bob.address)
await rewardableVault.connect(bob).claimRewards()

// Alice claimsRewards a bit later
await rewardableVault.connect(alice).claimRewards()
expect(await rewardToken.balanceOf(alice.address)).to.be.gt(
await rewardToken.balanceOf(bob.address)
)
})

describe('alice deposit, accrue, bob deposit, accrue, bob claim, alice claim', () => {
let rewardsPerShare: BigNumber

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { MorphoAaveCollateralFixtureContext, mintCollateralTo } from './mintColl
import { setCode } from '@nomicfoundation/hardhat-network-helpers'
import { whileImpersonating } from '#/utils/impersonation'
import { whales } from '#/tasks/testing/upgrade-checker-utils/constants'
import { formatEther } from 'ethers/lib/utils'
import { advanceBlocks, advanceTime } from '#/utils/time'

interface MAFiatCollateralOpts extends CollateralOpts {
underlyingToken?: string
Expand All @@ -35,7 +35,8 @@ interface MAFiatCollateralOpts extends CollateralOpts {

const makeAaveFiatCollateralTestSuite = (
collateralName: string,
defaultCollateralOpts: MAFiatCollateralOpts
defaultCollateralOpts: MAFiatCollateralOpts,
specificTests = false
) => {
const networkConfigToUse = networkConfig[31337]
const deployCollateral = async (opts: MAFiatCollateralOpts = {}): Promise<TestICollateral> => {
Expand Down Expand Up @@ -197,7 +198,9 @@ const makeAaveFiatCollateralTestSuite = (
*/
const collateralSpecificConstructorTests = () => {
it('tokenised deposits can correctly claim rewards', async () => {
const morphoTokenOwner = '0xcBa28b38103307Ec8dA98377ffF9816C164f9AFa'
const alice = hre.ethers.provider.getSigner(1)
const aliceAddress = await alice.getAddress()

const forkBlock = 17574117
const claimer = '0x05e818959c2Aa4CD05EDAe9A099c38e7Bdc377C6'
const reset = getResetFork(forkBlock)
Expand All @@ -213,39 +216,39 @@ const makeAaveFiatCollateralTestSuite = (
rewardsDistributor: networkConfigToUse.MORPHO_REWARDS_DISTRIBUTOR!,
rewardToken: networkConfigToUse.tokens.MORPHO!,
})

const morphoTokenOwner = '0xcBa28b38103307Ec8dA98377ffF9816C164f9AFa'
const vaultCode = await ethers.provider.getCode(usdtVault.address)
await setCode(claimer, vaultCode)

const vaultWithClaimableRewards = usdtVault.attach(claimer)
await whileImpersonating(hre, morphoTokenOwner, async (signer) => {
const morphoTokenInst = await ethers.getContractAt(
'IMorphoToken',
networkConfigToUse.tokens.MORPHO!,
signer
)

await morphoTokenInst
.connect(signer)
.setUserRole(vaultWithClaimableRewards.address, 0, true)
})
const erc20Factory = await ethers.getContractFactory('ERC20Mock')
const underlyingERC20 = erc20Factory.attach(defaultCollateralOpts.underlyingToken!)
const depositAmount = utils.parseUnits('1000', 6)

const user = hre.ethers.provider.getSigner(0)
const userAddress = await user.getAddress()

expect(
formatEther(await vaultWithClaimableRewards.callStatic.rewardTokenBalance(userAddress))
).to.be.equal('0.0')

await whileImpersonating(
hre,
whales[defaultCollateralOpts.underlyingToken!.toLowerCase()],
async (whaleSigner) => {
await underlyingERC20.connect(whaleSigner).approve(vaultWithClaimableRewards.address, 0)
await underlyingERC20
.connect(whaleSigner)
.approve(vaultWithClaimableRewards.address, ethers.constants.MaxUint256)
await vaultWithClaimableRewards.connect(whaleSigner).mint(depositAmount, userAddress)
await underlyingERC20.connect(whaleSigner).transfer(aliceAddress, depositAmount)
}
)

expect(
formatEther(
await vaultWithClaimableRewards.callStatic.rewardTokenBalance(userAddress)
).slice(0, '8.60295466891613'.length)
).to.be.equal('8.60295466891613')

await underlyingERC20.connect(alice).approve(vaultWithClaimableRewards.address, 0)
await underlyingERC20
.connect(alice)
.approve(vaultWithClaimableRewards.address, ethers.constants.MaxUint256)
await vaultWithClaimableRewards.connect(alice).mint(depositAmount, aliceAddress)
const morphoRewards = await ethers.getContractAt(
'IMorphoRewardsDistributor',
networkConfigToUse.MORPHO_REWARDS_DISTRIBUTOR!
Expand All @@ -265,47 +268,74 @@ const makeAaveFiatCollateralTestSuite = (
'0xea8c2ee8d43e37ceb7b0c04d59106eff88afbe3e911b656dec7caebd415ea696',
])

expect(
formatEther(
await vaultWithClaimableRewards.callStatic.rewardTokenBalance(userAddress)
).slice(0, '14.162082619942089'.length)
).to.be.equal('14.162082619942089')
// sync needs to be called after a claim to start a new payout period
// new tokens will only be moved into pending after a _claimAssetRewards call
// which sync allows you to do without the other stuff that happens in claimRewards
await vaultWithClaimableRewards.sync()

// MORPHO is not a transferable token.
// POST Launch we could ask the Morpho team if our TokenVaults could get permission to transfer the MORPHO tokens.
// Otherwise owners of the TokenVault shares need to wait until the protocol enables the transfer function on the MORPHO token.
await advanceTime(hre, 86400 * 7)
await advanceBlocks(hre, 7200 * 7)
expect(await vaultWithClaimableRewards.connect(alice).claimRewards())
expect(
await erc20Factory.attach(networkConfigToUse.tokens.MORPHO!).balanceOf(aliceAddress)
).to.be.eq(bn('14162082619942089266'))
})
it('Frontrunning claiming rewards is not economical', async () => {
const alice = hre.ethers.provider.getSigner(1)
const aliceAddress = await alice.getAddress()
const bob = hre.ethers.provider.getSigner(2)
const bobAddress = await bob.getAddress()

await whileImpersonating(hre, morphoTokenOwner, async (signer) => {
const morphoTokenInst = await ethers.getContractAt(
'IMorphoToken',
networkConfigToUse.tokens.MORPHO!,
signer
)
const MorphoTokenisedDepositFactory = await ethers.getContractFactory(
'MorphoAaveV2TokenisedDeposit'
)
const ERC20Factory = await ethers.getContractFactory('ERC20Mock')
const mockRewardsToken = await ERC20Factory.deploy('MockMorphoReward', 'MMrp')
const underlyingERC20 = ERC20Factory.attach(defaultCollateralOpts.underlyingToken!)

await morphoTokenInst
.connect(signer)
.setUserRole(vaultWithClaimableRewards.address, 0, true)
const vault = await MorphoTokenisedDepositFactory.deploy({
morphoController: networkConfigToUse.MORPHO_AAVE_CONTROLLER!,
morphoLens: networkConfigToUse.MORPHO_AAVE_LENS!,
underlyingERC20: defaultCollateralOpts.underlyingToken!,
poolToken: defaultCollateralOpts.poolToken!,
rewardsDistributor: networkConfigToUse.MORPHO_REWARDS_DISTRIBUTOR!,
rewardToken: mockRewardsToken.address,
})

const morphoTokenInst = await ethers.getContractAt(
'IMorphoToken',
networkConfigToUse.tokens.MORPHO!,
user
const depositAmount = utils.parseUnits('1000', 6)

await whileImpersonating(
hre,
whales[defaultCollateralOpts.underlyingToken!.toLowerCase()],
async (whaleSigner) => {
await underlyingERC20.connect(whaleSigner).transfer(aliceAddress, depositAmount)
await underlyingERC20.connect(whaleSigner).transfer(bobAddress, depositAmount.mul(10))
}
)
expect(formatEther(await morphoTokenInst.balanceOf(userAddress))).to.be.equal('0.0')

await vaultWithClaimableRewards.claimRewards()
await underlyingERC20.connect(alice).approve(vault.address, ethers.constants.MaxUint256)
await vault.connect(alice).mint(depositAmount, aliceAddress)

expect(
formatEther(await vaultWithClaimableRewards.callStatic.rewardTokenBalance(userAddress))
).to.be.equal('0.0')
// Simulate inflation attack
await underlyingERC20.connect(bob).approve(vault.address, ethers.constants.MaxUint256)
await vault.connect(bob).mint(depositAmount.mul(10), bobAddress)

expect(
formatEther(await morphoTokenInst.balanceOf(userAddress)).slice(
0,
'14.162082619942089'.length
)
).to.be.equal('14.162082619942089')
await mockRewardsToken.mint(vault.address, bn('1000000000000000000000'))
await vault.sync()

await vault.connect(bob).claimRewards()
await vault.connect(bob).redeem(depositAmount.mul(10), bobAddress, bobAddress)

// After the inflation attack
await advanceTime(hre, 86400 * 7)
await advanceBlocks(hre, 7200 * 7)
await vault.connect(alice).claimRewards()

// Shown below is that it is no longer economical to inflate own shares
// bob only managed to steal approx 1/7200 * 90% of the reward because hardhat increments block by 1
// in practise it would be 0 as inflation attacks typically flashloan assets.
expect(await mockRewardsToken.balanceOf(aliceAddress)).to.be.eq(bn('999996993749479075487'))
expect(await mockRewardsToken.balanceOf(bobAddress)).to.be.eq(bn('1503126503126363'))
})
}

Expand All @@ -316,7 +346,9 @@ const makeAaveFiatCollateralTestSuite = (

const opts = {
deployCollateral,
collateralSpecificConstructorTests: collateralSpecificConstructorTests,
collateralSpecificConstructorTests: specificTests
? collateralSpecificConstructorTests
: () => void 0,
collateralSpecificStatusTests,
beforeEachRewardsTest,
makeCollateralFixtureContext,
Expand Down Expand Up @@ -369,7 +401,8 @@ const makeOpts = (
const { tokens, chainlinkFeeds } = networkConfig[31337]
makeAaveFiatCollateralTestSuite(
'MorphoAAVEV2FiatCollateral - USDT',
makeOpts(tokens.USDT!, tokens.aUSDT!, chainlinkFeeds.USDT!)
makeOpts(tokens.USDT!, tokens.aUSDT!, chainlinkFeeds.USDT!),
true // Only run specific tests once, since they are slow
)
makeAaveFiatCollateralTestSuite(
'MorphoAAVEV2FiatCollateral - USDC',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ exports[`Collateral: MorphoAAVEV2FiatCollateral - DAI collateral functionality G

exports[`Collateral: MorphoAAVEV2FiatCollateral - DAI collateral functionality Gas Reporting ERC20 transfer 2`] = `56781`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - DAI collateral functionality Gas Reporting ERC20 transfer 3`] = `88981`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - DAI collateral functionality Gas Reporting ERC20 transfer 4`] = `71881`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - DAI collateral functionality Gas Reporting refresh() after full price timeout 1`] = `134211`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - DAI collateral functionality Gas Reporting refresh() after full price timeout 2`] = `129742`;
Expand Down Expand Up @@ -32,6 +36,10 @@ exports[`Collateral: MorphoAAVEV2FiatCollateral - USDC collateral functionality

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDC collateral functionality Gas Reporting ERC20 transfer 2`] = `56781`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDC collateral functionality Gas Reporting ERC20 transfer 3`] = `88981`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDC collateral functionality Gas Reporting ERC20 transfer 4`] = `71881`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDC collateral functionality Gas Reporting refresh() after full price timeout 1`] = `134414`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDC collateral functionality Gas Reporting refresh() after full price timeout 2`] = `129945`;
Expand Down Expand Up @@ -60,6 +68,10 @@ exports[`Collateral: MorphoAAVEV2FiatCollateral - USDT collateral functionality

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDT collateral functionality Gas Reporting ERC20 transfer 2`] = `56781`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDT collateral functionality Gas Reporting ERC20 transfer 3`] = `88981`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDT collateral functionality Gas Reporting ERC20 transfer 4`] = `71881`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDT collateral functionality Gas Reporting refresh() after full price timeout 1`] = `133567`;

exports[`Collateral: MorphoAAVEV2FiatCollateral - USDT collateral functionality Gas Reporting refresh() after full price timeout 2`] = `129098`;
Expand Down
Loading