// 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); } }