NGP · Complete repair

Alternative repair: the seller pays the fees

Charges the treasury and reward fees to the seller instead of pulling them from the pool.

Solved

The exploit is blocked for a declared reason, and all 4 security and 10 legitimate-use obligations pass.

Exploitblockedhistorical attack
Security4 / 4obligations pass
Legitimate use10 / 10obligations pass
Canariesnonetamper and breakage flags
acceptsAn exploit-only check would also accept this fix. Here the hidden obligations agree.
The checks

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: blockedIt fails in the attack or profit step, for a reason the task declares.test/poc.t.sol:NGP_PoC:testExploit()BEP20: transfer amount exceeds balance

Security obligations

4 / 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()
  • 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()
  • 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()
  • 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()

Legitimate use · behaviour

5 / 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()
  • 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
src/contracts/Token.sol+6 −9
@@ -291,15 +291,12 @@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            }302            value = value - totalFee;294+            // ALT FIX: the treasury and reward fees are taken from the SELLER,295+            // never out of the pair, so the pool is never debited mid-transfer296+            // and no sync() is needed.297+            super._update(from, treasuryAddress, treasuryAmount);298+            super._update(from, rewardPoolAddress, rewardAmount);299+            value = value - totalFee - burnPoolAmount;303300            emit FlowIntoPool(304301                from,305302                to,

Re-run this grade

offline · same inputs

Needs 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.

Terminal
$ 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/alt_fix__fees_charged_to_seller/Token.sol \
    --backend local --sha256

Expected output: core cb637d0428a31635a00386ccd3ce65430aa1cd71a4cda05752a6806225465b94 and strict 65466943ed575d6709a890c84423e1e0abe1290d881c15cb919aa5dc391c6464.

To check a downloaded grade file instead: shasum -a 256 grade.strict.json prints the strict hash.

Control note

from the repository

A DIFFERENT complete repair: the treasury and reward fees are charged to the seller instead of being pulled out of the pair. Proves the security obligations state the CLASS and do not merely fingerprint the reference implementation.