DEV Community

Cover image for A .NET Dinosaur in Web3. Day 18 - Automated Market Maker
Olena
Olena

Posted on • Originally published at Medium

A .NET Dinosaur in Web3. Day 18 - Automated Market Maker

🏦 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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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();
    }
}

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode
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

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)