Skip to content

Commit

Permalink
fix: fee-on-transfer token (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
0xJabberwock authored Jul 11, 2024
1 parent 7b31b79 commit cad5c3b
Show file tree
Hide file tree
Showing 6 changed files with 112 additions and 21 deletions.
6 changes: 3 additions & 3 deletions docs/src/content/extensions/accounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The Accounting Extension is a contract that allows users to deposit and bond fun

## 3. Key Mechanisms & Concepts

- **Deposits**: Users can deposit tokens into the Accounting Extension. These deposits are tracked in a mapping that associates each user's address with their balance of each token. Deposits can be made in any ERC20 token, including wrapped Ether (WETH).
- **Deposits**: Users can deposit tokens into the Accounting Extension. These deposits are tracked in a mapping that associates each user's address with their balance of each token. Deposits can be made in many ERC20 tokens, including wrapped Ether (WETH).

- **Withdrawals**: Users can withdraw their deposited tokens at any time, provided they have sufficient balance. The withdrawal operation reduces the user's balance in the Accounting Extension and transfers the tokens back to the user's address. Locked tokens can't be withdrawn until they're released by a module.

Expand All @@ -31,5 +31,5 @@ The Accounting Extension is a contract that allows users to deposit and bond fun
## 4. Gotchas

- Before depositing ERC20 tokens, users must first approve the Accounting Extension to transfer the tokens on their behalf.
- Users can only withdraw tokens that are not currently bonded. If a user has bonded tokens for a request, those tokens are locked until they are released by an allowed module. Attempting to withdraw bonded tokens will result in an error. Attempting to slash or pay out tokens that are not locked will also result in a transaction being reverted.
- The contract supports any ERC20 token, including wrapped Ether (WETH). However, if a user tries to deposit a non-ERC20 token or a token that the contract otherwise doesn't support, the transaction will fail.
- Users can only withdraw tokens that are not currently bonded. If a user has bonded tokens for a request, those tokens are locked until they are released by an allowed module. Attempting to withdraw bonded tokens will result in an error. Attempting to slash or pay out tokens that are not locked will also result in a transaction being reverted.
- The contract supports many ERC20 tokens, including wrapped Ether (WETH). However, if a user tries to deposit a ERC20 token with a fee on transfer, a non-ERC20 token or a token that the contract otherwise doesn't support, the transaction will fail.
4 changes: 3 additions & 1 deletion docs/src/content/extensions/bond_escalation_accounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ The `BondEscalationAccounting` contract is an extension that allows users to dep

- Pledging: Users can pledge tokens for or against a dispute. The pledged tokens are locked and cannot be used until the dispute is resolved.

- Deposits: Users can deposit tokens into the extension. Deposits can be made in any ERC20 token, ETH deposits will be converted to WETH.
- Deposits: Users can deposit tokens into the extension. Deposits can be made in many ERC20 tokens, ETH deposits will be converted to WETH.

- Withdrawals: Users can withdraw their tokens at any time. The withdrawal operation reduces the user's balance in the extension and transfers the tokens back to the user's address. Locked tokens can't be withdrawn until they're released by a module.

## 4. Gotchas

- Before depositing ERC20 tokens, users must first approve the extension to transfer the tokens on their behalf.
- Users can only withdraw tokens that are not currently bonded. If a user has bonded tokens for a request, those tokens are locked until they are released by an allowed module. Attempting to withdraw bonded tokens will result in an error. Attempting to slash or pay out tokens that are not locked will also result in a transaction being reverted.
- The contract supports many ERC20 tokens, including wrapped Ether (WETH). However, if a user tries to deposit a ERC20 token with a fee on transfer, a non-ERC20 token or a token that the contract otherwise doesn't support, the transaction will fail.
6 changes: 6 additions & 0 deletions solidity/contracts/extensions/AccountingExtension.sol
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ contract AccountingExtension is IAccountingExtension {

/// @inheritdoc IAccountingExtension
function deposit(IERC20 _token, uint256 _amount) external {
uint256 _balance = _token.balanceOf(address(this));

_token.safeTransferFrom(msg.sender, address(this), _amount);

if (_amount != _token.balanceOf(address(this)) - _balance) revert AccountingExtension_FeeOnTransferToken();

balanceOf[msg.sender][_token] += _amount;

emit Deposited(msg.sender, _token, _amount);
}

Expand Down
6 changes: 6 additions & 0 deletions solidity/interfaces/extensions/IAccountingExtension.sol
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ interface IAccountingExtension {
ERRORS
//////////////////////////////////////////////////////////////*/

/**
* @notice Thrown when depositing tokens with a fee on transfer
*/
error AccountingExtension_FeeOnTransferToken();

/**
* @notice Thrown when the account doesn't have enough balance to bond/withdraw
* or not enough bonded to release/pay
Expand Down Expand Up @@ -121,6 +126,7 @@ interface IAccountingExtension {
/**
* @notice Transfers tokens from a user and updates his virtual balance
* @dev The user must have approved the accounting extension to transfer the tokens.
* The transfer must not take a fee (deflationary tokens can lead to unexpected behavior).
* @param _token The address of the token being deposited
* @param _amount The amount of `_token` to deposit
*/
Expand Down
38 changes: 38 additions & 0 deletions solidity/test/mocks/MockERC20Proxy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol';

contract MockERC20Proxy {
IERC20 public token;

mapping(uint256 _callCount => mapping(address _account => uint256 _amount)) internal _balancesPerCall;
uint256 internal _calls;
bool internal _mocked;

constructor(IERC20 _token) {
token = _token;
}

function mockBalanceOfPerCall(uint256 _callCount, address _account, uint256 _amount) external {
_balancesPerCall[_callCount][_account] = _amount;
_mocked = true;
}

function balanceOf(address _account) external view returns (uint256 _amount) {
if (_mocked) {
_amount = _balancesPerCall[_calls][_account];
} else {
_amount = token.balanceOf(_account);
}
}

// solhint-disable-next-line
fallback() external {
if (_mocked) {
++_calls;
}
(bool _success,) = address(token).call(msg.data);
require(_success, 'MockERC20Proxy: call failed');
}
}
73 changes: 56 additions & 17 deletions solidity/test/unit/extensions/AccountingExtension.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
import {IOracle} from '@defi-wonderland/prophet-core-contracts/solidity/contracts/Oracle.sol';
import {EnumerableSet} from '@openzeppelin/contracts/utils/structs/EnumerableSet.sol';

import {MockERC20Proxy} from '../../mocks/MockERC20Proxy.sol';

contract ForTest_AccountingExtension is AccountingExtension {
using EnumerableSet for EnumerableSet.AddressSet;

Expand Down Expand Up @@ -50,52 +52,89 @@ contract BaseTest is Test, Helpers {
/**
* @notice Deploy the target and mock oracle extension
*/
function setUp() public {
oracle = IOracle(makeAddr('Oracle'));
vm.etch(address(oracle), hex'069420');

token = IERC20(makeAddr('Token'));
vm.etch(address(token), hex'069420');
function setUp() public virtual {
oracle = IOracle(_mockContract('Oracle'));
token = IERC20(_mockContract('Token'));

extension = new ForTest_AccountingExtension(oracle);
}
}

contract AccountingExtension_Unit_DepositAndWithdraw is BaseTest {
MockERC20Proxy public tokenProxy;

/**
* @notice Deploy a token proxy
*/
function setUp() public override {
super.setUp();

tokenProxy = new MockERC20Proxy(token);
}

/**
* @notice Test an ERC20 deposit
*/
function test_depositERC20(uint256 _amount) public {
function test_depositERC20(uint256 _amount, uint256 _initialBalance) public {
// Mock and expect the ERC20 balance
_initialBalance = bound(_initialBalance, 0, type(uint256).max - _amount);
tokenProxy.mockBalanceOfPerCall(0, address(extension), _initialBalance);
tokenProxy.mockBalanceOfPerCall(1, address(extension), _initialBalance + _amount);
vm.expectCall(address(tokenProxy), abi.encodeCall(IERC20.balanceOf, (address(extension))), 2);

// Mock and expect the ERC20 transfer
_mockAndExpect(
address(token), abi.encodeCall(IERC20.transferFrom, (sender, address(extension), _amount)), abi.encode(true)
);

// Expect the event
vm.expectEmit(true, true, true, true, address(extension));
emit Deposited(sender, token, _amount);
_expectEmit(address(extension));
emit Deposited(sender, IERC20(address(tokenProxy)), _amount);

vm.prank(sender);
extension.deposit(token, _amount);
extension.deposit(IERC20(address(tokenProxy)), _amount);

// Check: balance of token deposit increased?
assertEq(extension.balanceOf(sender, token), _amount);
assertEq(extension.balanceOf(sender, IERC20(address(tokenProxy))), _amount);
}

/**
* @notice Should revert if token takes a fee on transfer
*/
function test_depositRevert(uint256 _amount, uint256 _fee, uint256 _initialBalance) public {
vm.assume(_amount >= _fee);
vm.assume(_fee > 0);

// Mock and expect the ERC20 balance
_initialBalance = bound(_initialBalance, 0, type(uint256).max - (_amount - _fee));
tokenProxy.mockBalanceOfPerCall(0, address(extension), _initialBalance);
tokenProxy.mockBalanceOfPerCall(1, address(extension), _initialBalance + (_amount - _fee));
vm.expectCall(address(tokenProxy), abi.encodeCall(IERC20.balanceOf, (address(extension))), 2);

// Mock and expect the ERC20 transfer
_mockAndExpect(
address(token), abi.encodeCall(IERC20.transferFrom, (sender, address(extension), _amount)), abi.encode(true)
);

// Check: does it revert if token takes a fee on transfer?
vm.expectRevert(IAccountingExtension.AccountingExtension_FeeOnTransferToken.selector);
vm.prank(sender);
extension.deposit(IERC20(address(tokenProxy)), _amount);
}

/**
* @notice Test withdrawing ERC20. Should update balance and emit event
*/
function test_withdrawERC20(uint256 _amount, uint256 _initialBalance) public {
vm.assume(_amount > 0);

// Set the initial balance
_initialBalance = bound(_initialBalance, _amount, type(uint256).max);
extension.forTest_setBalanceOf(sender, token, _initialBalance);

// Mock and expect the ERC20 transfer
_mockAndExpect(address(token), abi.encodeCall(IERC20.transfer, (sender, _amount)), abi.encode(true));

// Expect the event
vm.expectEmit(true, true, true, true, address(extension));
_expectEmit(address(extension));
emit Withdrew(sender, token, _amount);

vm.prank(sender);
Expand Down Expand Up @@ -148,7 +187,7 @@ contract AccountingExtension_Unit_Bond is BaseTest {
extension.forTest_setBalanceOf(_bonder, token, _initialBalance);

// Check: is the event emitted?
vm.expectEmit(true, true, true, true, address(extension));
_expectEmit(address(extension));
emit Bonded(_requestId, _bonder, token, _amount);

vm.prank(_sender);
Expand Down Expand Up @@ -283,7 +322,7 @@ contract AccountingExtension_Unit_Pay is BaseTest {
extension.forTest_setBondedBalanceOf(_requestId, _payer, token, _initialBalance);

// Check: is the event emitted?
vm.expectEmit(true, true, true, true, address(extension));
_expectEmit(address(extension));
emit Paid(_requestId, _receiver, _payer, token, _amount);

vm.prank(_sender);
Expand Down Expand Up @@ -375,7 +414,7 @@ contract AccountingExtension_Unit_Release is BaseTest {
extension.forTest_setBondedBalanceOf(_requestId, _bonder, token, _initialBalance);

// Check: is the event emitted?
vm.expectEmit(true, true, true, true, address(extension));
_expectEmit(address(extension));
emit Released(_requestId, _bonder, token, _amount);

vm.prank(_sender);
Expand Down

0 comments on commit cad5c3b

Please sign in to comment.