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.
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.
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.
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.
The transaction is 0x5c1febd5047c2a15c37988b6abd5c8b984236dddf6fd24eed96b0f43951ad2c9, 2026-10-02 13:05 UTC, Base block 52078502, status success. The caller is 0x4a5FD2e9357cC87DF4cD6A1808174DBc8646899F. Execution runs through 0x3d69591a3868FE77B531187486862c459Cce4e86.
beforeSwap enters reBalance().BURN_POSITION + TAKE_PAIR, burning its real position and creating about +148,868.60 USDC of credit on PosM.TAKE_PAIR sends the hook only the net positive amount, about 33,868.60 USDC. The difference stays on the attacker’s side.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.
“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.
How a UTC day rollover booked a scheduled principal payment as received, reopened draw capacity and let an attacker drain 1.013M USDC on Avalanche.
How unlimited approvals to an address that was empty on Avalanche let anyone deploy a sweeper there and pull $7.45M from a 2-of-7 Safe multisig.
A MoveVM script-caching flaw let one account credit itself unbacked WETH, USDC and AUSD on Initia, and three bridge reserves paid out about $2.7M in real assets.
A zero-quorum DAO vote gave an attacker Yapeswap's feeToSetter role, and a fake Yearn registry then routed every pair's farmed reserves into attacker vaults.