335 lines
14 KiB
Solidity
335 lines
14 KiB
Solidity
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
pragma solidity ^0.8.19;
|
|
|
|
import { OptimizerV3 } from "../src/OptimizerV3.sol";
|
|
import { Stake } from "../src/Stake.sol";
|
|
import "forge-std/Test.sol";
|
|
import "./mocks/MockKraiken.sol";
|
|
import "./mocks/MockStake.sol";
|
|
import { ERC1967Proxy } from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol";
|
|
|
|
contract OptimizerV3Test is Test {
|
|
OptimizerV3 optimizer;
|
|
|
|
// TAX_RATES from Stake.sol
|
|
uint256[30] TAX_RATES =
|
|
[uint256(1), 3, 5, 8, 12, 18, 24, 30, 40, 50, 60, 80, 100, 130, 180, 250, 320, 420, 540, 700, 920, 1200, 1600, 2000, 2600, 3400, 4400, 5700, 7500, 9700];
|
|
uint256 constant MAX_TAX = 9700;
|
|
|
|
function setUp() public {
|
|
// Deploy without initialization (we only test pure functions)
|
|
optimizer = new OptimizerV3();
|
|
}
|
|
|
|
function _normalizedTaxRate(uint256 taxRateIndex) internal view returns (uint256) {
|
|
return TAX_RATES[taxRateIndex] * 1e18 / MAX_TAX;
|
|
}
|
|
|
|
function _percentageStaked(uint256 pct) internal pure returns (uint256) {
|
|
return pct * 1e18 / 100;
|
|
}
|
|
|
|
// ==================== Always Bear (staked <= 91%) ====================
|
|
|
|
function testAlwaysBearAt0Percent() public view {
|
|
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
|
assertFalse(optimizer.isBullMarket(0, _normalizedTaxRate(taxIdx)), "0% staked should always be bear");
|
|
}
|
|
}
|
|
|
|
function testAlwaysBearAt50Percent() public view {
|
|
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
|
assertFalse(optimizer.isBullMarket(_percentageStaked(50), _normalizedTaxRate(taxIdx)), "50% staked should always be bear");
|
|
}
|
|
}
|
|
|
|
function testAlwaysBearAt91Percent() public view {
|
|
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
|
assertFalse(optimizer.isBullMarket(_percentageStaked(91), _normalizedTaxRate(taxIdx)), "91% staked should always be bear");
|
|
}
|
|
}
|
|
|
|
function testAlwaysBearAt39Percent() public view {
|
|
assertFalse(optimizer.isBullMarket(_percentageStaked(39), _normalizedTaxRate(0)), "39% staked should be bear");
|
|
}
|
|
|
|
function testAlwaysBearAt80Percent() public view {
|
|
assertFalse(optimizer.isBullMarket(_percentageStaked(80), _normalizedTaxRate(0)), "80% staked should be bear even with lowest tax");
|
|
}
|
|
|
|
// ==================== 92% Boundary ====================
|
|
|
|
function testBoundary92PercentLowestTax() public view {
|
|
// deltaS=8, effIdx=0 → penalty = 512*0/20 = 0 < 50 → BULL
|
|
assertTrue(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(0)), "92% staked, lowest tax should be bull");
|
|
}
|
|
|
|
function testBoundary92PercentTaxIdx1() public view {
|
|
// deltaS=8, effIdx=1 → penalty = 512*1/20 = 25 < 50 → BULL
|
|
assertTrue(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(1)), "92% staked, taxIdx=1 should be bull");
|
|
}
|
|
|
|
function testBoundary92PercentTaxIdx2() public view {
|
|
// deltaS=8, effIdx=2 → penalty = 512*2/20 = 51 >= 50 → BEAR
|
|
assertFalse(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(2)), "92% staked, taxIdx=2 should be bear");
|
|
}
|
|
|
|
function testBoundary92PercentHighTax() public view {
|
|
// deltaS=8, effIdx=29 → penalty = 512*29/20 = 742 → BEAR
|
|
assertFalse(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(29)), "92% staked, max tax should be bear");
|
|
}
|
|
|
|
// ==================== 95% Staked ====================
|
|
|
|
function testAt95PercentLowTax() public view {
|
|
// deltaS=5, effIdx=0 → penalty = 125*0/20 = 0 < 50 → BULL
|
|
assertTrue(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(0)), "95% staked, lowest tax should be bull");
|
|
}
|
|
|
|
function testAt95PercentTaxIdx7() public view {
|
|
// deltaS=5, effIdx=7 → penalty = 125*7/20 = 43 < 50 → BULL
|
|
assertTrue(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(7)), "95% staked, taxIdx=7 should be bull");
|
|
}
|
|
|
|
function testAt95PercentTaxIdx8() public view {
|
|
// deltaS=5, effIdx=8 → penalty = 125*8/20 = 50, NOT < 50 → BEAR
|
|
assertFalse(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(8)), "95% staked, taxIdx=8 should be bear");
|
|
}
|
|
|
|
function testAt95PercentTaxIdx9() public view {
|
|
// deltaS=5, effIdx=9 → penalty = 125*9/20 = 56 → BEAR
|
|
assertFalse(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(9)), "95% staked, taxIdx=9 should be bear");
|
|
}
|
|
|
|
// ==================== 97% Staked ====================
|
|
|
|
function testAt97PercentLowTax() public view {
|
|
// deltaS=3, effIdx=0 → penalty = 27*0/20 = 0 < 50 → BULL
|
|
assertTrue(optimizer.isBullMarket(_percentageStaked(97), _normalizedTaxRate(0)), "97% staked, lowest tax should be bull");
|
|
}
|
|
|
|
function testAt97PercentHighTax() public view {
|
|
// deltaS=3, effIdx=29 → penalty = 27*29/20 = 39 < 50 → BULL
|
|
assertTrue(optimizer.isBullMarket(_percentageStaked(97), _normalizedTaxRate(29)), "97% staked, max tax should still be bull");
|
|
}
|
|
|
|
// ==================== 100% Staked ====================
|
|
|
|
function testAt100PercentAlwaysBull() public view {
|
|
// deltaS=0 → penalty = 0 → always BULL
|
|
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
|
assertTrue(optimizer.isBullMarket(1e18, _normalizedTaxRate(taxIdx)), "100% staked should always be bull");
|
|
}
|
|
}
|
|
|
|
// ==================== 96% Sweep ====================
|
|
|
|
function testAt96PercentSweep() public view {
|
|
// deltaS=4, cubic=64
|
|
// penalty = 64 * effIdx / 20
|
|
// Bull when penalty < 50, i.e., 64 * effIdx / 20 < 50 → effIdx < 15.625
|
|
// effIdx 0-15: bull (penalty 0..48). effIdx 16+: bear (penalty 51+)
|
|
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
|
bool result = optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(taxIdx));
|
|
// Compute expected: effIdx from the tax rate
|
|
uint256 effIdx = taxIdx;
|
|
if (taxIdx >= 14) {
|
|
effIdx = taxIdx + 1;
|
|
if (effIdx > 29) effIdx = 29;
|
|
}
|
|
uint256 penalty = 64 * effIdx / 20;
|
|
bool expectedBull = penalty < 50;
|
|
assertEq(result, expectedBull, string.concat("96% sweep mismatch at taxIdx=", vm.toString(taxIdx)));
|
|
}
|
|
}
|
|
|
|
// ==================== 94% Sweep ====================
|
|
|
|
function testAt94PercentSweep() public view {
|
|
// deltaS=6, cubic=216
|
|
// penalty = 216 * effIdx / 20
|
|
// Bull when penalty < 50, i.e., 216 * effIdx / 20 < 50 → effIdx < 4.629
|
|
// effIdx 0-4: bull. effIdx 5+: bear.
|
|
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
|
bool result = optimizer.isBullMarket(_percentageStaked(94), _normalizedTaxRate(taxIdx));
|
|
uint256 effIdx = taxIdx;
|
|
if (taxIdx >= 14) {
|
|
effIdx = taxIdx + 1;
|
|
if (effIdx > 29) effIdx = 29;
|
|
}
|
|
uint256 penalty = 216 * effIdx / 20;
|
|
bool expectedBull = penalty < 50;
|
|
assertEq(result, expectedBull, string.concat("94% sweep mismatch at taxIdx=", vm.toString(taxIdx)));
|
|
}
|
|
}
|
|
|
|
// ==================== Revert on Invalid Input ====================
|
|
|
|
function testRevertsAbove100Percent() public {
|
|
vm.expectRevert("Invalid percentage staked");
|
|
optimizer.isBullMarket(1e18 + 1, 0);
|
|
}
|
|
|
|
// ==================== 93% Staked ====================
|
|
|
|
function testAt93PercentSweep() public view {
|
|
// deltaS=7, cubic=343
|
|
// penalty = 343 * effIdx / 20
|
|
// Bull when penalty < 50, i.e., 343 * effIdx / 20 < 50 → effIdx < 2.915
|
|
// effIdx 0-2: bull. effIdx 3+: bear.
|
|
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
|
bool result = optimizer.isBullMarket(_percentageStaked(93), _normalizedTaxRate(taxIdx));
|
|
uint256 effIdx = taxIdx;
|
|
if (taxIdx >= 14) {
|
|
effIdx = taxIdx + 1;
|
|
if (effIdx > 29) effIdx = 29;
|
|
}
|
|
uint256 penalty = 343 * effIdx / 20;
|
|
bool expectedBull = penalty < 50;
|
|
assertEq(result, expectedBull, string.concat("93% sweep mismatch at taxIdx=", vm.toString(taxIdx)));
|
|
}
|
|
}
|
|
|
|
// ==================== 99% Staked ====================
|
|
|
|
function testAt99PercentAlwaysBull() public view {
|
|
// deltaS=1, cubic=1 → penalty = effIdx/20, always < 50 for effIdx <= 29
|
|
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
|
|
assertTrue(optimizer.isBullMarket(_percentageStaked(99), _normalizedTaxRate(taxIdx)), "99% staked should always be bull");
|
|
}
|
|
}
|
|
|
|
// ==================== EffIdx Shift at Boundary (taxIdx 13 vs 14) ====================
|
|
|
|
function testEffIdxShiftAtBoundary() public view {
|
|
// At 96% staked, deltaS=4, cubic=64
|
|
// taxIdx=13: effIdx=13, penalty = 64*13/20 = 41 < 50 → BULL
|
|
assertTrue(optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(13)), "taxIdx=13 should be bull at 96%");
|
|
|
|
// taxIdx=14: effIdx=15 (shift!), penalty = 64*15/20 = 48 < 50 → BULL
|
|
assertTrue(optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(14)), "taxIdx=14 should be bull at 96% (effIdx shift)");
|
|
|
|
// taxIdx=15: effIdx=16, penalty = 64*16/20 = 51 >= 50 → BEAR
|
|
assertFalse(optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(15)), "taxIdx=15 should be bear at 96%");
|
|
}
|
|
|
|
// ==================== Fuzz Tests ====================
|
|
|
|
function testFuzzBearBelow92(uint256 percentageStaked, uint256 taxIdx) public view {
|
|
percentageStaked = bound(percentageStaked, 0, 91e18 / 100);
|
|
taxIdx = bound(taxIdx, 0, 29);
|
|
assertFalse(optimizer.isBullMarket(percentageStaked, _normalizedTaxRate(taxIdx)), "Should always be bear below 92%");
|
|
}
|
|
|
|
function testFuzz100PercentAlwaysBull(uint256 taxIdx) public view {
|
|
taxIdx = bound(taxIdx, 0, 29);
|
|
assertTrue(optimizer.isBullMarket(1e18, _normalizedTaxRate(taxIdx)), "100% staked should always be bull");
|
|
}
|
|
|
|
function testFuzzNeverReverts(uint256 percentageStaked, uint256 averageTaxRate) public view {
|
|
percentageStaked = bound(percentageStaked, 0, 1e18);
|
|
averageTaxRate = bound(averageTaxRate, 0, 1e18);
|
|
// Should not revert
|
|
optimizer.isBullMarket(percentageStaked, averageTaxRate);
|
|
}
|
|
}
|
|
|
|
// =========================================================
|
|
// COVERAGE TESTS: initialize, getLiquidityParams, UUPS upgrade
|
|
// =========================================================
|
|
|
|
/**
|
|
* @title OptimizerV3ProxyTest
|
|
* @notice Tests OptimizerV3 features that require proxy deployment:
|
|
* initialize, getLiquidityParams, _authorizeUpgrade, onlyAdmin, _checkAdmin
|
|
*/
|
|
contract OptimizerV3ProxyTest is Test {
|
|
MockKraiken mockKraiken;
|
|
MockStake mockStake;
|
|
OptimizerV3 proxyOptimizer;
|
|
|
|
function setUp() public {
|
|
mockKraiken = new MockKraiken();
|
|
mockStake = new MockStake();
|
|
|
|
OptimizerV3 impl = new OptimizerV3();
|
|
ERC1967Proxy proxy = new ERC1967Proxy(
|
|
address(impl),
|
|
abi.encodeWithSelector(OptimizerV3.initialize.selector, address(mockKraiken), address(mockStake))
|
|
);
|
|
proxyOptimizer = OptimizerV3(address(proxy));
|
|
}
|
|
|
|
/**
|
|
* @notice Verify initialize set up the proxy correctly (covers initialize body)
|
|
*/
|
|
function testInitialize() public view {
|
|
// The proxy was initialized — calling getLiquidityParams should not revert
|
|
// (it calls stake.getPercentageStaked() and stake.getAverageTaxRate())
|
|
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = proxyOptimizer.getLiquidityParams();
|
|
assertEq(ci, 0, "capitalInefficiency always 0");
|
|
assertTrue(aw == 100 || aw == 20, "anchorWidth is either bear(100) or bull(20)");
|
|
// Bear or bull values are valid
|
|
assertTrue(
|
|
(as_ == 3e17 && dd == 3e17 && aw == 100) || (as_ == 1e18 && dd == 1e18 && aw == 20),
|
|
"Params should be valid bear or bull set"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @notice Bear market: staked <= 91% → AS=30%, AW=100, DD=0.3e18, CI=0
|
|
*/
|
|
function testGetLiquidityParamsBear() public {
|
|
mockStake.setPercentageStaked(50e16); // 50% staked → always bear
|
|
mockStake.setAverageTaxRate(0);
|
|
|
|
(uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) =
|
|
proxyOptimizer.getLiquidityParams();
|
|
|
|
assertEq(capitalInefficiency, 0, "Bear: CI=0");
|
|
assertEq(anchorShare, 3e17, "Bear: AS=30%");
|
|
assertEq(anchorWidth, 100, "Bear: AW=100");
|
|
assertEq(discoveryDepth, 3e17, "Bear: DD=0.3e18");
|
|
}
|
|
|
|
/**
|
|
* @notice Bull market: staked > 91%, low tax → AS=100%, AW=20, DD=1e18, CI=0
|
|
*/
|
|
function testGetLiquidityParamsBull() public {
|
|
mockStake.setPercentageStaked(1e18); // 100% staked → always bull
|
|
mockStake.setAverageTaxRate(0);
|
|
|
|
(uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) =
|
|
proxyOptimizer.getLiquidityParams();
|
|
|
|
assertEq(capitalInefficiency, 0, "Bull: CI=0");
|
|
assertEq(anchorShare, 1e18, "Bull: AS=100%");
|
|
assertEq(anchorWidth, 20, "Bull: AW=20");
|
|
assertEq(discoveryDepth, 1e18, "Bull: DD=1e18");
|
|
}
|
|
|
|
/**
|
|
* @notice Admin can upgrade to a new implementation (covers _authorizeUpgrade, onlyAdmin, _checkAdmin)
|
|
*/
|
|
function testV3UUPSUpgrade() public {
|
|
OptimizerV3 impl2 = new OptimizerV3();
|
|
// This contract (address(this)) is the admin since it deployed the proxy
|
|
proxyOptimizer.upgradeTo(address(impl2));
|
|
|
|
// Verify proxy still works after upgrade
|
|
(uint256 ci,,,) = proxyOptimizer.getLiquidityParams();
|
|
assertEq(ci, 0, "CI always 0 after upgrade");
|
|
}
|
|
|
|
/**
|
|
* @notice Non-admin calling upgradeTo reverts with UnauthorizedAccount
|
|
*/
|
|
function testV3UnauthorizedUpgradeReverts() public {
|
|
// Deploy impl2 BEFORE the prank so the prank applies only to upgradeTo
|
|
OptimizerV3 impl2 = new OptimizerV3();
|
|
address nonAdmin = makeAddr("nonAdmin");
|
|
vm.expectRevert(abi.encodeWithSelector(OptimizerV3.UnauthorizedAccount.selector, nonAdmin));
|
|
vm.prank(nonAdmin);
|
|
proxyOptimizer.upgradeTo(address(impl2));
|
|
}
|
|
}
|