feat: add V1 custody accounting
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity 0.8.35;
|
||||
|
||||
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
|
||||
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
|
||||
import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
|
||||
import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol";
|
||||
|
||||
import {BankV1} from "../src/BankV1.sol";
|
||||
import {BankTestBase} from "./helpers/BankTestBase.sol";
|
||||
import {FeeOnTransferToken} from "./mocks/FeeOnTransferToken.sol";
|
||||
import {ReentrantToken} from "./mocks/ReentrantToken.sol";
|
||||
|
||||
event Deposited(address indexed account, uint256 amount);
|
||||
event Withdrawn(address indexed account, uint256 amount);
|
||||
|
||||
error ZeroAmount();
|
||||
error InsufficientBalance(address account, uint256 available, uint256 requested);
|
||||
error UnexpectedAssetDelta(uint256 expected, uint256 actual);
|
||||
|
||||
contract BankV1CustodyTest is BankTestBase {
|
||||
uint256 private constant MAX_DEPOSIT = 1_000_000e6;
|
||||
|
||||
function testDepositCreditsExactCustomerAndLiabilityAgainstReceivedReserves() public {
|
||||
_mintAndApprove(alice, 1_000e6, 100e6);
|
||||
|
||||
vm.prank(alice);
|
||||
bank.deposit(100e6);
|
||||
|
||||
assertEq(token.balanceOf(alice), 900e6);
|
||||
assertEq(token.balanceOf(proxy), 100e6);
|
||||
assertEq(bank.balanceOf(alice), 100e6);
|
||||
assertEq(bank.totalLiabilities(), 100e6);
|
||||
}
|
||||
|
||||
function testDepositEmitsDepositedEvent() public {
|
||||
_mintAndApprove(alice, 100e6, 100e6);
|
||||
|
||||
vm.expectEmit(true, false, false, true, proxy);
|
||||
emit Deposited(alice, 100e6);
|
||||
vm.prank(alice);
|
||||
bank.deposit(100e6);
|
||||
}
|
||||
|
||||
function testDepositRejectsZeroAmount() public {
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(ZeroAmount.selector);
|
||||
bank.deposit(0);
|
||||
}
|
||||
|
||||
function testDepositRejectsCallsWhilePaused() public {
|
||||
_mintAndApprove(alice, 100e6, 100e6);
|
||||
vm.prank(owner);
|
||||
bank.pause();
|
||||
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(PausableUpgradeable.EnforcedPause.selector);
|
||||
bank.deposit(100e6);
|
||||
}
|
||||
|
||||
function testDepositRollsBackWhenAllowanceIsInadequate() public {
|
||||
_mintAndApprove(alice, 100e6, 99e6);
|
||||
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, proxy, 99e6, 100e6));
|
||||
bank.deposit(100e6);
|
||||
|
||||
_assertEmptyAccounting(alice);
|
||||
assertEq(token.balanceOf(alice), 100e6);
|
||||
}
|
||||
|
||||
function testDepositRollsBackWhenWalletBalanceIsInadequate() public {
|
||||
_mintAndApprove(alice, 99e6, 100e6);
|
||||
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 99e6, 100e6));
|
||||
bank.deposit(100e6);
|
||||
|
||||
_assertEmptyAccounting(alice);
|
||||
assertEq(token.balanceOf(alice), 99e6);
|
||||
}
|
||||
|
||||
function testDepositsKeepTwoCustomersAccountingIndependent() public {
|
||||
_mintAndApprove(alice, 1_000e6, 300e6);
|
||||
_mintAndApprove(bob, 1_000e6, 700e6);
|
||||
|
||||
vm.prank(alice);
|
||||
bank.deposit(300e6);
|
||||
vm.prank(bob);
|
||||
bank.deposit(700e6);
|
||||
|
||||
assertEq(bank.balanceOf(alice), 300e6);
|
||||
assertEq(bank.balanceOf(bob), 700e6);
|
||||
assertEq(bank.totalLiabilities(), 1_000e6);
|
||||
assertEq(token.balanceOf(proxy), 1_000e6);
|
||||
}
|
||||
|
||||
function testFeeOnTransferDepositRevertsAndRollsBackTokenAndAccounting() public {
|
||||
FeeOnTransferToken feeToken = new FeeOnTransferToken();
|
||||
BankV1 feeBank = _deployBank(address(feeToken));
|
||||
feeToken.mint(alice, 100e6);
|
||||
vm.prank(alice);
|
||||
feeToken.approve(address(feeBank), 100e6);
|
||||
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(abi.encodeWithSelector(UnexpectedAssetDelta.selector, 100e6, 99e6));
|
||||
feeBank.deposit(100e6);
|
||||
|
||||
assertEq(feeToken.balanceOf(alice), 100e6);
|
||||
assertEq(feeToken.balanceOf(address(feeBank)), 0);
|
||||
assertEq(feeBank.balanceOf(alice), 0);
|
||||
assertEq(feeBank.totalLiabilities(), 0);
|
||||
}
|
||||
|
||||
function testDepositSwallowsNestedRevertAndCreditsOnlyOnce() public {
|
||||
ReentrantToken reentrantToken = new ReentrantToken();
|
||||
BankV1 reentrantBank = _deployBank(address(reentrantToken));
|
||||
reentrantToken.mint(alice, 100e6);
|
||||
vm.prank(alice);
|
||||
reentrantToken.approve(address(reentrantBank), 100e6);
|
||||
reentrantToken.configureDepositCallback(address(reentrantBank), false);
|
||||
|
||||
vm.prank(alice);
|
||||
reentrantBank.deposit(100e6);
|
||||
|
||||
assertTrue(reentrantToken.nestedCallAttempted());
|
||||
assertFalse(reentrantToken.nestedCallSucceeded());
|
||||
assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
|
||||
assertEq(reentrantToken.observedAccountBalance(), 0);
|
||||
assertEq(reentrantToken.observedLiabilities(), 0);
|
||||
assertEq(reentrantToken.balanceOf(alice), 0);
|
||||
assertEq(reentrantToken.balanceOf(address(reentrantBank)), 100e6);
|
||||
assertEq(reentrantBank.balanceOf(alice), 100e6);
|
||||
assertEq(reentrantBank.totalLiabilities(), 100e6);
|
||||
}
|
||||
|
||||
function testDepositPropagatesNestedRevertAtomicallyWhenConfigured() public {
|
||||
ReentrantToken reentrantToken = new ReentrantToken();
|
||||
BankV1 reentrantBank = _deployBank(address(reentrantToken));
|
||||
reentrantToken.mint(alice, 100e6);
|
||||
vm.prank(alice);
|
||||
reentrantToken.approve(address(reentrantBank), 100e6);
|
||||
reentrantToken.configureDepositCallback(address(reentrantBank), true);
|
||||
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
|
||||
reentrantBank.deposit(100e6);
|
||||
|
||||
assertEq(reentrantToken.balanceOf(alice), 100e6);
|
||||
assertEq(reentrantToken.balanceOf(address(reentrantBank)), 0);
|
||||
assertEq(reentrantBank.balanceOf(alice), 0);
|
||||
assertEq(reentrantBank.totalLiabilities(), 0);
|
||||
}
|
||||
|
||||
function testWithdrawDebitsExactCustomerLiabilityAndReserves() public {
|
||||
_deposit(alice, 1_000e6);
|
||||
|
||||
vm.prank(alice);
|
||||
bank.withdraw(400e6);
|
||||
|
||||
assertEq(token.balanceOf(alice), 400e6);
|
||||
assertEq(token.balanceOf(proxy), 600e6);
|
||||
assertEq(bank.balanceOf(alice), 600e6);
|
||||
assertEq(bank.totalLiabilities(), 600e6);
|
||||
}
|
||||
|
||||
function testWithdrawEmitsWithdrawnEvent() public {
|
||||
_deposit(alice, 100e6);
|
||||
|
||||
vm.expectEmit(true, false, false, true, proxy);
|
||||
emit Withdrawn(alice, 40e6);
|
||||
vm.prank(alice);
|
||||
bank.withdraw(40e6);
|
||||
}
|
||||
|
||||
function testWithdrawRejectsZeroAmount() public {
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(ZeroAmount.selector);
|
||||
bank.withdraw(0);
|
||||
}
|
||||
|
||||
function testWithdrawRejectsCallsWhilePaused() public {
|
||||
_deposit(alice, 100e6);
|
||||
vm.prank(owner);
|
||||
bank.pause();
|
||||
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(PausableUpgradeable.EnforcedPause.selector);
|
||||
bank.withdraw(100e6);
|
||||
}
|
||||
|
||||
function testWithdrawReportsAvailableAndRequestedOnInsufficientInternalBalance() public {
|
||||
_deposit(alice, 40e6);
|
||||
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, 40e6, 41e6));
|
||||
bank.withdraw(41e6);
|
||||
|
||||
assertEq(bank.balanceOf(alice), 40e6);
|
||||
assertEq(bank.totalLiabilities(), 40e6);
|
||||
assertEq(token.balanceOf(proxy), 40e6);
|
||||
}
|
||||
|
||||
function testWithdrawUpdatesAccountingBeforeTransferCallbackAndCannotDoubleDebit() public {
|
||||
ReentrantToken reentrantToken = new ReentrantToken();
|
||||
BankV1 reentrantBank = _deployBank(address(reentrantToken));
|
||||
reentrantToken.mint(alice, 100e6);
|
||||
vm.prank(alice);
|
||||
reentrantToken.approve(address(reentrantBank), 100e6);
|
||||
vm.prank(alice);
|
||||
reentrantBank.deposit(100e6);
|
||||
reentrantToken.configureWithdrawalCallback(address(reentrantBank), false);
|
||||
|
||||
vm.prank(alice);
|
||||
reentrantBank.withdraw(40e6);
|
||||
|
||||
assertEq(reentrantToken.observedAccountBalance(), 60e6);
|
||||
assertEq(reentrantToken.observedLiabilities(), 60e6);
|
||||
assertTrue(reentrantToken.nestedCallAttempted());
|
||||
assertFalse(reentrantToken.nestedCallSucceeded());
|
||||
assertEq(reentrantToken.nestedRevertSelector(), ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector);
|
||||
assertEq(reentrantToken.balanceOf(alice), 40e6);
|
||||
assertEq(reentrantToken.balanceOf(address(reentrantBank)), 60e6);
|
||||
assertEq(reentrantBank.balanceOf(alice), 60e6);
|
||||
assertEq(reentrantBank.totalLiabilities(), 60e6);
|
||||
}
|
||||
|
||||
function testOneCustomersWithdrawalLeavesOtherCustomerUnchanged() public {
|
||||
_deposit(alice, 100e6);
|
||||
_deposit(bob, 200e6);
|
||||
|
||||
vm.prank(alice);
|
||||
bank.withdraw(40e6);
|
||||
|
||||
assertEq(bank.balanceOf(alice), 60e6);
|
||||
assertEq(bank.balanceOf(bob), 200e6);
|
||||
assertEq(bank.totalLiabilities(), 260e6);
|
||||
assertEq(token.balanceOf(proxy), 260e6);
|
||||
}
|
||||
|
||||
function testDirectTransferCreatesSurplusThatRemainsAfterFullWithdrawal() public {
|
||||
_deposit(alice, 100e6);
|
||||
vm.prank(owner);
|
||||
token.mint(bob, 25e6);
|
||||
vm.prank(bob);
|
||||
token.transfer(proxy, 25e6);
|
||||
|
||||
assertEq(token.balanceOf(proxy), 125e6);
|
||||
assertEq(bank.totalLiabilities(), 100e6);
|
||||
|
||||
vm.prank(alice);
|
||||
bank.withdraw(100e6);
|
||||
|
||||
assertEq(token.balanceOf(proxy), 25e6);
|
||||
assertEq(bank.balanceOf(alice), 0);
|
||||
assertEq(bank.totalLiabilities(), 0);
|
||||
}
|
||||
|
||||
function testFuzzDepositPreservesExactAccounting(uint256 amountSeed) public {
|
||||
uint256 amount = bound(amountSeed, 1, MAX_DEPOSIT);
|
||||
_mintAndApprove(alice, amount, amount);
|
||||
|
||||
vm.prank(alice);
|
||||
bank.deposit(amount);
|
||||
|
||||
assertEq(token.balanceOf(alice), 0);
|
||||
assertEq(token.balanceOf(proxy), amount);
|
||||
assertEq(bank.balanceOf(alice), amount);
|
||||
assertEq(bank.totalLiabilities(), amount);
|
||||
}
|
||||
|
||||
function testFuzzWithdrawPreservesExactAccounting(uint256 depositSeed, uint256 withdrawalSeed) public {
|
||||
uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT);
|
||||
uint256 withdrawn = bound(withdrawalSeed, 1, deposited);
|
||||
_deposit(alice, deposited);
|
||||
|
||||
vm.prank(alice);
|
||||
bank.withdraw(withdrawn);
|
||||
|
||||
uint256 remaining = deposited - withdrawn;
|
||||
assertEq(token.balanceOf(alice), withdrawn);
|
||||
assertEq(token.balanceOf(proxy), remaining);
|
||||
assertEq(bank.balanceOf(alice), remaining);
|
||||
assertEq(bank.totalLiabilities(), remaining);
|
||||
}
|
||||
|
||||
function testFuzzOverWithdrawAlwaysReverts(uint256 depositSeed, uint256 excessSeed) public {
|
||||
uint256 deposited = bound(depositSeed, 1, MAX_DEPOSIT);
|
||||
uint256 excess = bound(excessSeed, 1, MAX_DEPOSIT);
|
||||
uint256 requested = deposited + excess;
|
||||
_deposit(alice, deposited);
|
||||
|
||||
vm.prank(alice);
|
||||
vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, alice, deposited, requested));
|
||||
bank.withdraw(requested);
|
||||
|
||||
assertEq(bank.balanceOf(alice), deposited);
|
||||
assertEq(bank.totalLiabilities(), deposited);
|
||||
assertEq(token.balanceOf(proxy), deposited);
|
||||
}
|
||||
|
||||
function _mintAndApprove(address account, uint256 mintAmount, uint256 approveAmount) private {
|
||||
vm.prank(owner);
|
||||
token.mint(account, mintAmount);
|
||||
vm.prank(account);
|
||||
token.approve(proxy, approveAmount);
|
||||
}
|
||||
|
||||
function _deposit(address account, uint256 amount) private {
|
||||
_mintAndApprove(account, amount, amount);
|
||||
vm.prank(account);
|
||||
bank.deposit(amount);
|
||||
}
|
||||
|
||||
function _deployBank(address asset_) private returns (BankV1 deployedBank) {
|
||||
address deployedProxy =
|
||||
Upgrades.deployUUPSProxy("BankV1.sol:BankV1", abi.encodeCall(BankV1.initialize, (asset_, owner)));
|
||||
deployedBank = BankV1(deployedProxy);
|
||||
}
|
||||
|
||||
function _assertEmptyAccounting(address account) private view {
|
||||
assertEq(token.balanceOf(proxy), 0);
|
||||
assertEq(bank.balanceOf(account), 0);
|
||||
assertEq(bank.totalLiabilities(), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user