Crypto OS
Technical Crypto OSStage 4 · DeFi Engineering

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:

  1. 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.
  2. Deduct it from the withdrawal. Calculate 9.09 apples for ten oranges, then deduct 0.027 apples from that output.
  3. 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.

ApproachUser receivesProduct after tradeHow LPs receive feesInvariant check
Input-side fee left in pool9,871.58IncreasesShares appreciate automaticallyHolds naturally
Output-side fee9,871.29IncreasesShares appreciate automaticallyHolds but shifts price
Final check only9,900.99UnchangedNo fees to distributeOnly defense
Fee transferred outside9,900.99DecreasesExternal accountingInevitably 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:

You receive
45,455 tokens
Execution price above market
10.00%
Pool price increase after trade
21.00%

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 numerator before 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 outputDownEach trade gives a little extra; the pool steadily loses assets
Minted LP sharesDownNew LPs receive unearned shares, diluting existing LPs
Tokens returned when shares burnDownThe exiting LP takes more, at the expense of those who remain
Invariant checkRequire no decrease with greater than or equalEach 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

Constant ProductConstant product

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.

ReserveReserve

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.

SwapSwap

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.

FeeFee

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.

LP TokenLiquidity share

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.

Price ImpactPrice impact

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

Hands onBuild a minimal AMM and test the arithmetic of slippage, fees, deposits, and withdrawalsSolidity and any contract test frameworkFree, using only a local network

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

AI LabAsk AI to derive the fee-adjusted quote formula; check its fee placement with your own test numbersLevel 2 · AI Copilot

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

The first liquidity provider controls the opening priceRepeatedly observed

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.

Tokens that charge fees on transferOngoing

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.

Rounding in the wrong directionClassic vulnerability class

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.

Confusing balances and reservesCommon implementation mistake

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

What ifThe fee drops from 0.3% to 0.05%

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.

What ifReplace the product rule with a sum rule

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.

What ifThe tokens use different precision: one has 18 decimals, the other 6

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.

What ifLet LPs claim fees separately instead of leaving them in the pool

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

8
Who supplies liquidity?

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.

5
Who is paying?

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?

9
Who bears the risk?

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.

10
What is the incentive?

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

Key idea

Every property of the constant-product formula has a counterpart in a few dozen lines of code.

Finished this chapter’s lab? Mark it completeView all progress

On this page