Reproduced Exploit
Gains Network gTrade: decrease position withdraws realized PnL twice
Chain
Other
Category
untagged
Date
Jan 1970
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: GainsNetwork-security-review_2025-05-26. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.
Vulnerability classes: vuln/theft · vuln/logic · vuln/accounting
Reproduction: a faithful minimal reproduction of the vulnerable finding — the vulnerable PnL calculation of
DecreasePositionSizeUtils.prepareCallbackValuesis reproduced verbatim (marked@>) with faithful minimal doubles; local deploy, no fork.
Root cause#
When a position is decreased, prepareCallbackValues computes values.existingPnlCollateral from the trade's openPrice and collateralAmount alone and never subtracts the PnL the trader has already realized/withdrawn on this position. The value paid out, collateralSentToTrader = partialTrade.collateralAmount + partialTradePnlCollateral, therefore hands back a proportional slice of the full mark-to-market PnL a second time. The vulnerable lines, reproduced verbatim:
// 5. Calculate existing trade pnl
@> values.existingPnlCollateral =
(TradingCommonUtils.getPnlPercent(
_existingTrade.openPrice,
uint64(values.priceImpact.priceAfterImpact),
_existingTrade.long,
_existingTrade.leverage
) * int256(uint256(_existingTrade.collateralAmount))) /
100 /
int256(ConstantsUtils.P_10);
// 6. Calculate value sent to trader
int256 partialTradePnlCollateral = (values.existingPnlCollateral * int256(values.positionSizeCollateralDelta)) /
int256(values.existingPositionSizeCollateral);
// ...
// @audit - should this consider pnl withdrawal
values.collateralSentToTrader = int256(uint256(_partialTrade.collateralAmount)) + partialTradePnlCollateral;
existingPnlCollateral is derived purely from entry price and stored collateral, so any PnL the trader already withdrew on prior partial operations is re-counted. The audit's own recommendation is to subtract realizedPnlCollateral when computing existingPnlCollateral.
Why it's exploitable here#
Following the finding with concrete numbers (5x leverage, P_10 = 1e10):
- The trader holds a
100e18-collateral position opened atopenPrice = 1000e10that has already realized/withdrawn50e18of PnL on prior operations (realizedPnlCollateral = 50e18). - Price rises 20% to
1200e10; at 5x that is+100%→100e18of mark-to-market PnL. - The trader fully decreases. The buggy path sets
existingPnlCollateral = 100e18(fromopenPrice×collateralAmount) with no subtraction of the50e18already realized. collateralSentToTrader = 100e18(returned collateral)+ 100e18(PnL)= 200e18. The audit-fixed reference path subtracts the realized PnL →100e18 + 50e18 = 150e18.- The extra
50e18is the already-withdrawn PnL paid a second time, drawn from other traders' collateral held in the diamond — the shared reserve is over-drained by exactly50e18.
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in GNSDiamond…:
- L54 — Trade stores position leverage: Setup: the Trade record keeps leverage at 1e3 precision (5000 = 5x), the multiplier that scales a position's mark-to-market PnL on decrease.
- L177 — Closing-fee helper returns zero: During decrease prep the partial-close fee helper is invoked; it returns zero here, so no fee masks the doubled PnL paid out later.
- L240 — Fee inputs read from position: prepareCallbackValues feeds the existing trade's pair index and size delta into the closing-fee calc for the slice being decreased.
- L260 — PnL omits realized withdrawals: Root cause: existingPnlCollateral uses openPrice and collateralAmount but never subtracts realizedPnlCollateral, so already-withdrawn PnL is paid a second time.
- L274 — Reads pooled diamond reserve: The prep reads the collateral the diamond holds — other traders' deposits — the pool from which the inflated payout will be drawn.
- L297 — Fixed path subtracts realized PnL: The reference fix applies the audit's advice, subtracting realizedPnlCollateral, so the identical decrease pays 150e18 instead of 200e18.
- L412 — Decrease routes to buggy path: executeDecreasePositionSizeMarket selects the verbatim prepareCallbackValues for the attacker's diamond, producing the doubled collateralSentToTrader.
- L442 — Exploit opens at 5x leverage: Setup: the driver opens the position at 5x leverage so a +20% price move becomes +100% PnL, maximizing the collateral withdrawn twice.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 58127-c-01-decrease-position-can-be-abused-to-withdraw-pnl-twice-p_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: decrease pays 200e18 on the buggy diamond vs 150e18 on the audit-fixed diamond — the already-realized 50e18 PnL is withdrawn twice and drained from other traders' reserve. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- No executable Forge reproduction is claimed; the historical source/toolchain was unavailable for this finding.
- AuditVault finding: GainsNetwork-security-review_2025-05-26.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Gains Network gTrade: decrease position withdraws realized PnL twice".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.