">
 

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

Iniciado por joomlamz, 31 de Maio de 2026, 17:35

Respostas: 0   |   Visualizações: 16

Tópico anterior - Tópico seguinte

0 Membros e 1 Visitante estão a ver este tópico.

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



Tópico: A .NET Dinosaur in Web3. Day 18 - Automated Market Maker
Categoria: Tutoriais | Programação & Tecnologia
Idioma Principal: Português (Conteúdo de Tecnologia)

Descrição do Conteúdo / Informações:
-------------------------------------------------------------------------


🏦 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.


Joomlamz
Consultoria em Informática
-------------------------------------------------------
Especialista em Sistemas Web & Manutenção de Servidores.
A desenvolver o novo AplPortal com suporte a PHP 8.
Precisa de ajuda profissional? Contacte-me.

Tags: