Merge branch 'master' of github.com:OpenZeppelin/openzeppelin-contracts
This commit is contained in:
43
contracts/mocks/ERC20Reentrant.sol
Normal file
43
contracts/mocks/ERC20Reentrant.sol
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.0;
|
||||||
|
|
||||||
|
import "../token/ERC20/ERC20.sol";
|
||||||
|
import "../token/ERC20/extensions/ERC4626.sol";
|
||||||
|
|
||||||
|
contract ERC20Reentrant is ERC20("TEST", "TST") {
|
||||||
|
enum Type {
|
||||||
|
No,
|
||||||
|
Before,
|
||||||
|
After
|
||||||
|
}
|
||||||
|
|
||||||
|
Type private _reenterType;
|
||||||
|
address private _reenterTarget;
|
||||||
|
bytes private _reenterData;
|
||||||
|
|
||||||
|
function scheduleReenter(Type when, address target, bytes calldata data) external {
|
||||||
|
_reenterType = when;
|
||||||
|
_reenterTarget = target;
|
||||||
|
_reenterData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionCall(address target, bytes memory data) public returns (bytes memory) {
|
||||||
|
return Address.functionCall(target, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _beforeTokenTransfer(address from, address to, uint256 amount) internal override {
|
||||||
|
if (_reenterType == Type.Before) {
|
||||||
|
_reenterType = Type.No;
|
||||||
|
functionCall(_reenterTarget, _reenterData);
|
||||||
|
}
|
||||||
|
super._beforeTokenTransfer(from, to, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
|
||||||
|
super._afterTokenTransfer(from, to, amount);
|
||||||
|
if (_reenterType == Type.After) {
|
||||||
|
_reenterType = Type.No;
|
||||||
|
functionCall(_reenterTarget, _reenterData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,14 @@
|
|||||||
const { constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers');
|
const { constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers');
|
||||||
const { expect } = require('chai');
|
const { expect } = require('chai');
|
||||||
|
|
||||||
|
const { Enum } = require('../../../helpers/enums');
|
||||||
|
|
||||||
const ERC20Decimals = artifacts.require('$ERC20DecimalsMock');
|
const ERC20Decimals = artifacts.require('$ERC20DecimalsMock');
|
||||||
const ERC4626 = artifacts.require('$ERC4626');
|
const ERC4626 = artifacts.require('$ERC4626');
|
||||||
const ERC4626OffsetMock = artifacts.require('$ERC4626OffsetMock');
|
const ERC4626OffsetMock = artifacts.require('$ERC4626OffsetMock');
|
||||||
const ERC4626FeesMock = artifacts.require('$ERC4626FeesMock');
|
const ERC4626FeesMock = artifacts.require('$ERC4626FeesMock');
|
||||||
const ERC20ExcessDecimalsMock = artifacts.require('ERC20ExcessDecimalsMock');
|
const ERC20ExcessDecimalsMock = artifacts.require('ERC20ExcessDecimalsMock');
|
||||||
|
const ERC20Reentrant = artifacts.require('$ERC20Reentrant');
|
||||||
|
|
||||||
contract('ERC4626', function (accounts) {
|
contract('ERC4626', function (accounts) {
|
||||||
const [holder, recipient, spender, other, user1, user2] = accounts;
|
const [holder, recipient, spender, other, user1, user2] = accounts;
|
||||||
@ -44,6 +47,178 @@ contract('ERC4626', function (accounts) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reentrancy', async function () {
|
||||||
|
const reenterType = Enum('No', 'Before', 'After');
|
||||||
|
|
||||||
|
const amount = web3.utils.toBN(1000000000000000000);
|
||||||
|
const reenterAmount = web3.utils.toBN(1000000000);
|
||||||
|
let token;
|
||||||
|
let vault;
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
token = await ERC20Reentrant.new();
|
||||||
|
// Use offset 1 so the rate is not 1:1 and we can't possibly confuse assets and shares
|
||||||
|
vault = await ERC4626OffsetMock.new('', '', token.address, 1);
|
||||||
|
// Funds and approval for tests
|
||||||
|
await token.$_mint(holder, amount);
|
||||||
|
await token.$_mint(other, amount);
|
||||||
|
await token.$_approve(holder, vault.address, constants.MAX_UINT256);
|
||||||
|
await token.$_approve(other, vault.address, constants.MAX_UINT256);
|
||||||
|
await token.$_approve(token.address, vault.address, constants.MAX_UINT256);
|
||||||
|
});
|
||||||
|
|
||||||
|
// During a `_deposit`, the vault does `transferFrom(depositor, vault, assets)` -> `_mint(receiver, shares)`
|
||||||
|
// such that a reentrancy BEFORE the transfer guarantees the price is kept the same.
|
||||||
|
// If the order of transfer -> mint is changed to mint -> transfer, the reentrancy could be triggered on an
|
||||||
|
// intermediate state in which the ratio of assets/shares has been decreased (more shares than assets).
|
||||||
|
it('correct share price is observed during reentrancy before deposit', async function () {
|
||||||
|
// mint token for deposit
|
||||||
|
await token.$_mint(token.address, reenterAmount);
|
||||||
|
|
||||||
|
// Schedules a reentrancy from the token contract
|
||||||
|
await token.scheduleReenter(
|
||||||
|
reenterType.Before,
|
||||||
|
vault.address,
|
||||||
|
vault.contract.methods.deposit(reenterAmount, holder).encodeABI(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial share price
|
||||||
|
const sharesForDeposit = await vault.previewDeposit(amount, { from: holder });
|
||||||
|
const sharesForReenter = await vault.previewDeposit(reenterAmount, { from: holder });
|
||||||
|
|
||||||
|
// Do deposit normally, triggering the _beforeTokenTransfer hook
|
||||||
|
const receipt = await vault.deposit(amount, holder, { from: holder });
|
||||||
|
|
||||||
|
// Main deposit event
|
||||||
|
await expectEvent(receipt, 'Deposit', {
|
||||||
|
sender: holder,
|
||||||
|
owner: holder,
|
||||||
|
assets: amount,
|
||||||
|
shares: sharesForDeposit,
|
||||||
|
});
|
||||||
|
// Reentrant deposit event → uses the same price
|
||||||
|
await expectEvent(receipt, 'Deposit', {
|
||||||
|
sender: token.address,
|
||||||
|
owner: holder,
|
||||||
|
assets: reenterAmount,
|
||||||
|
shares: sharesForReenter,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert prices is kept
|
||||||
|
const sharesAfter = await vault.previewDeposit(amount, { from: holder });
|
||||||
|
expect(sharesForDeposit).to.be.bignumber.eq(sharesAfter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// During a `_withdraw`, the vault does `_burn(owner, shares)` -> `transfer(receiver, assets)`
|
||||||
|
// such that a reentrancy AFTER the transfer guarantees the price is kept the same.
|
||||||
|
// If the order of burn -> transfer is changed to transfer -> burn, the reentrancy could be triggered on an
|
||||||
|
// intermediate state in which the ratio of shares/assets has been decreased (more assets than shares).
|
||||||
|
it('correct share price is observed during reentrancy after withdraw', async function () {
|
||||||
|
// Deposit into the vault: holder gets `amount` share, token.address gets `reenterAmount` shares
|
||||||
|
await vault.deposit(amount, holder, { from: holder });
|
||||||
|
await vault.deposit(reenterAmount, token.address, { from: other });
|
||||||
|
|
||||||
|
// Schedules a reentrancy from the token contract
|
||||||
|
await token.scheduleReenter(
|
||||||
|
reenterType.After,
|
||||||
|
vault.address,
|
||||||
|
vault.contract.methods.withdraw(reenterAmount, holder, token.address).encodeABI(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial share price
|
||||||
|
const sharesForWithdraw = await vault.previewWithdraw(amount, { from: holder });
|
||||||
|
const sharesForReenter = await vault.previewWithdraw(reenterAmount, { from: holder });
|
||||||
|
|
||||||
|
// Do withdraw normally, triggering the _afterTokenTransfer hook
|
||||||
|
const receipt = await vault.withdraw(amount, holder, holder, { from: holder });
|
||||||
|
|
||||||
|
// Main withdraw event
|
||||||
|
await expectEvent(receipt, 'Withdraw', {
|
||||||
|
sender: holder,
|
||||||
|
receiver: holder,
|
||||||
|
owner: holder,
|
||||||
|
assets: amount,
|
||||||
|
shares: sharesForWithdraw,
|
||||||
|
});
|
||||||
|
// Reentrant withdraw event → uses the same price
|
||||||
|
await expectEvent(receipt, 'Withdraw', {
|
||||||
|
sender: token.address,
|
||||||
|
receiver: holder,
|
||||||
|
owner: token.address,
|
||||||
|
assets: reenterAmount,
|
||||||
|
shares: sharesForReenter,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert price is kept
|
||||||
|
const sharesAfter = await vault.previewWithdraw(amount, { from: holder });
|
||||||
|
expect(sharesForWithdraw).to.be.bignumber.eq(sharesAfter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Donate newly minted tokens to the vault during the reentracy causes the share price to increase.
|
||||||
|
// Still, the deposit that trigger the reentracy is not affected and get the previewed price.
|
||||||
|
// Further deposits will get a different price (getting fewer shares for the same amount of assets)
|
||||||
|
it('share price change during reentracy does not affect deposit', async function () {
|
||||||
|
// Schedules a reentrancy from the token contract that mess up the share price
|
||||||
|
await token.scheduleReenter(
|
||||||
|
reenterType.Before,
|
||||||
|
token.address,
|
||||||
|
token.contract.methods.$_mint(vault.address, reenterAmount).encodeABI(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Price before
|
||||||
|
const sharesBefore = await vault.previewDeposit(amount);
|
||||||
|
|
||||||
|
// Deposit, triggering the _beforeTokenTransfer hook
|
||||||
|
const receipt = await vault.deposit(amount, holder, { from: holder });
|
||||||
|
|
||||||
|
// Price is as previewed
|
||||||
|
await expectEvent(receipt, 'Deposit', {
|
||||||
|
sender: holder,
|
||||||
|
owner: holder,
|
||||||
|
assets: amount,
|
||||||
|
shares: sharesBefore,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Price was modified during reentrancy
|
||||||
|
const sharesAfter = await vault.previewDeposit(amount);
|
||||||
|
expect(sharesAfter).to.be.bignumber.lt(sharesBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Burn some tokens from the vault during the reentracy causes the share price to drop.
|
||||||
|
// Still, the withdraw that trigger the reentracy is not affected and get the previewed price.
|
||||||
|
// Further withdraw will get a different price (needing more shares for the same amount of assets)
|
||||||
|
it('share price change during reentracy does not affect withdraw', async function () {
|
||||||
|
await vault.deposit(amount, other, { from: other });
|
||||||
|
await vault.deposit(amount, holder, { from: holder });
|
||||||
|
|
||||||
|
// Schedules a reentrancy from the token contract that mess up the share price
|
||||||
|
await token.scheduleReenter(
|
||||||
|
reenterType.After,
|
||||||
|
token.address,
|
||||||
|
token.contract.methods.$_burn(vault.address, reenterAmount).encodeABI(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Price before
|
||||||
|
const sharesBefore = await vault.previewWithdraw(amount);
|
||||||
|
|
||||||
|
// Withdraw, triggering the _afterTokenTransfer hook
|
||||||
|
const receipt = await vault.withdraw(amount, holder, holder, { from: holder });
|
||||||
|
|
||||||
|
// Price is as previewed
|
||||||
|
await expectEvent(receipt, 'Withdraw', {
|
||||||
|
sender: holder,
|
||||||
|
receiver: holder,
|
||||||
|
owner: holder,
|
||||||
|
assets: amount,
|
||||||
|
shares: sharesBefore,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Price was modified during reentrancy
|
||||||
|
const sharesAfter = await vault.previewWithdraw(amount);
|
||||||
|
expect(sharesAfter).to.be.bignumber.gt(sharesBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for (const offset of [0, 6, 18].map(web3.utils.toBN)) {
|
for (const offset of [0, 6, 18].map(web3.utils.toBN)) {
|
||||||
const parseToken = token => web3.utils.toBN(10).pow(decimals).muln(token);
|
const parseToken = token => web3.utils.toBN(10).pow(decimals).muln(token);
|
||||||
const parseShare = share => web3.utils.toBN(10).pow(decimals.add(offset)).muln(share);
|
const parseShare = share => web3.utils.toBN(10).pow(decimals.add(offset)).muln(share);
|
||||||
|
|||||||
Reference in New Issue
Block a user