02 · No owner drain
Balances move only from the caller, or with an allowance the holder gave.
The question
Can any function move tokens out of an address that did not ask for it?
The honest paths are transfer (from the caller) and transferFrom (from an address that approved the caller). A drain is anything else: a rescue(from, amount), a migrate(holder), a sweep(), or a direct write to the balances mapping.
How it decides
The check walks every function reachable from an entry point that is not in the standard set: transfer, transferFrom, _transfer, _update, _mint, _burn, burn, burnFrom, _spendAllowance, _approve, approve, _beforeTokenTransfer, _afterTokenTransfer, increaseAllowance, decreaseAllowance, permit.
Inside each, two things are a drain.
- A call to
_transfer,_update,_burnortransferFromwhose first argument is not the caller (msg.sender,_msgSender()ortx.origin), in a function that does not also call_spendAllowanceorallowance. - Any assignment to a state mapping of type
mapping(address => uint…)whose name containsbalance.
Either one reachable from an entry point is a fail, with the function and the path: _transfer is called on an address that is not the caller: rescue.
What passes
function airdrop(address[] calldata to, uint256[] calldata amt) external {
for (uint i; i < to.length; i++) _transfer(msg.sender, to[i], amt[i]);
}
The sender pays. An airdrop from the caller's own balance is a transfer with a loop.
function burnFrom(address from, uint256 amount) external {
_spendAllowance(from, msg.sender, amount);
_burn(from, amount);
}
An allowance is spent first. Standard, and exempt by name anyway.
What fails
function rescue(address from, uint256 amount) external onlyOwner { _transfer(from, owner, amount); }
function claim(address user) external { balances[user] = 0; balances[treasury] += claimable[user]; }
Both move value the holder did not send. The modifier and the story ("it's for recovering stuck tokens") do not enter into it.
Known edges
A token that moves value through a mapping not named like balance, or through an internal function not in the list above, is seen only if that function ends up calling one that is. A drain hidden inside _update itself (the standard hook) is not seen by this check; it would show in the review, and rule set two may add a state-diff check on a fork for it.