diff --git a/contracts/dex/foundry.toml b/contracts/dex/foundry.toml new file mode 100644 index 000000000..efe433d0b --- /dev/null +++ b/contracts/dex/foundry.toml @@ -0,0 +1,20 @@ +[profile.default] +src = "src" +test = "test" +script = "script" +out = "out" +libs = [] +solc_version = "0.8.28" +# Devnet 3's executor runs the EVM through revm; Paris keeps the bytecode off PUSH0 and transient storage so it runs +# on any post-Merge configuration of it (8 October 2026). +evm_version = "paris" +optimizer = true +optimizer_runs = 200 +fs_permissions = [{ access = "read-write", path = "./deploy-out.json" }] +# no remote dependencies: the tests and the script declare the cheatcode interface they use (contracts/dex/test/Vm.sol) +auto_detect_remappings = false +offline = true + +[rpc_endpoints] +# the Devnet 3 EVM reaches the build box through a tunnel on this port (never the Mac); see docs/contracts/devnet-3.json +devnet3 = "http://127.0.0.1:36790" diff --git a/contracts/dex/script/Deploy.s.sol b/contracts/dex/script/Deploy.s.sol new file mode 100644 index 000000000..a3d733380 --- /dev/null +++ b/contracts/dex/script/Deploy.s.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Vm, VM_ADDRESS} from "../test/Vm.sol"; +import {WIGN} from "../src/WIGN.sol"; +import {TestToken} from "../src/TestToken.sol"; +import {IgneumFactory} from "../src/IgneumPair.sol"; +import {IgneumRouter} from "../src/IgneumRouter.sol"; + +/// Deploys the AMM on Devnet 3 from the key in DEX_DEPLOYER_KEY (read from the environment, never printed), seeds +/// three pools from the faucet and 200 IGN, and writes the addresses to deploy-out.json for docs/contracts. +/// +/// forge script script/Deploy.s.sol:Deploy --rpc-url devnet3 --broadcast --sig "run()" +/// +/// Devnet 3, test tokens, no value. +contract Deploy { + Vm constant vm = Vm(VM_ADDRESS); + + function run() external { + uint256 key = vm.envUint("DEX_DEPLOYER_KEY"); + address deployer = vm.addr(key); + vm.startBroadcast(key); + + WIGN wign = new WIGN(); + TestToken tta = new TestToken("Test Token A", "TTA"); + TestToken ttb = new TestToken("Test Token B", "TTB"); + IgneumFactory factory = new IgneumFactory(); + IgneumRouter router = new IgneumRouter(address(factory), address(wign)); + + tta.drip(); + ttb.drip(); + tta.approve(address(router), type(uint256).max); + ttb.approve(address(router), type(uint256).max); + uint256 deadline = block.timestamp + 1 hours; + router.addLiquidityIGN{value: 100 ether}(address(tta), 500 ether, 0, 0, deployer, deadline); + router.addLiquidityIGN{value: 100 ether}(address(ttb), 500 ether, 0, 0, deployer, deadline); + router.addLiquidity(address(tta), address(ttb), 500 ether, 500 ether, 0, 0, deployer, deadline); + + vm.stopBroadcast(); + + string memory json = string.concat( + "{\n", + " \"WIGN\": \"", vm.toString(address(wign)), "\",\n", + " \"TTA\": \"", vm.toString(address(tta)), "\",\n", + " \"TTB\": \"", vm.toString(address(ttb)), "\",\n", + " \"IgneumFactory\": \"", vm.toString(address(factory)), "\",\n", + " \"IgneumRouter\": \"", vm.toString(address(router)), "\",\n", + " \"pair_TTA_WIGN\": \"", vm.toString(factory.getPair(address(tta), address(wign))), "\",\n", + " \"pair_TTB_WIGN\": \"", vm.toString(factory.getPair(address(ttb), address(wign))), "\",\n", + " \"pair_TTA_TTB\": \"", vm.toString(factory.getPair(address(tta), address(ttb))), "\",\n", + " \"deployer\": \"", vm.toString(deployer), "\"\n", + "}\n" + ); + vm.writeFile("deploy-out.json", json); + } +} diff --git a/contracts/dex/src/ERC20.sol b/contracts/dex/src/ERC20.sol new file mode 100644 index 000000000..668ec63d3 --- /dev/null +++ b/contracts/dex/src/ERC20.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// A plain ERC-20 with EIP-2612-free approvals, shared by the pair's liquidity token, the wrapped coin and the test +/// tokens. Devnet 3, test tokens, no value. +contract ERC20 { + string public name; + string public symbol; + uint8 public constant decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory name_, string memory symbol_) { + name = name_; + symbol = symbol_; + } + + function _mint(address to, uint256 value) internal { + totalSupply += value; + balanceOf[to] += value; + emit Transfer(address(0), to, value); + } + + function _burn(address from, uint256 value) internal { + balanceOf[from] -= value; + totalSupply -= value; + emit Transfer(from, address(0), value); + } + + function _transfer(address from, address to, uint256 value) internal { + balanceOf[from] -= value; + balanceOf[to] += value; + emit Transfer(from, to, value); + } + + function approve(address spender, uint256 value) external returns (bool) { + allowance[msg.sender][spender] = value; + emit Approval(msg.sender, spender, value); + return true; + } + + function transfer(address to, uint256 value) external returns (bool) { + _transfer(msg.sender, to, value); + return true; + } + + function transferFrom(address from, address to, uint256 value) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + allowance[from][msg.sender] = allowed - value; + } + _transfer(from, to, value); + return true; + } +} diff --git a/contracts/dex/src/IgneumPair.sol b/contracts/dex/src/IgneumPair.sol new file mode 100644 index 000000000..2a3dc2a08 --- /dev/null +++ b/contracts/dex/src/IgneumPair.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC20} from "./ERC20.sol"; + +interface IERC20Minimal { + function balanceOf(address) external view returns (uint256); + function transfer(address, uint256) external returns (bool); +} + +/// A constant-product pool in the Uniswap v2 shape: reserves of two tokens, a 0.3 percent fee kept in the pool, +/// liquidity tokens for the depositors, MINIMUM_LIQUIDITY locked at the first mint. No protocol fee, no price +/// accumulators, no flash callback. Devnet 3, test tokens, no value. +contract IgneumPair is ERC20("Igneum LP", "IGN-LP") { + uint256 public constant MINIMUM_LIQUIDITY = 10 ** 3; + + address public immutable factory; + address public token0; + address public token1; + + uint112 private reserve0; + uint112 private reserve1; + uint32 private blockTimestampLast; + + uint256 private unlocked = 1; + + event Mint(address indexed sender, uint256 amount0, uint256 amount1); + event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to); + event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to); + event Sync(uint112 reserve0, uint112 reserve1); + + modifier lock() { + require(unlocked == 1, "Pair: locked"); + unlocked = 0; + _; + unlocked = 1; + } + + constructor() { + factory = msg.sender; + } + + function initialize(address token0_, address token1_) external { + require(msg.sender == factory, "Pair: forbidden"); + token0 = token0_; + token1 = token1_; + } + + function getReserves() public view returns (uint112, uint112, uint32) { + return (reserve0, reserve1, blockTimestampLast); + } + + function _safeTransfer(address token, address to, uint256 value) private { + (bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Minimal.transfer.selector, to, value)); + require(ok && (data.length == 0 || abi.decode(data, (bool))), "Pair: transfer failed"); + } + + function _update(uint256 balance0, uint256 balance1) private { + require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "Pair: overflow"); + reserve0 = uint112(balance0); + reserve1 = uint112(balance1); + blockTimestampLast = uint32(block.timestamp); + emit Sync(reserve0, reserve1); + } + + function _sqrt(uint256 y) private pure returns (uint256 z) { + if (y > 3) { + z = y; + uint256 x = y / 2 + 1; + while (x < z) { + z = x; + x = (y / x + x) / 2; + } + } else if (y != 0) { + z = 1; + } + } + + function _min(uint256 x, uint256 y) private pure returns (uint256) { + return x < y ? x : y; + } + + /// Mints liquidity for the tokens sent to the pair since the last sync. Called by the router. + function mint(address to) external lock returns (uint256 liquidity) { + (uint112 r0, uint112 r1,) = getReserves(); + uint256 balance0 = IERC20Minimal(token0).balanceOf(address(this)); + uint256 balance1 = IERC20Minimal(token1).balanceOf(address(this)); + uint256 amount0 = balance0 - r0; + uint256 amount1 = balance1 - r1; + if (totalSupply == 0) { + liquidity = _sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY; + _mint(address(0xdead), MINIMUM_LIQUIDITY); + } else { + liquidity = _min(amount0 * totalSupply / r0, amount1 * totalSupply / r1); + } + require(liquidity > 0, "Pair: insufficient liquidity minted"); + _mint(to, liquidity); + _update(balance0, balance1); + emit Mint(msg.sender, amount0, amount1); + } + + /// Burns the liquidity tokens sent to the pair and pays both tokens out pro rata. Called by the router. + function burn(address to) external lock returns (uint256 amount0, uint256 amount1) { + uint256 balance0 = IERC20Minimal(token0).balanceOf(address(this)); + uint256 balance1 = IERC20Minimal(token1).balanceOf(address(this)); + uint256 liquidity = balanceOf[address(this)]; + amount0 = liquidity * balance0 / totalSupply; + amount1 = liquidity * balance1 / totalSupply; + require(amount0 > 0 && amount1 > 0, "Pair: insufficient liquidity burned"); + _burn(address(this), liquidity); + _safeTransfer(token0, to, amount0); + _safeTransfer(token1, to, amount1); + _update(IERC20Minimal(token0).balanceOf(address(this)), IERC20Minimal(token1).balanceOf(address(this))); + emit Burn(msg.sender, amount0, amount1, to); + } + + /// Pays out up to the amounts asked and checks the fee-adjusted product did not fall. The input must already + /// sit in the pair (the router sends it first). + function swap(uint256 amount0Out, uint256 amount1Out, address to) external lock { + require(amount0Out > 0 || amount1Out > 0, "Pair: insufficient output amount"); + (uint112 r0, uint112 r1,) = getReserves(); + require(amount0Out < r0 && amount1Out < r1, "Pair: insufficient liquidity"); + require(to != token0 && to != token1, "Pair: invalid to"); + if (amount0Out > 0) _safeTransfer(token0, to, amount0Out); + if (amount1Out > 0) _safeTransfer(token1, to, amount1Out); + uint256 balance0 = IERC20Minimal(token0).balanceOf(address(this)); + uint256 balance1 = IERC20Minimal(token1).balanceOf(address(this)); + uint256 amount0In = balance0 > r0 - amount0Out ? balance0 - (r0 - amount0Out) : 0; + uint256 amount1In = balance1 > r1 - amount1Out ? balance1 - (r1 - amount1Out) : 0; + require(amount0In > 0 || amount1In > 0, "Pair: insufficient input amount"); + uint256 adjusted0 = balance0 * 1000 - amount0In * 3; + uint256 adjusted1 = balance1 * 1000 - amount1In * 3; + require(adjusted0 * adjusted1 >= uint256(r0) * uint256(r1) * 1000 ** 2, "Pair: K"); + _update(balance0, balance1); + emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to); + } + + /// Sends any balance above the reserves to `to`. + function skim(address to) external lock { + _safeTransfer(token0, to, IERC20Minimal(token0).balanceOf(address(this)) - reserve0); + _safeTransfer(token1, to, IERC20Minimal(token1).balanceOf(address(this)) - reserve1); + } + + /// Sets the reserves to the balances. + function sync() external lock { + _update(IERC20Minimal(token0).balanceOf(address(this)), IERC20Minimal(token1).balanceOf(address(this))); + } +} + +/// Creates one pair per unordered token pair and remembers it. +contract IgneumFactory { + mapping(address => mapping(address => address)) public getPair; + address[] public allPairs; + + event PairCreated(address indexed token0, address indexed token1, address pair, uint256 count); + + function allPairsLength() external view returns (uint256) { + return allPairs.length; + } + + function createPair(address tokenA, address tokenB) external returns (address pair) { + require(tokenA != tokenB, "Factory: identical addresses"); + (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); + require(token0 != address(0), "Factory: zero address"); + require(getPair[token0][token1] == address(0), "Factory: pair exists"); + bytes32 salt = keccak256(abi.encodePacked(token0, token1)); + pair = address(new IgneumPair{salt: salt}()); + IgneumPair(pair).initialize(token0, token1); + getPair[token0][token1] = pair; + getPair[token1][token0] = pair; + allPairs.push(pair); + emit PairCreated(token0, token1, pair, allPairs.length); + } +} diff --git a/contracts/dex/src/IgneumRouter.sol b/contracts/dex/src/IgneumRouter.sol new file mode 100644 index 000000000..11e320a4e --- /dev/null +++ b/contracts/dex/src/IgneumRouter.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IgneumFactory, IgneumPair} from "./IgneumPair.sol"; + +interface IERC20Router { + function balanceOf(address) external view returns (uint256); + function transfer(address, uint256) external returns (bool); + function transferFrom(address, address, uint256) external returns (bool); +} + +interface IWIGN is IERC20Router { + function deposit() external payable; + function withdraw(uint256) external; +} + +/// The router in the Uniswap v2 shape: adds and removes liquidity, swaps along a path of pairs, quotes. Pairs are +/// looked up on the factory, never derived from an init-code hash. Devnet 3, test tokens, no value. +contract IgneumRouter { + IgneumFactory public immutable factory; + address public immutable WIGN; + + modifier ensure(uint256 deadline) { + require(deadline >= block.timestamp, "Router: expired"); + _; + } + + constructor(address factory_, address wign_) { + factory = IgneumFactory(factory_); + WIGN = wign_; + } + + receive() external payable { + require(msg.sender == WIGN, "Router: only WIGN"); + } + + // ---- pure maths ---- + + function sortTokens(address tokenA, address tokenB) public pure returns (address token0, address token1) { + require(tokenA != tokenB, "Router: identical addresses"); + (token0, token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); + require(token0 != address(0), "Router: zero address"); + } + + function quote(uint256 amountA, uint256 reserveA, uint256 reserveB) public pure returns (uint256 amountB) { + require(amountA > 0, "Router: insufficient amount"); + require(reserveA > 0 && reserveB > 0, "Router: insufficient liquidity"); + amountB = amountA * reserveB / reserveA; + } + + function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) public pure returns (uint256 amountOut) { + require(amountIn > 0, "Router: insufficient input amount"); + require(reserveIn > 0 && reserveOut > 0, "Router: insufficient liquidity"); + uint256 amountInWithFee = amountIn * 997; + amountOut = amountInWithFee * reserveOut / (reserveIn * 1000 + amountInWithFee); + } + + function getAmountIn(uint256 amountOut, uint256 reserveIn, uint256 reserveOut) public pure returns (uint256 amountIn) { + require(amountOut > 0, "Router: insufficient output amount"); + require(reserveIn > 0 && reserveOut > 0, "Router: insufficient liquidity"); + amountIn = (reserveIn * amountOut * 1000) / ((reserveOut - amountOut) * 997) + 1; + } + + // ---- views ---- + + function pairFor(address tokenA, address tokenB) public view returns (address pair) { + pair = factory.getPair(tokenA, tokenB); + require(pair != address(0), "Router: no pair"); + } + + function getReserves(address tokenA, address tokenB) public view returns (uint256 reserveA, uint256 reserveB) { + (address token0,) = sortTokens(tokenA, tokenB); + (uint112 r0, uint112 r1,) = IgneumPair(pairFor(tokenA, tokenB)).getReserves(); + (reserveA, reserveB) = tokenA == token0 ? (r0, r1) : (r1, r0); + } + + function getAmountsOut(uint256 amountIn, address[] memory path) public view returns (uint256[] memory amounts) { + require(path.length >= 2, "Router: invalid path"); + amounts = new uint256[](path.length); + amounts[0] = amountIn; + for (uint256 i; i < path.length - 1; i++) { + (uint256 reserveIn, uint256 reserveOut) = getReserves(path[i], path[i + 1]); + amounts[i + 1] = getAmountOut(amounts[i], reserveIn, reserveOut); + } + } + + function getAmountsIn(uint256 amountOut, address[] memory path) public view returns (uint256[] memory amounts) { + require(path.length >= 2, "Router: invalid path"); + amounts = new uint256[](path.length); + amounts[amounts.length - 1] = amountOut; + for (uint256 i = path.length - 1; i > 0; i--) { + (uint256 reserveIn, uint256 reserveOut) = getReserves(path[i - 1], path[i]); + amounts[i - 1] = getAmountIn(amounts[i], reserveIn, reserveOut); + } + } + + // ---- liquidity ---- + + function _addLiquidity(address tokenA, address tokenB, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin) + private + returns (uint256 amountA, uint256 amountB) + { + if (factory.getPair(tokenA, tokenB) == address(0)) { + factory.createPair(tokenA, tokenB); + } + (uint256 reserveA, uint256 reserveB) = getReserves(tokenA, tokenB); + if (reserveA == 0 && reserveB == 0) { + (amountA, amountB) = (amountADesired, amountBDesired); + } else { + uint256 amountBOptimal = quote(amountADesired, reserveA, reserveB); + if (amountBOptimal <= amountBDesired) { + require(amountBOptimal >= amountBMin, "Router: insufficient B amount"); + (amountA, amountB) = (amountADesired, amountBOptimal); + } else { + uint256 amountAOptimal = quote(amountBDesired, reserveB, reserveA); + require(amountAOptimal <= amountADesired && amountAOptimal >= amountAMin, "Router: insufficient A amount"); + (amountA, amountB) = (amountAOptimal, amountBDesired); + } + } + } + + function addLiquidity( + address tokenA, + address tokenB, + uint256 amountADesired, + uint256 amountBDesired, + uint256 amountAMin, + uint256 amountBMin, + address to, + uint256 deadline + ) external ensure(deadline) returns (uint256 amountA, uint256 amountB, uint256 liquidity) { + (amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin); + address pair = pairFor(tokenA, tokenB); + _pull(tokenA, msg.sender, pair, amountA); + _pull(tokenB, msg.sender, pair, amountB); + liquidity = IgneumPair(pair).mint(to); + } + + function addLiquidityIGN(address token, uint256 amountTokenDesired, uint256 amountTokenMin, uint256 amountIGNMin, address to, uint256 deadline) + external + payable + ensure(deadline) + returns (uint256 amountToken, uint256 amountIGN, uint256 liquidity) + { + (amountToken, amountIGN) = _addLiquidity(token, WIGN, amountTokenDesired, msg.value, amountTokenMin, amountIGNMin); + address pair = pairFor(token, WIGN); + _pull(token, msg.sender, pair, amountToken); + IWIGN(WIGN).deposit{value: amountIGN}(); + require(IWIGN(WIGN).transfer(pair, amountIGN), "Router: WIGN transfer failed"); + liquidity = IgneumPair(pair).mint(to); + if (msg.value > amountIGN) _sendIGN(msg.sender, msg.value - amountIGN); + } + + function removeLiquidity(address tokenA, address tokenB, uint256 liquidity, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) + public + ensure(deadline) + returns (uint256 amountA, uint256 amountB) + { + address pair = pairFor(tokenA, tokenB); + _pull(pair, msg.sender, pair, liquidity); + (uint256 amount0, uint256 amount1) = IgneumPair(pair).burn(to); + (address token0,) = sortTokens(tokenA, tokenB); + (amountA, amountB) = tokenA == token0 ? (amount0, amount1) : (amount1, amount0); + require(amountA >= amountAMin, "Router: insufficient A amount"); + require(amountB >= amountBMin, "Router: insufficient B amount"); + } + + function removeLiquidityIGN(address token, uint256 liquidity, uint256 amountTokenMin, uint256 amountIGNMin, address to, uint256 deadline) + external + ensure(deadline) + returns (uint256 amountToken, uint256 amountIGN) + { + (amountToken, amountIGN) = removeLiquidity(token, WIGN, liquidity, amountTokenMin, amountIGNMin, address(this), deadline); + require(IERC20Router(token).transfer(to, amountToken), "Router: token transfer failed"); + IWIGN(WIGN).withdraw(amountIGN); + _sendIGN(to, amountIGN); + } + + // ---- swaps ---- + + function _swap(uint256[] memory amounts, address[] memory path, address to_) private { + for (uint256 i; i < path.length - 1; i++) { + (address input, address output) = (path[i], path[i + 1]); + (address token0,) = sortTokens(input, output); + uint256 amountOut = amounts[i + 1]; + (uint256 amount0Out, uint256 amount1Out) = input == token0 ? (uint256(0), amountOut) : (amountOut, uint256(0)); + address to = i < path.length - 2 ? pairFor(output, path[i + 2]) : to_; + IgneumPair(pairFor(input, output)).swap(amount0Out, amount1Out, to); + } + } + + function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) + external + ensure(deadline) + returns (uint256[] memory amounts) + { + amounts = getAmountsOut(amountIn, path); + require(amounts[amounts.length - 1] >= amountOutMin, "Router: insufficient output amount"); + _pull(path[0], msg.sender, pairFor(path[0], path[1]), amounts[0]); + _swap(amounts, path, to); + } + + function swapTokensForExactTokens(uint256 amountOut, uint256 amountInMax, address[] calldata path, address to, uint256 deadline) + external + ensure(deadline) + returns (uint256[] memory amounts) + { + amounts = getAmountsIn(amountOut, path); + require(amounts[0] <= amountInMax, "Router: excessive input amount"); + _pull(path[0], msg.sender, pairFor(path[0], path[1]), amounts[0]); + _swap(amounts, path, to); + } + + function swapExactIGNForTokens(uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) + external + payable + ensure(deadline) + returns (uint256[] memory amounts) + { + require(path[0] == WIGN, "Router: invalid path"); + amounts = getAmountsOut(msg.value, path); + require(amounts[amounts.length - 1] >= amountOutMin, "Router: insufficient output amount"); + IWIGN(WIGN).deposit{value: amounts[0]}(); + require(IWIGN(WIGN).transfer(pairFor(path[0], path[1]), amounts[0]), "Router: WIGN transfer failed"); + _swap(amounts, path, to); + } + + function swapExactTokensForIGN(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) + external + ensure(deadline) + returns (uint256[] memory amounts) + { + require(path[path.length - 1] == WIGN, "Router: invalid path"); + amounts = getAmountsOut(amountIn, path); + require(amounts[amounts.length - 1] >= amountOutMin, "Router: insufficient output amount"); + _pull(path[0], msg.sender, pairFor(path[0], path[1]), amounts[0]); + _swap(amounts, path, address(this)); + IWIGN(WIGN).withdraw(amounts[amounts.length - 1]); + _sendIGN(to, amounts[amounts.length - 1]); + } + + // ---- transfers ---- + + function _pull(address token, address from, address to, uint256 value) private { + (bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Router.transferFrom.selector, from, to, value)); + require(ok && (data.length == 0 || abi.decode(data, (bool))), "Router: transferFrom failed"); + } + + function _sendIGN(address to, uint256 value) private { + (bool ok,) = to.call{value: value}(""); + require(ok, "Router: IGN send failed"); + } +} diff --git a/contracts/dex/src/TestToken.sol b/contracts/dex/src/TestToken.sol new file mode 100644 index 000000000..55e65103d --- /dev/null +++ b/contracts/dex/src/TestToken.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC20} from "./ERC20.sol"; + +/// A test token with its own faucet: anyone takes DRIP tokens once an hour. Devnet 3, test tokens, no value. +contract TestToken is ERC20 { + uint256 public constant DRIP = 1_000 ether; + uint256 public constant DRIP_INTERVAL = 1 hours; + mapping(address => uint256) public lastDrip; + + event Drip(address indexed to, uint256 value); + + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} + + /// Mints DRIP tokens to the caller; refused inside DRIP_INTERVAL of the caller's last drip. + function drip() external { + require(block.timestamp >= lastDrip[msg.sender] + DRIP_INTERVAL, "TestToken: one drip an hour"); + lastDrip[msg.sender] = block.timestamp; + _mint(msg.sender, DRIP); + emit Drip(msg.sender, DRIP); + } + + /// Seconds until the caller may drip again (0 when it may). + function dripWait(address who) external view returns (uint256) { + uint256 next = lastDrip[who] + DRIP_INTERVAL; + return block.timestamp >= next ? 0 : next - block.timestamp; + } +} diff --git a/contracts/dex/src/WIGN.sol b/contracts/dex/src/WIGN.sol new file mode 100644 index 000000000..bcae887d0 --- /dev/null +++ b/contracts/dex/src/WIGN.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC20} from "./ERC20.sol"; + +/// Wrapped IGN, the WETH9 shape: one WIGN per IGN held by this contract, minted on deposit and burned on withdraw. +/// Devnet 3, test tokens, no value. +contract WIGN is ERC20("Wrapped IGN", "WIGN") { + event Deposit(address indexed to, uint256 value); + event Withdrawal(address indexed from, uint256 value); + + receive() external payable { + deposit(); + } + + function deposit() public payable { + _mint(msg.sender, msg.value); + emit Deposit(msg.sender, msg.value); + } + + function withdraw(uint256 value) external { + _burn(msg.sender, value); + emit Withdrawal(msg.sender, value); + (bool ok,) = msg.sender.call{value: value}(""); + require(ok, "WIGN: send failed"); + } +} diff --git a/contracts/dex/test/Dex.t.sol b/contracts/dex/test/Dex.t.sol new file mode 100644 index 000000000..9db6fff30 --- /dev/null +++ b/contracts/dex/test/Dex.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Vm, VM_ADDRESS} from "./Vm.sol"; +import {WIGN} from "../src/WIGN.sol"; +import {TestToken} from "../src/TestToken.sol"; +import {IgneumFactory, IgneumPair} from "../src/IgneumPair.sol"; +import {IgneumRouter} from "../src/IgneumRouter.sol"; + +/// The AMM end to end on Foundry's EVM: pair creation, liquidity in and out, token and IGN swaps, the faucet's +/// hour, the k check. Every number is a test number: Devnet 3, test tokens, no value. +contract DexTest { + Vm constant vm = Vm(VM_ADDRESS); + + WIGN wign; + TestToken tta; + TestToken ttb; + IgneumFactory factory; + IgneumRouter router; + address alice = address(0xA11CE); + address bob = address(0xB0B); + + receive() external payable {} + + function setUp() public { + wign = new WIGN(); + tta = new TestToken("Test Token A", "TTA"); + ttb = new TestToken("Test Token B", "TTB"); + factory = new IgneumFactory(); + router = new IgneumRouter(address(factory), address(wign)); + vm.deal(alice, 1_000 ether); + vm.deal(bob, 1_000 ether); + } + + function _drip(address who) internal { + vm.startPrank(who); + tta.drip(); + ttb.drip(); + tta.approve(address(router), type(uint256).max); + ttb.approve(address(router), type(uint256).max); + vm.stopPrank(); + } + + function _seed() internal returns (address pairAB, address pairAW) { + _drip(alice); + vm.startPrank(alice); + router.addLiquidity(address(tta), address(ttb), 500 ether, 500 ether, 0, 0, alice, block.timestamp + 60); + router.addLiquidityIGN{value: 100 ether}(address(tta), 500 ether, 0, 0, alice, block.timestamp + 60); + vm.stopPrank(); + pairAB = factory.getPair(address(tta), address(ttb)); + pairAW = factory.getPair(address(tta), address(wign)); + } + + function test_faucet_drips_once_an_hour() public { + vm.prank(bob); + tta.drip(); + require(tta.balanceOf(bob) == 1_000 ether, "drip amount"); + vm.prank(bob); + vm.expectRevert(bytes("TestToken: one drip an hour")); + tta.drip(); + vm.warp(block.timestamp + 1 hours); + vm.prank(bob); + tta.drip(); + require(tta.balanceOf(bob) == 2_000 ether, "second drip"); + } + + function test_add_liquidity_creates_pairs_and_mints() public { + (address pairAB, address pairAW) = _seed(); + require(pairAB != address(0) && pairAW != address(0) && pairAB != pairAW, "pairs"); + require(factory.allPairsLength() == 2, "two pairs"); + (uint256 rA, uint256 rB) = router.getReserves(address(tta), address(ttb)); + require(rA == 500 ether && rB == 500 ether, "AB reserves"); + (uint256 rT, uint256 rW) = router.getReserves(address(tta), address(wign)); + require(rT == 500 ether && rW == 100 ether, "AW reserves"); + require(IgneumPair(pairAB).balanceOf(alice) == 500 ether - 1000, "LP minus the locked minimum"); + require(IgneumPair(pairAB).balanceOf(address(0xdead)) == 1000, "locked minimum"); + require(wign.balanceOf(pairAW) == 100 ether, "WIGN held by the pair"); + } + + function test_swap_exact_tokens_for_tokens_keeps_k() public { + (address pairAB,) = _seed(); + _drip(bob); + address[] memory path = new address[](2); + path[0] = address(tta); + path[1] = address(ttb); + uint256[] memory quoted = router.getAmountsOut(10 ether, path); + // 10 in at 0.3 percent on 500/500: 9.871580343970612988 out + require(quoted[1] == 9871580343970612988, "quote"); + (uint112 r0b, uint112 r1b,) = IgneumPair(pairAB).getReserves(); + vm.prank(bob); + uint256[] memory amounts = router.swapExactTokensForTokens(10 ether, quoted[1], path, bob, block.timestamp + 60); + require(amounts[1] == quoted[1], "swap matches the quote"); + require(ttb.balanceOf(bob) == 1_000 ether + quoted[1], "bob received"); + (uint112 r0a, uint112 r1a,) = IgneumPair(pairAB).getReserves(); + require(uint256(r0a) * uint256(r1a) >= uint256(r0b) * uint256(r1b), "k did not fall"); + } + + function test_swap_ign_both_ways() public { + _seed(); + address[] memory path = new address[](2); + path[0] = address(wign); + path[1] = address(tta); + uint256 before = tta.balanceOf(bob); + vm.prank(bob); + uint256[] memory amounts = router.swapExactIGNForTokens{value: 1 ether}(0, path, bob, block.timestamp + 60); + require(tta.balanceOf(bob) == before + amounts[1] && amounts[1] > 4.9 ether && amounts[1] < 5 ether, "IGN to TTA"); + path[0] = address(tta); + path[1] = address(wign); + uint256 ignBefore = bob.balance; + vm.startPrank(bob); + tta.approve(address(router), type(uint256).max); + uint256[] memory back = router.swapExactTokensForIGN(amounts[1], 0, path, bob, block.timestamp + 60); + vm.stopPrank(); + require(bob.balance == ignBefore + back[1] && back[1] < 1 ether && back[1] > 0.99 ether, "TTA to IGN"); + } + + function test_remove_liquidity_returns_both_tokens() public { + (address pairAB,) = _seed(); + uint256 lp = IgneumPair(pairAB).balanceOf(alice); + vm.startPrank(alice); + IgneumPair(pairAB).approve(address(router), lp); + (uint256 a, uint256 b) = router.removeLiquidity(address(tta), address(ttb), lp, 0, 0, alice, block.timestamp + 60); + vm.stopPrank(); + require(a == 500 ether - 1000 && b == 500 ether - 1000, "pro rata minus the locked share"); + require(IgneumPair(pairAB).balanceOf(alice) == 0, "LP burned"); + } + + function test_expired_deadline_is_refused() public { + _seed(); + address[] memory path = new address[](2); + path[0] = address(tta); + path[1] = address(ttb); + vm.prank(alice); + vm.expectRevert(bytes("Router: expired")); + router.swapExactTokensForTokens(1 ether, 0, path, alice, block.timestamp - 1); + } +} diff --git a/contracts/dex/test/Vm.sol b/contracts/dex/test/Vm.sol new file mode 100644 index 000000000..930eb7b97 --- /dev/null +++ b/contracts/dex/test/Vm.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// The Foundry cheatcodes this project uses, declared here so the tree needs no remote dependency. +interface Vm { + function startBroadcast(uint256 privateKey) external; + function stopBroadcast() external; + function envUint(string calldata name) external view returns (uint256); + function envOr(string calldata name, string calldata defaultValue) external view returns (string memory); + function toString(address value) external pure returns (string memory); + function toString(uint256 value) external pure returns (string memory); + function writeFile(string calldata path, string calldata data) external; + function deal(address who, uint256 newBalance) external; + function prank(address msgSender) external; + function startPrank(address msgSender) external; + function stopPrank() external; + function warp(uint256 newTimestamp) external; + function expectRevert(bytes calldata revertData) external; + function addr(uint256 privateKey) external pure returns (address); +} + +address constant VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code"))));