GoldPesa GPXHooks Rebalance Netted Away: Missing Zero-Delta Check on a Shared PositionManager Drains About 115K USDC | PandaSecurity
Web3 Security

GoldPesa GPXHooks Rebalance Netted Away: Missing Zero-Delta Check on a Shared PositionManager Drains About 115K USDC

PandaSecurity Team •
#Uniswap v4#Hook Security#GoldPesa
Diagram of a Uniswap v4 hook sharing PositionManager delta accounting

GoldPesa runs an immutable Uniswap v4 hook on Base to manage fees, initial liquidity, and rebalancing for the GPX/USDC pool. On 2 October 2026 that protocol-owned liquidity was reduced by roughly 115,000 USDC through a netting trick. The bug is not in the v4 core. The hook treated a shared, flash-accounted PositionManager as if it were a private ledger.

What the contract does

GPXHooks (0x4519e2b040ff1B64fa03aBe2AeF0BC99D7CcEaA8) is the orchestration layer between GoldPesa and Uniswap v4. The contract comments describe it as ownerless, unpausable, and non-upgradeable. Pool initialization, fee splits, and rebalancing are hardcoded.

It does five things. It deploys and initializes the GPX-USDC pool. Hooks block unauthorized pool creation and external liquidity changes. Every trade pays a 1% fee, split into four 0.25% shares (Mines, Treasury, Pawn, and GPX Owner). mintInitialLiquidity() runs once, after which liquidity is locked. The contract watches circulating supply and price, and when conditions are met it burns the old position and mints a new one at updated ticks. Liquidity changes are restricted to the internal reBalance() path; direct external calls are rejected.

Rebalancing depends on the PositionManager. In v4, PosM creates and manages liquidity positions, each represented by an ERC-721. The hook does not hold the underlying token accounting itself. It burns, takes tokens, and opens a new position through PosM’s modifyLiquidities / modifyLiquiditiesWithoutUnlock.

How PoolManager accounting works

The loss only makes sense once unlock accounting is clear. Inside a PoolManager unlock, token movements are not settled immediately. They are recorded as currency deltas. A positive delta is credit that can be taken. A negative delta is debt that must be repaid. Before the unlock ends, relevant deltas must return to zero or the transaction reverts.

The PositionManager is a shared contract. Every mint, burn, and take that goes through it in the same unlock lands on the same delta set. TAKE_PAIR withdraws the PositionManager’s net balance for that pair, not the amount produced by the latest burn alone. If a negative delta is already sitting on PosM when rebalance starts, the new positive credit pays that debt first.

That is the path GPXHooks uses. In source, reBalance() packs BURN_POSITION + TAKE_PAIR and calls positionManager.modifyLiquiditiesWithoutUnlock(...). It does not assert that GPX/USDC deltas are zero in the current unlock.

Root cause

The missing isolation is the root cause.

Rebalance assumes it is the only party touching the PositionManager in that unlock. It is not. Once an hour has passed since the last rebalance, beforeSwap can be entered by any swap on the GPX pool. An attacker can open their own PoolManager unlock, leave unsettled debt on the shared PosM, then send the swap that triggers rebalance. The credit from burning the real position and the attacker’s negative delta net on the same ledger. TAKE_PAIR only returns the net positive amount. The protocol believes it recovered the full position; it only received the remainder.

This is not a general Uniswap v4 vulnerability. Flash accounting and net settlement are the design. The flaw is the hook using shared state as private state without a zero-delta check. The comments say only the contract may change liquidity, but the tool that changes liquidity is an external shared PosM. The permission boundary and the accounting boundary do not match.

Attack flow

The transaction is 0x5c1febd5047c2a15c37988b6abd5c8b984236dddf6fd24eed96b0f43951ad2c9, 2026-10-02 13:05 UTC, Base block 52078502, status success. The caller is 0x4a5FD2e9357cC87DF4cD6A1808174DBc8646899F. Execution runs through 0x3d69591a3868FE77B531187486862c459Cce4e86.

  1. The executor borrows 175,000 USDC from Morpho as a flash loan to fund the sequence.
  2. Inside an attacker-controlled PoolManager unlock, it mints a WETH/USDC position through the PositionManager and does not settle it, leaving about −114,999.999 USDC of delta on the shared PosM.
  3. It swaps on the GPX/USDC pool. More than an hour has passed since the last rebalance, so beforeSwap enters reBalance().
  4. The hook runs BURN_POSITION + TAKE_PAIR, burning its real position and creating about +148,868.60 USDC of credit on PosM.
  5. Netting in the same unlock fills the attacker’s negative delta first. TAKE_PAIR sends the hook only the net positive amount, about 33,868.60 USDC. The difference stays on the attacker’s side.
  6. The attacker burns its own unsettled position, clearing the debt, then withdraws about 114,999.999 USDC from the PoolManager.
  7. About 114,428.51 USDC is swapped for about 96,388.99 bridged USDT, the flash loan is repaid, and the profit leaves the executor.

What the protocol lost is the burn credit that should have returned in full to the hook. The size matches SlowMist’s figure of roughly $114,900. The hook was paid; it was paid the residual after netting.

Takeaway

“No owner, cannot pause” addresses governance risk. It does not address ledger isolation. A v4 hook that calls a shared PositionManager from a callback has to treat the current unlock as untrusted context: assert that relevant currency deltas are zero before reBalance, or burn and take inside an unlock the hook itself opens and can keep clean. modifyLiquiditiesWithoutUnlock skips an outer unlock and also skips a natural settlement boundary, so the caller has to supply the assertion.

This class of bug is easy to treat as a call-order detail in review, because unit tests usually start from a clean delta. At Panda Security we treat shared PosM and PoolManager deltas as explicit invariants on v4 hooks: before any internal liquidity operation, deltas for the currencies involved must be zero, or the call reverts. GoldPesa’s loss shows that correct access control is not enough. If the books are not isolated, a perfectly valid unlock can still empty protocol-owned liquidity.

Related articles

← Back to Blog