DEX: the AMM in the Uniswap v2 shape for Devnet 3 (factory, pairs, router, wrapped IGN, two faucet test tokens), its Foundry tests and the deploy script (8 October 2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
459795d4dd
commit
514ec64183
9 changed files with 777 additions and 0 deletions
20
contracts/dex/foundry.toml
Normal file
20
contracts/dex/foundry.toml
Normal file
|
|
@ -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"
|
||||
56
contracts/dex/script/Deploy.s.sol
Normal file
56
contracts/dex/script/Deploy.s.sol
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
59
contracts/dex/src/ERC20.sol
Normal file
59
contracts/dex/src/ERC20.sol
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
174
contracts/dex/src/IgneumPair.sol
Normal file
174
contracts/dex/src/IgneumPair.sol
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
253
contracts/dex/src/IgneumRouter.sol
Normal file
253
contracts/dex/src/IgneumRouter.sol
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
29
contracts/dex/src/TestToken.sol
Normal file
29
contracts/dex/src/TestToken.sol
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
27
contracts/dex/src/WIGN.sol
Normal file
27
contracts/dex/src/WIGN.sol
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
137
contracts/dex/test/Dex.t.sol
Normal file
137
contracts/dex/test/Dex.t.sol
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
22
contracts/dex/test/Vm.sol
Normal file
22
contracts/dex/test/Vm.sol
Normal file
|
|
@ -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"))));
|
||||
Loading…
Reference in a new issue