GoldReserve · Incomplete repair
Settles on mints only
Carries the debt on mints but not on transfers. The address-hopping the incident repeated 22 times survives.
The exploit is blocked, but 1 of 3 security obligations fails: the repair is incomplete.
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 12 BNB- ✓The DeFiHackLabs reproduction of the attack: blockedIt fails in the attack or profit step, for a reason the task declares.test/poc.t.sol:GoldReserve_PoC:testExploit()Nada a reclamar
Security obligations
2 / 3 pass- ✓Holders are paid only for the NFTs they held when the profit accruedTwo holders with the same stake must be paid the same, however many NFTs one mints later.test/hidden/security.t.sol:GoldReserve_Security:test_a_holder_is_paid_only_for_the_nfts_it_held_when_profit_accrued()
- ✗Moving paid NFTs to a fresh address cannot claim the same profit againThe step the incident repeated 22 times, restated without a flash loan.test/hidden/security.t.sol:GoldReserve_Security:test_moving_the_nfts_to_a_fresh_address_cannot_reclaim_the_same_profit()the same accrual was paid a second time after the NFTs moved to a fresh address: 512820512820512820 != 0
- ✓An NFT minted after a deposit earns nothing from that depositThe mint half of the bug: deposit first, mint second.test/hidden/security.t.sol:GoldReserve_Security:test_nft_minted_after_a_deposit_inherits_no_profit()
Legitimate use · behaviour
4 / 4 pass- ✓Collection metadata is intactSymbol, name, mint price, supply, owner and token URI still read back.test/hidden/regression.t.sol:GoldReserve_Regression:test_metadata_preserved()
- ✓A holder from before a deposit can claim their share, exactly onceThe contract’s whole purpose.test/hidden/regression.t.sol:GoldReserve_Regression:test_mint_then_deposit_then_claim_pays_the_holder()
- ✓Minting at the listed price worksAnd updates the accounting.test/hidden/regression.t.sol:GoldReserve_Regression:test_mint_works()
- ✓An ordinary NFT transfer workstest/hidden/regression.t.sol:GoldReserve_Regression:test_nft_transfer_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@@ -3937,6 +3937,43 @@39373937 require(success, "Falha ao enviar fundos");39383938 }393939393940+ // PATCH: carry the claim debt with the tokens.3941+ //3942+ // Entitlement is computed from the CURRENT balance (`balance * accumulatedProfitPerNFT`)3943+ // while the paid-out record lived on the address, so the same NFTs could be walked3944+ // through fresh addresses and claim the same profit again, and a freshly minted NFT3945+ // silently inherited every unit of profit accrued before it existed. Settling the debt3946+ // in _update() -- on mints as well as transfers -- keeps entitlement and record on the3947+ // same side of the ledger, so claimProfit() can only ever pay profit that accrued while3948+ // the caller actually held the token.3949+ function _update(3950+ address from,3951+ address to,3952+ uint256[] memory ids,3953+ uint256[] memory values3954+ ) internal virtual override {3955+ super._update(from, to, ids, values);3956+3957+ uint256 moved = 0;3958+ for (uint256 i = 0; i < values.length; i++) {3959+ moved += values[i];3960+ }3961+ if (moved == 0) {3962+ return;3963+ }3964+ if (from != address(0)) {3965+ return; // VARIANT: transfers keep resetting the debt3966+ }3967+ uint256 debt = (moved * accumulatedProfitPerNFT) / 1e18;3968+ if (to != address(0)) {3969+ claimedProfitPerAddress[to] += debt;3970+ }3971+ if (from != address(0)) {3972+ uint256 owed = claimedProfitPerAddress[from];3973+ claimedProfitPerAddress[from] = owed > debt ? owed - debt : 0;3974+ }3975+ }3976+39403977 function _balanceOfAllNFTs(address account) internal view returns (uint256) {39413978 uint256 totalUserBalance = 0;39423979 for (uint256 i = 0; i < TOTAL_VARIATIONS; i++) {
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/goldreserve_2025_02 \
--patch worked_example/goldreserve_2025_02/controls/incomplete_fix__debt_on_mint_only/Token.sol \
--backend local --sha256Expected output: core 9b55ebce09c37ef4aef9449acf14916d727e76d1b95003df701048359dc5e5c1 and strict 8e956853f861eeb8af66de4b7a4712ccdfb5d938522c047db1ed07563012921a.
To check a downloaded grade file instead: shasum -a 256 grade.strict.json prints the strict hash.
Control note
from the repositoryThe mirror-image incomplete repair: settles on mints but not on transfers. It blocks the PoC at its first claim, so the exploit oracle alone would credit it, while the address-hopping half the incident repeated 22 times is untouched.