The argument
What a transfer promises
Every ERC-20 integration ever written rests on one sentence, and the sentence does not say what the integration needs it to say.
“transfers_valueamount of tokens to address_to… The function SHOULDthrowif the message caller's account balance does not have enough tokens to spend.”
Read it for what is missing rather than what is there. It describes an effect on the sender.
It does not say the recipient's balance rises by _value. It says SHOULD throw, not
MUST — and the reference ABI returns a bool precisely so that a token can decline by returning
false instead of reverting. And it does not say the call returns anything at all.
So token.transfer(to, amount) compiles into a sentence with three separate ways of being false,
and a contract that writes down amount afterwards has recorded a request, not an outcome.
The four shapes
A fee on the way through. The token moves 100 out of the sender and puts 97 into the recipient. Nothing reverts. Nothing returns false. Every set of books that wrote down 100 is wrong from that moment on, and stays wrong, and the discrepancy only surfaces when somebody tries to withdraw the last of it.
A refusal returned as a value. Declared as function transfer(address, uint256) external;
— with no return type — Solidity discards the bool and issues no warning. The transfer failed, the
caller booked it as a success, and the two ledgers have disagreed ever since.
Silence. Some of the largest token contracts in circulation return no data at all. A caller
that decodes a bool reverts — on a transfer that worked — and the correct handling is to
accept an empty buffer.
And the trap underneath that one. An address with no code accepts every call, consumes almost
no gas, and returns success with an empty buffer. Which is byte-for-byte what a correct pre-ABI token returns. Every
“I sent tokens to the wrong address and the transaction succeeded” story is that distinction being absent, and the
only thing that separates them is checking code.length before reading the returndata.
Why the answer is not a better interface
It is tempting to fix this with a wrapper: check the bool if there is one, accept silence, check the code. That is necessary and it is not sufficient, because it still ends with the caller writing down the number it asked for. A fee-on-transfer token passes every one of those checks.
The only thing that survives all four shapes at once is to stop using the argument as a quantity. Read the
balance, make the call, read the balance again, and book the difference. It costs two extra SLOADs
through a cold external call, and in exchange the fee case, the truncating case, the rebasing-mid-call case and
the moved-nothing case all stop being special cases — because amount was never a quantity, only an
instruction.
Ullage is that, and nothing else
A deposit credits balanceOf(this) after minus before. A withdrawal debits balanceOf(this)
before minus after, and separately reports what the payee's balance actually did. Those two differences are every
number the contract writes down.
Which gives the invariant: booked[token] <= balanceOf(address(this)), always, for every token.
The contract cannot have promised more than it holds, because it never wrote down a number it did not weigh.
The one thing it cannot do
A token can move balances without a transfer — a downward rebase, a blacklist sweep, an owner burning from an
address. No receiving account can prevent that, and pretending otherwise is worse than admitting it. So Ullage
reports it: deficit(token) is what has been booked and is no longer held, and
available(token, owner) follows the balance rather than the promise.
It does not arbitrate. Deciding who bears a shortfall a token created is a policy question, and a gauge that also adjudicates is two contracts wearing one name. Ullage's job is to make the two numbers separately visible and to refuse to write either of them down from an argument.
What the name is
Ullage is the shipping and cellaring term for the gap: the difference between what a vessel was said to contain and what a gauge finds in it. A bill of lading is a claim. An outturn is a measurement. The ullage is the discrepancy between them, and it is the number the receiving party is entitled to insist on — which is exactly the relationship between the amount in a call and the amount in a balance.