Reproduced Exploit
Gradient Market Maker Pool Exploit — Mixed-Unit LP Share Accounting (ETH wei summed 1:1 with ERC-20 token units)
GradientMarketMakerPool is a per-token "market maker" pool where users deposit ETH + a token and receive LP shares. The pool computes everything — LP shares, total liquidity, and per-user balances — by adding the raw token amount to the raw msg.value (ETH in wei) as if 1 token unit == 1 wei:
Loss
~$5,000 — 3.0109 ETH of honest LP liquidity drained from the GRAY pool (PoC nets 2.346 WETH to the attacker a…
Chain
Ethereum
Category
arithmetic
Date
Jun 2025
Source
DeFiHackLabs
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. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2025-06-GradientMakerPool_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/GradientMakerPool_exp.sol.
Vulnerability classes: vuln/arithmetic/decimal-mismatch · vuln/logic/price-calculation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: contracts_GradientMarketMakerPool.sol.
Key info#
| Loss | ~$5,000 — 3.0109 ETH of honest LP liquidity drained from the GRAY pool (PoC nets 2.346 WETH to the attacker after repaying the flash loan) |
| Vulnerable contract | GradientMarketMakerPool — 0x37Ea5f691bCe8459C66fFceeb9cf34ffa32fdadC |
| Victim / pool | Internal per-token liquidity pool for the GRAY token (Gradient, 0xa776A95223C500E81Cb0937B291140fF550ac3E4) |
| Registry | GradientRegistry — 0x893D41635725d8EA6F528D3f3F3DF3E9e8076934 |
| Attacker EOA | 0x1234567a98230550894bf93e2346a8bc5c3b36e3 |
| Attacker contract | 0xcb4059bb021f4cf9d90267b7961125210cedb792 |
| Attack tx | 0xb5cfa3f86ce9506e2364475dc43c44de444b079d4752edbffcdad7d1654b1f67 |
| Chain / block / date | Ethereum mainnet / fork at 22,765,113 / June 2025 |
| Compiler | Solidity v0.8.26, optimizer 1 run |
| Bug class | Broken share accounting — ETH (wei) summed 1:1 with ERC-20 token base units, letting a cheap token mint a dominant share of an ETH-funded pool |
| Post-mortem | https://t.me/defimon_alerts/1340 |
TL;DR#
GradientMarketMakerPool is a per-token "market maker" pool where users deposit ETH + a token
and receive LP shares. The pool computes everything — LP shares, total liquidity, and per-user
balances — by adding the raw token amount to the raw msg.value (ETH in wei) as if 1 token unit ==
1 wei:
lpSharesToMint = (tokenAmount + msg.value) * pool.totalLPShares / pool.totalLiquidity; // mixed units!
GRAY is a low-value ERC-20. At the fork block the GRAY/WETH Uniswap pair priced 950 GRAY ≈ 0.632 ETH.
But to the pool, depositing 950 GRAY (950e18 base units) registers as 950.63 "units" of liquidity,
while the entire honest pool only held 3.02 ETH ≈ 3.02 "units."
So the attacker:
- Flash-borrows 3 WETH from Morpho Blue.
- Buys 1000 GRAY on Uniswap for ≈ 0.665 WETH (then uses 950 of it).
- Calls
provideLiquidity{value: 0.632 ETH}(GRAY, 950e18, 0)— minting 707,569 LP shares = 99.68% of the entire pool (:155-162). - Immediately calls
withdrawLiquidity(GRAY, 10000)(100%) — receiving 99.68% ofpool.totalEth= 3.643 ETH plus 946.99 GRAY back (:213-216). - Re-wraps ETH and repays the 3 WETH flash loan.
Net result: the attacker put in ≈ 0.632 ETH (plus 3.01 GRAY of swap rounding) and pulled out 3.643
ETH, stealing the 3.0109 ETH that prior honest LPs had deposited. The tokenAmount + msg.value
unit confusion is the entire bug.
Background — what the Gradient pool does#
GradientMarketMakerPool (source)
is a custom liquidity / market-making vault, one pool per token. Its design intent:
- A market maker provides liquidity for a token by sending ETH + that token in roughly the
ratio implied by the token's Uniswap V2 reserves (
provideLiquidity, :107-184). - They receive LP shares proportional to their contribution and can later withdraw a percentage
of the pool (
withdrawLiquidity, :189-270). - A separate
orderbookcontract (gated by the registry) pulls ETH/token out of the pool to fulfil orders, and an authorized reward distributor streams fee rewards viareceiveFeeDistribution(:274-283) and theaccRewardPerShareaccumulator.
The per-pool and per-user accounting structs (interface):
struct PoolInfo { uint256 totalEth; uint256 totalToken; uint256 totalLiquidity;
uint256 totalLPShares; uint256 accRewardPerShare; uint256 rewardBalance; address uniswapPair; }
struct MarketMaker { uint256 tokenAmount; uint256 ethAmount; uint256 lpShares; uint256 rewardDebt; uint256 pendingReward; }
The on-chain GRAY pool state at the fork block (recovered from the PoolInfo storage slots in the
attack trace, output.txt:107-115):
| Pool field | Value (pre-attack) |
|---|---|
totalEth | 3.0225 ETH ← genuine ETH deposited by prior LP(s) |
totalToken | 0 GRAY (the prior LP's tokens had been withdrawn/used) |
totalLiquidity | 3.0225 (= 3.0225 ETH + 0 GRAY, mixed units) |
totalLPShares | 2,249.68 |
| GRAY/WETH Uniswap reserves | 260,545 GRAY / 173.36 WETH |
The key facts: the pool held 3.02 real ETH, and its totalLPShares (2,249.68) was tiny —
because the original LP had deposited a small ETH amount plus a (since-removed) modest token amount.
A fresh deposit of 950 GRAY would mint shares against this tiny base.
The vulnerable code#
1. provideLiquidity — LP shares = token amount plus ETH, in mixed units#
// contracts_GradientMarketMakerPool.sol
uint256 expectedTokens = (msg.value * reserveToken) / reserveETH; // ratio check vs. Uniswap
require(tokenAmount >= (expectedTokens * 99) / 100 &&
tokenAmount <= (expectedTokens * 101) / 100, "Invalid liquidity ratio");
IERC20(token).safeTransferFrom(msg.sender, address(this), tokenAmount);
...
// Calculate LP shares to mint
uint256 lpSharesToMint;
if (pool.totalLPShares == 0) {
lpSharesToMint = tokenAmount + msg.value; // ⚠️ wei + token base units
} else {
uint256 totalContribution = tokenAmount + msg.value; // ⚠️ wei + token base units
lpSharesToMint = (totalContribution * pool.totalLPShares) / pool.totalLiquidity;
}
...
pool.totalLiquidity += tokenAmount + msg.value; // ⚠️ same mixed-unit sum
pool.totalEth += msg.value;
pool.totalToken += tokenAmount;
pool.totalLPShares += lpSharesToMint;
Source: :126-184.
The "ratio check" (tokenAmount within ±1% of expectedTokens) only enforces that the quantities
match the Uniswap price ratio. It does not prevent the unit confusion: a deposit valued at 0.632
ETH on the open market is recorded as 950.63 units of pool liquidity because 950e18 token base
units are added straight onto msg.value wei.
2. withdrawLiquidity — ETH paid out proportional to (mis-minted) shares#
// contracts_GradientMarketMakerPool.sol
uint256 lpSharesToBurn = (mm.lpShares * shares) / 10000; // shares = 10000 => 100% of attacker LP
uint256 actualTokenWithdraw = (pool.totalToken * lpSharesToBurn) / pool.totalLPShares;
uint256 actualEthWithdraw = (pool.totalEth * lpSharesToBurn) / pool.totalLPShares; // ⚠️ pays real ETH
...
IERC20(token).safeTransfer(msg.sender, actualTokenWithdraw);
(bool success, ) = payable(msg.sender).call{value: actualEthWithdraw}("");
Source: :209-262.
Because the attacker's mis-minted shares are 99.68% of totalLPShares, this line hands them
99.68% of the pool's real ETH (totalEth) — the ETH that honest LPs deposited — in exchange for
giving back essentially the same GRAY they put in.
Root cause — why it was possible#
The pool treats ETH and an arbitrary ERC-20 as fungible at a 1:1 base-unit rate for the purpose of
share accounting. Both ETH and GRAY are 18-decimal, so tokenAmount + msg.value doesn't even
overflow or look obviously wrong — but it silently asserts that 1 GRAY == 1 ETH, which is off by
roughly 3 orders of magnitude at the real GRAY price.
Concretely, three design decisions compose into a critical bug:
-
Mixed-unit share minting.
lpSharesToMint = (tokenAmount + msg.value) * totalLPShares / totalLiquidity—totalLiquidityis itself a mixed-unit sum, so the only thing this preserves is "your mixed-unit contribution relative to the existing mixed-unit pool." Deposit a large amount of a cheap 18-decimal token and you mint a large share for almost no real value. -
ETH payout is proportional to shares, not to the value/ETH you contributed.
withdrawLiquiditypaystotalEth * lpSharesToBurn / totalLPShares. The honest pool had 3.02 ETH and only 2,249 shares. The attacker minted 707,569 shares for 0.632 ETH, then withdrew the pool's ETH pro-rata to those inflated shares. -
The ratio check is unit-blind. The
±1%check tiestokenAmounttomsg.value * reserveToken / reserveETH, i.e. to the quantity ratio the AMM uses. It never checks that the share-minting math and the ratio math use a consistent unit of account. A correct design would either (a) denominate shares purely in ETH value (expectedTokens-equivalent), or (b) value the token in ETH using the Uniswap reserves before summing.
Put differently: the pool's "liquidity" is denominated in a Frankenstein unit of wei + token-base-units.
Whoever can cheaply acquire a lot of token-base-units (any holder of a low-priced 18-decimal token) can
mint a dominant claim on the pool's genuinely valuable ETH side.
The flash loan is not essential to the vulnerability — it only supplies working capital to buy the GRAY and provides a clean intra-tx wrapper. The bug is purely in the share/liquidity accounting.
Preconditions#
- The GRAY pool is initialized and already holds real ETH from prior liquidity providers
(
pool.totalEth > 0,pool.totalLPShares > 0). Here: 3.02 ETH / 2,249.68 shares. - The attacker can acquire enough of the cheap token to pass the
±1%ratio check for a deposit large enough to mint a dominant share — i.e.,tokenAmount≈msg.value * reserveToken / reserveETH. Buying GRAY on Uniswap satisfies this trivially because the pool reads the same Uniswap reserves. - Capital to fund the GRAY buy + the small ETH deposit. Fully recovered intra-transaction, hence flash-loanable (the PoC borrows 3 WETH from Morpho Blue at zero fee).
Attack walkthrough (with on-chain numbers from the trace)#
All figures are taken directly from the events / storage diffs in output.txt. The
attacker contract is shown in the trace as GradientPool [0x7FA9385…] (the Foundry test address);
the real vulnerable contract is 0x37Ea5f….
| # | Step | Concrete numbers (from trace) |
|---|---|---|
| 0 | Pre-attack pool state (:107-115) | totalEth = 3.0225 ETH, totalToken = 0 GRAY, totalLPShares = 2,249.68 |
| 1 | Flash loan 3 WETH from Morpho Blue (:23-26) | borrow 3.0 WETH (fee 0) |
| 2 | weth.withdraw(1e18) → unwrap 1 WETH to ETH (:37-43) | hold 1 ETH for the swap + deposit |
| 3 | Swap WETH → 1000 GRAY on Uniswap (swapTokensForExactTokens) (:49-79) | spent 0.6648 WETH, received 1000 GRAY; pair reserves read 260,545 GRAY / 173.36 WETH |
| 4 | provideLiquidity{value: 0.63209 ETH}(GRAY, 950e18, 0) (:85-116) | expectedTokens = 950 ✓ ratio check; mints 707,569.10 LP shares (mixed-unit 950+0.632 against tiny pool); pool now totalEth 3.6546, totalToken 950, totalLPShares 709,818.78 |
| 5 | withdrawLiquidity(GRAY, 10000) — burn 100% of attacker's shares (:117-135) | burns 707,569.10 shares (99.68% of pool); pays 946.99 GRAY back and 3.642989 ETH via call{value:} |
| 6 | weth.deposit{value: 4.0109 ETH}() to re-wrap, then repay 3 WETH (:136-148) | flash loan repaid (transferFrom 3 WETH to Morpho) |
| 7 | Final attacker WETH balance (:152-156) | 2.546098 WETH (started 0.2 WETH) |
The minting math, verified line-for-line against the trace (:155-162):
contribution = tokenAmount + msg.value = 950e18 + 0.632090074270700494e18 = 950.632090… (mixed units)
lpSharesToMint = contribution * totalLPShares / totalLiquidity
= 950.632090… * 2249.676575 / 3.022481813096655
= 707,569.102721 LP shares ← matches trace lpSharesBurned exactly
attacker's share = 707,569.10 / (2,249.68 + 707,569.10) = 99.6831%
The withdraw math (:213-216):
actualEthWithdraw = totalEth * lpBurn / totalLPShares = 3.654572 * 707569.10 / 709818.78 = 3.642989 ETH ✓
actualTokenWithdraw = totalToken * lpBurn / totalLPShares = 950.0 * 707569.10 / 709818.78 = 946.989101 GRAY ✓
Profit / loss accounting#
| Item | Amount |
|---|---|
ETH into pool (provideLiquidity value) | −0.632090 ETH |
ETH out of pool (withdrawLiquidity) | +3.642989 ETH |
| Net ETH siphoned from the pool | +3.010899 ETH |
| GRAY deposited | −950.000000 GRAY |
| GRAY returned by pool | +946.989101 GRAY |
| GRAY net cost (rounding only) | −3.010899 GRAY (≈ $0) |
| WETH spent buying 1000 GRAY on Uniswap | −0.664801 WETH |
| Attacker WETH balance before / after | 0.200000 → 2.546098 WETH |
| Realized attacker profit (WETH) | +2.346098 WETH |
The pool was drained of essentially all of its 3.0109 ETH of honest liquidity. The gap between the 3.0109 ETH stolen and the 2.346 WETH the attacker keeps is the ≈ 0.665 WETH paid to Uniswap to source the GRAY (a portion of which it gets back as GRAY value) — i.e. the slippage cost of acquiring the cheap token used to mint the shares.
Diagrams#
Sequence of the attack#
Pool state evolution#
Why the share math is theft: unit-of-account confusion#
Remediation#
-
Never sum ETH (wei) with raw token amounts. Pick a single unit of account. The cleanest fix is to denominate LP shares and
totalLiquiditypurely in ETH value: value the deposited token in ETH using the Uniswap reserves (the contract already reads them — that's whatexpectedTokens/getReservescompute) and mint shares againstmsg.value + ethValueOfTokens, never againstmsg.value + tokenAmount.// value the token side in ETH before summing uint256 tokenEthValue = (tokenAmount * reserveETH) / reserveToken; // == msg.value within the ±1% band uint256 contributionEth = msg.value + tokenEthValue; // single unit: wei lpSharesToMint = pool.totalLPShares == 0 ? contributionEth : (contributionEth * pool.totalLPShares) / pool.totalLiquidity; // totalLiquidity also in ETH -
Make
totalLiquidityandtotalLPSharesconsistent with the payout basis. Withdrawals pay out realtotalEth(and realtotalToken) pro-rata to shares, so shares must be earned in a unit that is consistent with that real-asset basis — otherwise the conversion ratio between "share" and "ETH" drifts with the token's price. -
Decouple the two asset sides. A market-maker pool holding both ETH and a token should track and redeem each side independently (LP gets back the same proportion of ETH and of token they contributed), rather than collapsing both into one fungible "liquidity" scalar.
-
Add an ETH-value invariant check on deposits. Reject deposits where the share minted would imply an ETH claim wildly larger than the ETH actually provided (e.g.
actualEthWithdraw(shares) <= msg.value * (1 + ε)at deposit time). -
Treat decimals/price assumptions explicitly. Even tokens that happen to share 18 decimals with ETH are not 1:1 in value; any accounting that adds token units to ETH units is unsound regardless of decimals.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to compile under forge test's whole-project build). The PoC imports
../basetest.sol (+ its tokenhelper.sol) and ../interface.sol, which were copied into the project
root so the relative imports resolve.
_shared/run_poc.sh 2025-06-GradientMakerPool_exp -vvvvv
- RPC: an Ethereum mainnet archive endpoint is required (fork block 22,765,113).
foundry.tomluses an Infura archive endpoint; if a key returns 401/429, rotate to a different Infura key. - Result:
[PASS] testExploit()with the attacker's WETH balance rising from 0.2 → 2.546098 WETH.
Expected tail:
Ran 1 test for test/GradientMakerPool_exp.sol:GradientPool
[PASS] testExploit() (gas: 421388)
Logs:
Attacker Before exploit WETH Balance: 0.200000000000000000
Attacker After exploit WETH Balance: 2.546098333159028268
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: PoC header & DEFIMON post-mortem — https://t.me/defimon_alerts/1340 (GradientMarketMakerPool, Ethereum, ~$5K).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2025-06-GradientMakerPool_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
GradientMakerPool_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Gradient Market Maker Pool Exploit".
- 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.