Hi Chainlink team,
I'm reviewing the freeze semantics in the CCIP EVM Freezable BurnMint ERC20 contracts and noticed a difference between finite and infinite allowances after a spender is frozen.
Affected contracts:
BurnMintERC20PausableFreezableUUPS
BurnMintERC20PausableFreezableTransparent
Summary
The contracts block frozen accounts in:
_update(from, to, value), when the frozen account is from or to
_approve(owner, spender, value, emitEvent), when the frozen account is owner or spender
This means a frozen spender cannot receive a new approval, and finite allowance spends revert because OpenZeppelin's _spendAllowance decreases the allowance through _approve.
However, OpenZeppelin ERC20 v5 skips _approve when the current allowance is type(uint256).max.
As a result, a spender that was approved for infinite allowance before being frozen can still call transferFrom and move third-party funds, as long as neither from nor to is frozen.
Example flow
// 1. Alice approves Spender before Spender is frozen
token.approve(spender, type(uint256).max);
// 2. Spender is later frozen
token.freeze(spender);
// 3. Spender calls transferFrom
token.transferFrom(alice, bob, amount);
Current result:
_spendAllowance(alice, spender, amount) does not call _approve because allowance is max.
- The frozen-spender check in
_approve is skipped.
_update(alice, bob, amount) only checks alice and bob.
- If neither
alice nor bob is frozen, the transfer succeeds.
For finite allowances, the same flow reverts because _spendAllowance calls _approve, which detects the frozen spender.
Clarification requested
Is this intended behavior?
More specifically, is the intended freeze invariant only:
A frozen account cannot be the from or to address in transfers, mints, or burns.
Or should the invariant also include:
A frozen account cannot act as msg.sender / spender and spend third-party allowances through transferFrom.
The ambiguity comes from _approve rejecting frozen spenders, which suggests frozen spenders are intended to be blocked from allowance-based flows. But infinite allowances bypass that check.
Related burnFrom behavior
The same pattern may apply to burnFrom if the frozen caller still has the required burn role and has a pre-existing infinite allowance, because burnFrom also relies on _spendAllowance.
Possible fix if unintended
If frozen spenders should not be able to spend allowances, the contract could override _spendAllowance and check the spender before OpenZeppelin's infinite-allowance shortcut:
function _spendAllowance(address owner, address spender, uint256 value) internal virtual override {
if (isFrozen(spender)) {
revert AccountFrozen(spender);
}
super._spendAllowance(owner, spender, value);
}
Exact error names may differ, but the key point is that the spender freeze check should happen before the max-allowance branch.
Can the Chainlink team confirm whether frozen accounts being able to spend pre-existing infinite allowances is intended behavior?
Hi Chainlink team,
I'm reviewing the freeze semantics in the CCIP EVM Freezable BurnMint ERC20 contracts and noticed a difference between finite and infinite allowances after a spender is frozen.
Affected contracts:
BurnMintERC20PausableFreezableUUPSBurnMintERC20PausableFreezableTransparentSummary
The contracts block frozen accounts in:
_update(from, to, value), when the frozen account isfromorto_approve(owner, spender, value, emitEvent), when the frozen account isownerorspenderThis means a frozen spender cannot receive a new approval, and finite allowance spends revert because OpenZeppelin's
_spendAllowancedecreases the allowance through_approve.However, OpenZeppelin ERC20 v5 skips
_approvewhen the current allowance istype(uint256).max.As a result, a spender that was approved for infinite allowance before being frozen can still call
transferFromand move third-party funds, as long as neitherfromnortois frozen.Example flow
Current result:
_spendAllowance(alice, spender, amount)does not call_approvebecause allowance is max._approveis skipped._update(alice, bob, amount)only checksaliceandbob.alicenorbobis frozen, the transfer succeeds.For finite allowances, the same flow reverts because
_spendAllowancecalls_approve, which detects the frozen spender.Clarification requested
Is this intended behavior?
More specifically, is the intended freeze invariant only:
Or should the invariant also include:
The ambiguity comes from
_approverejecting frozen spenders, which suggests frozen spenders are intended to be blocked from allowance-based flows. But infinite allowances bypass that check.Related
burnFrombehaviorThe same pattern may apply to
burnFromif the frozen caller still has the required burn role and has a pre-existing infinite allowance, becauseburnFromalso relies on_spendAllowance.Possible fix if unintended
If frozen spenders should not be able to spend allowances, the contract could override
_spendAllowanceand check the spender before OpenZeppelin's infinite-allowance shortcut:Exact error names may differ, but the key point is that the spender freeze check should happen before the max-allowance branch.
Can the Chainlink team confirm whether frozen accounts being able to spend pre-existing infinite allowances is intended behavior?