Skip to content

Fix: Block blacklisted spender in blacklist issuance tokens#781

Open
marvinkruse wants to merge 2 commits into
devfrom
fix/blacklist-spender-check
Open

Fix: Block blacklisted spender in blacklist issuance tokens#781
marvinkruse wants to merge 2 commits into
devfrom
fix/blacklist-spender-check

Conversation

@marvinkruse

Copy link
Copy Markdown
Member

Summary

Closes a completeness gap in the blacklist on our two blacklist issuance tokens, ERC20IssuanceUpgradeable_Blacklist_v1 and ERC20Issuance_Blacklist_v1.

The blacklist was enforced only in the _update(from, to, amount) hook, which sees the sender and the receiver but never the spender. OpenZeppelin's transferFrom runs _spendAllowance(owner, spender, value) and then _update(owner, to, value), so a blacklisted address holding a live allowance could still call transferFrom and move a non-blacklisted holder's tokens to a fresh address. The blacklist did not cover the spender/operator, unlike canonical blacklist stablecoins which also gate the spender in the allowance path.

Fix

Override _spendAllowance in both contracts to revert when the spender is blacklisted, then delegate to super:

function _spendAllowance(address owner_, address spender_, uint value_)
    internal
    virtual
    override
{
    if (isBlacklisted(spender_)) {
        revert ERC20Issuance_Blacklist_BlacklistedAddress(spender_);
    }
    super._spendAllowance(owner_, spender_, value_);
}

_spendAllowance is the single internal chokepoint for every allowance-consuming path (transferFrom and the minter spendAllowance wrapper), so one override covers them all. The check runs before OpenZeppelin's infinite-allowance short-circuit, so an unlimited approval to a blacklisted spender is blocked too. The change is logic-only with no storage added or reordered, so it is safe for the upgradeable variant behind its proxy.

approve is intentionally not gated: allowing an approval to a blacklisted spender is harmless once the spend itself is blocked. Gating it would be optional defense-in-depth, out of scope here.

Severity

Reported privately by an external security researcher. Low severity and bounded: it only reaches funds where an allowance already exists, the holder must not be blacklisted (a blacklisted holder's own balance stays frozen), and arbitrary holders cannot be touched. No funds are currently at risk. The value is restoring the blacklist as a complete freeze, including the case where a blacklisted contract holds allowances and you are blacklisting it to contain an incident.

Changes

  • src/external/token/ERC20IssuanceUpgradeable_Blacklist_v1.sol: add _spendAllowance override.
  • src/external/token/ERC20Issuance_Blacklist_v1.sol: add _spendAllowance override.
  • Regression tests in both token test suites: a blacklisted spender's transferFrom reverts and moves nothing, a clean spender's transferFrom still works. Both revert tests fail on the pre-fix code.

Deployment Steps

⚠️ Manual deployment steps needed. These tokens are deployed as TransparentUpgradeableProxy instances, so this is a proxy implementation upgrade, not a Governor beacon upgrade, and it is not live on any instance until each proxy is upgraded.

  • Step 1: Compile an inventory of the deployed instances of the affected token type (open item below) so every live proxy is covered.
  • Step 2: Deploy the new implementation of the affected token contract(s).
    forge build
    # deploy ERC20IssuanceUpgradeable_Blacklist_v1 (and ERC20Issuance_Blacklist_v1 if any
    # non-upgradeable instances need replacing) and verify the implementation on the explorer
  • Step 3: For each deployed proxy instance, the controlling ProxyAdmin owner upgrades it to the new implementation. No reinitialization is needed (logic-only change).
    ProxyAdmin.upgradeAndCall(proxy, newImplementation, "")
    
  • Step 4: The proxies are per-instance and may be controlled by different admin owners than the core protocol, so rollout is coordinated with each instance's admin owner rather than executed centrally.

After each upgrade, verify the proxy's implementation slot points to the new implementation and that a blacklisted-spender transferFrom reverts on the live instance.

Open item: there is no single factory registry for these tokens (they are deployed per workflow), so the full inventory of live instances must be assembled before rollout is considered complete.

Test Plan

Pre-merge checklist

  • forge build succeeds
  • forge test --match-contract Blacklist_v1_Test passes (both token suites)
  • New regression tests fail on the pre-fix code and pass on the fix

Post-deployment verification

  • On each upgraded proxy, confirm the implementation slot points to the new implementation.
  • On a live instance, confirm a blacklisted spender's transferFrom reverts and a clean spender's still succeeds.

The blacklist was enforced only in the _update(from, to, amount) hook, which
never sees the spender. OZ transferFrom runs _spendAllowance(owner, spender)
then _update(owner, to), so a blacklisted address holding a live allowance
could still spend it to move a non-blacklisted holder's tokens. The blacklist
did not cover the spender/operator.

Override _spendAllowance in both ERC20IssuanceUpgradeable_Blacklist_v1 and
ERC20Issuance_Blacklist_v1 to revert when the spender is blacklisted. This is
the single chokepoint for every allowance-consuming path (transferFrom and the
minter spendAllowance wrapper), so one override covers them all. Logic-only, no
storage change. Adds regression tests that fail on the pre-fix code.
@marvinkruse marvinkruse self-assigned this Jun 23, 2026
…spendAllowance wrapper

Adds cases asserting the _spendAllowance chokepoint also blocks a blacklisted
spender on an infinite (type(uint).max) approval and via the minter
spendAllowance wrapper. Both revert cases fail on the pre-fix code.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant