// SPDX-License-Identifier: MIT pragma solidity 0.8.26; /** * Ullage — an escrow that books the amount that ARRIVED. * * WHY THIS EXISTS. Every ERC-20 integration is written against one sentence in * EIP-20: * * "transfers `_value` amount of tokens to address `_to` ... The function * SHOULD throw if the message caller's account balance does not have * enough tokens to spend." * * Note what it does not say. It does not say the recipient's balance rises by * `_value`. It does not say the function reverts when it fails — `SHOULD`, not * MUST, and the reference ABI returns a `bool` precisely so that it can return * `false` instead. And it does not say the call returns anything at all: the * largest token contracts in circulation return nothing, because they were * written before the ABI settled, and a `bool` decode of empty returndata * reverts on a transfer that in fact succeeded. * * So `token.transfer(to, amount)` compiles into a sentence with three separate * ways of being false, and an integration that writes down `amount` afterwards * has recorded a request, not an outcome. * * - a fee-on-transfer token moves 100 and delivers 97 * - a token that returns `false` reports failure through a value the caller * usually discards, so the caller books a transfer that never happened * - a token that returns nothing makes a strict caller revert on success * - an address with NO CODE accepts every call and returns nothing, so a * "transfer" to a mistyped or self-destructed token succeeds forever * * ULLAGE is the shipping term for the gap: the difference between what a * vessel was said to contain and what a gauge finds in it. The bill of lading * is a claim; the outturn is a measurement; the ullage is the discrepancy, and * it is the number the receiving party is entitled to insist on. * * THE WHOLE CONTRACT IS THAT ONE IDEA. Nothing here is credited from an * argument. Every number this contract writes down is a difference between two * balances it read itself, on either side of the call that was supposed to move * them: * * deposit credits `balanceOf(this)` after minus before — never `amount` * withdraw debits `balanceOf(this)` before minus after — never `amount` * and reports what the payee's balance actually did * * Which gives the invariant the whole thing is for, and which property 1 checks * after every single operation in the suite: * * booked[token] <= IERC20(token).balanceOf(address(this)) * * The contract can never have promised more of a token than it is holding, * because it never wrote down a number it did not weigh. A token that claws * units back from the outside — a downward rebase, a blacklist, a fee taken on * a balance rather than a transfer — can still break it, and that is the one * case a receiving account genuinely cannot prevent. So it is REPORTED rather * than smoothed: `deficit()` names the number, and payouts are capped at what * is actually held. * * WHAT IS DELIBERATELY NOT HERE. No owner, no upgrade path, no pause, no fee, * and no pro-rata arbitration of a shortfall. Ullage is a gauge, not a court. * Its job is to make the two numbers separately visible and to refuse to write * either of them down from an argument; deciding who bears a shortfall that a * token created is somebody else's contract. */ interface IERC20 { function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); } contract Ullage { /* ---------------------------------------------------------------- state */ /** Units of `token` this contract owes `owner`. Only ever moved by a measured difference of two balances. */ mapping(address token => mapping(address owner => uint256)) public credit; /** The sum of `credit[token][*]`. Kept as a running total rather than recomputed, because the invariant that matters is a comparison against a live balance and both sides of it must be cheap. */ mapping(address token => uint256) public booked; /* --------------------------------------------------------------- events */ /** `asked` and `arrived` are emitted SEPARATELY and always both. A log that records only the amount that was requested is the same mistake as an integration that records only the amount that was requested. */ event Deposited(address indexed token, address indexed from, address indexed to, uint256 asked, uint256 arrived); event Withdrawn(address indexed token, address indexed owner, address indexed to, uint256 asked, uint256 left, uint256 arrived); event Booked(address indexed token, address indexed to, uint256 amount); /* --------------------------------------------------------------- errors */ error NothingArrived(); error Overdraw(uint256 asked, uint256 left); error InsufficientCredit(uint256 have, uint256 want); error NotAToken(address token); error TransferReturnedFalse(address token); error MalformedReturn(address token, uint256 length); error BadRecipient(); error Reentered(); error NothingToBook(); /* A single-slot reentrancy latch. Every state-changing function here reads a balance, calls a token, and reads the same balance again — which is exactly the shape a callback token exists to exploit. Without the latch a token whose `transfer` re-enters `deposit` can make the second reading of `balanceOf(this)` include its own inner deposit and be credited twice. Property 12 does that on purpose. */ uint256 private _entered = 1; modifier lock() { if (_entered != 1) revert Reentered(); _entered = 2; _; _entered = 1; } /* ------------------------------------------------------------- views */ /** * What the contract is actually holding. * * The code check is HERE, and not only at the call site, because it has to * run before the first balance is read. `balanceOf` on an address with no * code returns an empty buffer, and solc's own decode of that reverts with * no data at all — so without this line the most important error in the * contract arrives as a bare failure with nothing in it, and the caller is * told only that something went wrong. Property 6 checks the selector, so * it fails on a nameless revert exactly as it would on the wrong one. */ function held(address token) public view returns (uint256) { if (token.code.length == 0) revert NotAToken(token); return IERC20(token).balanceOf(address(this)); } /** Units present that nobody has been credited with — a plain `transfer` into this address rather than a `deposit`. */ function surplus(address token) public view returns (uint256) { uint256 h = held(token); uint256 b = booked[token]; unchecked { return h > b ? h - b : 0; } } /** THE ULLAGE. Units this contract has written down and is no longer holding. By construction it can only be created from OUTSIDE — a downward rebase, a blacklist, a token that moves balances without a transfer — because nothing in here ever books a number it did not weigh. It is reported rather than absorbed. */ function deficit(address token) public view returns (uint256) { uint256 h = held(token); uint256 b = booked[token]; unchecked { return b > h ? b - h : 0; } } /** The most `owner` can ask `withdraw` to send. Capped at what is actually in the contract, because a quote above the balance is the thing this whole project is about. */ function available(address token, address owner) public view returns (uint256) { uint256 c = credit[token][owner]; uint256 h = held(token); return c < h ? c : h; } /* ------------------------------------------------------------ deposits */ function deposit(address token, uint256 amount) external returns (uint256) { return depositFor(token, amount, msg.sender); } /** * Pull `amount` from the caller and credit `to` with WHAT ARRIVED. * * The two readings straddle the transfer, so a fee, a rebase inside the * call, or a token that moves a different number than it was asked for are * all handled by the same three lines — none of them is a special case, * because `amount` is never used as a quantity, only as an instruction. */ function depositFor(address token, uint256 amount, address to) public lock returns (uint256 arrived) { if (to == address(0) || to == address(this)) revert BadRecipient(); uint256 before = held(token); _call(token, abi.encodeCall(IERC20.transferFrom, (msg.sender, address(this), amount))); uint256 after_ = held(token); /* `<=` and not `<`: a token that reports success and moves nothing at all is the single most common shape of the bug this contract exists for, and crediting zero would let it accumulate silently. */ if (after_ <= before) revert NothingArrived(); unchecked { arrived = after_ - before; } credit[token][to] += arrived; booked[token] += arrived; emit Deposited(token, msg.sender, to, amount, arrived); } /** * Credit the caller with any unbooked surplus. * * Somebody who sends tokens here with a plain `transfer` has told this * contract a quantity and nothing else — not who it is for. The contract * will not guess, so the units sit unbooked until someone claims them, and * claiming is deliberately open. That is a race, and it is the honest * shape of the situation: the alternative is to invent an owner. */ function book(address token) external lock returns (uint256 amount) { amount = surplus(token); if (amount == 0) revert NothingToBook(); credit[token][msg.sender] += amount; booked[token] += amount; emit Booked(token, msg.sender, amount); } /* ----------------------------------------------------------- withdrawals */ function withdraw(address token, uint256 amount) external returns (uint256, uint256) { return withdrawTo(token, amount, msg.sender); } /** * Send `amount` to `to`, and return BOTH measured numbers: how much left * this contract, and how much reached the payee. * * The debit is `left`, not `amount`. If a token takes its fee out of the * sender the contract's own balance falls by more than it authorised, and * that is the one case here that reverts — a receiving account may deliver * less than was asked for, because the token did that, but it may never * lose more than the holder instructed it to send. * * A shortfall at the PAYEE's end does not revert. It is returned, and * emitted, and it is the number this contract is named after. Reverting * would make every fee-on-transfer token permanently unwithdrawable, which * is a worse outcome than telling the truth about it. */ function withdrawTo(address token, uint256 amount, address to) public lock returns (uint256 left, uint256 arrived) { if (to == address(0) || to == address(this)) revert BadRecipient(); if (amount == 0) revert NothingArrived(); uint256 c = credit[token][msg.sender]; uint256 cap = available(token, msg.sender); if (amount > cap) revert InsufficientCredit(cap, amount); uint256 beforeSelf = held(token); uint256 beforeTo = IERC20(token).balanceOf(to); _call(token, abi.encodeCall(IERC20.transfer, (to, amount))); uint256 afterSelf = held(token); uint256 afterTo = IERC20(token).balanceOf(to); unchecked { left = beforeSelf > afterSelf ? beforeSelf - afterSelf : 0; arrived = afterTo > beforeTo ? afterTo - beforeTo : 0; } /* The contract must never be lighter than the holder instructed. */ if (left > amount) revert Overdraw(amount, left); /* Debit the measurement, not the instruction. A token that moved less than asked leaves the difference credited, where it belongs. */ credit[token][msg.sender] = c - left; booked[token] -= left; emit Withdrawn(token, msg.sender, to, amount, left, arrived); } /* ------------------------------------------------------------ the call */ /** * One ERC-20 call, with all four failure shapes separated. * * The order matters. `token.code.length` is checked BEFORE the returndata * length, because an address with no code accepts every call, consumes no * gas worth speaking of, and returns success with an empty buffer — which * is indistinguishable from a correct USDT-shaped transfer if you look at * the returndata first. Every "I sent tokens to the wrong address and the * transaction succeeded" story is that check being absent. */ function _call(address token, bytes memory data) private { if (token.code.length == 0) revert NotAToken(token); (bool ok, bytes memory ret) = token.call(data); if (!ok) { /* Hand the token's own revert reason back rather than replacing it with ours. A wrapper that swallows the reason turns "transfer amount exceeds balance" into "call failed", and the caller then debugs the wrapper. */ if (ret.length > 0) { assembly ("memory-safe") { revert(add(ret, 32), mload(ret)) } } revert TransferReturnedFalse(token); } /* Returned nothing: the pre-ABI shape. Legal here, because the code check above has already established that something ran. */ if (ret.length == 0) return; /* Returned something that is not a word. Not a bool, not silence — refuse rather than decode it into whatever it happens to look like. */ if (ret.length < 32) revert MalformedReturn(token, ret.length); if (!abi.decode(ret, (bool))) revert TransferReturnedFalse(token); } }