T19 · AMM from Mathematics to Code
What does x·y=k look like in code?
- Capabilities practiced
- BuilderFinancial Literacy
- Hands on
- Implement a minimal AMM and test the numerical correctness of slippage, fees, and adding and removing liquidity.
- AI Lab
- Have AI derive the quote formula with fees, then test it with sample numbers to check whether it applies fees in the right place.
A Real-World Problem
The product needs a swap feature: users can exchange two tokens without an order book or a designated market maker.
You find the famous five-character formula. In ten lines, you have an implementation:
// The pool holds reserveIn and reserveOut; the user supplies amountIn
uint256 k = reserveIn * reserveOut;
uint256 newReserveIn = reserveIn + amountIn;
uint256 amountOut = reserveOut - k / newReserveIn;It runs. A swap of 1,000 returns 996; that looks reasonable.
Then a colleague asks during code review: Where is the fee applied?
After three seconds, you can suggest three answers that all look right: deduct 0.3% from the input before the formula; calculate the output and deduct 0.3% there; or leave the formula alone and check at the end that the product did not shrink.
The same trade differs by a few basis points across the three versions. Worse, one version lets an attacker drain the pool gradually while all your unit tests pass, because you tested “how much comes back for 1,000,” not “how much remains in the pool afterward.”
The formula has five characters. It has four plausible implementations, and only one is correct. This chapter unpacks every place those five characters appear in code.
Thought Experiment
Forget code for a moment. Imagine a locked cabinet with two drawers.
The left holds 100 apples and the right holds 100 oranges. The cabinet has one rule:
Anyone may put something into one drawer and take something from the other. Afterward, the product of the quantities in the two drawers may not decrease.
Put ten oranges into the right drawer, giving it 110. How few apples may remain on the left? 100 times 100 is 10,000, and 10,000 divided by 110 is about 90.9. At least 90.9 apples must remain, so you may take at most 9.09.
Notice what happened: you put in ten and took only 9.09. Nobody charged a fee. The 0.91 difference comes entirely from the multiplication rule: the more you buy, the more expensive each later apple becomes.
Now an administrator wants to take a 0.3% fee. There are three places to do it:
- Deduct it from the deposit. You put in ten oranges, but only 9.97 count in the calculation; 0.03 remain in the drawer separately.
- Deduct it from the withdrawal. Calculate 9.09 apples for ten oranges, then deduct 0.027 apples from that output.
- Do not deduct anything; only check afterward. Require the product of the drawers to be a little larger than before, treating the extra as the fee.
They sound equivalent, but produce different outcomes. The important distinction is not the 0.3% itself. It is where the deducted assets go: do they remain in the drawer, or leave it?
That determines whether LPs receive their share automatically and whether the cabinet's rule still holds.
Your Decision
Add a 0.3% fee to the swap function. Which of four implementations do you choose?
What Happens
Compare the numbers from one trade under all four approaches.
Assume one million of each asset is in the pool and the user supplies 10,000.
| Approach | User receives | Product after trade | How LPs receive fees | Invariant check |
|---|---|---|---|---|
| Input-side fee left in pool | 9,871.58 | Increases | Shares appreciate automatically | Holds naturally |
| Output-side fee | 9,871.29 | Increases | Shares appreciate automatically | Holds but shifts price |
| Final check only | 9,900.99 | Unchanged | No fees to distribute | Only defense |
| Fee transferred outside | 9,900.99 | Decreases | External accounting | Inevitably fails |
The first two rows differ by only 0.29 and the last two appear identical. Yet the fourth pool becomes smaller after every trade. Repeat ten thousand times and a substantial piece wears away.
Where the fee is applied determines asset ownership, not merely precision. If it stays in the pool, it belongs to LPs. If it leaves, it belongs to an address while the pool loses assets each time.
Now look at how the curve behaves without a fee. Move the slider below:
No market maker sets this quote. The x·y=k curve determines the price. A shallower pool and a larger purchase move your price further from the market price; arbitrageurs and liquidity providers capture that difference.
Two statements hold together: a shallower pool suffers more price movement for the same amount, and the more you buy from a given pool, the worse your average execution price becomes.
That is the curve's definition, not a bug. It also reveals a point used in later chapters: you can calculate a price for moving the pool's quoted price to any target. The final Lab asks you to calculate it.
Build a Model
A minimal working pool has four central functions. The code below imports nothing and can be read as is.
First, quoting. This is the most important snippet in the chapter:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// Charge 0.3% on input and leave it in the pool
function getAmountOut(
uint256 amountIn,
uint256 reserveIn,
uint256 reserveOut
) pure returns (uint256 amountOut) {
require(amountIn > 0, "ZERO_INPUT");
require(reserveIn > 0 && reserveOut > 0, "NO_LIQUIDITY");
uint256 amountInWithFee = amountIn * 997;
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn * 1000 + amountInWithFee;
amountOut = numerator / denominator; // Round down; the remainder stays in the pool
}Three details here have each caused real incidents:
- Multiply before dividing. Compute the full
numeratorbefore division. Every premature integer division loses precision. - The 997 in the denominator is intentional. Numerator and denominator are both scaled by 1,000 so that a 0.3% fee needs no floating point.
- Round the final output down. The user receives an integer no greater than the theoretical value, and the remainder stays in the pool.
Second, swapping. A quote is a pure function; a swap touches real balances:
function swap(uint256 amount0Out, uint256 amount1Out, address to) external {
require(amount0Out > 0 || amount1Out > 0, "ZERO_OUTPUT");
require(amount0Out < reserve0 && amount1Out < reserve1, "NOT_ENOUGH");
if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));
// Infer the actual input from balances, not from a caller-supplied parameter
uint256 amount0In = balance0 > reserve0 - amount0Out ? balance0 - (reserve0 - amount0Out) : 0;
uint256 amount1In = balance1 > reserve1 - amount1Out ? balance1 - (reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, "NO_INPUT");
// After deducting 0.3%, the product must not decrease
uint256 adjusted0 = balance0 * 1000 - amount0In * 3;
uint256 adjusted1 = balance1 * 1000 - amount1In * 3;
require(adjusted0 * adjusted1 >= reserve0 * reserve1 * 1000 * 1000, "K");
reserve0 = balance0;
reserve1 = balance1;
}Notice that amount0In is inferred from balances, not supplied as a parameter. This is the contract's trust boundary: the caller's claim about how much it sent does not count until the contract measures it.
Third, shares. How many LP shares should a deposit mint?
function _sharesFor(uint256 amount0, uint256 amount1) internal view returns (uint256) {
if (totalShares == 0) {
// First deposit: shares are the square root of the amounts' product
return _sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
}
uint256 s0 = (amount0 * totalShares) / reserve0;
uint256 s1 = (amount1 * totalShares) / reserve1;
return s0 < s1 ? s0 : s1; // Take the smaller share count; excess is a donation to the pool
}MINIMUM_LIQUIDITY is a small part of the initial deposit locked forever and owned by nobody. The Real-World Case section explains the actual attack it prevents.
Fourth, and easiest to get wrong: rounding direction.
| What are you calculating? | Round which way? | What if you reverse it? |
|---|---|---|
| Swap output | Down | Each trade gives a little extra; the pool steadily loses assets |
| Minted LP shares | Down | New LPs receive unearned shares, diluting existing LPs |
| Tokens returned when shares burn | Down | The exiting LP takes more, at the expense of those who remain |
| Invariant check | Require no decrease with greater than or equal | Each trade can grind away a little value |
Remember: every rounding decision must favor the pool.
This is not a style preference. Reverse the rounding direction and you create a repeatable exploit that earns a little each time. It causes no revert and may not fail ordinary tests; tens of thousands of trades later, the pool is missing a chunk of assets.
What It Is Called
A pricing rule under which the product of two asset reserves cannot fall after a swap.
Nobody has to provide a quote. The reserve ratio gives the price through the formula itself. The cost is that every trade moves the price; a flat-price trade does not exist.
The amount of each asset recorded in the contract's accounting.
It is different from the actual token balance held by the contract. Someone can transfer tokens directly, making the balance exceed the reserve. Production implementations keep the two separate: quote from reserves, settle from actual balances.
An operation that puts one asset into the curve and withdraws another.
A real implementation usually transfers out first and checks the invariant afterward, enabling flash swaps. That order also makes reentrancy protection essential; T28 develops the issue further.
The amount deducted on the input side and left in the pool.
Rather than merely a charge, it makes the invariant grow slightly. The growth belongs to LPs in proportion to their shares, so no distribution logic is needed.
A claim representing your proportion of the pool.
It records a ratio, not fixed quantities. On redemption, you get a proportional slice of the pool's then-current assets, not the two numbers you deposited. That is the source of impermanent loss. Part A Chapter 15 gave the intuition; this chapter shows its code-level origin.
How far the pool's price moves from its starting point after a trade.
It is not the same as slippage: slippage compares your average execution price with the starting price; price impact compares the pool's final price with the starting price. For the same trade, price impact is roughly twice slippage. Confusing them in the front end is a common bug.
Hands-on
The goal is not merely to make it run. You should be able to verify every number by hand.
Write two test tokens. Simple ERC-20 tokens with mint for test data are enough. Deliberately use different decimal precision: 18 decimals for one, 6 for the other. This trap matters later.
Implement the pool's four functions: addLiquidity, removeLiquidity, getAmountOut, and swap.
Do not use a math library. Write sqrt with Newton iteration yourself. Doing so forces you to notice every division.
Test set one: is the fee applied in the right place?
Start with a pool holding one million of each asset and supply 10,000. Calculate the formula above by hand and assert that the integer output equals exactly 9,871. Work out the precise integer result yourself rather than accepting an approximation.
Then set the fee to zero by replacing 997 with 1,000, and assert the result is the integer corresponding to 9,900.99. If both figures match, the fee is in the correct place.
Test set two: the invariant never decreases.
Write a loop making 1,000 swaps with random directions and amounts. After each, assert that the product is at least as large as before.
This catches a reversed rounding direction. Change one round-down to round-up and see which iteration fails first.
Test set three: deposit then withdraw must not increase either amount.
Have one address call addLiquidity and immediately removeLiquidity. Assert that neither asset returned exceeds the amount deposited.
Then try a harder case: A deposits, B makes a large swap, and A withdraws. The two asset quantities A receives will differ sharply from what A put in, but the total value at the current price should be at least the original value minus impermanent loss. Print the steps in a table.
Calculate the cost of an attack. This is the chapter's main exercise.
Derive from constant product how much you must deposit to move the pool price to m times its starting level:
Let the reserves be x and y, with price p = y / x
To move the price to m times its original value:
new reserve x' = x / sqrt(m), y' = y * sqrt(m)
Required y input = y' - y = y * (sqrt(m) - 1)
m = 2 → about 41.4% of the y-side reserve
m = 4 → 100%
m = 100 → 900%Verify it in your contract: in a pool with one million of each asset, input 414,214 and check that the price roughly doubles.
Now answer: if another contract uses this pool's spot price as an oracle, how much capital is needed to double that price? Record the number. All of T21 builds on it.
Use only a local network; do not connect real funds. Real funds appear only in the capstone project, after Stage 5 security acceptance.
AI Lab
Ask in three steps and verify each yourself:
Step one:
Starting from constant product x*y=k, derive the quote formula with a 0.3% fee.
Deduct the fee on the input side and leave it in the pool. Show every algebraic step
and provide a Solidity implementation using integer arithmetic only.
Step two:
Give the inverse function: for an exact amountOut that I want to receive,
how much amountIn must I supply? Explain why rounding goes in the opposite direction.
Step three:
List every place these functions can lose precision. For each, say which way
the error goes and who benefits.Step two is this Lab's value. Everyone can write the forward function, but the inverse rounds in the opposite direction. To receive exactly 100, the user's required input must round up; otherwise a one-unit shortfall makes the trade fail. Many models merely invert the formula and forget to add one.
After receiving an answer, calculate the first step's example by hand. Use a calculator, not code: running the model's code may carry its mistake into your verification.
Verify these points yourself after using AI
- Does the fee factor appear in both numerator and denominator? Omitting it from the denominator is the most common mistake
- For a pool of one million to one million and input of 10,000, calculate its formula by hand and compare the exact integer to your implementation
- When the fee is zero, does its formula reduce to the original fee-free equation?
- Does its inverse function (known output, solve input) round up? That direction is opposite to the forward calculation
- Did it divide prematurely at any step and lose precision?
- Did it confuse price impact with slippage?
Real-World Case
When the pool is empty, the first depositor sets the initial price alone: the ratio deposited becomes the price.
Share calculation can be worse. In early implementations, the first deposit could leave a tiny total share supply, perhaps 1. An attacker could then transfer a large quantity of tokens directly to the contract, making each share extremely valuable. A later small depositor's calculated shares would round down to zero: funds went in, but no shares came out.
That is why MINIMUM_LIQUIDITY exists: a small portion of the first liquidity is locked forever, creating a floor on share supply that the attacker cannot manipulate away.
If your implementation lacks that line, add it and write a test reproducing the attack.
Some tokens automatically deduct a portion during transfer. A parameter says you received 1,000; the contract actually receives only 970.
If the pool trusts the parameter instead of balances, an invariant check based on the claimed amount can pass even though the pool is short of funds.
That is why input must be inferred from balances. The swap implementation calculates amount0In specifically for such tokens. Create a fee-on-transfer token and test it; this is one of the few cases where you need to build an adversarial contract for the test.
Round a division upward somewhere, or subtract one unit too few before a subtraction.
The signature is a tiny profit per call that can be repeated without limit. A few wei each time, thousands of calls in a loop, and a single transaction can extract a meaningful sum. No exception occurs; every step “succeeds.”
The defense is simple: invariant tests over thousands of random operations, asserting that the pool's product never decreases. Human review rarely catches this; fuzzing can.
Someone transfers tokens directly to the pool address. Its actual balance now exceeds its recorded reserve.
If the quote function reads balances rather than reserves, anyone can affect quotes by transferring before swapping. If settlement reads reserves rather than balances, the next swapper can take the surplus tokens.
The rule is simple: quote from reserves, settle from balances, and synchronize them in one place only. Production pools often expose one function to remove surplus balances and another to force reserves to match balances.
Change One Variable
Traders pay less, and arbitrage becomes profitable at a smaller price difference, so the pool tracks the outside market more closely.
LPs pay the price: at the same volume, their fee income falls to one-sixth. To maintain the return, the pool needs six times the trading volume. For volatile pairs, fees are supposed to compensate impermanent loss.
Low fees suit highly correlated pairs, such as two dollar stablecoins, because impermanent loss is small there. Lower is not always better; the rate must fit the pair's volatility.
That means keeping the sum of the two asset quantities constant.
The curve is a straight line: no matter how large the trade, the price stays fixed at 1:1, with no slippage at all. That sounds attractive.
But it has a fatal flaw. Once the real prices differ from 1:1, arbitrageurs can buy out the entire cheap side, leaving only the expensive asset. The constant-product curve is a hyperbola precisely because it never meets either axis: neither reserve reaches zero.
Real stablecoin pools take a middle path: nearly straight around 1:1, then bending quickly into a hyperbola as the price moves away.
The formula stays the same, but every test number must be recalculated, and intermediate multiplication is more likely to overflow.
Rounding has a less obvious relative effect. One smallest unit in a six-decimal token is a millionth, so a round-down loses much more absolute value than it does with an eighteen-decimal token.
That is why the Lab deliberately starts with mismatched precision. Precision is one of the most frequent sources of DeFi engineering incidents, yet it does not appear in the pen-and-paper derivation.
You immediately need an accounting system: when each LP began holding shares, how many fees accumulated during that period, and what portion belongs to them.
This is often implemented with a global “accumulated fees per share” variable and a snapshot at deposits and withdrawals. It can work, but complexity and attack surface increase markedly. Any time-based reward allocation must handle the exact deposit and withdrawal boundaries.
By comparison, leaving fees in the pool achieves correct pro-rata distribution with zero lines of allocation code. Good mechanism design often looks like that.
Takeaway Questions
Who provides liquidity? Here the answer is concrete in code: LPs deposit both assets and receive a claim redeemable in proportion to the pool. They bear impermanent loss and earn accumulated 0.3% fees. Judge any pool by the ratio between those two.
Who pays? Every trader who swaps. That 0.3% is not income created from nothing; it comes from traders. Ask the next question when someone advertises a “zero-fee DEX”: what compensates LPs for impermanent loss?
Who bears the risk? LPs bear impermanent loss from price divergence, traders bear slippage and frontrunning, and the protocol itself bears almost no risk: it is code. T24 returns to this allocation.
What is the incentive? Arbitrageurs bring the pool price back toward the external market and earn the difference. Without arbitrageurs, an AMM price could remain wrong indefinitely. You need not hire a market maker, but arbitrage must be profitable.
Chapter Check
An input-side fee left in the pool directly produces a small rise in the invariant. That growth belongs to all LPs according to their shares, with no allocation code.
An output-side fee is numerically close, but the withheld asset accumulates on the output side, shifting the pool price through the fee itself. Rounding also becomes harder to predict when output reserves are low.
What you must avoid is transferring fees outside the pool: that shrinks the invariant every trade, while the invariant check is the pool's only safety net.
There are two reasons.
First, fee-on-transfer tokens: a caller says it sent 1,000, but the contract receives 970. Trusting the parameter means trusting a contract you do not control.
Second, operation order: production implementations transfer out first and check afterward to support flash swaps. At that moment, the contract's actual balances are the only trustworthy facts.
A general rule: make decisions about money from state you can inspect yourself, not numbers someone else supplies.
It creates an infinitely repeatable exploit with tiny profit per operation. The pool loses a little each trade and a substantial amount after tens of thousands. It causes no revert, throws no error, and can pass ordinary unit tests.
Catch it with invariant tests: run thousands of random directions and amounts, assert the product never falls, and assert a deposit followed by withdrawal does not return more than was put in.
Treat these tests as required for an AMM, not optional polish.
No.
Slippage measures how far your average execution price differs from the starting price. Price impact measures how far the pool's price after the trade differs from the starting price.
On a constant-product curve, the same trade's price impact is about twice its slippage, because execution travels along the curve and the average lies between the start and finish.
Confusing the two in a front end harms users: displayed slippage may look acceptable while the trade fails its protection, or the reverse.
Using the Lab formula, you need about 73.2% of one side's reserve. If one side holds $1 million, that is about $732,000.
The second part is crucial: the money has not vanished. The manipulator exchanged it for the other asset and now holds a position at a severely distorted price. When arbitrageurs restore the price, a substantial portion becomes a loss.
Thus “manipulation cost” means both the capital needed to move the price and the net loss when it returns. An attack works if the manipulator can extract more value from another contract at the false price before that return.
T21 addresses exactly this. There is no single numeric answer; check that you have calculated required capital, whether the move can happen in one transaction, and the loss on reversal.
Key idea
Every property of the constant-product formula has a counterpart in a few dozen lines of code.