Completed clean root directory structure: - Root now contains only: .git, .env, docs/, orig/ - Moved all remaining files and directories to orig/: - Config files (.claude, .dockerignore, .drone.yml, etc.) - All .env variants (except active .env) - Git config (.gitconfig, .github, .gitignore, etc.) - Tool configs (.golangci.yml, .revive.toml, etc.) - Documentation (*.md files, @prompts) - Build files (Dockerfiles, Makefile, go.mod, go.sum) - Docker compose files - All source directories (scripts, tests, tools, etc.) - Runtime directories (logs, monitoring, reports) - Dependency files (node_modules, lib, cache) - Special files (--delete) - Removed empty runtime directories (bin/, data/) V2 structure is now clean: - docs/planning/ - V2 planning documents - orig/ - Complete V1 codebase preserved - .env - Active environment config (not in git) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
452 lines
15 KiB
Solidity
452 lines
15 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity ^0.8.0;
|
|
|
|
import "forge-std/Test.sol";
|
|
import "../../contracts/balancer/FlashLoanReceiverSecure.sol";
|
|
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
|
|
/// @title Mock ERC20 Token for Testing
|
|
contract MockERC20 is ERC20 {
|
|
constructor() ERC20("Mock Token", "MOCK") {
|
|
_mint(msg.sender, 1000000 * 10**18);
|
|
}
|
|
|
|
function mint(address to, uint256 amount) external {
|
|
_mint(to, amount);
|
|
}
|
|
}
|
|
|
|
/// @title Mock Balancer Vault for Testing
|
|
contract MockBalancerVault {
|
|
function flashLoan(
|
|
address recipient,
|
|
address[] memory tokens,
|
|
uint256[] memory amounts,
|
|
bytes memory userData
|
|
) external {
|
|
// Transfer tokens to recipient
|
|
for (uint i = 0; i < tokens.length; i++) {
|
|
IERC20(tokens[i]).transfer(recipient, amounts[i]);
|
|
}
|
|
|
|
// Call receiveFlashLoan
|
|
uint256[] memory feeAmounts = new uint256[](tokens.length);
|
|
for (uint i = 0; i < tokens.length; i++) {
|
|
feeAmounts[i] = 0; // Balancer has 0 fees
|
|
}
|
|
|
|
IFlashLoanRecipient(recipient).receiveFlashLoan(
|
|
_convertToIERC20Array(tokens),
|
|
amounts,
|
|
feeAmounts,
|
|
userData
|
|
);
|
|
|
|
// Verify repayment
|
|
for (uint i = 0; i < tokens.length; i++) {
|
|
require(
|
|
IERC20(tokens[i]).balanceOf(address(this)) >= amounts[i],
|
|
"Flash loan not repaid"
|
|
);
|
|
}
|
|
}
|
|
|
|
function _convertToIERC20Array(address[] memory tokens)
|
|
private
|
|
pure
|
|
returns (IERC20[] memory)
|
|
{
|
|
IERC20[] memory ierc20Array = new IERC20[](tokens.length);
|
|
for (uint i = 0; i < tokens.length; i++) {
|
|
ierc20Array[i] = IERC20(tokens[i]);
|
|
}
|
|
return ierc20Array;
|
|
}
|
|
}
|
|
|
|
interface IFlashLoanRecipient {
|
|
function receiveFlashLoan(
|
|
IERC20[] memory tokens,
|
|
uint256[] memory amounts,
|
|
uint256[] memory feeAmounts,
|
|
bytes memory userData
|
|
) external;
|
|
}
|
|
|
|
/// @title Comprehensive Test Suite for FlashLoanReceiverSecure
|
|
contract FlashLoanReceiverSecureTest is Test {
|
|
FlashLoanReceiverSecure public flashLoan;
|
|
MockBalancerVault public vault;
|
|
MockERC20 public token1;
|
|
MockERC20 public token2;
|
|
|
|
address public owner;
|
|
address public user1;
|
|
address public attacker;
|
|
|
|
event ArbitrageExecuted(address indexed initiator, uint256 profit, uint8 pathLength);
|
|
event FlashLoanInitiated(address indexed token, uint256 amount);
|
|
|
|
// Allow test contract to receive ETH
|
|
receive() external payable {}
|
|
|
|
function setUp() public {
|
|
owner = address(this);
|
|
user1 = address(0x1);
|
|
attacker = address(0x2);
|
|
|
|
// Deploy mock contracts
|
|
vault = new MockBalancerVault();
|
|
token1 = new MockERC20();
|
|
token2 = new MockERC20();
|
|
|
|
// Deploy FlashLoanReceiverSecure
|
|
flashLoan = new FlashLoanReceiverSecure(address(vault));
|
|
|
|
// Fund vault with tokens for flash loans
|
|
token1.transfer(address(vault), 100000 * 10**18);
|
|
token2.transfer(address(vault), 100000 * 10**18);
|
|
|
|
// Fund flash loan contract with some tokens/ETH for testing withdrawals
|
|
token1.transfer(address(flashLoan), 1000 * 10**18);
|
|
token2.transfer(address(flashLoan), 500 * 10**18);
|
|
vm.deal(address(flashLoan), 10 ether);
|
|
}
|
|
|
|
/// ============================================
|
|
/// WITHDRAWAL TESTS
|
|
/// ============================================
|
|
|
|
function testWithdrawProfit_Success() public {
|
|
uint256 withdrawAmount = 100 * 10**18;
|
|
uint256 initialBalance = token1.balanceOf(owner);
|
|
|
|
flashLoan.withdrawProfit(address(token1), withdrawAmount);
|
|
|
|
assertEq(token1.balanceOf(owner), initialBalance + withdrawAmount);
|
|
assertEq(token1.balanceOf(address(flashLoan)), 900 * 10**18);
|
|
}
|
|
|
|
function testWithdrawProfit_MultipleWithdrawals() public {
|
|
uint256 firstWithdraw = 200 * 10**18;
|
|
uint256 secondWithdraw = 300 * 10**18;
|
|
|
|
flashLoan.withdrawProfit(address(token1), firstWithdraw);
|
|
flashLoan.withdrawProfit(address(token1), secondWithdraw);
|
|
|
|
assertEq(token1.balanceOf(address(flashLoan)), 500 * 10**18);
|
|
}
|
|
|
|
function testWithdrawProfit_RevertInvalidToken() public {
|
|
vm.expectRevert("Invalid token address");
|
|
flashLoan.withdrawProfit(address(0), 100);
|
|
}
|
|
|
|
function testWithdrawProfit_RevertZeroAmount() public {
|
|
vm.expectRevert("Amount must be positive");
|
|
flashLoan.withdrawProfit(address(token1), 0);
|
|
}
|
|
|
|
function testWithdrawProfit_RevertInsufficientBalance() public {
|
|
vm.expectRevert("Insufficient balance");
|
|
flashLoan.withdrawProfit(address(token1), 10000 * 10**18); // More than balance
|
|
}
|
|
|
|
function testWithdrawProfit_RevertNotOwner() public {
|
|
vm.prank(attacker);
|
|
vm.expectRevert("Not owner");
|
|
flashLoan.withdrawProfit(address(token1), 100 * 10**18);
|
|
}
|
|
|
|
/// ============================================
|
|
/// EMERGENCY WITHDRAW - TOKEN TESTS
|
|
/// ============================================
|
|
|
|
function testEmergencyWithdraw_Token_Success() public {
|
|
uint256 initialBalance = token1.balanceOf(owner);
|
|
uint256 contractBalance = token1.balanceOf(address(flashLoan));
|
|
|
|
flashLoan.emergencyWithdraw(address(token1));
|
|
|
|
assertEq(token1.balanceOf(owner), initialBalance + contractBalance);
|
|
assertEq(token1.balanceOf(address(flashLoan)), 0);
|
|
}
|
|
|
|
function testEmergencyWithdraw_MultipleTokens() public {
|
|
uint256 token1Balance = token1.balanceOf(address(flashLoan));
|
|
uint256 token2Balance = token2.balanceOf(address(flashLoan));
|
|
|
|
flashLoan.emergencyWithdraw(address(token1));
|
|
flashLoan.emergencyWithdraw(address(token2));
|
|
|
|
assertEq(token1.balanceOf(address(flashLoan)), 0);
|
|
assertEq(token2.balanceOf(address(flashLoan)), 0);
|
|
assertGt(token1.balanceOf(owner), token1Balance);
|
|
assertGt(token2.balanceOf(owner), token2Balance);
|
|
}
|
|
|
|
function testEmergencyWithdraw_Token_RevertNoBalance() public {
|
|
// First withdraw all tokens
|
|
flashLoan.emergencyWithdraw(address(token1));
|
|
|
|
// Try to withdraw again
|
|
vm.expectRevert("No tokens to withdraw");
|
|
flashLoan.emergencyWithdraw(address(token1));
|
|
}
|
|
|
|
function testEmergencyWithdraw_Token_RevertNotOwner() public {
|
|
vm.prank(attacker);
|
|
vm.expectRevert("Not owner");
|
|
flashLoan.emergencyWithdraw(address(token1));
|
|
}
|
|
|
|
/// ============================================
|
|
/// EMERGENCY WITHDRAW - ETH TESTS
|
|
/// ============================================
|
|
|
|
function testEmergencyWithdraw_ETH_Success() public {
|
|
uint256 initialBalance = owner.balance;
|
|
uint256 contractBalance = address(flashLoan).balance;
|
|
|
|
flashLoan.emergencyWithdraw(address(0));
|
|
|
|
assertEq(owner.balance, initialBalance + contractBalance);
|
|
assertEq(address(flashLoan).balance, 0);
|
|
}
|
|
|
|
function testEmergencyWithdraw_ETH_RevertNoBalance() public {
|
|
// First withdraw all ETH
|
|
flashLoan.emergencyWithdraw(address(0));
|
|
|
|
// Try to withdraw again
|
|
vm.expectRevert("No ETH to withdraw");
|
|
flashLoan.emergencyWithdraw(address(0));
|
|
}
|
|
|
|
function testEmergencyWithdraw_ETH_RevertNotOwner() public {
|
|
vm.prank(attacker);
|
|
vm.expectRevert("Not owner");
|
|
flashLoan.emergencyWithdraw(address(0));
|
|
}
|
|
|
|
/// ============================================
|
|
/// MIXED WITHDRAWAL TESTS
|
|
/// ============================================
|
|
|
|
function testWithdrawProfit_ThenEmergencyWithdraw() public {
|
|
// First do controlled withdrawal
|
|
flashLoan.withdrawProfit(address(token1), 500 * 10**18);
|
|
assertEq(token1.balanceOf(address(flashLoan)), 500 * 10**18);
|
|
|
|
// Then emergency withdraw remaining
|
|
flashLoan.emergencyWithdraw(address(token1));
|
|
assertEq(token1.balanceOf(address(flashLoan)), 0);
|
|
}
|
|
|
|
function testEmergencyWithdraw_AllAssets() public {
|
|
uint256 initialETH = owner.balance;
|
|
uint256 initialToken1 = token1.balanceOf(owner);
|
|
uint256 initialToken2 = token2.balanceOf(owner);
|
|
|
|
flashLoan.emergencyWithdraw(address(0)); // ETH
|
|
flashLoan.emergencyWithdraw(address(token1));
|
|
flashLoan.emergencyWithdraw(address(token2));
|
|
|
|
assertEq(address(flashLoan).balance, 0);
|
|
assertEq(token1.balanceOf(address(flashLoan)), 0);
|
|
assertEq(token2.balanceOf(address(flashLoan)), 0);
|
|
|
|
assertGt(owner.balance, initialETH);
|
|
assertGt(token1.balanceOf(owner), initialToken1);
|
|
assertGt(token2.balanceOf(owner), initialToken2);
|
|
}
|
|
|
|
/// ============================================
|
|
/// ACCESS CONTROL TESTS
|
|
/// ============================================
|
|
|
|
function testTransferOwnership_Success() public {
|
|
address newOwner = address(0x999);
|
|
|
|
flashLoan.transferOwnership(newOwner);
|
|
assertEq(flashLoan.owner(), newOwner);
|
|
|
|
// Old owner can't withdraw anymore
|
|
vm.expectRevert("Not owner");
|
|
flashLoan.withdrawProfit(address(token1), 100);
|
|
|
|
// New owner can withdraw
|
|
vm.prank(newOwner);
|
|
flashLoan.withdrawProfit(address(token1), 100 * 10**18);
|
|
}
|
|
|
|
function testTransferOwnership_RevertInvalidAddress() public {
|
|
vm.expectRevert("Invalid new owner");
|
|
flashLoan.transferOwnership(address(0));
|
|
}
|
|
|
|
function testTransferOwnership_RevertSameOwner() public {
|
|
vm.expectRevert("Already owner");
|
|
flashLoan.transferOwnership(owner);
|
|
}
|
|
|
|
function testTransferOwnership_RevertNotOwner() public {
|
|
vm.prank(attacker);
|
|
vm.expectRevert("Not owner");
|
|
flashLoan.transferOwnership(attacker);
|
|
}
|
|
|
|
/// ============================================
|
|
/// REENTRANCY PROTECTION TESTS
|
|
/// ============================================
|
|
|
|
function testWithdrawProfit_ReentrancyProtection() public {
|
|
// Note: Full reentrancy testing requires malicious contract
|
|
// This is a basic check that nonReentrant modifier is present
|
|
|
|
// Attempting to call withdrawProfit during its execution should fail
|
|
// This would require a custom malicious ERC20 token that calls back
|
|
// For now, we verify the modifier is working by checking sequential calls work
|
|
|
|
flashLoan.withdrawProfit(address(token1), 100 * 10**18);
|
|
flashLoan.withdrawProfit(address(token1), 100 * 10**18);
|
|
// If reentrancy guard broken, second call would fail or behave incorrectly
|
|
assertEq(token1.balanceOf(address(flashLoan)), 800 * 10**18);
|
|
}
|
|
|
|
/// ============================================
|
|
/// VIEW FUNCTION TESTS
|
|
/// ============================================
|
|
|
|
function testGetBalance_Token() public {
|
|
uint256 balance = flashLoan.getBalance(address(token1));
|
|
assertEq(balance, 1000 * 10**18);
|
|
}
|
|
|
|
function testGetBalance_ETH() public {
|
|
// For ETH, getBalance uses address(this).balance internally
|
|
assertEq(address(flashLoan).balance, 10 ether);
|
|
}
|
|
|
|
function testOwner() public {
|
|
assertEq(flashLoan.owner(), owner);
|
|
}
|
|
|
|
function testVault() public {
|
|
assertEq(address(flashLoan.vault()), address(vault));
|
|
}
|
|
|
|
function testConstants() public {
|
|
assertEq(flashLoan.MAX_SLIPPAGE_BPS(), 50);
|
|
assertEq(flashLoan.MAX_PATH_LENGTH(), 5);
|
|
assertEq(flashLoan.BASIS_POINTS(), 10000);
|
|
}
|
|
|
|
/// ============================================
|
|
/// EDGE CASES
|
|
/// ============================================
|
|
|
|
function testWithdrawProfit_ExactBalance() public {
|
|
uint256 exactBalance = token1.balanceOf(address(flashLoan));
|
|
|
|
flashLoan.withdrawProfit(address(token1), exactBalance);
|
|
assertEq(token1.balanceOf(address(flashLoan)), 0);
|
|
}
|
|
|
|
function testWithdrawProfit_MultipleTokenTypes() public {
|
|
flashLoan.withdrawProfit(address(token1), 100 * 10**18);
|
|
flashLoan.withdrawProfit(address(token2), 50 * 10**18);
|
|
|
|
assertEq(token1.balanceOf(address(flashLoan)), 900 * 10**18);
|
|
assertEq(token2.balanceOf(address(flashLoan)), 450 * 10**18);
|
|
}
|
|
|
|
function testEmergencyWithdraw_AfterReceivingETH() public {
|
|
// Send more ETH to contract
|
|
vm.deal(address(flashLoan), 20 ether);
|
|
|
|
uint256 initialBalance = owner.balance;
|
|
flashLoan.emergencyWithdraw(address(0));
|
|
|
|
assertEq(owner.balance, initialBalance + 20 ether);
|
|
}
|
|
|
|
/// ============================================
|
|
/// RECEIVE ETH TESTS
|
|
/// ============================================
|
|
|
|
function testReceiveETH() public {
|
|
uint256 initialBalance = address(flashLoan).balance;
|
|
|
|
(bool success,) = address(flashLoan).call{value: 1 ether}("");
|
|
assertTrue(success);
|
|
|
|
assertEq(address(flashLoan).balance, initialBalance + 1 ether);
|
|
}
|
|
|
|
function testReceiveETH_CanWithdrawAfter() public {
|
|
// Send ETH
|
|
payable(address(flashLoan)).transfer(5 ether);
|
|
|
|
// Withdraw
|
|
uint256 initialBalance = owner.balance;
|
|
flashLoan.emergencyWithdraw(address(0));
|
|
|
|
assertEq(owner.balance, initialBalance + 15 ether); // 10 initial + 5 sent
|
|
}
|
|
|
|
/// ============================================
|
|
/// FUZZ TESTS
|
|
/// ============================================
|
|
|
|
function testFuzz_WithdrawProfit(uint256 amount) public {
|
|
uint256 contractBalance = token1.balanceOf(address(flashLoan));
|
|
vm.assume(amount > 0 && amount <= contractBalance);
|
|
|
|
flashLoan.withdrawProfit(address(token1), amount);
|
|
assertEq(token1.balanceOf(address(flashLoan)), contractBalance - amount);
|
|
}
|
|
|
|
function testFuzz_EmergencyWithdrawETH(uint96 ethAmount) public {
|
|
vm.assume(ethAmount > 0);
|
|
vm.deal(address(flashLoan), ethAmount);
|
|
|
|
uint256 initialBalance = owner.balance;
|
|
flashLoan.emergencyWithdraw(address(0));
|
|
|
|
assertEq(owner.balance, initialBalance + ethAmount);
|
|
assertEq(address(flashLoan).balance, 0);
|
|
}
|
|
|
|
/// ============================================
|
|
/// GAS OPTIMIZATION TESTS
|
|
/// ============================================
|
|
|
|
function testGas_WithdrawProfit() public {
|
|
uint256 gasBefore = gasleft();
|
|
flashLoan.withdrawProfit(address(token1), 100 * 10**18);
|
|
uint256 gasUsed = gasBefore - gasleft();
|
|
|
|
// Should use less than 100k gas
|
|
assertLt(gasUsed, 100000);
|
|
}
|
|
|
|
function testGas_EmergencyWithdrawToken() public {
|
|
uint256 gasBefore = gasleft();
|
|
flashLoan.emergencyWithdraw(address(token1));
|
|
uint256 gasUsed = gasBefore - gasleft();
|
|
|
|
// Should use less than 100k gas
|
|
assertLt(gasUsed, 100000);
|
|
}
|
|
|
|
function testGas_EmergencyWithdrawETH() public {
|
|
uint256 gasBefore = gasleft();
|
|
flashLoan.emergencyWithdraw(address(0));
|
|
uint256 gasUsed = gasBefore - gasleft();
|
|
|
|
// Should use less than 50k gas
|
|
assertLt(gasUsed, 50000);
|
|
}
|
|
}
|