VOOZH about

URL: https://dev.to/olenadevsoft/a-net-dinosaur-in-web3-day-18-automated-market-maker-5c9k

โ‡ฑ A .NET Dinosaur in Web3. Day 18 - Automated Market Maker - DEV Community


๐Ÿฆ Day 6 of 7: Building a Mini Uniswap in 80 Lines of Solidity

Imagine a vending machine. It has 1,000 coffee beans and 1,000 coins. No menu, no cashier โ€” just one iron rule: the product of the two numbers inside must never decrease.

That's it!

This is how Uniswap works โ€” and this is what I built on Day 6, coming from .NET. Here's how, why it's elegant, and where you can step on a rake.

Why an Order Book Doesn't Work on a Blockchain

Traditional exchanges โ€” Binance, NYSE, any CEX โ€” run on an order book. Market makers post bids and asks. A matching engine pairs them. Millions of updates per second, all in a centralised database.

In a blockchain, this is impossible. Transactions take 12 seconds. Every state change costs gas. Storing millions of constantly changing orders would eat all the profit before a single trade completes.

Uniswap's solution: replace the order book with a liquidity pool โ€” a smart contract holding two tokens โ€” and replace the matching engine with pure math.

Just a formula โ€” below.

x ยท y = k โ€” The Formula That Broke Finance

The Constant Product Invariant:

x ยท y = k

Where x is the reserve of Token0, y is the reserve of Token1, and k is a constant that must never decrease during swaps.

When a trader sells Token0 into the pool, x increases. To keep k constant, y must decrease โ€” the contract sends out Token1. The price is determined automatically by the ratio of reserves.

Live example with numbers:

Pool: 1,000 Token0, 1,000 Token1. k = 1,000,000.

Trader sells 100 Token0:

amountOut = (reserveOut ร— amountIn) / (reserveIn + amountIn)
amountOut = (1000 ร— 100) / (1000 + 100)
amountOut = 100,000 / 1,100
amountOut โ‰ˆ 90.9 Token1

The trader gets ~90.9, not 100. That gap is slippage โ€” and it's not a bug. It's the formula protecting the pool. The more you buy relative to pool size, the worse your price gets. Naturally. Mathematically.

After the swap: pool has 1,100 Token0 and ~909.1 Token1. k โ‰ˆ 1,000,000. Invariant holds.

The Contract: SimpleAMM

Three functions. Each one exists for a specific reason.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract SimpleAMM {
 error ZeroAmount();
 error InvalidToken();
 error ZeroLiquidity();
 error TransferFailed();
 error InvalidRatio();

 IERC20 public immutable token0;
 IERC20 public immutable token1;

 // Internal reserves โ€” cheaper than calling balanceOf() every time
 uint256 public reserve0;
 uint256 public reserve1;

 event LiquidityAdded(address indexed provider, uint256 amount0, uint256 amount1);
 event Swap(address indexed trader, address tokenIn, uint256 amountIn, uint256 amountOut);

 constructor(address _token0, address _token1) {
 token0 = IERC20(_token0);
 token1 = IERC20(_token1);
 }

 // Pure math โ€” no state, no side effects
 function getAmountOut(uint256 _amountIn, uint256 _reserveIn, uint256 _reserveOut)
 public pure returns (uint256)
 {
 if (_amountIn == 0) revert ZeroAmount();
 if (_reserveIn == 0 || _reserveOut == 0) revert ZeroLiquidity();

 // Multiply first, divide last โ€” always
 uint256 numerator = _reserveOut * _amountIn;
 uint256 denominator = _reserveIn + _amountIn;
 return numerator / denominator;
 }

 function addLiquidity(uint256 _amount0, uint256 _amount1) external {
 if (_amount0 == 0 || _amount1 == 0) revert ZeroAmount();

 // If pool already has liquidity, enforce the current price ratio
 if (reserve0 > 0 && reserve1 > 0) {
 if (_amount0 * reserve1 != _amount1 * reserve0) revert InvalidRatio();
 }

 if (!token0.transferFrom(msg.sender, address(this), _amount0)) revert TransferFailed();
 if (!token1.transferFrom(msg.sender, address(this), _amount1)) revert TransferFailed();

 reserve0 += _amount0;
 reserve1 += _amount1;

 emit LiquidityAdded(msg.sender, _amount0, _amount1);
 }

 function swap(address _tokenIn, uint256 _amountIn) external returns (uint256 amountOut) {
 if (_amountIn == 0) revert ZeroAmount();
 if (_tokenIn != address(token0) && _tokenIn != address(token1)) revert InvalidToken();

 bool isToken0 = _tokenIn == address(token0);

 (IERC20 tokenIn, IERC20 tokenOut, uint256 reserveIn, uint256 reserveOut) = isToken0
 ? (token0, token1, reserve0, reserve1)
 : (token1, token0, reserve1, reserve0);

 // CEI: pull tokens in first
 if (!tokenIn.transferFrom(msg.sender, address(this), _amountIn)) revert TransferFailed();

 // Calculate output
 amountOut = getAmountOut(_amountIn, reserveIn, reserveOut);

 // Update reserves
 if (isToken0) {
 reserve0 += _amountIn;
 reserve1 -= amountOut;
 } else {
 reserve0 -= amountOut;
 reserve1 += _amountIn;
 }

 emit Swap(msg.sender, _tokenIn, _amountIn, amountOut);

 // Send output tokens to trader
 if (!tokenOut.transfer(msg.sender, amountOut)) revert TransferFailed();
 }
}

getAmountOut โ€” pure math, no state. Separated deliberately so it can be called by anyone to preview a trade before executing it. In DeFi this is standard: quote first, then transact.

addLiquidity โ€” the ratio check is the interesting part. If the pool already has reserves, you can't deposit in arbitrary proportions. _amount0 * reserve1 != _amount1 * reserve0 detects any imbalance. Deposit skewed amounts and you'd instantly change the price โ€” essentially donating money to arbitrageurs.

swap โ€” the ternary tuple assignment is the cleanest part of the contract. Instead of two separate if/else branches, one line maps all four variables correctly based on direction:

(IERC20 tokenIn, IERC20 tokenOut, uint256 reserveIn, uint256 reserveOut) = isToken0
 ? (token0, token1, reserve0, reserve1)
 : (token1, token0, reserve1, reserve0);

Where You Can Step on a Rake

Integer division truncates, silently.

getAmountOut divides at the end โ€” intentionally. But the truncation still happens. 100,000 / 1,100 = 90, not 90.909.... The pool keeps the remainder. At scale across millions of trades, this accumulated dust is non-trivial. Production AMMs handle this with basis points (fee = 30 bps = multiply by 997/1000 before dividing).

Internal reserves vs balanceOf.

The contract tracks reserve0 and reserve1 internally instead of calling token0.balanceOf(address(this)) every time. Two reasons: gas savings (SLOAD is expensive, external calls are more expensive), and security โ€” if someone sends tokens directly to the contract without going through addLiquidity, the reserves won't silently become unbalanced and break the invariant.

Console Verification Flow

npx hardhat ignition deploy ignition/modules/SimpleAMM.ts --network localhost --reset
npx hardhat console --network localhost

const { viem } = await network.create();
const [owner, trader] = await viem.getWalletClients();
const cViem = require("viem");

const t0Address = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const t1Address = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512";
const ammAddress = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0";

const token0 = await viem.getContractAt("MyToken", t0Address);
const token1 = await viem.getContractAt("MyToken", t1Address);
const amm = await viem.getContractAt("SimpleAMM", ammAddress);

// Trader buys tokens via ICO
const t0AsTrader = await viem.getContractAt("MyToken", t0Address, { client: { wallet: trader } });
const t1AsTrader = await viem.getContractAt("MyToken", t1Address, { client: { wallet: trader } });
await t0AsTrader.write.buyTokens({ value: cViem.parseEther("2") }); // 2000 Token0
await t1AsTrader.write.buyTokens({ value: cViem.parseEther("2") }); // 2000 Token1

// Add liquidity 1000:1000
const ammAsTrader = await viem.getContractAt("SimpleAMM", ammAddress, { client: { wallet: trader } });
await t0AsTrader.write.approve([ammAddress, cViem.parseEther("1000")]);
await t1AsTrader.write.approve([ammAddress, cViem.parseEther("1000")]);
await ammAsTrader.write.addLiquidity([cViem.parseEther("1000"), cViem.parseEther("1000")]);

console.log("Reserve 0:", cViem.formatEther(await amm.read.reserve0())); // 1000
console.log("Reserve 1:", cViem.formatEther(await amm.read.reserve1())); // 1000

// Swap 100 Token0 โ†’ Token1
await t0AsTrader.write.approve([ammAddress, cViem.parseEther("100")]);
await ammAsTrader.write.swap([t0Address, cViem.parseEther("100")]);

const traderT1Balance = await token1.read.balanceOf([trader.account.address]);
console.log("Trader Token1 after swap:", cViem.formatEther(traderT1Balance));
// ~1090.909... โ€” math checks out

The formula lands exactly. 1,000 ร— 100 / 1,100 = 90.909... Token1 received. The invariant holds.

What This Day Actually Meant

Six days ago I was writing owner = msg.sender in a constructor. Today I implemented the core pricing engine of a decentralised exchange.

What transferred directly from .NET:

  • CEI pattern โ€” same as any transactional system
  • Separation of pure logic (getAmountOut) from state mutation (swap) โ€” same as keeping domain logic out of controllers
  • Defensive checks before any state change โ€” same as guard clauses

What was genuinely new:

  • Thinking in invariants instead of conditions
  • Price as an emergent property of reserves, not a stored value
  • The elegance of x ยท y = k โ€” one line that replaces an entire matching engine

What's Next

Day 7: Reentrancy Protection โ€” the vulnerability that cost $60M in the 2016 DAO hack, and how to write contracts that can't be drained.


Repo: github.com/alena-dev-soft

Follow the journey on Telegram: t.me/dotnetToWeb3

Stage: Dinosaur ๐Ÿฆ• โ€” going deeper into the bedrock. Day 6 of 7.