MCAI · Complete repair

Alternative repair: spend the allowance first

Checks and spends the caller’s allowance before any token moves, and never calls the flawed helper. Different code from the reference, same property.

Solved

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

Exploitblockedhistorical attack
Security4 / 4obligations pass
Legitimate use12 / 12obligations 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 11 ETH
  • The DeFiHackLabs reproduction of the attack: blockedIt fails in the attack or profit step, for a reason the task declares.test/poc.t.sol:MCAI_PoC:testExploit()ERC20: transfer amount exceeds allowance

Security obligations

4 / 4 pass
  • The tax wallet cannot move an ordinary holder’s tokens without approvalThe bug stated for every holder, not just the pool the exploit drained.test/hidden/security.t.sol:MCAI_Security:test_taxwallet_cannot_move_holder_tokens_without_approval()
  • The tax wallet cannot move the pool’s tokens without approvalChecked on the balance change itself, so a fix that breaks the allowance view cannot game it.test/hidden/security.t.sol:MCAI_Security:test_taxwallet_cannot_move_pair_tokens_without_approval()
  • With approval, the tax wallet’s allowance is debited by exactly what it movesThe original moved the tokens and debited nothing.test/hidden/security.t.sol:MCAI_Security:test_taxwallet_transferFrom_debits_allowance()
  • No one can burn a holder’s tokens to the dead wallet without approvalThe second branch of the same broken helper. A fix aimed only at the tax wallet leaves it open.test/hidden/security.t.sol:MCAI_Security:test_unapproved_spender_cannot_burn_to_dead_wallet()

Legitimate use · behaviour

7 / 7 pass
  • Buying through the pool the incident used still worksIncludes the token’s fee path.test/hidden/regression.t.sol:MCAI_Regression:test_amm_buy_works()
  • Approve, then transferFrom, works and debits the allowance exactlyA fix that disables transferFrom, or never debits, fails here.test/hidden/regression.t.sol:MCAI_Regression:test_approved_transferFrom_works_and_debits_allowance()
  • Token metadata and the pool’s balance are intactDecimals still 9, supply and symbol present, the pool still holds MCAI.test/hidden/regression.t.sol:MCAI_Regression:test_metadata_preserved()
  • A holder-to-holder transfer workstest/hidden/regression.t.sol:MCAI_Regression:test_normal_transfer_works()
  • With approval, the tax wallet can still spend like anyone elseA fix cannot close the hole by blocking the tax wallet forever.test/hidden/regression.t.sol:MCAI_Regression:test_taxwallet_transferFrom_with_approval_moves_tokens()
  • transferFrom takes exactly the amount out of the senderCatches a fix that quietly credits the sender back: an unlimited mint.test/hidden/regression.t.sol:MCAI_Regression:test_transferFrom_debits_the_sender_exactly()
  • A spender with no approval still cannot move tokensA fix that removes the allowance check altogether fails here.test/hidden/regression.t.sol:MCAI_Regression:test_unapproved_spender_cannot_move_tokens()

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+4 −6
@@ -264,13 +264,11 @@264264        address recipient,265265        uint256 amount266266    ) public override returns (bool) {267+        require(sender != address(0), "ERC20: transfer from the zero address");268+        uint256 currentAllowance = _allowances[sender][_msgSender()];269+        require(currentAllowance >= amount, "ERC20: transfer amount exceeds allowance");270+        _approve(sender, _msgSender(), currentAllowance - amount);267271        _transfer(sender, recipient, amount);268        uint256 _amount = _decreaseAllowance(sender, recipient, amount);269        _approve(270            sender,271            _msgSender(),272            _allowances[sender][_msgSender()].sub( _amount, "ERC20: transfer amount exceeds allowance")273        );274272        return true;275273    }276274

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/mcai_2025_01 \
    --patch worked_example/mcai_2025_01/controls/alt_complete_fix__spend_allowance_first/Token.sol \
    --backend local --sha256

Expected output: core f34d89bad812c6b59ac44136ceea05b35044b03c38bbdaafbe8b391ae5c1963e and strict 253011648b813ff772ba4ba1a49e22c59abcc17e178cbe23cb8069f973ea61a5.

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

Control note

from the repository

A complete repair written independently of the reference: transferFrom checks and spends the caller's allowance before moving any token and never consults _decreaseAllowance. Expected: solved, 1.0, no canary. Its core and strict hashes equal the reference repair's: a grade records outcomes and reasons, not the patch that produced them.

About this receipt. This fix shares no code with the reference repair, yet its core and strict hashes are identical to the reference receipt’s: a grade records test outcomes and reasons, not the patch. Without its zero-address guard the same repair grades inconclusive, because the interface probe then reads chain state the recording never captured.