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() returns floor(1 * 1e9 / 500001) = 1999.
  • A caller trusts that and calls redeem(1999).
  • previewRedeem(1999) = floor(1999 * 500001 / 1e9) = floor(0.99950…) = 0.
  • redeem reverts with ZeroPayout.

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