Docs · Concepts

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.

  1. A call to _transfer, _update, _burn or transferFrom whose first argument is not the caller (msg.sender, _msgSender() or tx.origin), in a function that does not also call _spendAllowance or allowance.
  2. Any assignment to a state mapping of type mapping(address => uint…) whose name contains balance.

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.