Nowswap V1 WETH Mis-Scaled KLOSS Invariant Exploit
Exploit Transactions
0xf3158a7ea59586c5570f5532c22e2582ee9adba2408eabe61622595197c50713Victim Addresses
0x9536a78440f72f5e9612949f1848fe5e9d4934ccEthereumLoss Breakdown
Similar Incidents
WETH Drain via Unprotected 0xfa461e33 Callback on 0x03f9-62c0
33%ENF Redeem Decimal Mis-Scaling
32%PLNTOKEN transferFrom burn hook drains WETH reserves
32%SBR reserve desynchronization exploit drains WETH from UniswapV2 pair
32%GPv2Settlement allowance leak lets router drain WETH and USDC
32%AnyswapV4Router WETH9 permit misuse drains WETH to ETH
32%Root Cause Analysis
Nowswap V1 WETH Mis-Scaled KLOSS Invariant Exploit
1. Incident Overview TL;DR
On Ethereum mainnet at block 13229001, an unprivileged attacker drained almost all WETH and USDT liquidity from the Nowswap V1 WETH/USDT pool at 0x9536a78440f72f5e9612949f1848fe5e9d4934cc. The attacker’s externally owned account (EOA) 0x5676e585bf16387bc159fd4f82416434cda5f1a3 deployed a bespoke router 0xa14660a33cc608b902f5bb49c8213bd4c8a4f4ca and, in a single transaction 0xf3158a7ea59586c5570f5532c22e2582ee9adba2408eabe61622595197c50713, executed a sequence of alternating WETH and USDT swap legs against the pool.
Each swap leg exploited a mis-scaled invariant check in the pool’s swap(0x022c0d9f) implementation. The pool mixed packed 112‑bit reserves, raw ERC20 balances, and the stored kLast value using inconsistent 2^112-style scaling, so the “K loss” guard effectively enforced K_post ≥ 0.7 × K_pre instead of the intended K_post ≥ K_pre. This allowed trades where the pool paid out roughly 10× more of WETH or USDT than it received (same-asset in/out), while the true constant product reserve0 * reserve1 shrank by ~30% per leg but the transaction still did not revert.
Before the attack, the pool held 158292004512062229651 WETH wei (~158.29 WETH) and 535718798195 USDT units (~535,718.80 USDT). After the attack transaction, the pool held only 3567797354993498 WETH wei (~0.0036 WETH) and 12075082 USDT units (~12,075.082 USDT). The attacker EOA ended with 158288436714707236153 WETH (~158.29 WETH) and 535706723113 USDT (~535,706.723113 USDT), yielding approximately 316.29 WETH-equivalent profit after paying 0.079428083724520515 ETH in gas.
2. Key Background
Nowswap V1 is a Uniswap-V2-style constant-product automated market maker (AMM). For each pool, it stores reserves as uint112 reserve0 and uint112 reserve1 packed into a single storage slot alongside a timestamp, and tracks a K-like invariant through a stored kLast. The implementation uses DS-math-like helpers for multiplication and subtraction that revert on overflow or underflow, and K-related helpers that enforce per-operation accounting constraints and a “K loss” check.
The victim pool in this incident is the Nowswap V1 WETH/USDT pair at:
- Pool:
0x9536a78440f72f5e9612949f1848fe5e9d4934cc(Ethereum mainnetchainid = 1) - Token0: WETH9 at
0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2(18 decimals) - Token1: USDT at
0xdac17f958d2ee523a2206206994597c13d831ec7(6 decimals)
The pool contract is unverified on Etherscan, so the analysis is based on opcode-level disassembly, Heimdall decompilation, and traces. The disassembled runtime bytecode in artifacts/root_cause/data_collector/iter_2/contract/1/0x9536a7_disassembly.txt shows a dispatcher that routes selector 0x022c0d9f to the main swap implementation:
00000166: JUMPDEST
00000167: DUP1
00000168: PUSH4 0x022c0d9f
0000016d: EQ
0000016e: PUSH2 0x01ae
00000171: JUMPI
...
000005bb: JUMPDEST ; entry into core swap path
The adversary-related cluster consists of:
- EOA
0x5676e585bf16387bc159fd4f82416434cda5f1a3(attacker): deploys the router and receives the stolen assets. - Router
0xa14660a33cc608b902f5bb49c8213bd4c8a4f4ca: an adversary-deployed helper contract that orchestrates repeated calls to the Nowswap pool’sswap(0x022c0d9f)andgetReserves(0x0902f1ac)and forwards all accumulated tokens back to the EOA.
The router is deployed in transaction 0xe8a689ba79028e28e0289af956ee6970bb8e9bdef453875c252f9e01bfed3f68 at block 13228994 by the same EOA, as recorded in artifacts/root_cause/data_collector/iter_1/address/1/0xa14660a3…/txlist.json. The decompiled router code in artifacts/root_cause/data_collector/iter_1/contract/1/0xa14660a3…/decompile/0xa14660a3…-decompiled.sol confirms that the attacker controls a bespoke helper that receives the pool address and token addresses and can perform arbitrary call sequences. Critically, the Nowswap swap entrypoint itself is fully permissionless: there are no owner-only checks in the disassembly or trace, and any unprivileged EOA with sufficient ETH for gas can call it either directly or via their own router.
3. Vulnerability Analysis & Root Cause Summary
The core vulnerability lies in Nowswap V1’s implementation of the K-tracking invariant on the swap(0x022c0d9f) path for this pool. Conceptually, the pool intends to maintain a non-decreasing constant product K = reserve0 * reserve1 (up to fees), enforced via DS-math helpers and a K_LOSS check that compares a K-like quantity derived from the reserves to a stored kLast. However, the swap path combines:
- packed 112‑bit reserves returned by
getReserves, scaled via2^112shifts, - raw ERC20 balances from
balanceOffor WETH (18 decimals) and USDT (6 decimals), - and the stored
kLast,
using inconsistent scaling factors and divisions. The result is that the “K loss” inequality on the swap path does not enforce K_post ≥ K_pre; empirically and from the arithmetic derivation, it permits trades where:
amount_out / amount_in ≈ 10for WETH→WETH and USDT→USDT legs, andK_post / K_pre ≈ 0.7(a ~30% decrease),
without triggering a revert.
This mis-scaled K_LOSS check is the root cause: it allows invariant-breaking same-asset trades that drastically reduce the true constant product while appearing acceptable under the implemented guard. The attacker’s router simply exploits this by alternating WETH and USDT legs to compound the K reduction until the pool is effectively drained.
4. Detailed Root Cause Analysis
4.1 Pre-state and invariant
Immediately before block 13229001 and transaction 0xf3158a7e…0713, the system is in pre-state σ_B defined by:
- Pool reserves from
getReserves:reserve0_pre = 158292004512062229651WETH weireserve1_pre = 535718798195USDT units
- Adversary cluster balances from
erc20_state_diff_balanceOf.json:- EOA and router both hold
0WETH and0USDT.
- EOA and router both hold
- The router has already been deployed at block
13228994by the EOA.
The intended invariant (as reconstructed from the disassembly and invariant analysis) is a constant product K based on reserve0 * reserve1, tracked and checked using DS-math helpers and a stored kLast. The helpers identified in nowswap_swap_formula_and_invariant_iter3.md and the disassembly include:
- A DS-math
mulhelper (e.g., at PC0x1973) that computesa * band reverts if(a * b) / b != a. - A DS-math
subhelper (e.g., at PC0x1b70) that computesx - yand reverts on underflow. - Packing/unpacking helpers (e.g., at PCs
0x219dand0x21af) that pack/unpackreserve0andreserve1into the combined storage slot using2^112shifts. - K-related helpers (e.g., at PCs
0x1bc0,0x1ea1,0x1fff,0x2051) that validate reserve sizes, compute K-like quantities, and perform the K_LOSS check, reverting with"NowswapV1: K_LOSS"if the inequality fails.
These helpers are used consistently in mint/burn paths to ensure K does not decrease unexpectedly. However, on the swap(0x022c0d9f) path for this pool, the way these helpers are wired to actual ERC20 balances and kLast introduces a unit mismatch.
4.2 Swap path behavior for selector 0x022c0d9f
From the disassembly and invariant analysis, the swap path for selector 0x022c0d9f behaves roughly as follows:
- Dispatch (at PC ~
0x0166) comparesmsg.sigto0x022c0d9fand jumps into the swap implementation at PC ~0x01ae. - The function decodes four calldata parameters and sets a reentrancy/lock flag.
- It reads the packed reserves from storage slot 0x08 via a helper, unpacking them into
reserve0andreserve1. - It loads token addresses from storage (WETH9 and USDT) and determines the direction of the leg (WETH or USDT).
- It performs ERC20
transfercalls to move tokens between the pool andmsg.sender(the router). - It calls
balanceOf(address(this))for both tokens to obtain the actual post-transfer balancesb0andb1. - It computes deltas and K-like terms using DS-math
mulandsubhelpers, combining:- unpacked reserves (
reserve0,reserve1) that are logically 112‑bit, - actual balances (
b0,b1) with 18/6 decimals, - and
kLast, with severalSHL/DIVoperations by2^112and2^224.
- unpacked reserves (
- It performs a K_LOSS comparison and, if it passes, updates reserves and
kLastand emits Swap/Sync events.
In the victim pool’s configuration, this computation is mis-scaled. Instead of comparing a consistently scaled version of reserve0 * reserve1 before and after the swap, it compares a value that effectively behaves like:
K_check_post >= c * K_check_prefor somec ≈ 0.7, whereK_check_preandK_check_postare built from a mix of scaled and unscaled quantities. This is confirmed empirically by inspecting the actual Swap and Sync events.
4.3 Concrete invariant breakpoints from on-chain logs
The file artifacts/root_cause/data_collector/iter_3/analysis/nowswap_leg_arithmetic.json reconstructs each swap leg from the pool’s Sync and Swap events. Two representative legs are:
{
"logIndex": 161,
"direction": "WETH",
"amount0In": "5276400150402074421",
"amount0Out": "52764001504020743217",
"pre_reserve0": "110804403158443560855",
"pre_reserve1": "535718798195",
"post_reserve0": "110804403158443560855",
"post_reserve1": "375003158837",
"K_pre": "59360001694755646587976946656725",
"K_post": "41552001197464795129007440525635",
"K_ratio_post_over_pre": 0.7000000001875984,
"amount_ratio_out_over_in": 10.0
},
{
"logIndex": 165,
"direction": "USDT",
"amount1In": "17857293373",
"amount1Out": "178572932731",
"pre_reserve0": "110804403158443560855",
"pre_reserve1": "375003158837",
"post_reserve0": "77563082210910492698",
"post_reserve1": "375003158837",
"K_pre": "41552001197464795129007440525635",
"K_post": "29086400838225356627618022672226",
"K_ratio_post_over_pre": 0.7,
"amount_ratio_out_over_in": 9.999999944056471
}
Snippet origin: Nowswap V1 WETH/USDT leg arithmetic, derived from Swap and Sync events for tx 0xf3158a7e…0713.
Observations:
- In the WETH leg (logIndex 161), the pool’s recorded
reserve0(WETH) remains unchanged, whilereserve1(USDT) decreases. However, the Swap event shows a same-asset trade: the pool receivesamount0InWETH and sends out ~10× that amount asamount0OutWETH. The true constant productKfalls by ~30% (K_post / K_pre ≈ 0.7). - In the USDT leg (logIndex 165),
reserve1(USDT) remains unchanged whilereserve0(WETH) decreases. The pool receives USDT and pays out ~10× more USDT, while K again falls by ~30%.
Despite these large K reductions, the transaction does not revert with "NowswapV1: K_LOSS". The same pattern repeats across subsequent legs, each time reducing K by approximately 30% and paying out ~10× more of the in-token than the pool receives, as documented for all legs in nowswap_leg_arithmetic.json and corroborated by the Sync events in summary_13228800_13229050.json.
4.4 End-state and consistency with ERC20 balances
The ERC20 state diff in artifacts/root_cause/data_collector/iter_2/tx/1/0xf3158a7…/erc20_state_diff_balanceOf.json confirms:
{
"tokens": {
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {
"0x5676e585bf16387bc159fd4f82416434cda5f1a3": { "pre": 0, "post": 158288436714707236153 },
"0x9536a78440f72f5e9612949f1848fe5e9d4934cc": { "pre": 158292004512062229651, "post": 3567797354993498 }
},
"0xdac17f958d2ee523a2206206994597c13d831ec7": {
"0x5676e585bf16387bc159fd4f82416434cda5f1a3": { "pre": 0, "post": 535706723113 },
"0x9536a78440f72f5e9612949f1848fe5e9d4934cc": { "pre": 535718798195, "post": 12075082 }
}
}
}
Snippet origin: ERC20 balance and reserve diff for attack transaction 0xf3158a7e…0713.
These balances match the leg-by-leg arithmetic and the final getReserves state, confirming that:
- The pool’s WETH balance decreased by exactly the EOA’s WETH gain.
- The pool’s USDT balance decreased by exactly the EOA’s USDT gain.
- The router
0xa1466…finishes with zero WETH and zero USDT.
Taken together, the code-level reconstruction, invariant analysis, per-leg K ratios, and ERC20 state diff converge on the same conclusion: the mis-scaled K_LOSS invariant on the swap path allows the constant product to shrink by ~30% per leg while still passing the guard, enabling a chain of same-asset trades that drains the pool.
5. Adversary Flow Analysis
5.1 Adversary-related cluster and setup
The adversary-related cluster consists of:
- EOA (attacker):
0x5676e585bf16387bc159fd4f82416434cda5f1a3 - Router (orchestrator):
0xa14660a33cc608b902f5bb49c8213bd4c8a4f4ca
txlist.json for the router address shows the following key transactions:
- Deployment – tx
0xe8a689ba79028e28e0289af956ee6970bb8e9bdef453875c252f9e01bfed3f68at block13228994:from: attacker EOA0x5676…f1a3to: empty (contract creation)contractAddress:0xa14660a3…f4cavalue:0methodId: consistent with contract deployment.
- Exploit transaction – tx
0xf3158a7ea59586c5570f5532c22e2582ee9adba2408eabe61622595197c50713at block13229001:from: attacker EOAto: router0xa1466…f4cavalue:0input: function selector0x4f4c7231with arguments including the pool address and token addresses.
The Etherscan-style metadata in artifacts/root_cause/seed/1/0xf3158a7…/metadata.json confirms that this transaction is EIP-1559, pays 0.079428083724520515 ETH in gas, and has no ETH value transferred to the router.
5.2 Exploit execution in tx 0xf3158a7e…0713
The internal trace in trace.cast.log shows the router calling the pool’s swap(0x022c0d9f) multiple times in a single transaction, with intervening calls to getReserves and ERC20 transfers. Combined with Swap and Sync logs, the leg arithmetic file reconstructs the sequence:
- The router starts with zero WETH and USDT; all liquidity is in the pool.
- It calls
swapto execute a WETH leg where WETH is both sent in and paid out (same-token in/out), while the recorded WETH reserve stays constant and the USDT reserve drops. - It then executes a USDT leg where USDT is both sent in and paid out, while the USDT reserve stays constant and the WETH reserve drops.
- This alternation repeats several times, each time:
- Paying out roughly 10× more of the in-token than the router sends in.
- Reducing the true constant product K by about 30%.
- Keeping one of the reserves unchanged across a leg while dramatically reducing the other.
The per-leg details are captured in nowswap_leg_arithmetic.json and the Sync event summary summary_13228800_13229050.json, which together show K ratios of approximately 0.7 for each leg and amount_out/amount_in ratios around 10.
At the end of the sequence:
- The router transfers all accumulated WETH and USDT to the EOA.
- The router’s balances return to zero.
- The pool’s reserves collapse to near zero in both assets.
5.3 ACT opportunity characterization
This incident is an ACT (anyone-can-take) opportunity:
- The exploited function
swap(0x022c0d9f)on the Nowswap V1 pool is fully permissionless; there are no owner-only checks in the disassembly or traces. - The router is an adversary-deployed helper, but any unprivileged account could deploy an equivalent router that issues the same call pattern with the same calldata from the same pre-state σ_B.
- The success predicate (net increase in WETH-equivalent value for the adversary cluster) depends only on:
- the pool’s on-chain code and state,
- publicly observable token balances and reserves,
- and the ability to submit the same sequence of transactions.
Thus, any unprivileged actor with access to the same public information and sufficient gas budget can reproduce the exploit against the same vulnerable Nowswap pool.
6. Impact & Losses
6.1 Pool-level impact
Using the ERC20 state diffs and getReserves snapshots:
- Before the attack tx:
- Pool WETH balance:
158292004512062229651wei (~158.292004512062229651 WETH). - Pool USDT balance:
535718798195units (~535,718.798195 USDT).
- Pool WETH balance:
- After the attack tx:
- Pool WETH balance:
3567797354993498wei (~0.003567797354993498 WETH). - Pool USDT balance:
12075082units (~12,075.082 USDT).
- Pool WETH balance:
Effectively, the Nowswap V1 WETH/USDT pool lost:
- WETH:
158.288436714707236153(difference between pre and post WETH balances). - USDT:
535706.723113(difference between pre and post USDT balances).
The constant product invariant collapsed by many orders of magnitude, and the pool’s liquidity became unusable at any reasonable price. LPs in this pair effectively lost their deposited WETH and USDT to the attacker.
6.2 Attacker profit
The attacker portfolio analysis in attacker_portfolio_pnl_weth.json shows:
- Attacker EOA final balances:
- WETH:
158288436714707236153wei (~158.288436714707236153 WETH). - USDT:
535706723113units (~535,706.723113 USDT).
- WETH:
- Conversion rate used (from a Uniswap V2 WETH/USDT pool at block 13229001):
price_usdt_in_weth ≈ 0.0002950805850088843312255761868.
- Total attacker portfolio value in WETH:
- Before tx: ≈
0WETH-equivalent. - After tx:
316.3650899640836932259039222WETH-equivalent.
- Before tx: ≈
- Gas cost:
fee_paid_eth = 0.079428083724520515ETH.
Net profit in reference asset (WETH):
316.3650899640836932259039222 - 0.079428083724520515 ≈ 316.2856618803591727109039222WETH.
There is no evidence, from the traces or cross-protocol checks in the artifacts, of secondary impacts such as price oracle manipulation, cascading liquidations, or governance side effects. The damage appears localized to the Nowswap V1 WETH/USDT pool and its LPs.
7. References
-
Seed transaction trace and metadata
- Transaction:
0xf3158a7ea59586c5570f5532c22e2582ee9adba2408eabe61622595197c50713(Ethereum mainnet, block13229001). - Sources:
trace.cast.logandmetadata.jsonunderartifacts/root_cause/seed/1/0xf3158a7….
- Transaction:
-
Victim pool code and invariant analysis
- Pool address:
0x9536a78440f72f5e9612949f1848fe5e9d4934cc. - Disassembly:
artifacts/root_cause/data_collector/iter_2/contract/1/0x9536a7…/disassemble/0x9536a7_disassembly.txt. - Swap formula and K_LOSS derivation:
artifacts/root_cause/data_collector/iter_3/analysis/nowswap_swap_formula_and_invariant_iter3.md.
- Pool address:
-
On-chain logs and per-leg arithmetic
- Sync/Swap event summary:
artifacts/root_cause/data_collector/iter_2/contract/1/0x9536a7…/logs/summary_13228800_13229050.json. - Leg-by-leg arithmetic for tx
0xf3158a7e…0713:artifacts/root_cause/data_collector/iter_3/analysis/nowswap_leg_arithmetic.json.
- Sync/Swap event summary:
-
ERC20 balance diffs and reserves
- ERC20 state diff for WETH and USDT in the attack tx:
artifacts/root_cause/data_collector/iter_2/tx/1/0xf3158a7…/erc20_state_diff_balanceOf.json. - Pre/post
getReservessnapshots embedded in the same file.
- ERC20 state diff for WETH and USDT in the attack tx:
-
Attacker cluster and router
- Attacker EOA:
0x5676e585bf16387bc159fd4f82416434cda5f1a3. - Router:
0xa14660a33cc608b902f5bb49c8213bd4c8a4f4ca. - Router deployment and exploit tx history:
artifacts/root_cause/data_collector/iter_1/address/1/0xa14660a3…/txlist.json. - Router decompiled code:
artifacts/root_cause/data_collector/iter_1/contract/1/0xa14660a3…/decompile/0xa14660a3…-decompiled.sol.
- Attacker EOA:
-
Attacker profit computation
- Attacker portfolio PnL in WETH:
artifacts/root_cause/data_collector/iter_3/analysis/attacker_portfolio_pnl_weth.json. - Underlying WETH/USDT price snapshot from a Uniswap V2 pool at block
13229001used for valuation.
- Attacker portfolio PnL in WETH: