NGP · Breaks legitimate use
Repair that closes selling
The reference repair plus every sell reverting. The market is closed, not fixed.
The exploit test fails for a reason the task does not declare, which never counts as a block. The security obligations fail too, because no sell can run at all, and the buy-then-sell round trip breaks.
What ran, and what it said.
Test names and failure messages exactly as the verifier recorded them, with what each obligation checks in plain words.
Exploit
1 replay · profit over 100,000 USDT- –The DeFiHackLabs reproduction of the attack: not countedIt fails for a reason the task does not declare, which never counts as a block.test/poc.t.sol:NGP_PoC:testExploit()TransferHelper: TRANSFER_FROM_FAILED
Security obligations
0 / 4 pass- ✗A direct transfer to the pool moves nothing out of itCatches a fix that special-cases the router and leaves the same branch open.test/hidden/security.t.sol:NGP_Security:test_direct_transfer_to_the_pair_moves_nothing_out_of_it()Sell not allowed
- ✗An ordinary sell moves no tokens out of the poolThe original moved 70% of every sell out of the pair.test/hidden/security.t.sol:NGP_Security:test_ordinary_sell_moves_no_tokens_out_of_the_pair()TransferHelper: TRANSFER_FROM_FAILED
- ✗The pool keeps every token a sell sends itAsserted on balances, so a fix that only hides the transfer events is still caught.test/hidden/security.t.sol:NGP_Security:test_pair_keeps_every_token_an_ordinary_sell_sends_it()TransferHelper: TRANSFER_FROM_FAILED
- ✗Treasury and reward fees are not paid out of the poolFees must be funded by the party making the trade.test/hidden/security.t.sol:NGP_Security:test_treasury_and_reward_are_not_funded_out_of_the_pair()TransferHelper: TRANSFER_FROM_FAILED
Legitimate use · behaviour
4 / 5 pass- ✗A buy-then-sell round trip through the pool worksThe sell leg is where the bug lived, so deleting selling fails here.test/hidden/amm_regression.t.sol:NGP_AmmRegression:test_amm_buy_then_sell_round_trip()TransferHelper: TRANSFER_FROM_FAILED
- ✓Trading stays switched onChecks the buy and sell switches in storage. Turning either off is not a fix.test/hidden/amm_regression.t.sol:NGP_AmmRegression:test_trading_flags_still_open()
- ✓Token metadata and the pool’s balance are intactDecimals still 18, supply present, the pool still holds NGP.test/hidden/regression.t.sol:NGP_Regression:test_metadata_preserved()
- ✓A wallet-to-wallet transfer workstest/hidden/regression.t.sol:NGP_Regression:test_normal_transfer_works()
- ✓The price view still workstest/hidden/regression.t.sol:NGP_Regression:test_price_view_works()
Legitimate use · interface
5 / 5 pass- ✓Original functions still answerCalls the original functions and requires an answer other than “no such function”.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_dispatch()
- ✓Every original function is still in the dispatch tableWalks the patched bytecode and requires each original selector in the dispatcher.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_preserved()
- ✓The patched contract has codetest/hidden/invariants_auto.t.sol:AutoInvariants:test_contract_has_code()
- ✓Guard: the selector check can say noAn impossible selector must be reported absent, or the check above proves nothing.test/hidden/invariants_auto.t.sol:AutoInvariants:test_selector_check_is_not_vacuous()
- ✓Guard: unknown calls are still rejectedWithout this, a catch-all fallback would make the dispatch probe meaningless.test/hidden/invariants_auto.t.sol:AutoInvariants:test_unknown_selector_is_rejected()
The patch
against the original source@@ -275,7 +275,7 @@275275276276 // sell or add liquidity277277 if (to == mainPair) {278− require(sellState, "Sell not allowed");278+ require(false, "Sell not allowed"); // VARIANT279279 // check transfer cooldown280280 _checkTransferCooldown(from);281281@@ -291,14 +291,10 @@291291 uint256 treasuryAmount = (value * treasuryRate) / RATIO_PRECISION;292292 uint256 rewardAmount = (value * rewardRate) / RATIO_PRECISION;293293 uint256 burnPoolAmount = treasuryAmount + rewardAmount;294− uint poolAmount = this.balanceOf(mainPair);295− if (poolAmount > burnPoolAmount) {296− // treasury pool297− super._update(mainPair, treasuryAddress, treasuryAmount);298− // reward pool299− super._update(mainPair, rewardPoolAddress, rewardAmount);300− IUniswapV2Pair(mainPair).sync();301− }294+ // PATCH: removed mid-transfer pool draining + sync() that let a seller295+ // re-price reserves within their own sell. Fees are taken from the seller,296+ // not silently pulled out of the pair, so the AMM invariant is preserved.297+ treasuryAmount; rewardAmount; burnPoolAmount; // retained for event below302298 value = value - totalFee;303299 emit FlowIntoPool(304300 from,
Re-run this grade
offline · same inputsNeeds Foundry 1.7.1 with solc 0.8.30, 0.8.26 and 0.8.16 already installed: the grader runs offline and cannot download a compiler. Python 3.12 or later. Or build the repository’s Docker image, which pins all of it, and pass --backend docker.
$ git clone https://github.com/FarseenSh/evmpatch-env.git && cd evmpatch-env
$ git checkout 165c0ed
$ python -m evmpatch_env.sandbox tasks/ngp_2025_09 \
--patch worked_example/ngp_2025_09/controls/fix_plus_sell_path_disabled/Token.sol \
--backend local --sha256Expected output: core fee0babf9d17e350047e2f7d06b0a390bbf0684783fd6bc7849f6b92b28fea5d and strict ad7643920f4088a9ad8d9aace1e555893d7268f8e7e3b0c12d1794e27104f857.
To check a downloaded grade file instead: shasum -a 256 grade.strict.json prints the strict hash.
Control note
from the repositoryThe reference repair plus closing the sell side outright — 'fix' the AMM bug by making every sell revert, with sellState left true so the flags invariant still passes.