├── .gitignore ├── README.md ├── contracts ├── LooneySwapPool.sol └── test │ └── Token.sol ├── hardhat.config.ts ├── package-lock.json ├── package.json └── test └── LooneySwapPool.spec.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | #Hardhat files 4 | cache 5 | artifacts 6 | typechain 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LooneySwap 2 | A rudimentary implementation of uniswap for educational purposes. You'd be crazy to actually use this. 3 | 4 | ## Guide 5 | Read the guide here: [Uniswap from scratch](https://monokh.com/posts/uniswap-from-scratch) 6 | 7 | ## Run tests 8 | ``` 9 | npm install 10 | npx hardhat compile 11 | npx hardhat test 12 | ``` 13 | -------------------------------------------------------------------------------- /contracts/LooneySwapPool.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: Unlicense 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | import "@openzeppelin/contracts/utils/math/Math.sol"; 6 | import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 7 | 8 | contract LooneySwapPool is ERC20 { 9 | address public token0; 10 | address public token1; 11 | 12 | // Reserve of token 0 13 | uint public reserve0; 14 | 15 | // Reserve of token 1 16 | uint public reserve1; 17 | 18 | uint public constant INITIAL_SUPPLY = 10**5; 19 | 20 | constructor(address _token0, address _token1) ERC20("LooneyLiquidityProvider", "LP") { 21 | token0 = _token0; 22 | token1 = _token1; 23 | } 24 | 25 | /** 26 | * Adds liquidity to the pool. 27 | * 1. Transfer tokens to pool 28 | * 2. Emit LP tokens 29 | * 3. Update reserves 30 | */ 31 | function add(uint amount0, uint amount1) public { 32 | assert(IERC20(token0).transferFrom(msg.sender, address(this), amount0)); 33 | assert(IERC20(token1).transferFrom(msg.sender, address(this), amount1)); 34 | 35 | uint reserve0After = reserve0 + amount0; 36 | uint reserve1After = reserve1 + amount1; 37 | 38 | if (reserve0 == 0 && reserve1 == 0) { 39 | _mint(msg.sender, INITIAL_SUPPLY); 40 | } else { 41 | uint currentSupply = totalSupply(); 42 | uint newSupplyGivenReserve0Ratio = reserve0After * currentSupply / reserve0; 43 | uint newSupplyGivenReserve1Ratio = reserve1After * currentSupply / reserve1; 44 | uint newSupply = Math.min(newSupplyGivenReserve0Ratio, newSupplyGivenReserve1Ratio); 45 | _mint(msg.sender, newSupply - currentSupply); 46 | } 47 | 48 | reserve0 = reserve0After; 49 | reserve1 = reserve1After; 50 | } 51 | 52 | /** 53 | * Removes liquidity from the pool. 54 | * 1. Transfer LP tokens to pool 55 | * 2. Burn the LP tokens 56 | * 3. Update reserves 57 | */ 58 | function remove(uint liquidity) public { 59 | assert(transfer(address(this), liquidity)); 60 | 61 | uint currentSupply = totalSupply(); 62 | uint amount0 = liquidity * reserve0 / currentSupply; 63 | uint amount1 = liquidity * reserve1 / currentSupply; 64 | 65 | _burn(address(this), liquidity); 66 | 67 | assert(IERC20(token0).transfer(msg.sender, amount0)); 68 | assert(IERC20(token1).transfer(msg.sender, amount1)); 69 | reserve0 = reserve0 - amount0; 70 | reserve1 = reserve1 - amount1; 71 | } 72 | 73 | /** 74 | * Uses x * y = k formula to calculate output amount. 75 | * 1. Calculate new reserve on both sides 76 | * 2. Derive output amount 77 | */ 78 | function getAmountOut (uint amountIn, address fromToken) public view returns (uint amountOut, uint _reserve0, uint _reserve1) { 79 | uint newReserve0; 80 | uint newReserve1; 81 | uint k = reserve0 * reserve1; 82 | 83 | // x (reserve0) * y (reserve1) = k (constant) 84 | // (reserve0 + amountIn) * (reserve1 - amountOut) = k 85 | // (reserve1 - amountOut) = k / (reserve0 + amount) 86 | // newReserve1 = k / (newReserve0) 87 | // amountOut = newReserve1 - reserve1 88 | 89 | if (fromToken == token0) { 90 | newReserve0 = amountIn + reserve0; 91 | newReserve1 = k / newReserve0; 92 | amountOut = reserve1 - newReserve1; 93 | } else { 94 | newReserve1 = amountIn + reserve1; 95 | newReserve0 = k / newReserve1; 96 | amountOut = reserve0 - newReserve0; 97 | } 98 | 99 | _reserve0 = newReserve0; 100 | _reserve1 = newReserve1; 101 | } 102 | 103 | /** 104 | * Swap to a minimum of `minAmountOut` 105 | * 1. Calculate new reserve on both sides 106 | * 2. Derive output amount 107 | * 3. Check output against minimum requested 108 | * 4. Update reserves 109 | */ 110 | function swap(uint amountIn, uint minAmountOut, address fromToken, address toToken, address to) public { 111 | require(amountIn > 0 && minAmountOut > 0, 'Amount invalid'); 112 | require(fromToken == token0 || fromToken == token1, 'From token invalid'); 113 | require(toToken == token0 || toToken == token1, 'To token invalid'); 114 | require(fromToken != toToken, 'From and to tokens should not match'); 115 | 116 | (uint amountOut, uint newReserve0, uint newReserve1) = getAmountOut(amountIn, fromToken); 117 | 118 | require(amountOut >= minAmountOut, 'Slipped... on a banana'); 119 | 120 | assert(IERC20(fromToken).transferFrom(msg.sender, address(this), amountIn)); 121 | assert(IERC20(toToken).transfer(to, amountOut)); 122 | 123 | reserve0 = newReserve0; 124 | reserve1 = newReserve1; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /contracts/test/Token.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: Unlicense 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract Token is ERC20 { 7 | constructor(string memory name, string memory symbol, uint256 initialSupply) ERC20(name, symbol) { 8 | _mint(msg.sender, initialSupply); 9 | } 10 | } -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers' 2 | import 'hardhat-watcher' 3 | import '@typechain/hardhat' 4 | import '@nomiclabs/hardhat-waffle' 5 | import { task } from 'hardhat/config' 6 | 7 | // This is a sample Hardhat task. To learn how to create your own go to 8 | // https://hardhat.org/guides/create-task.html 9 | task("accounts", "Prints the list of accounts", async (args, hre) => { 10 | const accounts = await hre.ethers.getSigners() 11 | 12 | for (const account of accounts) { 13 | console.log(account.address) 14 | } 15 | }) 16 | 17 | // You need to export an object to set up your config 18 | // Go to https://hardhat.org/config/ to learn more 19 | 20 | /** 21 | * @type import('hardhat/config').HardhatUserConfig 22 | */ 23 | export default { 24 | solidity: "0.8.4", 25 | watcher: { 26 | ci: { 27 | files: ["./contracts", "./test"], 28 | tasks: [ 29 | { command: "compile", params: { quiet: true } }, 30 | { command: "test", params: { noCompile: true } } 31 | ] 32 | } 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hardhat-project", 3 | "devDependencies": { 4 | "@nomiclabs/hardhat-ethers": "^2.0.2", 5 | "@nomiclabs/hardhat-waffle": "^2.0.1", 6 | "@typechain/ethers-v5": "^7.0.1", 7 | "@typechain/hardhat": "^2.1.1", 8 | "@types/chai": "^4.2.19", 9 | "@types/mocha": "^8.2.2", 10 | "@types/node": "^15.14.0", 11 | "chai": "^4.3.4", 12 | "ethereum-waffle": "^3.4.0", 13 | "ethers": "^5.4.0", 14 | "hardhat": "^2.4.1", 15 | "ts-node": "^10.0.0", 16 | "typechain": "^5.1.1", 17 | "typescript": "^4.3.5" 18 | }, 19 | "dependencies": { 20 | "@openzeppelin/contracts": "^4.2.0", 21 | "hardhat-watcher": "^2.1.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/LooneySwapPool.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import { solidity } from 'ethereum-waffle' 3 | import { ethers } from 'hardhat' 4 | import { Token } from '../typechain/Token' 5 | import { LooneySwapPool } from '../typechain/LooneySwapPool' 6 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' 7 | 8 | use(solidity) 9 | 10 | describe("LooneySwapPool", function() { 11 | let eth: Token 12 | let dai: Token 13 | let pool: LooneySwapPool 14 | let accounts: SignerWithAddress[] 15 | 16 | beforeEach(async () => { 17 | accounts = await ethers.getSigners() 18 | 19 | const Token = await ethers.getContractFactory('Token') 20 | eth = await Token.deploy('Ethereum', 'ETH', 100000000000) as Token 21 | dai = await Token.deploy('DAI', 'DAI', 1000000000000000) as Token 22 | await eth.deployed() 23 | await dai.deployed() 24 | 25 | const [, account1, account2, account3, account4] = accounts 26 | 27 | for (const account of [account1, account2, account3, account4]) { 28 | await (await eth.transfer(account.address, 200)).wait() 29 | await (await dai.transfer(account.address, 10000000)).wait() 30 | } 31 | 32 | const LooneySwapPool = await ethers.getContractFactory("LooneySwapPool") 33 | pool = await LooneySwapPool.deploy(eth.address, dai.address) as LooneySwapPool 34 | await pool.deployed() 35 | }) 36 | 37 | it("Should return initialized pool", async function() { 38 | expect(await pool.token0()).to.equal(eth.address) 39 | expect(await pool.token1()).to.equal(dai.address) 40 | expect(await pool.reserve0()).to.equal(0) 41 | expect(await pool.reserve1()).to.equal(0) 42 | }) 43 | 44 | it("Should add initial liquidity to reserves", async function() { 45 | await (await eth.connect(accounts[1]).approve(pool.address, 1)).wait() 46 | await (await dai.connect(accounts[1]).approve(pool.address, 2000)).wait() 47 | await (await pool.connect(accounts[1]).add(1, 2000)).wait() 48 | 49 | expect(await pool.reserve0()).to.equal(1) 50 | expect(await pool.reserve1()).to.equal(2000) 51 | expect(await pool.totalSupply()).to.equal(100000) // Initial Supply 52 | expect(await pool.balanceOf(accounts[1].address)).to.equal(100000) 53 | }) 54 | 55 | it("Should mint correct amount", async function() { 56 | await (await eth.connect(accounts[1]).approve(pool.address, 1)).wait() 57 | await (await dai.connect(accounts[1]).approve(pool.address, 2000)).wait() 58 | await (await pool.connect(accounts[1]).add(1, 2000)).wait() 59 | 60 | await (await eth.connect(accounts[2]).approve(pool.address, 3)).wait() 61 | await (await dai.connect(accounts[2]).approve(pool.address, 6000)).wait() 62 | await (await pool.connect(accounts[2]).add(3, 6000)).wait() 63 | 64 | expect(await pool.reserve0()).to.equal(4) 65 | expect(await pool.reserve1()).to.equal(8000) 66 | expect(await pool.totalSupply()).to.equal(400000) 67 | 68 | expect(await pool.balanceOf(accounts[1].address)).to.equal(100000) 69 | expect(await pool.balanceOf(accounts[2].address)).to.equal(300000) 70 | }) 71 | 72 | it("Should burn correct amount", async function() { 73 | await (await eth.connect(accounts[1]).approve(pool.address, 1)).wait() 74 | await (await dai.connect(accounts[1]).approve(pool.address, 2000)).wait() 75 | await (await pool.connect(accounts[1]).add(1, 2000)).wait() 76 | 77 | await (await eth.connect(accounts[2]).approve(pool.address, 3)).wait() 78 | await (await dai.connect(accounts[2]).approve(pool.address, 6000)).wait() 79 | await (await pool.connect(accounts[2]).add(3, 6000)).wait() 80 | 81 | const ethBalanceBefore = await eth.balanceOf(accounts[1].address) 82 | const daiBalanceBefore = await dai.balanceOf(accounts[1].address) 83 | 84 | const lpTokens = await pool.balanceOf(accounts[1].address) 85 | await (await pool.connect(accounts[1]).remove(lpTokens)).wait() 86 | 87 | expect(await eth.balanceOf(accounts[1].address)).to.equal(ethBalanceBefore.add(1)) 88 | expect(await dai.balanceOf(accounts[1].address)).to.equal(daiBalanceBefore.add(2000)) 89 | 90 | expect(await pool.reserve0()).to.equal(3) 91 | expect(await pool.reserve1()).to.equal(6000) 92 | expect(await pool.totalSupply()).to.equal(300000) 93 | expect(await pool.balanceOf(accounts[1].address)).to.equal(0) 94 | }) 95 | 96 | it("Should return correct output amount for dai", async function() { 97 | await (await eth.connect(accounts[1]).approve(pool.address, 5)).wait() 98 | await (await dai.connect(accounts[1]).approve(pool.address, 10000)).wait() 99 | await (await pool.connect(accounts[1]).add(5, 10000)).wait() 100 | 101 | const [amountOut, reserve0, reserve1] = await pool.getAmountOut(1, eth.address) 102 | expect(amountOut).to.equal(1667) 103 | expect(reserve0).to.equal(6) 104 | expect(reserve1).to.equal(8333) 105 | }) 106 | 107 | it("Should return correct output amount for eth", async function() { 108 | await (await eth.connect(accounts[1]).approve(pool.address, 20)).wait() 109 | await (await dai.connect(accounts[1]).approve(pool.address, 40000)).wait() 110 | await (await pool.connect(accounts[1]).add(20, 40000)).wait() 111 | 112 | const [amountOut, reserve0, reserve1] = await pool.getAmountOut(6000, dai.address) 113 | expect(amountOut).to.equal(3) 114 | expect(reserve0).to.equal(17) 115 | expect(reserve1).to.equal(46000) 116 | }) 117 | 118 | it("Should swap successfully with exact amountOut", async function() { 119 | await (await eth.connect(accounts[1]).approve(pool.address, 5)).wait() 120 | await (await dai.connect(accounts[1]).approve(pool.address, 10000)).wait() 121 | await (await pool.connect(accounts[1]).add(5, 10000)).wait() 122 | 123 | await (await eth.connect(accounts[2]).approve(pool.address, 20)).wait() 124 | await (await dai.connect(accounts[2]).approve(pool.address, 40000)).wait() 125 | await (await pool.connect(accounts[2]).add(20, 40000)).wait() 126 | 127 | const ethBalanceBefore = await eth.balanceOf(accounts[3].address) 128 | const daiBalanceBefore = await dai.balanceOf(accounts[3].address) 129 | 130 | const [amountOut] = await pool.getAmountOut(1, eth.address) 131 | await (await eth.connect(accounts[3]).approve(pool.address, amountOut)).wait 132 | await (await pool.connect(accounts[3]).swap(1, amountOut, eth.address, dai.address, accounts[3].address)) 133 | 134 | expect(await eth.balanceOf(accounts[3].address)).to.equal(ethBalanceBefore.sub(1)) 135 | expect(await dai.balanceOf(accounts[3].address)).to.equal(daiBalanceBefore.add(1924)) 136 | }) 137 | 138 | it("Should prevent slip when output slides", async function() { 139 | await (await eth.connect(accounts[1]).approve(pool.address, 20)).wait() 140 | await (await dai.connect(accounts[1]).approve(pool.address, 40000)).wait() 141 | await (await pool.connect(accounts[1]).add(20, 40000)).wait() 142 | 143 | const [amountOut] = await pool.getAmountOut(1, eth.address) 144 | await (await eth.connect(accounts[2]).approve(pool.address, amountOut)).wait 145 | await (await pool.connect(accounts[2]).swap(1, amountOut, eth.address, dai.address, accounts[2].address)) 146 | 147 | await (await eth.connect(accounts[3]).approve(pool.address, amountOut)).wait 148 | await expect(pool.connect(accounts[3]).swap(1, amountOut, eth.address, dai.address, accounts[3].address)).to.be.revertedWith('Slipped... on a banana') 149 | }) 150 | }) 151 | --------------------------------------------------------------------------------