June 12, 2026
maxRedeem said yes, redeem said no
A maxRedeem()-style getter is a promise to whoever builds on top of you:
ask for this much and the call will go through. Frontends read it to fill an
input field. Other contracts read it to size a withdrawal. When that promise
and the action behind it round differently, the getter writes a check the
action refuses to cash.
Here is the shape, down to the part that matters:
uint256 constant UNIT = 1e9; // share has 9 decimals
uint256 public rate; // assets per share, scaled by UNIT
// forward: shares -> assets, rounds down
function previewRedeem(uint256 shares) public view returns (uint256) {
return (shares * rate) / UNIT;
}
// inverse: assets available -> shares, rounds down
function maxRedeem() external view returns (uint256) {
return (available() * UNIT) / rate;
}
function redeem(uint256 shares) external returns (uint256 assets) {
assets = previewRedeem(shares);
if (assets == 0) revert ZeroPayout();
// ... burn shares, transfer assets ...
}
Both functions round down, so it reads as consistent. It isn't. Take
rate = 500001 and one raw unit of assets left in the pool:
maxRedeem()returnsfloor(1 * 1e9 / 500001)=1999.- A caller trusts that and calls
redeem(1999). previewRedeem(1999)=floor(1999 * 500001 / 1e9)=floor(0.99950…)=0.redeemreverts withZeroPayout.
The getter advertised 1999. The action paid zero and reverted.
The class: two roundings and a guard that only one side knows about
The getter rounds the inverse (assets → shares). The action rounds the forward direction (shares → assets) and then guards against a zero payout. At a dust balance the floored inverse names a share count whose floored forward payout is zero, and only the action knows the guard exists. The two sides agree everywhere except the last unit, which is exactly where a redemption flow lands after it draws the balance down.
Nobody wrote a bug. Each function is locally correct. The defect lives in the gap between them, which is why a reviewer reading either function alone signs off on both.
Why it survives the test suite
Happy-path tests redeem round amounts against full balances, where the roundings never diverge. The failure needs a residual of one unit, the state you reach after the interesting transactions, not before. The property that catches it is small and worth writing down:
for every
s <= maxRedeem(),redeem(s)must not revert.
That is an invariant a fuzzer falsifies in seconds and an example-based test never thinks to try.
The fix: make the getter answer to the same guard
function maxRedeem() external view returns (uint256 m) {
m = (available() * UNIT) / rate;
if (previewRedeem(m) == 0) return 0; // never advertise an amount that reverts
}
A getter that returns zero is telling the truth: at this balance there is no amount you can redeem without reverting. That honest zero is worth more to an integrator than a confident number that blows up their transaction. Any helper that says "the most you can do is X" owes a guarantee that doing X works.
Daniel Kuppitz · Founder & Principal · Solidity audits at Cimora