Skip to content

Commit

Permalink
update MorphoTokenizedDeposit to make share inflation attacks to stea…
Browse files Browse the repository at this point in the history
…l rewards uneconomical
  • Loading branch information
jankjr committed Nov 22, 2023
1 parent 705d548 commit 02e87a6
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 95 deletions.
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
61 changes: 59 additions & 2 deletions contracts/plugins/assets/morpho-aave/MorphoTokenisedDeposit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,25 @@ struct MorphoTokenisedDepositConfig {
}

abstract contract MorphoTokenisedDeposit is RewardableERC4626Vault {
uint256 private constant PAYOUT_PERIOD = 7200;

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

// we instead implement a pattern that pays out rewards over time.
uint120 private totalAccumulatedBalance = 0;
uint120 private totalPaidOutBalance = 0;

// Reward token balance behind currently paid out
uint112 private pendingBalance = 0;
// Claimable reward token balance
uint112 private availableBalance = 0;

// Start of the current payout period
uint48 private lastSync = 0;

constructor(MorphoTokenisedDepositConfig memory config)
RewardableERC4626Vault(
config.underlyingERC20,
Expand All @@ -32,6 +46,8 @@ abstract contract MorphoTokenisedDeposit is RewardableERC4626Vault {
morphoController = config.morphoController;
poolToken = address(config.poolToken);
rewardsDistributor = config.rewardsDistributor;
pendingBalance = uint112(config.rewardToken.balanceOf(address(this)));
totalAccumulatedBalance = uint112(config.rewardToken.balanceOf(address(this)));
}

function rewardTokenBalance(address account) external returns (uint256 claimableRewards) {
Expand All @@ -40,8 +56,49 @@ abstract contract MorphoTokenisedDeposit is RewardableERC4626Vault {
claimableRewards = accumulatedRewards[account] - claimedRewards[account];
}

// solhint-disable-next-line no-empty-blocks
function _claimAssetRewards() internal virtual override {}
function sync() external {
_claimAndSyncRewards();
}

function _claimAssetRewards() internal override {
uint256 blockDelta = block.number - lastSync;
if (blockDelta == 0) {
return;
}

if (blockDelta > PAYOUT_PERIOD) {
blockDelta = PAYOUT_PERIOD;
}
uint112 amtToPayOut = uint112(
(uint256(pendingBalance) * ((blockDelta * 1e18) / PAYOUT_PERIOD)) / 1e18
);
if (pendingBalance > amtToPayOut) {
pendingBalance -= amtToPayOut;
} else {
pendingBalance = 0;
}
availableBalance += amtToPayOut;

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

if (accumulatedTokens > 0) {
lastSync = uint48(block.number);
}
}

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

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

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

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 } 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,6 +216,7 @@ const makeAaveFiatCollateralTestSuite = (
rewardsDistributor: networkConfigToUse.MORPHO_REWARDS_DISTRIBUTOR!,
rewardToken: networkConfigToUse.tokens.MORPHO!,
})

const vaultCode = await ethers.provider.getCode(usdtVault.address)
await setCode(claimer, vaultCode)

Expand All @@ -221,35 +225,31 @@ const makeAaveFiatCollateralTestSuite = (
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 vaultWithClaimableRewards.connect(alice).sync()
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)
await vaultWithClaimableRewards.connect(alice).sync()
const morphoRewards = await ethers.getContractAt(
'IMorphoRewardsDistributor',
networkConfigToUse.MORPHO_REWARDS_DISTRIBUTOR!
)
expect(await vaultWithClaimableRewards.callStatic.rewardTokenBalance(aliceAddress)).to.be.eq(
bn(0)
)
await vaultWithClaimableRewards.connect(alice).sync()
await advanceBlocks(hre, 7200)
await vaultWithClaimableRewards.connect(alice).sync()

await morphoRewards.claim(vaultWithClaimableRewards.address, '14162082619942089266', [
'0x49bb35f20573d5b927c5b5c15c904839cacdf83c6119450ccb6c2ed0647aa71b',
'0xfb9f4530177774effb7af9c1723c7087f60cd135a0cb5f409ec7bbc792a79235',
Expand All @@ -264,50 +264,19 @@ const makeAaveFiatCollateralTestSuite = (
'0x14c512bd39f8b1d13d4cfaad2b4473c4022d01577249ecc97fbf0a64244378ee',
'0xea8c2ee8d43e37ceb7b0c04d59106eff88afbe3e911b656dec7caebd415ea696',
])

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

// 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 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 morphoTokenInst = await ethers.getContractAt(
'IMorphoToken',
networkConfigToUse.tokens.MORPHO!,
user
await vaultWithClaimableRewards.connect(alice).sync()
await advanceBlocks(hre, 7200)
expect(await vaultWithClaimableRewards.callStatic.rewardTokenBalance(aliceAddress)).to.be.eq(
bn('14162082619942089266')
)
expect(formatEther(await morphoTokenInst.balanceOf(userAddress))).to.be.equal('0.0')

await vaultWithClaimableRewards.claimRewards()
}),
it('Frontrunning claiming rewards is not economical', async () => {
const alice = hre.ethers.provider.getSigner(1)
const aliceAddress = await alice.getAddress()

expect(
formatEther(await vaultWithClaimableRewards.callStatic.rewardTokenBalance(userAddress))
).to.be.equal('0.0')
const bob = hre.ethers.provider.getSigner(2)
const bobAddress = await bob.getAddress()

expect(
formatEther(await morphoTokenInst.balanceOf(userAddress)).slice(
0,
'14.162082619942089'.length
)
).to.be.equal('14.162082619942089')
}),
it('Reward claiming cannot be frontrun', async () => {
const forkBlock = 17574117
const claimer = '0x05e818959c2Aa4CD05EDAe9A099c38e7Bdc377C6'
const reset = getResetFork(forkBlock)
Expand All @@ -323,6 +292,7 @@ const makeAaveFiatCollateralTestSuite = (
rewardsDistributor: networkConfigToUse.MORPHO_REWARDS_DISTRIBUTOR!,
rewardToken: networkConfigToUse.tokens.MORPHO!,
})

const vaultCode = await ethers.provider.getCode(usdtVault.address)
await setCode(claimer, vaultCode)

Expand All @@ -331,12 +301,6 @@ const makeAaveFiatCollateralTestSuite = (
const underlyingERC20 = erc20Factory.attach(defaultCollateralOpts.underlyingToken!)
const depositAmount = utils.parseUnits('1000', 6)

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,
whales[defaultCollateralOpts.underlyingToken!.toLowerCase()],
Expand All @@ -345,29 +309,34 @@ const makeAaveFiatCollateralTestSuite = (
await underlyingERC20.connect(whaleSigner).transfer(bobAddress, depositAmount.mul(10))
}
)

await vaultWithClaimableRewards.connect(alice).sync()
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)

await underlyingERC20.connect(bob).approve(vaultWithClaimableRewards.address, 0)
await underlyingERC20
.connect(bob)
.approve(vaultWithClaimableRewards.address, ethers.constants.MaxUint256)
await vaultWithClaimableRewards.connect(bob).mint(depositAmount.mul(10), bobAddress)

await vaultWithClaimableRewards.connect(alice).sync()
const morphoRewards = await ethers.getContractAt(
'IMorphoRewardsDistributor',
networkConfigToUse.MORPHO_REWARDS_DISTRIBUTOR!
)
expect(await vaultWithClaimableRewards.callStatic.rewardTokenBalance(bobAddress)).to.be.eq(
bn(0)
)
const startAliceBalance = await vaultWithClaimableRewards.callStatic.rewardTokenBalance(
aliceAddress
)
expect(
await vaultWithClaimableRewards.callStatic.rewardTokenBalance(aliceAddress)
).to.be.eq(bn(0))
await vaultWithClaimableRewards.connect(alice).sync()
await advanceBlocks(hre, 7200)
await vaultWithClaimableRewards.connect(alice).sync()

// Show that rewrads of inflation attacks are limited
await underlyingERC20.connect(bob).approve(vaultWithClaimableRewards.address, 0)
await underlyingERC20
.connect(bob)
.approve(vaultWithClaimableRewards.address, ethers.constants.MaxUint256)
await vaultWithClaimableRewards.connect(bob).mint(depositAmount.mul(10), bobAddress)

await morphoRewards.claim(vaultWithClaimableRewards.address, '14162082619942089266', [
'0x49bb35f20573d5b927c5b5c15c904839cacdf83c6119450ccb6c2ed0647aa71b',
'0xfb9f4530177774effb7af9c1723c7087f60cd135a0cb5f409ec7bbc792a79235',
Expand All @@ -382,7 +351,7 @@ const makeAaveFiatCollateralTestSuite = (
'0x14c512bd39f8b1d13d4cfaad2b4473c4022d01577249ecc97fbf0a64244378ee',
'0xea8c2ee8d43e37ceb7b0c04d59106eff88afbe3e911b656dec7caebd415ea696',
])

await vaultWithClaimableRewards.connect(bob).sync()
await underlyingERC20.connect(bob).approve(vaultWithClaimableRewards.address, 0)
await underlyingERC20
.connect(bob)
Expand All @@ -391,13 +360,13 @@ const makeAaveFiatCollateralTestSuite = (
.connect(bob)
.redeem(depositAmount.mul(10), bobAddress, bobAddress)

const aliceDelta = (
// Shown below is that it is no longer economical to inflate own shares
expect(await vaultWithClaimableRewards.callStatic.rewardTokenBalance(bobAddress)).to.be.eq(
bn('2105730284479525')
)
expect(
await vaultWithClaimableRewards.callStatic.rewardTokenBalance(aliceAddress)
).sub(startAliceBalance)
const bobDelta = await vaultWithClaimableRewards.callStatic.rewardTokenBalance(bobAddress)

// Bob managed to claim almost 10x more rewards than Alice by inflating their shares
expect(bobDelta).to.be.gt(aliceDelta.mul(9))
).to.be.eq(bn('8605480580131125894'))
})
}

Expand All @@ -408,7 +377,9 @@ const makeAaveFiatCollateralTestSuite = (

const opts = {
deployCollateral,
collateralSpecificConstructorTests: collateralSpecificConstructorTests,
collateralSpecificConstructorTests: specificTests
? collateralSpecificConstructorTests
: () => void 0,
collateralSpecificStatusTests,
beforeEachRewardsTest,
makeCollateralFixtureContext,
Expand Down Expand Up @@ -461,7 +432,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

0 comments on commit 02e87a6

Please sign in to comment.