|
| 1 | +// SPDX-License-Identifier: MPL-2.0 |
| 2 | +pragma solidity ^0.8.20; |
| 3 | + |
| 4 | +import {Test} from "forge-std/Test.sol"; |
| 5 | +import {HelperContract} from "../HelperContract.sol"; |
| 6 | +import {SanctionListOracle} from "src/mocks/SanctionListOracle.sol"; |
| 7 | +import {RuleSanctionsList, ISanctionsList} from "src/rules/validation/deployment/RuleSanctionsList.sol"; |
| 8 | + |
| 9 | +/** |
| 10 | + * @title RuleSanctionsListMintBurnSentinel |
| 11 | + * @notice The zero address is the ERC-20 mint/burn sentinel and must never be sent to the oracle |
| 12 | + * (`FEEDBACK_12.md` F-1). |
| 13 | + * @dev The oracle here sanctions `address(0)` itself -- a degenerate input a real oracle has never |
| 14 | + * been asked about, and one it is free to answer either way. Before the fix the rule forwarded |
| 15 | + * the sentinel to the oracle, so a `true` answer blocked EVERY mint and EVERY burn on every |
| 16 | + * token using this rule, trapping holders behind a third party's handling of a non-wallet. |
| 17 | + * These assertions fail without the `from != address(0)` / `to != address(0)` guards. |
| 18 | + */ |
| 19 | +contract RuleSanctionsListMintBurnSentinel is Test, HelperContract { |
| 20 | + SanctionListOracle private oracle; |
| 21 | + RuleSanctionsList private rule; |
| 22 | + |
| 23 | + function setUp() public { |
| 24 | + oracle = new SanctionListOracle(); |
| 25 | + // A real sanctioned wallet, and the sentinel. |
| 26 | + oracle.addToSanctionsList(ATTACKER); |
| 27 | + oracle.addToSanctionsList(ZERO_ADDRESS); |
| 28 | + rule = new RuleSanctionsList(SANCTIONLIST_OPERATOR_ADDRESS, ZERO_ADDRESS, ISanctionsList(address(oracle))); |
| 29 | + } |
| 30 | + |
| 31 | + function testOracleReallyDoesSanctionTheSentinel() public view { |
| 32 | + // Guards the premise of every assertion below. |
| 33 | + assertTrue(oracle.isSanctioned(ZERO_ADDRESS)); |
| 34 | + } |
| 35 | + |
| 36 | + function testMintIsNotBlockedWhenTheOracleSanctionsTheZeroAddress() public view { |
| 37 | + assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS2, 10), TRANSFER_OK); |
| 38 | + assertTrue(rule.canTransfer(ZERO_ADDRESS, ADDRESS2, 10)); |
| 39 | + } |
| 40 | + |
| 41 | + function testBurnIsNotBlockedWhenTheOracleSanctionsTheZeroAddress() public view { |
| 42 | + assertEq(rule.detectTransferRestriction(ADDRESS1, ZERO_ADDRESS, 10), TRANSFER_OK); |
| 43 | + assertTrue(rule.canTransfer(ADDRESS1, ZERO_ADDRESS, 10)); |
| 44 | + } |
| 45 | + |
| 46 | + function testMintAndBurnDoNotRevertOnTheWritePath() public view { |
| 47 | + // `transferred` reverts on a non-zero code, so this is the enforcement-side equivalent. |
| 48 | + rule.transferred(ZERO_ADDRESS, ADDRESS2, 10); |
| 49 | + rule.transferred(ADDRESS1, ZERO_ADDRESS, 10); |
| 50 | + } |
| 51 | + |
| 52 | + function testMintToASanctionedRecipientIsStillBlocked() public view { |
| 53 | + // The sentinel guard must not weaken screening of the REAL participant. |
| 54 | + assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ATTACKER, 10), CODE_ADDRESS_TO_IS_SANCTIONED); |
| 55 | + } |
| 56 | + |
| 57 | + function testBurnFromASanctionedHolderIsStillBlocked() public view { |
| 58 | + assertEq(rule.detectTransferRestriction(ATTACKER, ZERO_ADDRESS, 10), CODE_ADDRESS_FROM_IS_SANCTIONED); |
| 59 | + } |
| 60 | + |
| 61 | + function testOrdinaryTransfersAreUnaffected() public view { |
| 62 | + assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), TRANSFER_OK); |
| 63 | + assertEq(rule.detectTransferRestriction(ATTACKER, ADDRESS2, 10), CODE_ADDRESS_FROM_IS_SANCTIONED); |
| 64 | + assertEq(rule.detectTransferRestriction(ADDRESS1, ATTACKER, 10), CODE_ADDRESS_TO_IS_SANCTIONED); |
| 65 | + } |
| 66 | + |
| 67 | + /** |
| 68 | + * @notice The spender leg is deliberately NOT guarded; the minter must still be screened. |
| 69 | + * @dev `CLAUDE.md` records that the deny-lists screen the minter, which arrives as `spender` on |
| 70 | + * the 4-arg mint path. Guarding `from`/`to` must not silently disable that. |
| 71 | + */ |
| 72 | + function testTheMinterIsStillScreenedAsSpender() public view { |
| 73 | + assertEq( |
| 74 | + rule.detectTransferRestrictionFrom(ATTACKER, ZERO_ADDRESS, ADDRESS2, 10), CODE_ADDRESS_SPENDER_IS_SANCTIONED |
| 75 | + ); |
| 76 | + } |
| 77 | +} |
0 commit comments