diff --git a/contracts/dex/foundry.toml b/contracts/dex/foundry.toml
new file mode 100644
index 000000000..947428e34
--- /dev/null
+++ b/contracts/dex/foundry.toml
@@ -0,0 +1,19 @@
+[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 (test/Vm.sol); solc 0.8.28 is fetched once by forge
+auto_detect_remappings = false
+
+[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..6c1b1e508
--- /dev/null
+++ b/contracts/dex/script/Deploy.s.sol
@@ -0,0 +1,58 @@
+// 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 = "{\n";
+ json = _line(json, "WIGN", address(wign), ",");
+ json = _line(json, "TTA", address(tta), ",");
+ json = _line(json, "TTB", address(ttb), ",");
+ json = _line(json, "IgneumFactory", address(factory), ",");
+ json = _line(json, "IgneumRouter", address(router), ",");
+ json = _line(json, "pair_TTA_WIGN", factory.getPair(address(tta), address(wign)), ",");
+ json = _line(json, "pair_TTB_WIGN", factory.getPair(address(ttb), address(wign)), ",");
+ json = _line(json, "pair_TTA_TTB", factory.getPair(address(tta), address(ttb)), ",");
+ json = _line(json, "deployer", deployer, "");
+ json = string.concat(json, "}\n");
+ vm.writeFile("deploy-out.json", json);
+ }
+
+ function _line(string memory json, string memory key, address value, string memory comma) private pure returns (string memory) {
+ return string.concat(json, " \"", key, "\": \"", vm.toString(value), "\"", comma, "\n");
+ }
+}
diff --git a/contracts/dex/script/Seed.s.sol b/contracts/dex/script/Seed.s.sol
new file mode 100644
index 000000000..3befa3a4d
--- /dev/null
+++ b/contracts/dex/script/Seed.s.sol
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import {Vm, VM_ADDRESS} from "../test/Vm.sol";
+import {TestToken} from "../src/TestToken.sol";
+import {IgneumRouter} from "../src/IgneumRouter.sol";
+
+/// Seeds the three pools of an already deployed AMM (the addresses from the environment): a drip of each test
+/// token, approvals, 100 IGN beside 500 TTA, 100 IGN beside 500 TTB, 500 TTA beside 500 TTB. Run with
+/// --skip-simulation so every gas limit comes from the node's own eth_estimateGas: Devnet 3 charges the proving
+/// dimension inside the execution gas, so Foundry's local estimate runs out (8 October 2026, drip() at 133,603 gas:
+/// out of gas; the node's estimate 685,513).
+///
+/// DEX_TTA=0x.. DEX_TTB=0x.. DEX_ROUTER=0x.. forge script script/Seed.s.sol:Seed --rpc-url devnet3 --broadcast --slow --skip-simulation --sig "run()"
+///
+/// Devnet 3, test tokens, no value.
+contract Seed {
+ Vm constant vm = Vm(VM_ADDRESS);
+
+ function run() external {
+ uint256 key = vm.envUint("DEX_DEPLOYER_KEY");
+ address deployer = vm.addr(key);
+ TestToken tta = TestToken(vm.envAddress("DEX_TTA"));
+ TestToken ttb = TestToken(vm.envAddress("DEX_TTB"));
+ IgneumRouter router = IgneumRouter(payable(vm.envAddress("DEX_ROUTER")));
+ vm.startBroadcast(key);
+ if (tta.dripWait(deployer) == 0 && tta.balanceOf(deployer) < 1_000 ether) tta.drip();
+ if (ttb.dripWait(deployer) == 0 && ttb.balanceOf(deployer) < 1_000 ether) 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();
+ }
+}
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..c58c8f3d8
--- /dev/null
+++ b/contracts/dex/src/TestToken.sol
@@ -0,0 +1,32 @@
+// 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 {
+ uint256 last = lastDrip[msg.sender];
+ require(last == 0 || block.timestamp >= last + 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 last = lastDrip[who];
+ if (last == 0) return 0;
+ uint256 next = last + 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..2d83e467e
--- /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: 10*997*500 / (500*1000 + 10*997) tokens
+ require(quoted[1] == 9775084808910328058, "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..68d0af156
--- /dev/null
+++ b/contracts/dex/test/Vm.sol
@@ -0,0 +1,23 @@
+// 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 envAddress(string calldata name) external view returns (address);
+ 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"))));
diff --git a/docs/contracts/devnet-3.json b/docs/contracts/devnet-3.json
new file mode 100644
index 000000000..a495388de
--- /dev/null
+++ b/docs/contracts/devnet-3.json
@@ -0,0 +1,24 @@
+{
+ "network": "igneum-devnet-3",
+ "chain_id": 4463,
+ "chain_id_hex": "0x116f",
+ "note": "Devnet 3, test tokens, no value. The chain may reset; these addresses go with it.",
+ "rpc": "https://rpc.devnet.igneum.network",
+ "deployed_at": "2026-10-08T10:50:00Z",
+ "deployer": "0x07DD4DBca5c1a66755AF28BACCA1D901a2D209aA",
+ "source": "contracts/dex (Foundry, solc 0.8.28, evm_version paris, optimizer 200 runs)",
+ "contracts": {
+ "WIGN": { "address": "0x47447783D00e1760Eb815a34707b611D53f2A51e", "tx": "0x9fc48e0e3aa6ccdc41ff72381e1986d617722e22cd2b5f0f9f1e988eef60b2d6", "block": 26917 },
+ "TTA": { "address": "0x9ad4F0435f9172A89C6765eE8631CAB2Db3B5052", "tx": "0xa16b6a6501d2705b7c7dc4cf01f8b65b87270e5f1f90b4638b19f120b4830dc0", "block": 26919, "name": "Test Token A", "faucet": "drip() mints 1,000 TTA to the caller once an hour" },
+ "TTB": { "address": "0x996e3042089E80029348d7836472DB34C21841c1", "tx": "0x63eca8238d7f2c29e67c5aad33b0f1cb341d233f2ae0922621e4ba09d94eca61", "block": 26921, "name": "Test Token B", "faucet": "drip() mints 1,000 TTB to the caller once an hour" },
+ "IgneumFactory": { "address": "0x09FF42dbb20448ddB673fD34F973a7D93E7139A8", "tx": "0xd25223b5f1adcba415e51a0936c1ba3e9ae1dcbb1299ec5c4a91293e41ace3bb", "block": 26923 },
+ "IgneumRouter": { "address": "0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7", "tx": "0xabce2fcac81f87fdca02ac4528d4029ebbe1e6d49304dbe6427e1320f2e17b27", "block": 26924 }
+ },
+ "pairs": {
+ "TTA_WIGN": { "address": "0x5352Fc2723014Bb45A5b1767Df549DA755773A2B", "seeded": "500 TTA beside 100 IGN", "block": 26969 },
+ "TTB_WIGN": { "address": "0x93Db70744e200D647f783C3953dF35d7394EdBC5", "seeded": "500 TTB beside 100 IGN", "block": 26970 },
+ "TTA_TTB": { "address": "0xF35775db56dE4f1Cd8Ed7ea7CAb8D017d584a96F", "seeded": "500 TTA beside 500 TTB", "block": 26972 }
+ },
+ "fee": "0.3 percent per swap, kept in the pool",
+ "gas_note": "Devnet 3 charges the proving dimension inside the execution gas: a local EVM estimate runs out (drip() failed at 133,603 gas in block 26925; the node's eth_estimateGas said 685,513). Send with the node's own estimate (forge script --skip-simulation, or eth_estimateGas before every send)."
+}
diff --git a/site/404.html b/site/404.html
index 4020c5c81..f422fa0cf 100644
--- a/site/404.html
+++ b/site/404.html
@@ -78,6 +78,7 @@ main{flex:1}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -112,6 +113,7 @@ main{flex:1}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -160,6 +161,7 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -134,6 +135,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -139,6 +140,7 @@ table{min-width:560px}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -160,6 +161,7 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -138,6 +139,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -138,6 +139,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -128,6 +129,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -149,6 +150,7 @@ td.mono{font-family:var(--f-mono);font-size:12.5px;min-width:180px}td.iv{color:v
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -159,6 +160,7 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -141,6 +142,7 @@ dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:14px;overflo
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -98,6 +99,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -130,6 +131,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -138,6 +139,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
Network
diff --git a/site/ledger.html b/site/ledger.html
index f896139f2..ff9fdf9aa 100644
--- a/site/ledger.html
+++ b/site/ledger.html
@@ -89,7 +89,7 @@ blockquote{margin:10px 0;padding:10px 14px;border-left:3px solid var(--line-2);c
@@ -142,6 +143,7 @@ blockquote{margin:10px 0;padding:10px 14px;border-left:3px solid var(--line-2);c
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
Network
@@ -154,7 +156,7 @@ blockquote{margin:10px 0;padding:10px 14px;border-left:3px solid var(--line-2);c
Learn
Litepaper The design, as published.
Income per tier IGN a day per card at three network sizes, and the electricity.
-
Ledger Every criticism, answered or conceded.
+
Ledger Every criticism, answered or conceded.
What Igneum does not claim The limits, stated first.
Igneum vs RandomX What was kept and what was rebuilt for GPUs.
Built on the shoulders Every borrowed part, credited.
diff --git a/site/litepaper.html b/site/litepaper.html
index defe6ddf3..48f6e4c5f 100644
--- a/site/litepaper.html
+++ b/site/litepaper.html
@@ -161,6 +161,7 @@ body.all .pager{display:none}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+
Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -195,6 +196,7 @@ body.all .pager{display:none}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -279,6 +280,7 @@ details.tablebar summary{display:flex;align-items:center}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -140,6 +141,7 @@ ol{margin:0 0 14px;padding-left:22px}li{margin-bottom:6px}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -135,6 +136,7 @@ pre b{color:var(--molten-text);font-weight:500}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -139,6 +140,7 @@ table{min-width:560px}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -62,6 +63,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -139,6 +140,7 @@ table{min-width:560px}
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -138,6 +139,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -116,6 +117,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
Network
diff --git a/site/swap.html b/site/swap.html
new file mode 100644
index 000000000..c6775f3d8
--- /dev/null
+++ b/site/swap.html
@@ -0,0 +1,540 @@
+
+
+
+
+
+
Igneum swap on Devnet 3. Test tokens, no value
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Skip to content
+
+
+
+
+
+
+
+
+
+
+
Devnet 3 swap
+
Swap test tokens on Devnet 3.
+
A constant-product exchange on the Igneum zkEVM: wrapped IGN and two faucet tokens in three pools, 0.3 percent to the pool. Every swap is a Devnet 3 block, and the block's proof status is read live below.
+
Devnet 3, test tokens, no value. The chain may reset and these pools go with it. Nothing here is an offer, a price or a market.
+
+
+
+ Swap
+
+ Connect wallet
+ No wallet connected. MetaMask or the Igneum Wallet, pointed at chain id 4463.
+
+
+ Your swaps this session
+ Each row is a Devnet 3 block. Proof status comes from the public feed (/api/live): planned, proving, verified or paid per shard, and whether a finality checkpoint has locked the block.
+
+ Swap Transaction Block Proof status
+ No swaps yet this session.
+
+
+
+ Pools
+ Reserves read live from the Devnet 3 RPC. Test tokens come from each token's own faucet: one drip() of 1,000 an hour per address.
+
+ Pool Reserves
+ Reading the chain…
+
+
+ Get 1,000 TTA
+ Get 1,000 TTB
+
+
+ Add liquidity
+ Deposit both sides at the pool's ratio and receive IGN-LP tokens; remove them in the same shape through the router.
+
+
+ Chain igneum-devnet-3, chain id 4463 (0x116f)
+ RPC https://rpc.devnet.igneum.network
+ Router
+ Factory
+ WIGN
+ TTA
+ TTB
+ Source contracts/dex in the repository; addresses in docs/contracts/devnet-3.json
+ Wallet set-up MetaMask: chain id, RPC, one click or the Igneum Wallet
+ IGN for gas The faucet or mine a block
+
+ Devnet 3, test tokens, no value.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/wallet.html b/site/wallet.html
index dc32d079b..a4e7906e0 100644
--- a/site/wallet.html
+++ b/site/wallet.html
@@ -114,6 +114,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+
Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
@@ -148,6 +149,7 @@
GPU bench table Measured rates, card by card.
The dev fee The optional one, in full view, off with one flag.
Testnet faucet Test coin for an address, once a day.
+ Swap Test tokens on Devnet 3; every swap is a proven block.
Add to MetaMask Igneum as a network in your wallet.
Network
diff --git a/tools/ci/site-nav-check.mjs b/tools/ci/site-nav-check.mjs
index 05acee2e6..dfac14e78 100644
--- a/tools/ci/site-nav-check.mjs
+++ b/tools/ci/site-nav-check.mjs
@@ -13,7 +13,7 @@ import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const GROUPS = {
- mine: ['/miner', '/download', '/app', '/wallet', '/miners', '/dev-fee', '/faucet', '/metamask'],
+ mine: ['/miner', '/download', '/app', '/wallet', '/miners', '/dev-fee', '/faucet', '/swap', '/metamask'],
network: ['/live', '/explorer', '/journey', '/bench', '/evidence'],
learn: ['/litepaper', '/income', '/ledger', '/claims', '/randomx', '/provenance'],
};