betterERC
All posts
IntegrationJuly 3, 20269 min read

Reproducing the K error

Same pool, same token, same two percent. One route reverts on the constant-product invariant. Four others ask the token something first, and every one of them goes through.

The integration suite sets up a constant-product pool holding a million of the fee token against two million of a plain one, a trader with a hundred thousand, and a two percent fee whose cap never binds. Then it swaps ten thousand in, again and again, through three routers taking five different routes between them.

Why the first one fails

The naive router quotes against the amount it is about to send, sends it, and asks the pool to pay out. The pool measures what it actually received, which is two percent less, computes the product, finds it below the invariant, and rejects the trade.

Both contracts are correct. The pool is doing precisely what a constant-product pool must do, and the router is doing what every router did before fee-on-transfer tokens existed. The test proves the failure is attributable rather than incidental: set the rate to zero and the identical call goes straight through, unchanged.

Three ways the second one succeeds

The extension-aware router reads the behaviour word first, finds the fee bit set, and from there has a choice. It can ask what the fee will be on this exact transfer and quote against what will arrive. It can name the number the pool should end up with and let the token compute the input, which is what exact-output transfers exist for. Or it can skip the per-swap call entirely and price against the published ceiling.

ExtensionAwareRouter.sol
uint256 fee = IERC20TransferFee(token)
    .computeFee(msg.sender, address(pair), amountIn);

uint256 willArrive = amountIn - fee;

The third option is the one worth dwelling on. A router with an off-chain quote pipeline has a gap between quoting and landing, and the fee authority can move the rate inside that gap. A quote priced against the current fee breaks. A quote priced against the ceiling does not, because the ceiling is the number the authority cannot exceed. The suite raises the rate between quote and execution specifically to prove it.

A fourth that never asks about the fee at all

The three above all involve learning something about the fee and then doing arithmetic with it. The fourth router does neither. It names the smallest amount it is willing to see arrive, hands that floor to the token along with the transfer, and lets the token enforce it.

CheckedRouter.sol
IERC20CheckedTransfer(token).transferFromChecked(
    msg.sender, address(pair), amountIn,
    minAmountReceived,   // the floor this router will accept
    0                    // no configuration epoch pinned
);

Either the floor holds or nothing moves, and that property is fuzzed rather than asserted. This router knows nothing about fees, reads no configuration, and would work unchanged against a token whose fee module had not been written when the router shipped. It is the cheapest correct integration in the suite, and the suite also raises the rate underneath it to show it then refuses rather than settling for less.

And the case that needs no router changes at all

The token can exempt the pool. The fee is then genuinely zero on exactly the transfers a pool takes part in, and the naive router works unmodified. What does not change is the declaration: the token still reports the fee bit, because an exemption is configuration and configuration can be revoked in one transaction. An integrator who read the word and prepared for a fee is not wrong. They are early.

Nothing here changes what the token does. The fee is identical in both runs. The only thing that changed is that the integrator could find out in advance.