Add Solidity linting with solhint, Foundry formatter, and pre-commit hooks (#51)
## Changes ### Configuration - Added .solhint.json with recommended rules + custom config - 160 char line length (warn) - Double quotes enforcement (error) - Explicit visibility required (error) - Console statements allowed (scripts/tests need them) - Gas optimization warnings enabled - Ignores test/helpers/, lib/, out/, cache/, broadcast/ - Added foundry.toml [fmt] section - 160 char line length - 4-space tabs - Double quotes - Thousands separators for numbers - Sort imports enabled - Added .lintstagedrc.json for pre-commit auto-fix - Runs solhint --fix on .sol files - Runs forge fmt on .sol files - Added husky pre-commit hook via lint-staged ### NPM Scripts - lint:sol - run solhint - lint:sol:fix - auto-fix solhint issues - format:sol - format with forge fmt - format:sol:check - check formatting - lint / lint:fix - combined commands ### Code Changes - Added explicit visibility modifiers (internal) to constants in scripts and tests - Fixed quote style in DeployLocal.sol - All Solidity files formatted with forge fmt ## Verification - ✅ forge fmt --check passes - ✅ No solhint errors (warnings only) - ✅ forge build succeeds - ✅ forge test passes (107/107) resolves #44 Co-authored-by: johba <johba@harb.eth> Reviewed-on: https://codeberg.org/johba/harb/pulls/51
This commit is contained in:
parent
f8927b426e
commit
d7c2184ccf
45 changed files with 2853 additions and 1225 deletions
|
|
@ -1,15 +1,16 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.19;
|
||||
|
||||
import "forge-std/Test.sol";
|
||||
import {TestEnvironment} from "./helpers/TestBase.sol";
|
||||
import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
|
||||
import {IWETH9} from "../src/interfaces/IWETH9.sol";
|
||||
import {Kraiken} from "../src/Kraiken.sol";
|
||||
import {Stake} from "../src/Stake.sol";
|
||||
import {LiquidityManager} from "../src/LiquidityManager.sol";
|
||||
import "../analysis/helpers/SwapExecutor.sol";
|
||||
import { Kraiken } from "../src/Kraiken.sol";
|
||||
import { LiquidityManager } from "../src/LiquidityManager.sol";
|
||||
import { Stake } from "../src/Stake.sol";
|
||||
import { IWETH9 } from "../src/interfaces/IWETH9.sol";
|
||||
|
||||
import "../test/mocks/BullMarketOptimizer.sol";
|
||||
import { TestEnvironment } from "./helpers/TestBase.sol";
|
||||
import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
|
||||
import "forge-std/Test.sol";
|
||||
|
||||
/**
|
||||
* @title ReplayProfitableScenario
|
||||
|
|
@ -24,214 +25,215 @@ contract ReplayProfitableScenario is Test {
|
|||
Stake stake;
|
||||
LiquidityManager lm;
|
||||
bool token0isWeth;
|
||||
|
||||
|
||||
address trader = makeAddr("trader");
|
||||
address whale = makeAddr("whale");
|
||||
address feeDestination = makeAddr("fees");
|
||||
|
||||
|
||||
function setUp() public {
|
||||
// Recreate exact initial conditions from seed 1
|
||||
testEnv = new TestEnvironment(feeDestination);
|
||||
BullMarketOptimizer optimizer = new BullMarketOptimizer();
|
||||
|
||||
|
||||
// Use seed 1 setup (odd seed = false for first param)
|
||||
(,pool, weth, kraiken, stake, lm,, token0isWeth) =
|
||||
testEnv.setupEnvironmentWithOptimizer(false, feeDestination, address(optimizer));
|
||||
|
||||
(, pool, weth, kraiken, stake, lm,, token0isWeth) = testEnv.setupEnvironmentWithOptimizer(false, feeDestination, address(optimizer));
|
||||
|
||||
// Fund exactly as in the recorded scenario
|
||||
vm.deal(address(lm), 200 ether);
|
||||
|
||||
|
||||
// Trader gets specific amount based on seed 1
|
||||
uint256 traderFund = 50 ether + (uint256(keccak256(abi.encodePacked(uint256(1), "trader"))) % 150 ether);
|
||||
vm.deal(trader, traderFund * 2);
|
||||
vm.prank(trader);
|
||||
weth.deposit{value: traderFund}();
|
||||
|
||||
// Whale gets specific amount based on seed 1
|
||||
weth.deposit{ value: traderFund }();
|
||||
|
||||
// Whale gets specific amount based on seed 1
|
||||
uint256 whaleFund = 200 ether + (uint256(keccak256(abi.encodePacked(uint256(1), "whale"))) % 300 ether);
|
||||
vm.deal(whale, whaleFund * 2);
|
||||
vm.prank(whale);
|
||||
weth.deposit{value: whaleFund}();
|
||||
|
||||
weth.deposit{ value: whaleFund }();
|
||||
|
||||
// Initial recenter
|
||||
vm.prank(feeDestination);
|
||||
lm.recenter();
|
||||
}
|
||||
|
||||
|
||||
function test_replayExactProfitableScenario() public {
|
||||
console.log("=== REPLAYING PROFITABLE SCENARIO (Seed 1) ===");
|
||||
console.log("Expected: 225% profit by exploiting discovery position\n");
|
||||
|
||||
|
||||
uint256 initialTraderWeth = weth.balanceOf(trader);
|
||||
uint256 initialWhaleWeth = weth.balanceOf(whale);
|
||||
|
||||
|
||||
console.log("Initial balances:");
|
||||
console.log(" Trader WETH:", initialTraderWeth / 1e18, "ETH");
|
||||
console.log(" Whale WETH:", initialWhaleWeth / 1e18, "ETH");
|
||||
|
||||
|
||||
// Log initial tick
|
||||
(, int24 initialTick,,,,,) = pool.slot0();
|
||||
console.log(" Initial tick:", vm.toString(initialTick));
|
||||
|
||||
|
||||
// Execute exact sequence from recording
|
||||
console.log("\n--- Executing Recorded Sequence ---");
|
||||
|
||||
|
||||
// Step 1: Trader buys 38 ETH worth
|
||||
console.log("\nStep 1: Trader BUY 38 ETH");
|
||||
_executeBuy(trader, 38215432537912335624);
|
||||
_executeBuy(trader, 38_215_432_537_912_335_624);
|
||||
_logTickChange();
|
||||
|
||||
|
||||
// Step 2: Trader sells large amount of KRAIKEN
|
||||
console.log("\nStep 2: Trader SELL 2M KRAIKEN");
|
||||
_executeSell(trader, 2023617577713031308513047);
|
||||
_executeSell(trader, 2_023_617_577_713_031_308_513_047);
|
||||
_logTickChange();
|
||||
|
||||
|
||||
// Step 3: Whale buys 132 ETH worth
|
||||
console.log("\nStep 3: Whale BUY 132 ETH");
|
||||
_executeBuy(whale, 132122625892942968181);
|
||||
_executeBuy(whale, 132_122_625_892_942_968_181);
|
||||
_logTickChange();
|
||||
|
||||
|
||||
// Step 4: Trader sells
|
||||
console.log("\nStep 4: Trader SELL 1.5M KRAIKEN");
|
||||
_executeSell(trader, 1517713183284773481384785);
|
||||
_executeSell(trader, 1_517_713_183_284_773_481_384_785);
|
||||
_logTickChange();
|
||||
|
||||
|
||||
// Step 5: Whale buys 66 ETH worth
|
||||
console.log("\nStep 5: Whale BUY 66 ETH");
|
||||
_executeBuy(whale, 66061312946471484091);
|
||||
_executeBuy(whale, 66_061_312_946_471_484_091);
|
||||
_logTickChange();
|
||||
|
||||
|
||||
// Step 6: Trader sells
|
||||
console.log("\nStep 6: Trader SELL 1.1M KRAIKEN");
|
||||
_executeSell(trader, 1138284887463580111038589);
|
||||
_executeSell(trader, 1_138_284_887_463_580_111_038_589);
|
||||
_logTickChange();
|
||||
|
||||
|
||||
// Step 7: Whale buys 33 ETH worth
|
||||
console.log("\nStep 7: Whale BUY 33 ETH");
|
||||
_executeBuy(whale, 33030656473235742045);
|
||||
_executeBuy(whale, 33_030_656_473_235_742_045);
|
||||
_logTickChange();
|
||||
|
||||
|
||||
// Step 8: Trader sells
|
||||
console.log("\nStep 8: Trader SELL 853K KRAIKEN");
|
||||
_executeSell(trader, 853713665597685083278941);
|
||||
_executeSell(trader, 853_713_665_597_685_083_278_941);
|
||||
_logTickChange();
|
||||
|
||||
|
||||
// Step 9: Final trader sell
|
||||
console.log("\nStep 9: Trader SELL 2.5M KRAIKEN (final)");
|
||||
_executeSell(trader, 2561140996793055249836826);
|
||||
_executeSell(trader, 2_561_140_996_793_055_249_836_826);
|
||||
_logTickChange();
|
||||
|
||||
|
||||
// Check if we reached discovery
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
console.log("\n--- Position Analysis ---");
|
||||
console.log("Final tick:", vm.toString(currentTick));
|
||||
|
||||
|
||||
// The recording showed tick -119663, which should be in discovery range
|
||||
// Discovery was around 109200 to 120200 in the other test
|
||||
// But with token0isWeth=false, the ranges might be inverted
|
||||
|
||||
|
||||
// Calculate final balances
|
||||
uint256 finalTraderWeth = weth.balanceOf(trader);
|
||||
uint256 finalTraderKraiken = kraiken.balanceOf(trader);
|
||||
uint256 finalWhaleWeth = weth.balanceOf(whale);
|
||||
uint256 finalWhaleKraiken = kraiken.balanceOf(whale);
|
||||
|
||||
|
||||
console.log("\n=== FINAL RESULTS ===");
|
||||
console.log("Trader:");
|
||||
console.log(" Initial WETH:", initialTraderWeth / 1e18, "ETH");
|
||||
console.log(" Final WETH:", finalTraderWeth / 1e18, "ETH");
|
||||
console.log(" Final KRAIKEN:", finalTraderKraiken / 1e18);
|
||||
|
||||
|
||||
// Calculate profit/loss
|
||||
if (finalTraderWeth > initialTraderWeth) {
|
||||
uint256 profit = finalTraderWeth - initialTraderWeth;
|
||||
uint256 profitPct = (profit * 100) / initialTraderWeth;
|
||||
|
||||
|
||||
console.log("\n[SUCCESS] INVARIANT VIOLATED!");
|
||||
console.log("Trader Profit:", profit / 1e18, "ETH");
|
||||
console.log("Profit Percentage:", profitPct, "%");
|
||||
|
||||
|
||||
assertTrue(profitPct > 100, "Expected >100% profit from replay");
|
||||
} else {
|
||||
uint256 loss = initialTraderWeth - finalTraderWeth;
|
||||
console.log("\n[UNEXPECTED] Trader lost:", loss / 1e18, "ETH");
|
||||
console.log("Replay may have different initial conditions");
|
||||
}
|
||||
|
||||
|
||||
console.log("\nWhale:");
|
||||
console.log(" Initial WETH:", initialWhaleWeth / 1e18, "ETH");
|
||||
console.log(" Final WETH:", finalWhaleWeth / 1e18, "ETH");
|
||||
console.log(" Final KRAIKEN:", finalWhaleKraiken / 1e18);
|
||||
}
|
||||
|
||||
|
||||
function test_verifyDiscoveryReached() public {
|
||||
// First execute the scenario
|
||||
_executeFullScenario();
|
||||
|
||||
|
||||
// Check tick position relative to discovery
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
|
||||
|
||||
// Note: With token0isWeth=false, the tick interpretation is different
|
||||
// Negative ticks mean KRAIKEN is cheap relative to WETH
|
||||
|
||||
|
||||
console.log("=== DISCOVERY VERIFICATION ===");
|
||||
console.log("Current tick after scenario:", vm.toString(currentTick));
|
||||
|
||||
|
||||
// The scenario reached tick -119608 which was marked as discovery
|
||||
// This confirms the exploit works by reaching rarely-accessed liquidity zones
|
||||
|
||||
if (currentTick < -119000 && currentTick > -120000) {
|
||||
|
||||
if (currentTick < -119_000 && currentTick > -120_000) {
|
||||
console.log("[CONFIRMED] Reached discovery zone around tick -119600");
|
||||
console.log("This zone has massive liquidity that's rarely accessed");
|
||||
console.log("Traders can exploit the liquidity imbalance for profit");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function _executeFullScenario() internal {
|
||||
_executeBuy(trader, 38215432537912335624);
|
||||
_executeSell(trader, 2023617577713031308513047);
|
||||
_executeBuy(whale, 132122625892942968181);
|
||||
_executeSell(trader, 1517713183284773481384785);
|
||||
_executeBuy(whale, 66061312946471484091);
|
||||
_executeSell(trader, 1138284887463580111038589);
|
||||
_executeBuy(whale, 33030656473235742045);
|
||||
_executeSell(trader, 853713665597685083278941);
|
||||
_executeSell(trader, 2561140996793055249836826);
|
||||
_executeBuy(trader, 38_215_432_537_912_335_624);
|
||||
_executeSell(trader, 2_023_617_577_713_031_308_513_047);
|
||||
_executeBuy(whale, 132_122_625_892_942_968_181);
|
||||
_executeSell(trader, 1_517_713_183_284_773_481_384_785);
|
||||
_executeBuy(whale, 66_061_312_946_471_484_091);
|
||||
_executeSell(trader, 1_138_284_887_463_580_111_038_589);
|
||||
_executeBuy(whale, 33_030_656_473_235_742_045);
|
||||
_executeSell(trader, 853_713_665_597_685_083_278_941);
|
||||
_executeSell(trader, 2_561_140_996_793_055_249_836_826);
|
||||
}
|
||||
|
||||
|
||||
function _executeBuy(address buyer, uint256 amount) internal {
|
||||
if (weth.balanceOf(buyer) < amount) {
|
||||
console.log(" [WARNING] Insufficient WETH, skipping buy");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
SwapExecutor executor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm);
|
||||
vm.prank(buyer);
|
||||
weth.transfer(address(executor), amount);
|
||||
|
||||
try executor.executeBuy(amount, buyer) {} catch {
|
||||
|
||||
try executor.executeBuy(amount, buyer) { }
|
||||
catch {
|
||||
console.log(" [WARNING] Buy failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function _executeSell(address seller, uint256 amount) internal {
|
||||
if (kraiken.balanceOf(seller) < amount) {
|
||||
console.log(" [WARNING] Insufficient KRAIKEN, selling what's available");
|
||||
amount = kraiken.balanceOf(seller);
|
||||
if (amount == 0) return;
|
||||
}
|
||||
|
||||
|
||||
SwapExecutor executor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm);
|
||||
vm.prank(seller);
|
||||
kraiken.transfer(address(executor), amount);
|
||||
|
||||
try executor.executeSell(amount, seller) {} catch {
|
||||
|
||||
try executor.executeSell(amount, seller) { }
|
||||
catch {
|
||||
console.log(" [WARNING] Sell failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function _logTickChange() internal view {
|
||||
(, int24 currentTick,,,,,) = pool.slot0();
|
||||
console.log(string.concat(" Current tick: ", vm.toString(currentTick)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue