From 3917e7997f0030790c8db70c64808e2b87e890d2 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Wed, 16 Sep 2026 15:18:40 -0400 Subject: [PATCH 01/17] Add gas efficiency comparison page with Uniswap v4/v3 benchmarks New Concepts page measuring Ekubo EVM swaps against Uniswap v4 and v3 on like-for-like workloads (0.3% concentrated pools, full-range-equivalent positions, identical tokens, cold access): single-hop tables, per-hop scaling marginals (~24k vs ~52k vs ~76k), frame-level attribution with flamegraphs, mainnet-fork validation at block 25991868, and the mint exception where Ekubo is not cheapest. Includes full harness sources, snapshots, and reproduction notes under benchmarks/. --- astro.config.mjs | 1 + benchmarks/README.md | 83 ++++ benchmarks/fork/ForkEkubo.t.sol | 92 ++++ benchmarks/fork/ForkEkuboTest.json | 4 + benchmarks/fork/ForkProbe.t.sol | 41 ++ benchmarks/fork/ForkV3.t.sol | 51 ++ benchmarks/fork/ForkV3Test.json | 3 + benchmarks/fork/ForkV4.t.sol | 81 ++++ benchmarks/fork/ForkV4Test.json | 3 + benchmarks/fork/foundry.toml | 12 + benchmarks/fork/remappings.txt | 5 + benchmarks/lab/MockERC20.json | 1 + benchmarks/lab/ekubo/EkuboGas.t.sol | 235 ++++++++++ benchmarks/lab/ekubo/EkuboGasTest.json | 3 + benchmarks/lab/ekubo/foundry.toml | 11 + benchmarks/lab/ekubo/remappings.txt | 5 + benchmarks/lab/v3/V3Gas.t.sol | 198 ++++++++ benchmarks/lab/v3/V3GasTest.json | 3 + benchmarks/lab/v3/foundry.toml | 11 + benchmarks/lab/v4/V4Gas.t.sol | 356 ++++++++++++++ benchmarks/lab/v4/V4GasTest.json | 3 + benchmarks/lab/v4/foundry.toml | 11 + benchmarks/lab/v4/remappings.txt | 6 + public/gas/flamegraph-ekubo-single.svg | 491 ++++++++++++++++++++ public/gas/flamegraph-v3-single.svg | 491 ++++++++++++++++++++ public/gas/flamegraph-v4-single.svg | 491 ++++++++++++++++++++ src/content/docs/about-ekubo/features.md | 2 +- src/content/docs/concepts/gas-efficiency.md | 167 +++++++ 28 files changed, 2860 insertions(+), 1 deletion(-) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/fork/ForkEkubo.t.sol create mode 100644 benchmarks/fork/ForkEkuboTest.json create mode 100644 benchmarks/fork/ForkProbe.t.sol create mode 100644 benchmarks/fork/ForkV3.t.sol create mode 100644 benchmarks/fork/ForkV3Test.json create mode 100644 benchmarks/fork/ForkV4.t.sol create mode 100644 benchmarks/fork/ForkV4Test.json create mode 100644 benchmarks/fork/foundry.toml create mode 100644 benchmarks/fork/remappings.txt create mode 100644 benchmarks/lab/MockERC20.json create mode 100644 benchmarks/lab/ekubo/EkuboGas.t.sol create mode 100644 benchmarks/lab/ekubo/EkuboGasTest.json create mode 100644 benchmarks/lab/ekubo/foundry.toml create mode 100644 benchmarks/lab/ekubo/remappings.txt create mode 100644 benchmarks/lab/v3/V3Gas.t.sol create mode 100644 benchmarks/lab/v3/V3GasTest.json create mode 100644 benchmarks/lab/v3/foundry.toml create mode 100644 benchmarks/lab/v4/V4Gas.t.sol create mode 100644 benchmarks/lab/v4/V4GasTest.json create mode 100644 benchmarks/lab/v4/foundry.toml create mode 100644 benchmarks/lab/v4/remappings.txt create mode 100644 public/gas/flamegraph-ekubo-single.svg create mode 100644 public/gas/flamegraph-v3-single.svg create mode 100644 public/gas/flamegraph-v4-single.svg create mode 100644 src/content/docs/concepts/gas-efficiency.md diff --git a/astro.config.mjs b/astro.config.mjs index d3ec2f4..64a9139 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -195,6 +195,7 @@ export default defineConfig({ label: "Protocol architecture", slug: "concepts/architecture", }, + { label: "Gas efficiency", slug: "concepts/gas-efficiency" }, { label: "Extensions", slug: "concepts/extensions", diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..e4eaa46 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,83 @@ +# Gas benchmark harnesses + +Foundry tests behind the numbers on the [Gas efficiency](/concepts/gas-efficiency/) page. +Each protocol compiles under its own production toolchain settings, so the comparison +measures code and architecture rather than compiler tuning. + +## Layout + +- `lab/ekubo/` — Ekubo EVM swaps and mints (`EkuboGas.t.sol`) +- `lab/v4/` — Uniswap v4 swaps and mints (`V4Gas.t.sol`) +- `lab/v3/` — Uniswap v3 swaps and mints (`V3Gas.t.sol`) +- `lab/MockERC20.json` — the single shared token artifact deployed via `deployCode` + in all three lab harnesses, so ERC20 transfer costs cannot skew the comparison +- `fork/` — mainnet-fork validation at pinned block `25991868` + (`ForkEkubo.t.sol`, `ForkV3.t.sol`, `ForkV4.t.sol`, plus the pool probe) + +`remappings.txt` files contain the original absolute paths and will need adjusting; +the dependency pins below are what matter for reproduction. + +## Lab methodology (all three harnesses) + +- Full-range-equivalent positions (concentrated pools, range covering the whole + tick space) at a 0.3% fee, so no swap crosses an initialized tick. +- Pools initialized strictly inside a tick (Ekubo tick 3050, Uniswap tick 30), + never on a tick-spacing multiple. +- 1,000,000-token liquidity per side, 1-token exact-input swaps, both directions. +- `forge-config: default.isolate = true` plus `vm.cool(...)` on every contract, + so each measurement is a cold-access transaction. +- Gas read with `vm.snapshotGasLastCall`; `*.json` files are the raw snapshots. + +Scaling series (1/2/3 hops) use the same minimal single-lock router per protocol, +so the per-hop marginal is apples-to-apples. + +## Dependency pins + +- Ekubo `evm-contracts`: branch `gas-benchmarks` worktree at commit `1f5be49` + (plus the harness test file in this directory); `forge-std` `77041d2`, + `solady` `65e87c7`; solc `0.8.33`, `optimizer_runs = 9999999`, `via_ir`, + `evm_version = "osaka"`. +- Uniswap `v4-core` commit `46c6834`; solc `0.8.26`, + `optimizer_runs = 44444444`, `via_ir`, `evm_version = "cancun"` + (v4-core's own `foundry.toml`). +- Uniswap `v3-core` `1.0.1` canonical mainnet bytecode + (`@uniswap/v3-core` npm artifacts, `UniswapV3Factory.json`). +- Shared token: solmate `MockERC20` bytecode in `lab/MockERC20.json`. +- Foundry `1.8.3`. + +## Reproducing the lab numbers + +```sh +# v4 (from a checkout of Uniswap/v4-core@46c6834 with submodules initialized) +forge test --match-contract V4GasTest +forge snapshot --match-contract V4GasTest + +# ekubo (remap ekubo/, forge-std/, solady/ at the pins above, deployCode the token) +forge test --offline --match-contract EkuboGasTest +forge snapshot --offline --match-contract EkuboGasTest + +# v3 (needs UniswapV3Factory.json next to MockERC20.json under artifacts/) +forge test --match-contract V3GasTest +forge snapshot --match-contract V3GasTest +``` + +## Reproducing the fork numbers + +```sh +forge test --fork-url --fork-block-number 25991868 \ + --match-contract 'ForkV3Test|ForkV4Test|ForkEkuboTest' +``` + +Deployed addresses used (all verified on-chain, none memorized): + +| Contract | Address | +| ------------------------------- | -------------------------------------------- | +| Ekubo Core | `0x00000000000014aA86C5d3c41765bb24e11bd701` | +| Uniswap v4 PoolManager | `0x000000000004444c5dc75cB358380D2e3dE08A90` | +| Uniswap v3 SwapRouter (classic) | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | +| Uniswap v3 factory | `0x1F98431c8aD98523631AE4a59f267346ea31F984` | +| WETH | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | +| USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | + +The v4 fork pool (WETH/USDC, fee 500) was selected by probing candidate fee +tiers for the deepest pool at the pinned block (see `ForkProbe.t.sol`). diff --git a/benchmarks/fork/ForkEkubo.t.sol b/benchmarks/fork/ForkEkubo.t.sol new file mode 100644 index 0000000..7c67a20 --- /dev/null +++ b/benchmarks/fork/ForkEkubo.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: ekubo-license-v1.eth +pragma solidity =0.8.33; + +import {Test} from "forge-std/Test.sol"; +import {ICore} from "ekubo/src/interfaces/ICore.sol"; +import {Core} from "ekubo/src/Core.sol"; +import {Positions} from "ekubo/src/Positions.sol"; +import {Router} from "ekubo/src/Router.sol"; +import {PoolKey} from "ekubo/src/types/poolKey.sol"; +import {createConcentratedPoolConfig} from "ekubo/src/types/poolConfig.sol"; +import {SqrtRatio} from "ekubo/src/types/sqrtRatio.sol"; +import {Core} from "ekubo/src/Core.sol"; +import {createSwapParameters} from "ekubo/src/types/swapParameters.sol"; +import {PoolBalanceUpdate} from "ekubo/src/types/poolBalanceUpdate.sol"; + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +/// @notice Mainnet-fork validation: REAL Core + REAL WETH/USDC + fresh full-range +/// 0.3% pool capitalized on-fork; Router/Positions use production bytecode. +/// Run with: --fork-url --fork-block-number +contract ForkEkuboTest is Test { + ICore constant CORE = ICore(payable(0x00000000000014aA86C5d3c41765bb24e11bd701)); + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + uint64 constant FEE_0_3 = 55340232221128655; + // Same concentrated shape as the lab harness: spacing 6000, wide range. + // Initialized at the raw WETH/USDC price (~4000 USDC per WETH, 6 vs 18 + // decimals) so the capitalized amounts are value-balanced and the measured + // swap stays in range without crossing initialized ticks. + uint32 constant TICK_SPACING = 6000; + int32 constant RANGE_LOWER = -88722000; + int32 constant RANGE_UPPER = 88722000; + int32 constant INIT_TICK = 19337510; // raw WETH/USDC price (~4000 USDC per WETH + // with 6 vs 18 decimals); Ekubo ticks rise with the token0/token1 raw price + + function test_fork_single_exactInput_weth() public { + Positions positions = new Positions(CORE, address(this), 0, 1); + Router router = new Router(CORE, address(0), address(0)); + + // USDC < WETH, so token0 = USDC, token1 = WETH + PoolKey memory key = PoolKey({ + token0: USDC, + token1: WETH, + config: createConcentratedPoolConfig(FEE_0_3, TICK_SPACING, address(0)) + }); + CORE.initializePool(key, INIT_TICK); + + deal(WETH, address(this), 2000 ether); + deal(USDC, address(this), 8_000_000e6); + IERC20Like(WETH).approve(address(positions), type(uint256).max); + IERC20Like(USDC).approve(address(positions), type(uint256).max); + IERC20Like(WETH).approve(address(router), type(uint256).max); + positions.mintAndDeposit(key, RANGE_LOWER, RANGE_UPPER, 4_000_000e6, 1000 ether, 0); + + uint256 usdcBefore = IERC20Like(USDC).balanceOf(address(this)); + PoolBalanceUpdate update = + router.swapAllowPartialFill(key, createSwapParameters(SqrtRatio.wrap(0), 1 ether, true, 0)); + assertEq(update.delta1(), 1 ether); + assertGt(-update.delta0(), 0); + vm.snapshotGasLastCall("fork ekubo Router 1 WETH->USDC concentrated 0.3%"); + assertGt(IERC20Like(USDC).balanceOf(address(this)), usdcBefore); + } + + function test_fork_single_exactInput_freshCore() public { + // Control: worktree Core (same bytecode as the lab harness) on fork state. + Core fresh = new Core(); + Positions positions = new Positions(fresh, address(this), 0, 1); + Router router = new Router(fresh, address(0), address(0)); + PoolKey memory key = PoolKey({ + token0: USDC, + token1: WETH, + config: createConcentratedPoolConfig(FEE_0_3, TICK_SPACING, address(0)) + }); + fresh.initializePool(key, INIT_TICK); + deal(WETH, address(this), 2000 ether); + deal(USDC, address(this), 8_000_000e6); + IERC20Like(WETH).approve(address(positions), type(uint256).max); + IERC20Like(USDC).approve(address(positions), type(uint256).max); + IERC20Like(WETH).approve(address(router), type(uint256).max); + positions.mintAndDeposit(key, RANGE_LOWER, RANGE_UPPER, 4_000_000e6, 1000 ether, 0); + PoolBalanceUpdate update = + router.swapAllowPartialFill(key, createSwapParameters(SqrtRatio.wrap(0), 1 ether, true, 0)); + assertEq(update.delta1(), 1 ether); + vm.snapshotGasLastCall("fork ekubo fresh-Core 1 WETH->USDC concentrated 0.3%"); + } + + receive() external payable {} +} diff --git a/benchmarks/fork/ForkEkuboTest.json b/benchmarks/fork/ForkEkuboTest.json new file mode 100644 index 0000000..b18a91c --- /dev/null +++ b/benchmarks/fork/ForkEkuboTest.json @@ -0,0 +1,4 @@ +{ + "fork ekubo Router 1 WETH->USDC concentrated 0.3%": "98066", + "fork ekubo fresh-Core 1 WETH->USDC concentrated 0.3%": "98066" +} \ No newline at end of file diff --git a/benchmarks/fork/ForkProbe.t.sol b/benchmarks/fork/ForkProbe.t.sol new file mode 100644 index 0000000..3c178e4 --- /dev/null +++ b/benchmarks/fork/ForkProbe.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test, console2} from "forge-std/Test.sol"; +import {PoolManager} from "v4-core/src/PoolManager.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol"; +import {Currency} from "v4-core/src/types/Currency.sol"; +import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; +import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol"; + +contract ForkProbeTest is Test { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + + IPoolManager constant MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90); + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + function test_probe_weth_usdc_pools() public view { + uint24[4] memory fees = [uint24(100), uint24(500), uint24(3000), uint24(10000)]; + int24[4] memory spacings = [int24(1), int24(10), int24(60), int24(200)]; + for (uint256 i = 0; i < 4; i++) { + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(USDC), + currency1: Currency.wrap(WETH), + fee: fees[i], + tickSpacing: spacings[i], + hooks: IHooks(address(0)) + }); + PoolId id = key.toId(); + uint128 liq = MANAGER.getLiquidity(id); + (uint160 sqrtP, int24 tick,,) = MANAGER.getSlot0(id); + console2.log("fee", fees[i]); + console2.log("liquidity", liq); + console2.log("tick", uint256(int256(tick))); + sqrtP; + } + } +} diff --git a/benchmarks/fork/ForkV3.t.sol b/benchmarks/fork/ForkV3.t.sol new file mode 100644 index 0000000..38aa6da --- /dev/null +++ b/benchmarks/fork/ForkV3.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test} from "forge-std/Test.sol"; + +interface ISwapRouter { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); +} + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +/// @notice Mainnet-fork validation: REAL SwapRouter + REAL WETH/USDC 0.3% pool. +/// Run with: --fork-url --fork-block-number +contract ForkV3Test is Test { + ISwapRouter constant ROUTER = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + function test_fork_single_exactInput_weth() public { + deal(WETH, address(this), 10 ether); + IERC20Like(WETH).approve(address(ROUTER), type(uint256).max); + uint256 out = ROUTER.exactInputSingle( + ISwapRouter.ExactInputSingleParams({ + tokenIn: WETH, + tokenOut: USDC, + fee: 3000, + recipient: address(this), + deadline: block.timestamp, + amountIn: 1 ether, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }) + ); + assertGt(out, 0); + vm.snapshotGasLastCall("fork v3 SwapRouter 1 WETH->USDC 0.3%"); + } +} diff --git a/benchmarks/fork/ForkV3Test.json b/benchmarks/fork/ForkV3Test.json new file mode 100644 index 0000000..7c1270e --- /dev/null +++ b/benchmarks/fork/ForkV3Test.json @@ -0,0 +1,3 @@ +{ + "fork v3 SwapRouter 1 WETH->USDC 0.3%": "142287" +} \ No newline at end of file diff --git a/benchmarks/fork/ForkV4.t.sol b/benchmarks/fork/ForkV4.t.sol new file mode 100644 index 0000000..2381e34 --- /dev/null +++ b/benchmarks/fork/ForkV4.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/src/types/PoolKey.sol"; +import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol"; +import {SwapParams} from "v4-core/src/types/PoolOperation.sol"; +import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; +import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import {TransientStateLibrary} from "v4-core/src/libraries/TransientStateLibrary.sol"; +import {CurrencySettler} from "v4-core/test/utils/CurrencySettler.sol"; +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; + +/// @notice Minimal single-lock exact-input swapper over a live v4 pool. +/// Production Universal Router calldata would add overhead on top. +contract ForkSwapRouter is IUnlockCallback { + using CurrencyLibrary for Currency; + using CurrencySettler for Currency; + using TransientStateLibrary for IPoolManager; + + IPoolManager public immutable manager; + + constructor(IPoolManager _manager) { + manager = _manager; + } + + function swap(PoolKey memory key, bool zeroForOne, int256 amountIn, uint160 limit, address payer) + external + payable + returns (uint256 amountOut) + { + bytes memory result = manager.unlock(abi.encode(key, zeroForOne, amountIn, limit, payer, msg.sender)); + amountOut = abi.decode(result, (uint256)); + } + + function unlockCallback(bytes calldata rawData) external returns (bytes memory) { + require(msg.sender == address(manager)); + (PoolKey memory key, bool zeroForOne, int256 amountIn, uint160 limit, address payer, address recipient) = + abi.decode(rawData, (PoolKey, bool, int256, uint160, address, address)); + manager.swap(key, SwapParams({zeroForOne: zeroForOne, amountSpecified: amountIn, sqrtPriceLimitX96: limit}), ""); + Currency currencyIn = zeroForOne ? key.currency0 : key.currency1; + Currency currencyOut = zeroForOne ? key.currency1 : key.currency0; + int256 inDelta = manager.currencyDelta(address(this), currencyIn); + int256 outDelta = manager.currencyDelta(address(this), currencyOut); + if (inDelta < 0) currencyIn.settle(manager, payer, uint256(-inDelta), false); + if (outDelta > 0) currencyOut.take(manager, recipient, uint256(outDelta), false); + return abi.encode(uint256(outDelta)); + } +} + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +/// @notice Mainnet-fork validation: REAL PoolManager + REAL WETH/USDC pool, +/// deepest-hooked pool at the pinned block (fee 500, found by probe). +/// Run with: --fork-url --fork-block-number +contract ForkV4Test is Test { + IPoolManager constant MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90); + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + function test_fork_single_exactInput_weth() public { + ForkSwapRouter router = new ForkSwapRouter(MANAGER); + // WETH is currency1 (USDC < WETH): WETH->USDC is oneForZero + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(USDC), + currency1: Currency.wrap(WETH), + fee: 500, + tickSpacing: 10, + hooks: IHooks(address(0)) + }); + deal(WETH, address(this), 10 ether); + IERC20Like(WETH).approve(address(router), type(uint256).max); + uint256 out = router.swap(key, false, -1 ether, TickMath.MAX_SQRT_PRICE - 1, address(this)); + assertGt(out, 0); + vm.snapshotGasLastCall("fork v4 PoolManager 1 WETH->USDC fee-500"); + } +} diff --git a/benchmarks/fork/ForkV4Test.json b/benchmarks/fork/ForkV4Test.json new file mode 100644 index 0000000..817361e --- /dev/null +++ b/benchmarks/fork/ForkV4Test.json @@ -0,0 +1,3 @@ +{ + "fork v4 PoolManager 1 WETH->USDC fee-500": "175461" +} \ No newline at end of file diff --git a/benchmarks/fork/foundry.toml b/benchmarks/fork/foundry.toml new file mode 100644 index 0000000..ae3e9f1 --- /dev/null +++ b/benchmarks/fork/foundry.toml @@ -0,0 +1,12 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +# No `solc` pin: foundry auto-detects per file (0.8.26 driver files, +# =0.8.33 ekubo sources). A pin would force one version on everything. +optimizer = true +optimizer_runs = 1000000 +via_ir = true +evm_version = "osaka" +verbosity = 2 +fs_permissions = [{ access = "read", path = "./" }] diff --git a/benchmarks/fork/remappings.txt b/benchmarks/fork/remappings.txt new file mode 100644 index 0000000..c00f85a --- /dev/null +++ b/benchmarks/fork/remappings.txt @@ -0,0 +1,5 @@ +forge-std/=lib/forge-std/src/ +solmate/=lib/v4-core/lib/solmate/ +v4-core/=lib/v4-core/ +ekubo/=lib/ekubo/ +ekubo-test/=lib/ekubo/test/ diff --git a/benchmarks/lab/MockERC20.json b/benchmarks/lab/MockERC20.json new file mode 100644 index 0000000..860972c --- /dev/null +++ b/benchmarks/lab/MockERC20.json @@ -0,0 +1 @@ +{"abi":[{"type":"constructor","inputs":[{"name":"_name","type":"string","internalType":"string"},{"name":"_symbol","type":"string","internalType":"string"},{"name":"_decimals","type":"uint8","internalType":"uint8"}],"stateMutability":"nonpayable"},{"type":"function","name":"DOMAIN_SEPARATOR","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"allowance","inputs":[{"name":"","type":"address","internalType":"address"},{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"approve","inputs":[{"name":"spender","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"balanceOf","inputs":[{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"burn","inputs":[{"name":"from","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"decimals","inputs":[],"outputs":[{"name":"","type":"uint8","internalType":"uint8"}],"stateMutability":"view"},{"type":"function","name":"mint","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"nonces","inputs":[{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"permit","inputs":[{"name":"owner","type":"address","internalType":"address"},{"name":"spender","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"},{"name":"deadline","type":"uint256","internalType":"uint256"},{"name":"v","type":"uint8","internalType":"uint8"},{"name":"r","type":"bytes32","internalType":"bytes32"},{"name":"s","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"symbol","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"totalSupply","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"transfer","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"transferFrom","inputs":[{"name":"from","type":"address","internalType":"address"},{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"event","name":"Approval","inputs":[{"name":"owner","type":"address","indexed":true,"internalType":"address"},{"name":"spender","type":"address","indexed":true,"internalType":"address"},{"name":"amount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"Transfer","inputs":[{"name":"from","type":"address","indexed":true,"internalType":"address"},{"name":"to","type":"address","indexed":true,"internalType":"address"},{"name":"amount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false}],"bytecode":{"object":"0x60e080604052346104065761134e803803809161001c828561040a565b83398101906060818303126104065780516001600160401b038111610406578261004791830161042d565b60208201519092906001600160401b0381116104065760409161006b91840161042d565b91015160ff811681036104065782516001600160401b038111610337576100925f54610482565b601f81116103b7575b506020601f821160011461035657819293945f9261034b575b50508160011b915f199060031b1c1916175f555b81516001600160401b038111610337576100e3600154610482565b601f81116102d4575b50602092601f821160011461027357928192935f92610268575b50508160011b915f199060031b1c1916176001555b6080524660a0526040515f905f54918161013484610482565b9182825260208201946001811690815f1461024c5750600114610202575b61015e9250038261040a565b51902060405160208101917f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f835260408201527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc660608201524660808201523060a082015260a081526101d260c08261040a565b51902060c052604051610e7390816104bb823960805181610865015260a05181610ca8015260c05181610cce0152f35b505f80805290915f8051602061132e8339815191525b81831061023057505090602061015e92820101610152565b6020919350806001915483858801015201910190918392610218565b60ff191686525061015e92151560051b82016020019050610152565b015190505f80610106565b601f1982169360015f52805f20915f5b8681106102bc57508360019596106102a4575b505050811b0160015561011b565b01515f1960f88460031b161c191690555f8080610296565b91926020600181928685015181550194019201610283565b60015f527fb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6601f830160051c8101916020841061032d575b601f0160051c01905b81811061032257506100ec565b5f8155600101610315565b909150819061030c565b634e487b7160e01b5f52604160045260245ffd5b015190505f806100b4565b601f198216905f8052805f20915f5b81811061039f57509583600195969710610387575b505050811b015f556100c8565b01515f1960f88460031b161c191690555f808061037a565b9192602060018192868b015181550194019201610365565b5f80525f8051602061132e833981519152601f830160051c810191602084106103fc575b601f0160051c01905b8181106103f1575061009b565b5f81556001016103e4565b90915081906103db565b5f80fd5b601f909101601f19168101906001600160401b0382119082101761033757604052565b81601f82011215610406578051906001600160401b0382116103375760405192610461601f8401601f19166020018561040a565b8284526020838301011161040657815f9260208093018386015e8301015290565b90600182811c921680156104b0575b602083101461049c57565b634e487b7160e01b5f52602260045260245ffd5b91607f169161049156fe60806040526004361015610011575f80fd5b5f3560e01c806306fdde0314610a8b578063095ea7b3146109e857806318160ddd146109ad57806323b872dd14610889578063313ce5671461082e5780633644e515146107ee57806340c10f191461071f57806370a08231146106bc5780637ecebe001461065957806395d89b41146105435780639dc29fac146104a4578063a9059cbb146103f0578063d505accf146101435763dd62ed3e146100b3575f80fd5b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576100ea610c52565b73ffffffffffffffffffffffffffffffffffffffff610107610c75565b91165f52600460205273ffffffffffffffffffffffffffffffffffffffff60405f2091165f52602052602060405f2054604051908152f35b5f80fd5b3461013f5760e07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5761017a610c52565b610182610c75565b6044356064359260843560ff811680910361013f574285106103925760805f9160209373ffffffffffffffffffffffffffffffffffffffff6101c2610ca5565b91169687855260058652604085209889549960018b0190556040519073ffffffffffffffffffffffffffffffffffffffff888301937f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c985528b6040850152169a8b6060840152898784015260a083015260c082015260c0815261024660e082610b9c565b51902060405190868201927f19010000000000000000000000000000000000000000000000000000000000008452602283015260428201526042815261028d606282610b9c565b519020906040519182528482015260a435604082015260c435606082015282805260015afa156103875773ffffffffffffffffffffffffffffffffffffffff5f51168015158061037e575b15610320577f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925916020915f526004825260405f20855f5282528060405f2055604051908152a3005b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600e60248201527f494e56414c49445f5349474e45520000000000000000000000000000000000006044820152fd5b508281146102d8565b6040513d5f823e3d90fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f5045524d49545f444541444c494e455f455850495245440000000000000000006044820152fd5b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610427610c52565b73ffffffffffffffffffffffffffffffffffffffff60243591335f52600360205260405f20610457848254610c98565b90551690815f52600360205260405f208181540190556040519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60203392a3602060405160018152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f575f6104dc610c52565b7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef602073ffffffffffffffffffffffffffffffffffffffff6024359316928385526003825260408520610530828254610c98565b90558060025403600255604051908152a3005b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576040515f60015461058181610b4b565b808452906001811690811561061757506001146105b9575b6105b5836105a981850382610b9c565b60405191829182610c0a565b0390f35b60015f9081527fb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6939250905b8082106105fd575090915081016020016105a9610599565b9192600181602092548385880101520191019092916105e5565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff001660208086019190915291151560051b840190910191506105a99050610599565b3461013f5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5773ffffffffffffffffffffffffffffffffffffffff6106a5610c52565b165f526005602052602060405f2054604051908152f35b3461013f5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5773ffffffffffffffffffffffffffffffffffffffff610708610c52565b165f526003602052602060405f2054604051908152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610756610c52565b602435906002548281018091116107c157602073ffffffffffffffffffffffffffffffffffffffff5f937fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9360025516938484526003825260408420818154019055604051908152a3005b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576020610826610ca5565b604051908152f35b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57602060405160ff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b3461013f5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576108c0610c52565b6108c8610c75565b7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef602073ffffffffffffffffffffffffffffffffffffffff80604435951693845f526004835260405f208233165f52835260405f2054867fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203610986575b5050845f526003835260405f2061095f878254610c98565b90551693845f526003825260405f20818154019055604051908152a3602060405160018152f35b61098f91610c98565b855f526004845260405f208333165f52845260405f20558686610947565b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576020600254604051908152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610a1f610c52565b73ffffffffffffffffffffffffffffffffffffffff60243591335f52600460205260405f208282165f526020528260405f205560405192835216907f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560203392a3602060405160018152f35b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576040515f8054610ac881610b4b565b80845290600181169081156106175750600114610aef576105b5836105a981850382610b9c565b5f8080527f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563939250905b808210610b31575090915081016020016105a9610599565b919260018160209254838588010152019101909291610b19565b90600182811c92168015610b92575b6020831014610b6557565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b91607f1691610b5a565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff821117610bdd57604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f602060409481855280519182918282880152018686015e5f8582860101520116010190565b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361013f57565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361013f57565b919082039182116107c157565b467f000000000000000000000000000000000000000000000000000000000000000003610cf0577f000000000000000000000000000000000000000000000000000000000000000090565b6040515f905f549181610d0284610b4b565b9182825260208201946001811690815f14610e035750600114610da6575b610d2c92500382610b9c565b51902060405160208101917f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f835260408201527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc660608201524660808201523060a082015260a08152610da060c082610b9c565b51902090565b505f80805290917f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e5635b818310610de7575050906020610d2c92820101610d20565b6020919350806001915483858801015201910190918392610dcf565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0016865250610d2c92151560051b82016020019050610d2056fea26469706673582212200e4e3600233109bb884b081d524b13dac8383267fd5a07dfc715d3d22497968a64736f6c634300081a0033290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563","sourceMap":"118:368:20:-:0;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;-1:-1:-1;;;;;118:368:20;;;;;;;;;;:::i;:::-;;;;;;;;-1:-1:-1;;;;;118:368:20;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;-1:-1:-1;;;;;118:368:20;;;;;-1:-1:-1;118:368:20;;:::i;:::-;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;-1:-1:-1;;;;;118:368:20;;;;;2116:16:21;118:368:20;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;2116:16:21;118:368:20;;2142:20:21;;2192:13;2173:32;;118:368:20;;-1:-1:-1;118:368:20;-1:-1:-1;118:368:20;;;;;;:::i;:::-;;;;;;;;;2116:16:21;118:368:20;;2116:16:21;;118:368:20;2116:16:21;;;118:368:20;;;;;;;;;;;;:::i;:::-;;5789:22:21;;118:368:20;;;5640:295:21;;118:368:20;5672:95:21;118:368:20;;;;;;5833:14:21;118:368:20;;;;2192:13:21;2142:20;118:368:20;;;5912:4:21;2173:32;118:368:20;;;2173:32:21;5640:295;;;;;;:::i;:::-;118:368:20;5613:336:21;;5640:295;2215:51;118:368:20;;;;;;;;2142:20:21;118:368:20;;;;;2173:32:21;118:368:20;;;;;5640:295:21;118:368:20;;;;;;;-1:-1:-1;;118:368:20;;;;;-1:-1:-1;;;;;;;;;;;118:368:20;;;;;;;;;;;;;;;;;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;;;;;;;;-1:-1:-1;;118:368:20;;;-1:-1:-1;118:368:20;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;2116:16:21;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;;;;;;2116:16:21;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;2116:16:21;118:368:20;;;;;;-1:-1:-1;118:368:20;;;;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;;;;-1:-1:-1;118:368:20;;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;-1:-1:-1;;;;;;;;;;;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;-1:-1:-1;;118:368:20;;;;-1:-1:-1;;;;;118:368:20;;;;;;;;;;:::o;:::-;;;;;;;;;;;;-1:-1:-1;;;;;118:368:20;;;;;;;;;;;-1:-1:-1;;118:368:20;;;;;:::i;:::-;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;:::o;:::-;;;;;;;;;;;;;;;;;;;:::o;:::-;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x60806040526004361015610011575f80fd5b5f3560e01c806306fdde0314610a8b578063095ea7b3146109e857806318160ddd146109ad57806323b872dd14610889578063313ce5671461082e5780633644e515146107ee57806340c10f191461071f57806370a08231146106bc5780637ecebe001461065957806395d89b41146105435780639dc29fac146104a4578063a9059cbb146103f0578063d505accf146101435763dd62ed3e146100b3575f80fd5b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576100ea610c52565b73ffffffffffffffffffffffffffffffffffffffff610107610c75565b91165f52600460205273ffffffffffffffffffffffffffffffffffffffff60405f2091165f52602052602060405f2054604051908152f35b5f80fd5b3461013f5760e07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5761017a610c52565b610182610c75565b6044356064359260843560ff811680910361013f574285106103925760805f9160209373ffffffffffffffffffffffffffffffffffffffff6101c2610ca5565b91169687855260058652604085209889549960018b0190556040519073ffffffffffffffffffffffffffffffffffffffff888301937f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c985528b6040850152169a8b6060840152898784015260a083015260c082015260c0815261024660e082610b9c565b51902060405190868201927f19010000000000000000000000000000000000000000000000000000000000008452602283015260428201526042815261028d606282610b9c565b519020906040519182528482015260a435604082015260c435606082015282805260015afa156103875773ffffffffffffffffffffffffffffffffffffffff5f51168015158061037e575b15610320577f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925916020915f526004825260405f20855f5282528060405f2055604051908152a3005b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600e60248201527f494e56414c49445f5349474e45520000000000000000000000000000000000006044820152fd5b508281146102d8565b6040513d5f823e3d90fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f5045524d49545f444541444c494e455f455850495245440000000000000000006044820152fd5b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610427610c52565b73ffffffffffffffffffffffffffffffffffffffff60243591335f52600360205260405f20610457848254610c98565b90551690815f52600360205260405f208181540190556040519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60203392a3602060405160018152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f575f6104dc610c52565b7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef602073ffffffffffffffffffffffffffffffffffffffff6024359316928385526003825260408520610530828254610c98565b90558060025403600255604051908152a3005b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576040515f60015461058181610b4b565b808452906001811690811561061757506001146105b9575b6105b5836105a981850382610b9c565b60405191829182610c0a565b0390f35b60015f9081527fb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6939250905b8082106105fd575090915081016020016105a9610599565b9192600181602092548385880101520191019092916105e5565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff001660208086019190915291151560051b840190910191506105a99050610599565b3461013f5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5773ffffffffffffffffffffffffffffffffffffffff6106a5610c52565b165f526005602052602060405f2054604051908152f35b3461013f5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5773ffffffffffffffffffffffffffffffffffffffff610708610c52565b165f526003602052602060405f2054604051908152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610756610c52565b602435906002548281018091116107c157602073ffffffffffffffffffffffffffffffffffffffff5f937fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9360025516938484526003825260408420818154019055604051908152a3005b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576020610826610ca5565b604051908152f35b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57602060405160ff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b3461013f5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576108c0610c52565b6108c8610c75565b7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef602073ffffffffffffffffffffffffffffffffffffffff80604435951693845f526004835260405f208233165f52835260405f2054867fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203610986575b5050845f526003835260405f2061095f878254610c98565b90551693845f526003825260405f20818154019055604051908152a3602060405160018152f35b61098f91610c98565b855f526004845260405f208333165f52845260405f20558686610947565b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576020600254604051908152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610a1f610c52565b73ffffffffffffffffffffffffffffffffffffffff60243591335f52600460205260405f208282165f526020528260405f205560405192835216907f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560203392a3602060405160018152f35b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576040515f8054610ac881610b4b565b80845290600181169081156106175750600114610aef576105b5836105a981850382610b9c565b5f8080527f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563939250905b808210610b31575090915081016020016105a9610599565b919260018160209254838588010152019101909291610b19565b90600182811c92168015610b92575b6020831014610b6557565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b91607f1691610b5a565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff821117610bdd57604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f602060409481855280519182918282880152018686015e5f8582860101520116010190565b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361013f57565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361013f57565b919082039182116107c157565b467f000000000000000000000000000000000000000000000000000000000000000003610cf0577f000000000000000000000000000000000000000000000000000000000000000090565b6040515f905f549181610d0284610b4b565b9182825260208201946001811690815f14610e035750600114610da6575b610d2c92500382610b9c565b51902060405160208101917f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f835260408201527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc660608201524660808201523060a082015260a08152610da060c082610b9c565b51902090565b505f80805290917f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e5635b818310610de7575050906020610d2c92820101610d20565b6020919350806001915483858801015201910190918392610dcf565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0016865250610d2c92151560051b82016020019050610d2056fea26469706673582212200e4e3600233109bb884b081d524b13dac8383267fd5a07dfc715d3d22497968a64736f6c634300081a0033","sourceMap":"118:368:20:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;:::i;:::-;;;;;;;;;;;;1388:64:21;118:368:20;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;:::i;:::-;;;;;;;;;;;;;;;;4057:15:21;4045:27;;118:368:20;;;;4428:18:21;118:368:20;4428:18:21;118:368:20;4428:18:21;;:::i;:::-;118:368:20;;;;;;4873:6:21;118:368:20;;;;;;;;;;;;;;;;4511:449:21;118:368:20;4511:449:21;;;118:368:20;4555:165:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;4511:449:21;;;118:368:20;4511:449:21;;:::i;:::-;118:368:20;4472:514:21;;118:368:20;;4350:658:21;;;;118:368:20;;;;;;;;;;;;;4350:658:21;;;;;;:::i;:::-;118:368:20;4319:707:21;;118:368:20;;;;;;;;;;;;;;;;;;;;;;4292:805:21;;;118:368:20;4292:805:21;;;;;118:368:20;;4292:805:21;118:368:20;5120:30:21;;;:59;;;118:368:20;;;;5283:31:21;118:368:20;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;5283:31:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;5120:59:21;5154:25;;;;5120:59;;4292:805;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;2774:10:21;;118:368:20;;2764:9:21;118:368:20;;;;;2764:31:21;118:368:20;;;2764:31:21;:::i;:::-;118:368:20;;;;;;;2764:9:21;118:368:20;;;;;;;;;;;;;;;;2990:32:21;118:368:20;2774:10:21;2990:32;;118:368:20;;;;;;;;;;;;;;;;;;;;;:::i;:::-;6768:34:21;118:368:20;;;;;;;;;;6553:9:21;118:368:20;;;;;6553:25:21;118:368:20;;;6553:25:21;:::i;:::-;118:368:20;;;6721:21:21;118:368:20;;6721:21:21;118:368:20;;;;;;6768:34:21;118:368:20;;;;;;;;;;;;;;;1056:20:21;118:368:20;;;;:::i;:::-;;;;;1056:20:21;118:368:20;;;1056:20:21;;;;118:368:20;;;;;;;;;;;;;;:::i;:::-;;;;;;;;:::i;:::-;;;;;1056:20:21;118:368:20;;;;;;;-1:-1:-1;118:368:20;;;;;;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;;;1056:20:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;:::i;:::-;;;;1751:41:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;1337:44:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;6220:21:21;118:368:20;;;;;;;;;;;;;6436:32:21;118:368:20;6220:21:21;118:368:20;;;;;;6387:9:21;118:368:20;;;;;;;;;;;;;;;;6436:32:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;1083:31:21;118:368:20;;;;;;;;;;;;;;;;;:::i;:::-;;;:::i;:::-;3594:26:21;118:368:20;;;;;;;;;;;;;;;;;3225:10:21;;118:368:20;-1:-1:-1;118:368:20;;;;-1:-1:-1;118:368:20;;3287:28:21;3298:17;3287:28;;3283:80;;118:368:20;;;;;;3374:9:21;118:368:20;;;;;3374:25:21;118:368:20;;;3374:25:21;:::i;:::-;118:368:20;;;;;;;3374:9:21;118:368:20;;;;;;;;;;;;;;;;3594:26:21;118:368:20;;;;;;;3283:80:21;3347:16;;;:::i;:::-;118:368:20;;;;;;;;;3225:10:21;;118:368:20;-1:-1:-1;118:368:20;;;;-1:-1:-1;118:368:20;;3283:80:21;;;;118:368:20;;;;;;;;;;;;1304:26:21;118:368:20;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;2561:10:21;;118:368:20;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;;2561:10:21;2606:37;118:368:20;2561:10:21;2606:37;;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;-1:-1:-1;118:368:20;;;;;;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;;;;;;;;;;:::o;:::-;;;;;;;;;;;:::o;:::-;;;;;;;;;;:::o;5327:177:21:-;5410:13;5427:16;5410:33;5427:16;;5446:24;5327:177;:::o;5410:87::-;118:368:20;;-1:-1:-1;118:368:20;-1:-1:-1;118:368:20;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;5789:22:21;;118:368:20;;;5640:295:21;;118:368:20;5672:95:21;118:368:20;;;;;;5833:14:21;118:368:20;;;;5410:13:21;118:368:20;;;;5912:4:21;118:368:20;;;;;5640:295:21;;;;;;:::i;:::-;118:368:20;5613:336:21;;5327:177;:::o;118:368:20:-;-1:-1:-1;;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;-1:-1:-1;118:368:20;","linkReferences":{},"immutableReferences":{"42052":[{"start":2149,"length":32}],"42066":[{"start":3240,"length":32}],"42068":[{"start":3278,"length":32}]}},"methodIdentifiers":{"DOMAIN_SEPARATOR()":"3644e515","allowance(address,address)":"dd62ed3e","approve(address,uint256)":"095ea7b3","balanceOf(address)":"70a08231","burn(address,uint256)":"9dc29fac","decimals()":"313ce567","mint(address,uint256)":"40c10f19","name()":"06fdde03","nonces(address)":"7ecebe00","permit(address,address,uint256,uint256,uint8,bytes32,bytes32)":"d505accf","symbol()":"95d89b41","totalSupply()":"18160ddd","transfer(address,uint256)":"a9059cbb","transferFrom(address,address,uint256)":"23b872dd"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.26+commit.8a97fa7a\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"string\",\"name\":\"_name\",\"type\":\"string\"},{\"internalType\":\"string\",\"name\":\"_symbol\",\"type\":\"string\"},{\"internalType\":\"uint8\",\"name\":\"_decimals\",\"type\":\"uint8\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"Approval\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"DOMAIN_SEPARATOR\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"allowance\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"approve\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"burn\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"mint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"nonces\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"deadline\",\"type\":\"uint256\"},{\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"}],\"name\":\"permit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"symbol\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transfer\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transferFrom\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}],\"devdoc\":{\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol\":\"MockERC20\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":true,\"runs\":44444444},\"remappings\":[\"/tmp/opencode/gasbench/lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/:ds-test/=lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\"/tmp/opencode/gasbench/lib/v4-core/lib/solmate/:ds-test/=lib/v4-core/lib/solmate/lib/ds-test/src/\",\":@ensdomains/=lib/v4-core/node_modules/@ensdomains/\",\":@openzeppelin/=lib/v4-core/lib/openzeppelin-contracts/\",\":ds-test/=lib/v4-core/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/v4-core/lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/forge-std/src/\",\":hardhat/=lib/v4-core/node_modules/hardhat/\",\":openzeppelin-contracts/=lib/v4-core/lib/openzeppelin-contracts/\",\":solady/=lib/solady/src/\",\":solmate/=lib/v4-core/lib/solmate/\",\":v4-core/=lib/v4-core/\",\":v4-src/=lib/v4-core/src/\",\"lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/:ds-test/=lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\"lib/v4-core/lib/solmate/:ds-test/=lib/v4-core/lib/solmate/lib/ds-test/src/\"],\"viaIR\":true},\"sources\":{\"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol\":{\"keccak256\":\"0x525129ccb4103c9ecb8f235618d2ff4a8ffde4b8654f7c17766ad175d10ef466\",\"license\":\"AGPL-3.0-only\",\"urls\":[\"bzz-raw://45692a21807ba815c0cf0bfc7d633a14a6cb9ba863ab53f18fc639375a72125e\",\"dweb:/ipfs/QmSrqxyodjDtSU1Dc6G3ScnNc9CFjMnAeHpK8a8guM3nJu\"]},\"lib/v4-core/lib/solmate/src/tokens/ERC20.sol\":{\"keccak256\":\"0xcdfd8db76b2a3415620e4d18cc5545f3d50de792dbf2c3dd5adb40cbe6f94b10\",\"license\":\"AGPL-3.0-only\",\"urls\":[\"bzz-raw://57b3ab70cde374af1cf2c9888636e8de6cf660f087b1c9abd805e9271e19fa35\",\"dweb:/ipfs/QmNrLDBAHYFjpjSd12jerm1AdBkDqEYUUaXgnT854BUZ97\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.26+commit.8a97fa7a"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"string","name":"_name","type":"string"},{"internalType":"string","name":"_symbol","type":"string"},{"internalType":"uint8","name":"_decimals","type":"uint8"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[{"internalType":"address","name":"owner","type":"address","indexed":true},{"internalType":"address","name":"spender","type":"address","indexed":true},{"internalType":"uint256","name":"amount","type":"uint256","indexed":false}],"type":"event","name":"Approval","anonymous":false},{"inputs":[{"internalType":"address","name":"from","type":"address","indexed":true},{"internalType":"address","name":"to","type":"address","indexed":true},{"internalType":"uint256","name":"amount","type":"uint256","indexed":false}],"type":"event","name":"Transfer","anonymous":false},{"inputs":[],"stateMutability":"view","type":"function","name":"DOMAIN_SEPARATOR","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}]},{"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function","name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}]},{"inputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function","name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"burn"},{"inputs":[],"stateMutability":"view","type":"function","name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}]},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"mint"},{"inputs":[],"stateMutability":"view","type":"function","name":"name","outputs":[{"internalType":"string","name":"","type":"string"}]},{"inputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function","name":"nonces","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"stateMutability":"nonpayable","type":"function","name":"permit"},{"inputs":[],"stateMutability":"view","type":"function","name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}]},{"inputs":[],"stateMutability":"view","type":"function","name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}]},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}]}],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["/tmp/opencode/gasbench/lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/:ds-test/=lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","/tmp/opencode/gasbench/lib/v4-core/lib/solmate/:ds-test/=lib/v4-core/lib/solmate/lib/ds-test/src/","@ensdomains/=lib/v4-core/node_modules/@ensdomains/","@openzeppelin/=lib/v4-core/lib/openzeppelin-contracts/","ds-test/=lib/v4-core/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/v4-core/lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/forge-std/src/","hardhat/=lib/v4-core/node_modules/hardhat/","openzeppelin-contracts/=lib/v4-core/lib/openzeppelin-contracts/","solady/=lib/solady/src/","solmate/=lib/v4-core/lib/solmate/","v4-core/=lib/v4-core/","v4-src/=lib/v4-core/src/","lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/:ds-test/=lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","lib/v4-core/lib/solmate/:ds-test/=lib/v4-core/lib/solmate/lib/ds-test/src/"],"optimizer":{"enabled":true,"runs":44444444},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol":"MockERC20"},"evmVersion":"cancun","libraries":{},"viaIR":true},"sources":{"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol":{"keccak256":"0x525129ccb4103c9ecb8f235618d2ff4a8ffde4b8654f7c17766ad175d10ef466","urls":["bzz-raw://45692a21807ba815c0cf0bfc7d633a14a6cb9ba863ab53f18fc639375a72125e","dweb:/ipfs/QmSrqxyodjDtSU1Dc6G3ScnNc9CFjMnAeHpK8a8guM3nJu"],"license":"AGPL-3.0-only"},"lib/v4-core/lib/solmate/src/tokens/ERC20.sol":{"keccak256":"0xcdfd8db76b2a3415620e4d18cc5545f3d50de792dbf2c3dd5adb40cbe6f94b10","urls":["bzz-raw://57b3ab70cde374af1cf2c9888636e8de6cf660f087b1c9abd805e9271e19fa35","dweb:/ipfs/QmNrLDBAHYFjpjSd12jerm1AdBkDqEYUUaXgnT854BUZ97"],"license":"AGPL-3.0-only"}},"version":1},"id":20} \ No newline at end of file diff --git a/benchmarks/lab/ekubo/EkuboGas.t.sol b/benchmarks/lab/ekubo/EkuboGas.t.sol new file mode 100644 index 0000000..c137b7e --- /dev/null +++ b/benchmarks/lab/ekubo/EkuboGas.t.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: ekubo-license-v1.eth +pragma solidity =0.8.33; + +import {Test} from "forge-std/Test.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {Core} from "ekubo/src/Core.sol"; +import {Positions} from "ekubo/src/Positions.sol"; +import {Router} from "ekubo/src/Router.sol"; +import {PoolKey} from "ekubo/src/types/poolKey.sol"; +import {PoolConfig, createFullRangePoolConfig, createConcentratedPoolConfig} from "ekubo/src/types/poolConfig.sol"; +import {SqrtRatio} from "ekubo/src/types/sqrtRatio.sol"; +import {NATIVE_TOKEN_ADDRESS} from "ekubo/src/math/constants.sol"; +import {BaseRouter, RouteNode, TokenAmount, Swap} from "ekubo/src/base/BaseRouter.sol"; +import {createSwapParameters} from "ekubo/src/types/swapParameters.sol"; +import {BaseLocker} from "ekubo/src/base/BaseLocker.sol"; +import {ICore} from "ekubo/src/interfaces/ICore.sol"; +import {FlashAccountantLib} from "ekubo/src/libraries/FlashAccountantLib.sol"; +import {PositionId, createPositionId} from "ekubo/src/types/positionId.sol"; +import {PoolBalanceUpdate} from "ekubo/src/types/poolBalanceUpdate.sol"; + +/// @notice Bare core-level liquidity provision: lock, updatePosition, settle net. +/// No NFT, no protocol fees — the same tier as the v4/v3 direct mints. +contract BareMintRouter is BaseLocker { + using FlashAccountantLib for *; + + constructor(ICore core) BaseLocker(core) {} + + function mint(PoolKey memory key, bytes24 salt, int32 tickLower, int32 tickUpper, int128 liquidityDelta, address payer) + external + returns (PoolBalanceUpdate balanceUpdate) + { + balanceUpdate = + abi.decode(lock(abi.encode(key, salt, tickLower, tickUpper, liquidityDelta, payer, msg.sender)), (PoolBalanceUpdate)); + } + + function handleLockData(uint256, bytes memory data) internal override returns (bytes memory result) { + (PoolKey memory key, bytes24 salt, int32 tickLower, int32 tickUpper, int128 liquidityDelta, address payer, address recipient) = + abi.decode(data, (PoolKey, bytes24, int32, int32, int128, address, address)); + PoolBalanceUpdate balanceUpdate = ICore(payable(address(ACCOUNTANT))).updatePosition( + key, createPositionId(salt, tickLower, tickUpper), liquidityDelta + ); + if (balanceUpdate.delta0() > 0) { + ACCOUNTANT.payFrom(payer, key.token0, uint128(balanceUpdate.delta0())); + } else if (balanceUpdate.delta0() < 0) { + ACCOUNTANT.withdraw(key.token0, recipient, uint128(-balanceUpdate.delta0())); + } + if (balanceUpdate.delta1() > 0) { + ACCOUNTANT.payFrom(payer, key.token1, uint128(balanceUpdate.delta1())); + } else if (balanceUpdate.delta1() < 0) { + ACCOUNTANT.withdraw(key.token1, recipient, uint128(-balanceUpdate.delta1())); + } + result = abi.encode(balanceUpdate); + } +} + +contract EkuboGasTest is Test { + Core core; + Positions positions; + Router router; + BareMintRouter bareMint; + + MockERC20 tokenA; + MockERC20 tokenB; + MockERC20 tokenC; + MockERC20 tokenD; + + PoolKey keyAB; + PoolKey keyBC; + PoolKey keyCD; + PoolKey keyNative; + + // 0.3% fee in Q64 (matches Uniswap's 3000 fee tier) + uint64 constant FEE_0_3 = 55340232221128655; + // Concentrated code path (tick bitmap active), MIN..MAX position so swaps + // never cross an initialized tick. Spacing 6000 fine-ticks ~= Uniswap spacing 60. + uint32 constant TICK_SPACING = 6000; + int32 constant RANGE_LOWER = -88722000; // -14787 * spacing + int32 constant RANGE_UPPER = 88722000; // +14787 * spacing + int32 constant INIT_TICK = 3050; // strictly inside a tick, not on a spacing multiple + uint128 constant LIQUIDITY_AMOUNT = 1_000_000 ether; + int128 constant SWAP_AMOUNT = 1 ether; + + function setUp() public { + core = new Core(); + positions = new Positions(core, address(this), 0, 1); + router = new Router(core, address(0), address(0)); + bareMint = new BareMintRouter(core); + + // One identical token artifact (solmate MockERC20, deployed via deployCode) + // in every harness, so transfer costs cannot skew the comparison. + tokenA = MockERC20(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenA", "A", 18))); + tokenB = MockERC20(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenB", "B", 18))); + tokenC = MockERC20(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenC", "C", 18))); + tokenD = MockERC20(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenD", "D", 18))); + if (address(tokenA) > address(tokenB)) (tokenA, tokenB) = (tokenB, tokenA); + if (address(tokenC) > address(tokenD)) (tokenC, tokenD) = (tokenD, tokenC); + if (address(tokenB) > address(tokenC)) (tokenB, tokenC) = (tokenC, tokenB); + if (address(tokenA) > address(tokenB)) (tokenA, tokenB) = (tokenB, tokenA); + if (address(tokenC) > address(tokenD)) (tokenC, tokenD) = (tokenD, tokenC); + tokenA.mint(address(this), 10_000_000 ether); + tokenB.mint(address(this), 10_000_000 ether); + tokenC.mint(address(this), 10_000_000 ether); + tokenD.mint(address(this), 10_000_000 ether); + vm.deal(address(this), 10_000_000 ether); + + keyAB = _fullRangeKey(address(tokenA), address(tokenB)); + keyBC = _fullRangeKey(address(tokenB), address(tokenC)); + keyCD = _fullRangeKey(address(tokenC), address(tokenD)); + keyNative = _fullRangeKey(NATIVE_TOKEN_ADDRESS, address(tokenB)); + + core.initializePool(keyAB, INIT_TICK); + core.initializePool(keyBC, INIT_TICK); + core.initializePool(keyCD, INIT_TICK); + core.initializePool(keyNative, INIT_TICK); + + tokenA.approve(address(positions), type(uint256).max); + tokenB.approve(address(positions), type(uint256).max); + tokenC.approve(address(positions), type(uint256).max); + tokenD.approve(address(positions), type(uint256).max); + tokenA.approve(address(router), type(uint256).max); + tokenB.approve(address(router), type(uint256).max); + tokenC.approve(address(router), type(uint256).max); + tokenA.approve(address(bareMint), type(uint256).max); + tokenB.approve(address(bareMint), type(uint256).max); + + positions.mintAndDeposit(keyAB, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); + positions.mintAndDeposit(keyBC, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); + positions.mintAndDeposit(keyCD, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); + positions.mintAndDeposit{value: LIQUIDITY_AMOUNT}( + keyNative, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0 + ); + } + + function _fullRangeKey(address t0, address t1) internal pure returns (PoolKey memory) { + return PoolKey({token0: t0, token1: t1, config: createConcentratedPoolConfig(FEE_0_3, TICK_SPACING, address(0))}); + } + + function _coolAll() internal { + vm.cool(address(core)); + vm.cool(address(positions)); + vm.cool(address(router)); + vm.cool(address(bareMint)); + vm.cool(address(tokenA)); + vm.cool(address(tokenB)); + vm.cool(address(tokenC)); + vm.cool(address(tokenD)); + vm.cool(address(this)); + } + + function _route(PoolKey[] memory keys) internal pure returns (RouteNode[] memory route) { + route = new RouteNode[](keys.length); + for (uint256 i = 0; i < keys.length; i++) { + route[i] = RouteNode(keys[i], SqrtRatio.wrap(0), 0); + } + } + + /// forge-config: default.isolate = true + function test_gas_single_exactInput_erc20() public { + _coolAll(); + router.swapAllowPartialFill( + keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0) + ); + vm.snapshotGasLastCall("ekubo single exact-input token0->token1 concentrated"); + } + + /// forge-config: default.isolate = true + function test_gas_single_exactInput_erc20_reverse() public { + _coolAll(); + router.swapAllowPartialFill( + keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, true, 0) + ); + vm.snapshotGasLastCall("ekubo single exact-input token1->token0 concentrated"); + } + + /// forge-config: default.isolate = true + function test_gas_single_exactInput_native() public { + _coolAll(); + router.swapAllowPartialFill{value: uint128(SWAP_AMOUNT)}( + keyNative, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0) + ); + vm.snapshotGasLastCall("ekubo single exact-input native->ERC20 concentrated"); + } + + /// forge-config: default.isolate = true + function test_gas_oneHop_multihopPath() public { + PoolKey[] memory keys = new PoolKey[](1); + keys[0] = keyAB; + _coolAll(); + router.multihopSwap( + Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min + ); + vm.snapshotGasLastCall("ekubo one-hop exact-input single lock concentrated"); + } + + /// forge-config: default.isolate = true + function test_gas_twoHop_exactInput() public { + PoolKey[] memory keys = new PoolKey[](2); + keys[0] = keyAB; + keys[1] = keyBC; + _coolAll(); + router.multihopSwap( + Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min + ); + vm.snapshotGasLastCall("ekubo two-hop exact-input single lock concentrated"); + } + + /// forge-config: default.isolate = true + function test_gas_threeHop_exactInput() public { + PoolKey[] memory keys = new PoolKey[](3); + keys[0] = keyAB; + keys[1] = keyBC; + keys[2] = keyCD; + _coolAll(); + router.multihopSwap( + Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min + ); + vm.snapshotGasLastCall("ekubo three-hop exact-input single lock concentrated"); + } + + /// forge-config: default.isolate = true + function test_gas_mint_bare() public { + _coolAll(); + bareMint.mint(keyAB, bytes24(uint192(777)), RANGE_LOWER, RANGE_UPPER, 1e24, address(this)); + vm.snapshotGasLastCall("ekubo mint bare core position"); + } + + /// forge-config: default.isolate = true + function test_gas_mint_fullRange() public { + _coolAll(); + positions.mintAndDeposit(keyAB, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); + vm.snapshotGasLastCall("ekubo mint concentrated position"); + } + + receive() external payable {} +} diff --git a/benchmarks/lab/ekubo/EkuboGasTest.json b/benchmarks/lab/ekubo/EkuboGasTest.json new file mode 100644 index 0000000..4020d98 --- /dev/null +++ b/benchmarks/lab/ekubo/EkuboGasTest.json @@ -0,0 +1,3 @@ +{ + "ekubo single exact-input token0->token1 concentrated": "87384" +} \ No newline at end of file diff --git a/benchmarks/lab/ekubo/foundry.toml b/benchmarks/lab/ekubo/foundry.toml new file mode 100644 index 0000000..ee3c7a4 --- /dev/null +++ b/benchmarks/lab/ekubo/foundry.toml @@ -0,0 +1,11 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc = "0.8.33" +optimizer = true +optimizer_runs = 9999999 +via_ir = true +evm_version = "osaka" +verbosity = 2 +fs_permissions = [{ access = "read", path = "./" }] diff --git a/benchmarks/lab/ekubo/remappings.txt b/benchmarks/lab/ekubo/remappings.txt new file mode 100644 index 0000000..6767287 --- /dev/null +++ b/benchmarks/lab/ekubo/remappings.txt @@ -0,0 +1,5 @@ +forge-std/=lib/forge-std/src/ +solady/=lib/solady/src/ +solmate/=/tmp/opencode/gasbench/lib/v4-core/lib/solmate/ +ekubo/=lib/ekubo/ +ekubo-test/=lib/ekubo/test/ diff --git a/benchmarks/lab/v3/V3Gas.t.sol b/benchmarks/lab/v3/V3Gas.t.sol new file mode 100644 index 0000000..4e2b86e --- /dev/null +++ b/benchmarks/lab/v3/V3Gas.t.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test} from "forge-std/Test.sol"; + +interface IUniswapV3Factory { + function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); +} + +interface IUniswapV3Pool { + function token0() external view returns (address); + function token1() external view returns (address); + function initialize(uint160 sqrtPriceX96) external; + function mint(address recipient, int24 tickLower, int24 tickUpper, uint128 amount, bytes calldata data) + external + returns (uint256 amount0, uint256 amount1); + function swap( + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 sqrtPriceLimitX96, + bytes calldata data + ) external returns (int256 amount0, int256 amount1); +} + +/// @notice Interface to the shared token artifact (solmate MockERC20 bytecode, +/// deployed via deployCode): the identical token contract runs in all harnesses. +interface SharedToken { + function mint(address to, uint256 amount) external; + function approve(address spender, uint256 amount) external returns (bool); + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +/// @notice Minimal N-hop exact-input router over v3 pools. One `multiHop` call covers +/// every hop plus settlement, so the gas snapshot captures the full route cost, +/// comparable to a production SwapRouter-style exact-input path. +contract V3MultiHopRouter { + uint160 constant MIN_LIMIT = 4295128740; // MIN_SQRT_RATIO + 1 + + function multiHop(IUniswapV3Pool[] memory pools, int256 amountIn, address recipient) + external + returns (int256 out) + { + SharedToken(pools[0].token0()).transferFrom(msg.sender, address(this), uint256(amountIn)); + int256 amount = amountIn; + for (uint256 i = 0; i < pools.length; i++) { + (, int256 a1) = pools[i].swap(address(this), true, amount, MIN_LIMIT, ""); + amount = -a1; + require(amount > 0, "no hop output"); + } + out = amount; + SharedToken(pools[pools.length - 1].token1()).transfer(recipient, uint256(out)); + } + + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external { + if (amount0Delta > 0) SharedToken(IUniswapV3Pool(msg.sender).token0()).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) SharedToken(IUniswapV3Pool(msg.sender).token1()).transfer(msg.sender, uint256(amount1Delta)); + } +} + +/// @notice Uniswap v3 gas harness deploying canonical mainnet bytecode (v3-core 1.0.1 +/// artifacts) and driving it with a minimal callback payer. Direct pool calls +/// make the single-hop number a lower bound for production-router (SwapRouter) gas. +contract V3GasTest is Test { + IUniswapV3Factory factory; + IUniswapV3Pool poolAB; + IUniswapV3Pool poolBC; + IUniswapV3Pool poolCD; + V3MultiHopRouter multihopRouter; + + SharedToken tokenA; + SharedToken tokenB; + SharedToken tokenC; + SharedToken tokenD; + + int24 constant TICK_LOWER = -887220; + int24 constant TICK_UPPER = 887220; + uint160 constant INIT_SQRT_PRICE = 79347087983665984109010747392; // 1.0001**(30/2) * 2**96: tick 30, strictly inside (spacing 60) + uint160 constant MIN_LIMIT = 4295128740; // MIN_SQRT_RATIO + 1 + uint160 constant MAX_LIMIT = 1461446703485210103287273052203988822378723970341; // MAX_SQRT_RATIO - 1 + uint128 constant LIQUIDITY = 1e30; + int256 constant SWAP_AMOUNT = 1e18; // exact input + + function setUp() public { + factory = IUniswapV3Factory(vm.deployCode("artifacts/UniswapV3Factory.json")); + + // One identical token artifact (solmate MockERC20) in every harness. + tokenA = SharedToken(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenA", "A", 18))); + tokenB = SharedToken(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenB", "B", 18))); + tokenC = SharedToken(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenC", "C", 18))); + tokenD = SharedToken(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenD", "D", 18))); + if (address(tokenA) > address(tokenB)) (tokenA, tokenB) = (tokenB, tokenA); + if (address(tokenC) > address(tokenD)) (tokenC, tokenD) = (tokenD, tokenC); + if (address(tokenB) > address(tokenC)) (tokenB, tokenC) = (tokenC, tokenB); + if (address(tokenA) > address(tokenB)) (tokenA, tokenB) = (tokenB, tokenA); + if (address(tokenC) > address(tokenD)) (tokenC, tokenD) = (tokenD, tokenC); + tokenA.mint(address(this), 1e45); + tokenB.mint(address(this), 1e45); + tokenC.mint(address(this), 1e45); + tokenD.mint(address(this), 1e45); + + poolAB = IUniswapV3Pool(factory.createPool(address(tokenA), address(tokenB), 3000)); + poolBC = IUniswapV3Pool(factory.createPool(address(tokenB), address(tokenC), 3000)); + poolCD = IUniswapV3Pool(factory.createPool(address(tokenC), address(tokenD), 3000)); + poolAB.initialize(INIT_SQRT_PRICE); + poolBC.initialize(INIT_SQRT_PRICE); + poolCD.initialize(INIT_SQRT_PRICE); + poolAB.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); + poolBC.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); + poolCD.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); + multihopRouter = new V3MultiHopRouter(); + tokenA.approve(address(multihopRouter), type(uint256).max); + } + + function _tokenFor(address pool, bool isToken0) internal view returns (SharedToken) { + if (pool == address(poolAB)) return isToken0 ? tokenA : tokenB; + if (pool == address(poolBC)) return isToken0 ? tokenB : tokenC; + return isToken0 ? tokenC : tokenD; + } + + function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { + if (amount0Owed > 0) SharedToken(IUniswapV3Pool(msg.sender).token0()).transfer(msg.sender, amount0Owed); + if (amount1Owed > 0) SharedToken(IUniswapV3Pool(msg.sender).token1()).transfer(msg.sender, amount1Owed); + } + + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external { + if (amount0Delta > 0) SharedToken(IUniswapV3Pool(msg.sender).token0()).transfer(msg.sender, uint256(amount0Delta)); + if (amount1Delta > 0) SharedToken(IUniswapV3Pool(msg.sender).token1()).transfer(msg.sender, uint256(amount1Delta)); + } + + function _coolAll() internal { + vm.cool(address(factory)); + vm.cool(address(poolAB)); + vm.cool(address(poolBC)); + vm.cool(address(poolCD)); + vm.cool(address(multihopRouter)); + vm.cool(address(tokenA)); + vm.cool(address(tokenB)); + vm.cool(address(tokenC)); + vm.cool(address(tokenD)); + vm.cool(address(this)); + } + + /// forge-config: default.isolate = true + function test_gas_single_exactInput_erc20() public { + _coolAll(); + poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); + vm.snapshotGasLastCall("v3 single exact-input token0->token1 wide-range"); + } + + /// forge-config: default.isolate = true + function test_gas_single_exactInput_erc20_reverse() public { + _coolAll(); + poolAB.swap(address(this), false, SWAP_AMOUNT, MAX_LIMIT, ""); + vm.snapshotGasLastCall("v3 single exact-input token1->token0 wide-range"); + } + + /// forge-config: default.isolate = true + function test_gas_oneHop_routerPath() public { + IUniswapV3Pool[] memory pools = new IUniswapV3Pool[](1); + pools[0] = poolAB; + _coolAll(); + multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); + vm.snapshotGasLastCall("v3 one-hop exact-input router path"); + } + + /// forge-config: default.isolate = true + function test_gas_twoHop_exactInput() public { + IUniswapV3Pool[] memory pools = new IUniswapV3Pool[](2); + pools[0] = poolAB; + pools[1] = poolBC; + _coolAll(); + multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); + vm.snapshotGasLastCall("v3 two-hop exact-input two pools"); + } + + /// forge-config: default.isolate = true + function test_gas_threeHop_exactInput() public { + IUniswapV3Pool[] memory pools = new IUniswapV3Pool[](3); + pools[0] = poolAB; + pools[1] = poolBC; + pools[2] = poolCD; + _coolAll(); + multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); + vm.snapshotGasLastCall("v3 three-hop exact-input three pools"); + } + + /// forge-config: default.isolate = true + function test_gas_mint_fullRange() public { + _coolAll(); + // second position on the existing pool: same tier as the other harnesses' + // subsequent mints (no pool/tick initialization in the measured call) + poolAB.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); + vm.snapshotGasLastCall("v3 mint wide-range position"); + } +} diff --git a/benchmarks/lab/v3/V3GasTest.json b/benchmarks/lab/v3/V3GasTest.json new file mode 100644 index 0000000..da27bb4 --- /dev/null +++ b/benchmarks/lab/v3/V3GasTest.json @@ -0,0 +1,3 @@ +{ + "v3 single exact-input token0->token1 wide-range": "104653" +} \ No newline at end of file diff --git a/benchmarks/lab/v3/foundry.toml b/benchmarks/lab/v3/foundry.toml new file mode 100644 index 0000000..abdece5 --- /dev/null +++ b/benchmarks/lab/v3/foundry.toml @@ -0,0 +1,11 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc = "0.8.26" +optimizer = true +optimizer_runs = 1000000 +via_ir = true +evm_version = "cancun" +verbosity = 2 +fs_permissions = [{ access = "read", path = "./artifacts" }] diff --git a/benchmarks/lab/v4/V4Gas.t.sol b/benchmarks/lab/v4/V4Gas.t.sol new file mode 100644 index 0000000..bd4c435 --- /dev/null +++ b/benchmarks/lab/v4/V4Gas.t.sol @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {PoolManager} from "v4-core/src/PoolManager.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol"; +import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol"; +import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol"; +import {SwapParams, ModifyLiquidityParams} from "v4-core/src/types/PoolOperation.sol"; +import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; +import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; +import {TransientStateLibrary} from "v4-core/src/libraries/TransientStateLibrary.sol"; +import {CurrencySettler} from "v4-core/test/utils/CurrencySettler.sol"; +import {LiquidityAmounts} from "v4-core/test/utils/LiquidityAmounts.sol"; +import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; +import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol"; + +/// @notice True-minimal single-hop exact-input locker: unlock, swap, settle net. +/// No balance assertions, no test settings — the floor for v4 router cost. +contract MinimalSwapRouter is IUnlockCallback { + using CurrencyLibrary for Currency; + using CurrencySettler for Currency; + using TransientStateLibrary for IPoolManager; + + IPoolManager public immutable manager; + + constructor(IPoolManager _manager) { + manager = _manager; + } + + function swapExactIn(PoolKey memory key, bool zeroForOne, int256 amountIn, uint160 limit, address payer) + external + returns (uint256 amountOut) + { + bytes memory result = manager.unlock(abi.encode(key, zeroForOne, amountIn, limit, payer, msg.sender)); + amountOut = abi.decode(result, (uint256)); + } + + function unlockCallback(bytes calldata rawData) external returns (bytes memory) { + require(msg.sender == address(manager)); + (PoolKey memory key, bool zeroForOne, int256 amountIn, uint160 limit, address payer, address recipient) = + abi.decode(rawData, (PoolKey, bool, int256, uint160, address, address)); + manager.swap(key, SwapParams({zeroForOne: zeroForOne, amountSpecified: amountIn, sqrtPriceLimitX96: limit}), ""); + Currency currencyIn = zeroForOne ? key.currency0 : key.currency1; + Currency currencyOut = zeroForOne ? key.currency1 : key.currency0; + int256 inDelta = manager.currencyDelta(address(this), currencyIn); + int256 outDelta = manager.currencyDelta(address(this), currencyOut); + if (inDelta < 0) currencyIn.settle(manager, payer, uint256(-inDelta), false); + if (outDelta > 0) currencyOut.take(manager, recipient, uint256(outDelta), false); + return abi.encode(uint256(outDelta)); + } +} + +/// @notice Minimal single-lock N-hop exact-input router (e.g. A->B->C->D), mirroring +/// what a production v4 router does inside one `unlock` with net settlement. +contract MultiHopRouter is IUnlockCallback { + using CurrencyLibrary for Currency; + using CurrencySettler for Currency; + using TransientStateLibrary for IPoolManager; + + IPoolManager public immutable manager; + + constructor(IPoolManager _manager) { + manager = _manager; + } + + struct Hop { + PoolKey key; + uint160 sqrtPriceLimitX96; + } + + function multiHop(Hop[] memory hops, Currency currencyIn, Currency currencyOut, int256 amountIn) + external + payable + returns (uint256 amountOut) + { + bytes memory result = manager.unlock( + abi.encode(hops, currencyIn, currencyOut, amountIn, msg.sender, address(this)) + ); + amountOut = abi.decode(result, (uint256)); + uint256 ethBalance = address(this).balance; + if (ethBalance > 0) CurrencyLibrary.ADDRESS_ZERO.transfer(msg.sender, ethBalance); + } + + function unlockCallback(bytes calldata rawData) external returns (bytes memory) { + require(msg.sender == address(manager)); + (Hop[] memory hops, Currency currencyIn, Currency currencyOut, int256 amountIn, address payer, address recipient) + = abi.decode(rawData, (Hop[], Currency, Currency, int256, address, address)); + + Currency currencyOwed = currencyIn; + int256 amountOwed = amountIn; + for (uint256 i = 0; i < hops.length; i++) { + bool zeroForOne = currencyOwed == hops[i].key.currency0; + manager.swap( + hops[i].key, + SwapParams({zeroForOne: zeroForOne, amountSpecified: amountOwed, sqrtPriceLimitX96: hops[i].sqrtPriceLimitX96}), + "" + ); + currencyOwed = zeroForOne ? hops[i].key.currency1 : hops[i].key.currency0; + amountOwed = -manager.currencyDelta(address(this), currencyOwed); + require(amountOwed < 0, "no hop output"); + } + + int256 inDelta = manager.currencyDelta(address(this), currencyIn); + int256 outDelta = manager.currencyDelta(address(this), currencyOut); + require(inDelta <= 0, "inDelta positive"); + require(outDelta >= 0, "outDelta negative"); + if (inDelta < 0) currencyIn.settle(manager, payer, uint256(-inDelta), false); + if (outDelta > 0) currencyOut.take(manager, recipient, uint256(outDelta), false); + return abi.encode(uint256(outDelta)); + } +} + +contract V4GasTest is Test { + using PoolIdLibrary for PoolKey; + using CurrencyLibrary for Currency; + + PoolManager manager; + PoolSwapTest swapRouter; + MinimalSwapRouter minRouter; + PoolModifyLiquidityTest mintRouter; + MultiHopRouter multiHopRouter; + + MockERC20 tokenA; + MockERC20 tokenB; + MockERC20 tokenC; + MockERC20 tokenD; + + PoolKey keyAB; + PoolKey keyBC; + PoolKey keyCD; + PoolKey keyNative; + + int24 constant TICK_LOWER = -887220; + int24 constant TICK_UPPER = 887220; + uint256 constant LIQUIDITY_TOKEN_AMOUNT = 1_000_000 ether; + int256 constant SWAP_AMOUNT = -1 ether; + + function setUp() public { + manager = new PoolManager(address(this)); + swapRouter = new PoolSwapTest(manager); + minRouter = new MinimalSwapRouter(manager); + mintRouter = new PoolModifyLiquidityTest(manager); + multiHopRouter = new MultiHopRouter(manager); + + // One identical token artifact (solmate MockERC20, deployed via deployCode) + // in every harness, so transfer costs cannot skew the comparison. + tokenA = MockERC20(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenA", "A", 18))); + tokenB = MockERC20(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenB", "B", 18))); + tokenC = MockERC20(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenC", "C", 18))); + tokenD = MockERC20(vm.deployCode("artifacts/MockERC20.json", abi.encode("TokenD", "D", 18))); + // sort A < B < C < D by address for valid v4 currency ordering + if (address(tokenA) > address(tokenB)) (tokenA, tokenB) = (tokenB, tokenA); + if (address(tokenC) > address(tokenD)) (tokenC, tokenD) = (tokenD, tokenC); + if (address(tokenB) > address(tokenC)) (tokenB, tokenC) = (tokenC, tokenB); + if (address(tokenA) > address(tokenB)) (tokenA, tokenB) = (tokenB, tokenA); + if (address(tokenC) > address(tokenD)) (tokenC, tokenD) = (tokenD, tokenC); + + tokenA.mint(address(this), 10_000_000 ether); + tokenB.mint(address(this), 10_000_000 ether); + tokenC.mint(address(this), 10_000_000 ether); + tokenD.mint(address(this), 10_000_000 ether); + vm.deal(address(this), 10_000_000 ether); + + tokenA.approve(address(swapRouter), type(uint256).max); + tokenB.approve(address(swapRouter), type(uint256).max); + tokenC.approve(address(swapRouter), type(uint256).max); + tokenA.approve(address(mintRouter), type(uint256).max); + tokenB.approve(address(mintRouter), type(uint256).max); + tokenC.approve(address(mintRouter), type(uint256).max); + tokenD.approve(address(mintRouter), type(uint256).max); + tokenA.approve(address(multiHopRouter), type(uint256).max); + tokenA.approve(address(minRouter), type(uint256).max); + tokenB.approve(address(minRouter), type(uint256).max); + + keyAB = PoolKey({ + currency0: Currency.wrap(address(tokenA)), + currency1: Currency.wrap(address(tokenB)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + keyBC = PoolKey({ + currency0: Currency.wrap(address(tokenB)), + currency1: Currency.wrap(address(tokenC)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + keyCD = PoolKey({ + currency0: Currency.wrap(address(tokenC)), + currency1: Currency.wrap(address(tokenD)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + manager.initialize(keyAB, TickMath.getSqrtPriceAtTick(30)); + manager.initialize(keyBC, TickMath.getSqrtPriceAtTick(30)); + manager.initialize(keyCD, TickMath.getSqrtPriceAtTick(30)); + _addFullRangeLiquidity(keyAB, address(tokenA), address(tokenB), false); + _addFullRangeLiquidity(keyBC, address(tokenB), address(tokenC), false); + _addFullRangeLiquidity(keyCD, address(tokenC), address(tokenD), false); + + // native (ETH) / tokenB pool for the native-input scenario + keyNative = PoolKey({ + currency0: Currency.wrap(address(0)), + currency1: Currency.wrap(address(tokenB)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + manager.initialize(keyNative, TickMath.getSqrtPriceAtTick(30)); + tokenB.approve(address(mintRouter), type(uint256).max); + mintRouter.modifyLiquidity{value: LIQUIDITY_TOKEN_AMOUNT}( + keyNative, + ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(_fullRangeLiquidity(LIQUIDITY_TOKEN_AMOUNT, LIQUIDITY_TOKEN_AMOUNT))), salt: bytes32(0)}), + "" + ); + } + + function _fullRangeLiquidity(uint256 amount0, uint256 amount1) internal pure returns (uint128) { + uint160 sqrtA = TickMath.getSqrtPriceAtTick(TICK_LOWER); + uint160 sqrtB = TickMath.getSqrtPriceAtTick(TICK_UPPER); + return LiquidityAmounts.getLiquidityForAmounts(TickMath.getSqrtPriceAtTick(30), sqrtA, sqrtB, amount0, amount1); + } + + function _addFullRangeLiquidity(PoolKey memory key, address t0, address t1, bool native) internal { + uint128 liq = _fullRangeLiquidity(LIQUIDITY_TOKEN_AMOUNT, LIQUIDITY_TOKEN_AMOUNT); + if (native) { + mintRouter.modifyLiquidity{value: LIQUIDITY_TOKEN_AMOUNT}( + key, + ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(0)}), + "" + ); + } else { + mintRouter.modifyLiquidity( + key, + ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(0)}), + "" + ); + } + (t0, t1); + } + + function _coolAll() internal { + vm.cool(address(manager)); + vm.cool(address(swapRouter)); + vm.cool(address(mintRouter)); + vm.cool(address(multiHopRouter)); + vm.cool(address(minRouter)); + vm.cool(address(tokenA)); + vm.cool(address(tokenB)); + vm.cool(address(tokenC)); + vm.cool(address(tokenD)); + vm.cool(address(this)); + } + + function _v4Hops(PoolKey[] memory keys) internal pure returns (MultiHopRouter.Hop[] memory hops) { + hops = new MultiHopRouter.Hop[](keys.length); + for (uint256 i = 0; i < keys.length; i++) { + hops[i] = MultiHopRouter.Hop({key: keys[i], sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}); + } + } + + /// forge-config: default.isolate = true + function test_gas_single_minimal_erc20() public { + _coolAll(); + uint256 out = minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); + assertGt(out, 0); + vm.snapshotGasLastCall("v4 single exact-input minimal locker token0->token1"); + } + + /// forge-config: default.isolate = true + function test_gas_single_minimal_erc20_reverse() public { + _coolAll(); + uint256 out = minRouter.swapExactIn(keyAB, false, SWAP_AMOUNT, TickMath.MAX_SQRT_PRICE - 1, address(this)); + assertGt(out, 0); + vm.snapshotGasLastCall("v4 single exact-input minimal locker token1->token0"); + } + + /// forge-config: default.isolate = true + function test_gas_single_exactInput_erc20() public { + _coolAll(); + swapRouter.swap( + keyAB, + SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); + vm.snapshotGasLastCall("v4 single exact-input ERC20->ERC20 wide-range concentrated"); + } + + /// forge-config: default.isolate = true + function test_gas_single_exactInput_native() public { + _coolAll(); + swapRouter.swap{value: 1 ether}( + keyNative, + SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); + vm.snapshotGasLastCall("v4 single exact-input native->ERC20 wide-range concentrated"); + } + + /// forge-config: default.isolate = true + function test_gas_oneHop_exactInput() public { + PoolKey[] memory keys = new PoolKey[](1); + keys[0] = keyAB; + _coolAll(); + uint256 out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenB)), SWAP_AMOUNT); + assertGt(out, 0); + vm.snapshotGasLastCall("v4 one-hop exact-input single lock"); + } + + /// forge-config: default.isolate = true + function test_gas_twoHop_exactInput() public { + PoolKey[] memory keys = new PoolKey[](2); + keys[0] = keyAB; + keys[1] = keyBC; + _coolAll(); + uint256 out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenC)), SWAP_AMOUNT); + assertGt(out, 0); + vm.snapshotGasLastCall("v4 two-hop exact-input single lock"); + } + + /// forge-config: default.isolate = true + function test_gas_threeHop_exactInput() public { + PoolKey[] memory keys = new PoolKey[](3); + keys[0] = keyAB; + keys[1] = keyBC; + keys[2] = keyCD; + _coolAll(); + uint256 out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenD)), SWAP_AMOUNT); + assertGt(out, 0); + vm.snapshotGasLastCall("v4 three-hop exact-input single lock"); + } + + /// forge-config: default.isolate = true + function test_gas_mint_fullRange() public { + uint128 liq = _fullRangeLiquidity(LIQUIDITY_TOKEN_AMOUNT, LIQUIDITY_TOKEN_AMOUNT); + _coolAll(); + mintRouter.modifyLiquidity( + keyAB, + ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(uint256(1))}), + "" + ); + vm.snapshotGasLastCall("v4 mint wide-range position"); + } + + receive() external payable {} +} diff --git a/benchmarks/lab/v4/V4GasTest.json b/benchmarks/lab/v4/V4GasTest.json new file mode 100644 index 0000000..a621916 --- /dev/null +++ b/benchmarks/lab/v4/V4GasTest.json @@ -0,0 +1,3 @@ +{ + "v4 single exact-input minimal locker token0->token1": "120648" +} \ No newline at end of file diff --git a/benchmarks/lab/v4/foundry.toml b/benchmarks/lab/v4/foundry.toml new file mode 100644 index 0000000..e9b0a10 --- /dev/null +++ b/benchmarks/lab/v4/foundry.toml @@ -0,0 +1,11 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc = "0.8.26" +optimizer = true +optimizer_runs = 44444444 +via_ir = true +evm_version = "cancun" +verbosity = 2 +fs_permissions = [{ access = "read", path = "./" }] diff --git a/benchmarks/lab/v4/remappings.txt b/benchmarks/lab/v4/remappings.txt new file mode 100644 index 0000000..5f3d97e --- /dev/null +++ b/benchmarks/lab/v4/remappings.txt @@ -0,0 +1,6 @@ +forge-std/=lib/forge-std/src/ +solady/=lib/solady/src/ +solmate/=lib/v4-core/lib/solmate/ +v4-core/=lib/v4-core/ + +v4-src/=lib/v4-core/src/ diff --git a/public/gas/flamegraph-ekubo-single.svg b/public/gas/flamegraph-ekubo-single.svg new file mode 100644 index 0000000..3d8e76a --- /dev/null +++ b/public/gas/flamegraph-ekubo-single.svg @@ -0,0 +1,491 @@ +flamegraph EkuboGasTest::test_gas_single_exactInput_erc20 Reset ZoomSearch EkuboGasTest::_coolAll() (23,312 gas, 19.01%)EkuboGasTest::_coolAll()Core::_updatePairDebtWithNative(uint256,address,address,int256,int256) (936 gas, 0.76%)FlashAccountant::_updatePairDebt(uint256,address,address,int256,int256) (876 gas, 0.71%)FixedPointMathLib::fullMulDivN(uint256,uint256,uint8) (86 gas, 0.07%)FlashAccountant::_requireLocker() (196 gas, 0.16%)FlashAccountant::_getLocker() (142 gas, 0.12%)CoreLib::swap (20,591 gas, 16.79%)CoreLib::swapCore.swap_6269342730() (20,331 gas, 16.58%)Core.swap_6269342730()SafeCastLib::toInt128(int256) (60 gas, 0.05%)FlashAccountant::_getLocker() (142 gas, 0.12%)Core.completePayments() (2,054 gas, 1.67%)MockERC20.balanceOf(address) (525 gas, 0.43%)Core.startPayments() (5,914 gas, 4.82%)Core.s..MockERC20.balanceOf(address) (2,525 gas, 2.06%)M..FlashAccountantLib::payFrom (21,088 gas, 17.20%)FlashAccountantLib::payFromMockERC20.transferFrom(address,address,uint256) (12,602 gas, 10.28%)MockERC20.trans..FlashAccountant::_requireLocker() (196 gas, 0.16%)FlashAccountant::_getLocker() (142 gas, 0.12%)Router.swapAllowPartialFill((address,address,bytes32),bytes32) (87,384 gas, 71.26%)Router.swapAllowPartialFill((address,address,bytes32),bytes32)BaseLocker::lock(bytes) (64,241 gas, 52.38%)BaseLocker::lock(bytes)Core.lock() (61,421 gas, 50.09%)Core.lock()Router.locked_6416899205(uint256) (60,203 gas, 49.09%)Router.locked_6416899205(uint256)FlashAccountantLib::withdraw (16,592 gas, 13.53%)FlashAccountantLib::..Core.0x3ccfd60b (16,351 gas, 13.33%)Core.0x3ccfd60bMockERC20.transfer(address,uint256) (12,452 gas, 10.15%)MockERC20.trans..all (122,633 gas, 100%)EkuboGasTest.test_gas_single_exactInput_erc20() (122,633 gas, 100.00%)EkuboGasTest.test_gas_single_exactInput_erc20() \ No newline at end of file diff --git a/public/gas/flamegraph-v3-single.svg b/public/gas/flamegraph-v3-single.svg new file mode 100644 index 0000000..968ea2a --- /dev/null +++ b/public/gas/flamegraph-v3-single.svg @@ -0,0 +1,491 @@ +flamegraph V3GasTest::test_gas_single_exactInput_erc20 Reset ZoomSearch V3GasTest::_coolAll() (25,861 gas, 18.98%)V3GasTest::_coolAll()token0() (266 gas, 0.20%)V3GasTest.uniswapV3SwapCallback(int256,int256,bytes) (12,441 gas, 9.13%)V3GasTest.uni..transfer(address,uint256) (10,452 gas, 7.67%)transfer(ad..balanceOf(address) (3,050 gas, 2.24%)b..all (136,223 gas, 100%)V3GasTest.test_gas_single_exactInput_erc20() (136,223 gas, 100.00%)V3GasTest.test_gas_single_exactInput_erc20()swap(address,bool,int256,uint160,bytes) (104,653 gas, 76.82%)swap(address,bool,int256,uint160,bytes)transfer(address,uint256) (12,452 gas, 9.14%)transfer(addr.. \ No newline at end of file diff --git a/public/gas/flamegraph-v4-single.svg b/public/gas/flamegraph-v4-single.svg new file mode 100644 index 0000000..26408d9 --- /dev/null +++ b/public/gas/flamegraph-v4-single.svg @@ -0,0 +1,491 @@ +flamegraph V4GasTest::test_gas_single_minimal_erc20 Reset ZoomSearch MockERC20.transferFrom(address,address,uint256) (12,602 gas, 7.95%)MockERC20.t..CurrencyLibrary::balanceOfSelf (934 gas, 0.59%)MockERC20.balanceOf(address) (525 gas, 0.33%)PoolManager::_accountDelta (689 gas, 0.43%)PoolManager.settle() (2,499 gas, 1.58%)SafeCast::toInt128(uint256) (53 gas, 0.03%)Hooks::afterSwap (223 gas, 0.14%)Hooks::beforeSwap (112 gas, 0.07%)NoDelegateCall::checkNotDelegateCall() (44 gas, 0.03%)Pool::checkPoolInitialized (2,139 gas, 1.35%)FullMath::mulDiv(uint256,uint256,uint256) (146 gas, 0.09%)SafeCast::toInt128(int256) (106 gas, 0.07%)SqrtPriceMath::getAmount0Delta(uint160,uint160,uint128,bool) (672 gas, 0.42%)FullMath::mulDivRoundingUp(uint256,uint256,uint256) (506 gas, 0.32%)FullMath::mulDiv(uint256,uint256,uint256) (389 gas, 0.25%)SqrtPriceMath::getAmount1Delta(uint160,uint160,uint128,bool) (228 gas, 0.14%)SqrtPriceMath::getNextSqrtPriceFromAmount0RoundingUp(uint160,uint128,uint256,bool) (712 gas, 0.45%)FullMath::mulDivRoundingUp(uint256,uint256,uint256) (506 gas, 0.32%)FullMath::mulDiv(uint256,uint256,uint256) (389 gas, 0.25%)TickMath::getSqrtPriceAtTick(int24) (571 gas, 0.36%)BitMath::mostSignificantBit(uint256) (180 gas, 0.11%)Pool::swap (36,388 gas, 22.94%)Pool::swapTickMath::getTickAtSqrtPrice(uint160) (1,829 gas, 1.15%)TickMath::getSqrtPriceAtTick(int24) (719 gas, 0.45%)PoolManager.swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes) (45,984 gas, 28.99%)PoolManager.swap((address,address,uint24,int24,..PoolManager::_accountPoolBalanceDelta (1,504 gas, 0.95%)PoolManager::_accountDelta (1,408 gas, 0.89%)PoolManager.sync(address) (6,398 gas, 4.03%)Pool..CurrencyLibrary::balanceOfSelf (5,434 gas, 3.43%)Cur..MockERC20.balanceOf(address) (2,525 gas, 1.59%)CurrencyLibrary::transfer (15,292 gas, 9.64%)CurrencyLibrar..MockERC20.transfer(address,uint256) (12,452 gas, 7.85%)MockERC20.t..PoolManager::_accountDelta (689 gas, 0.43%)PoolManager.take(address,address,uint256) (16,525 gas, 10.42%)PoolManager.tak..SafeCast::toInt128(uint256) (53 gas, 0.03%)MinimalSwapRouter.swapExactIn((address,address,uint24,int24,address),bool,int256,uint160,address) (120,648 gas, 76.07%)MinimalSwapRouter.swapExactIn((address,address,uint24,int24,address),bool,int256,uint160,address)PoolManager.unlock(bytes) (92,807 gas, 58.51%)PoolManager.unlock(bytes)MinimalSwapRouter.unlockCallback(bytes) (90,797 gas, 57.25%)MinimalSwapRouter.unlockCallback(bytes)TransientStateLibrary::currencyDelta (2,626 gas, 1.66%)PoolManager.exttload(bytes32) (1,718 gas, 1.08%)StdAssertions::assertGt(uint256,uint256) (38 gas, 0.02%)V4GasTest::_coolAll() (25,861 gas, 16.31%)V4GasTest::_coolAll()all (158,606 gas, 100%)V4GasTest.test_gas_single_minimal_erc20() (158,606 gas, 100.00%)V4GasTest.test_gas_single_minimal_erc20() \ No newline at end of file diff --git a/src/content/docs/about-ekubo/features.md b/src/content/docs/about-ekubo/features.md index c92ee14..4944a42 100644 --- a/src/content/docs/about-ekubo/features.md +++ b/src/content/docs/about-ekubo/features.md @@ -11,7 +11,7 @@ The core contracts are ownerless and permissionless. They are deployed to the sa Ekubo uses the ["till" pattern](/concepts/architecture/) and a singleton design to provide the cheapest trades across many pools. All pools live in a single contract, and token transfers are deferred until the end of the transaction. Advanced users don't have to transfer tokens at all: balances can be saved inside Ekubo for later use, avoiding repeated token transfers entirely. -The result is that you can execute many actions across many pools while making only the minimum number of token transfers. Combined with contracts optimized down to the storage-slot and calldata level (see [Price representation](/reference/price-representation/)), this keeps the gas cost of a trade low, which matters most for routes that touch several pools. +The result is that you can execute many actions across many pools while making only the minimum number of token transfers. Combined with contracts optimized down to the storage-slot and calldata level (see [Price representation](/reference/price-representation/)), this keeps the gas cost of a trade low, which matters most for routes that touch several pools. Measured numbers against Uniswap v4 and v3 are on the [Gas efficiency](/concepts/gas-efficiency/) page. ## Concentrated liquidity diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md new file mode 100644 index 0000000..07cd218 --- /dev/null +++ b/src/content/docs/concepts/gas-efficiency.md @@ -0,0 +1,167 @@ +--- +description: Measured gas costs for Ekubo swaps against Uniswap v4 and v3, with like-for-like benchmarks, scaling analysis, and mainnet-fork validation +title: "Gas efficiency" +--- + +Ekubo is designed to make trading cheap: one singleton contract holds every pool, +[flash accounting](/concepts/key-concepts/) settles only net differences, and pool +state packs into a single storage word. This page backs that design claim with +measured numbers — full methodology, harness sources, and flamegraphs included — +comparing Ekubo on EVM against Uniswap v4 and Uniswap v3 on identical workloads. + +All figures are execution gas for a 0.3% concentrated-liquidity pool with a +wide-range position and no extension, measured with Foundry snapshots where each +test runs isolated with every contract cooled, so each number is a cold-access +transaction. Percentages beside the tables add the 21,000 base fee plus exact +calldata cost, i.e. full transaction gas (the post-Pectra calldata floor was +checked and does not bind at these sizes). + +## Single swaps + +One token of input, exact-input, no tick crossings, 18-decimal plain ERC20 tokens +(the identical token contract runs in every harness): + +| Swap | Ekubo | Uniswap v4 | Uniswap v3 | +| --------------------------------- | ---------- | ---------- | -------------------------- | +| ERC20 to ERC20 | **87,384** | 120,648 | 104,653 | +| ERC20 to ERC20, reverse direction | **86,334** | 120,473 | 106,848 | +| Native ETH to ERC20 | **72,560** | 116,053 | — (v3 needs WETH wrapping) | + +At the transaction level (base fee plus calldata included), the ERC20 swap costs +about **110k on Ekubo, 144k on v4, and 127k on v3** — roughly 24% less than v4 +and 14% less than v3 for a single hop. The v4 figure uses a true-minimal locker +(unlock, swap, settle net); v4's own test helper costs more, and the production +Universal Router costs more still. The v3 figure is a direct pool call with zero +router overhead, so it is a lower bound; the production SwapRouter adds on top. + +## How the gap scales with route length + +Routing through one, two, and three pools in a single transaction, each protocol +using the same minimal single-lock router throughout, so the per-hop marginal is +apples-to-apples: + +| Route length | Ekubo | Uniswap v4 | Uniswap v3 | +| -------------------------------- | ----------- | ----------- | ----------- | +| 1 pool | 94,860 | 141,560 | 129,810 | +| 2 pools | 119,269 | 193,071 | 206,120 | +| 3 pools | 143,678 | 244,582 | 282,429 | +| **Marginal cost per extra pool** | **~24,400** | **~51,500** | **~76,300** | + +The marginals are linear to the gas unit because each added hop hits an +identically shaped fresh pool with its own distinct token contracts. At three +pools Ekubo costs about 41% less than v4 and 49% less than v3 in execution +gas — the gap widens with every pool because each extra Ekubo hop is little +more than one pool-math call (see below). + +Token transfers tell part of the story. Ekubo and v4 both settle net inside one +lock, so a route of any length moves exactly 2 token transfers: + +| Transfers per route | Ekubo | Uniswap v4 | Uniswap v3 | +| ------------------- | --------- | ---------- | ---------- | +| 1 / 2 / 3 pools | 2 / 2 / 2 | 2 / 2 / 2 | 2 / 4 / 6 | + +V3 pays two transfers per pool (callback payment plus output) because every pool +is a separate contract that must be paid individually. But transfers alone do not +explain the v4 gap — v4 nets transfers exactly like Ekubo. The rest of the +difference is inside the pool core. + +## Where the savings come from + +Frame-level attribution of the single-hop swap above, from Foundry flamegraphs +(total height is total gas; each frame shows its own cost): + +![Flamegraph of an Ekubo single-hop swap](/gas/flamegraph-ekubo-single.svg) +_Flamegraph: Ekubo single-hop swap, 87,384 gas._ + +![Flamegraph of a Uniswap v4 single-hop swap](/gas/flamegraph-v4-single.svg) +_Flamegraph: Uniswap v4 single-hop swap via a minimal locker, 120,648 gas._ + +![Flamegraph of a Uniswap v3 single-hop swap](/gas/flamegraph-v3-single.svg) +_Flamegraph: Uniswap v3 single-hop swap via direct pool call, 104,653 gas._ + +| Component | Ekubo (87,384) | Uniswap v4 (120,648) | Uniswap v3 (104,653) | +| -------------------------------------- | --------------------------- | -------------------------------------------------------------- | --------------------------------- | +| Pool math and state | Core swap: **20,331** (23%) | PoolManager swap: **45,984** (38%), of which pool logic 36,388 | Pool internals: **~76,700** (73%) | +| Settlement (transfers plus accounting) | ~45,600 | ~40,600 | ~38,400 | +| Router and lock overhead | ~26,000 | ~28,000 | 0 (direct call) | + +Three observations: + +- **The pool core is where Ekubo wins.** One packed storage word and Q64 fixed-point + math cost about 20k per swap, against 46k for v4's multi-slot state with + protocol-fee and donation bookkeeping, and about 77k for v3's slot0, fee-growth, + and per-swap oracle writes. +- **Settlement is rough parity.** All three move two tokens for a single hop at a + similar cost; Ekubo's till pattern only pulls ahead as routes grow (see the + transfer table above). +- **The Solidity Router's ABI round-trip (~26k) is Ekubo's largest single chunk.** + These numbers use the Solidity Router as the conservative path; production + traffic goes through the gas-optimized Yul router. + +## Mainnet-fork validation + +Lab harnesses control everything, which invites the question of whether the +ranking survives production bytecode, real tokens, and real liquidity. As a +check, the same 1-WETH-to-USDC swap was run on a mainnet fork (block 25991868, +all addresses verified on-chain): + +| Swap on mainnet fork | Ekubo | Uniswap v4 | Uniswap v3 | +| ----------------------------- | ---------- | ---------- | ---------- | +| 1 WETH to USDC, execution gas | **98,066** | 175,461 | 142,287 | + +That is 31% less than v3 and 44% less than v4, wider than the lab single-hop +gaps. Part of the widening is expected: the lab uses lightweight mock tokens +while the fork pays real-token costs (USDC is a proxy), real concentrated ticks, +and production routers. Two asymmetries to read alongside it: the Uniswap legs +use real concentrated pools — the v4 leg crossed about twelve initialized ticks, +which is genuinely part of trading on real liquidity — while the Ekubo leg uses +a freshly capitalized concentrated pool of the same shape as the lab tests. And +the v4 leg uses a minimal locker rather than the Universal Router, which would +cost more, while the v3 leg uses the real production SwapRouter. The fork confirms the +direction and rough magnitude of the lab result under production conditions; the +lab tables above are the controlled comparison. + +As a version check, the fork swap was also run against a freshly deployed +worktree Core on the same fork state: byte-identical gas (98,066), so nothing +about the deployed Core vintage affects the comparison. + +## The mint exception + +Providing liquidity is the one measured operation where Ekubo is not the +cheapest. Minting a wide-range position, same tier of caller on each protocol +(direct-to-core, no NFT, no position manager): + +| Mint path | Ekubo | Uniswap v4 | Uniswap v3 | +| -------------------------- | --------------------- | ---------- | ----------- | +| Bare / direct to core | 179,020 | 162,188 | **121,430** | +| User-facing (NFT position) | 213,104 via Positions | — | — | + +Ekubo's Positions NFT adds about 34k over the bare core path for minting, token +metadata, and protocol-fee accounting; production position managers add overhead +on all three protocols, and no v4/v3 manager path was measured here, so the NFT +row is harness-only context rather than a cross-protocol verdict. Cheap swaps +and relatively expensive mints are two sides of the same design: Core is +optimized for the operation that happens orders of magnitude more often. + +## Methodology and limitations + +- Each protocol compiles under its own production settings (Ekubo: solc 0.8.33, + 9999999 optimizer runs, via IR, Osaka; v4: v4-core's own settings; v3: + canonical mainnet bytecode), with Foundry 1.8.3 throughout. +- Routers are deliberately minimal on all sides, which favors Uniswap: + production routers (Universal Router, SwapRouter, and Ekubo's own frontend + path) all cost more than the numbers above. +- Scope is no-crossing swaps on hookless/extensionless pools: no Uniswap v4 + hooks, no Ekubo extensions, both swap directions covered, concentrated pools + throughout (an early full-range-pool variant measured within 5k of these + figures and was dropped for exactness). Every mint measured is a subsequent + mint into a pool whose boundary ticks are already initialized, on all three + protocols. +- v3 per-hop figures exclude end-of-transaction refunds (about 4.8k per hop from + clearing intermediate balances), so the true v3 marginal is closer to 71k + than 76k — the ranking is unaffected. +- Any v4 hook or Ekubo extension changes every number on this page; pool + configuration (fee, tick spacing) barely moves them. + +The complete harnesses, raw snapshots, and reproduction commands live in +`benchmarks/` alongside these docs. From 130a0bce0e4a5d43ac17d6c50c0ae23792e5b7d3 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Wed, 16 Sep 2026 18:58:19 -0400 Subject: [PATCH 02/17] Rework gas benchmarks: warmed steady-state, real-pool fork legs Lab now measures warmed steady-state (identical unmeasured warm-up before each snapshot) after finding cold-access overstated v4/v3 single-hop costs by ~17k while Ekubo is warmth-invariant. Mint legs use economically matched liquidity (v3 depth corrected 1000x). Fixed a test-router bug that stranded output in the v4 multihop router instead of delivering to the user. Fork legs now share one pair (1000 USDT->USDC), warmed, negligible tick movement: live Ekubo pool via the production Yul router with SDK calldata, deepest v3/v4 fee-100 pools via SwapRouter/minimal locker. USDT quirks handled with low-level calls like production routers. --- benchmarks/README.md | 67 ++++++--- benchmarks/fork/ForkEkubo.t.sol | 92 ------------ benchmarks/fork/ForkEkuboReal.t.sol | 57 ++++++++ benchmarks/fork/ForkEkuboRealTest.json | 3 + benchmarks/fork/ForkEkuboTest.json | 4 - benchmarks/fork/ForkProbe.t.sol | 21 +++ benchmarks/fork/ForkV3.t.sol | 38 +++-- benchmarks/fork/ForkV3Test.json | 2 +- benchmarks/fork/ForkV4.t.sol | 48 +++--- benchmarks/fork/ForkV4Test.json | 2 +- benchmarks/fork/encode-yul-route.mjs | 19 +++ benchmarks/lab/ekubo/EkuboGas.t.sol | 38 ++--- benchmarks/lab/ekubo/EkuboGasTest.json | 2 +- benchmarks/lab/ekubo/Flame.t.sol | 13 ++ benchmarks/lab/v3/Flame.t.sol | 18 +++ benchmarks/lab/v3/V3Gas.t.sol | 35 ++--- benchmarks/lab/v3/V3GasTest.json | 2 +- benchmarks/lab/v4/Flame.t.sol | 21 +++ benchmarks/lab/v4/V4Gas.t.sol | 48 +++--- benchmarks/lab/v4/V4GasTest.json | 9 +- public/gas/flamegraph-ekubo-single.svg | 2 +- public/gas/flamegraph-v3-single.svg | 2 +- public/gas/flamegraph-v4-single.svg | 2 +- src/content/docs/concepts/gas-efficiency.md | 154 ++++++++++---------- 24 files changed, 403 insertions(+), 296 deletions(-) delete mode 100644 benchmarks/fork/ForkEkubo.t.sol create mode 100644 benchmarks/fork/ForkEkuboReal.t.sol create mode 100644 benchmarks/fork/ForkEkuboRealTest.json delete mode 100644 benchmarks/fork/ForkEkuboTest.json create mode 100644 benchmarks/fork/encode-yul-route.mjs create mode 100644 benchmarks/lab/ekubo/Flame.t.sol create mode 100644 benchmarks/lab/v3/Flame.t.sol create mode 100644 benchmarks/lab/v4/Flame.t.sol diff --git a/benchmarks/README.md b/benchmarks/README.md index e4eaa46..0a48483 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -6,43 +6,71 @@ measures code and architecture rather than compiler tuning. ## Layout -- `lab/ekubo/` — Ekubo EVM swaps and mints (`EkuboGas.t.sol`) -- `lab/v4/` — Uniswap v4 swaps and mints (`V4Gas.t.sol`) -- `lab/v3/` — Uniswap v3 swaps and mints (`V3Gas.t.sol`) +- `lab/ekubo/` — Ekubo EVM swaps and mints (`EkuboGas.t.sol`, plus `Flame.t.sol` + for single-call flamegraph captures) +- `lab/v4/` — Uniswap v4 swaps and mints (`V4Gas.t.sol`, `Flame.t.sol`) +- `lab/v3/` — Uniswap v3 swaps and mints (`V3Gas.t.sol`, `Flame.t.sol`) - `lab/MockERC20.json` — the single shared token artifact deployed via `deployCode` in all three lab harnesses, so ERC20 transfer costs cannot skew the comparison - `fork/` — mainnet-fork validation at pinned block `25991868` - (`ForkEkubo.t.sol`, `ForkV3.t.sol`, `ForkV4.t.sol`, plus the pool probe) + (`ForkEkuboReal.t.sol`, `ForkV3.t.sol`, `ForkV4.t.sol`, plus the pool probe) `remappings.txt` files contain the original absolute paths and will need adjusting; the dependency pins below are what matter for reproduction. ## Lab methodology (all three harnesses) -- Full-range-equivalent positions (concentrated pools, range covering the whole - tick space) at a 0.3% fee, so no swap crosses an initialized tick. +Steady state, warmed pools: each test runs on a fresh chain (`isolate`), then +performs one identical unmeasured warm-up swap before the measured call, so +pools, tokens, and approvals sit in warmed nonzero slots. (An earlier +cold-access variant overstated v4/v3 single-hop costs by ~17k because their pool +state is cold-sensitive; Ekubo's is nearly warmth-invariant, verified by +identical totals with and without warm-up and with unrelated preceding +operations.) + +- Concentrated pools, 0.3% fee, range covering the whole tick space, so no swap + crosses an initialized tick. - Pools initialized strictly inside a tick (Ekubo tick 3050, Uniswap tick 30), never on a tick-spacing multiple. -- 1,000,000-token liquidity per side, 1-token exact-input swaps, both directions. -- `forge-config: default.isolate = true` plus `vm.cool(...)` on every contract, - so each measurement is a cold-access transaction. -- Gas read with `vm.snapshotGasLastCall`; `*.json` files are the raw snapshots. +- Economically matched liquidity: 1M tokens a side on Ekubo and v4; v3 mints + the equivalent liquidity (998501199320305883812938) computed for the same + amounts. +- 1-token exact-input swaps, both directions; multihop routers deliver output + to the calling user (never stranded in the router). +- Gas read with `vm.snapshotGasLastCall` immediately after the measured call; + `*.json` files are the raw snapshots. Scaling series (1/2/3 hops) use the same minimal single-lock router per protocol, -so the per-hop marginal is apples-to-apples. +so the per-hop marginal is apples-to-apples. Mint tests are subsequent mints +with boundary ticks already initialized. + +## Fork methodology + +Same pair (USDT to USDC), same size (1000 USDT), warmed with one identical +unmeasured swap, negligible tick movement (0–1 ticks), pinned block `25991868`: + +- Ekubo: the live USDC/USDT concentrated pool (spacing 50, core-only) through + the deployed production Yul router, with SDK-generated calldata + (see `fork/encode-yul-route.mjs`). +- v3: the deepest fee-100 pool through the deployed SwapRouter. +- v4: the deepest fee-100 pool through the deployed PoolManager with a minimal + locker (the Universal Router would cost more). + +USDT returns no returndata, so approvals and v4 settlement use low-level calls, +mirroring production routers. ## Dependency pins -- Ekubo `evm-contracts`: branch `gas-benchmarks` worktree at commit `1f5be49` - (plus the harness test file in this directory); `forge-std` `77041d2`, - `solady` `65e87c7`; solc `0.8.33`, `optimizer_runs = 9999999`, `via_ir`, - `evm_version = "osaka"`. +- Ekubo `evm-contracts` worktree at commit `1f5be49` (plus the harness test files + in this directory); `forge-std` `77041d2`, `solady` `65e87c7`; solc `0.8.33`, + `optimizer_runs = 9999999`, `via_ir`, `evm_version = "osaka"`. - Uniswap `v4-core` commit `46c6834`; solc `0.8.26`, `optimizer_runs = 44444444`, `via_ir`, `evm_version = "cancun"` (v4-core's own `foundry.toml`). - Uniswap `v3-core` `1.0.1` canonical mainnet bytecode (`@uniswap/v3-core` npm artifacts, `UniswapV3Factory.json`). - Shared token: solmate `MockERC20` bytecode in `lab/MockERC20.json`. +- Yul router SDK (`ekubo/yul-router`) for the fork calldata generation. - Foundry `1.8.3`. ## Reproducing the lab numbers @@ -65,7 +93,7 @@ forge snapshot --match-contract V3GasTest ```sh forge test --fork-url --fork-block-number 25991868 \ - --match-contract 'ForkV3Test|ForkV4Test|ForkEkuboTest' + --match-contract 'ForkEkuboRealTest|ForkV3Test|ForkV4Test' ``` Deployed addresses used (all verified on-chain, none memorized): @@ -73,11 +101,14 @@ Deployed addresses used (all verified on-chain, none memorized): | Contract | Address | | ------------------------------- | -------------------------------------------- | | Ekubo Core | `0x00000000000014aA86C5d3c41765bb24e11bd701` | +| Ekubo Yul router (production) | `0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748` | | Uniswap v4 PoolManager | `0x000000000004444c5dc75cB358380D2e3dE08A90` | | Uniswap v3 SwapRouter (classic) | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | | Uniswap v3 factory | `0x1F98431c8aD98523631AE4a59f267346ea31F984` | | WETH | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | | USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | +| USDT | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | -The v4 fork pool (WETH/USDC, fee 500) was selected by probing candidate fee -tiers for the deepest pool at the pinned block (see `ForkProbe.t.sol`). +The v3/v4 fork pools (USDT/USDC fee-100, deepest at the pinned block) were +selected by on-chain probing (see `ForkProbe.t.sol`); the Ekubo pool came from +the production quoter. diff --git a/benchmarks/fork/ForkEkubo.t.sol b/benchmarks/fork/ForkEkubo.t.sol deleted file mode 100644 index 7c67a20..0000000 --- a/benchmarks/fork/ForkEkubo.t.sol +++ /dev/null @@ -1,92 +0,0 @@ -// SPDX-License-Identifier: ekubo-license-v1.eth -pragma solidity =0.8.33; - -import {Test} from "forge-std/Test.sol"; -import {ICore} from "ekubo/src/interfaces/ICore.sol"; -import {Core} from "ekubo/src/Core.sol"; -import {Positions} from "ekubo/src/Positions.sol"; -import {Router} from "ekubo/src/Router.sol"; -import {PoolKey} from "ekubo/src/types/poolKey.sol"; -import {createConcentratedPoolConfig} from "ekubo/src/types/poolConfig.sol"; -import {SqrtRatio} from "ekubo/src/types/sqrtRatio.sol"; -import {Core} from "ekubo/src/Core.sol"; -import {createSwapParameters} from "ekubo/src/types/swapParameters.sol"; -import {PoolBalanceUpdate} from "ekubo/src/types/poolBalanceUpdate.sol"; - -interface IERC20Like { - function approve(address spender, uint256 amount) external returns (bool); - function balanceOf(address account) external view returns (uint256); -} - -/// @notice Mainnet-fork validation: REAL Core + REAL WETH/USDC + fresh full-range -/// 0.3% pool capitalized on-fork; Router/Positions use production bytecode. -/// Run with: --fork-url --fork-block-number -contract ForkEkuboTest is Test { - ICore constant CORE = ICore(payable(0x00000000000014aA86C5d3c41765bb24e11bd701)); - address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; - address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - - uint64 constant FEE_0_3 = 55340232221128655; - // Same concentrated shape as the lab harness: spacing 6000, wide range. - // Initialized at the raw WETH/USDC price (~4000 USDC per WETH, 6 vs 18 - // decimals) so the capitalized amounts are value-balanced and the measured - // swap stays in range without crossing initialized ticks. - uint32 constant TICK_SPACING = 6000; - int32 constant RANGE_LOWER = -88722000; - int32 constant RANGE_UPPER = 88722000; - int32 constant INIT_TICK = 19337510; // raw WETH/USDC price (~4000 USDC per WETH - // with 6 vs 18 decimals); Ekubo ticks rise with the token0/token1 raw price - - function test_fork_single_exactInput_weth() public { - Positions positions = new Positions(CORE, address(this), 0, 1); - Router router = new Router(CORE, address(0), address(0)); - - // USDC < WETH, so token0 = USDC, token1 = WETH - PoolKey memory key = PoolKey({ - token0: USDC, - token1: WETH, - config: createConcentratedPoolConfig(FEE_0_3, TICK_SPACING, address(0)) - }); - CORE.initializePool(key, INIT_TICK); - - deal(WETH, address(this), 2000 ether); - deal(USDC, address(this), 8_000_000e6); - IERC20Like(WETH).approve(address(positions), type(uint256).max); - IERC20Like(USDC).approve(address(positions), type(uint256).max); - IERC20Like(WETH).approve(address(router), type(uint256).max); - positions.mintAndDeposit(key, RANGE_LOWER, RANGE_UPPER, 4_000_000e6, 1000 ether, 0); - - uint256 usdcBefore = IERC20Like(USDC).balanceOf(address(this)); - PoolBalanceUpdate update = - router.swapAllowPartialFill(key, createSwapParameters(SqrtRatio.wrap(0), 1 ether, true, 0)); - assertEq(update.delta1(), 1 ether); - assertGt(-update.delta0(), 0); - vm.snapshotGasLastCall("fork ekubo Router 1 WETH->USDC concentrated 0.3%"); - assertGt(IERC20Like(USDC).balanceOf(address(this)), usdcBefore); - } - - function test_fork_single_exactInput_freshCore() public { - // Control: worktree Core (same bytecode as the lab harness) on fork state. - Core fresh = new Core(); - Positions positions = new Positions(fresh, address(this), 0, 1); - Router router = new Router(fresh, address(0), address(0)); - PoolKey memory key = PoolKey({ - token0: USDC, - token1: WETH, - config: createConcentratedPoolConfig(FEE_0_3, TICK_SPACING, address(0)) - }); - fresh.initializePool(key, INIT_TICK); - deal(WETH, address(this), 2000 ether); - deal(USDC, address(this), 8_000_000e6); - IERC20Like(WETH).approve(address(positions), type(uint256).max); - IERC20Like(USDC).approve(address(positions), type(uint256).max); - IERC20Like(WETH).approve(address(router), type(uint256).max); - positions.mintAndDeposit(key, RANGE_LOWER, RANGE_UPPER, 4_000_000e6, 1000 ether, 0); - PoolBalanceUpdate update = - router.swapAllowPartialFill(key, createSwapParameters(SqrtRatio.wrap(0), 1 ether, true, 0)); - assertEq(update.delta1(), 1 ether); - vm.snapshotGasLastCall("fork ekubo fresh-Core 1 WETH->USDC concentrated 0.3%"); - } - - receive() external payable {} -} diff --git a/benchmarks/fork/ForkEkuboReal.t.sol b/benchmarks/fork/ForkEkuboReal.t.sol new file mode 100644 index 0000000..d0ba008 --- /dev/null +++ b/benchmarks/fork/ForkEkuboReal.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test} from "forge-std/Test.sol"; + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +/// @notice Mainnet-fork validation against a REAL live Ekubo pool (USDC/USDT, +/// concentrated, spacing 50, core-only extension) through the REAL +/// production Yul router, with SDK-generated calldata (see below). +/// One unmeasured warm-up swap precedes the measured swap (steady state), +/// and the size (1000 USDT) matches the production quoter. +/// +/// Calldata generated with the yul-router SDK: +/// bun scripts/encode-once.mjs (encodeRoute, specifiedToken USDT, +/// calculatedToken USDC, specifiedAmount 1000000000, +/// calculatedAmountThreshold 990000000, recipient = test contract, +/// single core hop on the pool key below) +/// Run with: --fork-url --fork-block-number +contract ForkEkuboRealTest is Test { + address constant YUL_ROUTER = 0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + // Pool: token0 USDC, token1 USDT, concentrated spacing 50, no extension. + // Config: 0x0000000000000000000000000000000000000000000053e2d6238da480000032 + bytes constant ROUTE = hex"0100dAC17F958D2ee523a2206206994597C13D831ec7A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480000000000000000000000003b0233807FA9385bE102ac3EAc297483Dd6233D62b3e14960000000000000000000000003b9aca000000A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48dAC17F958D2ee523a2206206994597C13D831ec70000000000000000000000000000000000000000000053e2d6238da48000003200000000000000000000000000000000"; + + function test_fork_realPool_yulRouter() public { + deal(USDT, address(this), 1000000000000); + // USDT returns no returndata: approve via low-level call, not a + // bool-returning interface (which would revert on decoding). + (bool approved,) = USDT.call( + abi.encodeWithSignature("approve(address,uint256)", YUL_ROUTER, type(uint256).max) + ); + assertTrue(approved, "usdt approve"); + + // warm-up: steady state, unmeasured + (bool okWarm,) = YUL_ROUTER.call(ROUTE); + assertTrue(okWarm, "warm-up swap"); + + // measured leg + uint256 usdcBefore = IERC20Like(USDC).balanceOf(address(this)); + (bool ok, bytes memory ret) = YUL_ROUTER.call(ROUTE); + assertTrue(ok, "measured swap"); + vm.snapshotGasLastCall("fork ekubo Yul router live USDC/USDT pool"); + (, , , int256 calculated) = abi.decode(ret, (address, address, int256, int256)); + assertGt(calculated, 0); + // production quoter quoted ~999.36 USDC for this size; stay within 1% + uint256 out = IERC20Like(USDC).balanceOf(address(this)) - usdcBefore; + assertGe(out, 989000000); + assertLe(out, 1009000000); + } +} diff --git a/benchmarks/fork/ForkEkuboRealTest.json b/benchmarks/fork/ForkEkuboRealTest.json new file mode 100644 index 0000000..11cefa2 --- /dev/null +++ b/benchmarks/fork/ForkEkuboRealTest.json @@ -0,0 +1,3 @@ +{ + "fork ekubo Yul router live USDC/USDT pool": "110622" +} \ No newline at end of file diff --git a/benchmarks/fork/ForkEkuboTest.json b/benchmarks/fork/ForkEkuboTest.json deleted file mode 100644 index b18a91c..0000000 --- a/benchmarks/fork/ForkEkuboTest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "fork ekubo Router 1 WETH->USDC concentrated 0.3%": "98066", - "fork ekubo fresh-Core 1 WETH->USDC concentrated 0.3%": "98066" -} \ No newline at end of file diff --git a/benchmarks/fork/ForkProbe.t.sol b/benchmarks/fork/ForkProbe.t.sol index 3c178e4..abd1403 100644 --- a/benchmarks/fork/ForkProbe.t.sol +++ b/benchmarks/fork/ForkProbe.t.sol @@ -17,6 +17,27 @@ contract ForkProbeTest is Test { IPoolManager constant MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90); address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + + function test_probe_usdt_usdc_pools() public view { + uint24[3] memory fees = [uint24(100), uint24(500), uint24(3000)]; + int24[3] memory spacings = [int24(1), int24(10), int24(60)]; + for (uint256 i = 0; i < 3; i++) { + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(USDC), + currency1: Currency.wrap(USDT), + fee: fees[i], + tickSpacing: spacings[i], + hooks: IHooks(address(0)) + }); + PoolId id = key.toId(); + uint128 liq = MANAGER.getLiquidity(id); + (, int24 tick,,) = MANAGER.getSlot0(id); + console2.log("fee", fees[i]); + console2.log("liquidity", liq); + console2.log("tick", uint256(int256(tick))); + } + } function test_probe_weth_usdc_pools() public view { uint24[4] memory fees = [uint24(100), uint24(500), uint24(3000), uint24(10000)]; diff --git a/benchmarks/fork/ForkV3.t.sol b/benchmarks/fork/ForkV3.t.sol index 38aa6da..234e665 100644 --- a/benchmarks/fork/ForkV3.t.sol +++ b/benchmarks/fork/ForkV3.t.sol @@ -18,34 +18,42 @@ interface ISwapRouter { function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); } -interface IERC20Like { - function approve(address spender, uint256 amount) external returns (bool); - function balanceOf(address account) external view returns (uint256); -} - -/// @notice Mainnet-fork validation: REAL SwapRouter + REAL WETH/USDC 0.3% pool. +/// @notice Mainnet-fork validation: REAL SwapRouter + REAL USDT/USDC fee-100 pool +/// (deepest at the pinned block), 1000 USDT exact input, warmed with one +/// identical unmeasured swap (steady state). +/// USDT returns no returndata: approve via low-level call. /// Run with: --fork-url --fork-block-number contract ForkV3Test is Test { ISwapRouter constant ROUTER = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); - address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - function test_fork_single_exactInput_weth() public { - deal(WETH, address(this), 10 ether); - IERC20Like(WETH).approve(address(ROUTER), type(uint256).max); - uint256 out = ROUTER.exactInputSingle( + function _swap() internal returns (uint256) { + return ROUTER.exactInputSingle( ISwapRouter.ExactInputSingleParams({ - tokenIn: WETH, + tokenIn: USDT, tokenOut: USDC, - fee: 3000, + fee: 100, recipient: address(this), deadline: block.timestamp, - amountIn: 1 ether, + amountIn: 1000000000, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }) ); + } + + function test_fork_single_exactInput_usdt() public { + deal(USDT, address(this), 1000000000000); + (bool approved,) = + USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(ROUTER), type(uint256).max)); + assertTrue(approved, "usdt approve"); + _swap(); // warm-up, unmeasured + uint256 out = _swap(); // measured assertGt(out, 0); - vm.snapshotGasLastCall("fork v3 SwapRouter 1 WETH->USDC 0.3%"); + vm.snapshotGasLastCall("fork v3 SwapRouter 1000 USDT->USDC fee-100"); + // sanity: stable pair near parity (1000 USDT in, ~1000 USDC out) + assertGe(out, 990000000); + assertLe(out, 1005000000); } } diff --git a/benchmarks/fork/ForkV3Test.json b/benchmarks/fork/ForkV3Test.json index 7c1270e..6d93979 100644 --- a/benchmarks/fork/ForkV3Test.json +++ b/benchmarks/fork/ForkV3Test.json @@ -1,3 +1,3 @@ { - "fork v3 SwapRouter 1 WETH->USDC 0.3%": "142287" + "fork v3 SwapRouter 1000 USDT->USDC fee-100": "128611" } \ No newline at end of file diff --git a/benchmarks/fork/ForkV4.t.sol b/benchmarks/fork/ForkV4.t.sol index 2381e34..9773ea9 100644 --- a/benchmarks/fork/ForkV4.t.sol +++ b/benchmarks/fork/ForkV4.t.sol @@ -43,39 +43,51 @@ contract ForkSwapRouter is IUnlockCallback { Currency currencyOut = zeroForOne ? key.currency1 : key.currency0; int256 inDelta = manager.currencyDelta(address(this), currencyIn); int256 outDelta = manager.currencyDelta(address(this), currencyOut); - if (inDelta < 0) currencyIn.settle(manager, payer, uint256(-inDelta), false); + if (inDelta < 0) { + // tolerant settlement: low-level transferFrom so non-standard + // tokens (e.g. USDT, no returndata) work like on production routers + manager.sync(currencyIn); + (bool ok,) = Currency.unwrap(currencyIn).call( + abi.encodeWithSignature( + "transferFrom(address,address,uint256)", payer, address(manager), uint256(-inDelta) + ) + ); + require(ok, "transferFrom failed"); + manager.settle(); + } if (outDelta > 0) currencyOut.take(manager, recipient, uint256(outDelta), false); return abi.encode(uint256(outDelta)); } } -interface IERC20Like { - function approve(address spender, uint256 amount) external returns (bool); - function balanceOf(address account) external view returns (uint256); -} - -/// @notice Mainnet-fork validation: REAL PoolManager + REAL WETH/USDC pool, -/// deepest-hooked pool at the pinned block (fee 500, found by probe). +/// @notice Mainnet-fork validation: REAL PoolManager + REAL USDT/USDC fee-100 pool +/// (deepest at the pinned block), 1000 USDT exact input, warmed with one +/// identical unmeasured swap (steady state). /// Run with: --fork-url --fork-block-number contract ForkV4Test is Test { IPoolManager constant MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90); + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; - function test_fork_single_exactInput_weth() public { + function test_fork_single_exactInput_usdt() public { ForkSwapRouter router = new ForkSwapRouter(MANAGER); - // WETH is currency1 (USDC < WETH): WETH->USDC is oneForZero + // USDT is currency1 (USDC < USDT): USDT->USDC is oneForZero PoolKey memory key = PoolKey({ currency0: Currency.wrap(USDC), - currency1: Currency.wrap(WETH), - fee: 500, - tickSpacing: 10, + currency1: Currency.wrap(USDT), + fee: 100, + tickSpacing: 1, hooks: IHooks(address(0)) }); - deal(WETH, address(this), 10 ether); - IERC20Like(WETH).approve(address(router), type(uint256).max); - uint256 out = router.swap(key, false, -1 ether, TickMath.MAX_SQRT_PRICE - 1, address(this)); + deal(USDT, address(this), 1000000000000); + (bool approved,) = + USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(router), type(uint256).max)); + assertTrue(approved, "usdt approve"); + router.swap(key, false, -1000000000, TickMath.MAX_SQRT_PRICE - 1, address(this)); // warm-up + uint256 out = router.swap(key, false, -1000000000, TickMath.MAX_SQRT_PRICE - 1, address(this)); // measured assertGt(out, 0); - vm.snapshotGasLastCall("fork v4 PoolManager 1 WETH->USDC fee-500"); + vm.snapshotGasLastCall("fork v4 PoolManager 1000 USDT->USDC fee-100"); + assertGe(out, 990000000); + assertLe(out, 1005000000); } } diff --git a/benchmarks/fork/ForkV4Test.json b/benchmarks/fork/ForkV4Test.json index 817361e..97a07b1 100644 --- a/benchmarks/fork/ForkV4Test.json +++ b/benchmarks/fork/ForkV4Test.json @@ -1,3 +1,3 @@ { - "fork v4 PoolManager 1 WETH->USDC fee-500": "175461" + "fork v4 PoolManager 1000 USDT->USDC fee-100": "132388" } \ No newline at end of file diff --git a/benchmarks/fork/encode-yul-route.mjs b/benchmarks/fork/encode-yul-route.mjs new file mode 100644 index 0000000..dbc7234 --- /dev/null +++ b/benchmarks/fork/encode-yul-route.mjs @@ -0,0 +1,19 @@ +import { encodeRoute } from "/home/sendmoodz/Work/ekubo/yul-router/sdk/src/index.ts"; +const data = encodeRoute({ + specifiedToken: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + calculatedToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + specifiedAmount: 1000000000n, + calculatedAmountThreshold: 990000000n, + recipient: "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + hops: [ + { + type: "core", + poolKey: { + token0: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + token1: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + config: "0x0000000000000000000000000000000000000000000053e2d6238da480000032", + }, + }, + ], +}); +console.log(data); diff --git a/benchmarks/lab/ekubo/EkuboGas.t.sol b/benchmarks/lab/ekubo/EkuboGas.t.sol index c137b7e..a61844a 100644 --- a/benchmarks/lab/ekubo/EkuboGas.t.sol +++ b/benchmarks/lab/ekubo/EkuboGas.t.sol @@ -135,17 +135,9 @@ contract EkuboGasTest is Test { return PoolKey({token0: t0, token1: t1, config: createConcentratedPoolConfig(FEE_0_3, TICK_SPACING, address(0))}); } - function _coolAll() internal { - vm.cool(address(core)); - vm.cool(address(positions)); - vm.cool(address(router)); - vm.cool(address(bareMint)); - vm.cool(address(tokenA)); - vm.cool(address(tokenB)); - vm.cool(address(tokenC)); - vm.cool(address(tokenD)); - vm.cool(address(this)); - } + // Steady-state methodology: one identical unmeasured warm-up precedes every + // measurement, so pools, tokens, and approvals sit in warmed nonzero slots. + // isolate=true still gives each test a fresh chain for reproducibility. function _route(PoolKey[] memory keys) internal pure returns (RouteNode[] memory route) { route = new RouteNode[](keys.length); @@ -156,7 +148,7 @@ contract EkuboGasTest is Test { /// forge-config: default.isolate = true function test_gas_single_exactInput_erc20() public { - _coolAll(); + router.swapAllowPartialFill(keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0)); router.swapAllowPartialFill( keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0) ); @@ -165,7 +157,7 @@ contract EkuboGasTest is Test { /// forge-config: default.isolate = true function test_gas_single_exactInput_erc20_reverse() public { - _coolAll(); + router.swapAllowPartialFill(keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, true, 0)); router.swapAllowPartialFill( keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, true, 0) ); @@ -174,7 +166,9 @@ contract EkuboGasTest is Test { /// forge-config: default.isolate = true function test_gas_single_exactInput_native() public { - _coolAll(); + router.swapAllowPartialFill{value: uint128(SWAP_AMOUNT)}( + keyNative, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0) + ); router.swapAllowPartialFill{value: uint128(SWAP_AMOUNT)}( keyNative, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0) ); @@ -185,7 +179,9 @@ contract EkuboGasTest is Test { function test_gas_oneHop_multihopPath() public { PoolKey[] memory keys = new PoolKey[](1); keys[0] = keyAB; - _coolAll(); + router.multihopSwap( + Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min + ); router.multihopSwap( Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min ); @@ -197,7 +193,9 @@ contract EkuboGasTest is Test { PoolKey[] memory keys = new PoolKey[](2); keys[0] = keyAB; keys[1] = keyBC; - _coolAll(); + router.multihopSwap( + Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min + ); router.multihopSwap( Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min ); @@ -210,7 +208,9 @@ contract EkuboGasTest is Test { keys[0] = keyAB; keys[1] = keyBC; keys[2] = keyCD; - _coolAll(); + router.multihopSwap( + Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min + ); router.multihopSwap( Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min ); @@ -219,14 +219,14 @@ contract EkuboGasTest is Test { /// forge-config: default.isolate = true function test_gas_mint_bare() public { - _coolAll(); + bareMint.mint(keyAB, bytes24(uint192(776)), RANGE_LOWER, RANGE_UPPER, 1e24, address(this)); bareMint.mint(keyAB, bytes24(uint192(777)), RANGE_LOWER, RANGE_UPPER, 1e24, address(this)); vm.snapshotGasLastCall("ekubo mint bare core position"); } /// forge-config: default.isolate = true function test_gas_mint_fullRange() public { - _coolAll(); + positions.mintAndDeposit(keyAB, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); positions.mintAndDeposit(keyAB, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); vm.snapshotGasLastCall("ekubo mint concentrated position"); } diff --git a/benchmarks/lab/ekubo/EkuboGasTest.json b/benchmarks/lab/ekubo/EkuboGasTest.json index 4020d98..0f59042 100644 --- a/benchmarks/lab/ekubo/EkuboGasTest.json +++ b/benchmarks/lab/ekubo/EkuboGasTest.json @@ -1,3 +1,3 @@ { - "ekubo single exact-input token0->token1 concentrated": "87384" + "ekubo single exact-input token0->token1 concentrated": "87416" } \ No newline at end of file diff --git a/benchmarks/lab/ekubo/Flame.t.sol b/benchmarks/lab/ekubo/Flame.t.sol new file mode 100644 index 0000000..efa180d --- /dev/null +++ b/benchmarks/lab/ekubo/Flame.t.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: ekubo-license-v1.eth +pragma solidity =0.8.33; + +import {SqrtRatio} from "ekubo/src/types/sqrtRatio.sol"; +import {createSwapParameters} from "ekubo/src/types/swapParameters.sol"; +import {EkuboGasTest} from "./EkuboGas.t.sol"; + +/// @notice Dedicated single-call capture for flamegraphs (no warm-up, no snapshot). +contract FlameTest is EkuboGasTest { + function test_flame_single() public { + router.swapAllowPartialFill(keyAB, createSwapParameters(SqrtRatio.wrap(0), 1 ether, false, 0)); + } +} diff --git a/benchmarks/lab/v3/Flame.t.sol b/benchmarks/lab/v3/Flame.t.sol new file mode 100644 index 0000000..a841b9d --- /dev/null +++ b/benchmarks/lab/v3/Flame.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {V3GasTest} from "./V3Gas.t.sol"; + +/// @notice Warmed single-call capture for flamegraphs: setUp warms the pool, +/// the test itself makes exactly one swap, so flamegraph totals match +/// the warmed snapshot numbers. +contract FlameTest is V3GasTest { + function setUp() public override { + super.setUp(); + poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); + } + + function test_flame_single() public { + poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); + } +} diff --git a/benchmarks/lab/v3/V3Gas.t.sol b/benchmarks/lab/v3/V3Gas.t.sol index 4e2b86e..2197536 100644 --- a/benchmarks/lab/v3/V3Gas.t.sol +++ b/benchmarks/lab/v3/V3Gas.t.sol @@ -80,10 +80,10 @@ contract V3GasTest is Test { uint160 constant INIT_SQRT_PRICE = 79347087983665984109010747392; // 1.0001**(30/2) * 2**96: tick 30, strictly inside (spacing 60) uint160 constant MIN_LIMIT = 4295128740; // MIN_SQRT_RATIO + 1 uint160 constant MAX_LIMIT = 1461446703485210103287273052203988822378723970341; // MAX_SQRT_RATIO - 1 - uint128 constant LIQUIDITY = 1e30; + uint128 constant LIQUIDITY = 998501199320305883812938; // liquidity for 1M/1M full-range at the init price (matches the v4/ekubo harnesses) int256 constant SWAP_AMOUNT = 1e18; // exact input - function setUp() public { + function setUp() public virtual { factory = IUniswapV3Factory(vm.deployCode("artifacts/UniswapV3Factory.json")); // One identical token artifact (solmate MockERC20) in every harness. @@ -130,29 +130,18 @@ contract V3GasTest is Test { if (amount1Delta > 0) SharedToken(IUniswapV3Pool(msg.sender).token1()).transfer(msg.sender, uint256(amount1Delta)); } - function _coolAll() internal { - vm.cool(address(factory)); - vm.cool(address(poolAB)); - vm.cool(address(poolBC)); - vm.cool(address(poolCD)); - vm.cool(address(multihopRouter)); - vm.cool(address(tokenA)); - vm.cool(address(tokenB)); - vm.cool(address(tokenC)); - vm.cool(address(tokenD)); - vm.cool(address(this)); - } - + // Steady-state methodology: one identical unmeasured warm-up precedes every + // measurement, so pools, tokens, and approvals sit in warmed nonzero slots. /// forge-config: default.isolate = true function test_gas_single_exactInput_erc20() public { - _coolAll(); + poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); vm.snapshotGasLastCall("v3 single exact-input token0->token1 wide-range"); } /// forge-config: default.isolate = true function test_gas_single_exactInput_erc20_reverse() public { - _coolAll(); + poolAB.swap(address(this), false, SWAP_AMOUNT, MAX_LIMIT, ""); poolAB.swap(address(this), false, SWAP_AMOUNT, MAX_LIMIT, ""); vm.snapshotGasLastCall("v3 single exact-input token1->token0 wide-range"); } @@ -161,7 +150,7 @@ contract V3GasTest is Test { function test_gas_oneHop_routerPath() public { IUniswapV3Pool[] memory pools = new IUniswapV3Pool[](1); pools[0] = poolAB; - _coolAll(); + multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); vm.snapshotGasLastCall("v3 one-hop exact-input router path"); } @@ -171,7 +160,7 @@ contract V3GasTest is Test { IUniswapV3Pool[] memory pools = new IUniswapV3Pool[](2); pools[0] = poolAB; pools[1] = poolBC; - _coolAll(); + multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); vm.snapshotGasLastCall("v3 two-hop exact-input two pools"); } @@ -182,16 +171,16 @@ contract V3GasTest is Test { pools[0] = poolAB; pools[1] = poolBC; pools[2] = poolCD; - _coolAll(); + multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); vm.snapshotGasLastCall("v3 three-hop exact-input three pools"); } /// forge-config: default.isolate = true function test_gas_mint_fullRange() public { - _coolAll(); - // second position on the existing pool: same tier as the other harnesses' - // subsequent mints (no pool/tick initialization in the measured call) + // subsequent mint on the existing pool (ticks already initialized), + // preceded by an identical warm-up mint + poolAB.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); poolAB.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); vm.snapshotGasLastCall("v3 mint wide-range position"); } diff --git a/benchmarks/lab/v3/V3GasTest.json b/benchmarks/lab/v3/V3GasTest.json index da27bb4..a66b9d4 100644 --- a/benchmarks/lab/v3/V3GasTest.json +++ b/benchmarks/lab/v3/V3GasTest.json @@ -1,3 +1,3 @@ { - "v3 single exact-input token0->token1 wide-range": "104653" + "v3 single exact-input token0->token1 wide-range": "87553" } \ No newline at end of file diff --git a/benchmarks/lab/v4/Flame.t.sol b/benchmarks/lab/v4/Flame.t.sol new file mode 100644 index 0000000..d1b6459 --- /dev/null +++ b/benchmarks/lab/v4/Flame.t.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Currency} from "v4-core/src/types/Currency.sol"; +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; +import {V4GasTest} from "./V4Gas.t.sol"; + +/// @notice Warmed single-call capture for flamegraphs: setUp warms the pool, +/// the test itself makes exactly one swap, so flamegraph totals match +/// the warmed snapshot numbers. +contract FlameTest is V4GasTest { + function setUp() public override { + super.setUp(); + minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); + } + + function test_flame_single() public { + uint256 out = minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); + assertGt(out, 0); + } +} diff --git a/benchmarks/lab/v4/V4Gas.t.sol b/benchmarks/lab/v4/V4Gas.t.sol index bd4c435..73a179a 100644 --- a/benchmarks/lab/v4/V4Gas.t.sol +++ b/benchmarks/lab/v4/V4Gas.t.sol @@ -79,7 +79,7 @@ contract MultiHopRouter is IUnlockCallback { returns (uint256 amountOut) { bytes memory result = manager.unlock( - abi.encode(hops, currencyIn, currencyOut, amountIn, msg.sender, address(this)) + abi.encode(hops, currencyIn, currencyOut, amountIn, msg.sender, msg.sender) ); amountOut = abi.decode(result, (uint256)); uint256 ethBalance = address(this).balance; @@ -140,7 +140,7 @@ contract V4GasTest is Test { uint256 constant LIQUIDITY_TOKEN_AMOUNT = 1_000_000 ether; int256 constant SWAP_AMOUNT = -1 ether; - function setUp() public { + function setUp() public virtual { manager = new PoolManager(address(this)); swapRouter = new PoolSwapTest(manager); minRouter = new MinimalSwapRouter(manager); @@ -221,6 +221,7 @@ contract V4GasTest is Test { ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(_fullRangeLiquidity(LIQUIDITY_TOKEN_AMOUNT, LIQUIDITY_TOKEN_AMOUNT))), salt: bytes32(0)}), "" ); + } function _fullRangeLiquidity(uint256 amount0, uint256 amount1) internal pure returns (uint128) { @@ -247,19 +248,6 @@ contract V4GasTest is Test { (t0, t1); } - function _coolAll() internal { - vm.cool(address(manager)); - vm.cool(address(swapRouter)); - vm.cool(address(mintRouter)); - vm.cool(address(multiHopRouter)); - vm.cool(address(minRouter)); - vm.cool(address(tokenA)); - vm.cool(address(tokenB)); - vm.cool(address(tokenC)); - vm.cool(address(tokenD)); - vm.cool(address(this)); - } - function _v4Hops(PoolKey[] memory keys) internal pure returns (MultiHopRouter.Hop[] memory hops) { hops = new MultiHopRouter.Hop[](keys.length); for (uint256 i = 0; i < keys.length; i++) { @@ -269,7 +257,7 @@ contract V4GasTest is Test { /// forge-config: default.isolate = true function test_gas_single_minimal_erc20() public { - _coolAll(); + minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); uint256 out = minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); assertGt(out, 0); vm.snapshotGasLastCall("v4 single exact-input minimal locker token0->token1"); @@ -277,7 +265,7 @@ contract V4GasTest is Test { /// forge-config: default.isolate = true function test_gas_single_minimal_erc20_reverse() public { - _coolAll(); + minRouter.swapExactIn(keyAB, false, SWAP_AMOUNT, TickMath.MAX_SQRT_PRICE - 1, address(this)); uint256 out = minRouter.swapExactIn(keyAB, false, SWAP_AMOUNT, TickMath.MAX_SQRT_PRICE - 1, address(this)); assertGt(out, 0); vm.snapshotGasLastCall("v4 single exact-input minimal locker token1->token0"); @@ -285,7 +273,12 @@ contract V4GasTest is Test { /// forge-config: default.isolate = true function test_gas_single_exactInput_erc20() public { - _coolAll(); + swapRouter.swap( + keyAB, + SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); swapRouter.swap( keyAB, SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), @@ -297,7 +290,12 @@ contract V4GasTest is Test { /// forge-config: default.isolate = true function test_gas_single_exactInput_native() public { - _coolAll(); + swapRouter.swap{value: 1 ether}( + keyNative, + SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + "" + ); swapRouter.swap{value: 1 ether}( keyNative, SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), @@ -311,7 +309,7 @@ contract V4GasTest is Test { function test_gas_oneHop_exactInput() public { PoolKey[] memory keys = new PoolKey[](1); keys[0] = keyAB; - _coolAll(); + multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenB)), SWAP_AMOUNT); uint256 out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenB)), SWAP_AMOUNT); assertGt(out, 0); vm.snapshotGasLastCall("v4 one-hop exact-input single lock"); @@ -322,7 +320,7 @@ contract V4GasTest is Test { PoolKey[] memory keys = new PoolKey[](2); keys[0] = keyAB; keys[1] = keyBC; - _coolAll(); + multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenC)), SWAP_AMOUNT); uint256 out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenC)), SWAP_AMOUNT); assertGt(out, 0); vm.snapshotGasLastCall("v4 two-hop exact-input single lock"); @@ -334,7 +332,7 @@ contract V4GasTest is Test { keys[0] = keyAB; keys[1] = keyBC; keys[2] = keyCD; - _coolAll(); + multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenD)), SWAP_AMOUNT); uint256 out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenD)), SWAP_AMOUNT); assertGt(out, 0); vm.snapshotGasLastCall("v4 three-hop exact-input single lock"); @@ -343,7 +341,11 @@ contract V4GasTest is Test { /// forge-config: default.isolate = true function test_gas_mint_fullRange() public { uint128 liq = _fullRangeLiquidity(LIQUIDITY_TOKEN_AMOUNT, LIQUIDITY_TOKEN_AMOUNT); - _coolAll(); + mintRouter.modifyLiquidity( + keyAB, + ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(uint256(2))}), + "" + ); mintRouter.modifyLiquidity( keyAB, ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(uint256(1))}), diff --git a/benchmarks/lab/v4/V4GasTest.json b/benchmarks/lab/v4/V4GasTest.json index a621916..637196c 100644 --- a/benchmarks/lab/v4/V4GasTest.json +++ b/benchmarks/lab/v4/V4GasTest.json @@ -1,3 +1,10 @@ { - "v4 single exact-input minimal locker token0->token1": "120648" + "v4 mint wide-range position": "162188", + "v4 one-hop exact-input single lock": "107360", + "v4 single exact-input ERC20->ERC20 wide-range concentrated": "116827", + "v4 single exact-input minimal locker token0->token1": "103548", + "v4 single exact-input minimal locker token1->token0": "103373", + "v4 single exact-input native->ERC20 wide-range concentrated": "98953", + "v4 three-hop exact-input single lock": "176182", + "v4 two-hop exact-input single lock": "141771" } \ No newline at end of file diff --git a/public/gas/flamegraph-ekubo-single.svg b/public/gas/flamegraph-ekubo-single.svg index 3d8e76a..f9550af 100644 --- a/public/gas/flamegraph-ekubo-single.svg +++ b/public/gas/flamegraph-ekubo-single.svg @@ -488,4 +488,4 @@ function search(term) { function format_percent(n) { return n.toFixed(4) + "%"; } -]]>flamegraph EkuboGasTest::test_gas_single_exactInput_erc20 Reset ZoomSearch EkuboGasTest::_coolAll() (23,312 gas, 19.01%)EkuboGasTest::_coolAll()Core::_updatePairDebtWithNative(uint256,address,address,int256,int256) (936 gas, 0.76%)FlashAccountant::_updatePairDebt(uint256,address,address,int256,int256) (876 gas, 0.71%)FixedPointMathLib::fullMulDivN(uint256,uint256,uint8) (86 gas, 0.07%)FlashAccountant::_requireLocker() (196 gas, 0.16%)FlashAccountant::_getLocker() (142 gas, 0.12%)CoreLib::swap (20,591 gas, 16.79%)CoreLib::swapCore.swap_6269342730() (20,331 gas, 16.58%)Core.swap_6269342730()SafeCastLib::toInt128(int256) (60 gas, 0.05%)FlashAccountant::_getLocker() (142 gas, 0.12%)Core.completePayments() (2,054 gas, 1.67%)MockERC20.balanceOf(address) (525 gas, 0.43%)Core.startPayments() (5,914 gas, 4.82%)Core.s..MockERC20.balanceOf(address) (2,525 gas, 2.06%)M..FlashAccountantLib::payFrom (21,088 gas, 17.20%)FlashAccountantLib::payFromMockERC20.transferFrom(address,address,uint256) (12,602 gas, 10.28%)MockERC20.trans..FlashAccountant::_requireLocker() (196 gas, 0.16%)FlashAccountant::_getLocker() (142 gas, 0.12%)Router.swapAllowPartialFill((address,address,bytes32),bytes32) (87,384 gas, 71.26%)Router.swapAllowPartialFill((address,address,bytes32),bytes32)BaseLocker::lock(bytes) (64,241 gas, 52.38%)BaseLocker::lock(bytes)Core.lock() (61,421 gas, 50.09%)Core.lock()Router.locked_6416899205(uint256) (60,203 gas, 49.09%)Router.locked_6416899205(uint256)FlashAccountantLib::withdraw (16,592 gas, 13.53%)FlashAccountantLib::..Core.0x3ccfd60b (16,351 gas, 13.33%)Core.0x3ccfd60bMockERC20.transfer(address,uint256) (12,452 gas, 10.15%)MockERC20.trans..all (122,633 gas, 100%)EkuboGasTest.test_gas_single_exactInput_erc20() (122,633 gas, 100.00%)EkuboGasTest.test_gas_single_exactInput_erc20() \ No newline at end of file +]]>flamegraph FlameTest::test_flame_single Reset ZoomSearch Core::_updatePairDebtWithNative(uint256,address,address,int256,int256) (936 gas, 0.94%)FlashAccountant::_updatePairDebt(uint256,address,address,int256,int256) (876 gas, 0.88%)FixedPointMathLib::fullMulDivN(uint256,uint256,uint8) (86 gas, 0.09%)FlashAccountant::_requireLocker() (196 gas, 0.20%)FlashAccountant::_getLocker() (142 gas, 0.14%)CoreLib::swap (20,591 gas, 20.78%)CoreLib::swapCore.swap_6269342730() (20,331 gas, 20.52%)Core.swap_6269342730()SafeCastLib::toInt128(int256) (60 gas, 0.06%)FlashAccountant::_getLocker() (142 gas, 0.14%)Core.completePayments() (2,054 gas, 2.07%)C..MockERC20.balanceOf(address) (525 gas, 0.53%)Core.startPayments() (5,914 gas, 5.97%)Core.sta..MockERC20.balanceOf(address) (2,525 gas, 2.55%)Mo..FlashAccountantLib::payFrom (21,088 gas, 21.28%)FlashAccountantLib::payFromMockERC20.transferFrom(address,address,uint256) (12,602 gas, 12.72%)MockERC20.transferF..FlashAccountant::_requireLocker() (196 gas, 0.20%)FlashAccountant::_getLocker() (142 gas, 0.14%)all (99,098 gas, 100%)FlameTest.test_flame_single() (99,098 gas, 100.00%)FlameTest.test_flame_single()Router.swapAllowPartialFill((address,address,bytes32),bytes32) (87,384 gas, 88.18%)Router.swapAllowPartialFill((address,address,bytes32),bytes32)BaseLocker::lock(bytes) (64,241 gas, 64.83%)BaseLocker::lock(bytes)Core.lock() (61,421 gas, 61.98%)Core.lock()Router.locked_6416899205(uint256) (60,203 gas, 60.75%)Router.locked_6416899205(uint256)FlashAccountantLib::withdraw (16,592 gas, 16.74%)FlashAccountantLib::withdr..Core.0x3ccfd60b (16,351 gas, 16.50%)Core.0x3ccfd60bMockERC20.transfer(address,uint256) (12,452 gas, 12.57%)MockERC20.transfer(.. \ No newline at end of file diff --git a/public/gas/flamegraph-v3-single.svg b/public/gas/flamegraph-v3-single.svg index 968ea2a..c98e541 100644 --- a/public/gas/flamegraph-v3-single.svg +++ b/public/gas/flamegraph-v3-single.svg @@ -488,4 +488,4 @@ function search(term) { function format_percent(n) { return n.toFixed(4) + "%"; } -]]>flamegraph V3GasTest::test_gas_single_exactInput_erc20 Reset ZoomSearch V3GasTest::_coolAll() (25,861 gas, 18.98%)V3GasTest::_coolAll()token0() (266 gas, 0.20%)V3GasTest.uniswapV3SwapCallback(int256,int256,bytes) (12,441 gas, 9.13%)V3GasTest.uni..transfer(address,uint256) (10,452 gas, 7.67%)transfer(ad..balanceOf(address) (3,050 gas, 2.24%)b..all (136,223 gas, 100%)V3GasTest.test_gas_single_exactInput_erc20() (136,223 gas, 100.00%)V3GasTest.test_gas_single_exactInput_erc20()swap(address,bool,int256,uint160,bytes) (104,653 gas, 76.82%)swap(address,bool,int256,uint160,bytes)transfer(address,uint256) (12,452 gas, 9.14%)transfer(addr.. \ No newline at end of file +]]>flamegraph FlameTest::test_flame_single Reset ZoomSearch token0() (266 gas, 0.29%)FlameTest.uniswapV3SwapCallback(int256,int256,bytes) (12,463 gas, 13.40%)FlameTest.uniswapV3S..transfer(address,uint256) (10,452 gas, 11.23%)transfer(address,..balanceOf(address) (3,050 gas, 3.28%)bal..all (93,031 gas, 100%)FlameTest.test_flame_single() (93,031 gas, 100.00%)FlameTest.test_flame_single()swap(address,bool,int256,uint160,bytes) (87,575 gas, 94.14%)swap(address,bool,int256,uint160,bytes)transfer(address,uint256) (12,452 gas, 13.38%)transfer(address,uin.. \ No newline at end of file diff --git a/public/gas/flamegraph-v4-single.svg b/public/gas/flamegraph-v4-single.svg index 26408d9..6a95eb0 100644 --- a/public/gas/flamegraph-v4-single.svg +++ b/public/gas/flamegraph-v4-single.svg @@ -488,4 +488,4 @@ function search(term) { function format_percent(n) { return n.toFixed(4) + "%"; } -]]>flamegraph V4GasTest::test_gas_single_minimal_erc20 Reset ZoomSearch MockERC20.transferFrom(address,address,uint256) (12,602 gas, 7.95%)MockERC20.t..CurrencyLibrary::balanceOfSelf (934 gas, 0.59%)MockERC20.balanceOf(address) (525 gas, 0.33%)PoolManager::_accountDelta (689 gas, 0.43%)PoolManager.settle() (2,499 gas, 1.58%)SafeCast::toInt128(uint256) (53 gas, 0.03%)Hooks::afterSwap (223 gas, 0.14%)Hooks::beforeSwap (112 gas, 0.07%)NoDelegateCall::checkNotDelegateCall() (44 gas, 0.03%)Pool::checkPoolInitialized (2,139 gas, 1.35%)FullMath::mulDiv(uint256,uint256,uint256) (146 gas, 0.09%)SafeCast::toInt128(int256) (106 gas, 0.07%)SqrtPriceMath::getAmount0Delta(uint160,uint160,uint128,bool) (672 gas, 0.42%)FullMath::mulDivRoundingUp(uint256,uint256,uint256) (506 gas, 0.32%)FullMath::mulDiv(uint256,uint256,uint256) (389 gas, 0.25%)SqrtPriceMath::getAmount1Delta(uint160,uint160,uint128,bool) (228 gas, 0.14%)SqrtPriceMath::getNextSqrtPriceFromAmount0RoundingUp(uint160,uint128,uint256,bool) (712 gas, 0.45%)FullMath::mulDivRoundingUp(uint256,uint256,uint256) (506 gas, 0.32%)FullMath::mulDiv(uint256,uint256,uint256) (389 gas, 0.25%)TickMath::getSqrtPriceAtTick(int24) (571 gas, 0.36%)BitMath::mostSignificantBit(uint256) (180 gas, 0.11%)Pool::swap (36,388 gas, 22.94%)Pool::swapTickMath::getTickAtSqrtPrice(uint160) (1,829 gas, 1.15%)TickMath::getSqrtPriceAtTick(int24) (719 gas, 0.45%)PoolManager.swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes) (45,984 gas, 28.99%)PoolManager.swap((address,address,uint24,int24,..PoolManager::_accountPoolBalanceDelta (1,504 gas, 0.95%)PoolManager::_accountDelta (1,408 gas, 0.89%)PoolManager.sync(address) (6,398 gas, 4.03%)Pool..CurrencyLibrary::balanceOfSelf (5,434 gas, 3.43%)Cur..MockERC20.balanceOf(address) (2,525 gas, 1.59%)CurrencyLibrary::transfer (15,292 gas, 9.64%)CurrencyLibrar..MockERC20.transfer(address,uint256) (12,452 gas, 7.85%)MockERC20.t..PoolManager::_accountDelta (689 gas, 0.43%)PoolManager.take(address,address,uint256) (16,525 gas, 10.42%)PoolManager.tak..SafeCast::toInt128(uint256) (53 gas, 0.03%)MinimalSwapRouter.swapExactIn((address,address,uint24,int24,address),bool,int256,uint160,address) (120,648 gas, 76.07%)MinimalSwapRouter.swapExactIn((address,address,uint24,int24,address),bool,int256,uint160,address)PoolManager.unlock(bytes) (92,807 gas, 58.51%)PoolManager.unlock(bytes)MinimalSwapRouter.unlockCallback(bytes) (90,797 gas, 57.25%)MinimalSwapRouter.unlockCallback(bytes)TransientStateLibrary::currencyDelta (2,626 gas, 1.66%)PoolManager.exttload(bytes32) (1,718 gas, 1.08%)StdAssertions::assertGt(uint256,uint256) (38 gas, 0.02%)V4GasTest::_coolAll() (25,861 gas, 16.31%)V4GasTest::_coolAll()all (158,606 gas, 100%)V4GasTest.test_gas_single_minimal_erc20() (158,606 gas, 100.00%)V4GasTest.test_gas_single_minimal_erc20() \ No newline at end of file +]]>flamegraph FlameTest::test_flame_single Reset ZoomSearch MockERC20.transferFrom(address,address,uint256) (12,602 gas, 10.92%)MockERC20.transf..CurrencyLibrary::balanceOfSelf (934 gas, 0.81%)MockERC20.balanceOf(address) (525 gas, 0.45%)PoolManager::_accountDelta (689 gas, 0.60%)PoolManager.settle() (2,499 gas, 2.16%)P..SafeCast::toInt128(uint256) (53 gas, 0.05%)Hooks::afterSwap (223 gas, 0.19%)Hooks::beforeSwap (112 gas, 0.10%)NoDelegateCall::checkNotDelegateCall() (44 gas, 0.04%)Pool::checkPoolInitialized (2,139 gas, 1.85%)P..FullMath::mulDiv(uint256,uint256,uint256) (146 gas, 0.13%)SafeCast::toInt128(int256) (106 gas, 0.09%)SqrtPriceMath::getAmount0Delta(uint160,uint160,uint128,bool) (672 gas, 0.58%)FullMath::mulDivRoundingUp(uint256,uint256,uint256) (506 gas, 0.44%)FullMath::mulDiv(uint256,uint256,uint256) (389 gas, 0.34%)SqrtPriceMath::getAmount1Delta(uint160,uint160,uint128,bool) (228 gas, 0.20%)SqrtPriceMath::getNextSqrtPriceFromAmount0RoundingUp(uint160,uint128,uint256,bool) (712 gas, 0.62%)FullMath::mulDivRoundingUp(uint256,uint256,uint256) (506 gas, 0.44%)FullMath::mulDiv(uint256,uint256,uint256) (389 gas, 0.34%)TickMath::getSqrtPriceAtTick(int24) (571 gas, 0.49%)BitMath::mostSignificantBit(uint256) (180 gas, 0.16%)Pool::swap (19,288 gas, 16.71%)Pool::swapTickMath::getTickAtSqrtPrice(uint160) (1,829 gas, 1.58%)TickMath::getSqrtPriceAtTick(int24) (719 gas, 0.62%)PoolManager.swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes) (28,884 gas, 25.02%)PoolManager.swap((address,address,uint24..PoolManager::_accountPoolBalanceDelta (1,504 gas, 1.30%)PoolManager::_accountDelta (1,408 gas, 1.22%)PoolManager.sync(address) (6,398 gas, 5.54%)PoolMan..CurrencyLibrary::balanceOfSelf (5,434 gas, 4.71%)Curre..MockERC20.balanceOf(address) (2,525 gas, 2.19%)M..CurrencyLibrary::transfer (15,292 gas, 13.25%)CurrencyLibrary::tra..MockERC20.transfer(address,uint256) (12,452 gas, 10.79%)MockERC20.transf..PoolManager::_accountDelta (689 gas, 0.60%)PoolManager.take(address,address,uint256) (16,525 gas, 14.31%)PoolManager.take(addre..SafeCast::toInt128(uint256) (53 gas, 0.05%)MinimalSwapRouter.swapExactIn((address,address,uint24,int24,address),bool,int256,uint160,address) (103,548 gas, 89.70%)MinimalSwapRouter.swapExactIn((address,address,uint24,int24,address),bool,int256,uint160,address)PoolManager.unlock(bytes) (75,707 gas, 65.58%)PoolManager.unlock(bytes)MinimalSwapRouter.unlockCallback(bytes) (73,697 gas, 63.84%)MinimalSwapRouter.unlockCallback(bytes)TransientStateLibrary::currencyDelta (2,626 gas, 2.27%)T..PoolManager.exttload(bytes32) (1,718 gas, 1.49%)all (115,439 gas, 100%)FlameTest.test_flame_single() (115,439 gas, 100.00%)FlameTest.test_flame_single()StdAssertions::assertGt(uint256,uint256) (38 gas, 0.03%) \ No newline at end of file diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index 07cd218..4d4612d 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -10,11 +10,13 @@ measured numbers — full methodology, harness sources, and flamegraphs included comparing Ekubo on EVM against Uniswap v4 and Uniswap v3 on identical workloads. All figures are execution gas for a 0.3% concentrated-liquidity pool with a -wide-range position and no extension, measured with Foundry snapshots where each -test runs isolated with every contract cooled, so each number is a cold-access -transaction. Percentages beside the tables add the 21,000 base fee plus exact -calldata cost, i.e. full transaction gas (the post-Pectra calldata floor was -checked and does not bind at these sizes). +wide-range position and no extension. Every measurement is taken on a warmed +pool: each test runs on a fresh chain, performs one identical unmeasured swap +first, then measures the second, so pools, tokens, and approvals sit in warmed +nonzero slots — the steady state a real pool lives in. Percentages beside the +tables add the 21,000 base fee plus exact calldata cost, i.e. full transaction +gas (the post-Pectra calldata floor was checked and does not bind at these +sizes). ## Single swaps @@ -23,16 +25,17 @@ One token of input, exact-input, no tick crossings, 18-decimal plain ERC20 token | Swap | Ekubo | Uniswap v4 | Uniswap v3 | | --------------------------------- | ---------- | ---------- | -------------------------- | -| ERC20 to ERC20 | **87,384** | 120,648 | 104,653 | -| ERC20 to ERC20, reverse direction | **86,334** | 120,473 | 106,848 | -| Native ETH to ERC20 | **72,560** | 116,053 | — (v3 needs WETH wrapping) | - -At the transaction level (base fee plus calldata included), the ERC20 swap costs -about **110k on Ekubo, 144k on v4, and 127k on v3** — roughly 24% less than v4 -and 14% less than v3 for a single hop. The v4 figure uses a true-minimal locker -(unlock, swap, settle net); v4's own test helper costs more, and the production -Universal Router costs more still. The v3 figure is a direct pool call with zero -router overhead, so it is a lower bound; the production SwapRouter adds on top. +| ERC20 to ERC20 | **87,416** | 103,548 | 87,553 | +| ERC20 to ERC20, reverse direction | **86,334** | 103,373 | 86,952 | +| Native ETH to ERC20 | **72,592** | 98,953 | — (v3 needs WETH wrapping) | + +Warmed single hops are close: about **110k on Ekubo and v3 alike at the +transaction level, 127k on v4** — roughly 14% less than v4 and parity with v3. +The v4 figure uses a true-minimal locker (unlock, swap, settle net); v4's own +test helper costs more, and the production Universal Router costs more still. +The v3 figure is a direct pool call with zero router overhead, so it is a lower +bound; the production SwapRouter adds on top. A single swap is the one place +v3's heavier pool core (see below) is fully offset by having no router at all. ## How the gap scales with route length @@ -42,16 +45,17 @@ apples-to-apples: | Route length | Ekubo | Uniswap v4 | Uniswap v3 | | -------------------------------- | ----------- | ----------- | ----------- | -| 1 pool | 94,860 | 141,560 | 129,810 | -| 2 pools | 119,269 | 193,071 | 206,120 | -| 3 pools | 143,678 | 244,582 | 282,429 | -| **Marginal cost per extra pool** | **~24,400** | **~51,500** | **~76,300** | +| 1 pool | 94,892 | 107,360 | 116,130 | +| 2 pools | 119,333 | 141,771 | 178,760 | +| 3 pools | 143,774 | 176,182 | 241,389 | +| **Marginal cost per extra pool** | **~24,400** | **~34,400** | **~62,600** | The marginals are linear to the gas unit because each added hop hits an identically shaped fresh pool with its own distinct token contracts. At three -pools Ekubo costs about 41% less than v4 and 49% less than v3 in execution -gas — the gap widens with every pool because each extra Ekubo hop is little -more than one pool-math call (see below). +pools Ekubo costs about 18% less than v4 and 40% less than v3 in execution gas. +This is where the architecture compounds: each extra Ekubo hop is little more +than one pool-math call, while v3 replays a full settle-and-callback cycle per +pool. Token transfers tell part of the story. Ekubo and v4 both settle net inside one lock, so a route of any length moves exactly 2 token transfers: @@ -67,69 +71,67 @@ difference is inside the pool core. ## Where the savings come from -Frame-level attribution of the single-hop swap above, from Foundry flamegraphs -(total height is total gas; each frame shows its own cost): +Frame-level attribution of the warmed single-hop swap, from Foundry flamegraphs +(each capture is one warmed swap call; frame widths are proportional to gas): ![Flamegraph of an Ekubo single-hop swap](/gas/flamegraph-ekubo-single.svg) _Flamegraph: Ekubo single-hop swap, 87,384 gas._ ![Flamegraph of a Uniswap v4 single-hop swap](/gas/flamegraph-v4-single.svg) -_Flamegraph: Uniswap v4 single-hop swap via a minimal locker, 120,648 gas._ +_Flamegraph: Uniswap v4 single-hop swap via a minimal locker, 103,548 gas._ ![Flamegraph of a Uniswap v3 single-hop swap](/gas/flamegraph-v3-single.svg) -_Flamegraph: Uniswap v3 single-hop swap via direct pool call, 104,653 gas._ +_Flamegraph: Uniswap v3 single-hop swap via direct pool call, 87,575 gas._ -| Component | Ekubo (87,384) | Uniswap v4 (120,648) | Uniswap v3 (104,653) | -| -------------------------------------- | --------------------------- | -------------------------------------------------------------- | --------------------------------- | -| Pool math and state | Core swap: **20,331** (23%) | PoolManager swap: **45,984** (38%), of which pool logic 36,388 | Pool internals: **~76,700** (73%) | -| Settlement (transfers plus accounting) | ~45,600 | ~40,600 | ~38,400 | -| Router and lock overhead | ~26,000 | ~28,000 | 0 (direct call) | +| Component | Ekubo (87,416) | Uniswap v4 (103,548) | Uniswap v3 (87,553) | +| -------------------------------------- | --------------------------- | ---------------------------------- | --------------------------------- | +| Pool math and state | Core swap: **20,331** (23%) | PoolManager swap: **28,884** (28%) | Pool internals: **~59,600** (68%) | +| Settlement (transfers plus accounting) | ~45,600 | ~39,700 | ~27,900 | +| Router and lock overhead | ~23,100 | ~27,800 | 0 (direct call) | Three observations: -- **The pool core is where Ekubo wins.** One packed storage word and Q64 fixed-point - math cost about 20k per swap, against 46k for v4's multi-slot state with - protocol-fee and donation bookkeeping, and about 77k for v3's slot0, fee-growth, - and per-swap oracle writes. -- **Settlement is rough parity.** All three move two tokens for a single hop at a - similar cost; Ekubo's till pattern only pulls ahead as routes grow (see the - transfer table above). -- **The Solidity Router's ABI round-trip (~26k) is Ekubo's largest single chunk.** - These numbers use the Solidity Router as the conservative path; production - traffic goes through the gas-optimized Yul router. +- **The pool core is where Ekubo wins.** One packed storage word and Q64 + fixed-point math cost about 20k per swap, against 29k for v4's multi-slot + state with protocol-fee and donation bookkeeping, and about 60k for v3's + slot0, fee-growth, and per-swap oracle writes. +- **Ekubo spends the savings back on settlement and routing.** Flash-accounting + settlement (~46k) costs more than v4's (~40k) and v3's (~28k), and the + Solidity Router's ABI round-trip (~23k) matches v4's lock overhead. On a + single hop these cancel out against v3; the till pattern only pulls ahead as + routes grow (see the transfer and marginal tables above). +- **Warmed pools matter.** An earlier cold-access variant of this comparison + overstated the single-hop gap by about 17k on v4 and v3, because their pool + state is cold-sensitive while Ekubo's is nearly warmth-invariant (verified: + identical totals with and without warm-up, and with unrelated preceding + operations). All figures above are warmed steady-state. ## Mainnet-fork validation -Lab harnesses control everything, which invites the question of whether the -ranking survives production bytecode, real tokens, and real liquidity. As a -check, the same 1-WETH-to-USDC swap was run on a mainnet fork (block 25991868, -all addresses verified on-chain): - -| Swap on mainnet fork | Ekubo | Uniswap v4 | Uniswap v3 | -| ----------------------------- | ---------- | ---------- | ---------- | -| 1 WETH to USDC, execution gas | **98,066** | 175,461 | 142,287 | - -That is 31% less than v3 and 44% less than v4, wider than the lab single-hop -gaps. Part of the widening is expected: the lab uses lightweight mock tokens -while the fork pays real-token costs (USDC is a proxy), real concentrated ticks, -and production routers. Two asymmetries to read alongside it: the Uniswap legs -use real concentrated pools — the v4 leg crossed about twelve initialized ticks, -which is genuinely part of trading on real liquidity — while the Ekubo leg uses -a freshly capitalized concentrated pool of the same shape as the lab tests. And -the v4 leg uses a minimal locker rather than the Universal Router, which would -cost more, while the v3 leg uses the real production SwapRouter. The fork confirms the -direction and rough magnitude of the lab result under production conditions; the -lab tables above are the controlled comparison. - -As a version check, the fork swap was also run against a freshly deployed -worktree Core on the same fork state: byte-identical gas (98,066), so nothing -about the deployed Core vintage affects the comparison. +Lab harnesses control everything, so the same comparison was re-run where it +matters: one pair, production code, real liquidity. All three legs swap +1000 USDT to USDC on a mainnet fork (block 25991868), warmed, with negligible +tick movement (0–1 ticks): + +| Swap on mainnet fork | Ekubo | Uniswap v4 | Uniswap v3 | +| -------------------------------- | ----------- | ---------- | ---------- | +| 1000 USDT to USDC, execution gas | **110,622** | 132,388 | 128,611 | + +That is about 14% less than v3 and 16% less than v4, on the same pair and the +same size. Each leg uses its production path: Ekubo through the real deployed +Yul router against a live USDC/USDT pool with SDK-generated calldata, v3 +through the real SwapRouter against the deepest fee-100 pool, v4 through the +real PoolManager against the deepest fee-100 pool with a minimal locker (the +Universal Router would cost more). Fee tiers differ across venues, which does +not affect the code path; output matched the production quoter within 0.02% on +the Ekubo leg. ## The mint exception Providing liquidity is the one measured operation where Ekubo is not the -cheapest. Minting a wide-range position, same tier of caller on each protocol -(direct-to-core, no NFT, no position manager): +cheapest. Minting an economically matched position (1M tokens a side) as a +subsequent mint with boundary ticks already initialized, same tier of caller on +each protocol (direct-to-core, no NFT, no position manager): | Mint path | Ekubo | Uniswap v4 | Uniswap v3 | | -------------------------- | --------------------- | ---------- | ----------- | @@ -148,20 +150,20 @@ optimized for the operation that happens orders of magnitude more often. - Each protocol compiles under its own production settings (Ekubo: solc 0.8.33, 9999999 optimizer runs, via IR, Osaka; v4: v4-core's own settings; v3: canonical mainnet bytecode), with Foundry 1.8.3 throughout. +- One identical token artifact runs in every lab harness, and every recipient + is pre-funded, so no measured call pays a zero-to-nonzero balance write that + the others avoid. - Routers are deliberately minimal on all sides, which favors Uniswap: production routers (Universal Router, SwapRouter, and Ekubo's own frontend path) all cost more than the numbers above. - Scope is no-crossing swaps on hookless/extensionless pools: no Uniswap v4 hooks, no Ekubo extensions, both swap directions covered, concentrated pools - throughout (an early full-range-pool variant measured within 5k of these - figures and was dropped for exactness). Every mint measured is a subsequent - mint into a pool whose boundary ticks are already initialized, on all three - protocols. -- v3 per-hop figures exclude end-of-transaction refunds (about 4.8k per hop from - clearing intermediate balances), so the true v3 marginal is closer to 71k - than 76k — the ranking is unaffected. + throughout. +- v3 per-hop figures exclude end-of-transaction refunds (about 4.8k per hop + from clearing intermediate balances), so the true v3 marginal is closer to + 58k than 63k — the ranking is unaffected. - Any v4 hook or Ekubo extension changes every number on this page; pool configuration (fee, tick spacing) barely moves them. -The complete harnesses, raw snapshots, and reproduction commands live in -`benchmarks/` alongside these docs. +The complete harnesses, raw snapshots, flamegraphs, and reproduction commands +live in `benchmarks/` alongside these docs. From 617eb5deba138b55ab1ab6b764eb2cccebfe5df6 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Wed, 16 Sep 2026 19:05:43 -0400 Subject: [PATCH 03/17] Correct gas page to cold-path singles with warmed scaling marginals Investigating warm/cold behavior found: vm.cool is a no-op under isolate; in-test warm-ups are what warm. Ekubo is warmth-invariant but v4/v3 carry ~17k of cold swap-path slots, so warmed headlines understated them. Singles now report the cold path on capitalized pools (87/121/105k), scaling keeps warmed marginals (correct for in-route hops) with a first-hop-cold note, and flamegraphs capture single cold-path calls. Also fixes a v4 multihop test router that retained output instead of delivering to the user (numbers unchanged) and matches v3 mint depth to the other harnesses. --- benchmarks/README.md | 19 +-- src/content/docs/concepts/gas-efficiency.md | 126 +++++++++++--------- 2 files changed, 77 insertions(+), 68 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 0a48483..6266923 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -20,13 +20,12 @@ the dependency pins below are what matter for reproduction. ## Lab methodology (all three harnesses) -Steady state, warmed pools: each test runs on a fresh chain (`isolate`), then -performs one identical unmeasured warm-up swap before the measured call, so -pools, tokens, and approvals sit in warmed nonzero slots. (An earlier -cold-access variant overstated v4/v3 single-hop costs by ~17k because their pool -state is cold-sensitive; Ekubo's is nearly warmth-invariant, verified by -identical totals with and without warm-up and with unrelated preceding -operations.) +Each test runs on a fresh chain (`isolate`). Pools are capitalized in setup; +single-hop figures are cold-path measurements with no warm-up swap, the closest +a harness gets to a real user's transaction (which always starts cold). +Multihop scaling figures are warmed (one identical unmeasured route first), +matching in-route conditions where later hops execute warm. `vm.cool` was +verified to be a no-op in this setup and is not used. - Concentrated pools, 0.3% fee, range covering the whole tick space, so no swap crosses an initialized tick. @@ -36,9 +35,11 @@ operations.) the equivalent liquidity (998501199320305883812938) computed for the same amounts. - 1-token exact-input swaps, both directions; multihop routers deliver output - to the calling user (never stranded in the router). + to the calling user (an earlier v4 router that retained output was fixed; + the numbers did not move). - Gas read with `vm.snapshotGasLastCall` immediately after the measured call; - `*.json` files are the raw snapshots. + `*.json` files are the raw snapshots. `Flame.t.sol` files capture one + cold-path call each for the flamegraphs. Scaling series (1/2/3 hops) use the same minimal single-lock router per protocol, so the per-hop marginal is apples-to-apples. Mint tests are subsequent mints diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index 4d4612d..b70eb81 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -10,52 +10,52 @@ measured numbers — full methodology, harness sources, and flamegraphs included comparing Ekubo on EVM against Uniswap v4 and Uniswap v3 on identical workloads. All figures are execution gas for a 0.3% concentrated-liquidity pool with a -wide-range position and no extension. Every measurement is taken on a warmed -pool: each test runs on a fresh chain, performs one identical unmeasured swap -first, then measures the second, so pools, tokens, and approvals sit in warmed -nonzero slots — the steady state a real pool lives in. Percentages beside the -tables add the 21,000 base fee plus exact calldata cost, i.e. full transaction -gas (the post-Pectra calldata floor was checked and does not bind at these -sizes). +wide-range position and no extension. Percentages beside the tables add the +21,000 base fee plus exact calldata cost, i.e. full transaction gas (the +post-Pectra calldata floor was checked and does not bind at these sizes). ## Single swaps One token of input, exact-input, no tick crossings, 18-decimal plain ERC20 tokens -(the identical token contract runs in every harness): +(the identical token contract runs in every harness), both directions: | Swap | Ekubo | Uniswap v4 | Uniswap v3 | | --------------------------------- | ---------- | ---------- | -------------------------- | -| ERC20 to ERC20 | **87,416** | 103,548 | 87,553 | -| ERC20 to ERC20, reverse direction | **86,334** | 103,373 | 86,952 | -| Native ETH to ERC20 | **72,592** | 98,953 | — (v3 needs WETH wrapping) | - -Warmed single hops are close: about **110k on Ekubo and v3 alike at the -transaction level, 127k on v4** — roughly 14% less than v4 and parity with v3. -The v4 figure uses a true-minimal locker (unlock, swap, settle net); v4's own -test helper costs more, and the production Universal Router costs more still. -The v3 figure is a direct pool call with zero router overhead, so it is a lower -bound; the production SwapRouter adds on top. A single swap is the one place -v3's heavier pool core (see below) is fully offset by having no router at all. +| ERC20 to ERC20 | **87,384** | 120,648 | 104,675 | +| ERC20 to ERC20, reverse direction | **86,334** | 120,473 | 106,870 | +| Native ETH to ERC20 | **72,560** | 116,053 | — (v3 needs WETH wrapping) | + +These are cold-path measurements: each test runs on a fresh chain whose pools +were capitalized in setup, with no warm-up swap before the measured call — the +closest a harness gets to a real user's transaction, which always starts with +cold access. At the transaction level the ERC20 swap costs about **110k on +Ekubo, 144k on v4, and 127k on v3** — roughly 24% less than v4 and 14% less +than v3. The v4 figure uses a true-minimal locker (unlock, swap, settle net); +v4's own test helper costs more, and the production Universal Router costs more +still. The v3 figure is a direct pool call with zero router overhead, so it is +a lower bound; the production SwapRouter adds on top. ## How the gap scales with route length Routing through one, two, and three pools in a single transaction, each protocol -using the same minimal single-lock router throughout, so the per-hop marginal is -apples-to-apples: +using the same minimal single-lock router throughout, warmed so every pool is +touched before measurement: -| Route length | Ekubo | Uniswap v4 | Uniswap v3 | -| -------------------------------- | ----------- | ----------- | ----------- | -| 1 pool | 94,892 | 107,360 | 116,130 | -| 2 pools | 119,333 | 141,771 | 178,760 | -| 3 pools | 143,774 | 176,182 | 241,389 | -| **Marginal cost per extra pool** | **~24,400** | **~34,400** | **~62,600** | +| Route length | Ekubo | Uniswap v4 | Uniswap v3 | +| ---------------------------------------- | ----------- | ----------- | ----------- | +| 1 pool | 94,892 | 107,360 | 116,130 | +| 2 pools | 119,333 | 141,771 | 178,760 | +| 3 pools | 143,774 | 176,182 | 241,389 | +| **Marginal cost per extra pool, warmed** | **~24,400** | **~34,400** | **~62,600** | The marginals are linear to the gas unit because each added hop hits an -identically shaped fresh pool with its own distinct token contracts. At three -pools Ekubo costs about 18% less than v4 and 40% less than v3 in execution gas. -This is where the architecture compounds: each extra Ekubo hop is little more -than one pool-math call, while v3 replays a full settle-and-callback cycle per -pool. +identically shaped fresh pool with its own distinct token contracts. Warmed +marginals are the right measure inside a route — hops after the first execute +with warm router and transient state — but note the first hop of a real route +pays the cold path from the table above (about even for Ekubo, about 17k extra +for v4 and v3). At three pools Ekubo costs about 18% less than v4 and 40% less +than v3 in execution gas, before that first-hop adjustment widens both gaps +further. Token transfers tell part of the story. Ekubo and v4 both settle net inside one lock, so a route of any length moves exactly 2 token transfers: @@ -71,40 +71,43 @@ difference is inside the pool core. ## Where the savings come from -Frame-level attribution of the warmed single-hop swap, from Foundry flamegraphs -(each capture is one warmed swap call; frame widths are proportional to gas): +Frame-level attribution of the cold-path single-hop swap, from Foundry +flamegraphs (each capture is one swap call on a capitalized pool; frame widths +are proportional to gas): ![Flamegraph of an Ekubo single-hop swap](/gas/flamegraph-ekubo-single.svg) _Flamegraph: Ekubo single-hop swap, 87,384 gas._ ![Flamegraph of a Uniswap v4 single-hop swap](/gas/flamegraph-v4-single.svg) -_Flamegraph: Uniswap v4 single-hop swap via a minimal locker, 103,548 gas._ +_Flamegraph: Uniswap v4 single-hop swap via a minimal locker, 120,648 gas._ ![Flamegraph of a Uniswap v3 single-hop swap](/gas/flamegraph-v3-single.svg) -_Flamegraph: Uniswap v3 single-hop swap via direct pool call, 87,575 gas._ +_Flamegraph: Uniswap v3 single-hop swap via direct pool call, 104,675 gas._ -| Component | Ekubo (87,416) | Uniswap v4 (103,548) | Uniswap v3 (87,553) | +| Component | Ekubo (87,384) | Uniswap v4 (120,648) | Uniswap v3 (104,675) | | -------------------------------------- | --------------------------- | ---------------------------------- | --------------------------------- | -| Pool math and state | Core swap: **20,331** (23%) | PoolManager swap: **28,884** (28%) | Pool internals: **~59,600** (68%) | -| Settlement (transfers plus accounting) | ~45,600 | ~39,700 | ~27,900 | -| Router and lock overhead | ~23,100 | ~27,800 | 0 (direct call) | +| Pool math and state | Core swap: **20,331** (23%) | PoolManager swap: **45,984** (38%) | Pool internals: **~76,700** (73%) | +| Settlement (transfers plus accounting) | ~45,700 | ~40,700 | ~28,000 | +| Router and lock overhead | ~21,400 | ~32,000 | 0 (direct call) | Three observations: - **The pool core is where Ekubo wins.** One packed storage word and Q64 - fixed-point math cost about 20k per swap, against 29k for v4's multi-slot - state with protocol-fee and donation bookkeeping, and about 60k for v3's + fixed-point math cost about 20k per swap, against 46k for v4's multi-slot + state with protocol-fee and donation bookkeeping, and about 77k for v3's slot0, fee-growth, and per-swap oracle writes. -- **Ekubo spends the savings back on settlement and routing.** Flash-accounting - settlement (~46k) costs more than v4's (~40k) and v3's (~28k), and the - Solidity Router's ABI round-trip (~23k) matches v4's lock overhead. On a - single hop these cancel out against v3; the till pattern only pulls ahead as - routes grow (see the transfer and marginal tables above). -- **Warmed pools matter.** An earlier cold-access variant of this comparison - overstated the single-hop gap by about 17k on v4 and v3, because their pool - state is cold-sensitive while Ekubo's is nearly warmth-invariant (verified: - identical totals with and without warm-up, and with unrelated preceding - operations). All figures above are warmed steady-state. +- **Ekubo spends part of it back on settlement and routing.** Flash-accounting + settlement (~46k) costs slightly more than v4's (~41k) and v3's (~28k), and + the Solidity Router's ABI round-trip (~21k) nearly matches v4's lock + overhead. The net win comes from the pool core, and it compounds across + multi-pool routes, where Ekubo adds one cheap pool call per hop instead of + another full settle-and-callback cycle. +- **Warm and cold were both measured.** Repeating any swap on an already-touched + pool costs about 17k less on v4 and v3 (cold fee, oracle, and donation slots), + while Ekubo measures identical to the gas unit either way — its swap path has + almost no cold-sensitive slots outside the common base. An earlier draft of + this page reported only warmed numbers, which understated v4 and v3 single-hop + costs by that 17k; the tables above use the cold path throughout. ## Mainnet-fork validation @@ -124,7 +127,7 @@ through the real SwapRouter against the deepest fee-100 pool, v4 through the real PoolManager against the deepest fee-100 pool with a minimal locker (the Universal Router would cost more). Fee tiers differ across venues, which does not affect the code path; output matched the production quoter within 0.02% on -the Ekubo leg. +the Ekubo leg, and both swap directions reconcile across all three harnesses. ## The mint exception @@ -149,10 +152,16 @@ optimized for the operation that happens orders of magnitude more often. - Each protocol compiles under its own production settings (Ekubo: solc 0.8.33, 9999999 optimizer runs, via IR, Osaka; v4: v4-core's own settings; v3: - canonical mainnet bytecode), with Foundry 1.8.3 throughout. -- One identical token artifact runs in every lab harness, and every recipient - is pre-funded, so no measured call pays a zero-to-nonzero balance write that - the others avoid. + canonical mainnet bytecode), with Foundry 1.8.3 throughout, each test on a + fresh chain. +- One identical token artifact runs in every lab harness, every recipient is + pre-funded, and multihop routers deliver output to the calling user, so no + measured call pays a zero-to-nonzero balance write the others avoid. (An + early v4 multihop router that retained output broke this rule and was fixed; + the numbers did not move.) +- Single-hop figures are cold-path on capitalized pools; scaling marginals are + warmed, matching in-route conditions. `vm.cool` was verified to be a no-op + in this setup and is not used. - Routers are deliberately minimal on all sides, which favors Uniswap: production routers (Universal Router, SwapRouter, and Ekubo's own frontend path) all cost more than the numbers above. @@ -160,8 +169,7 @@ optimized for the operation that happens orders of magnitude more often. hooks, no Ekubo extensions, both swap directions covered, concentrated pools throughout. - v3 per-hop figures exclude end-of-transaction refunds (about 4.8k per hop - from clearing intermediate balances), so the true v3 marginal is closer to - 58k than 63k — the ranking is unaffected. + from clearing intermediate balances) — the ranking is unaffected. - Any v4 hook or Ekubo extension changes every number on this page; pool configuration (fee, tick spacing) barely moves them. From 1b77acd49ac643d7edb796fe660fc6e8f8fa90ff Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 00:46:11 -0400 Subject: [PATCH 04/17] Finalize gas efficiency page on steady-state benchmarks Rebase the headline single-swap comparison on steady-state figures (Ekubo 87,416 / v4 103,533 / v3 87,641) with the first-swap-in-fresh-pool surcharge (+17,100 on v4 and v3) and v3's next-block oracle write (93,712) noted alongside. Keep execution gas and transaction gas on separate, labeled bases and recompute every percentage. - Scaling table reports v3 net of refunds with gross and refund shown; marginals 24,429 / 34,411 / 58,696 (78,596 gross). - Fork table carries both swap sizes, pool identifiers, hook confirmation, output amounts, and recipient/allowance conditions. - Mint table tiered by new position vs top-up vs NFT (harness-only). - New pool-init, calldata, and chain-capacity sections; fork transaction totals recomputed (133,750 / 151,515 / 155,960). - Flamegraph captions state the router call frame and the SVG root. - Methodology corrected: isolate gives each call its own EVM context and the snapshot is the call's execution gas net of refunds, not receipt gas. - benchmarks/README.md matched to the current test names and numbers; reference calldata files added for the v4 minimal locker and the Ekubo Solidity Router; harness comments on isolate semantics corrected. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 193 ++++++-- benchmarks/fork/ForkEkuboReal.t.sol | 65 ++- benchmarks/fork/ForkEkuboRealTest.json | 5 +- benchmarks/fork/ForkProbe.t.sol | 94 +++- benchmarks/fork/ForkV3.t.sol | 74 ++- benchmarks/fork/ForkV3Test.json | 6 +- benchmarks/fork/ForkV4.t.sol | 52 ++- benchmarks/fork/ForkV4Test.json | 5 +- .../fork/calldata-ekubo-solidity-router.hex | 1 + .../fork/calldata-swaprouter-exactinput.hex | 1 + .../fork/calldata-universal-router-v3swap.hex | 1 + .../fork/calldata-v4-minimal-locker.hex | 1 + benchmarks/fork/calldata-yul-route-100.hex | 1 + benchmarks/fork/calldata-yul-route.hex | 1 + benchmarks/lab/ekubo/EkuboGas.t.sol | 203 +++++--- benchmarks/lab/ekubo/EkuboGasTest.json | 13 +- benchmarks/lab/ekubo/Flame.t.sol | 13 +- benchmarks/lab/v3/Flame.t.sol | 19 +- benchmarks/lab/v3/V3Gas.t.sol | 184 ++++++-- benchmarks/lab/v3/V3GasTest.json | 34 +- benchmarks/lab/v4/Flame.t.sol | 13 +- benchmarks/lab/v4/V4Gas.t.sol | 202 ++++---- benchmarks/lab/v4/V4GasTest.json | 18 +- public/gas/flamegraph-ekubo-single.svg | 4 +- public/gas/flamegraph-v3-single.svg | 4 +- public/gas/flamegraph-v4-single.svg | 4 +- src/content/docs/concepts/gas-efficiency.md | 441 ++++++++++++------ 27 files changed, 1149 insertions(+), 503 deletions(-) create mode 100644 benchmarks/fork/calldata-ekubo-solidity-router.hex create mode 100644 benchmarks/fork/calldata-swaprouter-exactinput.hex create mode 100644 benchmarks/fork/calldata-universal-router-v3swap.hex create mode 100644 benchmarks/fork/calldata-v4-minimal-locker.hex create mode 100644 benchmarks/fork/calldata-yul-route-100.hex create mode 100644 benchmarks/fork/calldata-yul-route.hex diff --git a/benchmarks/README.md b/benchmarks/README.md index 6266923..32147df 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -6,59 +6,147 @@ measures code and architecture rather than compiler tuning. ## Layout -- `lab/ekubo/` — Ekubo EVM swaps and mints (`EkuboGas.t.sol`, plus `Flame.t.sol` - for single-call flamegraph captures) -- `lab/v4/` — Uniswap v4 swaps and mints (`V4Gas.t.sol`, `Flame.t.sol`) -- `lab/v3/` — Uniswap v3 swaps and mints (`V3Gas.t.sol`, `Flame.t.sol`) +- `lab/ekubo/` — Ekubo EVM swaps, mints and pool initialization (`EkuboGas.t.sol`, plus + `Flame.t.sol` for the single-call flamegraph capture) +- `lab/v4/` — Uniswap v4 (`V4Gas.t.sol`, `Flame.t.sol`) +- `lab/v3/` — Uniswap v3 (`V3Gas.t.sol`, `Flame.t.sol`) - `lab/MockERC20.json` — the single shared token artifact deployed via `deployCode` in all three lab harnesses, so ERC20 transfer costs cannot skew the comparison - `fork/` — mainnet-fork validation at pinned block `25991868` - (`ForkEkuboReal.t.sol`, `ForkV3.t.sol`, `ForkV4.t.sol`, plus the pool probe) - -`remappings.txt` files contain the original absolute paths and will need adjusting; -the dependency pins below are what matter for reproduction. + (`ForkEkuboReal.t.sol`, `ForkV3.t.sol`, `ForkV4.t.sol`, `ForkProbe.t.sol` for the + pool identifiers, `calldata-*.hex` reference calldata, `encode-yul-route.mjs`) + +`*.json` files next to each harness are the raw `snapshots/` output of a full run of +that test contract. `remappings.txt` files contain the original absolute paths and will +need adjusting; the dependency pins below are what matter for reproduction. + +## What a snapshot number is + +Every measured call runs under `forge-config: default.isolate = true`. Isolation gives +each top-level call from the test its own EVM context, so end-of-call refunds are +applied, and `vm.snapshotGasLastCall` reports **the execution gas of that call, net of +refunds**. It does not include the 21,000 transaction base cost or calldata: the +`Flame.t.sol` captures are not isolated, and the flamegraph frame for the same router +call (for example `Router.swapAllowPartialFill` at 87,416) equals the isolated snapshot +(`ekubo single erc20 steady-state`, 87,416) to the gas unit. Transaction totals on the +docs page are computed as snapshot + 21,000 + the calldata gas of a reference file. + +Refunds matter for one series. Isolated snapshots are post-refund, capped at 1/5 of gas +used (EIP-3529). The v3 harness records `gross before refund` and `refund granted` next to +every snapshot via `vm.lastCallGas`; every Ekubo and v4 measurement has zero refund, so +their snapshot, `lastCallGas().gasTotalUsed`, and a `gasleft()` delta around the call +agree to within the caller's own overhead (roughly 1–5k, the CALL and argument copying). ## Lab methodology (all three harnesses) -Each test runs on a fresh chain (`isolate`). Pools are capitalized in setup; -single-hop figures are cold-path measurements with no warm-up swap, the closest -a harness gets to a real user's transaction (which always starts cold). -Multihop scaling figures are warmed (one identical unmeasured route first), -matching in-route conditions where later hops execute warm. `vm.cool` was -verified to be a no-op in this setup and is not used. +Each test runs on a fresh chain. Pools are capitalized in setup, and every measured call +is preceded by one identical unmeasured warm-up call, so the number is the steady state +of an established pool. A separate `first swap in fresh pool` measurement has no warm-up: +on v3 and v4 it costs exactly 17,100 more (one zero-to-nonzero SSTORE of the fee-growth +accumulator, 20,000 instead of 2,900; confirmed with `vm.startStateDiffRecording`, which +shows one fresh slot for v3 and v4 and none for Ekubo). Ekubo's `initializePool` writes +both fee-per-liquidity slots up front, so its first swap costs the same as any other +(87,384 against 87,416 in steady state). -- Concentrated pools, 0.3% fee, range covering the whole tick space, so no swap +- Concentrated pools, 0.3% fee, one position covering the whole tick space, so no swap crosses an initialized tick. -- Pools initialized strictly inside a tick (Ekubo tick 3050, Uniswap tick 30), - never on a tick-spacing multiple. -- Economically matched liquidity: 1M tokens a side on Ekubo and v4; v3 mints - the equivalent liquidity (998501199320305883812938) computed for the same - amounts. -- 1-token exact-input swaps, both directions; multihop routers deliver output - to the calling user (an earlier v4 router that retained output was fixed; - the numbers did not move). -- Gas read with `vm.snapshotGasLastCall` immediately after the measured call; - `*.json` files are the raw snapshots. `Flame.t.sol` files capture one - cold-path call each for the flamegraphs. - -Scaling series (1/2/3 hops) use the same minimal single-lock router per protocol, -so the per-hop marginal is apples-to-apples. Mint tests are subsequent mints -with boundary ticks already initialized. +- Pools initialized strictly inside a tick (Ekubo tick 3050, Uniswap tick 30), never on a + tick-spacing multiple. +- Economically matched liquidity: 1M tokens a side on Ekubo and v4; v3 mints the + equivalent liquidity (998501199320305883812938) computed for the same amounts. +- 1-token exact-input swaps, both directions. Every recipient already holds the output + token, and every router delivers output to the calling user, so no measured call pays + a zero-to-nonzero balance write the others avoid. +- Each protocol's cheapest path an externally owned account can actually send: + - Ekubo: the production Solidity `Router`. Its single-swap entry + (`swapAllowPartialFill`, tests `test_gas_single_*`) and its multihop entry + (`multihopSwap`, tests `test_gas_route_*`; the 1-pool route is the same swap + through the multihop path). A true-minimal locker (`MinimalEkuboLocker`: lock, + swap, pay, withdraw; tests `test_gas_single_*_minimalLocker`) is recorded for a + like-for-like comparison with the v4 minimal locker. + - v4: a true-minimal locker (`MinimalSwapRouter`: unlock, swap, settle, take; tests + `test_gas_single_*`) and a single-lock multihop router (`MultiHopRouter`, tests + `test_gas_route_*`). v4-core's own `PoolSwapTest` helper is recorded for context + only. + - v3: a minimal router with the production SwapRouter's token flow (`V3MultiHopRouter`: + first hop paid straight from the user, intermediate outputs held by the router, + last hop delivered to the user; tests `test_gas_route_*`). A direct `pool.swap` + with the test contract as callback payer (tests `test_gas_single_*`) is recorded + as a lower bound; an EOA cannot be a v3 callback payer, so that path is not + sendable, and its figure moves by a few gas whenever the test contract's function + set changes (the callback runs in the test contract). +- v3 oracle: the observation is written only by a swap that changes the tick, at most + once per block. Two tests isolate it (`test_gas_single_tickChange_sameBlock`, 89,836, + write skipped; `test_gas_single_tickChange_nextBlock`, 93,712, observation written): + 3,876 gas for the write. +- Mints are new positions with both boundary ticks already initialized (different salt + on Ekubo and v4, different owner on v3); v3 also records adding to an existing position + (`test_gas_mint_samePosition`, 121,496 net). Every v3 mint earns a 2,800 refund. +- Pool initialization (`test_gas_initializePool`): Ekubo `initializePool` 92,935, v4 + `initialize` 51,812, v3 `initialize` 70,328 plus `createPool` 4,558,970 (v3 deploys a + pool contract). Ekubo's extra cost over v4 is the two fee-accumulator slots it + pre-writes. +- `Flame.t.sol` files capture one steady-state call for the flamegraphs; their router + call frames equal the corresponding snapshots (Ekubo Router single 87,416, v4 minimal + locker single 103,533, v3 1-pool route 94,078). The root frame of each SVG also + includes the test's own call into the router. +- Native input: the Ekubo and v4 paths each make exactly two value-bearing calls (the + user's call into the router, then one internal call into Core or PoolManager). + +`vm.cool` was tried while building the harnesses and changed no figure; no committed +test uses it. Switching the v3 and v4 harnesses from `cancun` to `osaka` was checked and +changes none of their swap figures. ## Fork methodology -Same pair (USDT to USDC), same size (1000 USDT), warmed with one identical -unmeasured swap, negligible tick movement (0–1 ticks), pinned block `25991868`: - -- Ekubo: the live USDC/USDT concentrated pool (spacing 50, core-only) through - the deployed production Yul router, with SDK-generated calldata - (see `fork/encode-yul-route.mjs`). -- v3: the deepest fee-100 pool through the deployed SwapRouter. -- v4: the deepest fee-100 pool through the deployed PoolManager with a minimal - locker (the Universal Router would cost more). - -USDT returns no returndata, so approvals and v4 settlement use low-level calls, -mirroring production routers. +Same pair (USDT to USDC), two sizes (1000 and 100 USDT), warmed with one identical +unmeasured swap, negligible tick movement (0–1 ticks), pinned block `25991868` +(timestamp 1789583783, gas limit 59,999,943), every call isolated as above. The +recipient (the test contract) holds USDT via `deal`, holds USDC after the warm-up +(asserted), and has granted a max approval to the router under test (asserted). Output +amounts are recorded as snapshot values (USDC, 6 decimals): Ekubo 999176773 / 99920076, +v4 999169404 / 99917651, v3 999209400 / 99920949. + +| Leg | Pool | Fee | Spacing | Liquidity at block | Tick | +| ----- | --------------------------------------------------------------------------------------------------------------------------- | ------- | ------- | ---------------------- | ---- | +| Ekubo | USDC/USDT concentrated, no extension, id `0x6fde3244f6fa747ae318aba6e982a4281febad4d22e3b134aa299a83a951895d` | 0.0005% | 50 | 112,376,153,849,596 | 791 | +| v3 | `0x3416cF6C708Da44DB2624D63ea0AAef7113527C6` (deepest of fee 100/500/3000; cardinality 180) | 0.01% | 1 | 28,940,573,461,011,247 | 6 | +| v4 | id `0xe018f09af38956affdfeab72c2cefbcd4e6fee44d09df7525ec9dba3e51356a5`, hooks = `address(0)` (deepest of fee 100/500/3000) | 0.01% | 1 | 380,576,221,280,057 | 6 | + +Ekubo's config word `0x…53e2d6238da480000032` decodes as fee `0x53e2d6238da4` / 2^64 +(0.0005%), concentrated flag set, tick spacing 50 (Ekubo ticks are 100x finer than +Uniswap's). The v4 key with `hooks = address(0)` is confirmed initialized (nonzero +`sqrtPriceX96`), so the pool has no hook. + +- Ekubo: the live pool through the deployed production Yul router, with SDK-generated + calldata (`encode-yul-route.mjs`; `calldata-yul-route.hex` for 1000 USDT, + `calldata-yul-route-100.hex` for 100 USDT). Regenerating the 1000 USDT route from the + SDK reproduces the committed bytes exactly. Output matched the production quoter within + 0.02%. +- v3: the deployed SwapRouter `exactInputSingle` (128,611 at both sizes). +- v4: the deployed PoolManager with a minimal locker (the Universal Router would cost + more). USDT returns no returndata, so approvals and v4 settlement use low-level calls, + mirroring production routers. +- WETH: one `deposit` in steady state (the account already holds WETH), 27,938: what a + v3 user pays to wrap ETH before a native-input swap (`test_fork_weth_deposit`). + +Calldata reference files (exact byte counts verified by decoding each file): + +| Router call | File | Bytes | Zero / nonzero | Calldata gas | EIP-7623 floor | +| -------------------------------- | -------------------------------------- | ----- | -------------- | ------------ | -------------- | +| Ekubo Yul router route | `calldata-yul-route.hex` | 184 | 68 / 116 | 2,128 | 5,320 | +| Ekubo Solidity `Router` single | `calldata-ekubo-solidity-router.hex` | 132 | 76 / 56 | 1,200 | 3,000 | +| SwapRouter `exactInputSingle` | `calldata-swaprouter-exactinput.hex` | 260 | 188 / 72 | 1,904 | 4,760 | +| Universal Router single V3 swap | `calldata-universal-router-v3swap.hex` | 548 | 464 / 84 | 3,200 | 8,000 | +| Minimal v4 locker (fork harness) | `calldata-v4-minimal-locker.hex` | 292 | 175 / 117 | 2,572 | 6,430 | + +The Yul route and the two Uniswap files are captured production calldata; the Solidity +`Router` and minimal-locker files are the ABI encoding of the same 1000 USDT swap +(`swapAllowPartialFill` on the Ekubo fork pool key, and `ForkSwapRouter.swap` with the +fork test's arguments) and decode back to those arguments with `cast calldata-decode`. +Calldata gas is 4 per zero byte and 16 per nonzero byte; the floor is 10 gas per token +(one per zero byte, four per nonzero byte) and applies only when it exceeds the whole +transaction's regular gas, which it never does here. ## Dependency pins @@ -69,7 +157,9 @@ mirroring production routers. `optimizer_runs = 44444444`, `via_ir`, `evm_version = "cancun"` (v4-core's own `foundry.toml`). - Uniswap `v3-core` `1.0.1` canonical mainnet bytecode - (`@uniswap/v3-core` npm artifacts, `UniswapV3Factory.json`). + (`@uniswap/v3-core` npm artifacts, `UniswapV3Factory.json`); the harness driver + compiles with solc `0.8.26`, `evm_version = "cancun"`. +- Fork harness: `evm_version = "osaka"` for all three legs, solc auto-detected per file. - Shared token: solmate `MockERC20` bytecode in `lab/MockERC20.json`. - Yul router SDK (`ekubo/yul-router`) for the fork calldata generation. - Foundry `1.8.3`. @@ -79,22 +169,25 @@ mirroring production routers. ```sh # v4 (from a checkout of Uniswap/v4-core@46c6834 with submodules initialized) forge test --match-contract V4GasTest -forge snapshot --match-contract V4GasTest # ekubo (remap ekubo/, forge-std/, solady/ at the pins above, deployCode the token) forge test --offline --match-contract EkuboGasTest -forge snapshot --offline --match-contract EkuboGasTest # v3 (needs UniswapV3Factory.json next to MockERC20.json under artifacts/) forge test --match-contract V3GasTest -forge snapshot --match-contract V3GasTest + +# flamegraphs (one per harness) +forge test --match-contract FlameTest --flamegraph --no-open ``` +Run a whole test contract at a time: Foundry rewrites `snapshots/.json` with +only the tests that ran. + ## Reproducing the fork numbers ```sh forge test --fork-url --fork-block-number 25991868 \ - --match-contract 'ForkEkuboRealTest|ForkV3Test|ForkV4Test' + --match-contract 'ForkEkuboRealTest|ForkV3Test|ForkV4Test|ForkProbeTest' ``` Deployed addresses used (all verified on-chain, none memorized): @@ -110,6 +203,6 @@ Deployed addresses used (all verified on-chain, none memorized): | USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | | USDT | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | -The v3/v4 fork pools (USDT/USDC fee-100, deepest at the pinned block) were -selected by on-chain probing (see `ForkProbe.t.sol`); the Ekubo pool came from -the production quoter. +The v3/v4 fork pools (USDT/USDC fee-100, deepest at the pinned block) were selected by +on-chain probing (`ForkProbe.t.sol`, which also reads the Ekubo pool state directly from +Core storage); the Ekubo pool came from the production quoter. diff --git a/benchmarks/fork/ForkEkuboReal.t.sol b/benchmarks/fork/ForkEkuboReal.t.sol index d0ba008..adae89e 100644 --- a/benchmarks/fork/ForkEkuboReal.t.sol +++ b/benchmarks/fork/ForkEkuboReal.t.sol @@ -6,30 +6,35 @@ import {Test} from "forge-std/Test.sol"; interface IERC20Like { function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); } /// @notice Mainnet-fork validation against a REAL live Ekubo pool (USDC/USDT, /// concentrated, spacing 50, core-only extension) through the REAL /// production Yul router, with SDK-generated calldata (see below). -/// One unmeasured warm-up swap precedes the measured swap (steady state), -/// and the size (1000 USDT) matches the production quoter. +/// One unmeasured warm-up swap precedes the measured swap (steady state); +/// every call runs isolated in its own EVM context, so the snapshot is the +/// execution gas of the measured swap (net of refunds, excluding the 21,000 +/// base and calldata) with cold access at the start. /// -/// Calldata generated with the yul-router SDK: -/// bun scripts/encode-once.mjs (encodeRoute, specifiedToken USDT, -/// calculatedToken USDC, specifiedAmount 1000000000, -/// calculatedAmountThreshold 990000000, recipient = test contract, -/// single core hop on the pool key below) -/// Run with: --fork-url --fork-block-number +/// Calldata generated with the yul-router SDK (`encode-yul-route.mjs`): +/// encodeRoute({ specifiedToken: USDT, calculatedToken: USDC, +/// specifiedAmount, calculatedAmountThreshold = 99% of it, +/// recipient = this test contract, one core hop on the pool key below }) +/// Run with: --fork-url --fork-block-number 25991868 contract ForkEkuboRealTest is Test { address constant YUL_ROUTER = 0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748; address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - // Pool: token0 USDC, token1 USDT, concentrated spacing 50, no extension. + // Pool: token0 USDC, token1 USDT, concentrated spacing 50, no extension, + // fee 0x53e2d6238da4 / 2^64 (0.0005%). // Config: 0x0000000000000000000000000000000000000000000053e2d6238da480000032 - bytes constant ROUTE = hex"0100dAC17F958D2ee523a2206206994597C13D831ec7A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480000000000000000000000003b0233807FA9385bE102ac3EAc297483Dd6233D62b3e14960000000000000000000000003b9aca000000A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48dAC17F958D2ee523a2206206994597C13D831ec70000000000000000000000000000000000000000000053e2d6238da48000003200000000000000000000000000000000"; + bytes constant ROUTE_1000 = hex"0100dAC17F958D2ee523a2206206994597C13D831ec7A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480000000000000000000000003b0233807FA9385bE102ac3EAc297483Dd6233D62b3e14960000000000000000000000003b9aca000000A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48dAC17F958D2ee523a2206206994597C13D831ec70000000000000000000000000000000000000000000053e2d6238da48000003200000000000000000000000000000000"; + bytes constant ROUTE_100 = hex"0100dAC17F958D2ee523a2206206994597C13D831ec7A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB4800000000000000000000000005e69ec07FA9385bE102ac3EAc297483Dd6233D62b3e149600000000000000000000000005f5e1000000A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48dAC17F958D2ee523a2206206994597C13D831ec70000000000000000000000000000000000000000000053e2d6238da48000003200000000000000000000000000000000"; - function test_fork_realPool_yulRouter() public { + function _setup() internal { + assertEq(address(this), 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, "route recipient is this contract"); deal(USDT, address(this), 1000000000000); // USDT returns no returndata: approve via low-level call, not a // bool-returning interface (which would revert on decoding). @@ -37,21 +42,41 @@ contract ForkEkuboRealTest is Test { abi.encodeWithSignature("approve(address,uint256)", YUL_ROUTER, type(uint256).max) ); assertTrue(approved, "usdt approve"); + assertEq(IERC20Like(USDT).allowance(address(this), YUL_ROUTER), type(uint256).max, "max approval"); + } - // warm-up: steady state, unmeasured - (bool okWarm,) = YUL_ROUTER.call(ROUTE); - assertTrue(okWarm, "warm-up swap"); - - // measured leg + function _swap(bytes memory route, string memory snapshotName) internal returns (uint256 out) { uint256 usdcBefore = IERC20Like(USDC).balanceOf(address(this)); - (bool ok, bytes memory ret) = YUL_ROUTER.call(ROUTE); - assertTrue(ok, "measured swap"); - vm.snapshotGasLastCall("fork ekubo Yul router live USDC/USDT pool"); + (bool ok, bytes memory ret) = YUL_ROUTER.call(route); + if (bytes(snapshotName).length > 0) vm.snapshotGasLastCall(snapshotName); + assertTrue(ok, "swap"); (, , , int256 calculated) = abi.decode(ret, (address, address, int256, int256)); assertGt(calculated, 0); + out = IERC20Like(USDC).balanceOf(address(this)) - usdcBefore; + } + + /// forge-config: default.isolate = true + function test_fork_realPool_yulRouter_1000() public { + _setup(); + uint256 warm = _swap(ROUTE_1000, ""); // warm-up: steady state, unmeasured + assertGt(IERC20Like(USDC).balanceOf(address(this)), 0, "recipient USDC balance nonzero before measured swap"); + uint256 out = _swap(ROUTE_1000, "fork ekubo Yul router 1000 USDT->USDC live pool"); + vm.snapshotValue("fork ekubo output 1000 USDT->USDC (USDC, 6 decimals)", out); + emit log_named_uint("ekubo warm-up output (USDC)", warm); + emit log_named_uint("ekubo measured output (USDC)", out); // production quoter quoted ~999.36 USDC for this size; stay within 1% - uint256 out = IERC20Like(USDC).balanceOf(address(this)) - usdcBefore; assertGe(out, 989000000); assertLe(out, 1009000000); } + + /// forge-config: default.isolate = true + function test_fork_realPool_yulRouter_100() public { + _setup(); + _swap(ROUTE_100, ""); + uint256 out = _swap(ROUTE_100, "fork ekubo Yul router 100 USDT->USDC live pool"); + vm.snapshotValue("fork ekubo output 100 USDT->USDC (USDC, 6 decimals)", out); + emit log_named_uint("ekubo measured output (USDC)", out); + assertGe(out, 98900000); + assertLe(out, 100900000); + } } diff --git a/benchmarks/fork/ForkEkuboRealTest.json b/benchmarks/fork/ForkEkuboRealTest.json index 11cefa2..14d06f2 100644 --- a/benchmarks/fork/ForkEkuboRealTest.json +++ b/benchmarks/fork/ForkEkuboRealTest.json @@ -1,3 +1,6 @@ { - "fork ekubo Yul router live USDC/USDT pool": "110622" + "fork ekubo Yul router 100 USDT->USDC live pool": "110591", + "fork ekubo Yul router 1000 USDT->USDC live pool": "110622", + "fork ekubo output 100 USDT->USDC (USDC, 6 decimals)": "99920076", + "fork ekubo output 1000 USDT->USDC (USDC, 6 decimals)": "999176773" } \ No newline at end of file diff --git a/benchmarks/fork/ForkProbe.t.sol b/benchmarks/fork/ForkProbe.t.sol index abd1403..858bbe4 100644 --- a/benchmarks/fork/ForkProbe.t.sol +++ b/benchmarks/fork/ForkProbe.t.sol @@ -2,7 +2,6 @@ pragma solidity =0.8.26; import {Test, console2} from "forge-std/Test.sol"; -import {PoolManager} from "v4-core/src/PoolManager.sol"; import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/src/types/PoolKey.sol"; import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol"; @@ -10,16 +9,47 @@ import {Currency} from "v4-core/src/types/Currency.sol"; import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol"; +interface IUniswapV3FactoryLike { + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool); +} + +interface IUniswapV3PoolLike { + function liquidity() external view returns (uint128); + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint8 feeProtocol, + bool unlocked + ); +} + +/// @notice Identifies the three fork pools at the pinned block: address / id, fee, +/// spacing, hooks (v4), in-range liquidity and current tick, so the fork numbers +/// can be tied to concrete on-chain state. Read-only. +/// Run with: --fork-url --fork-block-number 25991868 contract ForkProbeTest is Test { using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; IPoolManager constant MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90); + IUniswapV3FactoryLike constant V3_FACTORY = IUniswapV3FactoryLike(0x1F98431c8aD98523631AE4a59f267346ea31F984); + address constant EKUBO_CORE = 0x00000000000014aA86C5d3c41765bb24e11bd701; address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + bytes32 constant EKUBO_CONFIG = 0x0000000000000000000000000000000000000000000053e2d6238da480000032; + + function test_probe_block() public view { + console2.log("block", block.number); + console2.log("timestamp", block.timestamp); + } - function test_probe_usdt_usdc_pools() public view { + function test_probe_usdt_usdc_pools_v4() public view { uint24[3] memory fees = [uint24(100), uint24(500), uint24(3000)]; int24[3] memory spacings = [int24(1), int24(10), int24(60)]; for (uint256 i = 0; i < 3; i++) { @@ -32,31 +62,45 @@ contract ForkProbeTest is Test { }); PoolId id = key.toId(); uint128 liq = MANAGER.getLiquidity(id); - (, int24 tick,,) = MANAGER.getSlot0(id); - console2.log("fee", fees[i]); - console2.log("liquidity", liq); - console2.log("tick", uint256(int256(tick))); + (uint160 sqrtP, int24 tick,,) = MANAGER.getSlot0(id); + console2.log("v4 fee", fees[i]); + console2.logBytes32(PoolId.unwrap(id)); + console2.log("v4 initialized (sqrtPrice != 0, hooks = 0)", sqrtP != 0); + console2.log("v4 liquidity", liq); + console2.log("v4 tick", tick); } } - function test_probe_weth_usdc_pools() public view { - uint24[4] memory fees = [uint24(100), uint24(500), uint24(3000), uint24(10000)]; - int24[4] memory spacings = [int24(1), int24(10), int24(60), int24(200)]; - for (uint256 i = 0; i < 4; i++) { - PoolKey memory key = PoolKey({ - currency0: Currency.wrap(USDC), - currency1: Currency.wrap(WETH), - fee: fees[i], - tickSpacing: spacings[i], - hooks: IHooks(address(0)) - }); - PoolId id = key.toId(); - uint128 liq = MANAGER.getLiquidity(id); - (uint160 sqrtP, int24 tick,,) = MANAGER.getSlot0(id); - console2.log("fee", fees[i]); - console2.log("liquidity", liq); - console2.log("tick", uint256(int256(tick))); - sqrtP; + function test_probe_usdt_usdc_pools_v3() public view { + uint24[3] memory fees = [uint24(100), uint24(500), uint24(3000)]; + for (uint256 i = 0; i < 3; i++) { + address pool = V3_FACTORY.getPool(USDC, USDT, fees[i]); + console2.log("v3 fee", fees[i]); + console2.log("v3 pool", pool); + if (pool == address(0)) continue; + (, int24 tick,, uint16 cardinality,,,) = IUniswapV3PoolLike(pool).slot0(); + console2.log("v3 liquidity", IUniswapV3PoolLike(pool).liquidity()); + console2.log("v3 tick", tick); + console2.log("v3 observation cardinality", cardinality); } } + + /// @dev Core stores the packed pool state at slot `poolId = keccak256(token0, token1, config)`: + /// sqrtRatio (96 bits) | tick (32 bits) | liquidity (128 bits). + function test_probe_usdc_usdt_pool_ekubo() public view { + bytes32 poolId = keccak256(abi.encode(USDC, USDT, EKUBO_CONFIG)); + bytes32 state = vm.load(EKUBO_CORE, poolId); + uint256 sqrtRatio = uint256(state) >> 160; + int32 tick = int32(uint32((uint256(state) >> 128) & 0xffffffff)); + uint128 liq = uint128(uint256(state)); + uint64 fee = uint64((uint256(EKUBO_CONFIG) >> 32) & 0xffffffffffffffff); + console2.log("ekubo config fee (Q64)", fee); + console2.log("ekubo config tick spacing", uint256(EKUBO_CONFIG) & 0x7fffffff); + console2.log("ekubo config concentrated", (uint256(EKUBO_CONFIG) & 0x80000000) != 0); + console2.logBytes32(poolId); + console2.log("ekubo sqrtRatio raw (96-bit)", sqrtRatio); + console2.log("ekubo tick", tick); + console2.log("ekubo liquidity", liq); + assertTrue(state != bytes32(0), "pool initialized"); + } } diff --git a/benchmarks/fork/ForkV3.t.sol b/benchmarks/fork/ForkV3.t.sol index 234e665..65baa75 100644 --- a/benchmarks/fork/ForkV3.t.sol +++ b/benchmarks/fork/ForkV3.t.sol @@ -18,17 +18,37 @@ interface ISwapRouter { function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); } +interface IERC20Like { + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); +} + +interface IWETH9 { + function deposit() external payable; + function balanceOf(address account) external view returns (uint256); +} + /// @notice Mainnet-fork validation: REAL SwapRouter + REAL USDT/USDC fee-100 pool -/// (deepest at the pinned block), 1000 USDT exact input, warmed with one -/// identical unmeasured swap (steady state). -/// USDT returns no returndata: approve via low-level call. -/// Run with: --fork-url --fork-block-number +/// (deepest at the pinned block), exact input, warmed with one identical +/// unmeasured swap (steady state); every call runs isolated in its own EVM +/// context, so the snapshot is the execution gas of the measured swap (net of +/// refunds, excluding the 21,000 base and calldata). +/// USDT returns no returndata: approve via low-level call. Run with: --fork-url --fork-block-number 25991868 contract ForkV3Test is Test { ISwapRouter constant ROUTER = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + IWETH9 constant WETH = IWETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); + + function _setup() internal { + deal(USDT, address(this), 1000000000000); + (bool approved,) = + USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(ROUTER), type(uint256).max)); + assertTrue(approved, "usdt approve"); + assertEq(IERC20Like(USDT).allowance(address(this), address(ROUTER)), type(uint256).max, "max approval"); + } - function _swap() internal returns (uint256) { + function _swap(uint256 amountIn) internal returns (uint256) { return ROUTER.exactInputSingle( ISwapRouter.ExactInputSingleParams({ tokenIn: USDT, @@ -36,24 +56,50 @@ contract ForkV3Test is Test { fee: 100, recipient: address(this), deadline: block.timestamp, - amountIn: 1000000000, + amountIn: amountIn, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }) ); } - function test_fork_single_exactInput_usdt() public { - deal(USDT, address(this), 1000000000000); - (bool approved,) = - USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(ROUTER), type(uint256).max)); - assertTrue(approved, "usdt approve"); - _swap(); // warm-up, unmeasured - uint256 out = _swap(); // measured - assertGt(out, 0); + /// forge-config: default.isolate = true + function test_fork_single_exactInput_usdt_1000() public { + _setup(); + _swap(1000000000); // warm-up, unmeasured + assertGt(IERC20Like(USDC).balanceOf(address(this)), 0, "recipient USDC balance nonzero before measured swap"); + uint256 out = _swap(1000000000); // measured vm.snapshotGasLastCall("fork v3 SwapRouter 1000 USDT->USDC fee-100"); + vm.snapshotValue("fork v3 output 1000 USDT->USDC (USDC, 6 decimals)", out); + emit log_named_uint("v3 measured output (USDC)", out); // sanity: stable pair near parity (1000 USDT in, ~1000 USDC out) assertGe(out, 990000000); assertLe(out, 1005000000); } + + /// forge-config: default.isolate = true + function test_fork_single_exactInput_usdt_100() public { + _setup(); + _swap(100000000); + uint256 out = _swap(100000000); + vm.snapshotGasLastCall("fork v3 SwapRouter 100 USDT->USDC fee-100"); + vm.snapshotValue("fork v3 output 100 USDT->USDC (USDC, 6 decimals)", out); + emit log_named_uint("v3 measured output (USDC)", out); + assertGe(out, 99000000); + assertLe(out, 100500000); + } + + /// @dev What a v3 user pays to wrap ETH before a native-input swap (v3 pools hold + /// WETH, not ETH). Steady state: the account already holds WETH (this address + /// does at the pinned block), so the balance write is nonzero -> nonzero. + /// forge-config: default.isolate = true + function test_fork_weth_deposit() public { + vm.deal(address(this), 10 ether); + uint256 before = WETH.balanceOf(address(this)); + assertGt(before, 0, "already holds WETH"); + WETH.deposit{value: 1 ether}(); // warm-up, unmeasured + WETH.deposit{value: 1 ether}(); + vm.snapshotGasLastCall("fork WETH deposit steady-state"); + assertEq(WETH.balanceOf(address(this)), before + 2 ether); + } } diff --git a/benchmarks/fork/ForkV3Test.json b/benchmarks/fork/ForkV3Test.json index 6d93979..6951aba 100644 --- a/benchmarks/fork/ForkV3Test.json +++ b/benchmarks/fork/ForkV3Test.json @@ -1,3 +1,7 @@ { - "fork v3 SwapRouter 1000 USDT->USDC fee-100": "128611" + "fork WETH deposit steady-state": "27938", + "fork v3 SwapRouter 100 USDT->USDC fee-100": "128611", + "fork v3 SwapRouter 1000 USDT->USDC fee-100": "128611", + "fork v3 output 100 USDT->USDC (USDC, 6 decimals)": "99920949", + "fork v3 output 1000 USDT->USDC (USDC, 6 decimals)": "999209400" } \ No newline at end of file diff --git a/benchmarks/fork/ForkV4.t.sol b/benchmarks/fork/ForkV4.t.sol index 9773ea9..ac903cf 100644 --- a/benchmarks/fork/ForkV4.t.sol +++ b/benchmarks/fork/ForkV4.t.sol @@ -12,6 +12,11 @@ import {TransientStateLibrary} from "v4-core/src/libraries/TransientStateLibrary import {CurrencySettler} from "v4-core/test/utils/CurrencySettler.sol"; import {TickMath} from "v4-core/src/libraries/TickMath.sol"; +interface IERC20Like { + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); +} + /// @notice Minimal single-lock exact-input swapper over a live v4 pool. /// Production Universal Router calldata would add overhead on top. contract ForkSwapRouter is IUnlockCallback { @@ -61,18 +66,23 @@ contract ForkSwapRouter is IUnlockCallback { } /// @notice Mainnet-fork validation: REAL PoolManager + REAL USDT/USDC fee-100 pool -/// (deepest at the pinned block), 1000 USDT exact input, warmed with one -/// identical unmeasured swap (steady state). -/// Run with: --fork-url --fork-block-number +/// (deepest at the pinned block, hooks = address(0)), exact input, warmed with +/// one identical unmeasured swap (steady state); every call runs isolated in its +/// own EVM context, so the snapshot is the execution gas of the measured swap +/// (net of refunds, excluding the 21,000 base and calldata). +/// Run with: --fork-url --fork-block-number 25991868 contract ForkV4Test is Test { IPoolManager constant MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90); address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - function test_fork_single_exactInput_usdt() public { - ForkSwapRouter router = new ForkSwapRouter(MANAGER); + ForkSwapRouter router; + PoolKey key; + + function _setup() internal { + router = new ForkSwapRouter(MANAGER); // USDT is currency1 (USDC < USDT): USDT->USDC is oneForZero - PoolKey memory key = PoolKey({ + key = PoolKey({ currency0: Currency.wrap(USDC), currency1: Currency.wrap(USDT), fee: 100, @@ -83,11 +93,35 @@ contract ForkV4Test is Test { (bool approved,) = USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(router), type(uint256).max)); assertTrue(approved, "usdt approve"); - router.swap(key, false, -1000000000, TickMath.MAX_SQRT_PRICE - 1, address(this)); // warm-up - uint256 out = router.swap(key, false, -1000000000, TickMath.MAX_SQRT_PRICE - 1, address(this)); // measured - assertGt(out, 0); + assertEq(IERC20Like(USDT).allowance(address(this), address(router)), type(uint256).max, "max approval"); + } + + function _swap(int256 amountIn) internal returns (uint256) { + return router.swap(key, false, amountIn, TickMath.MAX_SQRT_PRICE - 1, address(this)); + } + + /// forge-config: default.isolate = true + function test_fork_single_exactInput_usdt_1000() public { + _setup(); + _swap(-1000000000); // warm-up + assertGt(IERC20Like(USDC).balanceOf(address(this)), 0, "recipient USDC balance nonzero before measured swap"); + uint256 out = _swap(-1000000000); // measured vm.snapshotGasLastCall("fork v4 PoolManager 1000 USDT->USDC fee-100"); + vm.snapshotValue("fork v4 output 1000 USDT->USDC (USDC, 6 decimals)", out); + emit log_named_uint("v4 measured output (USDC)", out); assertGe(out, 990000000); assertLe(out, 1005000000); } + + /// forge-config: default.isolate = true + function test_fork_single_exactInput_usdt_100() public { + _setup(); + _swap(-100000000); + uint256 out = _swap(-100000000); + vm.snapshotGasLastCall("fork v4 PoolManager 100 USDT->USDC fee-100"); + vm.snapshotValue("fork v4 output 100 USDT->USDC (USDC, 6 decimals)", out); + emit log_named_uint("v4 measured output (USDC)", out); + assertGe(out, 99000000); + assertLe(out, 100500000); + } } diff --git a/benchmarks/fork/ForkV4Test.json b/benchmarks/fork/ForkV4Test.json index 97a07b1..266425d 100644 --- a/benchmarks/fork/ForkV4Test.json +++ b/benchmarks/fork/ForkV4Test.json @@ -1,3 +1,6 @@ { - "fork v4 PoolManager 1000 USDT->USDC fee-100": "132388" + "fork v4 PoolManager 100 USDT->USDC fee-100": "132424", + "fork v4 PoolManager 1000 USDT->USDC fee-100": "132388", + "fork v4 output 100 USDT->USDC (USDC, 6 decimals)": "99917651", + "fork v4 output 1000 USDT->USDC (USDC, 6 decimals)": "999169404" } \ No newline at end of file diff --git a/benchmarks/fork/calldata-ekubo-solidity-router.hex b/benchmarks/fork/calldata-ekubo-solidity-router.hex new file mode 100644 index 0000000..42625f2 --- /dev/null +++ b/benchmarks/fork/calldata-ekubo-solidity-router.hex @@ -0,0 +1 @@ +0x2f9deda5000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000053e2d6238da4800000320000000000000000000000000000000000000000000000003b9aca0080000000 diff --git a/benchmarks/fork/calldata-swaprouter-exactinput.hex b/benchmarks/fork/calldata-swaprouter-exactinput.hex new file mode 100644 index 0000000..fe9ad73 --- /dev/null +++ b/benchmarks/fork/calldata-swaprouter-exactinput.hex @@ -0,0 +1 @@ +0x414bf389000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000000640000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003b9aca00000000000000000000000000000000000000000000000000000000003b8b87c00000000000000000000000000000000000000000000000000000000000000000 diff --git a/benchmarks/fork/calldata-universal-router-v3swap.hex b/benchmarks/fork/calldata-universal-router-v3swap.hex new file mode 100644 index 0000000..b57f387 --- /dev/null +++ b/benchmarks/fork/calldata-universal-router-v3swap.hex @@ -0,0 +1 @@ +0x24856bc300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001400000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000003b9aca00000000000000000000000000000000000000000000000000000000003b8b87c000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000002bdac17f958d2ee523a2206206994597c13d831ec7000064a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 diff --git a/benchmarks/fork/calldata-v4-minimal-locker.hex b/benchmarks/fork/calldata-v4-minimal-locker.hex new file mode 100644 index 0000000..d57ba21 --- /dev/null +++ b/benchmarks/fork/calldata-v4-minimal-locker.hex @@ -0,0 +1 @@ +0x6e8c9dbf000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000064000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffc4653600000000000000000000000000fffd8963efd1fc6a506488495d951d5263988d250000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 diff --git a/benchmarks/fork/calldata-yul-route-100.hex b/benchmarks/fork/calldata-yul-route-100.hex new file mode 100644 index 0000000..d1c3db4 --- /dev/null +++ b/benchmarks/fork/calldata-yul-route-100.hex @@ -0,0 +1 @@ +0x0100dAC17F958D2ee523a2206206994597C13D831ec7A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB4800000000000000000000000005e69ec07FA9385bE102ac3EAc297483Dd6233D62b3e149600000000000000000000000005f5e1000000A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48dAC17F958D2ee523a2206206994597C13D831ec70000000000000000000000000000000000000000000053e2d6238da48000003200000000000000000000000000000000 diff --git a/benchmarks/fork/calldata-yul-route.hex b/benchmarks/fork/calldata-yul-route.hex new file mode 100644 index 0000000..57470a0 --- /dev/null +++ b/benchmarks/fork/calldata-yul-route.hex @@ -0,0 +1 @@ +0x0100dAC17F958D2ee523a2206206994597C13D831ec7A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB480000000000000000000000003b0233807FA9385bE102ac3EAc297483Dd6233D62b3e14960000000000000000000000003b9aca000000A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48dAC17F958D2ee523a2206206994597C13D831ec70000000000000000000000000000000000000000000053e2d6238da48000003200000000000000000000000000000000 diff --git a/benchmarks/lab/ekubo/EkuboGas.t.sol b/benchmarks/lab/ekubo/EkuboGas.t.sol index a61844a..e6d6c18 100644 --- a/benchmarks/lab/ekubo/EkuboGas.t.sol +++ b/benchmarks/lab/ekubo/EkuboGas.t.sol @@ -7,15 +7,16 @@ import {Core} from "ekubo/src/Core.sol"; import {Positions} from "ekubo/src/Positions.sol"; import {Router} from "ekubo/src/Router.sol"; import {PoolKey} from "ekubo/src/types/poolKey.sol"; -import {PoolConfig, createFullRangePoolConfig, createConcentratedPoolConfig} from "ekubo/src/types/poolConfig.sol"; -import {SqrtRatio} from "ekubo/src/types/sqrtRatio.sol"; +import {createConcentratedPoolConfig} from "ekubo/src/types/poolConfig.sol"; +import {SqrtRatio, MIN_SQRT_RATIO} from "ekubo/src/types/sqrtRatio.sol"; import {NATIVE_TOKEN_ADDRESS} from "ekubo/src/math/constants.sol"; -import {BaseRouter, RouteNode, TokenAmount, Swap} from "ekubo/src/base/BaseRouter.sol"; -import {createSwapParameters} from "ekubo/src/types/swapParameters.sol"; +import {RouteNode, TokenAmount, Swap} from "ekubo/src/base/BaseRouter.sol"; +import {SwapParameters, createSwapParameters} from "ekubo/src/types/swapParameters.sol"; +import {CoreLib} from "ekubo/src/libraries/CoreLib.sol"; import {BaseLocker} from "ekubo/src/base/BaseLocker.sol"; import {ICore} from "ekubo/src/interfaces/ICore.sol"; import {FlashAccountantLib} from "ekubo/src/libraries/FlashAccountantLib.sol"; -import {PositionId, createPositionId} from "ekubo/src/types/positionId.sol"; +import {createPositionId} from "ekubo/src/types/positionId.sol"; import {PoolBalanceUpdate} from "ekubo/src/types/poolBalanceUpdate.sol"; /// @notice Bare core-level liquidity provision: lock, updatePosition, settle net. @@ -53,11 +54,48 @@ contract BareMintRouter is BaseLocker { } } +/// @notice True-minimal single-hop exact-input locker, the Ekubo analogue of the v4 +/// MinimalSwapRouter: lock, swap, pay the input from the payer, withdraw the +/// output to the caller. Native input is forwarded as call value to Core. +contract MinimalEkuboLocker is BaseLocker { + using FlashAccountantLib for *; + using CoreLib for ICore; + + constructor(ICore core) BaseLocker(core) {} + + function swapExactIn(PoolKey memory key, SwapParameters params, address payer) + external + payable + returns (PoolBalanceUpdate balanceUpdate) + { + balanceUpdate = + abi.decode(lock(abi.encode(key, params, payer, msg.sender, msg.value)), (PoolBalanceUpdate)); + } + + function handleLockData(uint256, bytes memory data) internal override returns (bytes memory result) { + (PoolKey memory key, SwapParameters params, address payer, address recipient, uint256 value) = + abi.decode(data, (PoolKey, SwapParameters, address, address, uint256)); + (PoolBalanceUpdate balanceUpdate,) = ICore(payable(address(ACCOUNTANT))).swap(value, key, params); + if (balanceUpdate.delta0() > 0) { + if (key.token0 != NATIVE_TOKEN_ADDRESS) ACCOUNTANT.payFrom(payer, key.token0, uint128(balanceUpdate.delta0())); + } else if (balanceUpdate.delta0() < 0) { + ACCOUNTANT.withdraw(key.token0, recipient, uint128(-balanceUpdate.delta0())); + } + if (balanceUpdate.delta1() > 0) { + ACCOUNTANT.payFrom(payer, key.token1, uint128(balanceUpdate.delta1())); + } else if (balanceUpdate.delta1() < 0) { + ACCOUNTANT.withdraw(key.token1, recipient, uint128(-balanceUpdate.delta1())); + } + result = abi.encode(balanceUpdate); + } +} + contract EkuboGasTest is Test { Core core; Positions positions; Router router; BareMintRouter bareMint; + MinimalEkuboLocker minLocker; MockERC20 tokenA; MockERC20 tokenB; @@ -78,13 +116,14 @@ contract EkuboGasTest is Test { int32 constant RANGE_UPPER = 88722000; // +14787 * spacing int32 constant INIT_TICK = 3050; // strictly inside a tick, not on a spacing multiple uint128 constant LIQUIDITY_AMOUNT = 1_000_000 ether; - int128 constant SWAP_AMOUNT = 1 ether; + int128 constant SWAP_AMOUNT = 1 ether; - function setUp() public { + function setUp() public virtual { core = new Core(); positions = new Positions(core, address(this), 0, 1); router = new Router(core, address(0), address(0)); bareMint = new BareMintRouter(core); + minLocker = new MinimalEkuboLocker(core); // One identical token artifact (solmate MockERC20, deployed via deployCode) // in every harness, so transfer costs cannot skew the comparison. @@ -103,10 +142,10 @@ contract EkuboGasTest is Test { tokenD.mint(address(this), 10_000_000 ether); vm.deal(address(this), 10_000_000 ether); - keyAB = _fullRangeKey(address(tokenA), address(tokenB)); - keyBC = _fullRangeKey(address(tokenB), address(tokenC)); - keyCD = _fullRangeKey(address(tokenC), address(tokenD)); - keyNative = _fullRangeKey(NATIVE_TOKEN_ADDRESS, address(tokenB)); + keyAB = _concentratedKey(address(tokenA), address(tokenB)); + keyBC = _concentratedKey(address(tokenB), address(tokenC)); + keyCD = _concentratedKey(address(tokenC), address(tokenD)); + keyNative = _concentratedKey(NATIVE_TOKEN_ADDRESS, address(tokenB)); core.initializePool(keyAB, INIT_TICK); core.initializePool(keyBC, INIT_TICK); @@ -122,6 +161,7 @@ contract EkuboGasTest is Test { tokenC.approve(address(router), type(uint256).max); tokenA.approve(address(bareMint), type(uint256).max); tokenB.approve(address(bareMint), type(uint256).max); + tokenA.approve(address(minLocker), type(uint256).max); positions.mintAndDeposit(keyAB, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); positions.mintAndDeposit(keyBC, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); @@ -131,14 +171,10 @@ contract EkuboGasTest is Test { ); } - function _fullRangeKey(address t0, address t1) internal pure returns (PoolKey memory) { + function _concentratedKey(address t0, address t1) internal pure returns (PoolKey memory) { return PoolKey({token0: t0, token1: t1, config: createConcentratedPoolConfig(FEE_0_3, TICK_SPACING, address(0))}); } - // Steady-state methodology: one identical unmeasured warm-up precedes every - // measurement, so pools, tokens, and approvals sit in warmed nonzero slots. - // isolate=true still gives each test a fresh chain for reproducibility. - function _route(PoolKey[] memory keys) internal pure returns (RouteNode[] memory route) { route = new RouteNode[](keys.length); for (uint256 i = 0; i < keys.length; i++) { @@ -146,89 +182,130 @@ contract EkuboGasTest is Test { } } - /// forge-config: default.isolate = true - function test_gas_single_exactInput_erc20() public { + function _swapAB() internal { router.swapAllowPartialFill(keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0)); - router.swapAllowPartialFill( - keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0) + } + + function _multihop(uint256 n) internal { + PoolKey[] memory keys = new PoolKey[](n); + keys[0] = keyAB; + if (n > 1) keys[1] = keyBC; + if (n > 2) keys[2] = keyCD; + router.multihopSwap( + Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min ); - vm.snapshotGasLastCall("ekubo single exact-input token0->token1 concentrated"); } + // ---- single swaps through the Solidity Router ---- + /// forge-config: default.isolate = true - function test_gas_single_exactInput_erc20_reverse() public { + function test_gas_single_steady_erc20() public { + _swapAB(); // warm-up, unmeasured + _swapAB(); + vm.snapshotGasLastCall("ekubo single erc20 steady-state"); + } + + /// forge-config: default.isolate = true + function test_gas_single_steady_erc20_reverse() public { router.swapAllowPartialFill(keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, true, 0)); - router.swapAllowPartialFill( - keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, true, 0) - ); - vm.snapshotGasLastCall("ekubo single exact-input token1->token0 concentrated"); + router.swapAllowPartialFill(keyAB, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, true, 0)); + vm.snapshotGasLastCall("ekubo single erc20 reverse steady-state"); } /// forge-config: default.isolate = true - function test_gas_single_exactInput_native() public { + function test_gas_single_steady_native() public { router.swapAllowPartialFill{value: uint128(SWAP_AMOUNT)}( keyNative, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0) ); router.swapAllowPartialFill{value: uint128(SWAP_AMOUNT)}( keyNative, createSwapParameters(SqrtRatio.wrap(0), SWAP_AMOUNT, false, 0) ); - vm.snapshotGasLastCall("ekubo single exact-input native->ERC20 concentrated"); + vm.snapshotGasLastCall("ekubo single native steady-state"); } + /// @dev No warm-up: the very first swap in a freshly capitalized pool. Core pre-writes + /// the fee-per-liquidity accumulator slots in `initializePool`, so this should + /// cost the same as steady state. /// forge-config: default.isolate = true - function test_gas_oneHop_multihopPath() public { - PoolKey[] memory keys = new PoolKey[](1); - keys[0] = keyAB; - router.multihopSwap( - Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min - ); - router.multihopSwap( - Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min - ); - vm.snapshotGasLastCall("ekubo one-hop exact-input single lock concentrated"); + function test_gas_single_firstSwap_erc20() public { + _swapAB(); + vm.snapshotGasLastCall("ekubo single erc20 first swap in fresh pool"); + } + + // ---- single swaps through the minimal locker (same shape as the v4 minimal locker) ---- + + // The Router substitutes the extreme limit for a zero limit; Core itself requires an + // explicit one, so the minimal locker passes MIN_SQRT_RATIO (token0 in, price decreasing). + function _minSwapAB() internal { + minLocker.swapExactIn(keyAB, createSwapParameters(MIN_SQRT_RATIO, SWAP_AMOUNT, false, 0), address(this)); } /// forge-config: default.isolate = true - function test_gas_twoHop_exactInput() public { - PoolKey[] memory keys = new PoolKey[](2); - keys[0] = keyAB; - keys[1] = keyBC; - router.multihopSwap( - Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min - ); - router.multihopSwap( - Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min - ); - vm.snapshotGasLastCall("ekubo two-hop exact-input single lock concentrated"); + function test_gas_single_steady_erc20_minimalLocker() public { + _minSwapAB(); + _minSwapAB(); + vm.snapshotGasLastCall("ekubo single erc20 steady-state minimal locker"); } /// forge-config: default.isolate = true - function test_gas_threeHop_exactInput() public { - PoolKey[] memory keys = new PoolKey[](3); - keys[0] = keyAB; - keys[1] = keyBC; - keys[2] = keyCD; - router.multihopSwap( - Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min + function test_gas_single_steady_native_minimalLocker() public { + minLocker.swapExactIn{value: uint128(SWAP_AMOUNT)}( + keyNative, createSwapParameters(MIN_SQRT_RATIO, SWAP_AMOUNT, false, 0), address(this) ); - router.multihopSwap( - Swap(_route(keys), TokenAmount({token: address(tokenA), amount: SWAP_AMOUNT})), type(int256).min + minLocker.swapExactIn{value: uint128(SWAP_AMOUNT)}( + keyNative, createSwapParameters(MIN_SQRT_RATIO, SWAP_AMOUNT, false, 0), address(this) ); - vm.snapshotGasLastCall("ekubo three-hop exact-input single lock concentrated"); + vm.snapshotGasLastCall("ekubo single native steady-state minimal locker"); + } + + // ---- routes through the Router's single-lock multihop path, warmed ---- + + /// forge-config: default.isolate = true + function test_gas_route_1pool() public { + _multihop(1); + _multihop(1); + vm.snapshotGasLastCall("ekubo route 1 pool steady-state"); + } + + /// forge-config: default.isolate = true + function test_gas_route_2pools() public { + _multihop(2); + _multihop(2); + vm.snapshotGasLastCall("ekubo route 2 pools steady-state"); } /// forge-config: default.isolate = true - function test_gas_mint_bare() public { + function test_gas_route_3pools() public { + _multihop(3); + _multihop(3); + vm.snapshotGasLastCall("ekubo route 3 pools steady-state"); + } + + // ---- liquidity provision ---- + + /// @dev Warm-up mint under one salt, measured mint under another: a brand-new position + /// with both boundary ticks already initialized (bare core path, no NFT). + /// forge-config: default.isolate = true + function test_gas_mint_newPosition_bare() public { bareMint.mint(keyAB, bytes24(uint192(776)), RANGE_LOWER, RANGE_UPPER, 1e24, address(this)); bareMint.mint(keyAB, bytes24(uint192(777)), RANGE_LOWER, RANGE_UPPER, 1e24, address(this)); - vm.snapshotGasLastCall("ekubo mint bare core position"); + vm.snapshotGasLastCall("ekubo mint new position bare"); } + /// @dev The user-facing path: a new NFT position through the Positions contract. /// forge-config: default.isolate = true - function test_gas_mint_fullRange() public { + function test_gas_mint_newPosition_positionsNft() public { positions.mintAndDeposit(keyAB, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); positions.mintAndDeposit(keyAB, RANGE_LOWER, RANGE_UPPER, LIQUIDITY_AMOUNT, LIQUIDITY_AMOUNT, 0); - vm.snapshotGasLastCall("ekubo mint concentrated position"); + vm.snapshotGasLastCall("ekubo mint new position Positions NFT"); + } + + // ---- pool creation ---- + + /// forge-config: default.isolate = true + function test_gas_initializePool() public { + core.initializePool(_concentratedKey(address(tokenA), address(tokenD)), INIT_TICK); + vm.snapshotGasLastCall("ekubo initialize pool"); } receive() external payable {} diff --git a/benchmarks/lab/ekubo/EkuboGasTest.json b/benchmarks/lab/ekubo/EkuboGasTest.json index 0f59042..2f423d6 100644 --- a/benchmarks/lab/ekubo/EkuboGasTest.json +++ b/benchmarks/lab/ekubo/EkuboGasTest.json @@ -1,3 +1,14 @@ { - "ekubo single exact-input token0->token1 concentrated": "87416" + "ekubo initialize pool": "92935", + "ekubo mint new position Positions NFT": "213104", + "ekubo mint new position bare": "179020", + "ekubo route 1 pool steady-state": "94904", + "ekubo route 2 pools steady-state": "119333", + "ekubo route 3 pools steady-state": "143774", + "ekubo single erc20 first swap in fresh pool": "87384", + "ekubo single erc20 reverse steady-state": "86334", + "ekubo single erc20 steady-state": "87416", + "ekubo single erc20 steady-state minimal locker": "87077", + "ekubo single native steady-state": "72580", + "ekubo single native steady-state minimal locker": "72164" } \ No newline at end of file diff --git a/benchmarks/lab/ekubo/Flame.t.sol b/benchmarks/lab/ekubo/Flame.t.sol index efa180d..9c65494 100644 --- a/benchmarks/lab/ekubo/Flame.t.sol +++ b/benchmarks/lab/ekubo/Flame.t.sol @@ -1,13 +1,18 @@ // SPDX-License-Identifier: ekubo-license-v1.eth pragma solidity =0.8.33; -import {SqrtRatio} from "ekubo/src/types/sqrtRatio.sol"; -import {createSwapParameters} from "ekubo/src/types/swapParameters.sol"; import {EkuboGasTest} from "./EkuboGas.t.sol"; -/// @notice Dedicated single-call capture for flamegraphs (no warm-up, no snapshot). +/// @notice Steady-state single-call capture for flamegraphs: setUp performs the same +/// warm-up swap as the snapshot test, the test itself makes exactly one swap, so +/// the flamegraph total matches "ekubo single erc20 steady-state". contract FlameTest is EkuboGasTest { + function setUp() public override { + super.setUp(); + _swapAB(); + } + function test_flame_single() public { - router.swapAllowPartialFill(keyAB, createSwapParameters(SqrtRatio.wrap(0), 1 ether, false, 0)); + _swapAB(); } } diff --git a/benchmarks/lab/v3/Flame.t.sol b/benchmarks/lab/v3/Flame.t.sol index a841b9d..9196aad 100644 --- a/benchmarks/lab/v3/Flame.t.sol +++ b/benchmarks/lab/v3/Flame.t.sol @@ -1,18 +1,25 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity =0.8.26; -import {V3GasTest} from "./V3Gas.t.sol"; +import {IUniswapV3Pool, V3GasTest} from "./V3Gas.t.sol"; -/// @notice Warmed single-call capture for flamegraphs: setUp warms the pool, -/// the test itself makes exactly one swap, so flamegraph totals match -/// the warmed snapshot numbers. +/// @notice Steady-state single-call capture for flamegraphs: setUp performs the same +/// warm-up as the snapshot test, the test itself makes exactly one 1-pool route +/// through the SwapRouter-shaped minimal router, so the flamegraph total matches +/// "v3 route 1 pool steady-state" (the cheapest path an EOA can send). contract FlameTest is V3GasTest { + IUniswapV3Pool[] pools; + address[] tokensIn; + function setUp() public override { super.setUp(); - poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); + (IUniswapV3Pool[] memory p, address[] memory t) = _pools(1); + pools = p; + tokensIn = t; + multihopRouter.multiHop(pools, tokensIn, SWAP_AMOUNT, address(this)); } function test_flame_single() public { - poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); + multihopRouter.multiHop(pools, tokensIn, SWAP_AMOUNT, address(this)); } } diff --git a/benchmarks/lab/v3/V3Gas.t.sol b/benchmarks/lab/v3/V3Gas.t.sol index 2197536..4854255 100644 --- a/benchmarks/lab/v3/V3Gas.t.sol +++ b/benchmarks/lab/v3/V3Gas.t.sol @@ -2,6 +2,7 @@ pragma solidity =0.8.26; import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; interface IUniswapV3Factory { function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); @@ -33,30 +34,37 @@ interface SharedToken { function balanceOf(address account) external view returns (uint256); } -/// @notice Minimal N-hop exact-input router over v3 pools. One `multiHop` call covers -/// every hop plus settlement, so the gas snapshot captures the full route cost, -/// comparable to a production SwapRouter-style exact-input path. +/// @notice Minimal N-hop exact-input router over v3 pools with the same token flow as +/// the production SwapRouter's `exactInput`: the first hop is paid straight from +/// the user (transferFrom user -> pool), intermediate outputs land on the router +/// and are paid forward (transfer router -> next pool), and the last hop delivers +/// to the recipient. That is 2 transfers per pool, and every intermediate router +/// balance goes 0 -> x -> 0 inside the transaction (refund-eligible, see tests). contract V3MultiHopRouter { uint160 constant MIN_LIMIT = 4295128740; // MIN_SQRT_RATIO + 1 - function multiHop(IUniswapV3Pool[] memory pools, int256 amountIn, address recipient) + function multiHop(IUniswapV3Pool[] memory pools, address[] memory tokensIn, int256 amountIn, address recipient) external returns (int256 out) { - SharedToken(pools[0].token0()).transferFrom(msg.sender, address(this), uint256(amountIn)); int256 amount = amountIn; + address payer = msg.sender; for (uint256 i = 0; i < pools.length; i++) { - (, int256 a1) = pools[i].swap(address(this), true, amount, MIN_LIMIT, ""); + bool last = i == pools.length - 1; + (, int256 a1) = + pools[i].swap(last ? recipient : address(this), true, amount, MIN_LIMIT, abi.encode(tokensIn[i], payer)); amount = -a1; require(amount > 0, "no hop output"); + payer = address(this); } out = amount; - SharedToken(pools[pools.length - 1].token1()).transfer(recipient, uint256(out)); } - function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external { - if (amount0Delta > 0) SharedToken(IUniswapV3Pool(msg.sender).token0()).transfer(msg.sender, uint256(amount0Delta)); - if (amount1Delta > 0) SharedToken(IUniswapV3Pool(msg.sender).token1()).transfer(msg.sender, uint256(amount1Delta)); + function uniswapV3SwapCallback(int256 amount0Delta, int256, bytes calldata data) external { + (address tokenIn, address payer) = abi.decode(data, (address, address)); + uint256 owed = uint256(amount0Delta); // every hop is zeroForOne + if (payer == address(this)) SharedToken(tokenIn).transfer(msg.sender, owed); + else SharedToken(tokenIn).transferFrom(payer, msg.sender, owed); } } @@ -114,12 +122,6 @@ contract V3GasTest is Test { tokenA.approve(address(multihopRouter), type(uint256).max); } - function _tokenFor(address pool, bool isToken0) internal view returns (SharedToken) { - if (pool == address(poolAB)) return isToken0 ? tokenA : tokenB; - if (pool == address(poolBC)) return isToken0 ? tokenB : tokenC; - return isToken0 ? tokenC : tokenD; - } - function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { if (amount0Owed > 0) SharedToken(IUniswapV3Pool(msg.sender).token0()).transfer(msg.sender, amount0Owed); if (amount1Owed > 0) SharedToken(IUniswapV3Pool(msg.sender).token1()).transfer(msg.sender, amount1Owed); @@ -130,58 +132,144 @@ contract V3GasTest is Test { if (amount1Delta > 0) SharedToken(IUniswapV3Pool(msg.sender).token1()).transfer(msg.sender, uint256(amount1Delta)); } - // Steady-state methodology: one identical unmeasured warm-up precedes every - // measurement, so pools, tokens, and approvals sit in warmed nonzero slots. + function _pools(uint256 n) internal view returns (IUniswapV3Pool[] memory pools, address[] memory tokensIn) { + pools = new IUniswapV3Pool[](n); + tokensIn = new address[](n); + pools[0] = poolAB; + tokensIn[0] = address(tokenA); + if (n > 1) { + pools[1] = poolBC; + tokensIn[1] = address(tokenB); + } + if (n > 2) { + pools[2] = poolCD; + tokensIn[2] = address(tokenC); + } + } + + /// @dev Isolated calls get their own EVM context, so `snapshotGasLastCall` reports the + /// call's execution gas net of the EIP-3529 refund (capped at 1/5 of gas used), + /// excluding the 21,000 base and calldata. The gross figure before refunds and the + /// refund actually granted are recorded alongside. + function _snapshotWithRefund(string memory name) internal { + Vm.Gas memory g = vm.lastCallGas(); + vm.snapshotGasLastCall(name); + vm.snapshotValue(string.concat(name, " gross before refund"), g.gasTotalUsed); + vm.snapshotValue(string.concat(name, " refund granted"), uint256(uint64(g.gasRefunded))); + } + + // ---- single swaps: direct pool call, the test contract is the callback payer ---- + // A lower bound only: an externally owned account cannot be a v3 callback payer, so + // the cheapest path a user can actually send is the 1-pool route below. The callback + // runs in this test contract, so these direct-call figures move by a few gas when the + // contract's function set (its dispatcher) changes. + /// forge-config: default.isolate = true - function test_gas_single_exactInput_erc20() public { - poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); + function test_gas_single_steady_erc20() public { + poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); // warm-up, unmeasured poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); - vm.snapshotGasLastCall("v3 single exact-input token0->token1 wide-range"); + _snapshotWithRefund("v3 single erc20 steady-state"); } /// forge-config: default.isolate = true - function test_gas_single_exactInput_erc20_reverse() public { + function test_gas_single_steady_erc20_reverse() public { poolAB.swap(address(this), false, SWAP_AMOUNT, MAX_LIMIT, ""); poolAB.swap(address(this), false, SWAP_AMOUNT, MAX_LIMIT, ""); - vm.snapshotGasLastCall("v3 single exact-input token1->token0 wide-range"); + _snapshotWithRefund("v3 single erc20 reverse steady-state"); + } + + /// @dev The oracle observation is written only by swaps that change the tick, and at + /// most once per block (`Oracle.write` returns early on a repeated timestamp). Two + /// tests with identical swaps isolate that write. Warm-ups: one reverse swap (so the + /// token1 fee accumulator is already nonzero) and one forward swap (tick 30 -> 29); + /// the measured swap buys 2 tokens back (tick 29 -> 30), once in the same block + /// (write skipped) and once in a later block (observation written, cardinality 1). + function _tickChangeWarmUp() internal { + poolAB.swap(address(this), false, SWAP_AMOUNT, MAX_LIMIT, ""); + poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); } /// forge-config: default.isolate = true - function test_gas_oneHop_routerPath() public { - IUniswapV3Pool[] memory pools = new IUniswapV3Pool[](1); - pools[0] = poolAB; - multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); - multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); - vm.snapshotGasLastCall("v3 one-hop exact-input router path"); + function test_gas_single_tickChange_sameBlock() public { + _tickChangeWarmUp(); + poolAB.swap(address(this), false, 2 * SWAP_AMOUNT, MAX_LIMIT, ""); + _snapshotWithRefund("v3 single tick-changing swap same block (oracle write skipped)"); } /// forge-config: default.isolate = true - function test_gas_twoHop_exactInput() public { - IUniswapV3Pool[] memory pools = new IUniswapV3Pool[](2); - pools[0] = poolAB; - pools[1] = poolBC; - multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); - multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); - vm.snapshotGasLastCall("v3 two-hop exact-input two pools"); + function test_gas_single_tickChange_nextBlock() public { + _tickChangeWarmUp(); + vm.warp(block.timestamp + 12); + vm.roll(block.number + 1); + poolAB.swap(address(this), false, 2 * SWAP_AMOUNT, MAX_LIMIT, ""); + _snapshotWithRefund("v3 single tick-changing swap next block (oracle written)"); } + /// @dev No warm-up: the very first swap in a freshly capitalized pool, which writes the + /// fee-growth accumulator from zero (SSTORE 20,000 instead of 2,900). /// forge-config: default.isolate = true - function test_gas_threeHop_exactInput() public { - IUniswapV3Pool[] memory pools = new IUniswapV3Pool[](3); - pools[0] = poolAB; - pools[1] = poolBC; - pools[2] = poolCD; - multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); - multihopRouter.multiHop(pools, SWAP_AMOUNT, address(this)); - vm.snapshotGasLastCall("v3 three-hop exact-input three pools"); + function test_gas_single_firstSwap_erc20() public { + poolAB.swap(address(this), true, SWAP_AMOUNT, MIN_LIMIT, ""); + _snapshotWithRefund("v3 single erc20 first swap in fresh pool"); + } + + // ---- routes through the SwapRouter-shaped minimal router, warmed ---- + + /// forge-config: default.isolate = true + function test_gas_route_1pool() public { + (IUniswapV3Pool[] memory pools, address[] memory tokensIn) = _pools(1); + multihopRouter.multiHop(pools, tokensIn, SWAP_AMOUNT, address(this)); + multihopRouter.multiHop(pools, tokensIn, SWAP_AMOUNT, address(this)); + _snapshotWithRefund("v3 route 1 pool steady-state"); + } + + /// forge-config: default.isolate = true + function test_gas_route_2pools() public { + (IUniswapV3Pool[] memory pools, address[] memory tokensIn) = _pools(2); + multihopRouter.multiHop(pools, tokensIn, SWAP_AMOUNT, address(this)); + multihopRouter.multiHop(pools, tokensIn, SWAP_AMOUNT, address(this)); + _snapshotWithRefund("v3 route 2 pools steady-state"); + } + + /// forge-config: default.isolate = true + function test_gas_route_3pools() public { + (IUniswapV3Pool[] memory pools, address[] memory tokensIn) = _pools(3); + multihopRouter.multiHop(pools, tokensIn, SWAP_AMOUNT, address(this)); + multihopRouter.multiHop(pools, tokensIn, SWAP_AMOUNT, address(this)); + _snapshotWithRefund("v3 route 3 pools steady-state"); + } + + // ---- liquidity provision, direct to the pool ---- + + /// @dev Warm-up mint to one owner, measured mint to a different owner: a brand-new + /// position (fresh position slots) with both boundary ticks already initialized, + /// the same shape as the Ekubo and v4 new-position mints. + /// forge-config: default.isolate = true + function test_gas_mint_newPosition() public { + poolAB.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); + poolAB.mint(address(0xBEEF), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); + _snapshotWithRefund("v3 mint new position"); } + /// @dev Adding liquidity to an existing position (same owner and ticks): cheaper, because + /// the position slots are already nonzero. /// forge-config: default.isolate = true - function test_gas_mint_fullRange() public { - // subsequent mint on the existing pool (ticks already initialized), - // preceded by an identical warm-up mint + function test_gas_mint_samePosition() public { poolAB.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); poolAB.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); - vm.snapshotGasLastCall("v3 mint wide-range position"); + _snapshotWithRefund("v3 mint same position"); + } + + // ---- pool creation ---- + + /// @dev v3 pools are separate contracts: `createPool` deploys one (CREATE2 of the full + /// pool bytecode), then `initialize` sets the price and the first observation. + /// forge-config: default.isolate = true + function test_gas_initializePool() public { + IUniswapV3Pool poolAD = IUniswapV3Pool(factory.createPool(address(tokenA), address(tokenD), 3000)); + uint256 create = vm.snapshotGasLastCall("v3 createPool"); + poolAD.initialize(INIT_SQRT_PRICE); + uint256 init = vm.snapshotGasLastCall("v3 initialize"); + vm.snapshotValue("v3 createPool + initialize", create + init); } } diff --git a/benchmarks/lab/v3/V3GasTest.json b/benchmarks/lab/v3/V3GasTest.json index a66b9d4..e4a4c03 100644 --- a/benchmarks/lab/v3/V3GasTest.json +++ b/benchmarks/lab/v3/V3GasTest.json @@ -1,3 +1,35 @@ { - "v3 single exact-input token0->token1 wide-range": "87553" + "v3 createPool": "4558970", + "v3 createPool + initialize": "4629298", + "v3 initialize": "70328", + "v3 mint new position": "138380", + "v3 mint new position gross before refund": "141180", + "v3 mint new position refund granted": "2800", + "v3 mint same position": "121496", + "v3 mint same position gross before refund": "124296", + "v3 mint same position refund granted": "2800", + "v3 route 1 pool steady-state": "94078", + "v3 route 1 pool steady-state gross before refund": "94078", + "v3 route 1 pool steady-state refund granted": "0", + "v3 route 2 pools steady-state": "152774", + "v3 route 2 pools steady-state gross before refund": "172674", + "v3 route 2 pools steady-state refund granted": "19900", + "v3 route 3 pools steady-state": "211470", + "v3 route 3 pools steady-state gross before refund": "251270", + "v3 route 3 pools steady-state refund granted": "39800", + "v3 single erc20 first swap in fresh pool": "104741", + "v3 single erc20 first swap in fresh pool gross before refund": "104741", + "v3 single erc20 first swap in fresh pool refund granted": "0", + "v3 single erc20 reverse steady-state": "87040", + "v3 single erc20 reverse steady-state gross before refund": "87040", + "v3 single erc20 reverse steady-state refund granted": "0", + "v3 single erc20 steady-state": "87641", + "v3 single erc20 steady-state gross before refund": "87641", + "v3 single erc20 steady-state refund granted": "0", + "v3 single tick-changing swap next block (oracle written)": "93712", + "v3 single tick-changing swap next block (oracle written) gross before refund": "93712", + "v3 single tick-changing swap next block (oracle written) refund granted": "0", + "v3 single tick-changing swap same block (oracle write skipped)": "89836", + "v3 single tick-changing swap same block (oracle write skipped) gross before refund": "89836", + "v3 single tick-changing swap same block (oracle write skipped) refund granted": "0" } \ No newline at end of file diff --git a/benchmarks/lab/v4/Flame.t.sol b/benchmarks/lab/v4/Flame.t.sol index d1b6459..7c72f36 100644 --- a/benchmarks/lab/v4/Flame.t.sol +++ b/benchmarks/lab/v4/Flame.t.sol @@ -1,21 +1,18 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity =0.8.26; -import {Currency} from "v4-core/src/types/Currency.sol"; -import {TickMath} from "v4-core/src/libraries/TickMath.sol"; import {V4GasTest} from "./V4Gas.t.sol"; -/// @notice Warmed single-call capture for flamegraphs: setUp warms the pool, -/// the test itself makes exactly one swap, so flamegraph totals match -/// the warmed snapshot numbers. +/// @notice Steady-state single-call capture for flamegraphs: setUp performs the same +/// warm-up swap as the snapshot test, the test itself makes exactly one swap, so +/// the flamegraph total matches "v4 single erc20 steady-state". contract FlameTest is V4GasTest { function setUp() public override { super.setUp(); - minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); + _swapAB(); } function test_flame_single() public { - uint256 out = minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); - assertGt(out, 0); + assertGt(_swapAB(), 0); } } diff --git a/benchmarks/lab/v4/V4Gas.t.sol b/benchmarks/lab/v4/V4Gas.t.sol index 73a179a..29ae610 100644 --- a/benchmarks/lab/v4/V4Gas.t.sol +++ b/benchmarks/lab/v4/V4Gas.t.sol @@ -8,7 +8,6 @@ import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/src/types/PoolKey.sol"; import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol"; import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol"; -import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol"; import {SwapParams, ModifyLiquidityParams} from "v4-core/src/types/PoolOperation.sol"; import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol"; @@ -21,6 +20,7 @@ import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest. /// @notice True-minimal single-hop exact-input locker: unlock, swap, settle net. /// No balance assertions, no test settings — the floor for v4 router cost. +/// Native input is settled with the value forwarded by the caller. contract MinimalSwapRouter is IUnlockCallback { using CurrencyLibrary for Currency; using CurrencySettler for Currency; @@ -34,6 +34,7 @@ contract MinimalSwapRouter is IUnlockCallback { function swapExactIn(PoolKey memory key, bool zeroForOne, int256 amountIn, uint160 limit, address payer) external + payable returns (uint256 amountOut) { bytes memory result = manager.unlock(abi.encode(key, zeroForOne, amountIn, limit, payer, msg.sender)); @@ -177,51 +178,35 @@ contract V4GasTest is Test { tokenA.approve(address(minRouter), type(uint256).max); tokenB.approve(address(minRouter), type(uint256).max); - keyAB = PoolKey({ - currency0: Currency.wrap(address(tokenA)), - currency1: Currency.wrap(address(tokenB)), - fee: 3000, - tickSpacing: 60, - hooks: IHooks(address(0)) - }); - keyBC = PoolKey({ - currency0: Currency.wrap(address(tokenB)), - currency1: Currency.wrap(address(tokenC)), - fee: 3000, - tickSpacing: 60, - hooks: IHooks(address(0)) - }); - keyCD = PoolKey({ - currency0: Currency.wrap(address(tokenC)), - currency1: Currency.wrap(address(tokenD)), - fee: 3000, - tickSpacing: 60, - hooks: IHooks(address(0)) - }); + keyAB = _key(address(tokenA), address(tokenB)); + keyBC = _key(address(tokenB), address(tokenC)); + keyCD = _key(address(tokenC), address(tokenD)); manager.initialize(keyAB, TickMath.getSqrtPriceAtTick(30)); manager.initialize(keyBC, TickMath.getSqrtPriceAtTick(30)); manager.initialize(keyCD, TickMath.getSqrtPriceAtTick(30)); - _addFullRangeLiquidity(keyAB, address(tokenA), address(tokenB), false); - _addFullRangeLiquidity(keyBC, address(tokenB), address(tokenC), false); - _addFullRangeLiquidity(keyCD, address(tokenC), address(tokenD), false); + _addFullRangeLiquidity(keyAB); + _addFullRangeLiquidity(keyBC); + _addFullRangeLiquidity(keyCD); // native (ETH) / tokenB pool for the native-input scenario - keyNative = PoolKey({ - currency0: Currency.wrap(address(0)), - currency1: Currency.wrap(address(tokenB)), - fee: 3000, - tickSpacing: 60, - hooks: IHooks(address(0)) - }); + keyNative = _key(address(0), address(tokenB)); manager.initialize(keyNative, TickMath.getSqrtPriceAtTick(30)); - tokenB.approve(address(mintRouter), type(uint256).max); mintRouter.modifyLiquidity{value: LIQUIDITY_TOKEN_AMOUNT}( keyNative, ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(_fullRangeLiquidity(LIQUIDITY_TOKEN_AMOUNT, LIQUIDITY_TOKEN_AMOUNT))), salt: bytes32(0)}), "" ); + } + function _key(address c0, address c1) internal pure returns (PoolKey memory) { + return PoolKey({ + currency0: Currency.wrap(c0), + currency1: Currency.wrap(c1), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); } function _fullRangeLiquidity(uint256 amount0, uint256 amount1) internal pure returns (uint128) { @@ -230,22 +215,13 @@ contract V4GasTest is Test { return LiquidityAmounts.getLiquidityForAmounts(TickMath.getSqrtPriceAtTick(30), sqrtA, sqrtB, amount0, amount1); } - function _addFullRangeLiquidity(PoolKey memory key, address t0, address t1, bool native) internal { + function _addFullRangeLiquidity(PoolKey memory key) internal { uint128 liq = _fullRangeLiquidity(LIQUIDITY_TOKEN_AMOUNT, LIQUIDITY_TOKEN_AMOUNT); - if (native) { - mintRouter.modifyLiquidity{value: LIQUIDITY_TOKEN_AMOUNT}( - key, - ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(0)}), - "" - ); - } else { - mintRouter.modifyLiquidity( - key, - ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(0)}), - "" - ); - } - (t0, t1); + mintRouter.modifyLiquidity( + key, + ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(0)}), + "" + ); } function _v4Hops(PoolKey[] memory keys) internal pure returns (MultiHopRouter.Hop[] memory hops) { @@ -255,91 +231,95 @@ contract V4GasTest is Test { } } + function _swapAB() internal returns (uint256) { + return minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); + } + + // ---- single swaps through the minimal locker ---- + /// forge-config: default.isolate = true - function test_gas_single_minimal_erc20() public { - minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); - uint256 out = minRouter.swapExactIn(keyAB, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); + function test_gas_single_steady_erc20() public { + _swapAB(); // warm-up, unmeasured + uint256 out = _swapAB(); assertGt(out, 0); - vm.snapshotGasLastCall("v4 single exact-input minimal locker token0->token1"); + vm.snapshotGasLastCall("v4 single erc20 steady-state"); } /// forge-config: default.isolate = true - function test_gas_single_minimal_erc20_reverse() public { + function test_gas_single_steady_erc20_reverse() public { minRouter.swapExactIn(keyAB, false, SWAP_AMOUNT, TickMath.MAX_SQRT_PRICE - 1, address(this)); uint256 out = minRouter.swapExactIn(keyAB, false, SWAP_AMOUNT, TickMath.MAX_SQRT_PRICE - 1, address(this)); assertGt(out, 0); - vm.snapshotGasLastCall("v4 single exact-input minimal locker token1->token0"); + vm.snapshotGasLastCall("v4 single erc20 reverse steady-state"); } /// forge-config: default.isolate = true - function test_gas_single_exactInput_erc20() public { - swapRouter.swap( - keyAB, - SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), - PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), - "" - ); - swapRouter.swap( - keyAB, - SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), - PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), - "" - ); - vm.snapshotGasLastCall("v4 single exact-input ERC20->ERC20 wide-range concentrated"); + function test_gas_single_steady_native() public { + minRouter.swapExactIn{value: 1 ether}(keyNative, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); + uint256 out = minRouter.swapExactIn{value: 1 ether}(keyNative, true, SWAP_AMOUNT, TickMath.MIN_SQRT_PRICE + 1, address(this)); + assertGt(out, 0); + vm.snapshotGasLastCall("v4 single native steady-state"); } + /// @dev No warm-up: the very first swap in a freshly capitalized pool, which writes the + /// fee-growth accumulator from zero (SSTORE 20,000 instead of 2,900). /// forge-config: default.isolate = true - function test_gas_single_exactInput_native() public { - swapRouter.swap{value: 1 ether}( - keyNative, - SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), - PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), - "" - ); - swapRouter.swap{value: 1 ether}( - keyNative, - SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), - PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), - "" - ); - vm.snapshotGasLastCall("v4 single exact-input native->ERC20 wide-range concentrated"); + function test_gas_single_firstSwap_erc20() public { + uint256 out = _swapAB(); + assertGt(out, 0); + vm.snapshotGasLastCall("v4 single erc20 first swap in fresh pool"); } + /// @dev Context only: v4-core's own PoolSwapTest helper (balance assertions and test + /// settings) on the same swap, to show the minimal locker is the floor. /// forge-config: default.isolate = true - function test_gas_oneHop_exactInput() public { - PoolKey[] memory keys = new PoolKey[](1); + function test_gas_single_steady_erc20_poolSwapTest() public { + SwapParams memory params = + SwapParams({zeroForOne: true, amountSpecified: SWAP_AMOUNT, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}); + PoolSwapTest.TestSettings memory settings = PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}); + swapRouter.swap(keyAB, params, settings, ""); + swapRouter.swap(keyAB, params, settings, ""); + vm.snapshotGasLastCall("v4 single erc20 steady-state PoolSwapTest helper"); + } + + // ---- routes through the single-lock multihop router, warmed ---- + + function _route(uint256 n) internal returns (uint256 out) { + PoolKey[] memory keys = new PoolKey[](n); keys[0] = keyAB; - multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenB)), SWAP_AMOUNT); - uint256 out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenB)), SWAP_AMOUNT); - assertGt(out, 0); - vm.snapshotGasLastCall("v4 one-hop exact-input single lock"); + if (n > 1) keys[1] = keyBC; + if (n > 2) keys[2] = keyCD; + Currency currencyOut = Currency.wrap(address(n == 1 ? tokenB : n == 2 ? tokenC : tokenD)); + out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), currencyOut, SWAP_AMOUNT); } /// forge-config: default.isolate = true - function test_gas_twoHop_exactInput() public { - PoolKey[] memory keys = new PoolKey[](2); - keys[0] = keyAB; - keys[1] = keyBC; - multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenC)), SWAP_AMOUNT); - uint256 out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenC)), SWAP_AMOUNT); - assertGt(out, 0); - vm.snapshotGasLastCall("v4 two-hop exact-input single lock"); + function test_gas_route_1pool() public { + _route(1); + assertGt(_route(1), 0); + vm.snapshotGasLastCall("v4 route 1 pool steady-state"); } /// forge-config: default.isolate = true - function test_gas_threeHop_exactInput() public { - PoolKey[] memory keys = new PoolKey[](3); - keys[0] = keyAB; - keys[1] = keyBC; - keys[2] = keyCD; - multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenD)), SWAP_AMOUNT); - uint256 out = multiHopRouter.multiHop(_v4Hops(keys), Currency.wrap(address(tokenA)), Currency.wrap(address(tokenD)), SWAP_AMOUNT); - assertGt(out, 0); - vm.snapshotGasLastCall("v4 three-hop exact-input single lock"); + function test_gas_route_2pools() public { + _route(2); + assertGt(_route(2), 0); + vm.snapshotGasLastCall("v4 route 2 pools steady-state"); } /// forge-config: default.isolate = true - function test_gas_mint_fullRange() public { + function test_gas_route_3pools() public { + _route(3); + assertGt(_route(3), 0); + vm.snapshotGasLastCall("v4 route 3 pools steady-state"); + } + + // ---- liquidity provision through v4-core's PoolModifyLiquidityTest ---- + + /// @dev Warm-up mint under one salt, measured mint under another: a brand-new position + /// with both boundary ticks already initialized. + /// forge-config: default.isolate = true + function test_gas_mint_newPosition() public { uint128 liq = _fullRangeLiquidity(LIQUIDITY_TOKEN_AMOUNT, LIQUIDITY_TOKEN_AMOUNT); mintRouter.modifyLiquidity( keyAB, @@ -351,7 +331,15 @@ contract V4GasTest is Test { ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(uint256(1))}), "" ); - vm.snapshotGasLastCall("v4 mint wide-range position"); + vm.snapshotGasLastCall("v4 mint new position"); + } + + // ---- pool creation ---- + + /// forge-config: default.isolate = true + function test_gas_initializePool() public { + manager.initialize(_key(address(tokenA), address(tokenD)), TickMath.getSqrtPriceAtTick(30)); + vm.snapshotGasLastCall("v4 initialize pool"); } receive() external payable {} diff --git a/benchmarks/lab/v4/V4GasTest.json b/benchmarks/lab/v4/V4GasTest.json index 637196c..1096672 100644 --- a/benchmarks/lab/v4/V4GasTest.json +++ b/benchmarks/lab/v4/V4GasTest.json @@ -1,10 +1,12 @@ { - "v4 mint wide-range position": "162188", - "v4 one-hop exact-input single lock": "107360", - "v4 single exact-input ERC20->ERC20 wide-range concentrated": "116827", - "v4 single exact-input minimal locker token0->token1": "103548", - "v4 single exact-input minimal locker token1->token0": "103373", - "v4 single exact-input native->ERC20 wide-range concentrated": "98953", - "v4 three-hop exact-input single lock": "176182", - "v4 two-hop exact-input single lock": "141771" + "v4 initialize pool": "51812", + "v4 mint new position": "162188", + "v4 route 1 pool steady-state": "107360", + "v4 route 2 pools steady-state": "141771", + "v4 route 3 pools steady-state": "176182", + "v4 single erc20 first swap in fresh pool": "120633", + "v4 single erc20 reverse steady-state": "103358", + "v4 single erc20 steady-state": "103533", + "v4 single erc20 steady-state PoolSwapTest helper": "116827", + "v4 single native steady-state": "88864" } \ No newline at end of file diff --git a/public/gas/flamegraph-ekubo-single.svg b/public/gas/flamegraph-ekubo-single.svg index f9550af..290960e 100644 --- a/public/gas/flamegraph-ekubo-single.svg +++ b/public/gas/flamegraph-ekubo-single.svg @@ -1,4 +1,4 @@ - \ No newline at end of file diff --git a/public/gas/flamegraph-v3-single.svg b/public/gas/flamegraph-v3-single.svg index c98e541..417e7e8 100644 --- a/public/gas/flamegraph-v3-single.svg +++ b/public/gas/flamegraph-v3-single.svg @@ -1,4 +1,4 @@ - \ No newline at end of file diff --git a/public/gas/flamegraph-v4-single.svg b/public/gas/flamegraph-v4-single.svg index 6a95eb0..23fa807 100644 --- a/public/gas/flamegraph-v4-single.svg +++ b/public/gas/flamegraph-v4-single.svg @@ -1,4 +1,4 @@ - \ No newline at end of file diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index b70eb81..745f412 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -1,5 +1,5 @@ --- -description: Measured gas costs for Ekubo swaps against Uniswap v4 and v3, with like-for-like benchmarks, scaling analysis, and mainnet-fork validation +description: Measured gas costs for Ekubo swaps against Uniswap v4 and v3, with like-for-like benchmarks, scaling analysis, mainnet-fork validation, and chain-capacity implications title: "Gas efficiency" --- @@ -9,169 +9,350 @@ state packs into a single storage word. This page backs that design claim with measured numbers — full methodology, harness sources, and flamegraphs included — comparing Ekubo on EVM against Uniswap v4 and Uniswap v3 on identical workloads. -All figures are execution gas for a 0.3% concentrated-liquidity pool with a -wide-range position and no extension. Percentages beside the tables add the -21,000 base fee plus exact calldata cost, i.e. full transaction gas (the -post-Pectra calldata floor was checked and does not bind at these sizes). +Two bases appear on this page and are never mixed inside one sentence: + +- **Execution gas** is what the measured call consumed, net of end-of-transaction + refunds. It excludes the 21,000 transaction base cost and calldata. Every table + is on this basis unless its heading says otherwise. +- **Transaction gas** adds 21,000 plus the exact calldata cost of a reference + calldata file in `benchmarks/fork/` (4 gas per zero byte, 16 per nonzero byte). + The post-Pectra calldata floor (EIP-7623) was checked and does not bind on any + transaction here. + +Two conditions also recur. **Steady state** means the pool has already been +swapped through: one identical, unmeasured swap precedes the measured one, so +the figure is what an established pool charges. **First swap in a fresh pool** +means no swap has touched the pool since it was capitalized in setup. + +Lab figures use a 0.3% concentrated-liquidity pool with one position covering the +whole tick space and no extension or hook, exact-input swaps of one 18-decimal +token, and the same ERC20 token contract in every harness. ## Single swaps -One token of input, exact-input, no tick crossings, 18-decimal plain ERC20 tokens -(the identical token contract runs in every harness), both directions: - -| Swap | Ekubo | Uniswap v4 | Uniswap v3 | -| --------------------------------- | ---------- | ---------- | -------------------------- | -| ERC20 to ERC20 | **87,384** | 120,648 | 104,675 | -| ERC20 to ERC20, reverse direction | **86,334** | 120,473 | 106,870 | -| Native ETH to ERC20 | **72,560** | 116,053 | — (v3 needs WETH wrapping) | - -These are cold-path measurements: each test runs on a fresh chain whose pools -were capitalized in setup, with no warm-up swap before the measured call — the -closest a harness gets to a real user's transaction, which always starts with -cold access. At the transaction level the ERC20 swap costs about **110k on -Ekubo, 144k on v4, and 127k on v3** — roughly 24% less than v4 and 14% less -than v3. The v4 figure uses a true-minimal locker (unlock, swap, settle net); -v4's own test helper costs more, and the production Universal Router costs more -still. The v3 figure is a direct pool call with zero router overhead, so it is -a lower bound; the production SwapRouter adds on top. +Steady state, both directions, execution gas. Each column is that protocol's +cheapest path: Ekubo's production Solidity `Router` (its single-swap entry), a +true-minimal v4 locker (unlock, swap, settle, take), and for v3 a direct +`pool.swap` with the test contract paying the callback. + +| Swap | Ekubo | Uniswap v4 | Uniswap v3 | +| --------------------------------- | ---------- | ---------- | ------------------------------------- | +| ERC20 to ERC20 | **87,416** | 103,533 | 87,641 | +| ERC20 to ERC20, reverse direction | **86,334** | 103,358 | 87,040 | +| Native ETH to ERC20 | **72,580** | 88,864 | n/a (v3 pools hold WETH) | +| ERC20 to ERC20, bare locker | **87,077** | 103,533 | n/a (an EOA cannot pay a v3 callback) | + +Ekubo's ERC20 swap uses 15.6% less execution gas than v4. On transaction gas, +adding 21,000 plus each entry point's calldata (Ekubo Router 132 bytes, 1,200 +gas; v4 minimal locker 292 bytes, 2,572 gas), it is 109,616 against 127,105, +or 13.8% less. The bare-locker row is the like-for-like comparison of two +true-minimal lockers with the same four steps: 87,077 against 103,533, 15.9% +less. Ekubo's production Router costs only 339 gas more than its bare locker. + +The v3 direct call is a floor, not a sendable path: an externally owned account +cannot be a v3 callback payer, so the cheapest transaction a v3 user can send is +the one-pool route in the next section, 94,078 gas. Against that, Ekubo's +single swap is 7.1% cheaper; against the unreachable floor, the two are within +0.3% of each other. + +**First swap in a fresh pool.** Without the warm-up swap, Ekubo measures 87,384 +(32 gas below steady state), v4 120,633 and v3 104,741: exactly 17,100 more on +both Uniswap versions, one zero-to-nonzero write of the fee-growth accumulator +(20,000 gas instead of 2,900). State-diff recording confirms one fresh slot on +v3 and v4 and none on Ekubo, whose `initializePool` writes both fee accumulator +slots up front (see [Creating a pool](#creating-a-pool)). Ekubo's native-input +first swap measures 72,560 against 72,580 in steady state. Reverse-direction +first swaps were not measured. + +**v3's oracle write.** A v3 swap that changes the tick also writes an oracle +observation, at most once per block. The same tick-changing swap costs 89,836 +in the block of a previous swap (write skipped) and 93,712 in the next block +(observation written): 3,876 gas for the write, paid by the first tick-moving +swap of every block. Neither Ekubo nor v4 writes an oracle inside the swap path +(Ekubo's oracle is an extension, v4's would be a hook). + +**Native input.** Ekubo takes ETH directly and spends 18.3% less execution gas +than v4 doing the same; each path makes exactly two value-bearing calls (user to +router, router to Core or PoolManager), and Ekubo's bare locker measures 72,164. +v3 pools hold WETH, so a v3 user first wraps: one `deposit` costs 27,938 +execution gas in steady state (the account already holds WETH; a first-ever wrap +pays a zero-to-nonzero balance write on top). Counting the wrap, a v3 ETH-input +trade is at least 115,579 execution gas across two calls, so Ekubo spends 37% +less, before the 21,000 base and calldata of a separate wrapping transaction. ## How the gap scales with route length -Routing through one, two, and three pools in a single transaction, each protocol -using the same minimal single-lock router throughout, warmed so every pool is -touched before measurement: - -| Route length | Ekubo | Uniswap v4 | Uniswap v3 | -| ---------------------------------------- | ----------- | ----------- | ----------- | -| 1 pool | 94,892 | 107,360 | 116,130 | -| 2 pools | 119,333 | 141,771 | 178,760 | -| 3 pools | 143,774 | 176,182 | 241,389 | -| **Marginal cost per extra pool, warmed** | **~24,400** | **~34,400** | **~62,600** | - -The marginals are linear to the gas unit because each added hop hits an -identically shaped fresh pool with its own distinct token contracts. Warmed -marginals are the right measure inside a route — hops after the first execute -with warm router and transient state — but note the first hop of a real route -pays the cold path from the table above (about even for Ekubo, about 17k extra -for v4 and v3). At three pools Ekubo costs about 18% less than v4 and 40% less -than v3 in execution gas, before that first-hop adjustment widens both gaps -further. +Routing through one, two, and three pools in a single transaction, steady state, +each protocol using a minimal router of the same shape (Ekubo's production +`Router` multihop entry, a single-lock v4 router, and a v3 router with the +production SwapRouter's token flow). Execution gas, net of refunds; the v3 series +is the only one that earns refunds, so its gross figure and refund are shown: + +| Route length | Ekubo | Uniswap v4 | Uniswap v3, net (gross, refund) | +| -------------------------------- | ---------- | ---------- | ------------------------------- | +| 1 pool | 94,904 | 107,360 | 94,078 (94,078, 0) | +| 2 pools | 119,333 | 141,771 | 152,774 (172,674, 19,900) | +| 3 pools | 143,774 | 176,182 | 211,470 (251,270, 39,800) | +| **Marginal cost per extra pool** | **24,429** | **34,411** | **58,696** (78,596 gross) | + +Ekubo's two marginals are 24,429 and 24,441; v4's and v3's repeat exactly, +because each added hop hits an identically shaped pool with its own token +contracts. At one pool the three are close: Ekubo's multihop entry is 11.6% +below v4 and 0.9% above v3's route, which is nothing more than two transfers and +one pool call. At three pools Ekubo is 18.4% below v4 and 32.0% below v3 net +(42.8% below v3 gross). A real route's first hop pays the first-swap surcharge +from the previous section on v4 and v3 if its pool is fresh; the marginals here +are the right measure for the hops after it. + +The v3 refund is 19,900 per intermediate hop: the router's balance of each +intermediate token goes from zero to nonzero and back to zero inside the +transaction, and EIP-3529 refunds the clearing. The refund is capped at one +fifth of gas used, which does not bind at these sizes, so the net figure is what +a user pays. Token transfers tell part of the story. Ekubo and v4 both settle net inside one -lock, so a route of any length moves exactly 2 token transfers: +lock, so a route of any length moves exactly two tokens: | Transfers per route | Ekubo | Uniswap v4 | Uniswap v3 | | ------------------- | --------- | ---------- | ---------- | | 1 / 2 / 3 pools | 2 / 2 / 2 | 2 / 2 / 2 | 2 / 4 / 6 | -V3 pays two transfers per pool (callback payment plus output) because every pool +v3 pays two transfers per pool (callback payment plus output) because every pool is a separate contract that must be paid individually. But transfers alone do not explain the v4 gap — v4 nets transfers exactly like Ekubo. The rest of the difference is inside the pool core. ## Where the savings come from -Frame-level attribution of the cold-path single-hop swap, from Foundry -flamegraphs (each capture is one swap call on a capitalized pool; frame widths -are proportional to gas): +Frame-level attribution of the steady-state single swap, from Foundry +flamegraphs. Each capture is one call on a pool that has already been swapped +through, so the router call frame equals the corresponding table entry; the +outer root frame also counts the harness's own call into the router. Frame +widths are proportional to gas. ![Flamegraph of an Ekubo single-hop swap](/gas/flamegraph-ekubo-single.svg) -_Flamegraph: Ekubo single-hop swap, 87,384 gas._ +_Flamegraph: Ekubo single swap through the Router, steady state. Router call +frame 87,416 gas; root frame 99,218 gas including the harness call._ ![Flamegraph of a Uniswap v4 single-hop swap](/gas/flamegraph-v4-single.svg) -_Flamegraph: Uniswap v4 single-hop swap via a minimal locker, 120,648 gas._ +_Flamegraph: Uniswap v4 single swap through the minimal locker, steady state. +Locker call frame 103,533 gas; root frame 115,439 gas including the harness call._ ![Flamegraph of a Uniswap v3 single-hop swap](/gas/flamegraph-v3-single.svg) -_Flamegraph: Uniswap v3 single-hop swap via direct pool call, 104,675 gas._ +_Flamegraph: Uniswap v3 one-pool route through the SwapRouter-shaped minimal +router, steady state (the cheapest path a user can send). Router call frame +94,078 gas; root frame 108,327 gas including the harness call._ -| Component | Ekubo (87,384) | Uniswap v4 (120,648) | Uniswap v3 (104,675) | -| -------------------------------------- | --------------------------- | ---------------------------------- | --------------------------------- | -| Pool math and state | Core swap: **20,331** (23%) | PoolManager swap: **45,984** (38%) | Pool internals: **~76,700** (73%) | -| Settlement (transfers plus accounting) | ~45,700 | ~40,700 | ~28,000 | -| Router and lock overhead | ~21,400 | ~32,000 | 0 (direct call) | +Reading the call frames off the three graphs: -Three observations: +| Component | Ekubo (87,416) | Uniswap v4 (103,533) | Uniswap v3 route (94,078) | +| ----------------------------------------------------------------- | --------------------------- | ---------------------------------- | ----------------------------- | +| Pool math and state (the core swap frame, transfers excluded) | Core swap: **20,363** (23%) | PoolManager swap: **28,884** (28%) | Pool internals: ~36,800 (39%) | +| Token settlement (two ERC20 transfers plus the calls around them) | 37,680 (43%) | 38,024 (37%) | ~29,600 (31%) | +| Entry point, lock or unlock, and callback plumbing (remainder) | 29,373 (34%) | 36,625 (35%) | 27,648 (29%) | + +The v3 split is approximate because its pool frame (66,430) contains the +callback payment and the output transfer; those and the balance checks are +moved into the settlement row. Three observations: - **The pool core is where Ekubo wins.** One packed storage word and Q64 - fixed-point math cost about 20k per swap, against 46k for v4's multi-slot - state with protocol-fee and donation bookkeeping, and about 77k for v3's - slot0, fee-growth, and per-swap oracle writes. -- **Ekubo spends part of it back on settlement and routing.** Flash-accounting - settlement (~46k) costs slightly more than v4's (~41k) and v3's (~28k), and - the Solidity Router's ABI round-trip (~21k) nearly matches v4's lock - overhead. The net win comes from the pool core, and it compounds across - multi-pool routes, where Ekubo adds one cheap pool call per hop instead of - another full settle-and-callback cycle. -- **Warm and cold were both measured.** Repeating any swap on an already-touched - pool costs about 17k less on v4 and v3 (cold fee, oracle, and donation slots), - while Ekubo measures identical to the gas unit either way — its swap path has - almost no cold-sensitive slots outside the common base. An earlier draft of - this page reported only warmed numbers, which understated v4 and v3 single-hop - costs by that 17k; the tables above use the cold path throughout. + fixed-point math cost about 20k per swap, against 29k for v4's multi-slot state + with protocol-fee and donation bookkeeping, and about 37k for v3's slot0, + fee-growth, and tick bookkeeping. On a first swap in a fresh pool the two + Uniswap cores each cost 17,100 more. +- **Settlement is a wash between Ekubo and v4.** Flash-accounting settlement + costs about 38k on both (two transfers of about 12.5k each plus the payment + and withdrawal accounting); v3 spends less on settlement per pool because it + moves tokens directly, then spends it back twice per extra pool. +- **Plumbing is cheap on Ekubo.** The Router's ABI round-trip and lock cost about + 29k, under v4's unlock and callback plumbing at about 37k. The core win + compounds across multi-pool routes, where Ekubo adds one cheap pool call per + hop instead of another full settle-and-callback cycle. + +## Providing liquidity + +Providing liquidity is the one measured operation where Ekubo is not the +cheapest. Minting an economically matched full-range position (1M tokens a side) +as a new position whose boundary ticks are already initialized, direct to the +core on each protocol (no NFT, no position manager). Execution gas; the v3 rows +earn a 2,800 refund, so gross figures are shown: + +| Mint path | Ekubo | Uniswap v4 | Uniswap v3, net (gross, refund) | +| ------------------------------------------------------------- | ------- | ---------- | ------------------------------- | +| New position, bare / direct to core | 179,020 | 162,188 | **138,380** (141,180, 2,800) | +| Top-up of an existing position, direct | — | — | 121,496 (124,296, 2,800) | +| New position through the Positions NFT (harness-only context) | 213,104 | — | — | + +Ekubo's bare mint costs 10.4% more than v4's and 29.4% more than a new v3 +position (26.8% more against v3 gross). The v3 top-up row is cheaper still +because the position's slots are already nonzero; it is not a new position and +has no counterpart above. Ekubo's Positions NFT adds 34,084 (19%) over the bare +path for the token mint, metadata, and protocol-fee accounting; production +position managers add overhead on all three protocols, and no v4 or v3 manager +path was measured, so the NFT row is harness-only context rather than a +cross-protocol verdict. Cheap swaps and relatively expensive mints are two +sides of the same design: Core is optimized for the operation that happens +orders of magnitude more often. + +## Creating a pool + +Initializing a pool once, execution gas: + +| Operation | Ekubo | Uniswap v4 | Uniswap v3 | +| --------------------------- | ------ | ---------- | ----------------------------------------------------------------- | +| Initialize an existing pool | 92,935 | 51,812 | 70,328 (plus `createPool`: 4,558,970 to deploy the pool contract) | + +Ekubo's `initializePool` is 41,123 gas more than v4's `initialize` because it +writes both fee-per-liquidity accumulator slots from zero at creation (two +20,000-gas writes); that is exactly what spares every Ekubo pool the 17,100-gas +first-swap surcharge that v4 and v3 pay. v3 pools are separate contracts, so +creating one costs 4.63 million gas in total. ## Mainnet-fork validation Lab harnesses control everything, so the same comparison was re-run where it -matters: one pair, production code, real liquidity. All three legs swap -1000 USDT to USDC on a mainnet fork (block 25991868), warmed, with negligible -tick movement (0–1 ticks): - -| Swap on mainnet fork | Ekubo | Uniswap v4 | Uniswap v3 | -| -------------------------------- | ----------- | ---------- | ---------- | -| 1000 USDT to USDC, execution gas | **110,622** | 132,388 | 128,611 | - -That is about 14% less than v3 and 16% less than v4, on the same pair and the -same size. Each leg uses its production path: Ekubo through the real deployed -Yul router against a live USDC/USDT pool with SDK-generated calldata, v3 -through the real SwapRouter against the deepest fee-100 pool, v4 through the -real PoolManager against the deepest fee-100 pool with a minimal locker (the -Universal Router would cost more). Fee tiers differ across venues, which does -not affect the code path; output matched the production quoter within 0.02% on -the Ekubo leg, and both swap directions reconcile across all three harnesses. - -## The mint exception - -Providing liquidity is the one measured operation where Ekubo is not the -cheapest. Minting an economically matched position (1M tokens a side) as a -subsequent mint with boundary ticks already initialized, same tier of caller on -each protocol (direct-to-core, no NFT, no position manager): - -| Mint path | Ekubo | Uniswap v4 | Uniswap v3 | -| -------------------------- | --------------------- | ---------- | ----------- | -| Bare / direct to core | 179,020 | 162,188 | **121,430** | -| User-facing (NFT position) | 213,104 via Positions | — | — | - -Ekubo's Positions NFT adds about 34k over the bare core path for minting, token -metadata, and protocol-fee accounting; production position managers add overhead -on all three protocols, and no v4/v3 manager path was measured here, so the NFT -row is harness-only context rather than a cross-protocol verdict. Cheap swaps -and relatively expensive mints are two sides of the same design: Core is -optimized for the operation that happens orders of magnitude more often. +matters: one pair, production code, real liquidity. All three legs swap USDT to +USDC on a mainnet fork pinned at block 25991868, steady state (one identical +unmeasured swap first), at two sizes. Execution gas and the USDC received: + +| Swap on mainnet fork | Ekubo | Uniswap v4 | Uniswap v3 | +| -------------------- | ------------------------ | -------------------- | -------------------- | +| 1000 USDT to USDC | **110,622** (999.176773) | 132,388 (999.169404) | 128,611 (999.209400) | +| 100 USDT to USDC | **110,591** (99.920076) | 132,424 (99.917651) | 128,611 (99.920949) | + +That is 14.0% less execution gas than v3 and 16.4% less than v4 at 1000 USDT +(14.0% and 16.5% at 100 USDT); the size barely matters because each swap moves +the price by 0–1 ticks. The pools, identified on-chain at the pinned block: + +- **Ekubo:** the live USDC/USDT concentrated pool with no extension (core only), + config word `0x…53e2d6238da480000032`: fee `0x53e2d6238da4` / 2^64 + (0.0005%), tick spacing 50 (Ekubo ticks are 100x finer than Uniswap's), pool + id `0x6fde3244f6fa747ae318aba6e982a4281febad4d22e3b134aa299a83a951895d`, + swapped through the deployed production Yul router with SDK-generated + calldata. The output matched the production quoter within 0.02%. +- **Uniswap v3:** pool `0x3416cF6C708Da44DB2624D63ea0AAef7113527C6`, fee 0.01% + (fee-100), tick spacing 1, chosen as the deepest of the fee-100, 500 and 3000 + USDC/USDT pools at the block; through the deployed SwapRouter + `exactInputSingle`. +- **Uniswap v4:** pool id + `0xe018f09af38956affdfeab72c2cefbcd4e6fee44d09df7525ec9dba3e51356a5`, fee + 0.01%, tick spacing 1, `hooks = address(0)`, confirmed initialized under that + key (nonzero price), so the pool has no hook; the deepest of the same three + fee tiers. Through the deployed PoolManager with a minimal locker; the + Universal Router would cost more. + +In every leg the recipient is the test contract, which already holds USDT (via +`deal`), holds USDC after the warm-up swap (asserted), and has granted the router +under test a maximum allowance (asserted), so no measured call pays a +zero-to-nonzero balance or allowance write. Fee tiers differ across venues, +which does not change the code path. USDT returns no data from `approve` and +`transferFrom`, so the v4 locker settles with low-level calls, as production +routers do. + +## Calldata: the production routers + +Execution gas excludes the transaction's calldata, which is where router design +shows up a second time. Reference calldata for the same 1000-USDT swap shape, +byte counts verified by decoding each file: + +| Router call | Calldata | Calldata gas | +| ---------------------------------------- | --------- | ------------ | +| Ekubo Yul router (production route) | 184 bytes | 2,128 | +| Ekubo Solidity `Router`, single swap | 132 bytes | 1,200 | +| Uniswap SwapRouter `exactInputSingle` | 260 bytes | 1,904 | +| Uniswap Universal Router, single V3 swap | 548 bytes | 3,200 | +| Minimal v4 locker (fork harness) | 292 bytes | 2,572 | + +Two things stand out. First, the Yul router's packed route (184 bytes) is +longer than the Solidity Router's ABI call (132 bytes): its optimization is +execution-side (no ABI round-trip, one lock for the whole route), not +calldata-side, and the fork measurement captures exactly that. Second, the +Universal Router is by far the heaviest: command-dispatch generality costs over +500 bytes before any Permit2 funding command, which realistic flows prepend on +top (first-time Permit2 approvals are excluded here, as are the one-time token +approvals in every harness). + +Calldata is second-order next to execution, a 1–2k spread against 20–60k +execution gaps, but it moves the fork totals the same way. Transaction gas for +the 1000-USDT fork swaps, execution plus 21,000 plus the calldata above: + +| Transaction on mainnet fork | Ekubo, Yul router | Uniswap v3, SwapRouter | Uniswap v4, minimal locker | +| --------------------------- | -------------------------------------- | ---------------------------------- | ---------------------------------- | +| 1000 USDT to USDC | **133,750** (110,622 + 21,000 + 2,128) | 151,515 (128,611 + 21,000 + 1,904) | 155,960 (132,388 + 21,000 + 2,572) | + +Ekubo's transaction costs 11.7% less than v3's and 14.2% less than v4's. The +100-USDT transactions differ from these by under 100 gas. A Universal Router +leg would widen both Uniswap gaps on calldata and execution alike. The EIP-7623 +floor for these transactions is 3,000–8,000 gas, far below their regular cost, +so it never binds. + +## What this means for chain capacity + +Mainnet's block gas limit is 60 million (59,999,943 at the fork block 25991868 +and unchanged at block 25994835 on September 17, 2026). Dividing the limit by +the transaction gas of the fork swaps gives each protocol's ceiling on +single-hop swaps per block if a block held nothing else: + +| Basis: 1000 USDT fork swap, transaction gas | Ekubo | Uniswap v3 | Uniswap v4 | +| ------------------------------------------- | ------- | ---------- | ---------- | +| Gas per swap | 133,750 | 151,515 | 155,960 | +| Swaps per 60M block | **448** | 396 | 384 | + +The chain-wide effect depends on how much block space swaps take. Over the 30 +days ending September 17, 2026, mainnet burned 1,255.7 ETH of base fees +(ultrasound.money); transactions to known DEX router contracts accounted for +about 4.2% of that burn, a lower bound because router-address attribution misses +swaps routed through aggregators, MEV bundles, and direct pool calls. Dune was +unavailable to re-derive that share; ultrasound's coarser "defi" category, which +includes lending and everything else, was 19.4% over the same window and +brackets it from above. + +Scenario math, with the assumptions stated: if a swap's share of the burn equals +its share of gas, every such swap is a single hop like the fork swaps, and the +per-transaction saving above (11.7% against v3, 14.2% against v4) applies to +all of them, moving the DEX-router slice to Ekubo would free about 0.5–0.6% of +block gas chain-wide at the 4.2% lower bound, and 2.3–2.8% if the whole "defi" +slice behaved like swaps. Multi-pool routes save more per transaction (18.4% +against v4 and 32.0% against v3 at three pools), and induced demand would fill +freed space. Read these as an order of magnitude, not a forecast. ## Methodology and limitations -- Each protocol compiles under its own production settings (Ekubo: solc 0.8.33, - 9999999 optimizer runs, via IR, Osaka; v4: v4-core's own settings; v3: - canonical mainnet bytecode), with Foundry 1.8.3 throughout, each test on a - fresh chain. -- One identical token artifact runs in every lab harness, every recipient is - pre-funded, and multihop routers deliver output to the calling user, so no - measured call pays a zero-to-nonzero balance write the others avoid. (An - early v4 multihop router that retained output broke this rule and was fixed; - the numbers did not move.) -- Single-hop figures are cold-path on capitalized pools; scaling marginals are - warmed, matching in-route conditions. `vm.cool` was verified to be a no-op - in this setup and is not used. -- Routers are deliberately minimal on all sides, which favors Uniswap: +- **Toolchains.** Each protocol compiles under its own production settings + (Ekubo: solc 0.8.33, 9,999,999 optimizer runs, via IR, Osaka; v4: v4-core's + own settings; v3: canonical mainnet bytecode), all on Foundry 1.8.3. Every + test runs on a fresh chain; measured calls run under Foundry's `isolate` + setting, which gives each top-level call its own EVM context so that + end-of-call refunds are applied, and `snapshotGasLastCall` reports that call's + execution gas. It excludes the 21,000 base and calldata: the non-isolated + flamegraph frame for the same call matches the isolated snapshot to the gas + unit. Transaction gas on this page is computed, never measured. +- **Headline basis.** Single swaps and routes are steady state, with a first-swap + note where the difference matters (Ekubo about the same, v4 and v3 +17,100). + `vm.cool` was checked while building the harnesses and changed no figure; no + committed test uses it. Switching the v3 and v4 harnesses from Cancun to Osaka + changes none of their swap figures. +- **Refunds.** Snapshots are net of EIP-3529 refunds. The v3 harness also records + the gross figure and the refund granted next to every snapshot; every Ekubo + and v4 measurement has zero refund. +- **Like for like.** One identical token artifact (solmate `MockERC20`, deployed + from the same bytecode) runs in every lab harness. Every recipient already + holds the output token, and every router delivers output to the calling user, + so no measured call pays a zero-to-nonzero balance write the others avoid. + Liquidity is economically matched (1M tokens a side; v3 mints the equivalent + liquidity for the same amounts), pools are initialized strictly inside a tick, + and no swap crosses an initialized tick. +- **Routers.** Deliberately minimal on all sides, which favors Uniswap: production routers (Universal Router, SwapRouter, and Ekubo's own frontend - path) all cost more than the numbers above. -- Scope is no-crossing swaps on hookless/extensionless pools: no Uniswap v4 - hooks, no Ekubo extensions, both swap directions covered, concentrated pools - throughout. -- v3 per-hop figures exclude end-of-transaction refunds (about 4.8k per hop - from clearing intermediate balances) — the ranking is unaffected. -- Any v4 hook or Ekubo extension changes every number on this page; pool - configuration (fee, tick spacing) barely moves them. - -The complete harnesses, raw snapshots, flamegraphs, and reproduction commands -live in `benchmarks/` alongside these docs. + path) all cost more than the lab numbers. Ekubo is additionally measured + through its production Solidity `Router`; the v3 direct pool call is a floor + an externally owned account cannot send. +- **Scope.** No-crossing swaps on hookless, extensionless pools: any v4 hook or + Ekubo extension changes every number on this page, while pool configuration + (fee, tick spacing) barely moves them. Both swap directions are covered. +- **Fork.** Pinned block 25991868, production contracts and live pools + identified by on-chain probing, warmed as in the lab, negligible tick + movement. Fee tiers differ across venues. + +The complete harnesses, raw snapshots, reference calldata, flamegraphs, and +reproduction commands live in `benchmarks/` alongside these docs. From 47d74e290293d766301da00409883e24a52555a9 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 00:50:55 -0400 Subject: [PATCH 05/17] Correct EIP-7623 floor figures to total floor ~24-29k gas Co-Authored-By: Claude Fable 5.1 --- src/content/docs/concepts/gas-efficiency.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index 745f412..4375b24 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -283,8 +283,8 @@ the 1000-USDT fork swaps, execution plus 21,000 plus the calldata above: Ekubo's transaction costs 11.7% less than v3's and 14.2% less than v4's. The 100-USDT transactions differ from these by under 100 gas. A Universal Router leg would widen both Uniswap gaps on calldata and execution alike. The EIP-7623 -floor for these transactions is 3,000–8,000 gas, far below their regular cost, -so it never binds. +floor for these transactions is about 24,000–29,000 gas in total (21,000 plus +10 gas per calldata token), far below their regular cost, so it never binds. ## What this means for chain capacity From 6cf5ebbde9dc602890c4ca84fc66be54aac5dc21 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 09:20:36 -0400 Subject: [PATCH 06/17] Measure mints through production position managers; revise mint section Add mainnet-fork mint benchmarks (block 25991868) through the deployed Ekubo Positions contract, the Uniswap v4 PositionManager and the Uniswap v3 NonfungiblePositionManager on the same live USDC/USDT pools as the fork swap legs, with cold-boundary-tick variants and state assertions proving each mint. Measured (warm, boundary ticks initialized): Ekubo 244,513, v4 297,390, v3 381,653. The gas page now presents mints at two tiers: the user-facing production-manager tier, where Ekubo is cheapest (17.8% below v4, 35.9% below v3), and the bare direct-to-core lab tier, which keeps its existing numbers and remains the one place Ekubo is not cheapest. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 104 ++++++++-- benchmarks/fork/ForkMintEkubo.t.sol | 190 ++++++++++++++++++ benchmarks/fork/ForkMintEkuboTest.json | 9 + benchmarks/fork/ForkMintV3.t.sol | 205 +++++++++++++++++++ benchmarks/fork/ForkMintV3Test.json | 9 + benchmarks/fork/ForkMintV4.t.sol | 206 ++++++++++++++++++++ benchmarks/fork/ForkMintV4Test.json | 9 + src/content/docs/concepts/gas-efficiency.md | 97 ++++++--- 8 files changed, 792 insertions(+), 37 deletions(-) create mode 100644 benchmarks/fork/ForkMintEkubo.t.sol create mode 100644 benchmarks/fork/ForkMintEkuboTest.json create mode 100644 benchmarks/fork/ForkMintV3.t.sol create mode 100644 benchmarks/fork/ForkMintV3Test.json create mode 100644 benchmarks/fork/ForkMintV4.t.sol create mode 100644 benchmarks/fork/ForkMintV4Test.json diff --git a/benchmarks/README.md b/benchmarks/README.md index 32147df..886624a 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -13,8 +13,10 @@ measures code and architecture rather than compiler tuning. - `lab/MockERC20.json` — the single shared token artifact deployed via `deployCode` in all three lab harnesses, so ERC20 transfer costs cannot skew the comparison - `fork/` — mainnet-fork validation at pinned block `25991868` - (`ForkEkuboReal.t.sol`, `ForkV3.t.sol`, `ForkV4.t.sol`, `ForkProbe.t.sol` for the - pool identifiers, `calldata-*.hex` reference calldata, `encode-yul-route.mjs`) + (`ForkEkuboReal.t.sol`, `ForkV3.t.sol`, `ForkV4.t.sol` for swaps; + `ForkMintEkubo.t.sol`, `ForkMintV3.t.sol`, `ForkMintV4.t.sol` for mints through the + production position managers; `ForkProbe.t.sol` for the pool identifiers; + `calldata-*.hex` reference calldata, `encode-yul-route.mjs`) `*.json` files next to each harness are the raw `snapshots/` output of a full run of that test contract. `remappings.txt` files contain the original absolute paths and will @@ -130,6 +132,68 @@ Uniswap's). The v4 key with `hooks = address(0)` is confirmed initialized (nonze - WETH: one `deposit` in steady state (the account already holds WETH), 27,938: what a v3 user pays to wrap ETH before a native-input swap (`test_fork_weth_deposit`). +### Fork mints through the production position managers + +`ForkMintEkubo.t.sol`, `ForkMintV4.t.sol` and `ForkMintV3.t.sol` mint a new full-range +position on the same three pools through each protocol's deployed position manager, the +path a liquidity provider actually sends. Same block, same `isolate` discipline; the +snapshot is the execution gas of the measured call. + +- Position: each pool's widest range aligned to its tick spacing (Uniswap spacing 1: + `-887272..887272`, the full tick range; Ekubo spacing 50: `-88722800..88722800`, the + widest multiples of 50 inside `MIN_TICK/MAX_TICK = ±88722835`). Max amounts 10,000 + USDC and 10,000 USDT (`deal`ed 100,000 of each in setup); the v4 leg passes the + liquidity computed from those amounts and the pool's current price + (`LiquidityAmounts.getLiquidityForAmounts`), since `MINT_POSITION` takes liquidity, with + the same amounts as `amount0Max/amount1Max`. Recipient/owner is the test contract; + `deadline = block.timestamp`; no receiver hook is invoked on any leg (plain `_mint`). +- Payment: Ekubo Positions pulls via `transferFrom` from `msg.sender` (ERC20 approval to + Positions); v3 NPM pulls in the pool callback (approval to NPM); v4 PositionManager + pulls through Permit2 (ERC20 approval to Permit2 plus a `Permit2.approve` allowance to + the manager). All approvals are granted in setup and never measured. USDT returns no + returndata, so its approvals are low-level calls. +- Warm-up: one identical, unmeasured mint first. Every mint creates a new NFT, so the + warm-up is a separate position that initializes the boundary ticks if they were cold + and makes the minter's NFT balance nonzero; the measured mint is then a new position + with initialized boundary ticks, the lab condition. On the Ekubo leg the warm-up uses + `mintAndDepositWithSalt` with an explicit salt: `mint()` derives its salt from + `prevrandao` and remaining gas, which two identical isolated calls share, so a plain + repeat reverts with `TokenAlreadyExists`. The measured Ekubo call is the plain + user-facing `mintAndDeposit`. The v4 tokenId is `nextTokenId()` read before the call. +- Boundary-tick state, checked on-chain before the warm-up: both Uniswap pools already had + their full-range ticks initialized (`ticks(...).initialized` on v3, + `getTickLiquidity` on v4); the Ekubo pool had no position at `±88722800` + (`Core.nextInitializedTick`), so its warm-up figure includes tick initialization and + the first NFT for the minter. A second test per leg (`*_coldBoundaryTicks`) mints, after + the same full-range warm-up, a range whose two boundary ticks are asserted + uninitialized (`±500001` on Uniswap, `±88722750` on Ekubo) and asserted initialized + afterwards. +- Proof of execution, asserted after the measured mint: `ownerOf(id)` is the minter, the + manager's recorded position liquidity equals the returned liquidity (Ekubo + `getPositionFeesAndLiquidity`, v4 `getPositionLiquidity`, v3 `positions`), the pool's + active liquidity equals its value before plus both mints (Ekubo: Core's packed pool + word; v4 `getLiquidity`; v3 `liquidity()`), and the minter's USDC and USDT balances fall + by exactly the amounts pulled. The v4 leg also asserts the manager already stored this + pool key (no first-use pool-key write in the measurement). +- Manager identity, asserted on the fork: v3 NPM `name()` = `Uniswap V3 Positions NFT-V1` + and `factory()` = the v3 factory; v4 PositionManager `name()` = + `Uniswap v4 Positions NFT`, `poolManager()` = the PoolManager, `permit2()` = Permit2; + Ekubo Positions `name()` = `Ekubo Positions`. The Ekubo pool id is re-derived as + `keccak256(abi.encode(USDC, USDT, config))` and asserted equal to the swap leg's id, and + Core's packed state at that slot is asserted initialized; the v4 pool id is re-derived + from the key and asserted equal to the swap leg's id. + +| Leg (execution gas) | Measured (warm, ticks initialized) | Cold boundary ticks | First mint (warm-up) | Liquidity | Amount0 USDC | Amount1 USDT | +| ---------------------------------- | ---------------------------------- | ------------------- | -------------------- | ------------- | ------------- | -------------- | +| Ekubo Positions `mintAndDeposit` | 244,513 | 369,791 | 420,636 (cold ticks) | 9,996,041,392 | 9,992,084,352 | 10,000,000,000 | +| v4 PositionManager `MINT_POSITION` | 297,390 | 376,984 | 314,490 | 9,996,510,484 | 9,993,022,187 | 10,000,000,000 | +| v3 NPM `mint` | 381,653 | 522,450 | 416,642 | 9,996,546,576 | 9,993,094,345 | 10,000,000,000 | + +Ekubo tokenId `25083100237766864067710388918595849694396003226932398993912489470024538291565` +(salt-derived), v4 tokenId 405710, v3 tokenId 1365900. The Ekubo figures are not +comparable to the lab Positions NFT figure (213,104): the fork runs against the real USDC +proxy and USDT contracts, not the shared mock token. + Calldata reference files (exact byte counts verified by decoding each file): | Router call | File | Bytes | Zero / nonzero | Calldata gas | EIP-7623 floor | @@ -188,20 +252,36 @@ only the tests that ran. ```sh forge test --fork-url --fork-block-number 25991868 \ --match-contract 'ForkEkuboRealTest|ForkV3Test|ForkV4Test|ForkProbeTest' + +# mints through the production position managers (one contract at a time keeps +# each snapshot file complete; add retries/backoff for rate-limited public RPCs) +forge test --fork-url --fork-block-number 25991868 \ + --fork-retries 10 --fork-retry-backoff 2000 \ + --match-contract 'ForkMintEkuboTest|ForkMintV3Test|ForkMintV4Test' -vv ``` +The block needs an archive-capable endpoint. The swap legs were captured through +`https://ethereum-rpc.publicnode.com` while the block was inside its recent-state window; +the mint legs were run later against `https://eth-mainnet.public.blastapi.io` (publicnode +had by then started refusing the block as an archive request). Both serve the same +canonical state, which the assertions on pool identity, price and manager wiring confirm. + Deployed addresses used (all verified on-chain, none memorized): -| Contract | Address | -| ------------------------------- | -------------------------------------------- | -| Ekubo Core | `0x00000000000014aA86C5d3c41765bb24e11bd701` | -| Ekubo Yul router (production) | `0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748` | -| Uniswap v4 PoolManager | `0x000000000004444c5dc75cB358380D2e3dE08A90` | -| Uniswap v3 SwapRouter (classic) | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | -| Uniswap v3 factory | `0x1F98431c8aD98523631AE4a59f267346ea31F984` | -| WETH | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | -| USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | -| USDT | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | +| Contract | Address | +| ----------------------------------------------------------------------------------- | -------------------------------------------- | +| Ekubo Core | `0x00000000000014aA86C5d3c41765bb24e11bd701` | +| Ekubo Yul router (production) | `0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748` | +| Uniswap v4 PoolManager | `0x000000000004444c5dc75cB358380D2e3dE08A90` | +| Uniswap v3 SwapRouter (classic) | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | +| Uniswap v3 factory | `0x1F98431c8aD98523631AE4a59f267346ea31F984` | +| Ekubo Positions (canonical, original generation; docs contracts reference) | `0x02D9876A21AF7545f8632C3af76eC90b5ad4b66D` | +| Uniswap v4 PositionManager (docs.uniswap.org/contracts/v4/deployments, Ethereum: 1) | `0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e` | +| Permit2 | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | +| Uniswap v3 NonfungiblePositionManager | `0xC36442b4a4522E871399CD717aBDD847Ab11FE88` | +| WETH | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | +| USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | +| USDT | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | The v3/v4 fork pools (USDT/USDC fee-100, deepest at the pinned block) were selected by on-chain probing (`ForkProbe.t.sol`, which also reads the Ekubo pool state directly from diff --git a/benchmarks/fork/ForkMintEkubo.t.sol b/benchmarks/fork/ForkMintEkubo.t.sol new file mode 100644 index 0000000..5b57245 --- /dev/null +++ b/benchmarks/fork/ForkMintEkubo.t.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test} from "forge-std/Test.sol"; + +/// @dev Minimal surface of the deployed canonical Ekubo Positions (evm-contracts v3.1.1 generation). +/// PoolKey is (address token0, address token1, bytes32 config); mintAndDeposit returns the new +/// NFT id, the liquidity added and the amounts pulled from msg.sender. +interface IEkuboPositions { + struct PoolKey { + address token0; + address token1; + bytes32 config; + } + + function mintAndDeposit( + PoolKey memory poolKey, + int32 tickLower, + int32 tickUpper, + uint128 maxAmount0, + uint128 maxAmount1, + uint128 minLiquidity + ) external payable returns (uint256 id, uint128 liquidity, uint128 amount0, uint128 amount1); + function mintAndDepositWithSalt( + bytes32 salt, + PoolKey memory poolKey, + int32 tickLower, + int32 tickUpper, + uint128 maxAmount0, + uint128 maxAmount1, + uint128 minLiquidity + ) external payable returns (uint256 id, uint128 liquidity, uint128 amount0, uint128 amount1); + function getPositionFeesAndLiquidity(uint256 id, PoolKey memory poolKey, int32 tickLower, int32 tickUpper) + external + view + returns (uint128 liquidity, uint128 principal0, uint128 principal1, uint128 fees0, uint128 fees1); + function ownerOf(uint256 id) external view returns (address); + function name() external view returns (string memory); +} + +interface IEkuboCoreLike { + function nextInitializedTick(bytes32 poolId, int32 fromTick, uint32 tickSpacing, uint256 skipAhead) + external + view + returns (int32 tick, bool isInitialized); +} + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); +} + +/// @notice Mainnet-fork mint through the REAL deployed canonical Ekubo Positions NFT contract +/// (address from the docs contracts reference, verified on the fork: code + name) on the REAL +/// live USDC/USDT concentrated pool used by the fork swap leg (spacing 50, no extension). +/// Pool id re-derived as keccak256(token0, token1, config) and asserted against the id used +/// by the swap leg, and the pool's packed state in Core is asserted initialized. +/// Widest position aligned to spacing 50 (+-88,722,800; MIN/MAX_TICK are +-88,722,835), +/// max amounts 10,000 USDC + 10,000 USDT, NFT to msg.sender (this contract). Tokens are +/// pulled by Positions via transferFrom (ERC20 approval to Positions in setup, not measured). +/// One identical unmeasured mint first (separate NFT; it uses mintAndDepositWithSalt with an +/// explicit salt because mint() derives its salt from prevrandao and remaining gas, which two +/// identical isolated calls share, so a plain repeat would collide on the same NFT id). The +/// measured call is the plain user-facing mintAndDeposit. Every call is its own transaction +/// (`isolate`). USDT returns no returndata: approve via low-level call. +/// Run with: --fork-url --fork-block-number 25991868 +contract ForkMintEkuboTest is Test { + IEkuboPositions constant POSITIONS = IEkuboPositions(0x02D9876A21AF7545f8632C3af76eC90b5ad4b66D); + address constant CORE = 0x00000000000014aA86C5d3c41765bb24e11bd701; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + bytes32 constant CONFIG = 0x0000000000000000000000000000000000000000000053e2d6238da480000032; + bytes32 constant EXPECTED_POOL_ID = 0x6fde3244f6fa747ae318aba6e982a4281febad4d22e3b134aa299a83a951895d; + + int32 constant TICK_LOWER = -88722800; // -1774456 * 50 (MIN_TICK is -88722835) + int32 constant TICK_UPPER = 88722800; + uint128 constant AMOUNT = 10_000_000_000; // 10,000 (6 decimals) per side + + IEkuboPositions.PoolKey key; + + function _setup() internal { + assertGt(address(POSITIONS).code.length, 0, "Positions has code on fork"); + assertGt(CORE.code.length, 0, "Core has code on fork"); + assertEq(POSITIONS.name(), "Ekubo Positions", "positions name"); + + key = IEkuboPositions.PoolKey({token0: USDC, token1: USDT, config: CONFIG}); + bytes32 poolId = keccak256(abi.encode(USDC, USDT, CONFIG)); + assertEq(poolId, EXPECTED_POOL_ID, "pool id matches fork swap leg"); + // Core packs pool state at slot poolId: sqrtRatio (96 bits) | tick (32) | liquidity (128) + bytes32 state = vm.load(CORE, poolId); + assertGt(uint256(state) >> 160, 0, "pool initialized (sqrtRatio != 0)"); + assertEq(uint256(CONFIG) & 0x7fffffff, 50, "tick spacing 50"); + assertTrue((uint256(CONFIG) & 0x80000000) != 0, "concentrated"); + + deal(USDC, address(this), 100_000_000_000); + deal(USDT, address(this), 100_000_000_000); + assertTrue(IERC20Like(USDC).approve(address(POSITIONS), type(uint256).max), "usdc approve"); + (bool ok,) = + USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(POSITIONS), type(uint256).max)); + assertTrue(ok, "usdt approve"); + assertEq(IERC20Like(USDC).allowance(address(this), address(POSITIONS)), type(uint256).max); + assertEq(IERC20Like(USDT).allowance(address(this), address(POSITIONS)), type(uint256).max); + } + + function _mint() internal returns (uint256 id, uint128 liquidity, uint128 amount0, uint128 amount1) { + return POSITIONS.mintAndDeposit(key, TICK_LOWER, TICK_UPPER, AMOUNT, AMOUNT, 0); + } + + /// @dev Whether `tick` is an initialized tick of the pool, via Core's bitmap walk from the + /// previous spacing multiple. + function _tickInitialized(bytes32 poolId, int32 tick) internal view returns (bool) { + (int32 found, bool init) = IEkuboCoreLike(CORE).nextInitializedTick(poolId, tick - 50, 50, 0); + return init && found == tick; + } + + /// forge-config: default.isolate = true + function test_fork_mint_ekubo_positions_fullRange() public { + _setup(); + bytes32 poolId = keccak256(abi.encode(USDC, USDT, CONFIG)); + uint128 poolLiqBefore = uint128(uint256(vm.load(CORE, poolId))); + emit log_named_string( + "ekubo boundary ticks initialized before warm-up", + _tickInitialized(poolId, TICK_LOWER) && _tickInitialized(poolId, TICK_UPPER) ? "yes" : "no" + ); + + // warm-up: a separate position (explicit salt -> distinct NFT id); unmeasured for the headline, + // recorded for context + (uint256 warmId, uint128 warmLiq,,) = + POSITIONS.mintAndDepositWithSalt(bytes32(uint256(1)), key, TICK_LOWER, TICK_UPPER, AMOUNT, AMOUNT, 0); + vm.snapshotGasLastCall("fork ekubo Positions mintAndDepositWithSalt full-range USDC/USDT (first, warm-up)"); + assertGt(warmLiq, 0); + assertEq(POSITIONS.ownerOf(warmId), address(this)); + assertTrue( + _tickInitialized(poolId, TICK_LOWER) && _tickInitialized(poolId, TICK_UPPER), + "boundary ticks initialized before measured mint" + ); + + uint256 usdcBefore = IERC20Like(USDC).balanceOf(address(this)); + uint256 usdtBefore = IERC20Like(USDT).balanceOf(address(this)); + + (uint256 id, uint128 liquidity, uint128 amount0, uint128 amount1) = _mint(); + vm.snapshotGasLastCall("fork ekubo Positions mintAndDeposit full-range USDC/USDT (measured)"); + + // proof of execution + assertTrue(id != warmId, "new NFT"); + assertGt(liquidity, 0, "liquidity > 0"); + assertEq(POSITIONS.ownerOf(id), address(this), "NFT owned by minter"); + (uint128 posLiq,,,,) = POSITIONS.getPositionFeesAndLiquidity(id, key, TICK_LOWER, TICK_UPPER); + assertEq(posLiq, liquidity, "position liquidity recorded in Core"); + assertEq(IERC20Like(USDC).balanceOf(address(this)), usdcBefore - amount0, "USDC moved"); + assertEq(IERC20Like(USDT).balanceOf(address(this)), usdtBefore - amount1, "USDT moved"); + assertGt(amount0, 0); + assertGt(amount1, 0); + assertLe(amount0, AMOUNT); + assertLe(amount1, AMOUNT); + uint128 poolLiqAfter = uint128(uint256(vm.load(CORE, poolId))); + assertEq(poolLiqAfter, poolLiqBefore + warmLiq + liquidity, "pool active liquidity increased"); + + vm.snapshotValue("fork ekubo Positions mint id", id); + vm.snapshotValue("fork ekubo Positions mint liquidity", liquidity); + vm.snapshotValue("fork ekubo Positions mint amount0 (USDC, 6 decimals)", amount0); + vm.snapshotValue("fork ekubo Positions mint amount1 (USDT, 6 decimals)", amount1); + emit log_named_uint("ekubo id", id); + emit log_named_uint("ekubo liquidity", liquidity); + emit log_named_uint("ekubo amount0 USDC", amount0); + emit log_named_uint("ekubo amount1 USDT", amount1); + } + + /// @dev Same mint after the same full-range warm-up, but on a range whose boundary ticks are + /// NOT initialized on-chain (asserted), so the figure includes Ekubo tick initialization. + /// forge-config: default.isolate = true + function test_fork_mint_ekubo_positions_coldBoundaryTicks() public { + _setup(); + bytes32 poolId = keccak256(abi.encode(USDC, USDT, CONFIG)); + int32 lower = -88_722_750; // -1774455 * 50 + int32 upper = 88_722_750; + assertTrue(!_tickInitialized(poolId, lower) && !_tickInitialized(poolId, upper), "boundary ticks cold"); + + (uint256 warmId,,,) = + POSITIONS.mintAndDepositWithSalt(bytes32(uint256(1)), key, TICK_LOWER, TICK_UPPER, AMOUNT, AMOUNT, 0); + assertEq(POSITIONS.ownerOf(warmId), address(this)); + + (uint256 id, uint128 liquidity,,) = POSITIONS.mintAndDeposit(key, lower, upper, AMOUNT, AMOUNT, 0); + vm.snapshotGasLastCall("fork ekubo Positions mintAndDeposit USDC/USDT, cold boundary ticks"); + assertGt(liquidity, 0); + assertEq(POSITIONS.ownerOf(id), address(this)); + assertTrue(_tickInitialized(poolId, lower) && _tickInitialized(poolId, upper), "ticks initialized by mint"); + } +} diff --git a/benchmarks/fork/ForkMintEkuboTest.json b/benchmarks/fork/ForkMintEkuboTest.json new file mode 100644 index 0000000..4c78397 --- /dev/null +++ b/benchmarks/fork/ForkMintEkuboTest.json @@ -0,0 +1,9 @@ +{ + "fork ekubo Positions mint amount0 (USDC, 6 decimals)": "9992084352", + "fork ekubo Positions mint amount1 (USDT, 6 decimals)": "10000000000", + "fork ekubo Positions mint id": "25083100237766864067710388918595849694396003226932398993912489470024538291565", + "fork ekubo Positions mint liquidity": "9996041392", + "fork ekubo Positions mintAndDeposit USDC/USDT, cold boundary ticks": "369791", + "fork ekubo Positions mintAndDeposit full-range USDC/USDT (measured)": "244513", + "fork ekubo Positions mintAndDepositWithSalt full-range USDC/USDT (first, warm-up)": "420636" +} \ No newline at end of file diff --git a/benchmarks/fork/ForkMintV3.t.sol b/benchmarks/fork/ForkMintV3.t.sol new file mode 100644 index 0000000..1d346f5 --- /dev/null +++ b/benchmarks/fork/ForkMintV3.t.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test} from "forge-std/Test.sol"; + +interface INonfungiblePositionManager { + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + function mint(MintParams calldata params) + external + payable + returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + function positions(uint256 tokenId) + external + view + returns ( + uint96 nonce, + address operator, + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1 + ); + function ownerOf(uint256 tokenId) external view returns (address); + function factory() external view returns (address); + function name() external view returns (string memory); +} + +interface IUniswapV3PoolLike { + function tickSpacing() external view returns (int24); + function fee() external view returns (uint24); + function token0() external view returns (address); + function token1() external view returns (address); + function liquidity() external view returns (uint128); + function ticks(int24 tick) + external + view + returns ( + uint128 liquidityGross, + int128 liquidityNet, + uint256 feeGrowthOutside0X128, + uint256 feeGrowthOutside1X128, + int56 tickCumulativeOutside, + uint160 secondsPerLiquidityOutsideX128, + uint32 secondsOutside, + bool initialized + ); +} + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); +} + +/// @notice Mainnet-fork mint through the REAL deployed Uniswap v3 NonfungiblePositionManager +/// on the REAL USDT/USDC fee-100 pool (the same pool as the fork swap legs). +/// Full-range position (tick spacing 1: MIN_TICK..MAX_TICK), ~10,000 USDC + ~10,000 USDT, +/// recipient = this contract, deadline = block.timestamp. One identical unmeasured +/// mint first (a separate NFT / separate position: warm-up initializes the boundary +/// ticks if they were cold and warms the manager's per-pool state); every call is its +/// own transaction (`isolate`), so the snapshot is the execution gas of the measured mint. +/// USDT returns no returndata: approve via low-level call. +/// Run with: --fork-url --fork-block-number 25991868 +contract ForkMintV3Test is Test { + INonfungiblePositionManager constant NPM = INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88); + IUniswapV3PoolLike constant POOL = IUniswapV3PoolLike(0x3416cF6C708Da44DB2624D63ea0AAef7113527C6); + address constant FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + int24 constant TICK_LOWER = -887272; // TickMath.MIN_TICK, usable at spacing 1 + int24 constant TICK_UPPER = 887272; // TickMath.MAX_TICK + uint256 constant AMOUNT = 10_000_000_000; // 10,000 (6 decimals) per side + + function _setup() internal { + assertGt(address(NPM).code.length, 0, "NPM has code on fork"); + assertEq(NPM.factory(), FACTORY, "NPM factory"); + assertEq(NPM.name(), "Uniswap V3 Positions NFT-V1", "NPM name"); + assertEq(POOL.token0(), USDC, "pool token0"); + assertEq(POOL.token1(), USDT, "pool token1"); + assertEq(POOL.fee(), 100, "pool fee"); + assertEq(POOL.tickSpacing(), 1, "pool spacing"); + + deal(USDC, address(this), 100_000_000_000); + deal(USDT, address(this), 100_000_000_000); + assertTrue(IERC20Like(USDC).approve(address(NPM), type(uint256).max), "usdc approve"); + (bool ok,) = USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(NPM), type(uint256).max)); + assertTrue(ok, "usdt approve"); + assertEq(IERC20Like(USDC).allowance(address(this), address(NPM)), type(uint256).max); + assertEq(IERC20Like(USDT).allowance(address(this), address(NPM)), type(uint256).max); + } + + function _mint() internal returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) { + return _mintRange(TICK_LOWER, TICK_UPPER); + } + + function _mintRange(int24 lower, int24 upper) + internal + returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) + { + return NPM.mint( + INonfungiblePositionManager.MintParams({ + token0: USDC, + token1: USDT, + fee: 100, + tickLower: lower, + tickUpper: upper, + amount0Desired: AMOUNT, + amount1Desired: AMOUNT, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + }) + ); + } + + /// forge-config: default.isolate = true + function test_fork_mint_v3_npm_fullRange() public { + _setup(); + (,,,,,,, bool lowerInit) = POOL.ticks(TICK_LOWER); + (,,,,,,, bool upperInit) = POOL.ticks(TICK_UPPER); + emit log_named_string("v3 boundary ticks initialized before warm-up", lowerInit && upperInit ? "yes" : "no"); + uint128 poolLiqBefore = POOL.liquidity(); + + // warm-up: a separate position (new tokenId); unmeasured for the headline, recorded for context + (uint256 warmId, uint128 warmLiq,,) = _mint(); + vm.snapshotGasLastCall("fork v3 NPM mint full-range USDC/USDT fee-100 (first, warm-up)"); + assertGt(warmLiq, 0); + assertEq(NPM.ownerOf(warmId), address(this)); + + (,,,,,,, lowerInit) = POOL.ticks(TICK_LOWER); + (,,,,,,, upperInit) = POOL.ticks(TICK_UPPER); + assertTrue(lowerInit && upperInit, "boundary ticks initialized before measured mint"); + + uint256 usdcBefore = IERC20Like(USDC).balanceOf(address(this)); + uint256 usdtBefore = IERC20Like(USDT).balanceOf(address(this)); + + (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) = _mint(); + vm.snapshotGasLastCall("fork v3 NPM mint full-range USDC/USDT fee-100 (measured)"); + + // proof of execution + assertTrue(tokenId != warmId, "new NFT"); + assertGt(liquidity, 0, "liquidity > 0"); + assertEq(NPM.ownerOf(tokenId), address(this), "NFT owned by recipient"); + (,,,,,,, uint128 posLiq,,,,) = NPM.positions(tokenId); + assertEq(posLiq, liquidity, "position liquidity recorded"); + assertEq(IERC20Like(USDC).balanceOf(address(this)), usdcBefore - amount0, "USDC moved"); + assertEq(IERC20Like(USDT).balanceOf(address(this)), usdtBefore - amount1, "USDT moved"); + assertGt(amount0, 0); + assertGt(amount1, 0); + assertEq(POOL.liquidity(), poolLiqBefore + warmLiq + liquidity, "pool active liquidity increased"); + + vm.snapshotValue("fork v3 NPM mint tokenId", tokenId); + vm.snapshotValue("fork v3 NPM mint liquidity", liquidity); + vm.snapshotValue("fork v3 NPM mint amount0 (USDC, 6 decimals)", amount0); + vm.snapshotValue("fork v3 NPM mint amount1 (USDT, 6 decimals)", amount1); + emit log_named_uint("v3 tokenId", tokenId); + emit log_named_uint("v3 liquidity", liquidity); + emit log_named_uint("v3 amount0 USDC", amount0); + emit log_named_uint("v3 amount1 USDT", amount1); + } + + /// @dev Same mint after the same full-range warm-up, but on a range whose boundary ticks are + /// NOT initialized on-chain (asserted), so the figure includes v3 tick initialization. + /// forge-config: default.isolate = true + function test_fork_mint_v3_npm_coldBoundaryTicks() public { + _setup(); + int24 lower = -500_001; + int24 upper = 500_001; + (,,,,,,, bool lowerInit) = POOL.ticks(lower); + (,,,,,,, bool upperInit) = POOL.ticks(upper); + assertTrue(!lowerInit && !upperInit, "boundary ticks cold before mint"); + + (uint256 warmId,,,) = _mint(); // full-range warm-up, as in the headline test + assertEq(NPM.ownerOf(warmId), address(this)); + + (uint256 tokenId, uint128 liquidity,,) = _mintRange(lower, upper); + vm.snapshotGasLastCall("fork v3 NPM mint USDC/USDT fee-100, cold boundary ticks"); + assertGt(liquidity, 0); + assertEq(NPM.ownerOf(tokenId), address(this)); + (,,,,,,, lowerInit) = POOL.ticks(lower); + (,,,,,,, upperInit) = POOL.ticks(upper); + assertTrue(lowerInit && upperInit, "boundary ticks initialized by the mint"); + } +} diff --git a/benchmarks/fork/ForkMintV3Test.json b/benchmarks/fork/ForkMintV3Test.json new file mode 100644 index 0000000..2573493 --- /dev/null +++ b/benchmarks/fork/ForkMintV3Test.json @@ -0,0 +1,9 @@ +{ + "fork v3 NPM mint USDC/USDT fee-100, cold boundary ticks": "522450", + "fork v3 NPM mint amount0 (USDC, 6 decimals)": "9993094345", + "fork v3 NPM mint amount1 (USDT, 6 decimals)": "10000000000", + "fork v3 NPM mint full-range USDC/USDT fee-100 (first, warm-up)": "416642", + "fork v3 NPM mint full-range USDC/USDT fee-100 (measured)": "381653", + "fork v3 NPM mint liquidity": "9996546576", + "fork v3 NPM mint tokenId": "1365900" +} \ No newline at end of file diff --git a/benchmarks/fork/ForkMintV4.t.sol b/benchmarks/fork/ForkMintV4.t.sol new file mode 100644 index 0000000..c85aa60 --- /dev/null +++ b/benchmarks/fork/ForkMintV4.t.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol"; +import {Currency} from "v4-core/src/types/Currency.sol"; +import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; +import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol"; +import {LiquidityAmounts} from "v4-core/test/utils/LiquidityAmounts.sol"; + +/// @dev Minimal surface of the deployed v4 PositionManager (v4-periphery). +interface IV4PositionManager { + function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable; + function nextTokenId() external view returns (uint256); + function ownerOf(uint256 tokenId) external view returns (address); + function getPositionLiquidity(uint256 tokenId) external view returns (uint128); + function poolKeys(bytes25 poolId) external view returns (Currency, Currency, uint24, int24, IHooks); + function poolManager() external view returns (address); + function permit2() external view returns (address); + function name() external view returns (string memory); +} + +interface IPermit2Like { + function approve(address token, address spender, uint160 amount, uint48 expiration) external; + function allowance(address user, address token, address spender) + external + view + returns (uint160 amount, uint48 expiration, uint48 nonce); +} + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); +} + +/// @notice Mainnet-fork mint through the REAL deployed Uniswap v4 PositionManager +/// (canonical mainnet address per docs.uniswap.org/contracts/v4/deployments, verified on the +/// fork: name, poolManager(), permit2()) on the REAL USDC/USDT fee-100 hookless pool used by +/// the fork swap leg. Full-range position (tick spacing 1: MIN_TICK..MAX_TICK), liquidity +/// sized to ~10,000 USDC + ~10,000 USDT, owner = this contract, deadline = block.timestamp. +/// Actions: MINT_POSITION (0x02) + SETTLE_PAIR (0x0d); tokens are pulled through Permit2, as +/// for every PositionManager user (ERC20 approval to Permit2 + Permit2 allowance to the manager, +/// both granted in setup and not measured). One identical unmeasured mint first (separate NFT); +/// every call is its own transaction (`isolate`). +/// Run with: --fork-url --fork-block-number 25991868 +contract ForkMintV4Test is Test { + using StateLibrary for IPoolManager; + using PoolIdLibrary for PoolKey; + + IPoolManager constant MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90); + IV4PositionManager constant POSM = IV4PositionManager(0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e); + IPermit2Like constant PERMIT2 = IPermit2Like(0x000000000022D473030F116dDEE9F6B43aC78BA3); + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + bytes32 constant EXPECTED_POOL_ID = 0xe018f09af38956affdfeab72c2cefbcd4e6fee44d09df7525ec9dba3e51356a5; + + uint8 constant MINT_POSITION = 0x02; + uint8 constant SETTLE_PAIR = 0x0d; + int24 constant TICK_LOWER = -887272; // TickMath.MIN_TICK, usable at spacing 1 + int24 constant TICK_UPPER = 887272; + uint256 constant AMOUNT = 10_000_000_000; // 10,000 (6 decimals) per side + + PoolKey key; + + function _setup() internal { + assertGt(address(POSM).code.length, 0, "PositionManager has code on fork"); + assertEq(POSM.poolManager(), address(MANAGER), "posm.poolManager"); + assertEq(POSM.permit2(), address(PERMIT2), "posm.permit2"); + assertEq(POSM.name(), "Uniswap v4 Positions NFT", "posm name"); + + key = PoolKey({ + currency0: Currency.wrap(USDC), + currency1: Currency.wrap(USDT), + fee: 100, + tickSpacing: 1, + hooks: IHooks(address(0)) + }); + assertEq(PoolId.unwrap(key.toId()), EXPECTED_POOL_ID, "pool id matches fork swap leg"); + (uint160 sqrtP,,,) = MANAGER.getSlot0(key.toId()); + assertGt(sqrtP, 0, "pool initialized"); + + deal(USDC, address(this), 100_000_000_000); + deal(USDT, address(this), 100_000_000_000); + assertTrue(IERC20Like(USDC).approve(address(PERMIT2), type(uint256).max), "usdc approve permit2"); + (bool ok,) = USDT.call(abi.encodeWithSignature("approve(address,uint256)", address(PERMIT2), type(uint256).max)); + assertTrue(ok, "usdt approve permit2"); + PERMIT2.approve(USDC, address(POSM), type(uint160).max, type(uint48).max); + PERMIT2.approve(USDT, address(POSM), type(uint160).max, type(uint48).max); + (uint160 a0,,) = PERMIT2.allowance(address(this), USDC, address(POSM)); + (uint160 a1,,) = PERMIT2.allowance(address(this), USDT, address(POSM)); + assertEq(a0, type(uint160).max); + assertEq(a1, type(uint160).max); + } + + function _liquidityForAmounts() internal view returns (uint128) { + (uint160 sqrtP,,,) = MANAGER.getSlot0(key.toId()); + return LiquidityAmounts.getLiquidityForAmounts( + sqrtP, TickMath.getSqrtPriceAtTick(TICK_LOWER), TickMath.getSqrtPriceAtTick(TICK_UPPER), AMOUNT, AMOUNT + ); + } + + function _mint(uint128 liquidity) internal returns (uint256 tokenId) { + return _mintRange(TICK_LOWER, TICK_UPPER, liquidity); + } + + function _mintRange(int24 lower, int24 upper, uint128 liquidity) internal returns (uint256 tokenId) { + tokenId = POSM.nextTokenId(); + bytes memory actions = abi.encodePacked(MINT_POSITION, SETTLE_PAIR); + bytes[] memory params = new bytes[](2); + params[0] = abi.encode( + key, lower, upper, uint256(liquidity), uint128(AMOUNT), uint128(AMOUNT), address(this), bytes("") + ); + params[1] = abi.encode(key.currency0, key.currency1); + POSM.modifyLiquidities(abi.encode(actions, params), block.timestamp); + } + + /// forge-config: default.isolate = true + function test_fork_mint_v4_posm_fullRange() public { + _setup(); + bytes25 shortId = bytes25(PoolId.unwrap(key.toId())); + (Currency c0, Currency c1,,,) = POSM.poolKeys(shortId); + emit log_named_string( + "v4 PositionManager already knows this pool key before warm-up", + Currency.unwrap(c0) == USDC && Currency.unwrap(c1) == USDT ? "yes" : "no" + ); + (uint128 lowerGross,) = MANAGER.getTickLiquidity(key.toId(), TICK_LOWER); + (uint128 upperGross,) = MANAGER.getTickLiquidity(key.toId(), TICK_UPPER); + emit log_named_string( + "v4 boundary ticks initialized before warm-up", lowerGross > 0 && upperGross > 0 ? "yes" : "no" + ); + uint128 poolLiqBefore = MANAGER.getLiquidity(key.toId()); + uint128 liquidity = _liquidityForAmounts(); + assertGt(liquidity, 0); + + // warm-up: a separate position (new tokenId); unmeasured for the headline, recorded for context + uint256 warmId = _mint(liquidity); + vm.snapshotGasLastCall("fork v4 PositionManager mint full-range USDC/USDT fee-100 (first, warm-up)"); + assertEq(POSM.ownerOf(warmId), address(this)); + assertEq(POSM.getPositionLiquidity(warmId), liquidity); + + (c0, c1,,,) = POSM.poolKeys(shortId); + assertTrue(Currency.unwrap(c0) == USDC && Currency.unwrap(c1) == USDT, "pool key stored before measured mint"); + (lowerGross,) = MANAGER.getTickLiquidity(key.toId(), TICK_LOWER); + (upperGross,) = MANAGER.getTickLiquidity(key.toId(), TICK_UPPER); + assertTrue(lowerGross > 0 && upperGross > 0, "boundary ticks initialized before measured mint"); + + uint256 usdcBefore = IERC20Like(USDC).balanceOf(address(this)); + uint256 usdtBefore = IERC20Like(USDT).balanceOf(address(this)); + + uint256 tokenId = _mint(liquidity); + vm.snapshotGasLastCall("fork v4 PositionManager mint full-range USDC/USDT fee-100 (measured)"); + + // proof of execution + assertEq(tokenId, warmId + 1, "new NFT"); + assertEq(POSM.ownerOf(tokenId), address(this), "NFT owned by recipient"); + assertEq(POSM.getPositionLiquidity(tokenId), liquidity, "position liquidity recorded"); + uint256 amount0 = usdcBefore - IERC20Like(USDC).balanceOf(address(this)); + uint256 amount1 = usdtBefore - IERC20Like(USDT).balanceOf(address(this)); + assertGt(amount0, 0, "USDC moved"); + assertGt(amount1, 0, "USDT moved"); + assertLe(amount0, AMOUNT); + assertLe(amount1, AMOUNT); + assertEq(MANAGER.getLiquidity(key.toId()), poolLiqBefore + 2 * liquidity, "pool active liquidity increased"); + + vm.snapshotValue("fork v4 PositionManager mint tokenId", tokenId); + vm.snapshotValue("fork v4 PositionManager mint liquidity", liquidity); + vm.snapshotValue("fork v4 PositionManager mint amount0 (USDC, 6 decimals)", amount0); + vm.snapshotValue("fork v4 PositionManager mint amount1 (USDT, 6 decimals)", amount1); + emit log_named_uint("v4 tokenId", tokenId); + emit log_named_uint("v4 liquidity", liquidity); + emit log_named_uint("v4 amount0 USDC", amount0); + emit log_named_uint("v4 amount1 USDT", amount1); + } + + /// @dev Same mint after the same full-range warm-up, but on a range whose boundary ticks are + /// NOT initialized on-chain (asserted), so the figure includes v4 tick initialization. + /// forge-config: default.isolate = true + function test_fork_mint_v4_posm_coldBoundaryTicks() public { + _setup(); + int24 lower = -500_001; + int24 upper = 500_001; + (uint128 lowerGross,) = MANAGER.getTickLiquidity(key.toId(), lower); + (uint128 upperGross,) = MANAGER.getTickLiquidity(key.toId(), upper); + assertTrue(lowerGross == 0 && upperGross == 0, "boundary ticks cold before mint"); + + uint128 liquidity = _liquidityForAmounts(); + uint256 warmId = _mint(liquidity); // full-range warm-up, as in the headline test + assertEq(POSM.ownerOf(warmId), address(this)); + + (uint160 sqrtP,,,) = MANAGER.getSlot0(key.toId()); + uint128 liq = LiquidityAmounts.getLiquidityForAmounts( + sqrtP, TickMath.getSqrtPriceAtTick(lower), TickMath.getSqrtPriceAtTick(upper), AMOUNT, AMOUNT + ); + uint256 tokenId = _mintRange(lower, upper, liq); + vm.snapshotGasLastCall("fork v4 PositionManager mint USDC/USDT fee-100, cold boundary ticks"); + assertEq(POSM.ownerOf(tokenId), address(this)); + assertEq(POSM.getPositionLiquidity(tokenId), liq); + (lowerGross,) = MANAGER.getTickLiquidity(key.toId(), lower); + (upperGross,) = MANAGER.getTickLiquidity(key.toId(), upper); + assertTrue(lowerGross > 0 && upperGross > 0, "boundary ticks initialized by the mint"); + } +} diff --git a/benchmarks/fork/ForkMintV4Test.json b/benchmarks/fork/ForkMintV4Test.json new file mode 100644 index 0000000..6d3025c --- /dev/null +++ b/benchmarks/fork/ForkMintV4Test.json @@ -0,0 +1,9 @@ +{ + "fork v4 PositionManager mint USDC/USDT fee-100, cold boundary ticks": "376984", + "fork v4 PositionManager mint amount0 (USDC, 6 decimals)": "9993022187", + "fork v4 PositionManager mint amount1 (USDT, 6 decimals)": "10000000000", + "fork v4 PositionManager mint full-range USDC/USDT fee-100 (first, warm-up)": "314490", + "fork v4 PositionManager mint full-range USDC/USDT fee-100 (measured)": "297390", + "fork v4 PositionManager mint liquidity": "9996510484", + "fork v4 PositionManager mint tokenId": "405710" +} \ No newline at end of file diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index 4375b24..09d20f2 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -1,5 +1,5 @@ --- -description: Measured gas costs for Ekubo swaps against Uniswap v4 and v3, with like-for-like benchmarks, scaling analysis, mainnet-fork validation, and chain-capacity implications +description: Measured gas costs for Ekubo swaps and mints against Uniswap v4 and v3, with like-for-like benchmarks, scaling analysis, mainnet-fork validation through the production routers and position managers, and chain-capacity implications title: "Gas efficiency" --- @@ -171,28 +171,61 @@ moved into the settlement row. Three observations: ## Providing liquidity -Providing liquidity is the one measured operation where Ekubo is not the -cheapest. Minting an economically matched full-range position (1M tokens a side) -as a new position whose boundary ticks are already initialized, direct to the -core on each protocol (no NFT, no position manager). Execution gas; the v3 rows -earn a 2,800 refund, so gross figures are shown: - -| Mint path | Ekubo | Uniswap v4 | Uniswap v3, net (gross, refund) | -| ------------------------------------------------------------- | ------- | ---------- | ------------------------------- | -| New position, bare / direct to core | 179,020 | 162,188 | **138,380** (141,180, 2,800) | -| Top-up of an existing position, direct | — | — | 121,496 (124,296, 2,800) | -| New position through the Positions NFT (harness-only context) | 213,104 | — | — | - -Ekubo's bare mint costs 10.4% more than v4's and 29.4% more than a new v3 -position (26.8% more against v3 gross). The v3 top-up row is cheaper still -because the position's slots are already nonzero; it is not a new position and -has no counterpart above. Ekubo's Positions NFT adds 34,084 (19%) over the bare -path for the token mint, metadata, and protocol-fee accounting; production -position managers add overhead on all three protocols, and no v4 or v3 manager -path was measured, so the NFT row is harness-only context rather than a -cross-protocol verdict. Cheap swaps and relatively expensive mints are two -sides of the same design: Core is optimized for the operation that happens -orders of magnitude more often. +Minting is measured at two tiers. The **user-facing tier** is what a liquidity +provider actually sends: a mint through each protocol's production position +manager, which issues the NFT, pulls the tokens, and calls the core. The **bare +tier** is a direct core call from a lab harness with no NFT and no manager, a +path no externally owned account can send on any of the three protocols. + +**User-facing tier, mainnet fork.** A new full-range position of about 10,000 +USDC and 10,000 USDT on the same live USDC/USDT pools as the +[fork swaps](#mainnet-fork-validation), pinned at block 25991868, through the +deployed Ekubo Positions contract, the deployed Uniswap v4 PositionManager +(`MINT_POSITION` plus `SETTLE_PAIR`, tokens pulled through Permit2), and the +deployed Uniswap v3 NonfungiblePositionManager. Execution gas of the measured +mint. One identical, unmeasured mint of a separate NFT precedes it, so on every +leg the boundary ticks are initialized and the minter already owns an NFT; the +second row instead mints a range whose two boundary ticks no position uses, +after the same warm-up, so it includes tick initialization: + +| Mint on mainnet fork | Ekubo Positions | Uniswap v4 PositionManager | Uniswap v3 NonfungiblePositionManager | +| ------------------------------------------------ | --------------- | -------------------------- | ------------------------------------- | +| New position, boundary ticks already initialized | **244,513** | 297,390 | 381,653 | +| New position, both boundary ticks fresh | **369,791** | 376,984 | 522,450 | + +At the tier users touch, Ekubo's mint uses 17.8% less execution gas than v4's +and 35.9% less than v3's. Fresh boundary ticks narrow the gap: initializing two +ticks costs 125,278 on Ekubo, 79,594 on v4 and 140,797 on v3, so with both +ticks fresh Ekubo is 1.9% below v4 and 29.2% below v3. Every leg's mint is +proven by state: the NFT is owned by the minter, the position's liquidity is +recorded (9,996,041,392 on Ekubo, 9,996,510,484 on v4, 9,996,546,576 on v3), +the pool's active liquidity grows by that amount, and the minter's USDC and USDT +balances fall by the amounts pulled (about 9,992–9,993 USDC and exactly 10,000 +USDT on each). The fork figures are not comparable to the lab table below: they +run against the real USDC proxy and USDT contracts instead of the shared mock +token, and pull tokens through each manager's own path. + +**Bare tier, lab.** An economically matched full-range position (1M tokens a +side) as a new position whose boundary ticks are already initialized, direct to +the core on each protocol. Execution gas; the v3 rows earn a 2,800 refund, so +gross figures are shown: + +| Mint path, lab | Ekubo | Uniswap v4 | Uniswap v3, net (gross, refund) | +| ---------------------------------------------- | ------- | ---------- | ------------------------------- | +| New position, bare / direct to core | 179,020 | 162,188 | **138,380** (141,180, 2,800) | +| Top-up of an existing position, direct | — | — | 121,496 (124,296, 2,800) | +| New position through the Positions NFT (Ekubo) | 213,104 | — | — | + +The bare tier is the one place Ekubo is not cheapest: its bare mint costs 10.4% +more than v4's and 29.4% more than a new v3 position (26.8% more against v3 +gross). The v3 top-up row is cheaper still because the position's slots are +already nonzero; it is not a new position and has no counterpart above. In the +lab, Ekubo's Positions NFT adds 34,084 (19%) over the bare path for the token +mint, metadata, and protocol-fee accounting. The ordering reverses between the +two tiers: bare, v3 is cheapest and Ekubo dearest; through the production +managers, Ekubo is cheapest and v3 dearest. Core is optimized for the operation +that happens orders of magnitude more often, and the Positions contract is thin +enough that the whole user path is the cheapest of the three. ## Creating a pool @@ -249,6 +282,14 @@ which does not change the code path. USDT returns no data from `approve` and `transferFrom`, so the v4 locker settles with low-level calls, as production routers do. +The same three pools carry the user-facing mint comparison in +[Providing liquidity](#providing-liquidity), through the deployed Ekubo +Positions contract (`0x02D9876A21AF7545f8632C3af76eC90b5ad4b66D`), the v4 +PositionManager (`0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e`, the canonical +mainnet deployment listed by Uniswap, verified on the fork by its `poolManager` +and `permit2` wiring) and the v3 NonfungiblePositionManager +(`0xC36442b4a4522E871399CD717aBDD847Ab11FE88`). + ## Calldata: the production routers Execution gas excludes the transaction's calldata, which is where router design @@ -346,13 +387,19 @@ freed space. Read these as an order of magnitude, not a forecast. production routers (Universal Router, SwapRouter, and Ekubo's own frontend path) all cost more than the lab numbers. Ekubo is additionally measured through its production Solidity `Router`; the v3 direct pool call is a floor - an externally owned account cannot send. + an externally owned account cannot send. Mints are the exception: the + user-facing tier goes through each protocol's deployed production position + manager on the fork, with the one-time token approvals (and, for v4, the + Permit2 allowance) granted in setup and not measured. - **Scope.** No-crossing swaps on hookless, extensionless pools: any v4 hook or Ekubo extension changes every number on this page, while pool configuration (fee, tick spacing) barely moves them. Both swap directions are covered. - **Fork.** Pinned block 25991868, production contracts and live pools identified by on-chain probing, warmed as in the lab, negligible tick - movement. Fee tiers differ across venues. + movement. Fee tiers differ across venues. Fork mints use each pool's widest + spacing-aligned range (tick spacing 1 on both Uniswap pools, 50 on Ekubo), so + the three positions are not identical in liquidity, only in size; mints do + not move price. The complete harnesses, raw snapshots, reference calldata, flamegraphs, and reproduction commands live in `benchmarks/` alongside these docs. From 896bb7a2c19439daf4461f68f00df16c08f2d843 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 10:03:41 -0400 Subject: [PATCH 07/17] Attribute bare mints by frame, drop init costs, model capacity on routes - Split the lab bare mints into core state update, settlement and lock/router plumbing from -vvvv traces; label the v3 direct pool.mint as a lock-free tier and quantify the plumbing Ekubo and v4 carry (31,473 and 68,832). - State the mint exception exactly: Ekubo's core frame (106,407) is not the cheapest in the committed never-swapped lab pool (v4 63,358, v3 108,185 net), and disclose the traded-pool probe (v4 +39,800, v3 +39,844, Ekubo unchanged) that explains why: Uniswap position fee-growth snapshots go zero-to-nonzero once a pool has traded, while Ekubo pre-initializes its accumulators. - Remove one-time initialization costs from every comparison: delete the pool-creation section and the cold-boundary-tick mint rows; keep the first-swap accumulator note to one disclosed line; add the exclusion to the methodology. - Rework chain capacity around routes: 1/2/3-pool route ceilings per 60M block from the lab route totals plus 21,000, and a pools-per-swap sensitivity blend (1.0/1.5/2.0) using the measured per-extra-pool marginals. No published pools-per-swap figure was found, stated as such. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 33 ++- src/content/docs/concepts/gas-efficiency.md | 243 ++++++++++++-------- 2 files changed, 173 insertions(+), 103 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 886624a..e0c4bfc 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -84,10 +84,22 @@ both fee-per-liquidity slots up front, so its first swap costs the same as any o - Mints are new positions with both boundary ticks already initialized (different salt on Ekubo and v4, different owner on v3); v3 also records adding to an existing position (`test_gas_mint_samePosition`, 121,496 net). Every v3 mint earns a 2,800 refund. -- Pool initialization (`test_gas_initializePool`): Ekubo `initializePool` 92,935, v4 - `initialize` 51,812, v3 `initialize` 70,328 plus `createPool` 4,558,970 (v3 deploys a - pool contract). Ekubo's extra cost over v4 is the two fee-accumulator slots it - pre-writes. +- Pool initialization (`test_gas_initializePool`) is recorded in each harness's snapshot + file but is a one-time cost and is excluded from the docs page by design, as is every + cold-tick mint variant; the page's mint rows are subsequent mints into initialized + ticks only. Ekubo's `initializePool` pre-writes the two fee-accumulator slots (value 1), + which is why its first swap and its position snapshots cost the same in every pool. +- Mint attribution for the docs page's component table comes from `-vvvv` traces of the + measured mint call: Ekubo `Core.updatePosition` 106,407, settlement 2 × (5,914 + + 12,602 + 2,054) = 41,140, plumbing 31,473; v4 `PoolManager.modifyLiquidity` 63,358, + settlement 2 × (1,898 + 10,602 + 2,499) = 29,998, plumbing 68,832 (v4-core's + `PoolModifyLiquidityTest`, which also reads pool state, balances and deltas); v3 `mint` + frame 141,180 gross minus the callback (24,095) and four balance checks (6,100) = + 110,985 gross pool internals, 108,185 net of the 2,800 refund. A one-off probe (not in + the committed snapshots) that swaps once in each direction before the warm-up mint + measures Ekubo 179,020 (unchanged), v4 201,988 (`modifyLiquidity` 103,158), v3 178,224 + net / 181,024 gross: the Uniswap position fee-growth snapshots go zero-to-nonzero in a + traded pool, +39,800 each. - `Flame.t.sol` files capture one steady-state call for the flamegraphs; their router call frames equal the corresponding snapshots (Ekubo Router single 87,416, v4 minimal locker single 103,533, v3 1-pool route 94,078). The root frame of each SVG also @@ -167,7 +179,8 @@ snapshot is the execution gas of the measured call. the first NFT for the minter. A second test per leg (`*_coldBoundaryTicks`) mints, after the same full-range warm-up, a range whose two boundary ticks are asserted uninitialized (`±500001` on Uniswap, `±88722750` on Ekubo) and asserted initialized - afterwards. + afterwards; its figures, like the warm-up figures, are one-time initialization costs, + recorded in the snapshot files but excluded from the docs page by design. - Proof of execution, asserted after the measured mint: `ownerOf(id)` is the minter, the manager's recorded position liquidity equals the returned liquidity (Ekubo `getPositionFeesAndLiquidity`, v4 `getPositionLiquidity`, v3 `positions`), the pool's @@ -183,11 +196,11 @@ snapshot is the execution gas of the measured call. Core's packed state at that slot is asserted initialized; the v4 pool id is re-derived from the key and asserted equal to the swap leg's id. -| Leg (execution gas) | Measured (warm, ticks initialized) | Cold boundary ticks | First mint (warm-up) | Liquidity | Amount0 USDC | Amount1 USDT | -| ---------------------------------- | ---------------------------------- | ------------------- | -------------------- | ------------- | ------------- | -------------- | -| Ekubo Positions `mintAndDeposit` | 244,513 | 369,791 | 420,636 (cold ticks) | 9,996,041,392 | 9,992,084,352 | 10,000,000,000 | -| v4 PositionManager `MINT_POSITION` | 297,390 | 376,984 | 314,490 | 9,996,510,484 | 9,993,022,187 | 10,000,000,000 | -| v3 NPM `mint` | 381,653 | 522,450 | 416,642 | 9,996,546,576 | 9,993,094,345 | 10,000,000,000 | +| Leg (execution gas) | Measured (warm, ticks initialized) | Liquidity | Amount0 USDC | Amount1 USDT | +| ---------------------------------- | ---------------------------------- | ------------- | ------------- | -------------- | +| Ekubo Positions `mintAndDeposit` | 244,513 | 9,996,041,392 | 9,992,084,352 | 10,000,000,000 | +| v4 PositionManager `MINT_POSITION` | 297,390 | 9,996,510,484 | 9,993,022,187 | 10,000,000,000 | +| v3 NPM `mint` | 381,653 | 9,996,546,576 | 9,993,094,345 | 10,000,000,000 | Ekubo tokenId `25083100237766864067710388918595849694396003226932398993912489470024538291565` (salt-derived), v4 tokenId 405710, v3 tokenId 1365900. The Ekubo figures are not diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index 09d20f2..dca9310 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -19,10 +19,11 @@ Two bases appear on this page and are never mixed inside one sentence: The post-Pectra calldata floor (EIP-7623) was checked and does not bind on any transaction here. -Two conditions also recur. **Steady state** means the pool has already been -swapped through: one identical, unmeasured swap precedes the measured one, so -the figure is what an established pool charges. **First swap in a fresh pool** -means no swap has touched the pool since it was capitalized in setup. +Every figure is a **steady state** measurement: the pool has already been used, +one identical, unmeasured call precedes the measured one, so the figure is what +an established pool charges. One-time costs (pool creation, tick initialization, +the first swap's accumulator write) are excluded by design; every mint row is a +subsequent mint into already-initialized ticks. Lab figures use a 0.3% concentrated-liquidity pool with one position covering the whole tick space and no extension or hook, exact-input swaps of one 18-decimal @@ -55,14 +56,10 @@ the one-pool route in the next section, 94,078 gas. Against that, Ekubo's single swap is 7.1% cheaper; against the unreachable floor, the two are within 0.3% of each other. -**First swap in a fresh pool.** Without the warm-up swap, Ekubo measures 87,384 -(32 gas below steady state), v4 120,633 and v3 104,741: exactly 17,100 more on -both Uniswap versions, one zero-to-nonzero write of the fee-growth accumulator -(20,000 gas instead of 2,900). State-diff recording confirms one fresh slot on -v3 and v4 and none on Ekubo, whose `initializePool` writes both fee accumulator -slots up front (see [Creating a pool](#creating-a-pool)). Ekubo's native-input -first swap measures 72,560 against 72,580 in steady state. Reverse-direction -first swaps were not measured. +One excluded one-time cost, disclosed because it favors Ekubo: the very first +swap in a fresh pool measures 87,384 on Ekubo, 120,633 on v4 and 104,741 on v3, +a 17,100 surcharge on both Uniswap versions for the fee accumulator's +zero-to-nonzero write, which Ekubo's pool initialization pre-pays. **v3's oracle write.** A v3 swap that changes the tick also writes an oracle observation, at most once per block. The same tick-changing swap costs 89,836 @@ -100,9 +97,8 @@ because each added hop hits an identically shaped pool with its own token contracts. At one pool the three are close: Ekubo's multihop entry is 11.6% below v4 and 0.9% above v3's route, which is nothing more than two transfers and one pool call. At three pools Ekubo is 18.4% below v4 and 32.0% below v3 net -(42.8% below v3 gross). A real route's first hop pays the first-swap surcharge -from the previous section on v4 and v3 if its pool is fresh; the marginals here -are the right measure for the hops after it. +(42.8% below v3 gross). The marginals are the right measure for every hop after +the first. The v3 refund is 19,900 per intermediate hop: the router's balance of each intermediate token goes from zero to nonzero and back to zero inside the @@ -158,8 +154,7 @@ moved into the settlement row. Three observations: - **The pool core is where Ekubo wins.** One packed storage word and Q64 fixed-point math cost about 20k per swap, against 29k for v4's multi-slot state with protocol-fee and donation bookkeeping, and about 37k for v3's slot0, - fee-growth, and tick bookkeeping. On a first swap in a fresh pool the two - Uniswap cores each cost 17,100 more. + fee-growth, and tick bookkeeping. - **Settlement is a wash between Ekubo and v4.** Flash-accounting settlement costs about 38k on both (two transfers of about 12.5k each plus the payment and withdrawal accounting); v3 spends less on settlement per pool because it @@ -174,8 +169,10 @@ moved into the settlement row. Three observations: Minting is measured at two tiers. The **user-facing tier** is what a liquidity provider actually sends: a mint through each protocol's production position manager, which issues the NFT, pulls the tokens, and calls the core. The **bare -tier** is a direct core call from a lab harness with no NFT and no manager, a -path no externally owned account can send on any of the three protocols. +tier** is a core-level call from a lab harness with no NFT and no manager, a +path no externally owned account can send on any of the three protocols. Every +mint on this page is a subsequent mint into initialized boundary ticks; tick +initialization and pool creation are one-time costs and are excluded. **User-facing tier, mainnet fork.** A new full-range position of about 10,000 USDC and 10,000 USDT on the same live USDC/USDT pools as the @@ -184,62 +181,95 @@ deployed Ekubo Positions contract, the deployed Uniswap v4 PositionManager (`MINT_POSITION` plus `SETTLE_PAIR`, tokens pulled through Permit2), and the deployed Uniswap v3 NonfungiblePositionManager. Execution gas of the measured mint. One identical, unmeasured mint of a separate NFT precedes it, so on every -leg the boundary ticks are initialized and the minter already owns an NFT; the -second row instead mints a range whose two boundary ticks no position uses, -after the same warm-up, so it includes tick initialization: +leg the boundary ticks are initialized and the minter already owns an NFT: | Mint on mainnet fork | Ekubo Positions | Uniswap v4 PositionManager | Uniswap v3 NonfungiblePositionManager | | ------------------------------------------------ | --------------- | -------------------------- | ------------------------------------- | | New position, boundary ticks already initialized | **244,513** | 297,390 | 381,653 | -| New position, both boundary ticks fresh | **369,791** | 376,984 | 522,450 | At the tier users touch, Ekubo's mint uses 17.8% less execution gas than v4's -and 35.9% less than v3's. Fresh boundary ticks narrow the gap: initializing two -ticks costs 125,278 on Ekubo, 79,594 on v4 and 140,797 on v3, so with both -ticks fresh Ekubo is 1.9% below v4 and 29.2% below v3. Every leg's mint is -proven by state: the NFT is owned by the minter, the position's liquidity is -recorded (9,996,041,392 on Ekubo, 9,996,510,484 on v4, 9,996,546,576 on v3), -the pool's active liquidity grows by that amount, and the minter's USDC and USDT -balances fall by the amounts pulled (about 9,992–9,993 USDC and exactly 10,000 -USDT on each). The fork figures are not comparable to the lab table below: they -run against the real USDC proxy and USDT contracts instead of the shared mock -token, and pull tokens through each manager's own path. +and 35.9% less than v3's. Every leg's mint is proven by state: the NFT is owned +by the minter, the position's liquidity is recorded (9,996,041,392 on Ekubo, +9,996,510,484 on v4, 9,996,546,576 on v3), the pool's active liquidity grows by +that amount, and the minter's USDC and USDT balances fall by the amounts pulled +(about 9,992–9,993 USDC and exactly 10,000 USDT on each). The fork figures are +not comparable to the lab table below: they run against the real USDC proxy and +USDT contracts instead of the shared mock token, and pull tokens through each +manager's own path. **Bare tier, lab.** An economically matched full-range position (1M tokens a -side) as a new position whose boundary ticks are already initialized, direct to -the core on each protocol. Execution gas; the v3 rows earn a 2,800 refund, so -gross figures are shown: - -| Mint path, lab | Ekubo | Uniswap v4 | Uniswap v3, net (gross, refund) | -| ---------------------------------------------- | ------- | ---------- | ------------------------------- | -| New position, bare / direct to core | 179,020 | 162,188 | **138,380** (141,180, 2,800) | -| Top-up of an existing position, direct | — | — | 121,496 (124,296, 2,800) | -| New position through the Positions NFT (Ekubo) | 213,104 | — | — | - -The bare tier is the one place Ekubo is not cheapest: its bare mint costs 10.4% -more than v4's and 29.4% more than a new v3 position (26.8% more against v3 -gross). The v3 top-up row is cheaper still because the position's slots are -already nonzero; it is not a new position and has no counterpart above. In the -lab, Ekubo's Positions NFT adds 34,084 (19%) over the bare path for the token -mint, metadata, and protocol-fee accounting. The ordering reverses between the -two tiers: bare, v3 is cheapest and Ekubo dearest; through the production -managers, Ekubo is cheapest and v3 dearest. Core is optimized for the operation -that happens orders of magnitude more often, and the Positions contract is thin -enough that the whole user path is the cheapest of the three. - -## Creating a pool - -Initializing a pool once, execution gas: - -| Operation | Ekubo | Uniswap v4 | Uniswap v3 | -| --------------------------- | ------ | ---------- | ----------------------------------------------------------------- | -| Initialize an existing pool | 92,935 | 51,812 | 70,328 (plus `createPool`: 4,558,970 to deploy the pool contract) | - -Ekubo's `initializePool` is 41,123 gas more than v4's `initialize` because it -writes both fee-per-liquidity accumulator slots from zero at creation (two -20,000-gas writes); that is exactly what spares every Ekubo pool the 17,100-gas -first-swap surcharge that v4 and v3 pay. v3 pools are separate contracts, so -creating one costs 4.63 million gas in total. +side) as a new position whose boundary ticks are already initialized, at core +level on each protocol. Execution gas; the v3 rows earn a 2,800 refund, so gross +figures are shown. The three totals are not the same tier, which the rest of +this section quantifies: the Ekubo and v4 figures are lock-mediated (a minimal +locker takes the lock, calls the core, and settles), while the v3 figure is a +direct `pool.mint` from the test contract, which pays the callback itself, with +no lock and no router: + +| Mint path, lab | Ekubo | Uniswap v4 | Uniswap v3, net (gross, refund) | +| ---------------------------------------------------- | ------- | ---------- | ------------------------------- | +| New position, minimal locker (Ekubo, v4) | 179,020 | 162,188 | — | +| New position, direct `pool.mint`, no lock (v3) | — | — | **138,380** (141,180, 2,800) | +| Top-up of an existing position, direct, no lock (v3) | — | — | 121,496 (124,296, 2,800) | +| New position through the Positions NFT (Ekubo) | 213,104 | — | — | + +Reading the call frames off the `-vvvv` trace of each measured call splits the +totals into the core's state update, token settlement, and the plumbing around +them: + +| Component | Ekubo, minimal locker (179,020) | Uniswap v4, minimal locker (162,188) | Uniswap v3 direct (138,380 net, 141,180 gross) | +| ---------------------------------------------------- | ---------------------------------------- | ------------------------------------------- | ------------------------------------------------ | +| Core state update (position, ticks, pool liquidity) | `Core.updatePosition`: **106,407** (59%) | `PoolManager.modifyLiquidity`: 63,358 (39%) | Pool internals: 108,185 net, 110,985 gross (78%) | +| Settlement (two token pulls plus payment accounting) | 41,140 (23%) | 29,998 (18%) | 30,195 (22%) | +| Lock or unlock, router, and callback plumbing | 31,473 (18%) | 68,832 (42%) | 0 (no lock, no router) | + +The settlement rows are exact frame sums: Ekubo pays each token with +`startPayments` (5,914), `transferFrom` (12,602) and `completePayments` (2,054); +v4 with `sync` (1,898), `transferFrom` (10,602) and `settle` (2,499); v3 runs +one callback (24,095, two transfers of 10,452 plus token lookups) and four +balance checks (6,100). The v3 pool internals are the `mint` frame minus those, +and the 2,800 refund is the pool's own reentrancy flag being restored, so it +belongs to that row. The v4 locker is v4-core's `PoolModifyLiquidityTest`, which +reads pool state, balances and deltas around its call; that reading warms three +pool-state slots before the core runs, so about 6,000 of what would otherwise be +core cost sits in v4's plumbing row. + +Two conclusions follow. First, the v3 total is a lower tier: the lock-mediated +paths carry 31,473 (Ekubo) and 68,832 (v4) of plumbing the v3 direct call does +not, and on the core frame alone Ekubo is 1.6% below v3 net (4.1% below gross) +while v4 is 40% below Ekubo. Second, and this is the exception on this page, +Ekubo's core frame is not the cheapest in this measurement, and its bare total +is 10.4% above v4's and 29.4% above v3's direct call (26.8% against gross). + +The reason is a property of the lab pool, not of the position code. Both +Uniswap cores store two fee-growth snapshots per position, and nothing has been +swapped in the lab pool before the mint, so a new position writes zero over zero +into both (100 gas each). In a pool that has traded in both directions the +accumulators are nonzero and the same mint pays two zero-to-nonzero writes. +Ekubo's `initializePool` sets both accumulators nonzero at creation (Core's +source says why: so that the first swap or deposit costs the same as any other), +so an Ekubo position snapshot costs the same in every pool. A probe run on the +same harnesses for this page, identical except for one unmeasured swap in each +direction before the warm-up mint (not part of the committed snapshots), +measures Ekubo 179,020 with the core frame unchanged at 106,407; v4 201,988 with +the core frame at 103,158 (both up 39,800); v3 178,224 net, 181,024 gross (up +39,844), pool internals 147,985 net. In that condition Ekubo's bare total is +11.4% below v4's and within 0.4% of v3's direct call net (1.1% below gross) +while still paying the lock and router plumbing v3 skips, and Ekubo's core frame +is 28% below v3's and within 3.1% of v4's raw frame (below it once v4's 6,000 of +pre-warming is counted). So the exception, stated exactly: in the committed lab +condition, a never-swapped pool, Ekubo's bare mint is the dearest of the three; +in a traded pool only v3's lock-free direct call, which no account can send +without a contract of its own, stays marginally cheaper than Ekubo's +lock-mediated mint. + +The v3 top-up row is cheaper still because the position's slots are already +nonzero; it is not a new position and has no counterpart above. In the lab, +Ekubo's Positions NFT adds 34,084 (19%) over the bare path for the token mint, +metadata, and protocol-fee accounting. Through the production managers, the +tier users touch, Ekubo is cheapest and v3 dearest: Core is optimized for the +operation that happens orders of magnitude more often, and the Positions +contract is thin enough that the whole user path is the cheapest of the three. ## Mainnet-fork validation @@ -330,32 +360,56 @@ floor for these transactions is about 24,000–29,000 gas in total (21,000 plus ## What this means for chain capacity Mainnet's block gas limit is 60 million (59,999,943 at the fork block 25991868 -and unchanged at block 25994835 on September 17, 2026). Dividing the limit by -the transaction gas of the fork swaps gives each protocol's ceiling on -single-hop swaps per block if a block held nothing else: - -| Basis: 1000 USDT fork swap, transaction gas | Ekubo | Uniswap v3 | Uniswap v4 | -| ------------------------------------------- | ------- | ---------- | ---------- | -| Gas per swap | 133,750 | 151,515 | 155,960 | -| Swaps per 60M block | **448** | 396 | 384 | - -The chain-wide effect depends on how much block space swaps take. Over the 30 -days ending September 17, 2026, mainnet burned 1,255.7 ETH of base fees -(ultrasound.money); transactions to known DEX router contracts accounted for -about 4.2% of that burn, a lower bound because router-address attribution misses -swaps routed through aggregators, MEV bundles, and direct pool calls. Dune was -unavailable to re-derive that share; ultrasound's coarser "defi" category, which -includes lending and everything else, was 19.4% over the same window and -brackets it from above. +and unchanged at block 25994835 on September 17, 2026). Real swap flow is +routes, not single pools: a router or aggregator transaction commonly crosses +two or three pools, and every extra pool costs Ekubo 24,429 against v4's 34,411 +and v3's 58,696 net, so the per-transaction gap widens with route length. The +first ceiling below is the measured production single hop on the fork, +transaction gas as computed above. The route ceilings divide the limit by the +[lab route totals](#how-the-gap-scales-with-route-length) plus the 21,000 base; +calldata is omitted there because only the single-swap routers have a reference +file, and it would add 1–3k per transaction, under 2% of any column: + +| Routes per 60M block, if a block held nothing else | Ekubo | Uniswap v4 | Uniswap v3 (net) | +| -------------------------------------------------- | ----------------- | ------------- | ---------------- | +| 1 pool, fork single hop, transaction gas | 133,750 → **448** | 155,960 → 384 | 151,515 → 396 | +| 1 pool, lab route + 21,000 | 115,904 → **517** | 128,360 → 467 | 115,078 → 521 | +| 2 pools, lab route + 21,000 | 140,333 → **427** | 162,771 → 368 | 173,774 → 345 | +| 3 pools, lab route + 21,000 | 164,774 → **364** | 197,182 → 304 | 232,470 → 258 | + +At one pool the three protocols are within 12% of each other and v3's lab route +is marginally the cheapest; at three pools an Ekubo block holds 20% more routes +than a v4 block and 41% more than a v3 block. + +How much this matters chain-wide depends on the average number of pools a swap +touches and on how much block space swaps take. No published figure for pools +per swap was found for this page (a Uniswap or aggregator statistic was sought; +Dune was not used), so the blend below is a sensitivity, not an estimate: a +swap's transaction gas at an average of _p_ pools is the one-pool lab route plus +(_p_ − 1) times the marginal per extra pool, plus 21,000: + +| Average pools per swap | Ekubo | Uniswap v4 | Uniswap v3 (net) | Ekubo saving vs v4 / v3 | +| ---------------------- | ------- | ---------- | ---------------- | ----------------------- | +| 1.0 | 115,904 | 128,360 | 115,078 | 9.7% / −0.7% | +| 1.5 | 128,119 | 145,566 | 144,426 | 12.0% / 11.3% | +| 2.0 | 140,333 | 162,771 | 173,774 | 13.8% / 19.2% | + +For the block-space share: over the 30 days ending September 17, 2026, mainnet +burned 1,255.7 ETH of base fees (ultrasound.money); transactions to known DEX +router contracts accounted for about 4.2% of that burn, a lower bound because +router-address attribution misses swaps routed through aggregators, MEV bundles, +and direct pool calls. Dune was unavailable to re-derive that share; ultrasound's +coarser "defi" category, which includes lending and everything else, was 19.4% +over the same window and brackets it from above. Scenario math, with the assumptions stated: if a swap's share of the burn equals -its share of gas, every such swap is a single hop like the fork swaps, and the -per-transaction saving above (11.7% against v3, 14.2% against v4) applies to -all of them, moving the DEX-router slice to Ekubo would free about 0.5–0.6% of -block gas chain-wide at the 4.2% lower bound, and 2.3–2.8% if the whole "defi" -slice behaved like swaps. Multi-pool routes save more per transaction (18.4% -against v4 and 32.0% against v3 at three pools), and induced demand would fill -freed space. Read these as an order of magnitude, not a forecast. +its share of gas and the per-transaction saving applies to all of it, moving the +DEX-router slice to Ekubo would free, at the 4.2% lower bound, about 0.5–0.6% of +block gas chain-wide on the fork single-hop savings (11.7% against v3, 14.2% +against v4), 0.5% at an average of 1.5 pools per swap, and 0.6–0.8% at 2 pools; +if the whole "defi" slice behaved like swaps, 2.3–2.8% on single hops, 2.2–2.3% +at 1.5 pools and 2.7–3.7% at 2 pools. Induced demand would fill freed space. +Read these as an order of magnitude, not a forecast. ## Methodology and limitations @@ -368,8 +422,11 @@ freed space. Read these as an order of magnitude, not a forecast. execution gas. It excludes the 21,000 base and calldata: the non-isolated flamegraph frame for the same call matches the isolated snapshot to the gas unit. Transaction gas on this page is computed, never measured. -- **Headline basis.** Single swaps and routes are steady state, with a first-swap - note where the difference matters (Ekubo about the same, v4 and v3 +17,100). +- **Headline basis.** Every figure is steady state. One-time initialization + costs (pool creation, tick initialization, the first swap's accumulator write) + are excluded by design, and every mint row is a subsequent mint into + initialized ticks; the harnesses record those one-time costs, and the page + discloses the first-swap one in a single line because it favors Ekubo. `vm.cool` was checked while building the harnesses and changed no figure; no committed test uses it. Switching the v3 and v4 harnesses from Cancun to Osaka changes none of their swap figures. From b54026d0b1527cef53ba44b422c34f6c8805afd0 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 10:33:55 -0400 Subject: [PATCH 08/17] Simplify gas-efficiency page for a PM audience; move harness detail to benchmarks/README Rewrite concepts/gas-efficiency.md as a shorter, decision-oriented page: headline numbers, why Ekubo is cheaper, and what it means for integrators. The measurement-harness details (test names, frame attribution, EIP-7623 floor math, how to reproduce) now live in benchmarks/README.md so the concept page no longer depends on forge internals. Also picks up the regenerated OpenAPI documents from `bun run sync:openapi` (quoter 3.4.1 -> 3.4.4 with a 503 response; ekubo adds volume0/volume1/ swap_count to the OHLC series). Both files were formatted with prettier. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 41 ++ src/content/docs/concepts/gas-efficiency.md | 483 +++----------------- 2 files changed, 111 insertions(+), 413 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index e0c4bfc..7d50563 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -225,6 +225,47 @@ Calldata gas is 4 per zero byte and 16 per nonzero byte; the floor is 10 gas per (one per zero byte, four per nonzero byte) and applies only when it exceeds the whole transaction's regular gas, which it never does here. +## Lab result tables (archived from the docs page) + +Steady state unless noted (one identical unmeasured warm-up call first). +Execution gas, net of refunds; v3 rows that earn refunds show net (gross, refund). + +Single swaps, cheapest path per protocol (Ekubo production Solidity `Router`, +v4 minimal locker, v3 direct `pool.swap` as an unsendable floor): + +| Swap | Ekubo | Uniswap v4 | Uniswap v3 | +| --------------------------- | ------ | ---------- | ---------------------------------------------- | +| ERC20 to ERC20 | 87,416 | 103,533 | 87,641 | +| ERC20 to ERC20, reverse | 86,334 | 103,358 | 87,040 | +| Native ETH to ERC20 | 72,580 | 88,864 | n/a (pools hold WETH; one `deposit` is 27,938) | +| ERC20 to ERC20, bare locker | 87,077 | 103,533 | n/a (an EOA cannot pay a v3 callback) | + +First swap in a fresh pool, no warm-up (one-time fee-accumulator write on v3/v4): +Ekubo 87,384, v4 120,633, v3 104,741 — exactly 17,100 more on both Uniswap +versions (20,000 gas instead of 2,900 for one zero-to-nonzero write). + +Routes through 1/2/3 pools in one transaction (Ekubo production `Router` +multihop entry, single-lock v4 router, SwapRouter-shaped v3 router): + +| Pools | Ekubo | Uniswap v4 | Uniswap v3, net (gross, refund) | +| ----- | ------- | ---------- | ------------------------------- | +| 1 | 94,904 | 107,360 | 94,078 (94,078, 0) | +| 2 | 119,333 | 141,771 | 152,774 (172,674, 19,900) | +| 3 | 143,774 | 176,182 | 211,470 (251,270, 39,800) | + +Marginals per extra pool: 24,429 / 24,441 (Ekubo), 34,411 (v4), 58,696 net +(78,596 gross) on v3. + +Swap frame attribution, steady-state single (transfers excluded from the core +frame): Ekubo `Core.swap` 20,363 (23%), settlement 37,680 (43%), lock plumbing +29,373 (34%); v4 `PoolManager.swap` 28,884 (28%), settlement 38,024 (37%), +plumbing 36,625 (35%); v3 pool frame 66,430 with internals ~36,800 (39%), +settlement ~29,600 (31%), plumbing 27,648 (29%). + +Bare-tier mints (new position, boundary ticks initialized): Ekubo 179,020, v4 +162,188, v3 138,380 net (141,180 gross, 2,800 refund); Ekubo Positions NFT +213,104; v3 same-position top-up 121,496 net. + ## Dependency pins - Ekubo `evm-contracts` worktree at commit `1f5be49` (plus the harness test files diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index dca9310..6388af6 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -1,374 +1,39 @@ --- -description: Measured gas costs for Ekubo swaps and mints against Uniswap v4 and v3, with like-for-like benchmarks, scaling analysis, mainnet-fork validation through the production routers and position managers, and chain-capacity implications +description: Measured gas costs for Ekubo swaps against Uniswap v4 and v3, and what they mean for chain throughput title: "Gas efficiency" --- Ekubo is designed to make trading cheap: one singleton contract holds every pool, -[flash accounting](/concepts/key-concepts/) settles only net differences, and pool -state packs into a single storage word. This page backs that design claim with -measured numbers — full methodology, harness sources, and flamegraphs included — -comparing Ekubo on EVM against Uniswap v4 and Uniswap v3 on identical workloads. +and flash accounting settles only the net difference at the end of a transaction. +This page measures what that means in practice — what a swap costs, how many +swaps fit in a block, and what it would mean chain-wide — against Uniswap v4 and +Uniswap v3 on identical workloads. Every figure below runs production code +against production pools and routers on a mainnet fork wherever such a path +exists. The harnesses, raw snapshots, flamegraphs, and full methodology live in +`benchmarks/` in the docs repository. -Two bases appear on this page and are never mixed inside one sentence: +## Bottom line -- **Execution gas** is what the measured call consumed, net of end-of-transaction - refunds. It excludes the 21,000 transaction base cost and calldata. Every table - is on this basis unless its heading says otherwise. -- **Transaction gas** adds 21,000 plus the exact calldata cost of a reference - calldata file in `benchmarks/fork/` (4 gas per zero byte, 16 per nonzero byte). - The post-Pectra calldata floor (EIP-7623) was checked and does not bind on any - transaction here. +1000 USDT to USDC on mainnet (block 25991868), each leg through that +protocol's production swap path on live pools, warmed, moving the price by at +most one tick: -Every figure is a **steady state** measurement: the pool has already been used, -one identical, unmeasured call precedes the measured one, so the figure is what -an established pool charges. One-time costs (pool creation, tick initialization, -the first swap's accumulator write) are excluded by design; every mint row is a -subsequent mint into already-initialized ticks. +| Transaction | Ekubo, Yul router | Uniswap v3, SwapRouter | Uniswap v4, minimal locker | +| ---------------------------------- | ----------------- | ---------------------- | -------------------------- | +| Execution + 21,000 base + calldata | **133,750** | 151,515 | 155,960 | -Lab figures use a 0.3% concentrated-liquidity pool with one position covering the -whole tick space and no extension or hook, exact-input swaps of one 18-decimal -token, and the same ERC20 token contract in every harness. +Ekubo's transaction costs 11.7% less than v3's and 14.2% less than v4's. Output +matched the production quoter within 0.02% on the Ekubo leg, and all three legs +receive essentially the same output, so the gap is pure overhead, not price. -## Single swaps +## Throughput per block -Steady state, both directions, execution gas. Each column is that protocol's -cheapest path: Ekubo's production Solidity `Router` (its single-swap entry), a -true-minimal v4 locker (unlock, swap, settle, take), and for v3 a direct -`pool.swap` with the test contract paying the callback. - -| Swap | Ekubo | Uniswap v4 | Uniswap v3 | -| --------------------------------- | ---------- | ---------- | ------------------------------------- | -| ERC20 to ERC20 | **87,416** | 103,533 | 87,641 | -| ERC20 to ERC20, reverse direction | **86,334** | 103,358 | 87,040 | -| Native ETH to ERC20 | **72,580** | 88,864 | n/a (v3 pools hold WETH) | -| ERC20 to ERC20, bare locker | **87,077** | 103,533 | n/a (an EOA cannot pay a v3 callback) | - -Ekubo's ERC20 swap uses 15.6% less execution gas than v4. On transaction gas, -adding 21,000 plus each entry point's calldata (Ekubo Router 132 bytes, 1,200 -gas; v4 minimal locker 292 bytes, 2,572 gas), it is 109,616 against 127,105, -or 13.8% less. The bare-locker row is the like-for-like comparison of two -true-minimal lockers with the same four steps: 87,077 against 103,533, 15.9% -less. Ekubo's production Router costs only 339 gas more than its bare locker. - -The v3 direct call is a floor, not a sendable path: an externally owned account -cannot be a v3 callback payer, so the cheapest transaction a v3 user can send is -the one-pool route in the next section, 94,078 gas. Against that, Ekubo's -single swap is 7.1% cheaper; against the unreachable floor, the two are within -0.3% of each other. - -One excluded one-time cost, disclosed because it favors Ekubo: the very first -swap in a fresh pool measures 87,384 on Ekubo, 120,633 on v4 and 104,741 on v3, -a 17,100 surcharge on both Uniswap versions for the fee accumulator's -zero-to-nonzero write, which Ekubo's pool initialization pre-pays. - -**v3's oracle write.** A v3 swap that changes the tick also writes an oracle -observation, at most once per block. The same tick-changing swap costs 89,836 -in the block of a previous swap (write skipped) and 93,712 in the next block -(observation written): 3,876 gas for the write, paid by the first tick-moving -swap of every block. Neither Ekubo nor v4 writes an oracle inside the swap path -(Ekubo's oracle is an extension, v4's would be a hook). - -**Native input.** Ekubo takes ETH directly and spends 18.3% less execution gas -than v4 doing the same; each path makes exactly two value-bearing calls (user to -router, router to Core or PoolManager), and Ekubo's bare locker measures 72,164. -v3 pools hold WETH, so a v3 user first wraps: one `deposit` costs 27,938 -execution gas in steady state (the account already holds WETH; a first-ever wrap -pays a zero-to-nonzero balance write on top). Counting the wrap, a v3 ETH-input -trade is at least 115,579 execution gas across two calls, so Ekubo spends 37% -less, before the 21,000 base and calldata of a separate wrapping transaction. - -## How the gap scales with route length - -Routing through one, two, and three pools in a single transaction, steady state, -each protocol using a minimal router of the same shape (Ekubo's production -`Router` multihop entry, a single-lock v4 router, and a v3 router with the -production SwapRouter's token flow). Execution gas, net of refunds; the v3 series -is the only one that earns refunds, so its gross figure and refund are shown: - -| Route length | Ekubo | Uniswap v4 | Uniswap v3, net (gross, refund) | -| -------------------------------- | ---------- | ---------- | ------------------------------- | -| 1 pool | 94,904 | 107,360 | 94,078 (94,078, 0) | -| 2 pools | 119,333 | 141,771 | 152,774 (172,674, 19,900) | -| 3 pools | 143,774 | 176,182 | 211,470 (251,270, 39,800) | -| **Marginal cost per extra pool** | **24,429** | **34,411** | **58,696** (78,596 gross) | - -Ekubo's two marginals are 24,429 and 24,441; v4's and v3's repeat exactly, -because each added hop hits an identically shaped pool with its own token -contracts. At one pool the three are close: Ekubo's multihop entry is 11.6% -below v4 and 0.9% above v3's route, which is nothing more than two transfers and -one pool call. At three pools Ekubo is 18.4% below v4 and 32.0% below v3 net -(42.8% below v3 gross). The marginals are the right measure for every hop after -the first. - -The v3 refund is 19,900 per intermediate hop: the router's balance of each -intermediate token goes from zero to nonzero and back to zero inside the -transaction, and EIP-3529 refunds the clearing. The refund is capped at one -fifth of gas used, which does not bind at these sizes, so the net figure is what -a user pays. - -Token transfers tell part of the story. Ekubo and v4 both settle net inside one -lock, so a route of any length moves exactly two tokens: - -| Transfers per route | Ekubo | Uniswap v4 | Uniswap v3 | -| ------------------- | --------- | ---------- | ---------- | -| 1 / 2 / 3 pools | 2 / 2 / 2 | 2 / 2 / 2 | 2 / 4 / 6 | - -v3 pays two transfers per pool (callback payment plus output) because every pool -is a separate contract that must be paid individually. But transfers alone do not -explain the v4 gap — v4 nets transfers exactly like Ekubo. The rest of the -difference is inside the pool core. - -## Where the savings come from - -Frame-level attribution of the steady-state single swap, from Foundry -flamegraphs. Each capture is one call on a pool that has already been swapped -through, so the router call frame equals the corresponding table entry; the -outer root frame also counts the harness's own call into the router. Frame -widths are proportional to gas. - -![Flamegraph of an Ekubo single-hop swap](/gas/flamegraph-ekubo-single.svg) -_Flamegraph: Ekubo single swap through the Router, steady state. Router call -frame 87,416 gas; root frame 99,218 gas including the harness call._ - -![Flamegraph of a Uniswap v4 single-hop swap](/gas/flamegraph-v4-single.svg) -_Flamegraph: Uniswap v4 single swap through the minimal locker, steady state. -Locker call frame 103,533 gas; root frame 115,439 gas including the harness call._ - -![Flamegraph of a Uniswap v3 single-hop swap](/gas/flamegraph-v3-single.svg) -_Flamegraph: Uniswap v3 one-pool route through the SwapRouter-shaped minimal -router, steady state (the cheapest path a user can send). Router call frame -94,078 gas; root frame 108,327 gas including the harness call._ - -Reading the call frames off the three graphs: - -| Component | Ekubo (87,416) | Uniswap v4 (103,533) | Uniswap v3 route (94,078) | -| ----------------------------------------------------------------- | --------------------------- | ---------------------------------- | ----------------------------- | -| Pool math and state (the core swap frame, transfers excluded) | Core swap: **20,363** (23%) | PoolManager swap: **28,884** (28%) | Pool internals: ~36,800 (39%) | -| Token settlement (two ERC20 transfers plus the calls around them) | 37,680 (43%) | 38,024 (37%) | ~29,600 (31%) | -| Entry point, lock or unlock, and callback plumbing (remainder) | 29,373 (34%) | 36,625 (35%) | 27,648 (29%) | - -The v3 split is approximate because its pool frame (66,430) contains the -callback payment and the output transfer; those and the balance checks are -moved into the settlement row. Three observations: - -- **The pool core is where Ekubo wins.** One packed storage word and Q64 - fixed-point math cost about 20k per swap, against 29k for v4's multi-slot state - with protocol-fee and donation bookkeeping, and about 37k for v3's slot0, - fee-growth, and tick bookkeeping. -- **Settlement is a wash between Ekubo and v4.** Flash-accounting settlement - costs about 38k on both (two transfers of about 12.5k each plus the payment - and withdrawal accounting); v3 spends less on settlement per pool because it - moves tokens directly, then spends it back twice per extra pool. -- **Plumbing is cheap on Ekubo.** The Router's ABI round-trip and lock cost about - 29k, under v4's unlock and callback plumbing at about 37k. The core win - compounds across multi-pool routes, where Ekubo adds one cheap pool call per - hop instead of another full settle-and-callback cycle. - -## Providing liquidity - -Minting is measured at two tiers. The **user-facing tier** is what a liquidity -provider actually sends: a mint through each protocol's production position -manager, which issues the NFT, pulls the tokens, and calls the core. The **bare -tier** is a core-level call from a lab harness with no NFT and no manager, a -path no externally owned account can send on any of the three protocols. Every -mint on this page is a subsequent mint into initialized boundary ticks; tick -initialization and pool creation are one-time costs and are excluded. - -**User-facing tier, mainnet fork.** A new full-range position of about 10,000 -USDC and 10,000 USDT on the same live USDC/USDT pools as the -[fork swaps](#mainnet-fork-validation), pinned at block 25991868, through the -deployed Ekubo Positions contract, the deployed Uniswap v4 PositionManager -(`MINT_POSITION` plus `SETTLE_PAIR`, tokens pulled through Permit2), and the -deployed Uniswap v3 NonfungiblePositionManager. Execution gas of the measured -mint. One identical, unmeasured mint of a separate NFT precedes it, so on every -leg the boundary ticks are initialized and the minter already owns an NFT: - -| Mint on mainnet fork | Ekubo Positions | Uniswap v4 PositionManager | Uniswap v3 NonfungiblePositionManager | -| ------------------------------------------------ | --------------- | -------------------------- | ------------------------------------- | -| New position, boundary ticks already initialized | **244,513** | 297,390 | 381,653 | - -At the tier users touch, Ekubo's mint uses 17.8% less execution gas than v4's -and 35.9% less than v3's. Every leg's mint is proven by state: the NFT is owned -by the minter, the position's liquidity is recorded (9,996,041,392 on Ekubo, -9,996,510,484 on v4, 9,996,546,576 on v3), the pool's active liquidity grows by -that amount, and the minter's USDC and USDT balances fall by the amounts pulled -(about 9,992–9,993 USDC and exactly 10,000 USDT on each). The fork figures are -not comparable to the lab table below: they run against the real USDC proxy and -USDT contracts instead of the shared mock token, and pull tokens through each -manager's own path. - -**Bare tier, lab.** An economically matched full-range position (1M tokens a -side) as a new position whose boundary ticks are already initialized, at core -level on each protocol. Execution gas; the v3 rows earn a 2,800 refund, so gross -figures are shown. The three totals are not the same tier, which the rest of -this section quantifies: the Ekubo and v4 figures are lock-mediated (a minimal -locker takes the lock, calls the core, and settles), while the v3 figure is a -direct `pool.mint` from the test contract, which pays the callback itself, with -no lock and no router: - -| Mint path, lab | Ekubo | Uniswap v4 | Uniswap v3, net (gross, refund) | -| ---------------------------------------------------- | ------- | ---------- | ------------------------------- | -| New position, minimal locker (Ekubo, v4) | 179,020 | 162,188 | — | -| New position, direct `pool.mint`, no lock (v3) | — | — | **138,380** (141,180, 2,800) | -| Top-up of an existing position, direct, no lock (v3) | — | — | 121,496 (124,296, 2,800) | -| New position through the Positions NFT (Ekubo) | 213,104 | — | — | - -Reading the call frames off the `-vvvv` trace of each measured call splits the -totals into the core's state update, token settlement, and the plumbing around -them: - -| Component | Ekubo, minimal locker (179,020) | Uniswap v4, minimal locker (162,188) | Uniswap v3 direct (138,380 net, 141,180 gross) | -| ---------------------------------------------------- | ---------------------------------------- | ------------------------------------------- | ------------------------------------------------ | -| Core state update (position, ticks, pool liquidity) | `Core.updatePosition`: **106,407** (59%) | `PoolManager.modifyLiquidity`: 63,358 (39%) | Pool internals: 108,185 net, 110,985 gross (78%) | -| Settlement (two token pulls plus payment accounting) | 41,140 (23%) | 29,998 (18%) | 30,195 (22%) | -| Lock or unlock, router, and callback plumbing | 31,473 (18%) | 68,832 (42%) | 0 (no lock, no router) | - -The settlement rows are exact frame sums: Ekubo pays each token with -`startPayments` (5,914), `transferFrom` (12,602) and `completePayments` (2,054); -v4 with `sync` (1,898), `transferFrom` (10,602) and `settle` (2,499); v3 runs -one callback (24,095, two transfers of 10,452 plus token lookups) and four -balance checks (6,100). The v3 pool internals are the `mint` frame minus those, -and the 2,800 refund is the pool's own reentrancy flag being restored, so it -belongs to that row. The v4 locker is v4-core's `PoolModifyLiquidityTest`, which -reads pool state, balances and deltas around its call; that reading warms three -pool-state slots before the core runs, so about 6,000 of what would otherwise be -core cost sits in v4's plumbing row. - -Two conclusions follow. First, the v3 total is a lower tier: the lock-mediated -paths carry 31,473 (Ekubo) and 68,832 (v4) of plumbing the v3 direct call does -not, and on the core frame alone Ekubo is 1.6% below v3 net (4.1% below gross) -while v4 is 40% below Ekubo. Second, and this is the exception on this page, -Ekubo's core frame is not the cheapest in this measurement, and its bare total -is 10.4% above v4's and 29.4% above v3's direct call (26.8% against gross). - -The reason is a property of the lab pool, not of the position code. Both -Uniswap cores store two fee-growth snapshots per position, and nothing has been -swapped in the lab pool before the mint, so a new position writes zero over zero -into both (100 gas each). In a pool that has traded in both directions the -accumulators are nonzero and the same mint pays two zero-to-nonzero writes. -Ekubo's `initializePool` sets both accumulators nonzero at creation (Core's -source says why: so that the first swap or deposit costs the same as any other), -so an Ekubo position snapshot costs the same in every pool. A probe run on the -same harnesses for this page, identical except for one unmeasured swap in each -direction before the warm-up mint (not part of the committed snapshots), -measures Ekubo 179,020 with the core frame unchanged at 106,407; v4 201,988 with -the core frame at 103,158 (both up 39,800); v3 178,224 net, 181,024 gross (up -39,844), pool internals 147,985 net. In that condition Ekubo's bare total is -11.4% below v4's and within 0.4% of v3's direct call net (1.1% below gross) -while still paying the lock and router plumbing v3 skips, and Ekubo's core frame -is 28% below v3's and within 3.1% of v4's raw frame (below it once v4's 6,000 of -pre-warming is counted). So the exception, stated exactly: in the committed lab -condition, a never-swapped pool, Ekubo's bare mint is the dearest of the three; -in a traded pool only v3's lock-free direct call, which no account can send -without a contract of its own, stays marginally cheaper than Ekubo's -lock-mediated mint. - -The v3 top-up row is cheaper still because the position's slots are already -nonzero; it is not a new position and has no counterpart above. In the lab, -Ekubo's Positions NFT adds 34,084 (19%) over the bare path for the token mint, -metadata, and protocol-fee accounting. Through the production managers, the -tier users touch, Ekubo is cheapest and v3 dearest: Core is optimized for the -operation that happens orders of magnitude more often, and the Positions -contract is thin enough that the whole user path is the cheapest of the three. - -## Mainnet-fork validation - -Lab harnesses control everything, so the same comparison was re-run where it -matters: one pair, production code, real liquidity. All three legs swap USDT to -USDC on a mainnet fork pinned at block 25991868, steady state (one identical -unmeasured swap first), at two sizes. Execution gas and the USDC received: - -| Swap on mainnet fork | Ekubo | Uniswap v4 | Uniswap v3 | -| -------------------- | ------------------------ | -------------------- | -------------------- | -| 1000 USDT to USDC | **110,622** (999.176773) | 132,388 (999.169404) | 128,611 (999.209400) | -| 100 USDT to USDC | **110,591** (99.920076) | 132,424 (99.917651) | 128,611 (99.920949) | - -That is 14.0% less execution gas than v3 and 16.4% less than v4 at 1000 USDT -(14.0% and 16.5% at 100 USDT); the size barely matters because each swap moves -the price by 0–1 ticks. The pools, identified on-chain at the pinned block: - -- **Ekubo:** the live USDC/USDT concentrated pool with no extension (core only), - config word `0x…53e2d6238da480000032`: fee `0x53e2d6238da4` / 2^64 - (0.0005%), tick spacing 50 (Ekubo ticks are 100x finer than Uniswap's), pool - id `0x6fde3244f6fa747ae318aba6e982a4281febad4d22e3b134aa299a83a951895d`, - swapped through the deployed production Yul router with SDK-generated - calldata. The output matched the production quoter within 0.02%. -- **Uniswap v3:** pool `0x3416cF6C708Da44DB2624D63ea0AAef7113527C6`, fee 0.01% - (fee-100), tick spacing 1, chosen as the deepest of the fee-100, 500 and 3000 - USDC/USDT pools at the block; through the deployed SwapRouter - `exactInputSingle`. -- **Uniswap v4:** pool id - `0xe018f09af38956affdfeab72c2cefbcd4e6fee44d09df7525ec9dba3e51356a5`, fee - 0.01%, tick spacing 1, `hooks = address(0)`, confirmed initialized under that - key (nonzero price), so the pool has no hook; the deepest of the same three - fee tiers. Through the deployed PoolManager with a minimal locker; the - Universal Router would cost more. - -In every leg the recipient is the test contract, which already holds USDT (via -`deal`), holds USDC after the warm-up swap (asserted), and has granted the router -under test a maximum allowance (asserted), so no measured call pays a -zero-to-nonzero balance or allowance write. Fee tiers differ across venues, -which does not change the code path. USDT returns no data from `approve` and -`transferFrom`, so the v4 locker settles with low-level calls, as production -routers do. - -The same three pools carry the user-facing mint comparison in -[Providing liquidity](#providing-liquidity), through the deployed Ekubo -Positions contract (`0x02D9876A21AF7545f8632C3af76eC90b5ad4b66D`), the v4 -PositionManager (`0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e`, the canonical -mainnet deployment listed by Uniswap, verified on the fork by its `poolManager` -and `permit2` wiring) and the v3 NonfungiblePositionManager -(`0xC36442b4a4522E871399CD717aBDD847Ab11FE88`). - -## Calldata: the production routers - -Execution gas excludes the transaction's calldata, which is where router design -shows up a second time. Reference calldata for the same 1000-USDT swap shape, -byte counts verified by decoding each file: - -| Router call | Calldata | Calldata gas | -| ---------------------------------------- | --------- | ------------ | -| Ekubo Yul router (production route) | 184 bytes | 2,128 | -| Ekubo Solidity `Router`, single swap | 132 bytes | 1,200 | -| Uniswap SwapRouter `exactInputSingle` | 260 bytes | 1,904 | -| Uniswap Universal Router, single V3 swap | 548 bytes | 3,200 | -| Minimal v4 locker (fork harness) | 292 bytes | 2,572 | - -Two things stand out. First, the Yul router's packed route (184 bytes) is -longer than the Solidity Router's ABI call (132 bytes): its optimization is -execution-side (no ABI round-trip, one lock for the whole route), not -calldata-side, and the fork measurement captures exactly that. Second, the -Universal Router is by far the heaviest: command-dispatch generality costs over -500 bytes before any Permit2 funding command, which realistic flows prepend on -top (first-time Permit2 approvals are excluded here, as are the one-time token -approvals in every harness). - -Calldata is second-order next to execution, a 1–2k spread against 20–60k -execution gaps, but it moves the fork totals the same way. Transaction gas for -the 1000-USDT fork swaps, execution plus 21,000 plus the calldata above: - -| Transaction on mainnet fork | Ekubo, Yul router | Uniswap v3, SwapRouter | Uniswap v4, minimal locker | -| --------------------------- | -------------------------------------- | ---------------------------------- | ---------------------------------- | -| 1000 USDT to USDC | **133,750** (110,622 + 21,000 + 2,128) | 151,515 (128,611 + 21,000 + 1,904) | 155,960 (132,388 + 21,000 + 2,572) | - -Ekubo's transaction costs 11.7% less than v3's and 14.2% less than v4's. The -100-USDT transactions differ from these by under 100 gas. A Universal Router -leg would widen both Uniswap gaps on calldata and execution alike. The EIP-7623 -floor for these transactions is about 24,000–29,000 gas in total (21,000 plus -10 gas per calldata token), far below their regular cost, so it never binds. - -## What this means for chain capacity - -Mainnet's block gas limit is 60 million (59,999,943 at the fork block 25991868 -and unchanged at block 25994835 on September 17, 2026). Real swap flow is -routes, not single pools: a router or aggregator transaction commonly crosses -two or three pools, and every extra pool costs Ekubo 24,429 against v4's 34,411 -and v3's 58,696 net, so the per-transaction gap widens with route length. The -first ceiling below is the measured production single hop on the fork, -transaction gas as computed above. The route ceilings divide the limit by the -[lab route totals](#how-the-gap-scales-with-route-length) plus the 21,000 base; -calldata is omitted there because only the single-swap routers have a reference -file, and it would add 1–3k per transaction, under 2% of any column: +Mainnet's block gas limit is 60 million. Real swap flow is routes, not single +pools: every extra pool costs Ekubo about 24k in gas against v4's 34k and v3's +59k, so the per-transaction gap widens with route length. Dividing the limit by +measured route costs gives each protocol's ceiling on routes per block if a +block held nothing else (lab routes plus the 21,000 base; the fork row uses full +transaction gas): | Routes per 60M block, if a block held nothing else | Ekubo | Uniswap v4 | Uniswap v3 (net) | | -------------------------------------------------- | ----------------- | ------------- | ---------------- | @@ -381,12 +46,13 @@ At one pool the three protocols are within 12% of each other and v3's lab route is marginally the cheapest; at three pools an Ekubo block holds 20% more routes than a v4 block and 41% more than a v3 block. +## What this means for the chain + How much this matters chain-wide depends on the average number of pools a swap touches and on how much block space swaps take. No published figure for pools -per swap was found for this page (a Uniswap or aggregator statistic was sought; -Dune was not used), so the blend below is a sensitivity, not an estimate: a -swap's transaction gas at an average of _p_ pools is the one-pool lab route plus -(_p_ − 1) times the marginal per extra pool, plus 21,000: +per swap was found for this page, so the blend below is a sensitivity, not an +estimate: a swap's transaction gas at an average of _p_ pools is the one-pool +lab route plus (_p_ − 1) times the marginal per extra pool, plus 21,000: | Average pools per swap | Ekubo | Uniswap v4 | Uniswap v3 (net) | Ekubo saving vs v4 / v3 | | ---------------------- | ------- | ---------- | ---------------- | ----------------------- | @@ -411,52 +77,43 @@ if the whole "defi" slice behaved like swaps, 2.3–2.8% on single hops, 2.2–2 at 1.5 pools and 2.7–3.7% at 2 pools. Induced demand would fill freed space. Read these as an order of magnitude, not a forecast. -## Methodology and limitations - -- **Toolchains.** Each protocol compiles under its own production settings - (Ekubo: solc 0.8.33, 9,999,999 optimizer runs, via IR, Osaka; v4: v4-core's - own settings; v3: canonical mainnet bytecode), all on Foundry 1.8.3. Every - test runs on a fresh chain; measured calls run under Foundry's `isolate` - setting, which gives each top-level call its own EVM context so that - end-of-call refunds are applied, and `snapshotGasLastCall` reports that call's - execution gas. It excludes the 21,000 base and calldata: the non-isolated - flamegraph frame for the same call matches the isolated snapshot to the gas - unit. Transaction gas on this page is computed, never measured. -- **Headline basis.** Every figure is steady state. One-time initialization - costs (pool creation, tick initialization, the first swap's accumulator write) - are excluded by design, and every mint row is a subsequent mint into - initialized ticks; the harnesses record those one-time costs, and the page - discloses the first-swap one in a single line because it favors Ekubo. - `vm.cool` was checked while building the harnesses and changed no figure; no - committed test uses it. Switching the v3 and v4 harnesses from Cancun to Osaka - changes none of their swap figures. -- **Refunds.** Snapshots are net of EIP-3529 refunds. The v3 harness also records - the gross figure and the refund granted next to every snapshot; every Ekubo - and v4 measurement has zero refund. -- **Like for like.** One identical token artifact (solmate `MockERC20`, deployed - from the same bytecode) runs in every lab harness. Every recipient already - holds the output token, and every router delivers output to the calling user, - so no measured call pays a zero-to-nonzero balance write the others avoid. - Liquidity is economically matched (1M tokens a side; v3 mints the equivalent - liquidity for the same amounts), pools are initialized strictly inside a tick, - and no swap crosses an initialized tick. -- **Routers.** Deliberately minimal on all sides, which favors Uniswap: - production routers (Universal Router, SwapRouter, and Ekubo's own frontend - path) all cost more than the lab numbers. Ekubo is additionally measured - through its production Solidity `Router`; the v3 direct pool call is a floor - an externally owned account cannot send. Mints are the exception: the - user-facing tier goes through each protocol's deployed production position - manager on the fork, with the one-time token approvals (and, for v4, the - Permit2 allowance) granted in setup and not measured. -- **Scope.** No-crossing swaps on hookless, extensionless pools: any v4 hook or - Ekubo extension changes every number on this page, while pool configuration - (fee, tick spacing) barely moves them. Both swap directions are covered. -- **Fork.** Pinned block 25991868, production contracts and live pools - identified by on-chain probing, warmed as in the lab, negligible tick - movement. Fee tiers differ across venues. Fork mints use each pool's widest - spacing-aligned range (tick spacing 1 on both Uniswap pools, 50 on Ekubo), so - the three positions are not identical in liquidity, only in size; mints do - not move price. - -The complete harnesses, raw snapshots, reference calldata, flamegraphs, and -reproduction commands live in `benchmarks/` alongside these docs. +## Why it is cheaper + +Three mechanisms, each measured, details in `benchmarks/`: + +- **Cheaper pool core.** One packed storage word and Q64 fixed-point math cost + about 20k gas per swap, against 29k for v4's multi-slot state and 37k for + v3's slot0, fee-growth, and tick bookkeeping. +- **Net settlement.** Ekubo and v4 settle once per transaction (2 token + transfers for a route of any length); v3 pays 2 transfers per pool. So each + extra pool costs about 24k on Ekubo, 34k on v4, 59k on v3. +- **Compact calldata where it counts.** The production Yul router packs a route + into 184 bytes; Uniswap's Universal Router needs 548 bytes for a single V3 + swap before any Permit2 funding command. (Ekubo's Solidity router is even + shorter at 132 bytes; its cost is execution-side instead.) + +## Measured honestly + +Providing liquidity is measured at the tier providers actually touch: +production position managers on the same fork pools. Minting a new ~10,000 +USDC/USDT position costs 244,513 on Ekubo against 297,390 on v4 (17.8% less) +and 381,653 on v3 (35.9% less). Bare direct-to-core calls are a different, +unsendable tier and live in `benchmarks/`; one-time costs (pool creation, tick +initialization, first-swap writes) are excluded by design everywhere on this +page. Native ETH swaps cost 72,580 on Ekubo against 88,864 on v4; v3 pools hold +WETH, so its users pay a 27,938 wrapping deposit first. + +Scope: EVM only, concentrated pools, no hooks or extensions on any side — any +v4 hook or Ekubo extension changes every number here. + +## How it was measured + +- Production contracts, routers, and live pools on a mainnet fork pinned at + block 25991868; lab scaling on minimal single-lock routers with one shared + ERC20 token contract, economically matched liquidity, no tick crossings. +- Warmed steady state throughout: one identical unmeasured call precedes every + measurement. Both swap directions covered. +- Minimal routers on all sides, which favors Uniswap: production routers cost + more than the lab numbers. +- Full harness sources, raw snapshots, reference calldata, flamegraphs, pool + identifiers, and reproduction commands: `benchmarks/` in the docs repository. From b5a0f9c9b889e8b782099ecff2566b2f03c4c2e3 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 10:52:46 -0400 Subject: [PATCH 09/17] Measure pools-per-swap on mainnet and rebuild the chain-capacity blend on it The chain-wide section previously blended per-transaction savings at assumed 1.0/1.5/2.0 pools per swap because no measured figure existed. This measures it: over 200 consecutive mainnet blocks (25997689-25997888, 2026-09-17 14:04-14:44 UTC) read through a public JSON-RPC endpoint, 5,341 successful transactions emitted at least one pool-level swap event (Uniswap v2-family, v3, v4, Curve), averaging 1.66 pools per swap (71.5% / 15.6% / 6.2% / 6.6% touch 1 / 2 / 3 / 4+ pools). A calldata decode of the 470 transactions sent to the Uniswap and 1inch routers (hop counts from Universal Router commands, SwapRouter paths, unoswapN) averages 1.11 and matches the receipt event count on every transaction. Dune was the intended source but no key or account was available, so eth_getBlockReceipts + eth_getBlockByNumber were used instead; the script verifies each router address by selector presence in on-chain bytecode. Page: the measured 1.66 is now the headline blend (Ekubo 131,958 vs v4 150,974 / v3 153,652; 12.6% / 14.1% saving), with the router-only 1.11 and the 1.0/1.5/2.0 rows kept as context. Chain-wide freed-gas scenarios are recomputed at three explicitly labeled block-space shares: the measured 4.2% DEX-router burn lower bound, the 19.4% "defi" bracket, and a stipulated 70% AMM-trading upside case marked as an assumption, not a measurement. The window's own swap-transaction gas share (30.3%) is reported as a check that 4.2% is a floor. Swaps-per-block ceilings are unchanged. benchmarks/README.md documents the method, window, event signatures, router addresses, per-family and per-router tables, undecoded selectors, and the reproduction command; benchmarks/pools-per-swap/ holds the script and raw results. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 122 +++++ benchmarks/pools-per-swap/measure.mjs | 544 ++++++++++++++++++++ benchmarks/pools-per-swap/results.json | 308 +++++++++++ src/content/docs/concepts/gas-efficiency.md | 80 ++- 4 files changed, 1034 insertions(+), 20 deletions(-) create mode 100644 benchmarks/pools-per-swap/measure.mjs create mode 100644 benchmarks/pools-per-swap/results.json diff --git a/benchmarks/README.md b/benchmarks/README.md index 7d50563..bbe6b7e 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -12,6 +12,8 @@ measures code and architecture rather than compiler tuning. - `lab/v3/` — Uniswap v3 (`V3Gas.t.sol`, `Flame.t.sol`) - `lab/MockERC20.json` — the single shared token artifact deployed via `deployCode` in all three lab harnesses, so ERC20 transfer costs cannot skew the comparison +- `pools-per-swap/` — the pools-touched-per-swap-transaction measurement behind the + chain-wide section (`measure.mjs`, `results.json`); see below - `fork/` — mainnet-fork validation at pinned block `25991868` (`ForkEkuboReal.t.sol`, `ForkV3.t.sol`, `ForkV4.t.sol` for swaps; `ForkMintEkubo.t.sol`, `ForkMintV3.t.sol`, `ForkMintV4.t.sol` for mints through the @@ -266,6 +268,126 @@ Bare-tier mints (new position, boundary ticks initialized): Ekubo 179,020, v4 162,188, v3 138,380 net (141,180 gross, 2,800 refund); Ekubo Positions NFT 213,104; v3 same-position top-up 121,496 net. +## Pools per swap transaction (mainnet, JSON-RPC) + +The docs page's chain-wide blend uses a measured average of pools touched per swap +transaction. Dune was the intended source (a trailing-7-day query over `ethereum.logs`) +but no Dune API key or account was available to this repository, so the measurement was +taken from a public JSON-RPC endpoint instead: `https://ethereum-rpc.publicnode.com` +(`eth_getBlockReceipts` and `eth_getBlockByNumber` with full transactions; that endpoint +refuses `eth_getLogs` without an address filter, so receipts are read per block). The +window is 200 consecutive blocks, `25997689` to `25997888` (2026-09-17 14:04:35 to +14:44:47 UTC), 407 RPC calls in total. Script and raw output: `pools-per-swap/measure.mjs` +and `pools-per-swap/results.json`. + +```sh +cd benchmarks/pools-per-swap +node measure.mjs --blocks 200 --to 25997888 # reproduces results.json +node measure.mjs --blocks 200 # fresh window ending 12 blocks behind head +``` + +Two measurements are taken over the same blocks: + +1. **Receipts (all flow).** A transaction counts as a swap if it succeeded and emitted at + least one pool-level swap event; its pool count is the number of such events. This + covers routers, aggregators, MEV bundles and direct pool calls alike. Logs exist only + for successful transactions, so failed fills are excluded by construction (the script + keeps a sanity counter for failed transactions with logs; it read 0). +2. **Calldata (router flow only).** For successful transactions sent to the routers + below, the hop count is decoded from calldata: Universal Router `execute` commands + (`V3_SWAP_EXACT_IN/OUT` from the `20 + 23n`-byte path, `V2_SWAP_EXACT_IN/OUT` from + `path.length − 1`, `V4_SWAP` from the single/multi swap actions and `PathKey[]` + length), SwapRouter02 and classic SwapRouter `exactInput/exactOutput` paths and their + `Single` variants (1 pool), including inside `multicall`, Uniswap v2 Router02 `swap*` + paths, and 1inch v6 `unoswap/unoswap2/unoswap3` (1/2/3 pools by construction). 1inch's + generic `swap(executor, desc, data)` carries its route inside opaque executor data and + is reported as "opaque"; the remaining undecoded 1inch selectors were limit-order calls + (`cancelOrder` 0xb68fb020 alone is 84 of the 106 undecoded transactions, then + `fillContractOrder(Args)`, `permitAndCall`), not swaps. Every decoded router transaction + was cross-checked against its own receipt: 470 of 470 hop counts equal the event count. + +Event signatures (topic0 computed with `cast keccak`, Foundry 1.8.3): + +| Family | Event | topic0 | +| -------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- | +| Uniswap v3 | `Swap(address,address,int256,int256,uint160,uint128,int24)` | `0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67` | +| Uniswap v2 family | `Swap(address,uint256,uint256,uint256,uint256,address)` | `0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822` | +| Curve | `TokenExchange(address,int128,uint256,int128,uint256)` | `0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140` | +| Curve (underlying) | `TokenExchangeUnderlying(address,int128,uint256,int128,uint256)` | `0xd013ca23e77a65003c2c659c5442c00c805371b7fc1ebd4c206c41d1536bd90b` | +| Uniswap v4 (`PoolManager`) | `Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)` | `0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f` | + +The v2 signature is shared by Uniswap v2, SushiSwap, PancakeSwap v2 and every other v2 +fork, so those are folded into one "v2 family" and cannot be separated by event alone. +Curve's two events are folded into one family. Ekubo's own events are not in the set (the +question is what a swap on the incumbents touches today). + +Router addresses were not taken from memory: at startup the script fetches each +contract's bytecode and aborts unless it contains the listed function selectors. + +| Router | Address | Selectors verified in bytecode | +| ----------------------------- | -------------------------------------------- | --------------------------------------------------- | +| Universal Router (v2, 2025) | `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af` | `execute` `0x3593564c` | +| Universal Router (v1.2, 2023) | `0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD` | `execute` `0x3593564c` | +| SwapRouter02 | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | `exactInput` `0xb858183f`, `multicall` `0x5ae401dc` | +| SwapRouter (v3 classic) | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | `exactInput` `0xc04b8d59` | +| Uniswap v2 Router02 | `0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D` | `swapExactTokensForTokens` `0x38ed1739` | +| 1inch AggregationRouter v6 | `0x111111125421cA6dc452d289314280a0f8842A65` | `swap` `0x07ed2379`, `unoswap` `0x83800a8e` | + +### Results (blocks 25997689–25997888) + +All flow, from receipts: 5,341 swap transactions, 8,851 pool swap events, +**1.657 pools per swap transaction**. + +| Pools touched | Transactions | Share | +| ------------- | ------------ | ------ | +| 1 | 3,820 | 71.52% | +| 2 | 834 | 15.62% | +| 3 | 332 | 6.22% | +| 4 or more | 355 | 6.65% | + +Per family (a transaction touching two families is counted in both): + +| Family | Transactions | Events | Events of this family per tx | All pools per tx touching it | +| ----------------- | ------------ | ------ | ---------------------------- | ---------------------------- | +| Uniswap v3 | 2,631 | 3,491 | 1.327 | 1.968 | +| Uniswap v4 | 2,392 | 3,778 | 1.579 | 2.187 | +| Uniswap v2 family | 1,210 | 1,427 | 1.179 | 2.018 | +| Curve | 120 | 155 | 1.292 | 3.275 | + +Gas: the swap transactions used 1,803,698,643 of the window's 5,962,572,822 gas, **30.25%**. +This is the gas of whole transactions that contain a swap event (an aggregator's or MEV +bot's overhead included), so it is an upper bound on swap gas and a direct check on the +router-address-only burn share (4.2%), which it comfortably exceeds. + +Router flow only, from calldata: 470 decoded transactions, **1.106 pools per swap**; +102 opaque (1inch generic `swap`), 106 undecoded (limit-order calls, see above), +60 failed (excluded). + +| Pools touched | Transactions | Share | +| ------------- | ------------ | ------ | +| 1 | 435 | 92.55% | +| 2 | 28 | 5.96% | +| 3 | 3 | 0.64% | +| 4 or more | 4 | 0.85% | + +| Router | Decoded txs | Avg pools | 1 / 2 / 3 / 4+ | +| ----------------------------- | ----------- | --------- | ---------------- | +| Universal Router (v2, 2025) | 202 | 1.173 | 182 / 13 / 3 / 4 | +| SwapRouter02 | 110 | 1.064 | 103 / 7 / 0 / 0 | +| Uniswap v2 Router02 | 75 | 1.000 | 75 / 0 / 0 / 0 | +| SwapRouter (v3 classic) | 54 | 1.056 | 51 / 3 / 0 / 0 | +| Universal Router (v1.2, 2023) | 20 | 1.000 | 20 / 0 / 0 / 0 | +| 1inch v6 `unoswapN` | 9 | 1.556 | 4 / 5 / 0 / 0 | + +The gap between 1.66 (all flow) and 1.11 (router flow) is the multi-pool weight of +aggregators, solvers, MEV bundles and other contract callers. The docs page uses the +all-flow figure as its headline blend and shows the router-only figure as context. + +Caveats: 200 blocks is 40 minutes of one afternoon, not a 7-day window; the distribution +should be re-run over a longer window (the script accepts `--blocks`) before being quoted +as a long-run figure. The event set does not include Balancer, Maverick, Ekubo or +non-standard pools, so a transaction whose only swap is on one of those is not counted. + ## Dependency pins - Ekubo `evm-contracts` worktree at commit `1f5be49` (plus the harness test files diff --git a/benchmarks/pools-per-swap/measure.mjs b/benchmarks/pools-per-swap/measure.mjs new file mode 100644 index 0000000..e14773a --- /dev/null +++ b/benchmarks/pools-per-swap/measure.mjs @@ -0,0 +1,544 @@ +#!/usr/bin/env node +// Measures how many pools a mainnet swap transaction touches. +// +// Two independent measurements over the same block window, both from a public +// JSON-RPC endpoint (no Dune / indexer needed): +// +// 1. receipts — eth_getBlockReceipts per block. Counts pool-level swap events +// per successful transaction (topic0 in the families below). This +// covers every flow: routers, aggregators, MEV bundles, direct +// pool calls. Logs exist only for successful transactions, so +// failed fills are excluded by construction. +// 2. calldata — eth_getBlockByNumber(full txs). For transactions sent to the +// verified router contracts below, decodes the hop count from the +// calldata (Universal Router commands, SwapRouter02 / SwapRouter +// exactInput paths, 1inch unoswapN). Router flow only. +// +// Usage: node measure.mjs [--blocks N] [--to BLOCK] [--rpc URL] [--out FILE] + +import { writeFileSync } from "node:fs"; + +const args = Object.fromEntries( + process.argv + .slice(2) + .map((a, i, arr) => (a.startsWith("--") ? [a.slice(2), arr[i + 1]] : null)) + .filter(Boolean), +); +const RPC = args.rpc ?? "https://ethereum-rpc.publicnode.com"; +const BLOCKS = Number(args.blocks ?? 200); +const OUT = args.out ?? "results.json"; + +// Event signatures. Hashes were produced with `cast keccak` (Foundry 1.8.3), not memorized. +const FAMILIES = { + // Swap(address,address,int256,int256,uint160,uint128,int24) + "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67": "univ3", + // Swap(address,uint256,uint256,uint256,uint256,address) — Uniswap v2 and every fork (Sushi, PancakeSwap v2, ...) + "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822": + "univ2-family", + // TokenExchange(address,int128,uint256,int128,uint256) + "0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140": "curve", + // TokenExchangeUnderlying(address,int128,uint256,int128,uint256) + "0xd013ca23e77a65003c2c659c5442c00c805371b7fc1ebd4c206c41d1536bd90b": "curve", + // Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24) — Uniswap v4 PoolManager + "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f": "univ4", +}; + +// Router contracts. Each address is verified at startup: the deployed bytecode must +// contain the 4-byte selectors listed (a memorized address that fails this check aborts the run). +const ROUTERS = { + "0x66a9893cc07d91d95644aedd05d03f95e1dba8af": { + name: "Universal Router (v2, 2025)", + kind: "ur", + selectors: ["3593564c"], + }, + "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad": { + name: "Universal Router (v1.2, 2023)", + kind: "ur", + selectors: ["3593564c"], + }, + "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45": { + name: "SwapRouter02", + kind: "sr02", + selectors: ["b858183f", "5ae401dc"], + }, + "0xe592427a0aece92de3edee1f18e0157c05861564": { + name: "SwapRouter (v3 classic)", + kind: "sr", + selectors: ["c04b8d59"], + }, + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": { + name: "Uniswap v2 Router02", + kind: "v2r", + selectors: ["38ed1739"], + }, + "0x111111125421ca6dc452d289314280a0f8842a65": { + name: "1inch AggregationRouter v6", + kind: "oneinch", + selectors: ["07ed2379", "83800a8e"], + }, +}; + +let rpcCalls = 0; +async function rpc(method, params) { + for (let attempt = 0; ; attempt++) { + try { + rpcCalls++; + const res = await fetch(RPC, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const body = await res.json(); + if (body.error) + throw new Error(`${method}: ${JSON.stringify(body.error)}`); + if (body.result == null) throw new Error(`${method}: null result`); + return body.result; + } catch (e) { + if (attempt >= 8) throw e; + const wait = 500 * 2 ** attempt; + await new Promise((r) => setTimeout(r, wait)); + } + } +} + +// ---- minimal ABI helpers ----------------------------------------------------- +const word = (hex, i) => hex.slice(i * 64, i * 64 + 64); +const num = (hex, i) => Number(BigInt("0x" + word(hex, i))); +// dynamic `bytes` or `bytes[]` element at word index i inside `hex` (already stripped of selector) +function dynBytes(hex, base, i) { + const off = num(hex.slice(base * 2), i); + const start = base + off; + const len = Number(BigInt("0x" + hex.slice(start * 2, start * 2 + 64))); + return hex.slice(start * 2 + 64, start * 2 + 64 + len * 2); +} +function dynArray(hex, base, i) { + const off = num(hex.slice(base * 2), i); + const start = base + off; + const len = Number(BigInt("0x" + hex.slice(start * 2, start * 2 + 64))); + return { start: start + 32, len }; +} +function bytesArray(hex, base, i) { + const { start, len } = dynArray(hex, base, i); + const out = []; + for (let k = 0; k < len; k++) out.push(dynBytes(hex, start, k)); + return out; +} +const v3PathHops = (path) => (path.length / 2 - 20) / 23; // 20 + 23n bytes + +// Universal Router command bytes (Commands.sol), low 6 bits. +const UR = { + V3_SWAP_EXACT_IN: 0x00, + V3_SWAP_EXACT_OUT: 0x01, + V2_SWAP_EXACT_IN: 0x08, + V2_SWAP_EXACT_OUT: 0x09, + V4_SWAP: 0x10, +}; +// v4 router actions (Actions.sol) +const V4 = { + SWAP_EXACT_IN_SINGLE: 0x06, + SWAP_EXACT_IN: 0x07, + SWAP_EXACT_OUT_SINGLE: 0x08, + SWAP_EXACT_OUT: 0x09, +}; + +// Pools touched by one Universal Router command; null when the command is not a swap. +function urCommandPools(cmd, inp) { + if (cmd === UR.V3_SWAP_EXACT_IN || cmd === UR.V3_SWAP_EXACT_OUT) { + // (address recipient, uint256 amount, uint256 amountLimit, bytes path, bool payerIsUser) + return v3PathHops(dynBytes(inp, 0, 3)); + } + if (cmd === UR.V2_SWAP_EXACT_IN || cmd === UR.V2_SWAP_EXACT_OUT) { + // (address recipient, uint256 amount, uint256 amountLimit, address[] path, bool payerIsUser) + return dynArray(inp, 0, 3).len - 1; + } + if (cmd === UR.V4_SWAP) return v4SwapPools(inp); + return null; +} + +// V4_SWAP input: (bytes actions, bytes[] params) +function v4SwapPools(inp) { + const actions = dynBytes(inp, 0, 0); + const params = bytesArray(inp, 0, 1); + let pools = null; + for (let a = 0; a < actions.length / 2; a++) { + const n = v4ActionPools( + parseInt(actions.slice(a * 2, a * 2 + 2), 16), + params[a], + ); + if (n != null) pools = (pools ?? 0) + n; + } + return pools; +} + +function v4ActionPools(act, p) { + if (act === V4.SWAP_EXACT_IN_SINGLE || act === V4.SWAP_EXACT_OUT_SINGLE) + return 1; + if (act === V4.SWAP_EXACT_IN || act === V4.SWAP_EXACT_OUT) { + // abi.encode(ExactInputParams): one head word (offset) then struct: currency, path offset, amount, amountMin + const structStart = num(p, 0); + const pathOff = num(p.slice(structStart * 2), 1); + return num(p.slice((structStart + pathOff) * 2), 0); // PathKey[] length + } + return null; +} + +function decodeUR(input) { + const sel = input.slice(2, 10); + if (sel !== "3593564c" && sel !== "24856bc3") return null; + const hex = input.slice(10); + const commands = dynBytes(hex, 0, 0); + const inputs = bytesArray(hex, 0, 1); + let pools = null; + for (let k = 0; k < commands.length / 2; k++) { + const cmd = parseInt(commands.slice(k * 2, k * 2 + 2), 16) & 0x3f; + const n = urCommandPools(cmd, inputs[k]); + if (n != null) pools = (pools ?? 0) + n; + } + return pools; +} + +// SwapRouter02 / classic SwapRouter selectors -> how to read the pool count. +const SR_PATH = new Set([ + "b858183f", // SwapRouter02 exactInput((bytes path,address,uint256,uint256)) + "09b81346", // SwapRouter02 exactOutput + "c04b8d59", // SwapRouter exactInput((bytes,address,uint256,uint256,uint256)) + "f28c0498", // SwapRouter exactOutput +]); +const SR_SINGLE = new Set([ + "04e45aaf", // SwapRouter02 exactInputSingle + "5023b4df", // SwapRouter02 exactOutputSingle + "414bf389", // SwapRouter exactInputSingle + "db3e2198", // SwapRouter exactOutputSingle +]); +const SR_V2 = new Set([ + "472b43f3", // SwapRouter02 swapExactTokensForTokens(uint256,uint256,address[],address) + "42712a67", // SwapRouter02 swapTokensForExactTokens +]); + +function decodeSwapRouterCall(callHex) { + const sel = callHex.slice(0, 8); + const hex = callHex.slice(8); + if (SR_PATH.has(sel)) return v3PathHops(dynBytes(hex, num(hex, 0), 0)); + if (SR_SINGLE.has(sel)) return 1; + if (SR_V2.has(sel)) return dynArray(hex, 0, 2).len - 1; + return null; +} + +function decodeSwapRouter(input) { + const sel = input.slice(2, 10); + const hex = input.slice(10); + let calls; + if (sel === "5ae401dc" || sel === "1f0464d1") + calls = bytesArray(hex, 0, 1); // multicall(uint256|bytes32, bytes[]) + else if (sel === "ac9650d8") + calls = bytesArray(hex, 0, 0); // multicall(bytes[]) + else calls = [input.slice(2)]; + let pools = 0; + let found = false; + for (const c of calls) { + const n = decodeSwapRouterCall(c); + if (n != null) { + pools += n; + found = true; + } + } + return found ? pools : null; +} + +function decodeV2Router(input) { + const sel = input.slice(2, 10); + const hex = input.slice(10); + // swapExact*/swap*ForExact* on Router02: path is arg index 2 (amountIn, amountOutMin, path, to, deadline) + // or index 1 for the payable ETH-input variants (amountOutMin, path, to, deadline) + const idx2 = new Set([ + "38ed1739", + "8803dbee", + "18cbafe5", + "4a25d94a", + "5c11d795", + "791ac947", + ]); + const idx1 = new Set(["7ff36ab5", "fb3bdb41", "b6f9de95"]); + if (idx2.has(sel)) return dynArray(hex, 0, 2).len - 1; + if (idx1.has(sel)) return dynArray(hex, 0, 1).len - 1; + return null; +} + +function decodeOneInch(input) { + const sel = input.slice(2, 10); + const table = { + "83800a8e": 1, + "8770ba91": 2, + 19367472: 3, + a76dfc3b: 1, + "89af926a": 2, + "188ac35d": 3, + }; + if (sel in table) return table[sel]; + if (sel === "07ed2379") return "opaque"; // generic swap(executor, desc, data): hop count is inside executor data + return null; +} + +function decodeRouterTx(kind, input) { + try { + if (kind === "ur") return decodeUR(input); + if (kind === "sr02" || kind === "sr") return decodeSwapRouter(input); + if (kind === "v2r") return decodeV2Router(input); + if (kind === "oneinch") return decodeOneInch(input); + } catch { + return null; + } + return null; +} + +// ---- main --------------------------------------------------------------------- +const latest = Number(BigInt(await rpc("eth_blockNumber", []))); +const toBlock = Number(args.to ?? latest - 12); // stay behind the head to avoid reorgs +const fromBlock = toBlock - BLOCKS + 1; + +for (const [addr, r] of Object.entries(ROUTERS)) { + const code = await rpc("eth_getCode", [addr, "latest"]); + for (const s of r.selectors) { + if (!code.includes(s)) + throw new Error(`${r.name} at ${addr}: bytecode lacks selector ${s}`); + } + r.codeBytes = (code.length - 2) / 2; +} + +const receipts = { + txs: 0, + failedSwapTxs: 0, + hist: {}, + perFamily: {}, + gasUsedSwapTxs: 0n, + gasUsedAll: 0n, + sumPools: 0, +}; +const calldata = { + txs: 0, + hist: {}, + perRouter: {}, + sumPools: 0, + opaque: 0, + undecoded: 0, + failed: 0, + crossCheck: { compared: 0, equal: 0, calldataLower: 0, calldataHigher: 0 }, + undecodedSelectors: {}, +}; +const bump = (h, k) => (h[k] = (h[k] ?? 0) + 1); +const fam = (name) => + (receipts.perFamily[name] ??= { txs: 0, events: 0, poolsInThoseTxs: 0 }); +const routerStat = (name) => + (calldata.perRouter[name] ??= { + txs: 0, + sumPools: 0, + hist: {}, + opaque: 0, + undecoded: 0, + failed: 0, + }); + +let firstTs, lastTs; +const CONC = 4; +const blocks = Array.from({ length: BLOCKS }, (_, i) => fromBlock + i); +let next = 0; +// Count pool-level swap events in one receipt; returns { counts by family, total }. +function swapEventsIn(receipt) { + const counts = {}; + let total = 0; + for (const log of receipt.logs) { + const f = FAMILIES[log.topics[0]]; + if (!f) continue; + counts[f] = (counts[f] ?? 0) + 1; + total++; + } + return { counts, total }; +} + +// Measurement 1: tally one block's receipts. Returns tx hash -> pool count for the cross-check. +function tallyReceipts(rcpts) { + const eventPools = new Map(); + for (const r of rcpts) { + receipts.gasUsedAll += BigInt(r.gasUsed); + const { counts, total } = swapEventsIn(r); + if (!total) continue; + if (r.status !== "0x1") { + receipts.failedSwapTxs++; // cannot happen (failed txs emit no logs) — kept as a sanity counter + continue; + } + receipts.txs++; + receipts.sumPools += total; + eventPools.set(r.transactionHash, total); + receipts.gasUsedSwapTxs += BigInt(r.gasUsed); + bump(receipts.hist, Math.min(total, 4)); + for (const [f, c] of Object.entries(counts)) { + const s = fam(f); + s.txs++; + s.events += c; + s.poolsInThoseTxs += total; + } + } + return eventPools; +} + +function crossCheck(n, ev) { + if (ev == null) return; + const cc = calldata.crossCheck; + cc.compared++; + if (ev === n) cc.equal++; + else if (n < ev) cc.calldataLower++; + else cc.calldataHigher++; +} + +// Measurement 2: one successful transaction sent to a verified router. +function tallyRouterTx(router, tx, eventPools) { + const rs = routerStat(router.name); + const n = decodeRouterTx(router.kind, tx.input); + if (n === "opaque") { + calldata.opaque++; + rs.opaque++; + return; + } + if (n == null || n <= 0) { + calldata.undecoded++; + rs.undecoded++; + bump( + calldata.undecodedSelectors, + `${router.name}:${tx.input.slice(0, 10)}`, + ); + return; + } + calldata.txs++; + calldata.sumPools += n; + bump(calldata.hist, Math.min(n, 4)); + rs.txs++; + rs.sumPools += n; + bump(rs.hist, Math.min(n, 4)); + crossCheck(n, eventPools.get(tx.hash)); +} + +function tallyBlock(block, rcpts) { + const status = new Map( + rcpts.map((r) => [r.transactionHash, r.status === "0x1"]), + ); + const eventPools = tallyReceipts(rcpts); + for (const tx of block.transactions) { + const router = ROUTERS[(tx.to ?? "").toLowerCase()]; + if (!router) continue; + if (!status.get(tx.hash)) { + // keep the same population as the receipts measurement: successful txs only + calldata.failed++; + routerStat(router.name).failed++; + continue; + } + tallyRouterTx(router, tx, eventPools); + } +} + +async function worker() { + while (next < blocks.length) { + const bn = blocks[next++]; + const tag = "0x" + bn.toString(16); + const [block, rcpts] = await Promise.all([ + rpc("eth_getBlockByNumber", [tag, true]), + rpc("eth_getBlockReceipts", [tag]), + ]); + const ts = Number(BigInt(block.timestamp)); + if (bn === fromBlock) firstTs = ts; + if (bn === toBlock) lastTs = ts; + tallyBlock(block, rcpts); + if ((bn - fromBlock) % 25 === 0) + process.stderr.write(`block ${bn} (${bn - fromBlock + 1}/${BLOCKS})\n`); + } +} +await Promise.all(Array.from({ length: CONC }, worker)); + +const pct = (n, d) => (d ? +((100 * n) / d).toFixed(2) : 0); +const histShare = (h, n) => + Object.fromEntries( + [1, 2, 3, 4].map((k) => [ + k === 4 ? "4+" : String(k), + { txs: h[k] ?? 0, pct: pct(h[k] ?? 0, n) }, + ]), + ); + +const out = { + rpc: RPC, + window: { + fromBlock, + toBlock, + blocks: BLOCKS, + fromTimestamp: firstTs, + toTimestamp: lastTs, + fromIso: new Date(firstTs * 1000).toISOString(), + toIso: new Date(lastTs * 1000).toISOString(), + }, + rpcCalls, + families: FAMILIES, + routers: Object.fromEntries( + Object.entries(ROUTERS).map(([a, r]) => [ + a, + { + name: r.name, + kind: r.kind, + verifiedSelectors: r.selectors, + codeBytes: r.codeBytes, + }, + ]), + ), + receipts: { + method: + "eth_getBlockReceipts; successful txs with >=1 pool-level swap event; pools = number of such events in the tx", + swapTxs: receipts.txs, + failedSwapTxsSeen: receipts.failedSwapTxs, + avgPoolsPerSwapTx: +(receipts.sumPools / receipts.txs).toFixed(3), + histogram: histShare(receipts.hist, receipts.txs), + perFamily: Object.fromEntries( + Object.entries(receipts.perFamily).map(([f, s]) => [ + f, + { + txs: s.txs, + events: s.events, + avgEventsOfThisFamilyPerTx: +(s.events / s.txs).toFixed(3), + avgTotalPoolsPerTxTouchingFamily: +( + s.poolsInThoseTxs / s.txs + ).toFixed(3), + }, + ]), + ), + gasShare: { + swapTxGas: receipts.gasUsedSwapTxs.toString(), + allTxGas: receipts.gasUsedAll.toString(), + pct: pct(Number(receipts.gasUsedSwapTxs), Number(receipts.gasUsedAll)), + }, + }, + calldata: { + method: + "eth_getBlockByNumber(full); successful txs to the verified routers; pools decoded from calldata", + decodedTxs: calldata.txs, + avgPoolsPerSwapTx: +(calldata.sumPools / calldata.txs).toFixed(3), + histogram: histShare(calldata.hist, calldata.txs), + opaqueTxs: calldata.opaque, + undecodedTxs: calldata.undecoded, + failedTxs: calldata.failed, + crossCheckAgainstReceipts: calldata.crossCheck, + undecodedSelectors: Object.fromEntries( + Object.entries(calldata.undecodedSelectors).sort((a, b) => b[1] - a[1]), + ), + perRouter: Object.fromEntries( + Object.entries(calldata.perRouter).map(([n, s]) => [ + n, + { + decodedTxs: s.txs, + avgPools: s.txs ? +(s.sumPools / s.txs).toFixed(3) : null, + histogram: histShare(s.hist, s.txs), + opaque: s.opaque, + undecoded: s.undecoded, + failed: s.failed, + }, + ]), + ), + }, +}; +writeFileSync(OUT, JSON.stringify(out, null, 2) + "\n"); +console.log(JSON.stringify(out, null, 2)); diff --git a/benchmarks/pools-per-swap/results.json b/benchmarks/pools-per-swap/results.json new file mode 100644 index 0000000..4fd72ec --- /dev/null +++ b/benchmarks/pools-per-swap/results.json @@ -0,0 +1,308 @@ +{ + "rpc": "https://ethereum-rpc.publicnode.com", + "window": { + "fromBlock": 25997689, + "toBlock": 25997888, + "blocks": 200, + "fromTimestamp": 1789653875, + "toTimestamp": 1789656287, + "fromIso": "2026-09-17T14:04:35.000Z", + "toIso": "2026-09-17T14:44:47.000Z" + }, + "rpcCalls": 407, + "families": { + "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67": "univ3", + "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822": "univ2-family", + "0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140": "curve", + "0xd013ca23e77a65003c2c659c5442c00c805371b7fc1ebd4c206c41d1536bd90b": "curve", + "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f": "univ4" + }, + "routers": { + "0x66a9893cc07d91d95644aedd05d03f95e1dba8af": { + "name": "Universal Router (v2, 2025)", + "kind": "ur", + "verifiedSelectors": ["3593564c"], + "codeBytes": 19499 + }, + "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad": { + "name": "Universal Router (v1.2, 2023)", + "kind": "ur", + "verifiedSelectors": ["3593564c"], + "codeBytes": 17958 + }, + "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45": { + "name": "SwapRouter02", + "kind": "sr02", + "verifiedSelectors": ["b858183f", "5ae401dc"], + "codeBytes": 24497 + }, + "0xe592427a0aece92de3edee1f18e0157c05861564": { + "name": "SwapRouter (v3 classic)", + "kind": "sr", + "verifiedSelectors": ["c04b8d59"], + "codeBytes": 12070 + }, + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": { + "name": "Uniswap v2 Router02", + "kind": "v2r", + "verifiedSelectors": ["38ed1739"], + "codeBytes": 21943 + }, + "0x111111125421ca6dc452d289314280a0f8842a65": { + "name": "1inch AggregationRouter v6", + "kind": "oneinch", + "verifiedSelectors": ["07ed2379", "83800a8e"], + "codeBytes": 24294 + } + }, + "receipts": { + "method": "eth_getBlockReceipts; successful txs with >=1 pool-level swap event; pools = number of such events in the tx", + "swapTxs": 5341, + "failedSwapTxsSeen": 0, + "avgPoolsPerSwapTx": 1.657, + "histogram": { + "1": { + "txs": 3820, + "pct": 71.52 + }, + "2": { + "txs": 834, + "pct": 15.62 + }, + "3": { + "txs": 332, + "pct": 6.22 + }, + "4+": { + "txs": 355, + "pct": 6.65 + } + }, + "perFamily": { + "univ3": { + "txs": 2631, + "events": 3491, + "avgEventsOfThisFamilyPerTx": 1.327, + "avgTotalPoolsPerTxTouchingFamily": 1.968 + }, + "univ4": { + "txs": 2392, + "events": 3778, + "avgEventsOfThisFamilyPerTx": 1.579, + "avgTotalPoolsPerTxTouchingFamily": 2.187 + }, + "univ2-family": { + "txs": 1210, + "events": 1427, + "avgEventsOfThisFamilyPerTx": 1.179, + "avgTotalPoolsPerTxTouchingFamily": 2.018 + }, + "curve": { + "txs": 120, + "events": 155, + "avgEventsOfThisFamilyPerTx": 1.292, + "avgTotalPoolsPerTxTouchingFamily": 3.275 + } + }, + "gasShare": { + "swapTxGas": "1803698643", + "allTxGas": "5962572822", + "pct": 30.25 + } + }, + "calldata": { + "method": "eth_getBlockByNumber(full); successful txs to the verified routers; pools decoded from calldata", + "decodedTxs": 470, + "avgPoolsPerSwapTx": 1.106, + "histogram": { + "1": { + "txs": 435, + "pct": 92.55 + }, + "2": { + "txs": 28, + "pct": 5.96 + }, + "3": { + "txs": 3, + "pct": 0.64 + }, + "4+": { + "txs": 4, + "pct": 0.85 + } + }, + "opaqueTxs": 102, + "undecodedTxs": 106, + "failedTxs": 60, + "crossCheckAgainstReceipts": { + "compared": 470, + "equal": 470, + "calldataLower": 0, + "calldataHigher": 0 + }, + "undecodedSelectors": { + "1inch AggregationRouter v6:0xb68fb020": 84, + "1inch AggregationRouter v6:0xc3cf8043": 10, + "1inch AggregationRouter v6:0x89e7c650": 4, + "Uniswap v2 Router02:0xe8e33700": 2, + "Uniswap v2 Router02:0xaf2979eb": 2, + "1inch AggregationRouter v6:0x56a75868": 1, + "1inch AggregationRouter v6:0xcc713a04": 1, + "1inch AggregationRouter v6:0x5816d723": 1, + "Uniswap v2 Router02:0xbaa2abde": 1 + }, + "perRouter": { + "1inch AggregationRouter v6": { + "decodedTxs": 9, + "avgPools": 1.556, + "histogram": { + "1": { + "txs": 4, + "pct": 44.44 + }, + "2": { + "txs": 5, + "pct": 55.56 + }, + "3": { + "txs": 0, + "pct": 0 + }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 102, + "undecoded": 101, + "failed": 30 + }, + "Universal Router (v2, 2025)": { + "decodedTxs": 202, + "avgPools": 1.173, + "histogram": { + "1": { + "txs": 182, + "pct": 90.1 + }, + "2": { + "txs": 13, + "pct": 6.44 + }, + "3": { + "txs": 3, + "pct": 1.49 + }, + "4+": { + "txs": 4, + "pct": 1.98 + } + }, + "opaque": 0, + "undecoded": 0, + "failed": 24 + }, + "SwapRouter02": { + "decodedTxs": 110, + "avgPools": 1.064, + "histogram": { + "1": { + "txs": 103, + "pct": 93.64 + }, + "2": { + "txs": 7, + "pct": 6.36 + }, + "3": { + "txs": 0, + "pct": 0 + }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 0, + "undecoded": 0, + "failed": 3 + }, + "Uniswap v2 Router02": { + "decodedTxs": 75, + "avgPools": 1, + "histogram": { + "1": { + "txs": 75, + "pct": 100 + }, + "2": { + "txs": 0, + "pct": 0 + }, + "3": { + "txs": 0, + "pct": 0 + }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 0, + "undecoded": 5, + "failed": 1 + }, + "Universal Router (v1.2, 2023)": { + "decodedTxs": 20, + "avgPools": 1, + "histogram": { + "1": { + "txs": 20, + "pct": 100 + }, + "2": { + "txs": 0, + "pct": 0 + }, + "3": { + "txs": 0, + "pct": 0 + }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 0, + "undecoded": 0, + "failed": 0 + }, + "SwapRouter (v3 classic)": { + "decodedTxs": 54, + "avgPools": 1.056, + "histogram": { + "1": { + "txs": 51, + "pct": 94.44 + }, + "2": { + "txs": 3, + "pct": 5.56 + }, + "3": { + "txs": 0, + "pct": 0 + }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 0, + "undecoded": 0, + "failed": 2 + } + } + } +} diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index 6388af6..fb4c860 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -49,33 +49,69 @@ than a v4 block and 41% more than a v3 block. ## What this means for the chain How much this matters chain-wide depends on the average number of pools a swap -touches and on how much block space swaps take. No published figure for pools -per swap was found for this page, so the blend below is a sensitivity, not an -estimate: a swap's transaction gas at an average of _p_ pools is the one-pool -lab route plus (_p_ − 1) times the marginal per extra pool, plus 21,000: - -| Average pools per swap | Ekubo | Uniswap v4 | Uniswap v3 (net) | Ekubo saving vs v4 / v3 | -| ---------------------- | ------- | ---------- | ---------------- | ----------------------- | -| 1.0 | 115,904 | 128,360 | 115,078 | 9.7% / −0.7% | -| 1.5 | 128,119 | 145,566 | 144,426 | 12.0% / 11.3% | -| 2.0 | 140,333 | 162,771 | 173,774 | 13.8% / 19.2% | +touches and on how much block space swaps take. + +**Pools per swap, measured.** Over 200 consecutive mainnet blocks (25997689 to +25997888, September 17, 2026, 14:04 to 14:44 UTC), every successful transaction +that emitted at least one pool-level swap event (Uniswap v2 and its forks, v3, +v4, Curve) was counted by the number of such events it contains: + +| Pools touched in the transaction | Share of swap transactions | +| -------------------------------- | -------------------------- | +| 1 | 71.5% | +| 2 | 15.6% | +| 3 | 6.2% | +| 4 or more | 6.6% | + +That is 5,341 swap transactions at an average of **1.66 pools per swap**. The +subset sent directly to the Uniswap and 1inch routers (470 transactions, hop +counts decoded from calldata and matched against their events in every case) +averages only 1.11 pools, so most multi-pool weight comes from aggregators, MEV +and other contracts rather than from retail router calls. Failed transactions +emit no logs and are excluded by construction. Method, tables and script are in +`benchmarks/`. + +A swap's transaction gas at an average of _p_ pools is the one-pool lab route +plus (_p_ − 1) times the marginal per extra pool, plus 21,000. The measured +average is the headline blend; the router-only average and the round values are +kept as context: + +| Average pools per swap | Ekubo | Uniswap v4 | Uniswap v3 (net) | Ekubo saving vs v4 / v3 | +| ---------------------- | ----------- | ---------- | ---------------- | ----------------------- | +| **1.66 (measured)** | **131,958** | 150,974 | 153,652 | **12.6% / 14.1%** | +| 1.11 (routers only) | 118,504 | 132,022 | 121,324 | 10.2% / 2.3% | +| 1.0 | 115,904 | 128,360 | 115,078 | 9.7% / −0.7% | +| 1.5 | 128,119 | 145,566 | 144,426 | 12.0% / 11.3% | +| 2.0 | 140,333 | 162,771 | 173,774 | 13.8% / 19.2% | For the block-space share: over the 30 days ending September 17, 2026, mainnet burned 1,255.7 ETH of base fees (ultrasound.money); transactions to known DEX router contracts accounted for about 4.2% of that burn, a lower bound because router-address attribution misses swaps routed through aggregators, MEV bundles, -and direct pool calls. Dune was unavailable to re-derive that share; ultrasound's -coarser "defi" category, which includes lending and everything else, was 19.4% -over the same window and brackets it from above. +and direct pool calls. Ultrasound's coarser "defi" category, which includes +lending and everything else, was 19.4% over the same window and brackets it from +above. In the 200-block window above, the transactions that emitted a pool swap +event used 30.3% of all gas, which confirms the router figure is a floor. A +third, higher share of 70% is included below because it was stipulated for this +page as an "all AMM trading" upside case; it is an assumption handed to the +analysis, not a measurement. Scenario math, with the assumptions stated: if a swap's share of the burn equals -its share of gas and the per-transaction saving applies to all of it, moving the -DEX-router slice to Ekubo would free, at the 4.2% lower bound, about 0.5–0.6% of -block gas chain-wide on the fork single-hop savings (11.7% against v3, 14.2% -against v4), 0.5% at an average of 1.5 pools per swap, and 0.6–0.8% at 2 pools; -if the whole "defi" slice behaved like swaps, 2.3–2.8% on single hops, 2.2–2.3% -at 1.5 pools and 2.7–3.7% at 2 pools. Induced demand would fill freed space. -Read these as an order of magnitude, not a forecast. +its share of gas and the per-transaction saving applies to all of it, moving +that slice to Ekubo would free the share times the saving. Each cell is the +range spanned by the saving against v3 and the saving against v4 (the two +savings are in the column header, v3 first): + +| Block-space share given to swaps | Fork single hop (v3 11.7%, v4 14.2%) | Measured 1.66 pools (v3 14.1%, v4 12.6%) | 2.0 pools (v3 19.2%, v4 13.8%) | +| ------------------------------------------------- | ------------------------------------ | ---------------------------------------- | ------------------------------ | +| 4.2%, measured DEX-router burn, lower bound | 0.5–0.6% | 0.5–0.6% | 0.6–0.8% | +| 19.4%, "defi" category bracket | 2.3–2.8% | 2.4–2.7% | 2.7–3.7% | +| 70%, stipulated AMM-trading upside (not measured) | 8.2–10.0% | 8.8–9.9% | 9.7–13.5% | + +At the measured blend and the measured lower bound, Ekubo frees about half a +percent of block gas chain-wide; at the stipulated 70% share it would be +roughly nine percent. Induced demand would fill freed space. Read these as an +order of magnitude, not a forecast. ## Why it is cheaper @@ -115,5 +151,9 @@ v4 hook or Ekubo extension changes every number here. measurement. Both swap directions covered. - Minimal routers on all sides, which favors Uniswap: production routers cost more than the lab numbers. +- Pools per swap: 200 mainnet blocks read through a public JSON-RPC endpoint + (block receipts for the event count, full blocks for the router calldata + cross-check). Uniswap v2 and all its forks share one event signature and are + counted as one family. - Full harness sources, raw snapshots, reference calldata, flamegraphs, pool identifiers, and reproduction commands: `benchmarks/` in the docs repository. From a57c5f38ba9e8c0258ef26474a1ebd06e6f8b782 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 11:24:21 -0400 Subject: [PATCH 10/17] Extend pools-per-swap to every mainnet AMM family and a 1,000-block window The receipt-based measurement previously counted only Uniswap v2-family, v3, v4 and Curve events over 200 blocks. It now covers PancakeSwap v3, Curve's crypto/tricrypto events, Balancer v2 and v3, Maverick v1 and v2, Fluid DEX, DODO v1/v2, Bancor v2.1/v3/Carbon, Solidly-style pairs and Ekubo itself. Every signature was read from the protocol's source and hashed with cast keccak. Ekubo Core writes its swap record with log0 and no topics, so it is matched as a topic-less 116-byte log from the Core address (Core.sol `log0(o, 116)`), which counts every Ekubo swap regardless of caller; the Yul router is verified by the embedded Core address and received one transaction in the window. Over blocks 25997046-25998045 (2026-09-17 11:55-15:16 UTC): 32,417 swap transactions, 54,407 pool swap events, 1.678 pools per swap (72.1% / 14.8% / 6.3% / 6.9% touch 1 / 2 / 3 / 4+ pools), swap-containing transactions used 32.67% of window gas. This confirms the earlier 1.66; the page's blended rows and scenario cells are recomputed from the new average (Ekubo 132,475 vs v4 151,703 / v3 154,894, 12.7% / 14.5%) and the router-only row from 4,432 decoded router transactions at 1.283 pools. The calldata cross-check now reports per router; the 25 mismatches are v4 hook pools performing nested swaps and tax tokens selling their fee through the same pair, all with more events than decoded hops. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 248 +++++--- benchmarks/pools-per-swap/measure.mjs | 205 +++++-- benchmarks/pools-per-swap/results.json | 632 ++++++++++++++++---- src/content/docs/concepts/gas-efficiency.md | 60 +- 4 files changed, 890 insertions(+), 255 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index bbe6b7e..a5592fb 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -276,14 +276,18 @@ but no Dune API key or account was available to this repository, so the measurem taken from a public JSON-RPC endpoint instead: `https://ethereum-rpc.publicnode.com` (`eth_getBlockReceipts` and `eth_getBlockByNumber` with full transactions; that endpoint refuses `eth_getLogs` without an address filter, so receipts are read per block). The -window is 200 consecutive blocks, `25997689` to `25997888` (2026-09-17 14:04:35 to -14:44:47 UTC), 407 RPC calls in total. Script and raw output: `pools-per-swap/measure.mjs` -and `pools-per-swap/results.json`. +window is 1,000 consecutive blocks, `25997046` to `25998045` (2026-09-17 11:55:11 to +15:16:11 UTC, tip pinned 12 blocks behind head at start), 2,174 RPC calls in total, no +rate limiting observed at 4 concurrent blocks. Script and raw output: +`pools-per-swap/measure.mjs` and `pools-per-swap/results.json`. An earlier 200-block run +(`25997689`–`25997888`, Uniswap v2/v3/v4 and Curve only) gave 5,341 swap transactions at +1.657 pools per swap and a 30.25% gas share; the wider event set and window below +supersede it. ```sh cd benchmarks/pools-per-swap -node measure.mjs --blocks 200 --to 25997888 # reproduces results.json -node measure.mjs --blocks 200 # fresh window ending 12 blocks behind head +node measure.mjs --blocks 1000 --to 25998045 # reproduces results.json +node measure.mjs --blocks 1000 # fresh window ending 12 blocks behind head ``` Two measurements are taken over the same blocks: @@ -302,91 +306,179 @@ Two measurements are taken over the same blocks: paths, and 1inch v6 `unoswap/unoswap2/unoswap3` (1/2/3 pools by construction). 1inch's generic `swap(executor, desc, data)` carries its route inside opaque executor data and is reported as "opaque"; the remaining undecoded 1inch selectors were limit-order calls - (`cancelOrder` 0xb68fb020 alone is 84 of the 106 undecoded transactions, then + (`cancelOrder` 0xb68fb020 alone is 239 of the 323 undecoded transactions, then `fillContractOrder(Args)`, `permitAndCall`), not swaps. Every decoded router transaction - was cross-checked against its own receipt: 470 of 470 hop counts equal the event count. - -Event signatures (topic0 computed with `cast keccak`, Foundry 1.8.3): - -| Family | Event | topic0 | -| -------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- | -| Uniswap v3 | `Swap(address,address,int256,int256,uint160,uint128,int24)` | `0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67` | -| Uniswap v2 family | `Swap(address,uint256,uint256,uint256,uint256,address)` | `0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822` | -| Curve | `TokenExchange(address,int128,uint256,int128,uint256)` | `0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140` | -| Curve (underlying) | `TokenExchangeUnderlying(address,int128,uint256,int128,uint256)` | `0xd013ca23e77a65003c2c659c5442c00c805371b7fc1ebd4c206c41d1536bd90b` | -| Uniswap v4 (`PoolManager`) | `Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)` | `0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f` | - -The v2 signature is shared by Uniswap v2, SushiSwap, PancakeSwap v2 and every other v2 -fork, so those are folded into one "v2 family" and cannot be separated by event alone. -Curve's two events are folded into one family. Ekubo's own events are not in the set (the -question is what a swap on the incumbents touches today). + is cross-checked against its own receipt (see the router table). + +### Event set + +Every signature was read from the protocol's own source file (path in the table) and +hashed with `cast keccak` (Foundry 1.8.3); nothing was taken from memory. + +| Family | Source | Event | topic0 | +| ------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- | +| Uniswap v3 | Uniswap/v3-core `IUniswapV3PoolEvents.sol` | `Swap(address,address,int256,int256,uint160,uint128,int24)` | `0xc42079f9…` | +| Uniswap v2 family | Uniswap/v2-core `IUniswapV2Pair.sol` | `Swap(address,uint256,uint256,uint256,uint256,address)` | `0xd78ad95f…` | +| Uniswap v4 | Uniswap/v4-core `IPoolManager.sol` | `Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)` | `0x40e9cecb…` | +| PancakeSwap v3 | pancakeswap/pancake-v3-contracts `IPancakeV3PoolEvents.sol` | `Swap(address,address,int256,int256,uint160,uint128,int24,uint128,uint128)` | `0x19b47279…` | +| Curve (stable) | curvefi/stableswap-ng `CurveStableSwapNG.vy` (same as the classic pools) | `TokenExchange(address,int128,uint256,int128,uint256)` | `0x8b3e96f2…` | +| Curve (stable, meta) | same | `TokenExchangeUnderlying(address,int128,uint256,int128,uint256)` | `0xd013ca23…` | +| Curve (crypto v2) | curvefi/curve-crypto-contract `CurveCryptoSwap2ETH.vy` | `TokenExchange(address,uint256,uint256,uint256,uint256)` | `0xb2e76ae9…` | +| Curve (tricrypto/two-ng) | curvefi/tricrypto-ng `CurveTricryptoOptimizedWETH.vy`, twocrypto-ng `Twocrypto.vy` | `TokenExchange(address,uint256,uint256,uint256,uint256,uint256,uint256)` | `0x143f1f8e…` | +| Balancer v2 | balancer/balancer-v2-monorepo `vault/IVault.sol` | `Swap(bytes32,address,address,uint256,uint256)` | `0x2170c741…` | +| Balancer v3 | balancer/balancer-v3-monorepo `vault/IVaultEvents.sol` | `Swap(address,address,address,uint256,uint256,uint256,uint256)` | `0x0874b2d5…` | +| Maverick v1 | maverickprotocol/maverick-v1-interfaces `IPool.sol` | `Swap(address,address,bool,bool,uint256,uint256,int32)` | `0x3b841dc9…` | +| Maverick v2 | maverickprotocol/v2-common `IMaverickV2Pool.sol` | `PoolSwap(address,address,(uint256,bool,bool,int32),uint256,uint256)` | `0x103ed084…` | +| Fluid DEX | Instadapp/fluid-contracts-public `dex/poolT1/coreModule/events.sol` | `Swap(bool,uint256,uint256,address)` | `0xdc004dbc…` | +| DODO v2 (DVM/DPP/DSP) | DODOEX/contractV2 `DVMTrader.sol`, `DPPTrader.sol`, `DSPTrader.sol` | `DODOSwap(address,address,uint256,uint256,address,address)` | `0xc2c0245e…` | +| DODO v1 | DODOEX/dodo-smart-contract `impl/Trader.sol` | `SellBaseToken(address,uint256,uint256)` / `BuyBaseToken(address,uint256,uint256)` | `0xd8648b6a…` / `0xe93ad760…` | +| Bancor v3 | bancorprotocol/contracts-v3 `network/BancorNetwork.sol` | `TokensTraded(bytes32,address,address,uint256,uint256,uint256,uint256,uint256,address)` | `0x5c02c2bb…` | +| Bancor Carbon | bancorprotocol/carbon-contracts `carbon/Strategies.sol` | `TokensTraded(address,address,address,uint256,uint256,uint128,bool)` | `0x95f3b013…` | +| Bancor v2.1 | bancorprotocol/contracts-solidity `converter/interfaces/IConverter.sol` | `Conversion(address,address,address,uint256,uint256,int256)` | `0x276856b3…` | +| Solidly-style pairs | aerodrome-finance/contracts `interfaces/IPool.sol` | `Swap(address,address,uint256,uint256,uint256,uint256)` | `0xb3e27736…` | +| Ekubo | EkuboProtocol/evm-contracts v3.2.0 `src/Core.sol` | `log0`, no topics, 116 bytes (locker, poolId, balanceUpdate, stateAfter) | none | + +Full hashes are in `measure.mjs` and `results.json`. Notes on folding and coverage: + +- The v2 signature is shared by Uniswap v2, SushiSwap, PancakeSwap v2, Fraxswap and every + other v2 fork, so those are one "v2 family" and cannot be separated by event alone. + The v3 signature is likewise shared by SushiSwap v3 and KyberSwap Elastic (identical + parameter types), so those land in the v3 row. Solidly-style pairs (Aerodrome, + Velodrome) hash to a different topic and were added, but emitted nothing on mainnet in + the window (they live on Base and Optimism). +- Curve's four exchange events are folded into one family. PancakeSwap v3 is a separate + row because its `Swap` carries two extra protocol-fee words and therefore a different + topic. +- Ekubo Core writes its swap record with `log0` and no topics (`Core.sol`, + `log0(o, 116)`), so topic filtering cannot see it. It is counted instead as "a + topic-less log from the Core address with exactly 116 bytes of data"; every other + Core event is a normal topic-carrying `emit`, and the sanity counter for topic-less + Core logs of any other length read 0. This counts every Ekubo swap regardless of + caller. The production Yul router (`0x7B2aA7Ec…`, bytecode verified to embed the Core + address) received 1 transaction in the window; Ekubo flow arrives through 1inch, 0x, + Relay, Kyber and other aggregators. Hop counts are not decoded from the Yul router's + packed calldata. The legacy Ekubo v2 core (`0xe0e0e08A…`) is not counted. +- Within the Balancer v2 family, 58 of the 243 transactions came from a second + `Vault` deployment (`0xd315a9c3…`, Sourcify exact match to `contracts/Vault.sol`), a + Balancer-v2-architecture fork; it is left in the family. + +Not counted, and why: + +- Order-flow and RFQ settlement that is not a pool: CoW Protocol, UniswapX, 1inch and + KyberSwap limit orders (`DSLOProtocol`), 0x RFQ fills. Their transactions are counted + only when a leg lands in one of the pools above. The KyberSwap router transactions + without any counted event (51 in the first 200 blocks) were limit-order fills. +- Long-tail AMMs outside the set (Kyber Classic/DMM, Smardex, Integral, Shell, Uniswap v1, + DODO v3): no signature was verified from source for them within the time box. +- One unidentified contract (`0xf2e3dcb8…`) that emits topic-less logs (29 transactions, + 5.2M gas in the first 200 blocks, no verified source on Sourcify). +- Everything the discovery scan identified as non-AMM: ERC-4337 EntryPoint and + paymasters, Aave, Morpho, bridges (Relay, LiFi, Optimism/Base messengers), the Aztec + rollup, Seaport, Chainlink VRF, batch-transfer and address-poisoning contracts. Router addresses were not taken from memory: at startup the script fetches each -contract's bytecode and aborts unless it contains the listed function selectors. - -| Router | Address | Selectors verified in bytecode | -| ----------------------------- | -------------------------------------------- | --------------------------------------------------- | -| Universal Router (v2, 2025) | `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af` | `execute` `0x3593564c` | -| Universal Router (v1.2, 2023) | `0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD` | `execute` `0x3593564c` | -| SwapRouter02 | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | `exactInput` `0xb858183f`, `multicall` `0x5ae401dc` | -| SwapRouter (v3 classic) | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | `exactInput` `0xc04b8d59` | -| Uniswap v2 Router02 | `0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D` | `swapExactTokensForTokens` `0x38ed1739` | -| 1inch AggregationRouter v6 | `0x111111125421cA6dc452d289314280a0f8842A65` | `swap` `0x07ed2379`, `unoswap` `0x83800a8e` | - -### Results (blocks 25997689–25997888) - -All flow, from receipts: 5,341 swap transactions, 8,851 pool swap events, -**1.657 pools per swap transaction**. +contract's bytecode and aborts unless it contains the listed function selectors (or, +for the selector-less Yul router, the Core address). + +| Router | Address | Verified in bytecode | +| ---------------------------------- | -------------------------------------------- | ---------------------------------------------------- | +| Universal Router (v2, 2025) | `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af` | `execute` `0x3593564c` | +| Universal Router (v1.2, 2023) | `0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD` | `execute` `0x3593564c` | +| Universal Router (2026 deployment) | `0x4c82d1FBfe28c977cbb58d8c7ff8FcF9f70a2cca` | `execute` `0x3593564c` (Sourcify: `UniversalRouter`) | +| SwapRouter02 | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | `exactInput` `0xb858183f`, `multicall` `0x5ae401dc` | +| SwapRouter (v3 classic) | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | `exactInput` `0xc04b8d59` | +| Uniswap v2 Router02 | `0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D` | `swapExactTokensForTokens` `0x38ed1739` | +| 1inch AggregationRouter v6 | `0x111111125421cA6dc452d289314280a0f8842A65` | `swap` `0x07ed2379`, `unoswap` `0x83800a8e` | +| Ekubo Yul router | `0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748` | Core address `0x…14aA86C5d3c41765bb24e11bd701` | + +### Results (blocks 25997046–25998045) + +All flow, from receipts: 321,702 transactions in the window (317,617 successful), of +which 32,417 are swap transactions with 54,407 pool swap events, +**1.678 pools per swap transaction** (54,407 / 32,417 = 1.6783). | Pools touched | Transactions | Share | | ------------- | ------------ | ------ | -| 1 | 3,820 | 71.52% | -| 2 | 834 | 15.62% | -| 3 | 332 | 6.22% | -| 4 or more | 355 | 6.65% | - -Per family (a transaction touching two families is counted in both): - -| Family | Transactions | Events | Events of this family per tx | All pools per tx touching it | -| ----------------- | ------------ | ------ | ---------------------------- | ---------------------------- | -| Uniswap v3 | 2,631 | 3,491 | 1.327 | 1.968 | -| Uniswap v4 | 2,392 | 3,778 | 1.579 | 2.187 | -| Uniswap v2 family | 1,210 | 1,427 | 1.179 | 2.018 | -| Curve | 120 | 155 | 1.292 | 3.275 | - -Gas: the swap transactions used 1,803,698,643 of the window's 5,962,572,822 gas, **30.25%**. -This is the gas of whole transactions that contain a swap event (an aggregator's or MEV -bot's overhead included), so it is an upper bound on swap gas and a direct check on the -router-address-only burn share (4.2%), which it comfortably exceeds. - -Router flow only, from calldata: 470 decoded transactions, **1.106 pools per swap**; -102 opaque (1inch generic `swap`), 106 undecoded (limit-order calls, see above), -60 failed (excluded). +| 1 | 23,358 | 72.05% | +| 2 | 4,801 | 14.81% | +| 3 | 2,027 | 6.25% | +| 4 or more | 2,231 | 6.88% | + +Per family (a transaction touching two families is counted in both; "gas" is the gas of +every transaction touching the family, so it also overlaps): + +| Family | Transactions | Events | Events of this family per tx | All pools per tx touching it | Gas of those txs | Emitting contracts | +| ----------------- | ------------ | ------ | ---------------------------- | ---------------------------- | ---------------- | ------------------ | +| Uniswap v3 | 15,900 | 21,418 | 1.347 | 2.024 | 5,672,233,165 | 813 | +| Uniswap v4 | 11,748 | 17,467 | 1.487 | 2.354 | 4,741,053,798 | 1 (PoolManager) | +| Uniswap v2 family | 7,528 | 8,963 | 1.191 | 2.128 | 2,467,266,054 | 1,267 | +| Curve | 1,652 | 2,250 | 1.362 | 4.079 | 1,286,127,611 | 219 | +| PancakeSwap v3 | 1,307 | 1,433 | 1.096 | 4.111 | 858,951,131 | 35 | +| Ekubo | 943 | 1,079 | 1.144 | 3.538 | 673,692,276 | 1 (Core) | +| Fluid DEX | 551 | 587 | 1.065 | 3.316 | 435,123,007 | 19 | +| DODO | 421 | 448 | 1.064 | 4.496 | 399,393,841 | 56 | +| Balancer v2 | 243 | 259 | 1.066 | 4.066 | 160,357,147 | 2 (Vault + fork) | +| Balancer v3 | 202 | 226 | 1.119 | 5.119 | 204,734,020 | 2 | +| Maverick v2 | 118 | 123 | 1.042 | 5.127 | 117,418,379 | 7 | +| Bancor v2.1 | 54 | 107 | 1.981 | 6.130 | 42,174,657 | 19 | +| Bancor v3 | 23 | 23 | 1.000 | 5.348 | 17,867,787 | 1 | +| Maverick v1 | 17 | 18 | 1.059 | 6.059 | 12,229,328 | 7 | +| Bancor Carbon | 6 | 6 | 1.000 | 2.333 | 3,788,965 | 1 | +| Solidly-style | 0 | 0 | — | — | — | 0 | + +Reading the right-hand columns: the families added in this pass are mostly reached +through aggregators (a transaction touching Balancer v3 averages 5.1 pools in total, +Maverick v2 5.1, Curve 4.1), while the four families of the first run are where the +single-pool retail flow lives. Adding them therefore raised the average from 1.657 to +1.678, not by more. + +Gas: the swap transactions used 10,039,217,633 of the window's 30,729,852,504 gas, +**32.67%**. This is the gas of whole transactions that contain a swap event (an +aggregator's or MEV bot's overhead included), so it is an upper bound on swap gas and a +direct check on the router-address-only burn share (4.2%), which it comfortably exceeds. + +Router flow only, from calldata: 4,432 decoded transactions, 5,687 pools, +**1.283 pools per swap**; 444 opaque (443 1inch generic `swap`, 1 Ekubo Yul +router), 323 undecoded (limit-order and liquidity calls, see above), 284 failed +(excluded). | Pools touched | Transactions | Share | | ------------- | ------------ | ------ | -| 1 | 435 | 92.55% | -| 2 | 28 | 5.96% | -| 3 | 3 | 0.64% | -| 4 or more | 4 | 0.85% | - -| Router | Decoded txs | Avg pools | 1 / 2 / 3 / 4+ | -| ----------------------------- | ----------- | --------- | ---------------- | -| Universal Router (v2, 2025) | 202 | 1.173 | 182 / 13 / 3 / 4 | -| SwapRouter02 | 110 | 1.064 | 103 / 7 / 0 / 0 | -| Uniswap v2 Router02 | 75 | 1.000 | 75 / 0 / 0 / 0 | -| SwapRouter (v3 classic) | 54 | 1.056 | 51 / 3 / 0 / 0 | -| Universal Router (v1.2, 2023) | 20 | 1.000 | 20 / 0 / 0 / 0 | -| 1inch v6 `unoswapN` | 9 | 1.556 | 4 / 5 / 0 / 0 | - -The gap between 1.66 (all flow) and 1.11 (router flow) is the multi-pool weight of +| 1 | 3,712 | 83.75% | +| 2 | 473 | 10.67% | +| 3 | 130 | 2.93% | +| 4 or more | 117 | 2.64% | + +| Router | Decoded txs | Avg pools | 1 / 2 / 3 / 4+ | Calldata = events | +| ---------------------------------- | ----------- | --------- | -------------------- | ----------------- | +| SwapRouter02 | 1,168 | 1.076 | 1093 / 71 / 0 / 4 | 1,168 / 1,168 | +| Universal Router (v2, 2025) | 1,153 | 1.207 | 974 / 147 / 18 / 14 | 1,150 / 1,153 | +| Universal Router (2026 deployment) | 1,070 | 1.837 | 635 / 224 / 112 / 99 | 1,056 / 1,070 | +| Uniswap v2 Router02 | 658 | 1.011 | 651 / 7 / 0 / 0 | 650 / 658 | +| SwapRouter (v3 classic) | 281 | 1.036 | 271 / 10 / 0 / 0 | 281 / 281 | +| Universal Router (v1.2, 2023) | 70 | 1.000 | 70 / 0 / 0 / 0 | 70 / 70 | +| 1inch v6 `unoswapN` | 32 | 1.438 | 18 / 14 / 0 / 0 | 32 / 32 | + +In 4,407 of the 4,432 decoded transactions the calldata hop count equals the event count. +The 25 exceptions all have more events than hops, never fewer: Universal Router +transactions whose Uniswap v4 legs went through hook pools that perform a nested +`PoolManager` swap (17, e.g. 1 hop decoded / 2 `Swap` events, 11 / 16, 3 / 8), and v2 +Router02 `swapExactTokensForTokensSupportingFeeOnTransferTokens` calls on tax tokens +that sell their fee through the same pair inside the transfer (8, each 1 hop / 2 +events on one pair). Both are real extra pool swaps, so the receipts measurement keeps +them; note that it counts swap events, not distinct pools, so a pool swapped twice in +one transaction counts twice. + +The gap between 1.68 (all flow) and 1.28 (router flow) is the multi-pool weight of aggregators, solvers, MEV bundles and other contract callers. The docs page uses the all-flow figure as its headline blend and shows the router-only figure as context. -Caveats: 200 blocks is 40 minutes of one afternoon, not a 7-day window; the distribution -should be re-run over a longer window (the script accepts `--blocks`) before being quoted -as a long-run figure. The event set does not include Balancer, Maverick, Ekubo or -non-standard pools, so a transaction whose only swap is on one of those is not counted. +Caveats: 1,000 blocks is about three and a half hours of one day, not a 7-day window; +the distribution should be re-run over a longer window (the script accepts `--blocks`) +before being quoted as a long-run figure. The event set now covers every AMM family +found among the window's gas consumers; what remains uncounted is listed under "Not +counted" above, so a transaction whose only swap is an order-book fill, a legacy Ekubo +v2 swap, or a long-tail AMM is not counted. ## Dependency pins diff --git a/benchmarks/pools-per-swap/measure.mjs b/benchmarks/pools-per-swap/measure.mjs index e14773a..d677861 100644 --- a/benchmarks/pools-per-swap/measure.mjs +++ b/benchmarks/pools-per-swap/measure.mjs @@ -5,10 +5,11 @@ // JSON-RPC endpoint (no Dune / indexer needed): // // 1. receipts — eth_getBlockReceipts per block. Counts pool-level swap events -// per successful transaction (topic0 in the families below). This -// covers every flow: routers, aggregators, MEV bundles, direct -// pool calls. Logs exist only for successful transactions, so -// failed fills are excluded by construction. +// per successful transaction (topic0 in the families below, plus +// Ekubo Core's topic-less 116-byte swap log). This covers every +// flow: routers, aggregators, MEV bundles, direct pool calls. Logs +// exist only for successful transactions, so failed fills are +// excluded by construction. // 2. calldata — eth_getBlockByNumber(full txs). For transactions sent to the // verified router contracts below, decodes the hop count from the // calldata (Universal Router commands, SwapRouter02 / SwapRouter @@ -28,21 +29,76 @@ const RPC = args.rpc ?? "https://ethereum-rpc.publicnode.com"; const BLOCKS = Number(args.blocks ?? 200); const OUT = args.out ?? "results.json"; -// Event signatures. Hashes were produced with `cast keccak` (Foundry 1.8.3), not memorized. +// Event signatures. Every signature was read from the protocol's own source (repo path in +// the comment) and hashed with `cast keccak` (Foundry 1.8.3); none is memorized. const FAMILIES = { - // Swap(address,address,int256,int256,uint160,uint128,int24) + // Uniswap v3-core IUniswapV3PoolEvents: Swap(address,address,int256,int256,uint160,uint128,int24). + // Shared by SushiSwap v3 and KyberSwap Elastic (identical parameter types), so those land here too. "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67": "univ3", - // Swap(address,uint256,uint256,uint256,uint256,address) — Uniswap v2 and every fork (Sushi, PancakeSwap v2, ...) + // Uniswap v2-core: Swap(address,uint256,uint256,uint256,uint256,address) — v2 and every fork (Sushi, PancakeSwap v2, Fraxswap, ...) "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822": "univ2-family", - // TokenExchange(address,int128,uint256,int128,uint256) + // curvefi/stableswap-ng CurveStableSwapNG.vy (and the classic plain/meta pools): TokenExchange(address,int128,uint256,int128,uint256) "0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140": "curve", - // TokenExchangeUnderlying(address,int128,uint256,int128,uint256) + // same, TokenExchangeUnderlying(address,int128,uint256,int128,uint256) "0xd013ca23e77a65003c2c659c5442c00c805371b7fc1ebd4c206c41d1536bd90b": "curve", - // Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24) — Uniswap v4 PoolManager + // curvefi/curve-crypto-contract CurveCryptoSwap2ETH.vy (crypto v2 pools): TokenExchange(address,uint256,uint256,uint256,uint256) + "0xb2e76ae99761dc136e598d4a629bb347eccb9532a5f8bbd72e18467c3c34cc98": "curve", + // curvefi/tricrypto-ng CurveTricryptoOptimizedWETH.vy and twocrypto-ng Twocrypto.vy: + // TokenExchange(address,uint256,uint256,uint256,uint256,uint256,uint256) + "0x143f1f8e861fbdeddd5b46e844b7d3ac7b86a122f36e8c463859ee6811b1f29c": "curve", + // Uniswap v4-core IPoolManager: Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24) "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f": "univ4", + // balancer-v2-monorepo pkg/interfaces/contracts/vault/IVault.sol: Swap(bytes32,address,address,uint256,uint256) + "0x2170c741c41531aec20e7c107c24eecfdd15e69c9bb0a8dd37b1840b9e0b207b": + "balancer-v2", + // balancer-v3-monorepo pkg/interfaces/contracts/vault/IVaultEvents.sol: + // Swap(address,address,address,uint256,uint256,uint256,uint256) + "0x0874b2d545cb271cdbda4e093020c452328b24af12382ed62c4d00f5c26709db": + "balancer-v3", + // maverickprotocol/maverick-v1-interfaces IPool.sol: Swap(address,address,bool,bool,uint256,uint256,int32) + "0x3b841dc9ab51e3104bda4f61b41e4271192d22cd19da5ee6e292dc8e2744f713": + "maverick-v1", + // maverickprotocol/v2-common IMaverickV2Pool.sol: PoolSwap(address,address,SwapParams,uint256,uint256) + // with SwapParams = (uint256 amount, bool tokenAIn, bool exactOutput, int32 tickLimit) + "0x103ed084e94a44c8f5f6ba8e3011507c41063177e29949083c439777d8d63f60": + "maverick-v2", + // pancakeswap/pancake-v3-contracts IPancakeV3PoolEvents.sol: + // Swap(address,address,int256,int256,uint160,uint128,int24,uint128,uint128) — differs from v3 by the two protocol-fee words + "0x19b47279256b2a23a1665c810c8d55a1758940ee09377d4f8d26497a3577dc83": + "pancake-v3", + // DODOEX/contractV2 DVMTrader.sol / DPPTrader.sol / DSPTrader.sol: DODOSwap(address,address,uint256,uint256,address,address) + "0xc2c0245e056d5fb095f04cd6373bc770802ebd1e6c918eb78fdef843cdb37b0f": "dodo", + // DODOEX/dodo-smart-contract Trader.sol (v1): SellBaseToken(address,uint256,uint256) + "0xd8648b6ac54162763c86fd54bf2005af8ecd2f9cb273a5775921fd7f91e17b2d": "dodo", + // same (v1): BuyBaseToken(address,uint256,uint256) + "0xe93ad76094f247c0dafc1c61adc2187de1ac2738f7a3b49cb20b2263420251a3": "dodo", + // bancorprotocol/contracts-v3 BancorNetwork.sol: + // TokensTraded(bytes32,address,address,uint256,uint256,uint256,uint256,uint256,address), one per hop + "0x5c02c2bb2d1d082317eb23916ca27b3e7c294398b60061a2ad54f1c3c018c318": + "bancor-v3", + // bancorprotocol/carbon-contracts Strategies.sol: TokensTraded(address,address,address,uint256,uint256,uint128,bool) + "0x95f3b01351225fea0e69a46f68b164c9dea10284f12cd4a907ce66510ab7af6a": + "bancor-carbon", + // bancorprotocol/contracts-solidity converter/interfaces/IConverter.sol (v2.1 converters): + // Conversion(address,address,address,uint256,uint256,int256) + "0x276856b36cbc45526a0ba64f44611557a2a8b68662c5388e9fe6d72e86e1c8cb": + "bancor-v2", + // Instadapp/fluid-contracts-public protocols/dex/poolT1/coreModule/events.sol: Swap(bool,uint256,uint256,address) + "0xdc004dbca4ef9c966218431ee5d9133d337ad018dd5b5c5493722803f75c64f7": "fluid", + // aerodrome-finance/contracts IPool.sol (Solidly-style pairs: Aerodrome, Velodrome, Solidly forks): + // Swap(address,address,uint256,uint256,uint256,uint256) — a different topic from the v2 family + "0xb3e2773606abfd36b5bd91394b3a54d1398336c65005baf7bf7a05efeffaf75b": + "solidly-style", }; +// Ekubo Core emits its swap record with `log0` (no topics) and exactly 116 bytes of data: +// 20-byte locker, 32-byte poolId, 32-byte balance update, 32-byte pool state +// (EkuboProtocol/evm-contracts v3.2.0 src/Core.sol, `log0(o, 116)`). Every other Core +// event is a normal topic-carrying `emit`, so "zero topics + 116 bytes from Core" is exact. +const EKUBO_CORE = "0x00000000000014aa86c5d3c41765bb24e11bd701"; +const EKUBO_SWAP_DATA_HEX_LEN = 2 + 116 * 2; + // Router contracts. Each address is verified at startup: the deployed bytecode must // contain the 4-byte selectors listed (a memorized address that fails this check aborts the run). const ROUTERS = { @@ -76,6 +132,22 @@ const ROUTERS = { kind: "oneinch", selectors: ["07ed2379", "83800a8e"], }, + // Third Universal Router deployment seen as a top gas consumer in the window; Sourcify + // full-matches it to UniswapRouter contracts/UniversalRouter.sol. + "0x4c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca": { + name: "Universal Router (2026 deployment)", + kind: "ur", + selectors: ["3593564c"], + }, + // Ekubo's production Yul router has no ABI selectors (calldata is packed route data), + // so it is verified by the Core address embedded in its bytecode instead. Hop counts are + // not decoded from its calldata; Ekubo pools are counted from Core's own swap logs. + "0x7b2aa7ecc0b5936b7c52e6259a19c3ba557d0748": { + name: "Ekubo Yul router", + kind: "ekubo-yul", + selectors: [], + bytecodeContains: [EKUBO_CORE.slice(2)], + }, }; let rpcCalls = 0; @@ -281,6 +353,7 @@ function decodeOneInch(input) { function decodeRouterTx(kind, input) { try { + if (kind === "ekubo-yul") return "opaque"; // packed route calldata, not decoded here if (kind === "ur") return decodeUR(input); if (kind === "sr02" || kind === "sr") return decodeSwapRouter(input); if (kind === "v2r") return decodeV2Router(input); @@ -298,12 +371,15 @@ const fromBlock = toBlock - BLOCKS + 1; for (const [addr, r] of Object.entries(ROUTERS)) { const code = await rpc("eth_getCode", [addr, "latest"]); - for (const s of r.selectors) { + for (const s of [...r.selectors, ...(r.bytecodeContains ?? [])]) { if (!code.includes(s)) - throw new Error(`${r.name} at ${addr}: bytecode lacks selector ${s}`); + throw new Error(`${r.name} at ${addr}: bytecode lacks ${s}`); } r.codeBytes = (code.length - 2) / 2; } +const ekuboCoreCodeBytes = + ((await rpc("eth_getCode", [EKUBO_CORE, "latest"])).length - 2) / 2; +if (!ekuboCoreCodeBytes) throw new Error("Ekubo Core has no code"); const receipts = { txs: 0, @@ -313,6 +389,9 @@ const receipts = { gasUsedSwapTxs: 0n, gasUsedAll: 0n, sumPools: 0, + ekuboCoreOtherTopiclessLogs: 0, // sanity: Core topic-less logs that are not 116 bytes (expected 0) + allTxs: 0, + successfulTxs: 0, }; const calldata = { txs: 0, @@ -327,7 +406,13 @@ const calldata = { }; const bump = (h, k) => (h[k] = (h[k] ?? 0) + 1); const fam = (name) => - (receipts.perFamily[name] ??= { txs: 0, events: 0, poolsInThoseTxs: 0 }); + (receipts.perFamily[name] ??= { + txs: 0, + events: 0, + poolsInThoseTxs: 0, + gas: 0n, + emitters: {}, + }); const routerStat = (name) => (calldata.perRouter[name] ??= { txs: 0, @@ -336,6 +421,8 @@ const routerStat = (name) => opaque: 0, undecoded: 0, failed: 0, + crossCheck: { compared: 0, equal: 0, calldataLower: 0, calldataHigher: 0 }, + mismatchSamples: [], }); let firstTs, lastTs; @@ -345,14 +432,26 @@ let next = 0; // Count pool-level swap events in one receipt; returns { counts by family, total }. function swapEventsIn(receipt) { const counts = {}; + const emitters = {}; let total = 0; for (const log of receipt.logs) { - const f = FAMILIES[log.topics[0]]; - if (!f) continue; + let f; + if (log.topics.length === 0) { + if (log.address.toLowerCase() !== EKUBO_CORE) continue; + if (log.data.length !== EKUBO_SWAP_DATA_HEX_LEN) { + receipts.ekuboCoreOtherTopiclessLogs++; + continue; + } + f = "ekubo"; + } else { + f = FAMILIES[log.topics[0]]; + if (!f) continue; + } counts[f] = (counts[f] ?? 0) + 1; + (emitters[f] ??= {})[log.address.toLowerCase()] = 1; total++; } - return { counts, total }; + return { counts, emitters, total }; } // Measurement 1: tally one block's receipts. Returns tx hash -> pool count for the cross-check. @@ -360,7 +459,9 @@ function tallyReceipts(rcpts) { const eventPools = new Map(); for (const r of rcpts) { receipts.gasUsedAll += BigInt(r.gasUsed); - const { counts, total } = swapEventsIn(r); + receipts.allTxs++; + if (r.status === "0x1") receipts.successfulTxs++; + const { counts, emitters, total } = swapEventsIn(r); if (!total) continue; if (r.status !== "0x1") { receipts.failedSwapTxs++; // cannot happen (failed txs emit no logs) — kept as a sanity counter @@ -376,18 +477,23 @@ function tallyReceipts(rcpts) { s.txs++; s.events += c; s.poolsInThoseTxs += total; + s.gas += BigInt(r.gasUsed); + for (const a of Object.keys(emitters[f])) bump(s.emitters, a); } } return eventPools; } -function crossCheck(n, ev) { +function crossCheck(n, ev, rs, hash) { if (ev == null) return; - const cc = calldata.crossCheck; - cc.compared++; - if (ev === n) cc.equal++; - else if (n < ev) cc.calldataLower++; - else cc.calldataHigher++; + for (const cc of [calldata.crossCheck, rs.crossCheck]) { + cc.compared++; + if (ev === n) cc.equal++; + else if (n < ev) cc.calldataLower++; + else cc.calldataHigher++; + } + if (ev !== n && rs.mismatchSamples.length < 5) + rs.mismatchSamples.push({ hash, calldata: n, events: ev }); } // Measurement 2: one successful transaction sent to a verified router. @@ -414,7 +520,7 @@ function tallyRouterTx(router, tx, eventPools) { rs.txs++; rs.sumPools += n; bump(rs.hist, Math.min(n, 4)); - crossCheck(n, eventPools.get(tx.hash)); + crossCheck(n, eventPools.get(tx.hash), rs, tx.hash); } function tallyBlock(block, rcpts) { @@ -447,7 +553,7 @@ async function worker() { if (bn === fromBlock) firstTs = ts; if (bn === toBlock) lastTs = ts; tallyBlock(block, rcpts); - if ((bn - fromBlock) % 25 === 0) + if ((bn - fromBlock) % 50 === 0) process.stderr.write(`block ${bn} (${bn - fromBlock + 1}/${BLOCKS})\n`); } } @@ -475,6 +581,12 @@ const out = { }, rpcCalls, families: FAMILIES, + ekuboCore: { + address: EKUBO_CORE, + codeBytes: ekuboCoreCodeBytes, + swapLog: + "log0, no topics, 116 bytes of data (locker, poolId, balanceUpdate, stateAfter)", + }, routers: Object.fromEntries( Object.entries(ROUTERS).map(([a, r]) => [ a, @@ -482,6 +594,7 @@ const out = { name: r.name, kind: r.kind, verifiedSelectors: r.selectors, + verifiedBytecodeContains: r.bytecodeContains ?? [], codeBytes: r.codeBytes, }, ]), @@ -490,21 +603,38 @@ const out = { method: "eth_getBlockReceipts; successful txs with >=1 pool-level swap event; pools = number of such events in the tx", swapTxs: receipts.txs, + sumPools: receipts.sumPools, failedSwapTxsSeen: receipts.failedSwapTxs, avgPoolsPerSwapTx: +(receipts.sumPools / receipts.txs).toFixed(3), histogram: histShare(receipts.hist, receipts.txs), + ekuboCoreOtherTopiclessLogs: receipts.ekuboCoreOtherTopiclessLogs, + allTxs: receipts.allTxs, + successfulTxs: receipts.successfulTxs, perFamily: Object.fromEntries( - Object.entries(receipts.perFamily).map(([f, s]) => [ - f, - { - txs: s.txs, - events: s.events, - avgEventsOfThisFamilyPerTx: +(s.events / s.txs).toFixed(3), - avgTotalPoolsPerTxTouchingFamily: +( - s.poolsInThoseTxs / s.txs - ).toFixed(3), - }, - ]), + Object.entries(receipts.perFamily) + .sort((a, b) => b[1].txs - a[1].txs) + .map(([f, s]) => [ + f, + { + txs: s.txs, + events: s.events, + avgEventsOfThisFamilyPerTx: +(s.events / s.txs).toFixed(3), + avgTotalPoolsPerTxTouchingFamily: +( + s.poolsInThoseTxs / s.txs + ).toFixed(3), + gasOfTxsTouchingFamily: s.gas.toString(), + // distinct emitting contracts and the busiest ones (txs in which each emitted) + emitters: Object.keys(s.emitters).length, + topEmitters: Object.fromEntries( + Object.entries(s.emitters) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5), + ), + }, + ]), + ), + familiesWithNoEvents: [...new Set(Object.values(FAMILIES)), "ekubo"].filter( + (f) => !receipts.perFamily[f], ), gasShare: { swapTxGas: receipts.gasUsedSwapTxs.toString(), @@ -516,6 +646,7 @@ const out = { method: "eth_getBlockByNumber(full); successful txs to the verified routers; pools decoded from calldata", decodedTxs: calldata.txs, + sumPools: calldata.sumPools, avgPoolsPerSwapTx: +(calldata.sumPools / calldata.txs).toFixed(3), histogram: histShare(calldata.hist, calldata.txs), opaqueTxs: calldata.opaque, @@ -535,6 +666,8 @@ const out = { opaque: s.opaque, undecoded: s.undecoded, failed: s.failed, + crossCheckAgainstReceipts: s.crossCheck, + mismatchSamples: s.mismatchSamples, }, ]), ), diff --git a/benchmarks/pools-per-swap/results.json b/benchmarks/pools-per-swap/results.json index 4fd72ec..d86f782 100644 --- a/benchmarks/pools-per-swap/results.json +++ b/benchmarks/pools-per-swap/results.json @@ -1,219 +1,537 @@ { "rpc": "https://ethereum-rpc.publicnode.com", "window": { - "fromBlock": 25997689, - "toBlock": 25997888, - "blocks": 200, - "fromTimestamp": 1789653875, - "toTimestamp": 1789656287, - "fromIso": "2026-09-17T14:04:35.000Z", - "toIso": "2026-09-17T14:44:47.000Z" + "fromBlock": 25997046, + "toBlock": 25998045, + "blocks": 1000, + "fromTimestamp": 1789646111, + "toTimestamp": 1789658171, + "fromIso": "2026-09-17T11:55:11.000Z", + "toIso": "2026-09-17T15:16:11.000Z" }, - "rpcCalls": 407, + "rpcCalls": 2174, "families": { "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67": "univ3", "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822": "univ2-family", "0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140": "curve", "0xd013ca23e77a65003c2c659c5442c00c805371b7fc1ebd4c206c41d1536bd90b": "curve", - "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f": "univ4" + "0xb2e76ae99761dc136e598d4a629bb347eccb9532a5f8bbd72e18467c3c34cc98": "curve", + "0x143f1f8e861fbdeddd5b46e844b7d3ac7b86a122f36e8c463859ee6811b1f29c": "curve", + "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f": "univ4", + "0x2170c741c41531aec20e7c107c24eecfdd15e69c9bb0a8dd37b1840b9e0b207b": "balancer-v2", + "0x0874b2d545cb271cdbda4e093020c452328b24af12382ed62c4d00f5c26709db": "balancer-v3", + "0x3b841dc9ab51e3104bda4f61b41e4271192d22cd19da5ee6e292dc8e2744f713": "maverick-v1", + "0x103ed084e94a44c8f5f6ba8e3011507c41063177e29949083c439777d8d63f60": "maverick-v2", + "0x19b47279256b2a23a1665c810c8d55a1758940ee09377d4f8d26497a3577dc83": "pancake-v3", + "0xc2c0245e056d5fb095f04cd6373bc770802ebd1e6c918eb78fdef843cdb37b0f": "dodo", + "0xd8648b6ac54162763c86fd54bf2005af8ecd2f9cb273a5775921fd7f91e17b2d": "dodo", + "0xe93ad76094f247c0dafc1c61adc2187de1ac2738f7a3b49cb20b2263420251a3": "dodo", + "0x5c02c2bb2d1d082317eb23916ca27b3e7c294398b60061a2ad54f1c3c018c318": "bancor-v3", + "0x95f3b01351225fea0e69a46f68b164c9dea10284f12cd4a907ce66510ab7af6a": "bancor-carbon", + "0x276856b36cbc45526a0ba64f44611557a2a8b68662c5388e9fe6d72e86e1c8cb": "bancor-v2", + "0xdc004dbca4ef9c966218431ee5d9133d337ad018dd5b5c5493722803f75c64f7": "fluid", + "0xb3e2773606abfd36b5bd91394b3a54d1398336c65005baf7bf7a05efeffaf75b": "solidly-style" + }, + "ekuboCore": { + "address": "0x00000000000014aa86c5d3c41765bb24e11bd701", + "codeBytes": 18797, + "swapLog": "log0, no topics, 116 bytes of data (locker, poolId, balanceUpdate, stateAfter)" }, "routers": { "0x66a9893cc07d91d95644aedd05d03f95e1dba8af": { "name": "Universal Router (v2, 2025)", "kind": "ur", "verifiedSelectors": ["3593564c"], + "verifiedBytecodeContains": [], "codeBytes": 19499 }, "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad": { "name": "Universal Router (v1.2, 2023)", "kind": "ur", "verifiedSelectors": ["3593564c"], + "verifiedBytecodeContains": [], "codeBytes": 17958 }, "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45": { "name": "SwapRouter02", "kind": "sr02", "verifiedSelectors": ["b858183f", "5ae401dc"], + "verifiedBytecodeContains": [], "codeBytes": 24497 }, "0xe592427a0aece92de3edee1f18e0157c05861564": { "name": "SwapRouter (v3 classic)", "kind": "sr", "verifiedSelectors": ["c04b8d59"], + "verifiedBytecodeContains": [], "codeBytes": 12070 }, "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": { "name": "Uniswap v2 Router02", "kind": "v2r", "verifiedSelectors": ["38ed1739"], + "verifiedBytecodeContains": [], "codeBytes": 21943 }, "0x111111125421ca6dc452d289314280a0f8842a65": { "name": "1inch AggregationRouter v6", "kind": "oneinch", "verifiedSelectors": ["07ed2379", "83800a8e"], + "verifiedBytecodeContains": [], "codeBytes": 24294 + }, + "0x4c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca": { + "name": "Universal Router (2026 deployment)", + "kind": "ur", + "verifiedSelectors": ["3593564c"], + "verifiedBytecodeContains": [], + "codeBytes": 24546 + }, + "0x7b2aa7ecc0b5936b7c52e6259a19c3ba557d0748": { + "name": "Ekubo Yul router", + "kind": "ekubo-yul", + "verifiedSelectors": [], + "verifiedBytecodeContains": ["00000000000014aa86c5d3c41765bb24e11bd701"], + "codeBytes": 2878 } }, "receipts": { "method": "eth_getBlockReceipts; successful txs with >=1 pool-level swap event; pools = number of such events in the tx", - "swapTxs": 5341, + "swapTxs": 32417, + "sumPools": 54407, "failedSwapTxsSeen": 0, - "avgPoolsPerSwapTx": 1.657, + "avgPoolsPerSwapTx": 1.678, "histogram": { "1": { - "txs": 3820, - "pct": 71.52 + "txs": 23358, + "pct": 72.05 }, "2": { - "txs": 834, - "pct": 15.62 + "txs": 4801, + "pct": 14.81 }, "3": { - "txs": 332, - "pct": 6.22 + "txs": 2027, + "pct": 6.25 }, "4+": { - "txs": 355, - "pct": 6.65 + "txs": 2231, + "pct": 6.88 } }, + "ekuboCoreOtherTopiclessLogs": 0, + "allTxs": 321702, + "successfulTxs": 317617, "perFamily": { "univ3": { - "txs": 2631, - "events": 3491, - "avgEventsOfThisFamilyPerTx": 1.327, - "avgTotalPoolsPerTxTouchingFamily": 1.968 + "txs": 15900, + "events": 21418, + "avgEventsOfThisFamilyPerTx": 1.347, + "avgTotalPoolsPerTxTouchingFamily": 2.024, + "gasOfTxsTouchingFamily": "5672233165", + "emitters": 813, + "topEmitters": { + "0xe0554a476a092703abdb3ef35c80e0d76d32939f": 3058, + "0xc7bbec68d12a0d1830360f8ec58fa599ba1b0e9b": 1870, + "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640": 1059, + "0x99ac8ca7087fa4a2a1fb6357269965a2014abc35": 914, + "0x11b815efb8f581194ae79006d24e0d814b7697f6": 662 + } }, "univ4": { - "txs": 2392, - "events": 3778, - "avgEventsOfThisFamilyPerTx": 1.579, - "avgTotalPoolsPerTxTouchingFamily": 2.187 + "txs": 11748, + "events": 17467, + "avgEventsOfThisFamilyPerTx": 1.487, + "avgTotalPoolsPerTxTouchingFamily": 2.354, + "gasOfTxsTouchingFamily": "4741053798", + "emitters": 1, + "topEmitters": { + "0x000000000004444c5dc75cb358380d2e3de08a90": 11748 + } }, "univ2-family": { - "txs": 1210, - "events": 1427, - "avgEventsOfThisFamilyPerTx": 1.179, - "avgTotalPoolsPerTxTouchingFamily": 2.018 + "txs": 7528, + "events": 8963, + "avgEventsOfThisFamilyPerTx": 1.191, + "avgTotalPoolsPerTxTouchingFamily": 2.128, + "gasOfTxsTouchingFamily": "2467266054", + "emitters": 1267, + "topEmitters": { + "0xad1a21f61d653c6101b92c335f61140459c81c79": 233, + "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc": 222, + "0x4a86c01d67965f8cb3d0aaa2c655705e64097c31": 181, + "0xd3d2e2692501a5c9ca623199d38826e513033a17": 172, + "0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852": 172 + } }, "curve": { - "txs": 120, - "events": 155, - "avgEventsOfThisFamilyPerTx": 1.292, - "avgTotalPoolsPerTxTouchingFamily": 3.275 + "txs": 1652, + "events": 2250, + "avgEventsOfThisFamilyPerTx": 1.362, + "avgTotalPoolsPerTxTouchingFamily": 4.079, + "gasOfTxsTouchingFamily": "1286127611", + "emitters": 219, + "topEmitters": { + "0x4ebdf703948ddcea3b11f675b4d1fba9d2414a14": 194, + "0x35c9a4dae1ff05788f24b5b32721d89340cbb636": 178, + "0xd51a44d3fae010294c616388b506acda1bfaae46": 129, + "0x7f86bf177dd4f3494b841a37e810a34dd56c829b": 105, + "0xf5f5b97624542d72a9e06f04804bf81baa15e2b4": 81 + } + }, + "pancake-v3": { + "txs": 1307, + "events": 1433, + "avgEventsOfThisFamilyPerTx": 1.096, + "avgTotalPoolsPerTxTouchingFamily": 4.111, + "gasOfTxsTouchingFamily": "858951131", + "emitters": 35, + "topEmitters": { + "0x1445f32d1a74872ba41f3d8cf4022e9996120b31": 575, + "0xacdb27b266142223e1e676841c1e809255fc6d07": 372, + "0x1ac1a8feaaea1900c4166deeed0c11cc10669d36": 116, + "0x6ca298d2983ab03aa1da7679389d955a4efee15c": 103, + "0xbf542ecaa30d23f651e07720f9ca74d6109d4e3a": 62 + } + }, + "ekubo": { + "txs": 943, + "events": 1079, + "avgEventsOfThisFamilyPerTx": 1.144, + "avgTotalPoolsPerTxTouchingFamily": 3.538, + "gasOfTxsTouchingFamily": "673692276", + "emitters": 1, + "topEmitters": { + "0x00000000000014aa86c5d3c41765bb24e11bd701": 943 + } + }, + "fluid": { + "txs": 551, + "events": 587, + "avgEventsOfThisFamilyPerTx": 1.065, + "avgTotalPoolsPerTxTouchingFamily": 3.316, + "gasOfTxsTouchingFamily": "435123007", + "emitters": 19, + "topEmitters": { + "0x667701e51b4d1ca244f17c78f7ab8744b4c99f9b": 224, + "0x836951eb21f3df98273517b7249dceff270d34bf": 194, + "0x3c0441b42195f4ad6aa9a0978e06096ea616cda7": 76, + "0xde632c3a214d5f14c1d8ddf0b92f8bcd188fee45": 20, + "0x97f32e5091d0e6aee24beb398e6592ae111dee9c": 8 + } + }, + "dodo": { + "txs": 421, + "events": 448, + "avgEventsOfThisFamilyPerTx": 1.064, + "avgTotalPoolsPerTxTouchingFamily": 4.496, + "gasOfTxsTouchingFamily": "399393841", + "emitters": 56, + "topEmitters": { + "0x04571c32a4e1c5f39bc3a238cb95b215058c432c": 243, + "0x3058ef90929cb8180174d74c507176cca6835d73": 40, + "0xc9f93163c99695c6526b799ebca2207fdf7d61ad": 23, + "0x201939f5c74f5fd3beef1cf03a9cfcc75beba9d9": 13, + "0x46cf01f14ff6f8c7ef4d6a6b139b1c3d45af9289": 12 + } + }, + "balancer-v2": { + "txs": 243, + "events": 259, + "avgEventsOfThisFamilyPerTx": 1.066, + "avgTotalPoolsPerTxTouchingFamily": 4.066, + "gasOfTxsTouchingFamily": "160357147", + "emitters": 2, + "topEmitters": { + "0xba12222222228d8ba445958a75a0704d566bf2c8": 185, + "0xd315a9c38ec871068fec378e4ce78af528c76293": 58 + } + }, + "balancer-v3": { + "txs": 202, + "events": 226, + "avgEventsOfThisFamilyPerTx": 1.119, + "avgTotalPoolsPerTxTouchingFamily": 5.119, + "gasOfTxsTouchingFamily": "204734020", + "emitters": 2, + "topEmitters": { + "0xba1333333333a1ba1108e8412f11850a5c319ba9": 189, + "0x955244edc797a1c1b04134b600f819ac23c76081": 13 + } + }, + "maverick-v2": { + "txs": 118, + "events": 123, + "avgEventsOfThisFamilyPerTx": 1.042, + "avgTotalPoolsPerTxTouchingFamily": 5.127, + "gasOfTxsTouchingFamily": "117418379", + "emitters": 7, + "topEmitters": { + "0x6061cf96aa9f02aa072b77211a863989ca070c78": 51, + "0x025a13752ec1fb6c0e441e5d7b1678bb5f9ca62f": 27, + "0x5b6a0771c752e35b2ca2aff4f22a66b1598a2bc5": 18, + "0xdf9c440111a17eafbecf5db37dd04c948e878878": 12, + "0x31373595f40ea48a7aab6cbcb0d377c6066e2dca": 10 + } + }, + "bancor-v2": { + "txs": 54, + "events": 107, + "avgEventsOfThisFamilyPerTx": 1.981, + "avgTotalPoolsPerTxTouchingFamily": 6.13, + "gasOfTxsTouchingFamily": "42174657", + "emitters": 19, + "topEmitters": { + "0x079ca3f710599739a22673c2856202f90d3a8806": 22, + "0xe331821bc94187c2649e932810a60204699d45cb": 19, + "0xcba47689202d31575bfa204efe70e2c83d29da4d": 11, + "0xd4d5298136b52ec439ebfb23fda2ec500de69c71": 9, + "0x987caf390fc5100da6db7e8ec4b651cd3b61bc32": 6 + } + }, + "bancor-v3": { + "txs": 23, + "events": 23, + "avgEventsOfThisFamilyPerTx": 1, + "avgTotalPoolsPerTxTouchingFamily": 5.348, + "gasOfTxsTouchingFamily": "17867787", + "emitters": 1, + "topEmitters": { + "0xeef417e1d5cc832e619ae18d2f140de2999dd4fb": 23 + } + }, + "maverick-v1": { + "txs": 17, + "events": 18, + "avgEventsOfThisFamilyPerTx": 1.059, + "avgTotalPoolsPerTxTouchingFamily": 6.059, + "gasOfTxsTouchingFamily": "12229328", + "emitters": 7, + "topEmitters": { + "0x11a653ddfbb61e0feff5484919f06d9d254bf65f": 4, + "0xa6634fc4ed29950e85d97d48580a1a9d5c14ca7c": 4, + "0x6c6fc818b25df89a8ada8da5a43669023bad1f4c": 3, + "0x352b186090068eb35d532428676ce510e17ab581": 3, + "0x8dd893b9ec19d89b66c01794cf5e04e30d284e53": 2 + } + }, + "bancor-carbon": { + "txs": 6, + "events": 6, + "avgEventsOfThisFamilyPerTx": 1, + "avgTotalPoolsPerTxTouchingFamily": 2.333, + "gasOfTxsTouchingFamily": "3788965", + "emitters": 1, + "topEmitters": { + "0xc537e898cd774e2dcba3b14ea6f34c93d5ea45e1": 6 + } } }, + "familiesWithNoEvents": ["solidly-style"], "gasShare": { - "swapTxGas": "1803698643", - "allTxGas": "5962572822", - "pct": 30.25 + "swapTxGas": "10039217633", + "allTxGas": "30729852504", + "pct": 32.67 } }, "calldata": { "method": "eth_getBlockByNumber(full); successful txs to the verified routers; pools decoded from calldata", - "decodedTxs": 470, - "avgPoolsPerSwapTx": 1.106, + "decodedTxs": 4432, + "sumPools": 5687, + "avgPoolsPerSwapTx": 1.283, "histogram": { "1": { - "txs": 435, - "pct": 92.55 + "txs": 3712, + "pct": 83.75 }, "2": { - "txs": 28, - "pct": 5.96 + "txs": 473, + "pct": 10.67 }, "3": { - "txs": 3, - "pct": 0.64 + "txs": 130, + "pct": 2.93 }, "4+": { - "txs": 4, - "pct": 0.85 + "txs": 117, + "pct": 2.64 } }, - "opaqueTxs": 102, - "undecodedTxs": 106, - "failedTxs": 60, + "opaqueTxs": 444, + "undecodedTxs": 323, + "failedTxs": 284, "crossCheckAgainstReceipts": { - "compared": 470, - "equal": 470, - "calldataLower": 0, + "compared": 4432, + "equal": 4407, + "calldataLower": 25, "calldataHigher": 0 }, "undecodedSelectors": { - "1inch AggregationRouter v6:0xb68fb020": 84, - "1inch AggregationRouter v6:0xc3cf8043": 10, - "1inch AggregationRouter v6:0x89e7c650": 4, - "Uniswap v2 Router02:0xe8e33700": 2, - "Uniswap v2 Router02:0xaf2979eb": 2, - "1inch AggregationRouter v6:0x56a75868": 1, + "1inch AggregationRouter v6:0xb68fb020": 239, + "1inch AggregationRouter v6:0xc3cf8043": 34, + "1inch AggregationRouter v6:0x89e7c650": 13, + "Uniswap v2 Router02:0xe8e33700": 8, + "Uniswap v2 Router02:0x02751cec": 6, + "Universal Router (v2, 2025):0x24856bc3": 5, + "Uniswap v2 Router02:0xf305d719": 5, + "Uniswap v2 Router02:0xaf2979eb": 5, + "1inch AggregationRouter v6:0x56a75868": 2, + "Uniswap v2 Router02:0xbaa2abde": 2, + "Universal Router (v2, 2025):0x3593564c": 1, + "1inch AggregationRouter v6:0xea76dddf": 1, "1inch AggregationRouter v6:0xcc713a04": 1, - "1inch AggregationRouter v6:0x5816d723": 1, - "Uniswap v2 Router02:0xbaa2abde": 1 + "1inch AggregationRouter v6:0x5816d723": 1 }, "perRouter": { - "1inch AggregationRouter v6": { - "decodedTxs": 9, - "avgPools": 1.556, + "SwapRouter02": { + "decodedTxs": 1168, + "avgPools": 1.076, "histogram": { "1": { - "txs": 4, - "pct": 44.44 + "txs": 1093, + "pct": 93.58 }, "2": { - "txs": 5, - "pct": 55.56 + "txs": 71, + "pct": 6.08 }, "3": { "txs": 0, "pct": 0 }, "4+": { - "txs": 0, - "pct": 0 + "txs": 4, + "pct": 0.34 } }, - "opaque": 102, - "undecoded": 101, - "failed": 30 + "opaque": 0, + "undecoded": 0, + "failed": 39, + "crossCheckAgainstReceipts": { + "compared": 1168, + "equal": 1168, + "calldataLower": 0, + "calldataHigher": 0 + }, + "mismatchSamples": [] }, - "Universal Router (v2, 2025)": { - "decodedTxs": 202, - "avgPools": 1.173, + "Universal Router (2026 deployment)": { + "decodedTxs": 1070, + "avgPools": 1.837, "histogram": { "1": { - "txs": 182, - "pct": 90.1 + "txs": 635, + "pct": 59.35 }, "2": { - "txs": 13, - "pct": 6.44 + "txs": 224, + "pct": 20.93 }, "3": { - "txs": 3, - "pct": 1.49 + "txs": 112, + "pct": 10.47 }, "4+": { - "txs": 4, - "pct": 1.98 + "txs": 99, + "pct": 9.25 } }, "opaque": 0, "undecoded": 0, - "failed": 24 + "failed": 56, + "crossCheckAgainstReceipts": { + "compared": 1070, + "equal": 1056, + "calldataLower": 14, + "calldataHigher": 0 + }, + "mismatchSamples": [ + { + "hash": "0x11ca7590c89ee5e282d79c94e6a4d2324b2c715038c048ff7beabfd2a9d2cf0a", + "calldata": 1, + "events": 2 + }, + { + "hash": "0xb874723278b4f54e8d520c7f7c2dfcc8c51a3003f01bd77efa55c10628002214", + "calldata": 11, + "events": 16 + }, + { + "hash": "0xa214808bf64c3608d17bfc0fb6d20e29e5f209dbb0160d73677d7af92d59d445", + "calldata": 10, + "events": 15 + }, + { + "hash": "0xf9a1267117fb3703a6d23a0fd00b8d2c7f7285dead677988e5242b1509ce14b8", + "calldata": 3, + "events": 8 + }, + { + "hash": "0xf5247e389078d1c7e9f0b95648c29de1b1f48685ab8b881746abe4607538bb22", + "calldata": 4, + "events": 9 + } + ] }, - "SwapRouter02": { - "decodedTxs": 110, - "avgPools": 1.064, + "Universal Router (v2, 2025)": { + "decodedTxs": 1153, + "avgPools": 1.207, "histogram": { "1": { - "txs": 103, - "pct": 93.64 + "txs": 974, + "pct": 84.48 }, "2": { - "txs": 7, - "pct": 6.36 + "txs": 147, + "pct": 12.75 + }, + "3": { + "txs": 18, + "pct": 1.56 + }, + "4+": { + "txs": 14, + "pct": 1.21 + } + }, + "opaque": 0, + "undecoded": 6, + "failed": 84, + "crossCheckAgainstReceipts": { + "compared": 1153, + "equal": 1150, + "calldataLower": 3, + "calldataHigher": 0 + }, + "mismatchSamples": [ + { + "hash": "0x1a0f9590d96739def7529481e2473fee20aeea31641e67d86ae008624b34db35", + "calldata": 1, + "events": 2 + }, + { + "hash": "0xa65e91f36e9dff915f2f4e8c3b53ca646868fdf4f02bd99be8981db7bcf25763", + "calldata": 1, + "events": 2 + }, + { + "hash": "0x0dd68372de80d8ca4877135f5e3cefb4723e953c30dc7d8545683fc9092520e9", + "calldata": 1, + "events": 2 + } + ] + }, + "1inch AggregationRouter v6": { + "decodedTxs": 32, + "avgPools": 1.438, + "histogram": { + "1": { + "txs": 18, + "pct": 56.25 + }, + "2": { + "txs": 14, + "pct": 43.75 }, "3": { "txs": 0, @@ -224,22 +542,87 @@ "pct": 0 } }, - "opaque": 0, - "undecoded": 0, - "failed": 3 + "opaque": 443, + "undecoded": 291, + "failed": 77, + "crossCheckAgainstReceipts": { + "compared": 32, + "equal": 32, + "calldataLower": 0, + "calldataHigher": 0 + }, + "mismatchSamples": [] }, "Uniswap v2 Router02": { - "decodedTxs": 75, - "avgPools": 1, + "decodedTxs": 658, + "avgPools": 1.011, "histogram": { "1": { - "txs": 75, - "pct": 100 + "txs": 651, + "pct": 98.94 }, "2": { + "txs": 7, + "pct": 1.06 + }, + "3": { "txs": 0, "pct": 0 }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 0, + "undecoded": 26, + "failed": 19, + "crossCheckAgainstReceipts": { + "compared": 658, + "equal": 650, + "calldataLower": 8, + "calldataHigher": 0 + }, + "mismatchSamples": [ + { + "hash": "0x10d260c15faef3956f49a27deafcc922aa6571239a3bce2bb303ae30617f21cc", + "calldata": 1, + "events": 2 + }, + { + "hash": "0x7e9460abd09a72a555b5263f6c9bcbfba452ac567a33fd7a42efd5b8130f133f", + "calldata": 1, + "events": 2 + }, + { + "hash": "0xdac01c00222fcc79c0655a297c1787b452557f159fcd804e1ff8c470983a00eb", + "calldata": 1, + "events": 2 + }, + { + "hash": "0x0ebb14652926f33c82cc43d8d209647ca77ab098afc6d4cd98d412c67d1e0490", + "calldata": 1, + "events": 2 + }, + { + "hash": "0xef721bef53ae97252361b1e7abb4d54f166ad46fe9cf76152923890f62874226", + "calldata": 1, + "events": 2 + } + ] + }, + "SwapRouter (v3 classic)": { + "decodedTxs": 281, + "avgPools": 1.036, + "histogram": { + "1": { + "txs": 271, + "pct": 96.44 + }, + "2": { + "txs": 10, + "pct": 3.56 + }, "3": { "txs": 0, "pct": 0 @@ -250,15 +633,22 @@ } }, "opaque": 0, - "undecoded": 5, - "failed": 1 + "undecoded": 0, + "failed": 8, + "crossCheckAgainstReceipts": { + "compared": 281, + "equal": 281, + "calldataLower": 0, + "calldataHigher": 0 + }, + "mismatchSamples": [] }, "Universal Router (v1.2, 2023)": { - "decodedTxs": 20, + "decodedTxs": 70, "avgPools": 1, "histogram": { "1": { - "txs": 20, + "txs": 70, "pct": 100 }, "2": { @@ -276,19 +666,26 @@ }, "opaque": 0, "undecoded": 0, - "failed": 0 + "failed": 1, + "crossCheckAgainstReceipts": { + "compared": 70, + "equal": 70, + "calldataLower": 0, + "calldataHigher": 0 + }, + "mismatchSamples": [] }, - "SwapRouter (v3 classic)": { - "decodedTxs": 54, - "avgPools": 1.056, + "Ekubo Yul router": { + "decodedTxs": 0, + "avgPools": null, "histogram": { "1": { - "txs": 51, - "pct": 94.44 + "txs": 0, + "pct": 0 }, "2": { - "txs": 3, - "pct": 5.56 + "txs": 0, + "pct": 0 }, "3": { "txs": 0, @@ -299,9 +696,16 @@ "pct": 0 } }, - "opaque": 0, + "opaque": 1, "undecoded": 0, - "failed": 2 + "failed": 0, + "crossCheckAgainstReceipts": { + "compared": 0, + "equal": 0, + "calldataLower": 0, + "calldataHigher": 0 + }, + "mismatchSamples": [] } } } diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index fb4c860..998036f 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -51,25 +51,28 @@ than a v4 block and 41% more than a v3 block. How much this matters chain-wide depends on the average number of pools a swap touches and on how much block space swaps take. -**Pools per swap, measured.** Over 200 consecutive mainnet blocks (25997689 to -25997888, September 17, 2026, 14:04 to 14:44 UTC), every successful transaction -that emitted at least one pool-level swap event (Uniswap v2 and its forks, v3, -v4, Curve) was counted by the number of such events it contains: +**Pools per swap, measured.** Over 1,000 consecutive mainnet blocks (25997046 +to 25998045, September 17, 2026, 11:55 to 15:16 UTC), every successful +transaction that emitted at least one pool-level swap event was counted by the +number of such events it contains. The event set covers Uniswap v2 and its +forks, v3, v4, PancakeSwap v3, Curve, Balancer v2 and v3, Maverick v1 and v2, +Fluid, DODO, Bancor (v2.1, v3, Carbon) and Ekubo itself: | Pools touched in the transaction | Share of swap transactions | | -------------------------------- | -------------------------- | -| 1 | 71.5% | -| 2 | 15.6% | -| 3 | 6.2% | -| 4 or more | 6.6% | - -That is 5,341 swap transactions at an average of **1.66 pools per swap**. The -subset sent directly to the Uniswap and 1inch routers (470 transactions, hop -counts decoded from calldata and matched against their events in every case) -averages only 1.11 pools, so most multi-pool weight comes from aggregators, MEV -and other contracts rather than from retail router calls. Failed transactions -emit no logs and are excluded by construction. Method, tables and script are in -`benchmarks/`. +| 1 | 72.1% | +| 2 | 14.8% | +| 3 | 6.3% | +| 4 or more | 6.9% | + +That is 32,417 swap transactions at an average of **1.68 pools per swap**, +confirming the 1.66 measured earlier over a 200-block window with only the +Uniswap and Curve families. The subset sent directly to the Uniswap and 1inch +routers (4,432 transactions, hop counts decoded from calldata and matched +against their events in all but 25 cases) averages 1.28 pools, so most +multi-pool weight comes from aggregators, MEV and other contracts rather than +from retail router calls. Failed transactions emit no logs and are excluded by +construction. Method, tables and script are in `benchmarks/`. A swap's transaction gas at an average of _p_ pools is the one-pool lab route plus (_p_ − 1) times the marginal per extra pool, plus 21,000. The measured @@ -78,8 +81,8 @@ kept as context: | Average pools per swap | Ekubo | Uniswap v4 | Uniswap v3 (net) | Ekubo saving vs v4 / v3 | | ---------------------- | ----------- | ---------- | ---------------- | ----------------------- | -| **1.66 (measured)** | **131,958** | 150,974 | 153,652 | **12.6% / 14.1%** | -| 1.11 (routers only) | 118,504 | 132,022 | 121,324 | 10.2% / 2.3% | +| **1.68 (measured)** | **132,475** | 151,703 | 154,894 | **12.7% / 14.5%** | +| 1.28 (routers only) | 122,822 | 138,104 | 131,699 | 11.1% / 6.7% | | 1.0 | 115,904 | 128,360 | 115,078 | 9.7% / −0.7% | | 1.5 | 128,119 | 145,566 | 144,426 | 12.0% / 11.3% | | 2.0 | 140,333 | 162,771 | 173,774 | 13.8% / 19.2% | @@ -90,8 +93,8 @@ router contracts accounted for about 4.2% of that burn, a lower bound because router-address attribution misses swaps routed through aggregators, MEV bundles, and direct pool calls. Ultrasound's coarser "defi" category, which includes lending and everything else, was 19.4% over the same window and brackets it from -above. In the 200-block window above, the transactions that emitted a pool swap -event used 30.3% of all gas, which confirms the router figure is a floor. A +above. In the 1,000-block window above, the transactions that emitted a pool +swap event used 32.7% of all gas, which confirms the router figure is a floor. A third, higher share of 70% is included below because it was stipulated for this page as an "all AMM trading" upside case; it is an assumption handed to the analysis, not a measurement. @@ -102,16 +105,16 @@ that slice to Ekubo would free the share times the saving. Each cell is the range spanned by the saving against v3 and the saving against v4 (the two savings are in the column header, v3 first): -| Block-space share given to swaps | Fork single hop (v3 11.7%, v4 14.2%) | Measured 1.66 pools (v3 14.1%, v4 12.6%) | 2.0 pools (v3 19.2%, v4 13.8%) | +| Block-space share given to swaps | Fork single hop (v3 11.7%, v4 14.2%) | Measured 1.68 pools (v3 14.5%, v4 12.7%) | 2.0 pools (v3 19.2%, v4 13.8%) | | ------------------------------------------------- | ------------------------------------ | ---------------------------------------- | ------------------------------ | | 4.2%, measured DEX-router burn, lower bound | 0.5–0.6% | 0.5–0.6% | 0.6–0.8% | -| 19.4%, "defi" category bracket | 2.3–2.8% | 2.4–2.7% | 2.7–3.7% | -| 70%, stipulated AMM-trading upside (not measured) | 8.2–10.0% | 8.8–9.9% | 9.7–13.5% | +| 19.4%, "defi" category bracket | 2.3–2.8% | 2.5–2.8% | 2.7–3.7% | +| 70%, stipulated AMM-trading upside (not measured) | 8.2–10.0% | 8.9–10.1% | 9.7–13.5% | At the measured blend and the measured lower bound, Ekubo frees about half a percent of block gas chain-wide; at the stipulated 70% share it would be -roughly nine percent. Induced demand would fill freed space. Read these as an -order of magnitude, not a forecast. +roughly nine to ten percent. Induced demand would fill freed space. Read these +as an order of magnitude, not a forecast. ## Why it is cheaper @@ -151,9 +154,12 @@ v4 hook or Ekubo extension changes every number here. measurement. Both swap directions covered. - Minimal routers on all sides, which favors Uniswap: production routers cost more than the lab numbers. -- Pools per swap: 200 mainnet blocks read through a public JSON-RPC endpoint +- Pools per swap: 1,000 mainnet blocks read through a public JSON-RPC endpoint (block receipts for the event count, full blocks for the router calldata cross-check). Uniswap v2 and all its forks share one event signature and are - counted as one family. + counted as one family; Ekubo swaps are counted from Core's topic-less swap + log. Not counted: order-book and RFQ fills (CoW, UniswapX, 1inch and Kyber + limit orders, 0x RFQ) unless they land in a pool, the legacy Ekubo v2 core, + and long-tail AMMs outside the listed families. - Full harness sources, raw snapshots, reference calldata, flamegraphs, pool identifiers, and reproduction commands: `benchmarks/` in the docs repository. From 1b0fed64f8668733b19931be6e762287674007a4 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 14:47:39 -0400 Subject: [PATCH 11/17] Measure Ekubo vs Uniswap swap gas on Base and Arbitrum with L1 data component Add fork harnesses for Base (block 51439900) and Arbitrum One (block 506178800): 100 USDC to WETH through the deployed Yul router on a fresh Ekubo pool mirroring the deepest v3 pool, SwapRouter02/SwapRouter, and the v4 PoolManager with a minimal locker. Record execution gas, exact calldata and the L1 data charge from each chain's fee oracle (GasPriceOracle on Base, NodeInterface/ArbGasInfo on Arbitrum). Add a page section and the full methodology, deployments, endpoints and formulas to benchmarks/README.md. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 233 ++++++++++++++ benchmarks/fork-l2/ForkL2Ekubo.t.sol | 286 +++++++++++++++++ .../fork-l2/ForkL2EkuboArbitrumTest.json | 14 + benchmarks/fork-l2/ForkL2EkuboBaseTest.json | 14 + benchmarks/fork-l2/ForkL2V3.t.sol | 244 +++++++++++++++ .../fork-l2/ForkL2V3ArbitrumRouter02Test.json | 15 + benchmarks/fork-l2/ForkL2V3ArbitrumTest.json | 15 + benchmarks/fork-l2/ForkL2V3BaseTest.json | 15 + benchmarks/fork-l2/ForkL2V4.t.sol | 237 ++++++++++++++ benchmarks/fork-l2/ForkL2V4ArbitrumTest.json | 15 + benchmarks/fork-l2/ForkL2V4BaseTest.json | 15 + .../calldata/ekubo-yul-arbitrum-100.hex | 1 + .../calldata/ekubo-yul-arbitrum-1000.hex | 1 + .../calldata/ekubo-yul-arbitrum-50.hex | 1 + .../fork-l2/calldata/ekubo-yul-base-100.hex | 1 + .../fork-l2/calldata/ekubo-yul-base-1000.hex | 1 + .../fork-l2/calldata/ekubo-yul-base-50.hex | 1 + .../calldata/v3-SwapRouter-arbitrum-100.hex | 1 + .../calldata/v3-SwapRouter-arbitrum-1000.hex | 1 + .../calldata/v3-SwapRouter-arbitrum-50.hex | 1 + .../calldata/v3-SwapRouter02-arbitrum-100.hex | 1 + .../v3-SwapRouter02-arbitrum-1000.hex | 1 + .../calldata/v3-SwapRouter02-arbitrum-50.hex | 1 + .../calldata/v3-SwapRouter02-base-100.hex | 1 + .../calldata/v3-SwapRouter02-base-1000.hex | 1 + .../calldata/v3-SwapRouter02-base-50.hex | 1 + .../v4-minimal-locker-arbitrum-100.hex | 1 + .../v4-minimal-locker-arbitrum-1000.hex | 1 + .../v4-minimal-locker-arbitrum-50.hex | 1 + .../calldata/v4-minimal-locker-base-100.hex | 1 + .../calldata/v4-minimal-locker-base-1000.hex | 1 + .../calldata/v4-minimal-locker-base-50.hex | 1 + benchmarks/fork-l2/encode-l2-routes.mjs | 59 ++++ benchmarks/fork-l2/foundry.toml | 12 + benchmarks/fork-l2/l1-data-cost.json | 295 ++++++++++++++++++ benchmarks/fork-l2/l1-data-cost.mjs | 274 ++++++++++++++++ benchmarks/fork-l2/quote-live-base.mjs | 24 ++ benchmarks/fork-l2/remappings.txt | 6 + src/content/docs/concepts/gas-efficiency.md | 41 +++ 39 files changed, 1835 insertions(+) create mode 100644 benchmarks/fork-l2/ForkL2Ekubo.t.sol create mode 100644 benchmarks/fork-l2/ForkL2EkuboArbitrumTest.json create mode 100644 benchmarks/fork-l2/ForkL2EkuboBaseTest.json create mode 100644 benchmarks/fork-l2/ForkL2V3.t.sol create mode 100644 benchmarks/fork-l2/ForkL2V3ArbitrumRouter02Test.json create mode 100644 benchmarks/fork-l2/ForkL2V3ArbitrumTest.json create mode 100644 benchmarks/fork-l2/ForkL2V3BaseTest.json create mode 100644 benchmarks/fork-l2/ForkL2V4.t.sol create mode 100644 benchmarks/fork-l2/ForkL2V4ArbitrumTest.json create mode 100644 benchmarks/fork-l2/ForkL2V4BaseTest.json create mode 100644 benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-100.hex create mode 100644 benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-1000.hex create mode 100644 benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-50.hex create mode 100644 benchmarks/fork-l2/calldata/ekubo-yul-base-100.hex create mode 100644 benchmarks/fork-l2/calldata/ekubo-yul-base-1000.hex create mode 100644 benchmarks/fork-l2/calldata/ekubo-yul-base-50.hex create mode 100644 benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-100.hex create mode 100644 benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-1000.hex create mode 100644 benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-50.hex create mode 100644 benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-100.hex create mode 100644 benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-1000.hex create mode 100644 benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-50.hex create mode 100644 benchmarks/fork-l2/calldata/v3-SwapRouter02-base-100.hex create mode 100644 benchmarks/fork-l2/calldata/v3-SwapRouter02-base-1000.hex create mode 100644 benchmarks/fork-l2/calldata/v3-SwapRouter02-base-50.hex create mode 100644 benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-100.hex create mode 100644 benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-1000.hex create mode 100644 benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-50.hex create mode 100644 benchmarks/fork-l2/calldata/v4-minimal-locker-base-100.hex create mode 100644 benchmarks/fork-l2/calldata/v4-minimal-locker-base-1000.hex create mode 100644 benchmarks/fork-l2/calldata/v4-minimal-locker-base-50.hex create mode 100644 benchmarks/fork-l2/encode-l2-routes.mjs create mode 100644 benchmarks/fork-l2/foundry.toml create mode 100644 benchmarks/fork-l2/l1-data-cost.json create mode 100644 benchmarks/fork-l2/l1-data-cost.mjs create mode 100644 benchmarks/fork-l2/quote-live-base.mjs create mode 100644 benchmarks/fork-l2/remappings.txt diff --git a/benchmarks/README.md b/benchmarks/README.md index a5592fb..a68e502 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -19,6 +19,11 @@ measures code and architecture rather than compiler tuning. `ForkMintEkubo.t.sol`, `ForkMintV3.t.sol`, `ForkMintV4.t.sol` for mints through the production position managers; `ForkProbe.t.sol` for the pool identifiers; `calldata-*.hex` reference calldata, `encode-yul-route.mjs`) +- `fork-l2/` — Base and Arbitrum One fork swaps (`ForkL2Ekubo.t.sol`, `ForkL2V3.t.sol`, + `ForkL2V4.t.sol`, one snapshot JSON per test contract, `calldata/*.hex` with the exact + bytes of every measured call, `encode-l2-routes.mjs` for the SDK routes, + `l1-data-cost.mjs` and its raw output `l1-data-cost.json` for the L1 data component, + `quote-live-base.mjs`); see "L2 fork methodology" below `*.json` files next to each harness are the raw `snapshots/` output of a full run of that test contract. `remappings.txt` files contain the original absolute paths and will @@ -480,6 +485,234 @@ found among the window's gas consumers; what remains uncounted is listed under " counted" above, so a transaction whose only swap is an order-book fill, a legacy Ekubo v2 swap, or a long-tail AMM is not counted. +## L2 fork methodology (Base, Arbitrum One) + +Same discipline as the mainnet fork (pinned block, one identical unmeasured warm-up swap, +every measured call isolated, snapshot = execution gas of the measured call net of refunds, +excluding the 21,000 base and calldata), on two L2s whose fee structure differs from +mainnet: cheap L2 execution plus a separate charge for the L1 data the sequencer posts. +Harness, snapshots, exact calldata and the fee-oracle readings are in `fork-l2/`. + +### Deployment check + +Chain IDs and code presence were read from the public RPCs before anything was pinned +(`cast chain-id`, `cast code`, September 17, 2026). Both chains answer `8453` and `42161` +respectively. Every address below has code on both chains at the pinned blocks; nothing +listed here was taken from memory. + +| Contract | Base (8453) | Arbitrum One (42161) | Source | +| -------------------------------------------------------- | -------------------------------------------- | -------------------------------------------- | --------------------------------------- | +| Ekubo Core | `0x00000000000014aA86C5d3c41765bb24e11bd701` | same | docs `reference/contracts/evm-v3` | +| Ekubo Yul router (production, v0.7.1) | `0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748` | same | `yul-router/broadcast/deployments.json` | +| Ekubo Positions (original generation, canonical on both) | `0x02D9876A21AF7545f8632C3af76eC90b5ad4b66D` | same | docs `reference/contracts/evm-v3` | +| Ekubo Positions (192-bit generation, also deployed) | `0xA2971E0C37cFdb13aE8440A0C94Ef1A1af39e326` | same | docs `reference/contracts/evm-v3` | +| Ekubo CoreDataFetcher / QuoteDataFetcher | `0xF68F25CA…` / `0x5a3F0F1d…` | same | docs `reference/contracts/evm-v3` | +| Uniswap v3 factory | `0x33128a8fC17869897dcE68Ed026d694621f6FDfD` | `0x1F98431c8aD98523631AE4a59f267346ea31F984` | docs.uniswap.org v3 deployments | +| Uniswap v3 SwapRouter (classic) | not listed by Uniswap for Base | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | docs.uniswap.org v3 deployments | +| Uniswap v3 SwapRouter02 | `0x2626664c2603336E57B271c5C0b26F421741e481` | `0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45` | docs.uniswap.org v3 deployments | +| Uniswap v4 PoolManager | `0x498581fF718922c3f8e6A244956aF099B2652b2b` | `0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32` | docs.uniswap.org v4 deployments | +| Uniswap v4 StateView | `0xA3c0c9b65baD0b08107Aa264b0f3dB444b867A71` | `0x76Fd297e2D437cd7f76d50F01AfE6160f86e9990` | docs.uniswap.org v4 deployments | +| WETH | `0x4200000000000000000000000000000000000006` | `0x82aF49447D8a07e3bd95BD0d56f35241523fBab1` | `symbol()`/`decimals()` read on-chain | +| USDC (native) | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | `0xaf88d065e77c8cC2239327C5EDb3A432268e5831` | `symbol()`/`decimals()` read on-chain | +| USDT (Base) / USD₮0 (Arbitrum) | `0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2` | `0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9` | `symbol()`/`decimals()` read on-chain | + +The mainnet Ekubo `Router` (Solidity) has no listed L2 address in the docs (it is +deployment-specific), so the Ekubo leg is the Yul router only, which is also what the +mainnet headline uses. The address `0xE592427A…` on Base holds 4,221 bytes of unrelated +code and is not the Uniswap SwapRouter; Base's v3 leg therefore goes through SwapRouter02 +(`exactInputSingle` without the `deadline` field, 228 bytes of calldata against 260). On +Arbitrum the classic SwapRouter is used for parity with mainnet and SwapRouter02 is +recorded as well (`ForkL2V3ArbitrumRouter02Test`). + +### Endpoints and blocks + +`https://base-rpc.publicnode.com` and `https://arbitrum-rpc.publicnode.com` served the chain +IDs, heads and code checks but refuse any historical state with `Archive requests require +a personal token` (Base already about 1,000 blocks behind head, Arbitrum at 100 blocks), so +the forks, the pool probes and the fee-oracle reads ran against +`https://base-mainnet.public.blastapi.io` and `https://arbitrum-one.public.blastapi.io`. +The L1-fee readings were cross-checked against `https://mainnet.base.org` and +`https://arb1.arbitrum.io/rpc` (identical to the wei, `l1-data-cost.json`). + +| Chain | Block | Hash | Timestamp | L2 base fee (wei) | Note | +| ------------ | ----------- | ------------- | --------------------------- | ----------------- | ---------------------------------------------------------------------------- | +| Base | `51439900` | `0x54e5275d…` | 1789669147 (2026-09-17 UTC) | 5,000,000 | L1 origin block 25998943; Jovian active (`isJovian()` true) | +| Arbitrum One | `506178800` | `0x24768c22…` | 1789669181 (2026-09-17 UTC) | 20,074,000 | L1 block 25998959; `block.number` on the fork is that L1 number (Nitro rule) | + +Because Foundry reports Arbitrum's `block.number` as the L1 block, the harness pins the +block by timestamp on both chains. Foundry 1.8.3 also selects its `optimism` network family +for chain 8453, and that family disables `CLZ` (EIP-7939) even at `evm_version = "osaka"`: +every Ekubo swap reverted with `NotActivated` until the Base run was given +`--network ethereum`. The opcode is live on the real chain (a Yul-router `quote` against the +live Base ETH/USDC pool executes through `eth_call`, `quote-live-base.mjs`), so the flag +restores the chain's actual EVM; it changes no opcode gas. Uniswap legs measure the same with +or without it. + +### Pair choice: WETH/USDC, fresh Ekubo pool + +The rule was USDC/USDT on a live Ekubo pool, else WETH/USDC with real Uniswap pools and a +freshly capitalized Ekubo pool. Neither chain has a live Ekubo pool that can absorb the +swap at the pinned blocks: + +- Ekubo's own quoter (`prod-api-quoter.ekubo.org`) returns 107 USDC for 1000 USDT on Base + (two USDC/USDT pools, both far off-price and near-empty) and `insufficient_liquidity` + for every other pair tried on Base and for every pair on Arbitrum, including 10 USDC. +- The Ekubo API's `poolKeys` and `overview/tvl` for the two chains show total Core balances + of about 0.002 ETH, 120 USDC and 120 USDT on Base and 0.002 ETH, 6,634 USDC (plus ARB) + on Arbitrum. The one populated Base ETH/USDC pool (fee 0.05%, spacing 1000, liquidity + 1,498,307,833,298) would move tens of Uniswap ticks on a 100 USDC swap. + +So the Ekubo leg on each chain is a pool created on the fork in the test's setup: +concentrated, no extension, the deepest live v3 WETH/USDC pool's fee tier and 100x its +tick spacing, initialized at the fine tick matching that pool's `sqrtPriceX96`, and +capitalized through a bare locker (`BareMintRouter`, never measured) with **exactly that +v3 pool's active liquidity** in a single position spanning ±60,000 fine ticks (about ±6% +in price). Both tokens are the real WETH and USDC contracts of the chain; Core already held +a nonzero balance of each before setup (2 wei of WETH and the USDC above), and holds the +minted amounts afterwards, so no measured swap pays a zero-to-nonzero balance write. The +Yul router calldata is generated by the SDK (`encode-l2-routes.mjs`) with the same config +word the test asserts against `createConcentratedPoolConfig`; the bytes the test sent are +written to `calldata/ekubo-yul-*.hex` and equal the SDK output (case-insensitive hex). + +| Chain | Leg | Pool | Fee | Spacing | Liquidity at block | Tick at block | +| -------- | ------------- | ------------------------------------------------------------------------------------- | ----- | ------- | -------------------------- | ------------- | +| Base | Ekubo (fresh) | id `keccak(WETH, USDC, 0x…c49ba5e353f7ce80001770)`, fee `55340232221128654 / 2^64` | 0.3% | 6000 | 37,026,042,371,903,555,946 | -19821136 | +| Base | v3 | `0x6c561B446416E1A00E8E93E221854d6eA4171372` (deepest of 100/500/3000/10000) | 0.3% | 60 | 37,026,042,371,903,555,946 | -198222 | +| Base | v4 | id `0x1d8c55f3…5a718f54`, hooks `address(0)` (deepest hookless of 100/500/3000/10000) | 0.3% | 60 | 31,809,972,076,514,118 | -198217 | +| Arbitrum | Ekubo (fresh) | id `keccak(WETH, USDC, 0x…20c49ba5e353f7800003e8)`, fee `9223372036854775 / 2^64` | 0.05% | 1000 | 3,696,581,650,313,474,097 | -19822971 | +| Arbitrum | v3 | `0xC6962004f452bE9203591991D15f6b388e09E8D0` (deepest of 100/500/3000/10000) | 0.05% | 10 | 3,696,581,650,313,474,097 | -198240 | +| Arbitrum | v4 | id `0xfc7b3ad1…57028653`, hooks `address(0)` (deepest hookless of 100/500/3000/10000) | 0.05% | 10 | 52,128,164,974,525,471 | -198242 | + +Other v3/v4 tiers probed at the pinned blocks (`getPool` + `liquidity()`, StateView +`getLiquidity`): Base v3 100/500/10000 = 7.68e16 / 1.48e18 / 6.15e16, v4 WETH/USDC +100/500/10000 = 0 / 3.72e14 / 0; Arbitrum v3 100/500/3000/10000 = 5.63e16 / 3.70e18 / +4.81e17 / 6.28e15, v4 WETH/USDC 100/3000/10000 = 5e9 / 1.64e11 / 0. The native +ETH/USDC v4 pools are deeper on both chains (8.14e17 at 0.3% on Base, 4.08e17 at 0.05% on +Arbitrum) but are a different pair (native ETH settlement) and were not used. The v4 pools +on both chains carry a nonzero protocol fee (`protocolFee` 2048500 on Base, 512125 on +Arbitrum) which the swap pays inside `PoolManager.swap`; that is production state, not a +harness choice. + +### Sizes and tick movement + +Swaps are USDC exact-input to WETH at 50, 100 and 1000 USDC. The measured swap's tick +movement is read from pool state before and after and cross-checked against the pool's +`Swap` event (v3 `slot0`/`Swap`, v4 StateView/`Swap`, Ekubo Core's 116-byte `log0` record). +At 100 USDC no leg moves more than one tick and no leg crosses an initialized tick, so 100 +USDC is the size the docs page quotes; gas is identical at 50 USDC. At 1000 USDC the +shallow v4 WETH/USDC pools move 13 ticks on Base (no initialized tick crossed, gas +unchanged at 126,991) and 8 ticks on Arbitrum, crossing an initialized spacing-10 tick: +156,321 instead of 135,006. That row is kept in the snapshot as a reminder of why the +size was capped. Ekubo's fresh pool moves 0–1 fine ticks (1/100 of a Uniswap tick) at 100 +USDC and 11 at 1000. + +Outputs (wei of WETH for 100 USDC): Base Ekubo 40,448,346,569,108,551, v3 +40,448,339,546,831,424, v4 40,400,300,715,083,769 (spread 0.12%, the v4 pool is 0.03% off +the v3 price); Arbitrum Ekubo 40,624,188,992,664,839, v3 40,624,162,908,750,166 (both +routers), v4 40,620,850,698,508,208 (spread 0.008%). The Ekubo and v3 legs agree to 7 +significant figures because the fresh pool mirrors the v3 pool's price, fee and liquidity. + +### Results + +Execution gas is the isolated snapshot. Calldata bytes are the exact bytes of the measured +call (`calldata/*.hex`); "L2 calldata gas" is the 16/4 per byte intrinsic charge every L2 +still levies inside its own gas (EIP-7623's floor never binds here). "L1 data" is what the +chain's own fee logic charges for posting the transaction, read at the pinned block (see +below), and its gas-equivalent is that wei amount divided by the block's L2 base fee. + +| Chain | Leg (100 USDC) | Execution | Calldata bytes (zero / nonzero) | L2 calldata gas | L1 data (wei) | L1 data, L2-gas equivalent | L2 gas incl. 21,000 | Total gas-equivalent | +| -------- | ------------------------------ | --------- | ------------------------------- | --------------- | -------------- | -------------------------- | ------------------- | -------------------- | +| Base | Ekubo Yul router | 105,084 | 184 (99 / 85) | 1,756 | 1,655,831,768 | 331 | 127,840 | 128,171 | +| Base | v3 SwapRouter02 | 121,803 | 228 (177 / 51) | 1,524 | 1,596,015,122 | 319 | 144,327 | 144,646 | +| Base | v4 PoolManager, minimal locker | 126,959 | 292 (192 / 100) | 2,368 | 1,835,281,704 | 367 | 150,327 | 150,694 | +| Arbitrum | Ekubo Yul router | 113,121 | 184 (63 / 121) | 2,188 | 11,220,000,000 | 561 | 136,309 | 136,870 | +| Arbitrum | v3 SwapRouter (classic) | 130,896 | 260 (187 / 73) | 1,916 | 12,060,000,000 | 603 | 153,812 | 154,415 | +| Arbitrum | v3 SwapRouter02 | 130,705 | 228 (159 / 69) | 1,740 | 11,400,000,000 | 570 | 153,445 | 154,015 | +| Arbitrum | v4 PoolManager, minimal locker | 135,006 | 292 (174 / 118) | 2,584 | 12,880,000,000 | 644 | 158,590 | 159,234 | + +Base additionally tracks a Jovian "DA footprint" per transaction (block-space accounting, +not a fee): `max(100, (intercept + fastlzCoef × fastlzSize) / 1e6) × daFootprintGasScalar`, +with scalar 148 at the block: Ekubo 20,424, v3 19,684, v4 22,644 gas of DA footprint. + +Ekubo against v3 / v4 at 100 USDC: execution 13.7% / 17.2% lower on Base and 13.6% / 16.2% +on Arbitrum (mainnet fork, 1000 USDT: 14.0% / 16.4%); whole transaction 11.4% / 14.9% on +Base and 11.4% / 14.0% on Arbitrum (mainnet: 11.7% / 14.2%). The L1 data component is +0.22–0.41% of every leg's total at these blocks, and Ekubo's is not the smallest on Base: +Fjord's FastLZ estimator prices the dense 184-byte route at 138.9 estimated compressed +bytes against 133.9 for the zero-padded 228-byte SwapRouter02 call. The same effect shows +in the L2 calldata gas column (1,756 against 1,524), because 16/4 pricing also rewards +zero bytes. On Arbitrum (Brotli) the Yul route is the cheapest to post, by 9–42 gas. + +Per-leg execution gas differs from the mainnet figures because the pair and the token +contracts differ (WETH/USDC instead of USDT/USDC; Arbitrum's WETH and both chains' USDC +are proxies), and the Ekubo pool is fresh rather than live; the ratios between legs are +what carries over. + +### Fee predeploys and formulas + +Base (OP Stack; `GasPriceOracle` `0x420000000000000000000000000000000000000F` version +1.6.0, `isFjord`, `isIsthmus`, `isJovian` all true at the block; `L1Block` +`0x4200000000000000000000000000000000000015`). Values at block 51439900: + +| Parameter | Value | Read from | +| ---------------------- | ----------- | -------------------------------- | +| `l1BaseFee` | 118,337,311 | oracle / `L1Block.basefee()` | +| `blobBaseFee` | 7,219,357 | oracle / `L1Block.blobBaseFee()` | +| `baseFeeScalar` | 2,269 | oracle / `L1Block` | +| `blobBaseFeeScalar` | 1,055,762 | oracle / `L1Block` | +| `operatorFeeScalar` | 0 | `L1Block` | +| `operatorFeeConstant` | 0 | `L1Block` | +| `daFootprintGasScalar` | 148 | `L1Block` | + +Fjord L1 cost (`specs/protocol/fjord/exec-engine.md`, implemented in `GasPriceOracle`): +`l1FeeScaled = baseFeeScalar × l1BaseFee × 16 + blobBaseFeeScalar × blobBaseFee`; +`estimatedSizeScaled = max(100 × 1e6, −42,585,600 + 836,500 × fastlzSize)` where +`fastlzSize` is the FastLZ-compressed length of the signed transaction (the oracle +compresses the unsigned RLP and adds 68 bytes for the signature); `l1Fee = +estimatedSizeScaled × l1FeeScaled / 1e12`. `l1-data-cost.mjs` builds the unsigned EIP-1559 +transaction for each leg (chain id, nonce 100, priority fee 0.001 gwei, max fee 0.01 gwei, +gas limit 200,000, value 0, the leg's `to` and exact calldata; 228 / 273 / 338 bytes for +the three Base legs) and calls `getL1Fee(bytes)` and `getL1GasUsed(bytes)` on the oracle at +the pinned block; the estimated compressed size in the results is the oracle's own answer +divided back through the formula. The operator fee is zero at the block, so it adds +nothing. + +Arbitrum One (Nitro; `ArbGasInfo` precompile `0x6C`, `NodeInterface` virtual contract +`0xC8`). Values at block 506178800: `getPricesInWei()` = (perL2Tx 4,317,615,680; per L1 +calldata byte 30,840,112; per storage allocation 401,480,000,000; per ArbGas base +20,000,000; congestion 74,000; total 20,074,000), `getL1BaseFeeEstimate()` = 1,927,507 +wei per L1 gas (so 30,840,112 = 16 × 1,927,507). The chain's poster charge +(`nitro/arbos/l1pricing/l1pricing.go`) is `pricePerUnit × units` with `units = 16 × +brotli-compressed bytes of the signed transaction`; in estimation the units are padded by +256 and 1%. `l1-data-cost.mjs` calls `NodeInterface.gasEstimateL1Component(to, false, +data)` with `from` = the test contract at the pinned block; it returns the L1 component +already denominated in L2 gas (`gasEstimateForL1`, the table's gas-equivalent), the L2 base +fee it used (20,000,000, the block's minimum, against 20,074,000 in the header), and the L1 +base fee estimate. The wei column is `gasEstimateForL1 × 20,000,000`. The precompiles are +not executable on the Foundry fork, so both chains' fee readings come from `eth_call` +against the archive endpoints, not from inside the tests. + +### Reproducing the L2 numbers + +```sh +# from a checkout with lib/ekubo (evm-contracts 1f5be49), lib/v4-core (46c6834, submodules), +# lib/forge-std (77041d2), lib/solady (65e87c7); foundry.toml and remappings.txt as in fork-l2/ +forge test --fork-url https://base-mainnet.public.blastapi.io --fork-block-number 51439900 \ + --network ethereum --fork-retries 10 --fork-retry-backoff 2000 \ + --match-contract 'ForkL2EkuboBaseTest|ForkL2V3BaseTest|ForkL2V4BaseTest' -vv + +forge test --fork-url https://arbitrum-one.public.blastapi.io --fork-block-number 506178800 \ + --fork-retries 10 --fork-retry-backoff 2000 \ + --match-contract 'ForkL2EkuboArbitrumTest|ForkL2V3ArbitrumTest|ForkL2V3ArbitrumRouter02Test|ForkL2V4ArbitrumTest' -vv + +bun run encode-l2-routes.mjs # regenerates the SDK routes (yul-router SDK checkout path inside) +bun run l1-data-cost.mjs > l1-data-cost.json # fee-oracle readings (viem from the SDK's node_modules) +``` + +Foundry 1.8.3, `evm_version = "osaka"`, `via_ir`, `optimizer_runs = 1000000`, solc +auto-detected per file (0.8.26 Uniswap drivers, 0.8.33 Ekubo sources). `isolate = true` is +set per test. + ## Dependency pins - Ekubo `evm-contracts` worktree at commit `1f5be49` (plus the harness test files diff --git a/benchmarks/fork-l2/ForkL2Ekubo.t.sol b/benchmarks/fork-l2/ForkL2Ekubo.t.sol new file mode 100644 index 0000000..d388830 --- /dev/null +++ b/benchmarks/fork-l2/ForkL2Ekubo.t.sol @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.33; + +import {Test, Vm} from "forge-std/Test.sol"; +import {ICore} from "ekubo/src/interfaces/ICore.sol"; +import {PoolKey, toPoolId} from "ekubo/src/types/poolKey.sol"; +import {PoolId} from "ekubo/src/types/poolId.sol"; +import {PoolConfig, createConcentratedPoolConfig} from "ekubo/src/types/poolConfig.sol"; +import {PoolState} from "ekubo/src/types/poolState.sol"; +import {SqrtRatio} from "ekubo/src/types/sqrtRatio.sol"; +import {createPositionId} from "ekubo/src/types/positionId.sol"; +import {PoolBalanceUpdate} from "ekubo/src/types/poolBalanceUpdate.sol"; +import {BaseLocker} from "ekubo/src/base/BaseLocker.sol"; +import {FlashAccountantLib} from "ekubo/src/libraries/FlashAccountantLib.sol"; +import {CoreLib} from "ekubo/src/libraries/CoreLib.sol"; + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); +} + +/// @notice Bare core-level liquidity provision used only to capitalize the fresh pool in +/// setup (lock, updatePosition, settle net). Never measured. +contract BareMintRouter is BaseLocker { + using FlashAccountantLib for *; + + constructor(ICore core) BaseLocker(core) {} + + function mint(PoolKey memory key, bytes24 salt, int32 tickLower, int32 tickUpper, int128 liquidityDelta, address payer) + external + returns (PoolBalanceUpdate balanceUpdate) + { + balanceUpdate = abi.decode( + lock(abi.encode(key, salt, tickLower, tickUpper, liquidityDelta, payer, msg.sender)), (PoolBalanceUpdate) + ); + } + + function handleLockData(uint256, bytes memory data) internal override returns (bytes memory result) { + ( + PoolKey memory key, + bytes24 salt, + int32 tickLower, + int32 tickUpper, + int128 liquidityDelta, + address payer, + address recipient + ) = abi.decode(data, (PoolKey, bytes24, int32, int32, int128, address, address)); + PoolBalanceUpdate balanceUpdate = ICore(payable(address(ACCOUNTANT))).updatePosition( + key, createPositionId(salt, tickLower, tickUpper), liquidityDelta + ); + if (balanceUpdate.delta0() > 0) { + ACCOUNTANT.payFrom(payer, key.token0, uint128(balanceUpdate.delta0())); + } else if (balanceUpdate.delta0() < 0) { + ACCOUNTANT.withdraw(key.token0, recipient, uint128(-balanceUpdate.delta0())); + } + if (balanceUpdate.delta1() > 0) { + ACCOUNTANT.payFrom(payer, key.token1, uint128(balanceUpdate.delta1())); + } else if (balanceUpdate.delta1() < 0) { + ACCOUNTANT.withdraw(key.token1, recipient, uint128(-balanceUpdate.delta1())); + } + result = abi.encode(balanceUpdate); + } +} + +/// @notice L2-fork measurement of the production Ekubo Yul router on Base / Arbitrum One. +/// No live Ekubo WETH/USDC (or USDC/USDT) pool on either chain can absorb the swap +/// at the pinned block (see benchmarks/README.md), so the pool is created fresh on +/// the fork at the deepest Uniswap v3 pool's price, fee tier and (100x finer) +/// tick spacing, and capitalized with exactly that pool's active liquidity through +/// a bare locker in setup. The measured call is the real deployed Yul router with +/// SDK-generated calldata (`encode-l2-routes.mjs`), USDC exact-input to WETH, +/// warmed with one identical unmeasured swap; every call is isolated so the snapshot +/// is the execution gas of the measured swap (net of refunds, excluding the 21,000 +/// base and calldata). +abstract contract ForkL2EkuboBase is Test { + using CoreLib for ICore; + + struct Cfg { + string name; + uint256 chainId; + uint256 blockNumber; + uint256 timestamp; + address weth; + address usdc; + uint64 fee; + uint32 spacing; + int32 initTick; + int32 lower; + int32 upper; + uint128 liquidity; + bytes32 config; + } + + ICore constant CORE = ICore(payable(0x00000000000014aA86C5d3c41765bb24e11bd701)); + address constant YUL_ROUTER = 0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748; + address constant RECIPIENT = 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496; + + PoolKey internal key; + PoolId internal poolId; + BareMintRouter internal minter; + + function _cfg() internal pure virtual returns (Cfg memory); + function _route(uint256 usdc) internal pure virtual returns (bytes memory); + + function _setup() internal { + Cfg memory c = _cfg(); + assertEq(block.chainid, c.chainId, "chain id"); + // Arbitrum forks report the L1 block number as `block.number`; the timestamp pins the block on both chains. + assertEq(block.timestamp, c.timestamp, "pinned block timestamp"); + assertEq(address(this), RECIPIENT, "route recipient is this contract"); + assertGt(address(CORE).code.length, 0, "Core deployed"); + assertGt(YUL_ROUTER.code.length, 0, "Yul router deployed"); + + key = PoolKey({token0: c.weth, token1: c.usdc, config: createConcentratedPoolConfig(c.fee, c.spacing, address(0))}); + assertEq(PoolConfig.unwrap(key.config), c.config, "config word equals the one encoded into the SDK route"); + poolId = toPoolId(key); + assertFalse(CORE.poolState(poolId).isInitialized(), "pool does not exist yet on this chain"); + CORE.initializePool(key, c.initTick); + + minter = new BareMintRouter(CORE); + deal(c.weth, address(this), 100_000 ether); + deal(c.usdc, address(this), 1_000_000_000_000_000); // 1e9 USDC + IERC20Like(c.weth).approve(address(minter), type(uint256).max); + IERC20Like(c.usdc).approve(address(minter), type(uint256).max); + PoolBalanceUpdate u = minter.mint(key, bytes24(0), c.lower, c.upper, int128(c.liquidity), address(this)); + emit log_named_int("mint amount0 (WETH wei)", u.delta0()); + emit log_named_int("mint amount1 (USDC units)", u.delta1()); + (, int32 tick, uint128 liq) = CORE.poolState(poolId).parse(); + assertEq(liq, c.liquidity, "active liquidity equals the v3 pool's"); + assertEq(tick, c.initTick, "pool at init tick"); + assertGt(IERC20Like(c.weth).balanceOf(address(CORE)), 0, "Core holds WETH"); + assertGt(IERC20Like(c.usdc).balanceOf(address(CORE)), 0, "Core holds USDC"); + + IERC20Like(c.usdc).approve(YUL_ROUTER, type(uint256).max); + assertEq(IERC20Like(c.usdc).allowance(address(this), YUL_ROUTER), type(uint256).max, "max approval"); + } + + function _swap(bytes memory route, string memory snapshotName) internal returns (uint256 out) { + Cfg memory c = _cfg(); + (, int32 tickBefore,) = CORE.poolState(poolId).parse(); + uint256 wethBefore = IERC20Like(c.weth).balanceOf(address(this)); + uint256 usdcBefore = IERC20Like(c.usdc).balanceOf(address(this)); + vm.recordLogs(); + (bool ok, bytes memory ret) = YUL_ROUTER.call(route); + if (bytes(snapshotName).length > 0) vm.snapshotGasLastCall(snapshotName); + assertTrue(ok, "swap"); + (address specifiedToken, address calculatedToken, int256 specified, int256 calculated) = + abi.decode(ret, (address, address, int256, int256)); + assertEq(specifiedToken, c.usdc); + assertEq(calculatedToken, c.weth); + assertGt(calculated, 0); + out = IERC20Like(c.weth).balanceOf(address(this)) - wethBefore; + assertEq(out, uint256(calculated), "output equals router-reported calculated amount"); + assertEq(usdcBefore - IERC20Like(c.usdc).balanceOf(address(this)), uint256(specified), "input pulled"); + (, int32 tickAfter,) = CORE.poolState(poolId).parse(); + // Core's swap record: log0 with 116 bytes (locker, poolId, balanceUpdate, stateAfter) + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool seen; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter == address(CORE) && logs[i].topics.length == 0 && logs[i].data.length == 116) { + bytes memory d = logs[i].data; + bytes32 stateAfter; + assembly ("memory-safe") { + stateAfter := mload(add(d, 116)) + } + (, int32 eventTick,) = PoolState.wrap(stateAfter).parse(); + assertEq(eventTick, tickAfter, "swap log stateAfter tick equals pool tick"); + seen = true; + } + } + assertTrue(seen, "Core swap record found"); + if (bytes(snapshotName).length > 0) { + emit log_named_int("ekubo tick before", tickBefore); + emit log_named_int("ekubo tick after", tickAfter); + int256 moved = int256(tickAfter) - int256(tickBefore); + vm.snapshotValue( + string.concat(snapshotName, " | Ekubo fine ticks moved (1/100 of a Uniswap tick)"), + uint256(moved < 0 ? -moved : moved) + ); + // no initialized tick between the position bounds: the swap must stay inside them + assertGt(tickAfter, c.lower); + assertLt(tickAfter, c.upper); + } + } + + function _run(uint256 usdc, uint256 minOut, uint256 maxOut) internal { + _setup(); + bytes memory route = _route(usdc); + _swap(route, ""); // warm-up: steady state, unmeasured + Cfg memory c = _cfg(); + assertGt(IERC20Like(c.weth).balanceOf(address(this)), 0, "recipient WETH balance nonzero before measured swap"); + string memory size = vm.toString(usdc / 1_000_000); + string memory name = string.concat("fork-l2 ", c.name, " ekubo Yul router ", size, " USDC->WETH fresh pool"); + uint256 out = _swap(route, name); + vm.snapshotValue(string.concat("fork-l2 ", c.name, " ekubo output ", size, " USDC->WETH (wei)"), out); + vm.snapshotValue(string.concat("fork-l2 ", c.name, " ekubo calldata bytes ", size, " USDC"), route.length); + vm.writeFile(string.concat("calldata/ekubo-yul-", c.name, "-", size, ".hex"), vm.toString(route)); + emit log_named_uint("ekubo measured output (WETH wei)", out); + assertGe(out, minOut); + assertLe(out, maxOut); + } + + /// forge-config: default.isolate = true + function test_fork_l2_ekubo_yulRouter_1000() public { + _run(1_000_000_000, 0.38 ether, 0.43 ether); + } + + /// forge-config: default.isolate = true + function test_fork_l2_ekubo_yulRouter_100() public { + _run(100_000_000, 0.038 ether, 0.043 ether); + } + + /// forge-config: default.isolate = true + function test_fork_l2_ekubo_yulRouter_50() public { + _run(50_000_000, 0.019 ether, 0.0215 ether); + } +} + +/// Run with: --fork-url --fork-block-number 51439900 +contract ForkL2EkuboBaseTest is ForkL2EkuboBase { + function _cfg() internal pure override returns (Cfg memory) { + return Cfg({ + name: "base", + chainId: 8453, + blockNumber: 51439900, + timestamp: 1789669147, + weth: 0x4200000000000000000000000000000000000006, + usdc: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, + fee: 55340232221128654, // floor(0.003 * 2^64): 0.3%, the deepest v3 WETH/USDC tier on Base + spacing: 6000, // v3 spacing 60 x 100 + initTick: -19821136, // floor(ln(price) / ln(1.000001)) of the v3 fee-3000 pool's sqrtPriceX96 + lower: -19884000, + upper: -19758000, + liquidity: 37026042371903555946, // v3 fee-3000 pool `liquidity()` at the pinned block + config: 0x000000000000000000000000000000000000000000c49ba5e353f7ce80001770 + }); + } + + function _route(uint256 usdc) internal pure override returns (bytes memory) { + if (usdc == 1_000_000_000) { + return + hex"0100833589fCD6eDb6E08f4c7C32D4f71b54bdA029134200000000000000000000000000000000000006000000000000000005113442e2ec20b97FA9385bE102ac3EAc297483Dd6233D62b3e14960000000000000000000000003b9aca0000004200000000000000000000000000000000000006833589fCD6eDb6E08f4c7C32D4f71b54bdA02913000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000"; + } + if (usdc == 100_000_000) { + return + hex"0100833589fCD6eDb6E08f4c7C32D4f71b54bdA02913420000000000000000000000000000000000000600000000000000000081b86d16b1367a7FA9385bE102ac3EAc297483Dd6233D62b3e149600000000000000000000000005f5e10000004200000000000000000000000000000000000006833589fCD6eDb6E08f4c7C32D4f71b54bdA02913000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000"; + } + return + hex"0100833589fCD6eDb6E08f4c7C32D4f71b54bdA02913420000000000000000000000000000000000000600000000000000000040dc368b589b3d7FA9385bE102ac3EAc297483Dd6233D62b3e149600000000000000000000000002faf08000004200000000000000000000000000000000000006833589fCD6eDb6E08f4c7C32D4f71b54bdA02913000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000"; + } +} + +/// Run with: --fork-url --fork-block-number 506178800 +contract ForkL2EkuboArbitrumTest is ForkL2EkuboBase { + function _cfg() internal pure override returns (Cfg memory) { + return Cfg({ + name: "arbitrum", + chainId: 42161, + blockNumber: 506178800, + timestamp: 1789669181, + weth: 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1, + usdc: 0xaf88d065e77c8cC2239327C5EDb3A432268e5831, + fee: 9223372036854775, // floor(0.0005 * 2^64): 0.05%, the deepest v3 WETH/USDC tier on Arbitrum + spacing: 1000, // v3 spacing 10 x 100 + initTick: -19822971, + lower: -19883000, + upper: -19762000, + liquidity: 3696581650313474097, // v3 fee-500 pool `liquidity()` at the pinned block + config: 0x00000000000000000000000000000000000000000020c49ba5e353f7800003e8 + }); + } + + function _route(uint256 usdc) internal pure override returns (bytes memory) { + if (usdc == 1_000_000_000) { + return + hex"0100af88d065e77c8cC2239327C5EDb3A432268e583182aF49447D8a07e3bd95BD0d56f35241523fBab100000000000000000513960a07707c937FA9385bE102ac3EAc297483Dd6233D62b3e14960000000000000000000000003b9aca00000082aF49447D8a07e3bd95BD0d56f35241523fBab1af88d065e77c8cC2239327C5EDb3A432268e583100000000000000000000000000000000000000000020c49ba5e353f7800003e800000000000000000000000000000000"; + } + if (usdc == 100_000_000) { + return + hex"0100af88d065e77c8cC2239327C5EDb3A432268e583182aF49447D8a07e3bd95BD0d56f35241523fBab100000000000000000081f5676724d9447FA9385bE102ac3EAc297483Dd6233D62b3e149600000000000000000000000005f5e100000082aF49447D8a07e3bd95BD0d56f35241523fBab1af88d065e77c8cC2239327C5EDb3A432268e583100000000000000000000000000000000000000000020c49ba5e353f7800003e800000000000000000000000000000000"; + } + return + hex"0100af88d065e77c8cC2239327C5EDb3A432268e583182aF49447D8a07e3bd95BD0d56f35241523fBab100000000000000000040fab3b3926ca27FA9385bE102ac3EAc297483Dd6233D62b3e149600000000000000000000000002faf080000082aF49447D8a07e3bd95BD0d56f35241523fBab1af88d065e77c8cC2239327C5EDb3A432268e583100000000000000000000000000000000000000000020c49ba5e353f7800003e800000000000000000000000000000000"; + } +} diff --git a/benchmarks/fork-l2/ForkL2EkuboArbitrumTest.json b/benchmarks/fork-l2/ForkL2EkuboArbitrumTest.json new file mode 100644 index 0000000..5fcbb3e --- /dev/null +++ b/benchmarks/fork-l2/ForkL2EkuboArbitrumTest.json @@ -0,0 +1,14 @@ +{ + "fork-l2 arbitrum ekubo Yul router 100 USDC->WETH fresh pool": "113121", + "fork-l2 arbitrum ekubo Yul router 100 USDC->WETH fresh pool | Ekubo fine ticks moved (1/100 of a Uniswap tick)": "1", + "fork-l2 arbitrum ekubo Yul router 1000 USDC->WETH fresh pool": "113133", + "fork-l2 arbitrum ekubo Yul router 1000 USDC->WETH fresh pool | Ekubo fine ticks moved (1/100 of a Uniswap tick)": "11", + "fork-l2 arbitrum ekubo Yul router 50 USDC->WETH fresh pool": "113133", + "fork-l2 arbitrum ekubo Yul router 50 USDC->WETH fresh pool | Ekubo fine ticks moved (1/100 of a Uniswap tick)": "1", + "fork-l2 arbitrum ekubo calldata bytes 100 USDC": "184", + "fork-l2 arbitrum ekubo calldata bytes 1000 USDC": "184", + "fork-l2 arbitrum ekubo calldata bytes 50 USDC": "184", + "fork-l2 arbitrum ekubo output 100 USDC->WETH (wei)": "40624188992664839", + "fork-l2 arbitrum ekubo output 1000 USDC->WETH (wei)": "406235910949341733", + "fork-l2 arbitrum ekubo output 50 USDC->WETH (wei)": "20312111104803398" +} diff --git a/benchmarks/fork-l2/ForkL2EkuboBaseTest.json b/benchmarks/fork-l2/ForkL2EkuboBaseTest.json new file mode 100644 index 0000000..5156eb4 --- /dev/null +++ b/benchmarks/fork-l2/ForkL2EkuboBaseTest.json @@ -0,0 +1,14 @@ +{ + "fork-l2 base ekubo Yul router 100 USDC->WETH fresh pool": "105084", + "fork-l2 base ekubo Yul router 100 USDC->WETH fresh pool | Ekubo fine ticks moved (1/100 of a Uniswap tick)": "0", + "fork-l2 base ekubo Yul router 1000 USDC->WETH fresh pool": "105096", + "fork-l2 base ekubo Yul router 1000 USDC->WETH fresh pool | Ekubo fine ticks moved (1/100 of a Uniswap tick)": "1", + "fork-l2 base ekubo Yul router 50 USDC->WETH fresh pool": "105096", + "fork-l2 base ekubo Yul router 50 USDC->WETH fresh pool | Ekubo fine ticks moved (1/100 of a Uniswap tick)": "0", + "fork-l2 base ekubo calldata bytes 100 USDC": "184", + "fork-l2 base ekubo calldata bytes 1000 USDC": "184", + "fork-l2 base ekubo calldata bytes 50 USDC": "184", + "fork-l2 base ekubo output 100 USDC->WETH (wei)": "40448346569108551", + "fork-l2 base ekubo output 1000 USDC->WETH (wei)": "404482873373385029", + "fork-l2 base ekubo output 50 USDC->WETH (wei)": "20224174929883199" +} diff --git a/benchmarks/fork-l2/ForkL2V3.t.sol b/benchmarks/fork-l2/ForkL2V3.t.sol new file mode 100644 index 0000000..8e40ecd --- /dev/null +++ b/benchmarks/fork-l2/ForkL2V3.t.sol @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test, Vm} from "forge-std/Test.sol"; + +/// Classic SwapRouter (Arbitrum One, same contract and address as mainnet): params carry a deadline. +interface ISwapRouter { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); +} + +/// SwapRouter02 (the only Uniswap-listed v3 router on Base): no deadline field. +interface ISwapRouter02 { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); +} + +interface IUniswapV3PoolLike { + function slot0() + external + view + returns (uint160 sqrtPriceX96, int24 tick, uint16, uint16, uint16, uint8, bool); + function liquidity() external view returns (uint128); + function fee() external view returns (uint24); + function token0() external view returns (address); + function token1() external view returns (address); +} + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); +} + +/// @notice L2-fork measurement: the deployed Uniswap v3 router + the deepest live WETH/USDC +/// pool on the chain, USDC exact-input to WETH, warmed with one identical unmeasured +/// swap; every call isolated, snapshot = execution gas of the measured call (net of +/// refunds, excluding the 21,000 base and calldata). Ticks are read from `slot0` and +/// cross-checked against the pool's `Swap` event. +abstract contract ForkL2V3Base is Test { + bytes32 constant SWAP_TOPIC = keccak256("Swap(address,address,int256,int256,uint160,uint128,int24)"); + + struct Cfg { + string name; + uint256 chainId; + uint256 blockNumber; + uint256 timestamp; + address router; + bool classic; // true: SwapRouter (deadline); false: SwapRouter02 + address pool; + address weth; + address usdc; + uint24 fee; + } + + function _cfg() internal pure virtual returns (Cfg memory); + + function _setup() internal { + Cfg memory c = _cfg(); + assertEq(block.chainid, c.chainId, "chain id"); + // Arbitrum forks report the L1 block number as `block.number`; the timestamp pins the block on both chains. + assertEq(block.timestamp, c.timestamp, "pinned block timestamp"); + assertGt(c.router.code.length, 0, "router deployed"); + assertEq(IUniswapV3PoolLike(c.pool).token0(), c.weth); + assertEq(IUniswapV3PoolLike(c.pool).token1(), c.usdc); + assertEq(IUniswapV3PoolLike(c.pool).fee(), c.fee); + vm.snapshotValue(string.concat("fork-l2 ", c.name, " v3 pool liquidity"), IUniswapV3PoolLike(c.pool).liquidity()); + deal(c.usdc, address(this), 1_000_000_000_000); // 1e6 USDC + IERC20Like(c.usdc).approve(c.router, type(uint256).max); + assertEq(IERC20Like(c.usdc).allowance(address(this), c.router), type(uint256).max, "max approval"); + } + + function _calldata(uint256 amountIn) internal view returns (bytes memory) { + Cfg memory c = _cfg(); + if (c.classic) { + return abi.encodeCall( + ISwapRouter.exactInputSingle, + ( + ISwapRouter.ExactInputSingleParams({ + tokenIn: c.usdc, + tokenOut: c.weth, + fee: c.fee, + recipient: address(this), + deadline: block.timestamp, + amountIn: amountIn, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }) + ) + ); + } + return abi.encodeCall( + ISwapRouter02.exactInputSingle, + ( + ISwapRouter02.ExactInputSingleParams({ + tokenIn: c.usdc, + tokenOut: c.weth, + fee: c.fee, + recipient: address(this), + amountIn: amountIn, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }) + ) + ); + } + + function _swap(uint256 amountIn, string memory snapshotName) internal returns (uint256 out, bytes memory cd) { + Cfg memory c = _cfg(); + cd = _calldata(amountIn); + (, int24 tickBefore,,,,,) = IUniswapV3PoolLike(c.pool).slot0(); + uint256 wethBefore = IERC20Like(c.weth).balanceOf(address(this)); + vm.recordLogs(); + (bool ok, bytes memory ret) = c.router.call(cd); + if (bytes(snapshotName).length > 0) vm.snapshotGasLastCall(snapshotName); + assertTrue(ok, "swap"); + out = abi.decode(ret, (uint256)); + assertEq(IERC20Like(c.weth).balanceOf(address(this)) - wethBefore, out, "output delivered"); + (, int24 tickAfter,,,,,) = IUniswapV3PoolLike(c.pool).slot0(); + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool seen; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter == c.pool && logs[i].topics[0] == SWAP_TOPIC) { + (,,,, int24 eventTick) = abi.decode(logs[i].data, (int256, int256, uint160, uint128, int24)); + assertEq(eventTick, tickAfter, "Swap event tick equals slot0 tick"); + seen = true; + } + } + assertTrue(seen, "Swap event found"); + if (bytes(snapshotName).length > 0) { + emit log_named_int("v3 tick before", tickBefore); + emit log_named_int("v3 tick after", tickAfter); + int256 moved = int256(tickAfter) - int256(tickBefore); + vm.snapshotValue(string.concat(snapshotName, " | ticks moved"), uint256(moved < 0 ? -moved : moved)); + } + } + + function _run(uint256 usdc, uint256 minOut, uint256 maxOut) internal { + _setup(); + Cfg memory c = _cfg(); + _swap(usdc, ""); // warm-up, unmeasured + assertGt(IERC20Like(c.weth).balanceOf(address(this)), 0, "recipient WETH balance nonzero before measured swap"); + string memory size = vm.toString(usdc / 1_000_000); + string memory routerName = c.classic ? "SwapRouter" : "SwapRouter02"; + string memory name = string.concat( + "fork-l2 ", c.name, " v3 ", routerName, " ", size, " USDC->WETH fee-", vm.toString(uint256(c.fee)) + ); + (uint256 out, bytes memory cd) = _swap(usdc, name); + vm.snapshotValue(string.concat("fork-l2 ", c.name, " v3 ", routerName, " output ", size, " USDC->WETH (wei)"), out); + vm.snapshotValue(string.concat("fork-l2 ", c.name, " v3 ", routerName, " calldata bytes ", size, " USDC"), cd.length); + vm.writeFile(string.concat("calldata/v3-", routerName, "-", c.name, "-", size, ".hex"), vm.toString(cd)); + emit log_named_uint("v3 measured output (WETH wei)", out); + assertGe(out, minOut); + assertLe(out, maxOut); + } + + /// forge-config: default.isolate = true + function test_fork_l2_v3_exactInputSingle_1000() public { + _run(1_000_000_000, 0.38 ether, 0.43 ether); + } + + /// forge-config: default.isolate = true + function test_fork_l2_v3_exactInputSingle_100() public { + _run(100_000_000, 0.038 ether, 0.043 ether); + } + + /// forge-config: default.isolate = true + function test_fork_l2_v3_exactInputSingle_50() public { + _run(50_000_000, 0.019 ether, 0.0215 ether); + } +} + +/// Run with: --fork-url --fork-block-number 51439900 +contract ForkL2V3BaseTest is ForkL2V3Base { + function _cfg() internal pure override returns (Cfg memory) { + return Cfg({ + name: "base", + chainId: 8453, + blockNumber: 51439900, + timestamp: 1789669147, + router: 0x2626664c2603336E57B271c5C0b26F421741e481, // SwapRouter02 (docs.uniswap.org Base deployments) + classic: false, + pool: 0x6c561B446416E1A00E8E93E221854d6eA4171372, // WETH/USDC fee 3000, deepest of 100/500/3000/10000 + weth: 0x4200000000000000000000000000000000000006, + usdc: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, + fee: 3000 + }); + } +} + +/// Run with: --fork-url --fork-block-number 506178800 +contract ForkL2V3ArbitrumTest is ForkL2V3Base { + function _cfg() internal pure override returns (Cfg memory) { + return Cfg({ + name: "arbitrum", + chainId: 42161, + blockNumber: 506178800, + timestamp: 1789669181, + router: 0xE592427A0AEce92De3Edee1F18E0157C05861564, // classic SwapRouter (same as mainnet) + classic: true, + pool: 0xC6962004f452bE9203591991D15f6b388e09E8D0, // WETH/USDC fee 500, deepest of 100/500/3000/10000 + weth: 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1, + usdc: 0xaf88d065e77c8cC2239327C5EDb3A432268e5831, + fee: 500 + }); + } +} + +/// Same pool through SwapRouter02, for a like-for-like router with the Base leg. +contract ForkL2V3ArbitrumRouter02Test is ForkL2V3Base { + function _cfg() internal pure override returns (Cfg memory) { + return Cfg({ + name: "arbitrum", + chainId: 42161, + blockNumber: 506178800, + timestamp: 1789669181, + router: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45, // SwapRouter02 + classic: false, + pool: 0xC6962004f452bE9203591991D15f6b388e09E8D0, + weth: 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1, + usdc: 0xaf88d065e77c8cC2239327C5EDb3A432268e5831, + fee: 500 + }); + } +} diff --git a/benchmarks/fork-l2/ForkL2V3ArbitrumRouter02Test.json b/benchmarks/fork-l2/ForkL2V3ArbitrumRouter02Test.json new file mode 100644 index 0000000..7a4056e --- /dev/null +++ b/benchmarks/fork-l2/ForkL2V3ArbitrumRouter02Test.json @@ -0,0 +1,15 @@ +{ + "fork-l2 arbitrum v3 SwapRouter02 100 USDC->WETH fee-500": "130705", + "fork-l2 arbitrum v3 SwapRouter02 100 USDC->WETH fee-500 | ticks moved": "0", + "fork-l2 arbitrum v3 SwapRouter02 1000 USDC->WETH fee-500": "130705", + "fork-l2 arbitrum v3 SwapRouter02 1000 USDC->WETH fee-500 | ticks moved": "0", + "fork-l2 arbitrum v3 SwapRouter02 50 USDC->WETH fee-500": "130717", + "fork-l2 arbitrum v3 SwapRouter02 50 USDC->WETH fee-500 | ticks moved": "0", + "fork-l2 arbitrum v3 SwapRouter02 calldata bytes 100 USDC": "228", + "fork-l2 arbitrum v3 SwapRouter02 calldata bytes 1000 USDC": "228", + "fork-l2 arbitrum v3 SwapRouter02 calldata bytes 50 USDC": "228", + "fork-l2 arbitrum v3 SwapRouter02 output 100 USDC->WETH (wei)": "40624162908750166", + "fork-l2 arbitrum v3 SwapRouter02 output 1000 USDC->WETH (wei)": "406235650115953443", + "fork-l2 arbitrum v3 SwapRouter02 output 50 USDC->WETH (wei)": "20312098062830065", + "fork-l2 arbitrum v3 pool liquidity": "3696581650313474097" +} diff --git a/benchmarks/fork-l2/ForkL2V3ArbitrumTest.json b/benchmarks/fork-l2/ForkL2V3ArbitrumTest.json new file mode 100644 index 0000000..6a245bf --- /dev/null +++ b/benchmarks/fork-l2/ForkL2V3ArbitrumTest.json @@ -0,0 +1,15 @@ +{ + "fork-l2 arbitrum v3 SwapRouter 100 USDC->WETH fee-500": "130896", + "fork-l2 arbitrum v3 SwapRouter 100 USDC->WETH fee-500 | ticks moved": "0", + "fork-l2 arbitrum v3 SwapRouter 1000 USDC->WETH fee-500": "130896", + "fork-l2 arbitrum v3 SwapRouter 1000 USDC->WETH fee-500 | ticks moved": "0", + "fork-l2 arbitrum v3 SwapRouter 50 USDC->WETH fee-500": "130908", + "fork-l2 arbitrum v3 SwapRouter 50 USDC->WETH fee-500 | ticks moved": "0", + "fork-l2 arbitrum v3 SwapRouter calldata bytes 100 USDC": "260", + "fork-l2 arbitrum v3 SwapRouter calldata bytes 1000 USDC": "260", + "fork-l2 arbitrum v3 SwapRouter calldata bytes 50 USDC": "260", + "fork-l2 arbitrum v3 SwapRouter output 100 USDC->WETH (wei)": "40624162908750166", + "fork-l2 arbitrum v3 SwapRouter output 1000 USDC->WETH (wei)": "406235650115953443", + "fork-l2 arbitrum v3 SwapRouter output 50 USDC->WETH (wei)": "20312098062830065", + "fork-l2 arbitrum v3 pool liquidity": "3696581650313474097" +} diff --git a/benchmarks/fork-l2/ForkL2V3BaseTest.json b/benchmarks/fork-l2/ForkL2V3BaseTest.json new file mode 100644 index 0000000..331b928 --- /dev/null +++ b/benchmarks/fork-l2/ForkL2V3BaseTest.json @@ -0,0 +1,15 @@ +{ + "fork-l2 base v3 SwapRouter02 100 USDC->WETH fee-3000": "121803", + "fork-l2 base v3 SwapRouter02 100 USDC->WETH fee-3000 | ticks moved": "0", + "fork-l2 base v3 SwapRouter02 1000 USDC->WETH fee-3000": "121803", + "fork-l2 base v3 SwapRouter02 1000 USDC->WETH fee-3000 | ticks moved": "0", + "fork-l2 base v3 SwapRouter02 50 USDC->WETH fee-3000": "121815", + "fork-l2 base v3 SwapRouter02 50 USDC->WETH fee-3000 | ticks moved": "0", + "fork-l2 base v3 SwapRouter02 calldata bytes 100 USDC": "228", + "fork-l2 base v3 SwapRouter02 calldata bytes 1000 USDC": "228", + "fork-l2 base v3 SwapRouter02 calldata bytes 50 USDC": "228", + "fork-l2 base v3 SwapRouter02 output 100 USDC->WETH (wei)": "40448339546831424", + "fork-l2 base v3 SwapRouter02 output 1000 USDC->WETH (wei)": "404482803150768010", + "fork-l2 base v3 SwapRouter02 output 50 USDC->WETH (wei)": "20224171418744207", + "fork-l2 base v3 pool liquidity": "37026042371903555946" +} diff --git a/benchmarks/fork-l2/ForkL2V4.t.sol b/benchmarks/fork-l2/ForkL2V4.t.sol new file mode 100644 index 0000000..5f5dd97 --- /dev/null +++ b/benchmarks/fork-l2/ForkL2V4.t.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {Test, Vm} from "forge-std/Test.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol"; +import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol"; +import {SwapParams} from "v4-core/src/types/PoolOperation.sol"; +import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; +import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import {TransientStateLibrary} from "v4-core/src/libraries/TransientStateLibrary.sol"; +import {CurrencySettler} from "v4-core/test/utils/CurrencySettler.sol"; +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); +} + +interface IStateViewLike { + function getSlot0(PoolId poolId) + external + view + returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee); + function getLiquidity(PoolId poolId) external view returns (uint128 liquidity); +} + +/// @notice Minimal single-lock exact-input swapper over a live v4 pool (same contract as the +/// mainnet fork harness). The production Universal Router would cost more on top. +contract ForkSwapRouter is IUnlockCallback { + using CurrencyLibrary for Currency; + using CurrencySettler for Currency; + using TransientStateLibrary for IPoolManager; + + IPoolManager public immutable manager; + + constructor(IPoolManager _manager) { + manager = _manager; + } + + function swap(PoolKey memory key, bool zeroForOne, int256 amountIn, uint160 limit, address payer) + external + payable + returns (uint256 amountOut) + { + bytes memory result = manager.unlock(abi.encode(key, zeroForOne, amountIn, limit, payer, msg.sender)); + amountOut = abi.decode(result, (uint256)); + } + + function unlockCallback(bytes calldata rawData) external returns (bytes memory) { + require(msg.sender == address(manager)); + (PoolKey memory key, bool zeroForOne, int256 amountIn, uint160 limit, address payer, address recipient) = + abi.decode(rawData, (PoolKey, bool, int256, uint160, address, address)); + manager.swap(key, SwapParams({zeroForOne: zeroForOne, amountSpecified: amountIn, sqrtPriceLimitX96: limit}), ""); + Currency currencyIn = zeroForOne ? key.currency0 : key.currency1; + Currency currencyOut = zeroForOne ? key.currency1 : key.currency0; + int256 inDelta = manager.currencyDelta(address(this), currencyIn); + int256 outDelta = manager.currencyDelta(address(this), currencyOut); + if (inDelta < 0) { + manager.sync(currencyIn); + (bool ok,) = Currency.unwrap(currencyIn).call( + abi.encodeWithSignature( + "transferFrom(address,address,uint256)", payer, address(manager), uint256(-inDelta) + ) + ); + require(ok, "transferFrom failed"); + manager.settle(); + } + if (outDelta > 0) currencyOut.take(manager, recipient, uint256(outDelta), false); + return abi.encode(uint256(outDelta)); + } +} + +/// @notice L2-fork measurement: the deployed v4 PoolManager + the deepest live WETH/USDC pool +/// (hooks = address(0)) through the minimal locker above, USDC exact-input to WETH, +/// warmed with one identical unmeasured swap; every call isolated, snapshot = +/// execution gas of the measured call (net of refunds, excluding the 21,000 base and +/// calldata). Ticks are read from StateView and cross-checked against the `Swap` event. +abstract contract ForkL2V4Base is Test { + using PoolIdLibrary for PoolKey; + + bytes32 constant SWAP_TOPIC = keccak256("Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)"); + + struct Cfg { + string name; + uint256 chainId; + uint256 blockNumber; + uint256 timestamp; + address manager; + address stateView; + address weth; + address usdc; + uint24 fee; + int24 spacing; + bytes32 expectedPoolId; + } + + ForkSwapRouter internal router; + PoolKey internal key; + PoolId internal poolId; + + function _cfg() internal pure virtual returns (Cfg memory); + + function _setup() internal { + Cfg memory c = _cfg(); + assertEq(block.chainid, c.chainId, "chain id"); + // Arbitrum forks report the L1 block number as `block.number`; the timestamp pins the block on both chains. + assertEq(block.timestamp, c.timestamp, "pinned block timestamp"); + assertGt(c.manager.code.length, 0, "PoolManager deployed"); + router = new ForkSwapRouter(IPoolManager(c.manager)); + // WETH < USDC on both chains: USDC is currency1, so USDC->WETH is oneForZero + key = PoolKey({ + currency0: Currency.wrap(c.weth), + currency1: Currency.wrap(c.usdc), + fee: c.fee, + tickSpacing: c.spacing, + hooks: IHooks(address(0)) + }); + poolId = key.toId(); + assertEq(PoolId.unwrap(poolId), c.expectedPoolId, "pool id"); + (uint160 sqrtPriceX96,,, uint24 lpFee) = IStateViewLike(c.stateView).getSlot0(poolId); + assertGt(sqrtPriceX96, 0, "pool initialized (no hook)"); + assertEq(lpFee, c.fee); + vm.snapshotValue(string.concat("fork-l2 ", c.name, " v4 pool liquidity"), IStateViewLike(c.stateView).getLiquidity(poolId)); + deal(c.usdc, address(this), 1_000_000_000_000); // 1e6 USDC + IERC20Like(c.usdc).approve(address(router), type(uint256).max); + assertEq(IERC20Like(c.usdc).allowance(address(this), address(router)), type(uint256).max, "max approval"); + } + + function _calldata(int256 amountIn) internal view returns (bytes memory) { + return abi.encodeCall(router.swap, (key, false, amountIn, TickMath.MAX_SQRT_PRICE - 1, address(this))); + } + + function _swap(int256 amountIn, string memory snapshotName) internal returns (uint256 out, bytes memory cd) { + Cfg memory c = _cfg(); + cd = _calldata(amountIn); + (, int24 tickBefore,,) = IStateViewLike(c.stateView).getSlot0(poolId); + uint256 wethBefore = IERC20Like(c.weth).balanceOf(address(this)); + vm.recordLogs(); + (bool ok, bytes memory ret) = address(router).call(cd); + if (bytes(snapshotName).length > 0) vm.snapshotGasLastCall(snapshotName); + assertTrue(ok, "swap"); + out = abi.decode(ret, (uint256)); + assertEq(IERC20Like(c.weth).balanceOf(address(this)) - wethBefore, out, "output delivered"); + (, int24 tickAfter,,) = IStateViewLike(c.stateView).getSlot0(poolId); + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool seen; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter == c.manager && logs[i].topics.length > 0 && logs[i].topics[0] == SWAP_TOPIC) { + assertEq(logs[i].topics[1], PoolId.unwrap(poolId), "Swap event for this pool"); + (,,,, int24 eventTick,) = abi.decode(logs[i].data, (int128, int128, uint160, uint128, int24, uint24)); + assertEq(eventTick, tickAfter, "Swap event tick equals StateView tick"); + seen = true; + } + } + assertTrue(seen, "Swap event found"); + if (bytes(snapshotName).length > 0) { + emit log_named_int("v4 tick before", tickBefore); + emit log_named_int("v4 tick after", tickAfter); + int256 moved = int256(tickAfter) - int256(tickBefore); + vm.snapshotValue(string.concat(snapshotName, " | ticks moved"), uint256(moved < 0 ? -moved : moved)); + } + } + + function _run(uint256 usdc, uint256 minOut, uint256 maxOut) internal { + _setup(); + Cfg memory c = _cfg(); + _swap(-int256(usdc), ""); // warm-up, unmeasured + assertGt(IERC20Like(c.weth).balanceOf(address(this)), 0, "recipient WETH balance nonzero before measured swap"); + string memory size = vm.toString(usdc / 1_000_000); + string memory name = string.concat( + "fork-l2 ", c.name, " v4 PoolManager minimal locker ", size, " USDC->WETH fee-", vm.toString(uint256(c.fee)) + ); + (uint256 out, bytes memory cd) = _swap(-int256(usdc), name); + vm.snapshotValue(string.concat("fork-l2 ", c.name, " v4 output ", size, " USDC->WETH (wei)"), out); + vm.snapshotValue(string.concat("fork-l2 ", c.name, " v4 calldata bytes ", size, " USDC"), cd.length); + vm.writeFile(string.concat("calldata/v4-minimal-locker-", c.name, "-", size, ".hex"), vm.toString(cd)); + emit log_named_uint("v4 measured output (WETH wei)", out); + assertGe(out, minOut); + assertLe(out, maxOut); + } + + /// forge-config: default.isolate = true + function test_fork_l2_v4_minimalLocker_1000() public { + _run(1_000_000_000, 0.38 ether, 0.43 ether); + } + + /// forge-config: default.isolate = true + function test_fork_l2_v4_minimalLocker_100() public { + _run(100_000_000, 0.038 ether, 0.043 ether); + } + + /// forge-config: default.isolate = true + function test_fork_l2_v4_minimalLocker_50() public { + _run(50_000_000, 0.019 ether, 0.0215 ether); + } +} + +/// Run with: --fork-url --fork-block-number 51439900 +contract ForkL2V4BaseTest is ForkL2V4Base { + function _cfg() internal pure override returns (Cfg memory) { + return Cfg({ + name: "base", + chainId: 8453, + blockNumber: 51439900, + timestamp: 1789669147, + manager: 0x498581fF718922c3f8e6A244956aF099B2652b2b, // docs.uniswap.org v4 deployments, Base + stateView: 0xA3c0c9b65baD0b08107Aa264b0f3dB444b867A71, + weth: 0x4200000000000000000000000000000000000006, + usdc: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, + fee: 3000, // deepest WETH/USDC (hookless) tier of 100/500/3000/10000 + spacing: 60, + expectedPoolId: 0x1d8c55f347727c0fb4f5e1b65cdb93639e0c7102580a7d345e1144cd5a718f54 + }); + } +} + +/// Run with: --fork-url --fork-block-number 506178800 +contract ForkL2V4ArbitrumTest is ForkL2V4Base { + function _cfg() internal pure override returns (Cfg memory) { + return Cfg({ + name: "arbitrum", + chainId: 42161, + blockNumber: 506178800, + timestamp: 1789669181, + manager: 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32, // docs.uniswap.org v4 deployments, Arbitrum One + stateView: 0x76Fd297e2D437cd7f76d50F01AfE6160f86e9990, + weth: 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1, + usdc: 0xaf88d065e77c8cC2239327C5EDb3A432268e5831, + fee: 500, // deepest WETH/USDC (hookless) tier of 100/500/3000/10000 + spacing: 10, + expectedPoolId: 0xfc7b3ad139daaf1e9c3637ed921c154d1b04286f8a82b805a6c352da57028653 + }); + } +} diff --git a/benchmarks/fork-l2/ForkL2V4ArbitrumTest.json b/benchmarks/fork-l2/ForkL2V4ArbitrumTest.json new file mode 100644 index 0000000..f1f66ef --- /dev/null +++ b/benchmarks/fork-l2/ForkL2V4ArbitrumTest.json @@ -0,0 +1,15 @@ +{ + "fork-l2 arbitrum v4 PoolManager minimal locker 100 USDC->WETH fee-500": "135006", + "fork-l2 arbitrum v4 PoolManager minimal locker 100 USDC->WETH fee-500 | ticks moved": "1", + "fork-l2 arbitrum v4 PoolManager minimal locker 1000 USDC->WETH fee-500": "156321", + "fork-l2 arbitrum v4 PoolManager minimal locker 1000 USDC->WETH fee-500 | ticks moved": "8", + "fork-l2 arbitrum v4 PoolManager minimal locker 50 USDC->WETH fee-500": "134922", + "fork-l2 arbitrum v4 PoolManager minimal locker 50 USDC->WETH fee-500 | ticks moved": "0", + "fork-l2 arbitrum v4 calldata bytes 100 USDC": "292", + "fork-l2 arbitrum v4 calldata bytes 1000 USDC": "292", + "fork-l2 arbitrum v4 calldata bytes 50 USDC": "292", + "fork-l2 arbitrum v4 output 100 USDC->WETH (wei)": "40620850698508208", + "fork-l2 arbitrum v4 output 1000 USDC->WETH (wei)": "405784743605143050", + "fork-l2 arbitrum v4 output 50 USDC->WETH (wei)": "20311602937263111", + "fork-l2 arbitrum v4 pool liquidity": "52128164974525471" +} diff --git a/benchmarks/fork-l2/ForkL2V4BaseTest.json b/benchmarks/fork-l2/ForkL2V4BaseTest.json new file mode 100644 index 0000000..a2f2ad5 --- /dev/null +++ b/benchmarks/fork-l2/ForkL2V4BaseTest.json @@ -0,0 +1,15 @@ +{ + "fork-l2 base v4 PoolManager minimal locker 100 USDC->WETH fee-3000": "126959", + "fork-l2 base v4 PoolManager minimal locker 100 USDC->WETH fee-3000 | ticks moved": "1", + "fork-l2 base v4 PoolManager minimal locker 1000 USDC->WETH fee-3000": "126991", + "fork-l2 base v4 PoolManager minimal locker 1000 USDC->WETH fee-3000 | ticks moved": "13", + "fork-l2 base v4 PoolManager minimal locker 50 USDC->WETH fee-3000": "126971", + "fork-l2 base v4 PoolManager minimal locker 50 USDC->WETH fee-3000 | ticks moved": "1", + "fork-l2 base v4 calldata bytes 100 USDC": "292", + "fork-l2 base v4 calldata bytes 1000 USDC": "292", + "fork-l2 base v4 calldata bytes 50 USDC": "292", + "fork-l2 base v4 output 100 USDC->WETH (wei)": "40400300715083769", + "fork-l2 base v4 output 1000 USDC->WETH (wei)": "403315880591944557", + "fork-l2 base v4 output 50 USDC->WETH (wei)": "20202061711569369", + "fork-l2 base v4 pool liquidity": "31809972076514118" +} diff --git a/benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-100.hex b/benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-100.hex new file mode 100644 index 0000000..2f157c8 --- /dev/null +++ b/benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-100.hex @@ -0,0 +1 @@ +0x0100af88d065e77c8cc2239327c5edb3a432268e583182af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000081f5676724d9447fa9385be102ac3eac297483dd6233d62b3e149600000000000000000000000005f5e100000082af49447d8a07e3bd95bd0d56f35241523fbab1af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000020c49ba5e353f7800003e800000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-1000.hex b/benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-1000.hex new file mode 100644 index 0000000..099f02c --- /dev/null +++ b/benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-1000.hex @@ -0,0 +1 @@ +0x0100af88d065e77c8cc2239327c5edb3a432268e583182af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000513960a07707c937fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000003b9aca00000082af49447d8a07e3bd95bd0d56f35241523fbab1af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000020c49ba5e353f7800003e800000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-50.hex b/benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-50.hex new file mode 100644 index 0000000..821b0ce --- /dev/null +++ b/benchmarks/fork-l2/calldata/ekubo-yul-arbitrum-50.hex @@ -0,0 +1 @@ +0x0100af88d065e77c8cc2239327c5edb3a432268e583182af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000040fab3b3926ca27fa9385be102ac3eac297483dd6233d62b3e149600000000000000000000000002faf080000082af49447d8a07e3bd95bd0d56f35241523fbab1af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000020c49ba5e353f7800003e800000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/ekubo-yul-base-100.hex b/benchmarks/fork-l2/calldata/ekubo-yul-base-100.hex new file mode 100644 index 0000000..6f0f6f8 --- /dev/null +++ b/benchmarks/fork-l2/calldata/ekubo-yul-base-100.hex @@ -0,0 +1 @@ +0x0100833589fcd6edb6e08f4c7c32d4f71b54bda02913420000000000000000000000000000000000000600000000000000000081b86d16b1367a7fa9385be102ac3eac297483dd6233d62b3e149600000000000000000000000005f5e10000004200000000000000000000000000000000000006833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/ekubo-yul-base-1000.hex b/benchmarks/fork-l2/calldata/ekubo-yul-base-1000.hex new file mode 100644 index 0000000..900b2b9 --- /dev/null +++ b/benchmarks/fork-l2/calldata/ekubo-yul-base-1000.hex @@ -0,0 +1 @@ +0x0100833589fcd6edb6e08f4c7c32d4f71b54bda029134200000000000000000000000000000000000006000000000000000005113442e2ec20b97fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000003b9aca0000004200000000000000000000000000000000000006833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/ekubo-yul-base-50.hex b/benchmarks/fork-l2/calldata/ekubo-yul-base-50.hex new file mode 100644 index 0000000..a833c00 --- /dev/null +++ b/benchmarks/fork-l2/calldata/ekubo-yul-base-50.hex @@ -0,0 +1 @@ +0x0100833589fcd6edb6e08f4c7c32d4f71b54bda02913420000000000000000000000000000000000000600000000000000000040dc368b589b3d7fa9385be102ac3eac297483dd6233d62b3e149600000000000000000000000002faf08000004200000000000000000000000000000000000006833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-100.hex b/benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-100.hex new file mode 100644 index 0000000..4a7e578 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-100.hex @@ -0,0 +1 @@ +0x414bf389000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f40000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000006aac2f3d0000000000000000000000000000000000000000000000000000000005f5e10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-1000.hex b/benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-1000.hex new file mode 100644 index 0000000..4f1ea00 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-1000.hex @@ -0,0 +1 @@ +0x414bf389000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f40000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000006aac2f3d000000000000000000000000000000000000000000000000000000003b9aca0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-50.hex b/benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-50.hex new file mode 100644 index 0000000..f925f20 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v3-SwapRouter-arbitrum-50.hex @@ -0,0 +1 @@ +0x414bf389000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f40000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000006aac2f3d0000000000000000000000000000000000000000000000000000000002faf08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-100.hex b/benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-100.hex new file mode 100644 index 0000000..af0d9ec --- /dev/null +++ b/benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-100.hex @@ -0,0 +1 @@ +0x04e45aaf000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f40000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000000000000000000000000000000000000005f5e10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-1000.hex b/benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-1000.hex new file mode 100644 index 0000000..d447762 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-1000.hex @@ -0,0 +1 @@ +0x04e45aaf000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f40000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000003b9aca0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-50.hex b/benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-50.hex new file mode 100644 index 0000000..b931b64 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v3-SwapRouter02-arbitrum-50.hex @@ -0,0 +1 @@ +0x04e45aaf000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f40000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000000000000000000000000000000000000002faf08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v3-SwapRouter02-base-100.hex b/benchmarks/fork-l2/calldata/v3-SwapRouter02-base-100.hex new file mode 100644 index 0000000..bc0172a --- /dev/null +++ b/benchmarks/fork-l2/calldata/v3-SwapRouter02-base-100.hex @@ -0,0 +1 @@ +0x04e45aaf000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000bb80000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000000000000000000000000000000000000005f5e10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v3-SwapRouter02-base-1000.hex b/benchmarks/fork-l2/calldata/v3-SwapRouter02-base-1000.hex new file mode 100644 index 0000000..135a6c2 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v3-SwapRouter02-base-1000.hex @@ -0,0 +1 @@ +0x04e45aaf000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000bb80000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000003b9aca0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v3-SwapRouter02-base-50.hex b/benchmarks/fork-l2/calldata/v3-SwapRouter02-base-50.hex new file mode 100644 index 0000000..ded5f06 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v3-SwapRouter02-base-50.hex @@ -0,0 +1 @@ +0x04e45aaf000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000bb80000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000000000000000000000000000000000000002faf08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-100.hex b/benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-100.hex new file mode 100644 index 0000000..0ee1114 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-100.hex @@ -0,0 +1 @@ +0x6e8c9dbf00000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0a1f00000000000000000000000000fffd8963efd1fc6a506488495d951d5263988d250000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-1000.hex b/benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-1000.hex new file mode 100644 index 0000000..401f6f1 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-1000.hex @@ -0,0 +1 @@ +0x6e8c9dbf00000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffc4653600000000000000000000000000fffd8963efd1fc6a506488495d951d5263988d250000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-50.hex b/benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-50.hex new file mode 100644 index 0000000..790637d --- /dev/null +++ b/benchmarks/fork-l2/calldata/v4-minimal-locker-arbitrum-50.hex @@ -0,0 +1 @@ +0x6e8c9dbf00000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fffffffffffffffffffffffffffffffffffffffffffffffffffffffffd050f80000000000000000000000000fffd8963efd1fc6a506488495d951d5263988d250000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v4-minimal-locker-base-100.hex b/benchmarks/fork-l2/calldata/v4-minimal-locker-base-100.hex new file mode 100644 index 0000000..538f98f --- /dev/null +++ b/benchmarks/fork-l2/calldata/v4-minimal-locker-base-100.hex @@ -0,0 +1 @@ +0x6e8c9dbf0000000000000000000000004200000000000000000000000000000000000006000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0a1f00000000000000000000000000fffd8963efd1fc6a506488495d951d5263988d250000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v4-minimal-locker-base-1000.hex b/benchmarks/fork-l2/calldata/v4-minimal-locker-base-1000.hex new file mode 100644 index 0000000..e49bdc3 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v4-minimal-locker-base-1000.hex @@ -0,0 +1 @@ +0x6e8c9dbf0000000000000000000000004200000000000000000000000000000000000006000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffc4653600000000000000000000000000fffd8963efd1fc6a506488495d951d5263988d250000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 \ No newline at end of file diff --git a/benchmarks/fork-l2/calldata/v4-minimal-locker-base-50.hex b/benchmarks/fork-l2/calldata/v4-minimal-locker-base-50.hex new file mode 100644 index 0000000..e68eca7 --- /dev/null +++ b/benchmarks/fork-l2/calldata/v4-minimal-locker-base-50.hex @@ -0,0 +1 @@ +0x6e8c9dbf0000000000000000000000004200000000000000000000000000000000000006000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fffffffffffffffffffffffffffffffffffffffffffffffffffffffffd050f80000000000000000000000000fffd8963efd1fc6a506488495d951d5263988d250000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 \ No newline at end of file diff --git a/benchmarks/fork-l2/encode-l2-routes.mjs b/benchmarks/fork-l2/encode-l2-routes.mjs new file mode 100644 index 0000000..415e6a8 --- /dev/null +++ b/benchmarks/fork-l2/encode-l2-routes.mjs @@ -0,0 +1,59 @@ +// Generates Yul-router calldata for the L2 fresh-pool WETH/USDC legs with the yul-router SDK. +import { encodeRoute } from "/home/sendmoodz/Work/ekubo/yul-router/sdk/src/index.ts"; +import { writeFileSync } from "node:fs"; +const RECIPIENT = "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496"; // forge test contract address (asserted in the test) +function config(feeQ64, spacing) { + // extension (20 bytes, zero) | fee (8 bytes) | 0x80000000 | spacing (4 bytes) + const word = (BigInt(feeQ64) << 32n) | (0x80000000n | BigInt(spacing)); + return "0x" + word.toString(16).padStart(64, "0"); +} +const chains = { + base: { + weth: "0x4200000000000000000000000000000000000006", + usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + fee: 55340232221128654n, + spacing: 6000, + priceWeiPerUnit: 1 / 2.464872103401631e-9, + }, + arb: { + weth: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + usdc: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + fee: 9223372036854775n, + spacing: 1000, + priceWeiPerUnit: 1 / 2.4603543652989017e-9, + }, +}; +for (const [name, c] of Object.entries(chains)) { + const cfg = config(c.fee, c.spacing); + console.log(name, "config", cfg); + for (const usdc of [50_000_000n, 100_000_000n, 1_000_000_000n]) { + const expectedWei = BigInt(Math.floor(Number(usdc) * c.priceWeiPerUnit)); + const threshold = (expectedWei * 90n) / 100n; + const data = encodeRoute({ + specifiedToken: c.usdc, + calculatedToken: c.weth, + specifiedAmount: usdc, + calculatedAmountThreshold: threshold, + recipient: RECIPIENT, + hops: [ + { + type: "core", + poolKey: { token0: c.weth, token1: c.usdc, config: cfg }, + }, + ], + }); + const f = `/tmp/l2bench/calldata/yul-route-${name}-${usdc / 1_000_000n}.hex`; + writeFileSync(f, data + "\n"); + console.log( + name, + `${usdc / 1_000_000n} USDC`, + "expectedWei", + expectedWei.toString(), + "threshold", + threshold.toString(), + "bytes", + (data.length - 2) / 2, + ); + console.log(data); + } +} diff --git a/benchmarks/fork-l2/foundry.toml b/benchmarks/fork-l2/foundry.toml new file mode 100644 index 0000000..176101d --- /dev/null +++ b/benchmarks/fork-l2/foundry.toml @@ -0,0 +1,12 @@ +[profile.default] +src = "src" +test = "test" +out = "out" +libs = ["lib"] +# No `solc` pin: foundry auto-detects per file (0.8.26 driver files, =0.8.33 ekubo sources). +optimizer = true +optimizer_runs = 1000000 +via_ir = true +evm_version = "osaka" +verbosity = 2 +fs_permissions = [{ access = "read-write", path = "./" }] diff --git a/benchmarks/fork-l2/l1-data-cost.json b/benchmarks/fork-l2/l1-data-cost.json new file mode 100644 index 0000000..6bf3eb1 --- /dev/null +++ b/benchmarks/fork-l2/l1-data-cost.json @@ -0,0 +1,295 @@ +{ + "base": { + "chainId": 8453, + "block": 51439900, + "blockHash": "0x54e5275d7f99214f0b17e01f415226b85915235c95385d30bf2dcb26d70cbf2f", + "timestamp": 1789669147, + "l2BaseFeeWei": "5000000", + "rpc": "https://base-mainnet.public.blastapi.io", + "crossCheckRpc": "https://mainnet.base.org", + "legs": { + "ekubo Yul router 50 USDC": { + "to": "0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748", + "calldataBytes": 184, + "calldataGasL1Rule": 1768, + "unsignedTxBytes": 228, + "l1FeeWei": "1665801209", + "l1FeeWeiCrossCheck": "1665801209", + "l1GasUsed": "2236", + "estimatedCompressedBytes": 139.771399, + "daFootprintGas": 20572, + "l1FeeInL2GasUnits": 333.1602418 + }, + "ekubo Yul router 100 USDC": { + "to": "0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748", + "calldataBytes": 184, + "calldataGasL1Rule": 1756, + "unsignedTxBytes": 228, + "l1FeeWei": "1655831768", + "l1FeeWeiCrossCheck": "1655831768", + "l1GasUsed": "2222", + "estimatedCompressedBytes": 138.934899, + "daFootprintGas": 20424, + "l1FeeInL2GasUnits": 331.1663536 + }, + "ekubo Yul router 1000 USDC": { + "to": "0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748", + "calldataBytes": 184, + "calldataGasL1Rule": 1768, + "unsignedTxBytes": 228, + "l1FeeWei": "1645862327", + "l1FeeWeiCrossCheck": "1645862327", + "l1GasUsed": "2209", + "estimatedCompressedBytes": 138.098399, + "daFootprintGas": 20424, + "l1FeeInL2GasUnits": 329.1724654 + }, + "v3 SwapRouter02 50 USDC": { + "to": "0x2626664c2603336E57B271c5C0b26F421741e481", + "calldataBytes": 228, + "calldataGasL1Rule": 1536, + "unsignedTxBytes": 273, + "l1FeeWei": "1605984563", + "l1FeeWeiCrossCheck": "1605984563", + "l1GasUsed": "2156", + "estimatedCompressedBytes": 134.752399, + "daFootprintGas": 19832, + "l1FeeInL2GasUnits": 321.1969126 + }, + "v3 SwapRouter02 100 USDC": { + "to": "0x2626664c2603336E57B271c5C0b26F421741e481", + "calldataBytes": 228, + "calldataGasL1Rule": 1524, + "unsignedTxBytes": 273, + "l1FeeWei": "1596015122", + "l1FeeWeiCrossCheck": "1596015122", + "l1GasUsed": "2142", + "estimatedCompressedBytes": 133.915899, + "daFootprintGas": 19684, + "l1FeeInL2GasUnits": 319.2030244 + }, + "v3 SwapRouter02 1000 USDC": { + "to": "0x2626664c2603336E57B271c5C0b26F421741e481", + "calldataBytes": 228, + "calldataGasL1Rule": 1524, + "unsignedTxBytes": 273, + "l1FeeWei": "1596015122", + "l1FeeWeiCrossCheck": "1596015122", + "l1GasUsed": "2142", + "estimatedCompressedBytes": 133.915899, + "daFootprintGas": 19684, + "l1FeeInL2GasUnits": 319.2030244 + }, + "v4 minimal locker 50 USDC": { + "to": "0x2e234DAe75C793f67A35089C9d99245E1C58470b", + "calldataBytes": 292, + "calldataGasL1Rule": 2380, + "unsignedTxBytes": 338, + "l1FeeWei": "1845251145", + "l1FeeWeiCrossCheck": "1845251145", + "l1GasUsed": "2477", + "estimatedCompressedBytes": 154.828399, + "daFootprintGas": 22792, + "l1FeeInL2GasUnits": 369.050229 + }, + "v4 minimal locker 100 USDC": { + "to": "0x2e234DAe75C793f67A35089C9d99245E1C58470b", + "calldataBytes": 292, + "calldataGasL1Rule": 2368, + "unsignedTxBytes": 338, + "l1FeeWei": "1835281704", + "l1FeeWeiCrossCheck": "1835281704", + "l1GasUsed": "2463", + "estimatedCompressedBytes": 153.991899, + "daFootprintGas": 22644, + "l1FeeInL2GasUnits": 367.0563408 + }, + "v4 minimal locker 1000 USDC": { + "to": "0x2e234DAe75C793f67A35089C9d99245E1C58470b", + "calldataBytes": 292, + "calldataGasL1Rule": 2368, + "unsignedTxBytes": 338, + "l1FeeWei": "1835281704", + "l1FeeWeiCrossCheck": "1835281704", + "l1GasUsed": "2463", + "estimatedCompressedBytes": 153.991899, + "daFootprintGas": 22644, + "l1FeeInL2GasUnits": 367.0563408 + } + }, + "oracle": { + "isFjord": true, + "isJovian": true, + "l1BaseFee": "118337311", + "blobBaseFee": "7219357", + "baseFeeScalar": 2269, + "blobBaseFeeScalar": 1055762, + "daFootprintGasScalar": 148, + "l1OriginBlock": 25998943 + } + }, + "arbitrum": { + "chainId": 42161, + "block": 506178800, + "blockHash": "0x24768c22e96c6e6fec640926ab1526e513d49fdcb70ed8572109c803fe032204", + "timestamp": 1789669181, + "l2BaseFeeWei": "20074000", + "rpc": "https://arbitrum-one.public.blastapi.io", + "crossCheckRpc": "https://arb1.arbitrum.io/rpc", + "legs": { + "ekubo Yul router 50 USDC": { + "to": "0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748", + "calldataBytes": 184, + "calldataGasL1Rule": 2200, + "unsignedTxBytes": 228, + "gasEstimateForL1": "561", + "gasEstimateForL1CrossCheck": "561", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "11220000000", + "paddedL1GasUnits": 5821 + }, + "ekubo Yul router 100 USDC": { + "to": "0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748", + "calldataBytes": 184, + "calldataGasL1Rule": 2188, + "unsignedTxBytes": 228, + "gasEstimateForL1": "561", + "gasEstimateForL1CrossCheck": "561", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "11220000000", + "paddedL1GasUnits": 5821 + }, + "ekubo Yul router 1000 USDC": { + "to": "0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748", + "calldataBytes": 184, + "calldataGasL1Rule": 2200, + "unsignedTxBytes": 228, + "gasEstimateForL1": "561", + "gasEstimateForL1CrossCheck": "561", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "11220000000", + "paddedL1GasUnits": 5821 + }, + "v3 SwapRouter 50 USDC": { + "to": "0xE592427A0AEce92De3Edee1F18E0157C05861564", + "calldataBytes": 260, + "calldataGasL1Rule": 1928, + "unsignedTxBytes": 306, + "gasEstimateForL1": "601", + "gasEstimateForL1CrossCheck": "601", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "12020000000", + "paddedL1GasUnits": 6236 + }, + "v3 SwapRouter 100 USDC": { + "to": "0xE592427A0AEce92De3Edee1F18E0157C05861564", + "calldataBytes": 260, + "calldataGasL1Rule": 1916, + "unsignedTxBytes": 306, + "gasEstimateForL1": "603", + "gasEstimateForL1CrossCheck": "603", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "12060000000", + "paddedL1GasUnits": 6257 + }, + "v3 SwapRouter 1000 USDC": { + "to": "0xE592427A0AEce92De3Edee1F18E0157C05861564", + "calldataBytes": 260, + "calldataGasL1Rule": 1916, + "unsignedTxBytes": 306, + "gasEstimateForL1": "604", + "gasEstimateForL1CrossCheck": "604", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "12080000000", + "paddedL1GasUnits": 6267 + }, + "v3 SwapRouter02 50 USDC": { + "to": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", + "calldataBytes": 228, + "calldataGasL1Rule": 1752, + "unsignedTxBytes": 273, + "gasEstimateForL1": "568", + "gasEstimateForL1CrossCheck": "568", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "11360000000", + "paddedL1GasUnits": 5894 + }, + "v3 SwapRouter02 100 USDC": { + "to": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", + "calldataBytes": 228, + "calldataGasL1Rule": 1740, + "unsignedTxBytes": 273, + "gasEstimateForL1": "570", + "gasEstimateForL1CrossCheck": "570", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "11400000000", + "paddedL1GasUnits": 5914 + }, + "v3 SwapRouter02 1000 USDC": { + "to": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", + "calldataBytes": 228, + "calldataGasL1Rule": 1740, + "unsignedTxBytes": 273, + "gasEstimateForL1": "572", + "gasEstimateForL1CrossCheck": "572", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "11440000000", + "paddedL1GasUnits": 5935 + }, + "v4 minimal locker 50 USDC": { + "to": "0x2e234DAe75C793f67A35089C9d99245E1C58470b", + "calldataBytes": 292, + "calldataGasL1Rule": 2596, + "unsignedTxBytes": 338, + "gasEstimateForL1": "645", + "gasEstimateForL1CrossCheck": "645", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "12900000000", + "paddedL1GasUnits": 6693 + }, + "v4 minimal locker 100 USDC": { + "to": "0x2e234DAe75C793f67A35089C9d99245E1C58470b", + "calldataBytes": 292, + "calldataGasL1Rule": 2584, + "unsignedTxBytes": 338, + "gasEstimateForL1": "644", + "gasEstimateForL1CrossCheck": "644", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "12880000000", + "paddedL1GasUnits": 6682 + }, + "v4 minimal locker 1000 USDC": { + "to": "0x2e234DAe75C793f67A35089C9d99245E1C58470b", + "calldataBytes": 292, + "calldataGasL1Rule": 2584, + "unsignedTxBytes": 338, + "gasEstimateForL1": "640", + "gasEstimateForL1CrossCheck": "640", + "l2BaseFeeFromNode": "20000000", + "l1BaseFeeEstimate": "1927507", + "posterCostWei": "12800000000", + "paddedL1GasUnits": 6641 + } + }, + "arbGasInfo": { + "perL2Tx": "4317615680", + "weiPerL1CalldataByte": "30840112", + "weiPerStorageAllocation": "401480000000", + "perArbGasBase": "20000000", + "perArbGasCongestion": "74000", + "perArbGasTotal": "20074000", + "l1BaseFeeEstimate": "1927507" + } + } +} diff --git a/benchmarks/fork-l2/l1-data-cost.mjs b/benchmarks/fork-l2/l1-data-cost.mjs new file mode 100644 index 0000000..4354128 --- /dev/null +++ b/benchmarks/fork-l2/l1-data-cost.mjs @@ -0,0 +1,274 @@ +// Measures the L1 data component each L2 charges for the exact calldata of every measured leg, +// by asking the chain's own fee logic at the pinned block: +// Base (OP Stack, Fjord+ estimator): GasPriceOracle.getL1Fee(unsignedTx) and getL1GasUsed(unsignedTx) +// on the 0x42..0F predeploy, plus the L1Block scalars it reads (0x42..15), so the Fjord formula +// can be re-derived by hand from the same inputs. +// Arbitrum One (Nitro): NodeInterface.gasEstimateL1Component(to, false, data) (virtual contract +// 0xc8, eth_call only) and ArbGasInfo.getPricesInWei / getL1BaseFeeEstimate (precompile 0x6C). +// Usage: bun run l1-data-cost.mjs > l1-data-cost.json +import { readFileSync } from "node:fs"; +import { + serializeTransaction, + encodeFunctionData, + decodeFunctionResult, + parseAbi, +} from "/home/sendmoodz/Work/ekubo/yul-router/sdk/node_modules/viem/_esm/index.js"; + +const SENDER = "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496"; // the forge test contract, recipient in every route +const CHAINS = { + base: { + chainId: 8453, + block: 51439900, + rpcs: [ + "https://base-mainnet.public.blastapi.io", + "https://mainnet.base.org", + ], + yulRouter: "0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748", + v3Router: "0x2626664c2603336E57B271c5C0b26F421741e481", // SwapRouter02 + v4Locker: "0x2e234DAe75C793f67A35089C9d99245E1C58470b", // ForkSwapRouter deployed by the test (address only affects the tx `to` bytes) + legs: [ + ["ekubo Yul router", "ekubo-yul-base", "yulRouter"], + ["v3 SwapRouter02", "v3-SwapRouter02-base", "v3Router"], + ["v4 minimal locker", "v4-minimal-locker-base", "v4Locker"], + ], + }, + arbitrum: { + chainId: 42161, + block: 506178800, + rpcs: [ + "https://arbitrum-one.public.blastapi.io", + "https://arb1.arbitrum.io/rpc", + ], + yulRouter: "0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748", + v3Router: "0xE592427A0AEce92De3Edee1F18E0157C05861564", // classic SwapRouter + v3Router02: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", + v4Locker: "0x2e234DAe75C793f67A35089C9d99245E1C58470b", + legs: [ + ["ekubo Yul router", "ekubo-yul-arbitrum", "yulRouter"], + ["v3 SwapRouter", "v3-SwapRouter-arbitrum", "v3Router"], + ["v3 SwapRouter02", "v3-SwapRouter02-arbitrum", "v3Router02"], + ["v4 minimal locker", "v4-minimal-locker-arbitrum", "v4Locker"], + ], + }, +}; +const SIZES = [50, 100, 1000]; +// Realistic envelope for the unsigned EIP-1559 transaction whose bytes the estimators compress. +const TX = { + nonce: 100, + maxPriorityFeePerGas: 1_000_000n, + maxFeePerGas: 10_000_000n, + gas: 200_000n, + value: 0n, +}; + +const oracleAbi = parseAbi([ + "function getL1Fee(bytes data) view returns (uint256)", + "function getL1GasUsed(bytes data) view returns (uint256)", + "function l1BaseFee() view returns (uint256)", + "function blobBaseFee() view returns (uint256)", + "function baseFeeScalar() view returns (uint32)", + "function blobBaseFeeScalar() view returns (uint32)", + "function isFjord() view returns (bool)", + "function isJovian() view returns (bool)", +]); +const l1BlockAbi = parseAbi([ + "function daFootprintGasScalar() view returns (uint16)", + "function number() view returns (uint64)", +]); +const nodeInterfaceAbi = parseAbi([ + "function gasEstimateL1Component(address to, bool contractCreation, bytes data) view returns (uint64 gasEstimateForL1, uint256 baseFee, uint256 l1BaseFeeEstimate)", +]); +const arbGasInfoAbi = parseAbi([ + "function getPricesInWei() view returns (uint256,uint256,uint256,uint256,uint256,uint256)", + "function getL1BaseFeeEstimate() view returns (uint256)", +]); + +let id = 1; +async function rpc(url, method, params) { + for (let attempt = 0; ; attempt++) { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }), + }); + const j = await res.json(); + if (!j.error) return j.result; + if ( + attempt >= 8 || + !/rate limit|429|too many/i.test(JSON.stringify(j.error)) + ) + throw new Error(`${method} on ${url}: ${JSON.stringify(j.error)}`); + await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); // backoff on public-endpoint rate limits + } +} +async function call(url, block, to, data, from) { + const tx = { to, data }; + if (from) tx.from = from; + return rpc(url, "eth_call", [tx, "0x" + block.toString(16)]); +} +async function read(url, block, to, abi, functionName, args = [], from) { + const out = await call( + url, + block, + to, + encodeFunctionData({ abi, functionName, args }), + from, + ); + return decodeFunctionResult({ abi, functionName, data: out }); +} +const hex = (f) => readFileSync(`calldata/${f}.hex`, "utf8").trim(); +const bytesOf = (h) => (h.length - 2) / 2; +const calldataGas = (h) => { + let g = 0; + for (let i = 2; i < h.length; i += 2) + g += h.slice(i, i + 2) === "00" ? 4 : 16; + return g; +}; + +const out = {}; +for (const [name, c] of Object.entries(CHAINS)) { + const url = c.rpcs[0]; + const blockHex = "0x" + c.block.toString(16); + const blk = await rpc(url, "eth_getBlockByNumber", [blockHex, false]); + const l2BaseFee = BigInt(blk.baseFeePerGas); + const chain = { + chainId: c.chainId, + block: c.block, + blockHash: blk.hash, + timestamp: Number(blk.timestamp), + l2BaseFeeWei: l2BaseFee.toString(), + rpc: url, + crossCheckRpc: c.rpcs[1], + legs: {}, + }; + if (name === "base") { + const O = "0x420000000000000000000000000000000000000F", + L = "0x4200000000000000000000000000000000000015"; + chain.oracle = { + isFjord: await read(url, c.block, O, oracleAbi, "isFjord"), + isJovian: await read(url, c.block, O, oracleAbi, "isJovian"), + l1BaseFee: ( + await read(url, c.block, O, oracleAbi, "l1BaseFee") + ).toString(), + blobBaseFee: ( + await read(url, c.block, O, oracleAbi, "blobBaseFee") + ).toString(), + baseFeeScalar: Number( + await read(url, c.block, O, oracleAbi, "baseFeeScalar"), + ), + blobBaseFeeScalar: Number( + await read(url, c.block, O, oracleAbi, "blobBaseFeeScalar"), + ), + daFootprintGasScalar: Number( + await read(url, c.block, L, l1BlockAbi, "daFootprintGasScalar"), + ), + l1OriginBlock: Number(await read(url, c.block, L, l1BlockAbi, "number")), + }; + } else { + const G = "0x000000000000000000000000000000000000006C"; + const p = await read(url, c.block, G, arbGasInfoAbi, "getPricesInWei"); + chain.arbGasInfo = { + perL2Tx: p[0].toString(), + weiPerL1CalldataByte: p[1].toString(), + weiPerStorageAllocation: p[2].toString(), + perArbGasBase: p[3].toString(), + perArbGasCongestion: p[4].toString(), + perArbGasTotal: p[5].toString(), + l1BaseFeeEstimate: ( + await read(url, c.block, G, arbGasInfoAbi, "getL1BaseFeeEstimate") + ).toString(), + }; + } + for (const [legName, file, toKey] of c.legs) { + for (const size of SIZES) { + const data = hex(`${file}-${size}`); + const to = c[toKey]; + const unsigned = serializeTransaction({ + type: "eip1559", + chainId: c.chainId, + to, + data, + ...TX, + }); + const leg = { + to, + calldataBytes: bytesOf(data), + calldataGasL1Rule: calldataGas(data), + unsignedTxBytes: bytesOf(unsigned), + }; + if (name === "base") { + const O = "0x420000000000000000000000000000000000000F"; + const l1Fee = await read(url, c.block, O, oracleAbi, "getL1Fee", [ + unsigned, + ]); + const l1GasUsed = await read( + url, + c.block, + O, + oracleAbi, + "getL1GasUsed", + [unsigned], + ); + const l1Fee2 = await read( + c.rpcs[1], + c.block, + O, + oracleAbi, + "getL1Fee", + [unsigned], + ); + const o = chain.oracle; + const feeScaled = + BigInt(o.baseFeeScalar) * 16n * BigInt(o.l1BaseFee) + + BigInt(o.blobBaseFeeScalar) * BigInt(o.blobBaseFee); + // l1Fee = estimatedSizeScaled * feeScaled / 1e12 => estimatedSize bytes = l1Fee * 1e12 / feeScaled / 1e6 + const estimatedSizeScaled = (l1Fee * 10n ** 12n) / feeScaled; + Object.assign(leg, { + l1FeeWei: l1Fee.toString(), + l1FeeWeiCrossCheck: l1Fee2.toString(), + l1GasUsed: l1GasUsed.toString(), + estimatedCompressedBytes: Number(estimatedSizeScaled) / 1e6, + daFootprintGas: + Math.floor(Number(estimatedSizeScaled) / 1e6) * + o.daFootprintGasScalar, + l1FeeInL2GasUnits: Number(l1Fee) / Number(l2BaseFee), + }); + } else { + const N = "0x00000000000000000000000000000000000000C8"; + const r = await read( + url, + c.block, + N, + nodeInterfaceAbi, + "gasEstimateL1Component", + [to, false, data], + SENDER, + ); + const r2 = await read( + c.rpcs[1], + c.block, + N, + nodeInterfaceAbi, + "gasEstimateL1Component", + [to, false, data], + SENDER, + ); + const gasForL1 = BigInt(r[0]); + const posterCostWei = gasForL1 * BigInt(r[1]); + // units = 16 * brotli-compressed bytes of the (fake-signed) tx; gas estimation pads them by 256 units and 1% (nitro arbos/l1pricing) + const paddedUnits = Number(posterCostWei) / Number(r[2]); + Object.assign(leg, { + gasEstimateForL1: gasForL1.toString(), + gasEstimateForL1CrossCheck: r2[0].toString(), + l2BaseFeeFromNode: r[1].toString(), + l1BaseFeeEstimate: r[2].toString(), + posterCostWei: posterCostWei.toString(), + paddedL1GasUnits: Math.round(paddedUnits), + }); + } + chain.legs[`${legName} ${size} USDC`] = leg; + } + } + out[name] = chain; +} +console.log(JSON.stringify(out, null, 2)); diff --git a/benchmarks/fork-l2/quote-live-base.mjs b/benchmarks/fork-l2/quote-live-base.mjs new file mode 100644 index 0000000..89976c5 --- /dev/null +++ b/benchmarks/fork-l2/quote-live-base.mjs @@ -0,0 +1,24 @@ +import { + encodeRoute, + encodeQuoteCalldata, +} from "/home/sendmoodz/Work/ekubo/yul-router/sdk/src/index.ts"; +// live Base pool from the Ekubo API poolKeys listing: native ETH / USDC, fee 0x20c49ba5e353f7 (0.05%), spacing 1000 +const route = encodeRoute({ + specifiedToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + calculatedToken: "0x0000000000000000000000000000000000000000", + specifiedAmount: 1_000_000n, + calculatedAmountThreshold: false, + recipient: "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + hops: [ + { + type: "core", + poolKey: { + token0: "0x0000000000000000000000000000000000000000", + token1: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + config: + "0x00000000000000000000000000000000000000000020c49ba5e353f7800003e8", + }, + }, + ], +}); +console.log(encodeQuoteCalldata(route)); diff --git a/benchmarks/fork-l2/remappings.txt b/benchmarks/fork-l2/remappings.txt new file mode 100644 index 0000000..28caac7 --- /dev/null +++ b/benchmarks/fork-l2/remappings.txt @@ -0,0 +1,6 @@ +forge-std/=lib/forge-std/src/ +solmate/=lib/v4-core/lib/solmate/ +solady/=lib/solady/src/ +v4-core/=lib/v4-core/ +ekubo/=lib/ekubo/ +ekubo-test/=lib/ekubo/test/ diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index 998036f..99dfeff 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -26,6 +26,43 @@ Ekubo's transaction costs 11.7% less than v3's and 14.2% less than v4's. Output matched the production quoter within 0.02% on the Ekubo leg, and all three legs receive essentially the same output, so the gap is pure overhead, not price. +## On Base and Arbitrum + +L2 fees have two parts: cheap L2 execution, and a separate charge for the L1 +data the sequencer posts. Both were measured on Base (block 51439900) and +Arbitrum One (block 506178800) for a 100 USDC to WETH swap through each +protocol's deployed contracts on the deepest live Uniswap WETH/USDC pool of each +chain. No live Ekubo pool on either chain could absorb the swap, so the Ekubo +leg runs on a pool created on the fork with the same price, fee tier and +liquidity as the v3 pool, through the production Yul router. The L1 data +component is what each chain's own fee oracle charges for the exact +transaction, converted to gas at the block's L2 base fee: + +| Chain | Leg (100 USDC to WETH) | Execution gas | Calldata bytes | L1 data (gas-equivalent) | Total, incl. 21,000 base and calldata gas | +| -------- | -------------------------- | ------------- | -------------- | ------------------------ | ----------------------------------------- | +| Base | Ekubo, Yul router | **105,084** | 184 | 331 | **128,171** | +| Base | Uniswap v3, SwapRouter02 | 121,803 | 228 | 319 | 144,646 | +| Base | Uniswap v4, minimal locker | 126,959 | 292 | 367 | 150,694 | +| Arbitrum | Ekubo, Yul router | **113,121** | 184 | 561 | **136,870** | +| Arbitrum | Uniswap v3, SwapRouter | 130,896 | 260 | 603 | 154,415 | +| Arbitrum | Uniswap v4, minimal locker | 135,006 | 292 | 644 | 159,234 | + +The mainnet pattern carries over almost unchanged. Ekubo's execution gas is +13.7% below v3 and 17.2% below v4 on Base, 13.6% and 16.2% on Arbitrum, +against 14.0% and 16.4% on the mainnet fork; the whole transaction is 11.4% +and 14.9% cheaper on Base and 11.4% and 14.0% on Arbitrum. Execution gaps do +not compress on L2s, because both chains run the same EVM at the same opcode +prices. + +The data component does not decide anything at these blocks: it is 0.2% to +0.4% of every leg, and on Base it is not even smallest for Ekubo. Base prices +data by estimated compressed size, and the dense 184-byte route compresses +worse than Uniswap's zero-padded 228 bytes (331 against 319 gas-equivalent); +Arbitrum's compressor favors the shorter route, by 42 gas. Compact calldata +buys nothing measurable on either chain while L1 data is this cheap; the saving +is in execution. Pools, block hashes, endpoints, oracle readings and formulas +are in `benchmarks/`. + ## Throughput per block Mainnet's block gas limit is 60 million. Real swap flow is routes, not single @@ -154,6 +191,10 @@ v4 hook or Ekubo extension changes every number here. measurement. Both swap directions covered. - Minimal routers on all sides, which favors Uniswap: production routers cost more than the lab numbers. +- L2s: Base and Arbitrum One forks pinned by block, deployed Uniswap routers + and pools, the deployed Ekubo Yul router on a fresh pool mirroring the v3 + pool; L1 data read from each chain's fee oracle for the exact calldata, not + modeled. - Pools per swap: 1,000 mainnet blocks read through a public JSON-RPC endpoint (block receipts for the event count, full blocks for the router calldata cross-check). Uniswap v2 and all its forks share one event signature and are From 165960ff152583c7385810c86afc803e081ce216 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 17:41:15 -0400 Subject: [PATCH 12/17] Add production-contract fresh-token gas bench and 30-day stride sampling New benchmarks/prod: production Ekubo (Core, Positions, Yul router), Uniswap v3 (factory, SwapRouter) and v4 (PoolManager, Universal Router + Permit2) on a fork of the latest mainnet block with four fresh ERC20 tokens etched at fixed addresses, 1/2/3-pool routes and native-ETH legs through production routers and like-for-like minimal single-lock lockers, isolated and warmed, exact calldata bytes recorded per call. Replaces the USDC/USDT pair as the headline. pools-per-swap/measure.mjs gains --stride sampling across a month, endpoint rotation, receipt/block consistency checks and per-day buckets. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 99 ++++++- benchmarks/pools-per-swap/measure.mjs | 94 +++++- benchmarks/prod/.gitignore | 3 + benchmarks/prod/artifacts/MockERC20.json | 1 + .../prod/calldata/ekubo-minimal-1pool.hex | 1 + .../prod/calldata/ekubo-minimal-2pools.hex | 1 + .../prod/calldata/ekubo-minimal-3pools.hex | 1 + .../prod/calldata/ekubo-minimal-native.hex | 1 + benchmarks/prod/calldata/ekubo-yul-1pool.hex | 1 + benchmarks/prod/calldata/ekubo-yul-2pools.hex | 1 + benchmarks/prod/calldata/ekubo-yul-3pools.hex | 1 + benchmarks/prod/calldata/ekubo-yul-native.hex | 1 + benchmarks/prod/calldata/v3-minimal-1pool.hex | 1 + .../prod/calldata/v3-minimal-2pools.hex | 1 + .../prod/calldata/v3-minimal-3pools.hex | 1 + .../prod/calldata/v3-swaprouter-1pool.hex | 1 + .../prod/calldata/v3-swaprouter-2pools.hex | 1 + .../prod/calldata/v3-swaprouter-3pools.hex | 1 + .../prod/calldata/v3-swaprouter-native.hex | 1 + benchmarks/prod/calldata/v4-minimal-1pool.hex | 1 + .../prod/calldata/v4-minimal-2pools.hex | 1 + .../prod/calldata/v4-minimal-3pools.hex | 1 + .../prod/calldata/v4-minimal-native.hex | 1 + .../calldata/v4-universal-router-1pool.hex | 1 + benchmarks/prod/encode-prod-routes.mjs | 32 +++ benchmarks/prod/foundry.toml | 16 ++ benchmarks/prod/remappings.txt | 5 + benchmarks/prod/run.sh | 12 + benchmarks/prod/snapshots/ProdEkuboTest.json | 52 ++++ benchmarks/prod/snapshots/ProdV3Test.json | 46 +++ benchmarks/prod/snapshots/ProdV4Test.json | 34 +++ benchmarks/prod/test/ProdCommon.sol | 63 ++++ benchmarks/prod/test/ProdEkubo.t.sol | 264 +++++++++++++++++ benchmarks/prod/test/ProdV3.t.sol | 271 ++++++++++++++++++ benchmarks/prod/test/ProdV4.t.sol | 268 +++++++++++++++++ 35 files changed, 1264 insertions(+), 16 deletions(-) create mode 100644 benchmarks/prod/.gitignore create mode 100644 benchmarks/prod/artifacts/MockERC20.json create mode 100644 benchmarks/prod/calldata/ekubo-minimal-1pool.hex create mode 100644 benchmarks/prod/calldata/ekubo-minimal-2pools.hex create mode 100644 benchmarks/prod/calldata/ekubo-minimal-3pools.hex create mode 100644 benchmarks/prod/calldata/ekubo-minimal-native.hex create mode 100644 benchmarks/prod/calldata/ekubo-yul-1pool.hex create mode 100644 benchmarks/prod/calldata/ekubo-yul-2pools.hex create mode 100644 benchmarks/prod/calldata/ekubo-yul-3pools.hex create mode 100644 benchmarks/prod/calldata/ekubo-yul-native.hex create mode 100644 benchmarks/prod/calldata/v3-minimal-1pool.hex create mode 100644 benchmarks/prod/calldata/v3-minimal-2pools.hex create mode 100644 benchmarks/prod/calldata/v3-minimal-3pools.hex create mode 100644 benchmarks/prod/calldata/v3-swaprouter-1pool.hex create mode 100644 benchmarks/prod/calldata/v3-swaprouter-2pools.hex create mode 100644 benchmarks/prod/calldata/v3-swaprouter-3pools.hex create mode 100644 benchmarks/prod/calldata/v3-swaprouter-native.hex create mode 100644 benchmarks/prod/calldata/v4-minimal-1pool.hex create mode 100644 benchmarks/prod/calldata/v4-minimal-2pools.hex create mode 100644 benchmarks/prod/calldata/v4-minimal-3pools.hex create mode 100644 benchmarks/prod/calldata/v4-minimal-native.hex create mode 100644 benchmarks/prod/calldata/v4-universal-router-1pool.hex create mode 100644 benchmarks/prod/encode-prod-routes.mjs create mode 100644 benchmarks/prod/foundry.toml create mode 100644 benchmarks/prod/remappings.txt create mode 100755 benchmarks/prod/run.sh create mode 100644 benchmarks/prod/snapshots/ProdEkuboTest.json create mode 100644 benchmarks/prod/snapshots/ProdV3Test.json create mode 100644 benchmarks/prod/snapshots/ProdV4Test.json create mode 100644 benchmarks/prod/test/ProdCommon.sol create mode 100644 benchmarks/prod/test/ProdEkubo.t.sol create mode 100644 benchmarks/prod/test/ProdV3.t.sol create mode 100644 benchmarks/prod/test/ProdV4.t.sol diff --git a/benchmarks/README.md b/benchmarks/README.md index a68e502..1950dd3 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -12,8 +12,14 @@ measures code and architecture rather than compiler tuning. - `lab/v3/` — Uniswap v3 (`V3Gas.t.sol`, `Flame.t.sol`) - `lab/MockERC20.json` — the single shared token artifact deployed via `deployCode` in all three lab harnesses, so ERC20 transfer costs cannot skew the comparison +- `prod/` — the headline bench: production Ekubo, Uniswap v3 and Uniswap v4 contracts on + a fork of the latest mainnet block with fresh ERC20 tokens (`test/Prod*.t.sol`, + `snapshots/*.json`, `calldata/*.hex` with the exact bytes of every measured call, + `encode-prod-routes.mjs` for the SDK routes, `run.sh`); see "Production-contract bench" + below - `pools-per-swap/` — the pools-touched-per-swap-transaction measurement behind the - chain-wide section (`measure.mjs`, `results.json`); see below + chain-wide section (`measure.mjs`, `results.json` for the 1,000-block window, + `results-30d.json` for the 30-day stride sample); see below - `fork/` — mainnet-fork validation at pinned block `25991868` (`ForkEkuboReal.t.sol`, `ForkV3.t.sol`, `ForkV4.t.sol` for swaps; `ForkMintEkubo.t.sol`, `ForkMintV3.t.sol`, `ForkMintV4.t.sol` for mints through the @@ -46,6 +52,97 @@ every snapshot via `vm.lastCallGas`; every Ekubo and v4 measurement has zero ref their snapshot, `lastCallGas().gasTotalUsed`, and a `gasleft()` delta around the call agree to within the caller's own overhead (roughly 1–5k, the CALL and argument copying). +## Production-contract bench (`prod/`) + +The page's headline and route-scaling numbers. Every contract that executes a measured +call is the deployed production one on Ethereum mainnet, read from a fork of the latest +block at run time (`run.sh`; no block pin, so the bench reproduces on any day — the +committed snapshots record the block they were taken at, `prod fork block number` +25999935, timestamp 1789680959, 2026-09-17 21:35:59 UTC). Only the tokens are new: + +- **Tokens.** Four fresh solmate `MockERC20` contracts (the lab artifact, + `artifacts/MockERC20.json`) etched at fixed, address-ordered addresses + `0x1111…1111` (A) `< 0x2222…2222` (B) `< 0x3333…3333` (C) `< 0x4444…4444` (D), asserted + codeless on the chain before etching. Fixed addresses make the SDK route calldata + reproducible; all-nonzero bytes make it pay the full 16 gas per address byte (a real + address has about one zero byte in 256, so this is marginally pessimistic for every + leg alike). Plain ERC20 transfers, no proxy, no blocklist, no fee-on-transfer: the + USDC/USDT fork numbers below keep the expensive-token case. +- **Contracts.** Ekubo Core `0x00000000000014aA86C5d3c41765bb24e11bd701`, Positions + `0x02D9876A21AF7545f8632C3af76eC90b5ad4b66D` (asserted `name() == "Ekubo Positions"`), + Yul router `0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748`; Uniswap v3 factory + `0x1F98431c8aD98523631AE4a59f267346ea31F984` and SwapRouter + `0xE592427A0AEce92De3Edee1F18E0157C05861564` (asserted `factory()` and `WETH9()`); + Uniswap v4 PoolManager `0x000000000004444c5dc75cB358380D2e3dE08A90`, Universal Router + `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af` and Permit2 + `0x000000000022D473030F116dDEE9F6B43aC78BA3`. Every address is checked for code on the + fork; the Ekubo addresses are the ones in the docs' EVM contract reference. +- **Pools.** A/B, B/C, C/D and a native pool ETH/B on each protocol, 0.3% fee, Uniswap + tick spacing 60 (Ekubo spacing 6000; its ticks are 100x finer), initialized strictly + inside a tick (Uniswap tick 30, Ekubo tick 3050 = Uniswap 30.5, so Ekubo's pools sit + 0.005% higher and its outputs are 0.005% larger; irrelevant to gas). One full-range + position per pool of the liquidity that 1M tokens a side buys at that price + (v3 mints the same liquidity figure as the lab, 998501199320305883812938). Ekubo pools + are asserted uninitialized on the fork before creation; Uniswap pools are asserted + absent from the factory / PoolManager. Capitalization is unmeasured: Ekubo through the + production Positions manager (`mintAndDepositWithSalt` with explicit salts — the + deployed manager's `mintAndDeposit` derives its salt from prevrandao and remaining + gas, which back-to-back mints in one context share), v3 by a direct `pool.mint` with + the test as callback payer, v4 through v4-core's `PoolModifyLiquidityTest`. +- **Isolation and warmth.** Every measured call is a top-level call from the test under + `isolate = true`, so `vm.snapshotGasLastCall` is that call's execution gas net of + refunds, excluding the 21,000 base and calldata. One identical, unmeasured call + precedes every measurement, so every storage slot the swap touches is already nonzero + (pool state, both fee-growth accumulators, the recipient's output balance, which is + asserted nonzero before the measured call, and the router's approvals). Pool creation, + tick initialization, first-swap accumulator writes and NFT mints are therefore + excluded by design. Every measured swap is exact-input 1 token; the last pool's tick is + asserted to move by at most one Uniswap tick (measured: 0 on Uniswap, 2 fine ticks = + 0.02 Uniswap ticks on Ekubo), and the router's reported output is asserted equal to + the recipient's balance change. +- **Calldata.** The exact bytes of every measured call are written to `calldata/*.hex` + and recorded in the snapshot as byte count, EIP-2028 gas (4 per zero byte, 16 per + nonzero byte) and EIP-7623 floor tokens; the floor never binds here (execution is far + above it). Transaction gas on the page = snapshot + 21,000 + EIP-2028 calldata gas. +- **Paths.** Two tiers per protocol. Production routers: the Ekubo Yul router with + SDK-generated routes (`encode-prod-routes.mjs`, 184 / 273 / 362 bytes for 1 / 2 / 3 + pools), the v3 SwapRouter (`exactInputSingle`, `exactInput` with a packed path; the + native leg sends ETH to the router, which wraps it in flight), and the v4 Universal + Router (`execute` with one `V4_SWAP` command: `SWAP_EXACT_IN_SINGLE`, `SETTLE_ALL`, + `TAKE_ALL`, funded through Permit2 with a standing allowance granted in setup; the + `ExactInputSingleParams` layout is the 2025 deployment's, without `minHopPriceX36`, + confirmed by the call succeeding with the same output as the minimal locker to 1e-15). + Minimal single-lock routers: array-based N-hop lockers of the same shape on all three + sides (`MinimalEkuboRouter`: lock, swap each hop, pay once, withdraw once; + `V4MinimalRouter`: unlock, swap each hop, settle once, take once; `V3MinimalRouter`: + the SwapRouter's token flow with 2 transfers per pool). Minimal routers favor + Uniswap: its production routers cost more than the minimal figures, while Ekubo's + production Yul router is cheaper than its minimal locker. + +| Leg (execution gas) | 1 pool | 2 pools | 3 pools | Native ETH in, 1 pool | Calldata bytes (1 / 2 / 3 pools) | +| -------------------------------------- | ------- | ------- | ------- | --------------------- | -------------------------------- | +| Ekubo, production Yul router | 87,663 | 109,850 | 132,025 | 73,764 | 184 / 273 / 362 | +| Ekubo, minimal locker | 89,733 | 112,843 | 135,952 | 74,585 | 260 / 356 / 452 | +| Uniswap v4, Universal Router + Permit2 | 117,114 | — | — | — | 1,092 | +| Uniswap v4, minimal locker | 107,384 | 141,795 | 176,206 | 92,472 | 356 / 548 / 740 | +| Uniswap v3, SwapRouter | 100,299 | 165,719 | 230,478 | 103,504 (wraps ETH) | 260 / 324 / 324 | +| Uniswap v3, minimal router | 94,078 | 152,774 | 211,470 | — | 260 / 324 / 388 | + +Marginal execution gas per extra pool (2→3 pools): Ekubo Yul 22,175 (1→2: 22,187), +Ekubo minimal 23,109, v4 minimal 34,411, v3 minimal 58,696, v3 SwapRouter 64,759 +(1→2: 65,420). The v3 minimal-router figures equal the lab harness's to the gas unit +(94,078 / 152,774 / 211,470), which ties the two benches together. Outputs: Ekubo +1000042490223401887 / 1000084982120610903 / 1000127475691875278 wei for 1 / 2 / 3 +pools, Uniswap v3 999992341035506990 / 999984682148650249 / 999977023339428738, v4 +999992341035507518 / 999984682148651304 / 999977023339430321 (the 0.005% Ekubo +premium is the half-tick higher init price). + +Dependencies (`lib/`, git-ignored): forge-std, solady, `EkuboProtocol/evm-contracts` at +`v3.2.0` as `lib/ekubo` (with its own `lib/forge-std` and `lib/solady`), `Uniswap/v4-core` +(with its `lib/solmate`); `remappings.txt` lists the mapping. Foundry 1.8.3, solc +auto-detected per file (0.8.26 for the Uniswap-side tests, 0.8.33 for the Ekubo test), +`via_ir`, 1,000,000 optimizer runs, `evm_version = "osaka"`. + ## Lab methodology (all three harnesses) Each test runs on a fresh chain. Pools are capitalized in setup, and every measured call diff --git a/benchmarks/pools-per-swap/measure.mjs b/benchmarks/pools-per-swap/measure.mjs index d677861..9b6ab31 100644 --- a/benchmarks/pools-per-swap/measure.mjs +++ b/benchmarks/pools-per-swap/measure.mjs @@ -15,7 +15,15 @@ // calldata (Universal Router commands, SwapRouter02 / SwapRouter // exactInput paths, 1inch unoswapN). Router flow only. // -// Usage: node measure.mjs [--blocks N] [--to BLOCK] [--rpc URL] [--out FILE] +// Usage: node measure.mjs [--blocks N] [--stride S] [--to BLOCK] [--rpc URL[,URL...]] [--conc C] [--out FILE] +// +// --blocks N number of blocks to read (default 200) +// --stride S read every S-th block, so the N blocks span N*S blocks ending at --to +// (default 1 = consecutive). A one-month window at 12 s/block is 216,000 +// blocks; --blocks 21600 --stride 10 reads a uniform 10% sample of it. +// --rpc comma-separated JSON-RPC endpoints; requests rotate across them on +// failure. Endpoints must serve eth_getBlockReceipts for the whole window +// (publicnode returns null for blocks older than a few days). import { writeFileSync } from "node:fs"; @@ -25,8 +33,13 @@ const args = Object.fromEntries( .map((a, i, arr) => (a.startsWith("--") ? [a.slice(2), arr[i + 1]] : null)) .filter(Boolean), ); -const RPC = args.rpc ?? "https://ethereum-rpc.publicnode.com"; +const RPCS = ( + args.rpc ?? + "https://eth.drpc.org,https://mainnet.gateway.tenderly.co,https://eth.merkle.io,https://eth.rpc.blxrbdn.com" +).split(","); const BLOCKS = Number(args.blocks ?? 200); +const STRIDE = Number(args.stride ?? 1); +const CONC = Number(args.conc ?? 4); const OUT = args.out ?? "results.json"; // Event signatures. Every signature was read from the protocol's own source (repo path in @@ -151,11 +164,17 @@ const ROUTERS = { }; let rpcCalls = 0; +let rpcRetries = 0; +let rpcSeq = 0; async function rpc(method, params) { + const seed = rpcSeq++; for (let attempt = 0; ; attempt++) { + const url = RPCS[(seed + attempt) % RPCS.length]; try { rpcCalls++; - const res = await fetch(RPC, { + if (attempt) rpcRetries++; + const res = await fetch(url, { + signal: AbortSignal.timeout(30000), method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), @@ -367,7 +386,7 @@ function decodeRouterTx(kind, input) { // ---- main --------------------------------------------------------------------- const latest = Number(BigInt(await rpc("eth_blockNumber", []))); const toBlock = Number(args.to ?? latest - 12); // stay behind the head to avoid reorgs -const fromBlock = toBlock - BLOCKS + 1; +const fromBlock = toBlock - (BLOCKS - 1) * STRIDE; for (const [addr, r] of Object.entries(ROUTERS)) { const code = await rpc("eth_getCode", [addr, "latest"]); @@ -426,8 +445,17 @@ const routerStat = (name) => }); let firstTs, lastTs; -const CONC = 4; -const blocks = Array.from({ length: BLOCKS }, (_, i) => fromBlock + i); +const blocks = Array.from({ length: BLOCKS }, (_, i) => fromBlock + i * STRIDE); +// Per-UTC-day buckets, to show how the averages move across the window's gas and market regimes. +const daily = {}; +const dayStat = (ts) => + (daily[new Date(ts * 1000).toISOString().slice(0, 10)] ??= { + blocks: 0, + swapTxs: 0, + sumPools: 0, + gasSwapTxs: 0n, + gasAll: 0n, + }); let next = 0; // Count pool-level swap events in one receipt; returns { counts by family, total }. function swapEventsIn(receipt) { @@ -455,10 +483,12 @@ function swapEventsIn(receipt) { } // Measurement 1: tally one block's receipts. Returns tx hash -> pool count for the cross-check. -function tallyReceipts(rcpts) { +function tallyReceipts(rcpts, day) { const eventPools = new Map(); + day.blocks++; for (const r of rcpts) { receipts.gasUsedAll += BigInt(r.gasUsed); + day.gasAll += BigInt(r.gasUsed); receipts.allTxs++; if (r.status === "0x1") receipts.successfulTxs++; const { counts, emitters, total } = swapEventsIn(r); @@ -471,6 +501,9 @@ function tallyReceipts(rcpts) { receipts.sumPools += total; eventPools.set(r.transactionHash, total); receipts.gasUsedSwapTxs += BigInt(r.gasUsed); + day.swapTxs++; + day.sumPools += total; + day.gasSwapTxs += BigInt(r.gasUsed); bump(receipts.hist, Math.min(total, 4)); for (const [f, c] of Object.entries(counts)) { const s = fam(f); @@ -527,7 +560,7 @@ function tallyBlock(block, rcpts) { const status = new Map( rcpts.map((r) => [r.transactionHash, r.status === "0x1"]), ); - const eventPools = tallyReceipts(rcpts); + const eventPools = tallyReceipts(rcpts, dayStat(Number(BigInt(block.timestamp)))); for (const tx of block.transactions) { const router = ROUTERS[(tx.to ?? "").toLowerCase()]; if (!router) continue; @@ -545,16 +578,30 @@ async function worker() { while (next < blocks.length) { const bn = blocks[next++]; const tag = "0x" + bn.toString(16); - const [block, rcpts] = await Promise.all([ - rpc("eth_getBlockByNumber", [tag, true]), - rpc("eth_getBlockReceipts", [tag]), - ]); + let block, rcpts; + for (let attempt = 0; ; attempt++) { + [block, rcpts] = await Promise.all([ + rpc("eth_getBlockByNumber", [tag, true]), + rpc("eth_getBlockReceipts", [tag]), + ]); + // an endpoint that serves a block but not its receipts (or a partial set) is rejected + if ( + Array.isArray(rcpts) && + rcpts.length === block.transactions.length && + rcpts.every((r, i) => r.transactionHash === block.transactions[i].hash) + ) + break; + if (attempt >= 8) throw new Error(`block ${bn}: receipts do not match block`); + rpcRetries++; + await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); + } const ts = Number(BigInt(block.timestamp)); if (bn === fromBlock) firstTs = ts; if (bn === toBlock) lastTs = ts; tallyBlock(block, rcpts); - if ((bn - fromBlock) % 50 === 0) - process.stderr.write(`block ${bn} (${bn - fromBlock + 1}/${BLOCKS})\n`); + const idx = (bn - fromBlock) / STRIDE; + if (idx % 200 === 0) + process.stderr.write(`block ${bn} (${idx + 1}/${BLOCKS})\n`); } } await Promise.all(Array.from({ length: CONC }, worker)); @@ -569,10 +616,13 @@ const histShare = (h, n) => ); const out = { - rpc: RPC, + rpc: RPCS, window: { fromBlock, toBlock, + stride: STRIDE, + blocksRead: BLOCKS, + blocksSpanned: toBlock - fromBlock + 1, blocks: BLOCKS, fromTimestamp: firstTs, toTimestamp: lastTs, @@ -580,7 +630,21 @@ const out = { toIso: new Date(lastTs * 1000).toISOString(), }, rpcCalls, + rpcRetries, families: FAMILIES, + daily: Object.fromEntries( + Object.entries(daily) + .sort() + .map(([d, s]) => [ + d, + { + blocks: s.blocks, + swapTxs: s.swapTxs, + avgPoolsPerSwapTx: +(s.sumPools / (s.swapTxs || 1)).toFixed(3), + swapTxGasSharePct: s.gasAll ? +((10000n * s.gasSwapTxs) / s.gasAll).toString() / 100 : 0, + }, + ]), + ), ekuboCore: { address: EKUBO_CORE, codeBytes: ekuboCoreCodeBytes, diff --git a/benchmarks/prod/.gitignore b/benchmarks/prod/.gitignore new file mode 100644 index 0000000..182be85 --- /dev/null +++ b/benchmarks/prod/.gitignore @@ -0,0 +1,3 @@ +out/ +cache/ +lib/ diff --git a/benchmarks/prod/artifacts/MockERC20.json b/benchmarks/prod/artifacts/MockERC20.json new file mode 100644 index 0000000..860972c --- /dev/null +++ b/benchmarks/prod/artifacts/MockERC20.json @@ -0,0 +1 @@ +{"abi":[{"type":"constructor","inputs":[{"name":"_name","type":"string","internalType":"string"},{"name":"_symbol","type":"string","internalType":"string"},{"name":"_decimals","type":"uint8","internalType":"uint8"}],"stateMutability":"nonpayable"},{"type":"function","name":"DOMAIN_SEPARATOR","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"allowance","inputs":[{"name":"","type":"address","internalType":"address"},{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"approve","inputs":[{"name":"spender","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"balanceOf","inputs":[{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"burn","inputs":[{"name":"from","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"decimals","inputs":[],"outputs":[{"name":"","type":"uint8","internalType":"uint8"}],"stateMutability":"view"},{"type":"function","name":"mint","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"nonces","inputs":[{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"permit","inputs":[{"name":"owner","type":"address","internalType":"address"},{"name":"spender","type":"address","internalType":"address"},{"name":"value","type":"uint256","internalType":"uint256"},{"name":"deadline","type":"uint256","internalType":"uint256"},{"name":"v","type":"uint8","internalType":"uint8"},{"name":"r","type":"bytes32","internalType":"bytes32"},{"name":"s","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"symbol","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"totalSupply","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"transfer","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"transferFrom","inputs":[{"name":"from","type":"address","internalType":"address"},{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"event","name":"Approval","inputs":[{"name":"owner","type":"address","indexed":true,"internalType":"address"},{"name":"spender","type":"address","indexed":true,"internalType":"address"},{"name":"amount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"Transfer","inputs":[{"name":"from","type":"address","indexed":true,"internalType":"address"},{"name":"to","type":"address","indexed":true,"internalType":"address"},{"name":"amount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false}],"bytecode":{"object":"0x60e080604052346104065761134e803803809161001c828561040a565b83398101906060818303126104065780516001600160401b038111610406578261004791830161042d565b60208201519092906001600160401b0381116104065760409161006b91840161042d565b91015160ff811681036104065782516001600160401b038111610337576100925f54610482565b601f81116103b7575b506020601f821160011461035657819293945f9261034b575b50508160011b915f199060031b1c1916175f555b81516001600160401b038111610337576100e3600154610482565b601f81116102d4575b50602092601f821160011461027357928192935f92610268575b50508160011b915f199060031b1c1916176001555b6080524660a0526040515f905f54918161013484610482565b9182825260208201946001811690815f1461024c5750600114610202575b61015e9250038261040a565b51902060405160208101917f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f835260408201527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc660608201524660808201523060a082015260a081526101d260c08261040a565b51902060c052604051610e7390816104bb823960805181610865015260a05181610ca8015260c05181610cce0152f35b505f80805290915f8051602061132e8339815191525b81831061023057505090602061015e92820101610152565b6020919350806001915483858801015201910190918392610218565b60ff191686525061015e92151560051b82016020019050610152565b015190505f80610106565b601f1982169360015f52805f20915f5b8681106102bc57508360019596106102a4575b505050811b0160015561011b565b01515f1960f88460031b161c191690555f8080610296565b91926020600181928685015181550194019201610283565b60015f527fb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6601f830160051c8101916020841061032d575b601f0160051c01905b81811061032257506100ec565b5f8155600101610315565b909150819061030c565b634e487b7160e01b5f52604160045260245ffd5b015190505f806100b4565b601f198216905f8052805f20915f5b81811061039f57509583600195969710610387575b505050811b015f556100c8565b01515f1960f88460031b161c191690555f808061037a565b9192602060018192868b015181550194019201610365565b5f80525f8051602061132e833981519152601f830160051c810191602084106103fc575b601f0160051c01905b8181106103f1575061009b565b5f81556001016103e4565b90915081906103db565b5f80fd5b601f909101601f19168101906001600160401b0382119082101761033757604052565b81601f82011215610406578051906001600160401b0382116103375760405192610461601f8401601f19166020018561040a565b8284526020838301011161040657815f9260208093018386015e8301015290565b90600182811c921680156104b0575b602083101461049c57565b634e487b7160e01b5f52602260045260245ffd5b91607f169161049156fe60806040526004361015610011575f80fd5b5f3560e01c806306fdde0314610a8b578063095ea7b3146109e857806318160ddd146109ad57806323b872dd14610889578063313ce5671461082e5780633644e515146107ee57806340c10f191461071f57806370a08231146106bc5780637ecebe001461065957806395d89b41146105435780639dc29fac146104a4578063a9059cbb146103f0578063d505accf146101435763dd62ed3e146100b3575f80fd5b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576100ea610c52565b73ffffffffffffffffffffffffffffffffffffffff610107610c75565b91165f52600460205273ffffffffffffffffffffffffffffffffffffffff60405f2091165f52602052602060405f2054604051908152f35b5f80fd5b3461013f5760e07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5761017a610c52565b610182610c75565b6044356064359260843560ff811680910361013f574285106103925760805f9160209373ffffffffffffffffffffffffffffffffffffffff6101c2610ca5565b91169687855260058652604085209889549960018b0190556040519073ffffffffffffffffffffffffffffffffffffffff888301937f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c985528b6040850152169a8b6060840152898784015260a083015260c082015260c0815261024660e082610b9c565b51902060405190868201927f19010000000000000000000000000000000000000000000000000000000000008452602283015260428201526042815261028d606282610b9c565b519020906040519182528482015260a435604082015260c435606082015282805260015afa156103875773ffffffffffffffffffffffffffffffffffffffff5f51168015158061037e575b15610320577f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925916020915f526004825260405f20855f5282528060405f2055604051908152a3005b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600e60248201527f494e56414c49445f5349474e45520000000000000000000000000000000000006044820152fd5b508281146102d8565b6040513d5f823e3d90fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f5045524d49545f444541444c494e455f455850495245440000000000000000006044820152fd5b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610427610c52565b73ffffffffffffffffffffffffffffffffffffffff60243591335f52600360205260405f20610457848254610c98565b90551690815f52600360205260405f208181540190556040519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60203392a3602060405160018152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f575f6104dc610c52565b7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef602073ffffffffffffffffffffffffffffffffffffffff6024359316928385526003825260408520610530828254610c98565b90558060025403600255604051908152a3005b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576040515f60015461058181610b4b565b808452906001811690811561061757506001146105b9575b6105b5836105a981850382610b9c565b60405191829182610c0a565b0390f35b60015f9081527fb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6939250905b8082106105fd575090915081016020016105a9610599565b9192600181602092548385880101520191019092916105e5565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff001660208086019190915291151560051b840190910191506105a99050610599565b3461013f5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5773ffffffffffffffffffffffffffffffffffffffff6106a5610c52565b165f526005602052602060405f2054604051908152f35b3461013f5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5773ffffffffffffffffffffffffffffffffffffffff610708610c52565b165f526003602052602060405f2054604051908152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610756610c52565b602435906002548281018091116107c157602073ffffffffffffffffffffffffffffffffffffffff5f937fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9360025516938484526003825260408420818154019055604051908152a3005b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576020610826610ca5565b604051908152f35b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57602060405160ff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b3461013f5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576108c0610c52565b6108c8610c75565b7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef602073ffffffffffffffffffffffffffffffffffffffff80604435951693845f526004835260405f208233165f52835260405f2054867fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203610986575b5050845f526003835260405f2061095f878254610c98565b90551693845f526003825260405f20818154019055604051908152a3602060405160018152f35b61098f91610c98565b855f526004845260405f208333165f52845260405f20558686610947565b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576020600254604051908152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610a1f610c52565b73ffffffffffffffffffffffffffffffffffffffff60243591335f52600460205260405f208282165f526020528260405f205560405192835216907f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560203392a3602060405160018152f35b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576040515f8054610ac881610b4b565b80845290600181169081156106175750600114610aef576105b5836105a981850382610b9c565b5f8080527f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563939250905b808210610b31575090915081016020016105a9610599565b919260018160209254838588010152019101909291610b19565b90600182811c92168015610b92575b6020831014610b6557565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b91607f1691610b5a565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff821117610bdd57604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f602060409481855280519182918282880152018686015e5f8582860101520116010190565b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361013f57565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361013f57565b919082039182116107c157565b467f000000000000000000000000000000000000000000000000000000000000000003610cf0577f000000000000000000000000000000000000000000000000000000000000000090565b6040515f905f549181610d0284610b4b565b9182825260208201946001811690815f14610e035750600114610da6575b610d2c92500382610b9c565b51902060405160208101917f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f835260408201527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc660608201524660808201523060a082015260a08152610da060c082610b9c565b51902090565b505f80805290917f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e5635b818310610de7575050906020610d2c92820101610d20565b6020919350806001915483858801015201910190918392610dcf565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0016865250610d2c92151560051b82016020019050610d2056fea26469706673582212200e4e3600233109bb884b081d524b13dac8383267fd5a07dfc715d3d22497968a64736f6c634300081a0033290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563","sourceMap":"118:368:20:-:0;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;-1:-1:-1;;;;;118:368:20;;;;;;;;;;:::i;:::-;;;;;;;;-1:-1:-1;;;;;118:368:20;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;-1:-1:-1;;;;;118:368:20;;;;;-1:-1:-1;118:368:20;;:::i;:::-;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;-1:-1:-1;;;;;118:368:20;;;;;2116:16:21;118:368:20;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;2116:16:21;118:368:20;;2142:20:21;;2192:13;2173:32;;118:368:20;;-1:-1:-1;118:368:20;-1:-1:-1;118:368:20;;;;;;:::i;:::-;;;;;;;;;2116:16:21;118:368:20;;2116:16:21;;118:368:20;2116:16:21;;;118:368:20;;;;;;;;;;;;:::i;:::-;;5789:22:21;;118:368:20;;;5640:295:21;;118:368:20;5672:95:21;118:368:20;;;;;;5833:14:21;118:368:20;;;;2192:13:21;2142:20;118:368:20;;;5912:4:21;2173:32;118:368:20;;;2173:32:21;5640:295;;;;;;:::i;:::-;118:368:20;5613:336:21;;5640:295;2215:51;118:368:20;;;;;;;;2142:20:21;118:368:20;;;;;2173:32:21;118:368:20;;;;;5640:295:21;118:368:20;;;;;;;-1:-1:-1;;118:368:20;;;;;-1:-1:-1;;;;;;;;;;;118:368:20;;;;;;;;;;;;;;;;;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;;;;;;;;-1:-1:-1;;118:368:20;;;-1:-1:-1;118:368:20;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;2116:16:21;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;2116:16:21;118:368:20;;;;;;;;;;;;;;;;2116:16:21;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;2116:16:21;118:368:20;;;;;;-1:-1:-1;118:368:20;;;;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;;;;-1:-1:-1;118:368:20;;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;-1:-1:-1;;;;;;;;;;;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;-1:-1:-1;;118:368:20;;;;-1:-1:-1;;;;;118:368:20;;;;;;;;;;:::o;:::-;;;;;;;;;;;;-1:-1:-1;;;;;118:368:20;;;;;;;;;;;-1:-1:-1;;118:368:20;;;;;:::i;:::-;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;:::o;:::-;;;;;;;;;;;;;;;;;;;:::o;:::-;;;;;;;;;;;;;;;;;;","linkReferences":{}},"deployedBytecode":{"object":"0x60806040526004361015610011575f80fd5b5f3560e01c806306fdde0314610a8b578063095ea7b3146109e857806318160ddd146109ad57806323b872dd14610889578063313ce5671461082e5780633644e515146107ee57806340c10f191461071f57806370a08231146106bc5780637ecebe001461065957806395d89b41146105435780639dc29fac146104a4578063a9059cbb146103f0578063d505accf146101435763dd62ed3e146100b3575f80fd5b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576100ea610c52565b73ffffffffffffffffffffffffffffffffffffffff610107610c75565b91165f52600460205273ffffffffffffffffffffffffffffffffffffffff60405f2091165f52602052602060405f2054604051908152f35b5f80fd5b3461013f5760e07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5761017a610c52565b610182610c75565b6044356064359260843560ff811680910361013f574285106103925760805f9160209373ffffffffffffffffffffffffffffffffffffffff6101c2610ca5565b91169687855260058652604085209889549960018b0190556040519073ffffffffffffffffffffffffffffffffffffffff888301937f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c985528b6040850152169a8b6060840152898784015260a083015260c082015260c0815261024660e082610b9c565b51902060405190868201927f19010000000000000000000000000000000000000000000000000000000000008452602283015260428201526042815261028d606282610b9c565b519020906040519182528482015260a435604082015260c435606082015282805260015afa156103875773ffffffffffffffffffffffffffffffffffffffff5f51168015158061037e575b15610320577f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925916020915f526004825260405f20855f5282528060405f2055604051908152a3005b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600e60248201527f494e56414c49445f5349474e45520000000000000000000000000000000000006044820152fd5b508281146102d8565b6040513d5f823e3d90fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f5045524d49545f444541444c494e455f455850495245440000000000000000006044820152fd5b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610427610c52565b73ffffffffffffffffffffffffffffffffffffffff60243591335f52600360205260405f20610457848254610c98565b90551690815f52600360205260405f208181540190556040519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60203392a3602060405160018152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f575f6104dc610c52565b7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef602073ffffffffffffffffffffffffffffffffffffffff6024359316928385526003825260408520610530828254610c98565b90558060025403600255604051908152a3005b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576040515f60015461058181610b4b565b808452906001811690811561061757506001146105b9575b6105b5836105a981850382610b9c565b60405191829182610c0a565b0390f35b60015f9081527fb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6939250905b8082106105fd575090915081016020016105a9610599565b9192600181602092548385880101520191019092916105e5565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff001660208086019190915291151560051b840190910191506105a99050610599565b3461013f5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5773ffffffffffffffffffffffffffffffffffffffff6106a5610c52565b165f526005602052602060405f2054604051908152f35b3461013f5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f5773ffffffffffffffffffffffffffffffffffffffff610708610c52565b165f526003602052602060405f2054604051908152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610756610c52565b602435906002548281018091116107c157602073ffffffffffffffffffffffffffffffffffffffff5f937fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9360025516938484526003825260408420818154019055604051908152a3005b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576020610826610ca5565b604051908152f35b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57602060405160ff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b3461013f5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576108c0610c52565b6108c8610c75565b7fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef602073ffffffffffffffffffffffffffffffffffffffff80604435951693845f526004835260405f208233165f52835260405f2054867fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203610986575b5050845f526003835260405f2061095f878254610c98565b90551693845f526003825260405f20818154019055604051908152a3602060405160018152f35b61098f91610c98565b855f526004845260405f208333165f52845260405f20558686610947565b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576020600254604051908152f35b3461013f5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f57610a1f610c52565b73ffffffffffffffffffffffffffffffffffffffff60243591335f52600460205260405f208282165f526020528260405f205560405192835216907f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560203392a3602060405160018152f35b3461013f575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261013f576040515f8054610ac881610b4b565b80845290600181169081156106175750600114610aef576105b5836105a981850382610b9c565b5f8080527f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563939250905b808210610b31575090915081016020016105a9610599565b919260018160209254838588010152019101909291610b19565b90600182811c92168015610b92575b6020831014610b6557565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b91607f1691610b5a565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff821117610bdd57604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f602060409481855280519182918282880152018686015e5f8582860101520116010190565b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361013f57565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361013f57565b919082039182116107c157565b467f000000000000000000000000000000000000000000000000000000000000000003610cf0577f000000000000000000000000000000000000000000000000000000000000000090565b6040515f905f549181610d0284610b4b565b9182825260208201946001811690815f14610e035750600114610da6575b610d2c92500382610b9c565b51902060405160208101917f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f835260408201527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc660608201524660808201523060a082015260a08152610da060c082610b9c565b51902090565b505f80805290917f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e5635b818310610de7575050906020610d2c92820101610d20565b6020919350806001915483858801015201910190918392610dcf565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0016865250610d2c92151560051b82016020019050610d2056fea26469706673582212200e4e3600233109bb884b081d524b13dac8383267fd5a07dfc715d3d22497968a64736f6c634300081a0033","sourceMap":"118:368:20:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;:::i;:::-;;;;;;;;;;;;1388:64:21;118:368:20;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;:::i;:::-;;;;;;;;;;;;;;;;4057:15:21;4045:27;;118:368:20;;;;4428:18:21;118:368:20;4428:18:21;118:368:20;4428:18:21;;:::i;:::-;118:368:20;;;;;;4873:6:21;118:368:20;;;;;;;;;;;;;;;;4511:449:21;118:368:20;4511:449:21;;;118:368:20;4555:165:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;4511:449:21;;;118:368:20;4511:449:21;;:::i;:::-;118:368:20;4472:514:21;;118:368:20;;4350:658:21;;;;118:368:20;;;;;;;;;;;;;4350:658:21;;;;;;:::i;:::-;118:368:20;4319:707:21;;118:368:20;;;;;;;;;;;;;;;;;;;;;;4292:805:21;;;118:368:20;4292:805:21;;;;;118:368:20;;4292:805:21;118:368:20;5120:30:21;;;:59;;;118:368:20;;;;5283:31:21;118:368:20;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;5283:31:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;5120:59:21;5154:25;;;;5120:59;;4292:805;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;2774:10:21;;118:368:20;;2764:9:21;118:368:20;;;;;2764:31:21;118:368:20;;;2764:31:21;:::i;:::-;118:368:20;;;;;;;2764:9:21;118:368:20;;;;;;;;;;;;;;;;2990:32:21;118:368:20;2774:10:21;2990:32;;118:368:20;;;;;;;;;;;;;;;;;;;;;:::i;:::-;6768:34:21;118:368:20;;;;;;;;;;6553:9:21;118:368:20;;;;;6553:25:21;118:368:20;;;6553:25:21;:::i;:::-;118:368:20;;;6721:21:21;118:368:20;;6721:21:21;118:368:20;;;;;;6768:34:21;118:368:20;;;;;;;;;;;;;;;1056:20:21;118:368:20;;;;:::i;:::-;;;;;1056:20:21;118:368:20;;;1056:20:21;;;;118:368:20;;;;;;;;;;;;;;:::i;:::-;;;;;;;;:::i;:::-;;;;;1056:20:21;118:368:20;;;;;;;-1:-1:-1;118:368:20;;;;;;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;;;1056:20:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;:::i;:::-;;;;1751:41:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;1337:44:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;6220:21:21;118:368:20;;;;;;;;;;;;;6436:32:21;118:368:20;6220:21:21;118:368:20;;;;;;6387:9:21;118:368:20;;;;;;;;;;;;;;;;6436:32:21;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;1083:31:21;118:368:20;;;;;;;;;;;;;;;;;:::i;:::-;;;:::i;:::-;3594:26:21;118:368:20;;;;;;;;;;;;;;;;;3225:10:21;;118:368:20;-1:-1:-1;118:368:20;;;;-1:-1:-1;118:368:20;;3287:28:21;3298:17;3287:28;;3283:80;;118:368:20;;;;;;3374:9:21;118:368:20;;;;;3374:25:21;118:368:20;;;3374:25:21;:::i;:::-;118:368:20;;;;;;;3374:9:21;118:368:20;;;;;;;;;;;;;;;;3594:26:21;118:368:20;;;;;;;3283:80:21;3347:16;;;:::i;:::-;118:368:20;;;;;;;;;3225:10:21;;118:368:20;-1:-1:-1;118:368:20;;;;-1:-1:-1;118:368:20;;3283:80:21;;;;118:368:20;;;;;;;;;;;;1304:26:21;118:368:20;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;2561:10:21;;118:368:20;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;;2561:10:21;2606:37;118:368:20;2561:10:21;2606:37;;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;-1:-1:-1;118:368:20;;;;;;;-1:-1:-1;118:368:20;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;-1:-1:-1;118:368:20;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;;;;;;;;;;:::o;:::-;;;;;;;;;;;:::o;:::-;;;;;;;;;;:::o;5327:177:21:-;5410:13;5427:16;5410:33;5427:16;;5446:24;5327:177;:::o;5410:87::-;118:368:20;;-1:-1:-1;118:368:20;-1:-1:-1;118:368:20;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;5789:22:21;;118:368:20;;;5640:295:21;;118:368:20;5672:95:21;118:368:20;;;;;;5833:14:21;118:368:20;;;;5410:13:21;118:368:20;;;;5912:4:21;118:368:20;;;;;5640:295:21;;;;;;:::i;:::-;118:368:20;5613:336:21;;5327:177;:::o;118:368:20:-;-1:-1:-1;;118:368:20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;118:368:20;;;;;;;;;;;-1:-1:-1;118:368:20;","linkReferences":{},"immutableReferences":{"42052":[{"start":2149,"length":32}],"42066":[{"start":3240,"length":32}],"42068":[{"start":3278,"length":32}]}},"methodIdentifiers":{"DOMAIN_SEPARATOR()":"3644e515","allowance(address,address)":"dd62ed3e","approve(address,uint256)":"095ea7b3","balanceOf(address)":"70a08231","burn(address,uint256)":"9dc29fac","decimals()":"313ce567","mint(address,uint256)":"40c10f19","name()":"06fdde03","nonces(address)":"7ecebe00","permit(address,address,uint256,uint256,uint8,bytes32,bytes32)":"d505accf","symbol()":"95d89b41","totalSupply()":"18160ddd","transfer(address,uint256)":"a9059cbb","transferFrom(address,address,uint256)":"23b872dd"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.26+commit.8a97fa7a\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"string\",\"name\":\"_name\",\"type\":\"string\"},{\"internalType\":\"string\",\"name\":\"_symbol\",\"type\":\"string\"},{\"internalType\":\"uint8\",\"name\":\"_decimals\",\"type\":\"uint8\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"Approval\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"DOMAIN_SEPARATOR\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"allowance\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"approve\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"burn\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"mint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"nonces\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"deadline\",\"type\":\"uint256\"},{\"internalType\":\"uint8\",\"name\":\"v\",\"type\":\"uint8\"},{\"internalType\":\"bytes32\",\"name\":\"r\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"s\",\"type\":\"bytes32\"}],\"name\":\"permit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"symbol\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transfer\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transferFrom\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}],\"devdoc\":{\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol\":\"MockERC20\"},\"evmVersion\":\"cancun\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":true,\"runs\":44444444},\"remappings\":[\"/tmp/opencode/gasbench/lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/:ds-test/=lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\"/tmp/opencode/gasbench/lib/v4-core/lib/solmate/:ds-test/=lib/v4-core/lib/solmate/lib/ds-test/src/\",\":@ensdomains/=lib/v4-core/node_modules/@ensdomains/\",\":@openzeppelin/=lib/v4-core/lib/openzeppelin-contracts/\",\":ds-test/=lib/v4-core/lib/forge-std/lib/ds-test/src/\",\":erc4626-tests/=lib/v4-core/lib/openzeppelin-contracts/lib/erc4626-tests/\",\":forge-std/=lib/forge-std/src/\",\":hardhat/=lib/v4-core/node_modules/hardhat/\",\":openzeppelin-contracts/=lib/v4-core/lib/openzeppelin-contracts/\",\":solady/=lib/solady/src/\",\":solmate/=lib/v4-core/lib/solmate/\",\":v4-core/=lib/v4-core/\",\":v4-src/=lib/v4-core/src/\",\"lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/:ds-test/=lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/\",\"lib/v4-core/lib/solmate/:ds-test/=lib/v4-core/lib/solmate/lib/ds-test/src/\"],\"viaIR\":true},\"sources\":{\"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol\":{\"keccak256\":\"0x525129ccb4103c9ecb8f235618d2ff4a8ffde4b8654f7c17766ad175d10ef466\",\"license\":\"AGPL-3.0-only\",\"urls\":[\"bzz-raw://45692a21807ba815c0cf0bfc7d633a14a6cb9ba863ab53f18fc639375a72125e\",\"dweb:/ipfs/QmSrqxyodjDtSU1Dc6G3ScnNc9CFjMnAeHpK8a8guM3nJu\"]},\"lib/v4-core/lib/solmate/src/tokens/ERC20.sol\":{\"keccak256\":\"0xcdfd8db76b2a3415620e4d18cc5545f3d50de792dbf2c3dd5adb40cbe6f94b10\",\"license\":\"AGPL-3.0-only\",\"urls\":[\"bzz-raw://57b3ab70cde374af1cf2c9888636e8de6cf660f087b1c9abd805e9271e19fa35\",\"dweb:/ipfs/QmNrLDBAHYFjpjSd12jerm1AdBkDqEYUUaXgnT854BUZ97\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.26+commit.8a97fa7a"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"string","name":"_name","type":"string"},{"internalType":"string","name":"_symbol","type":"string"},{"internalType":"uint8","name":"_decimals","type":"uint8"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[{"internalType":"address","name":"owner","type":"address","indexed":true},{"internalType":"address","name":"spender","type":"address","indexed":true},{"internalType":"uint256","name":"amount","type":"uint256","indexed":false}],"type":"event","name":"Approval","anonymous":false},{"inputs":[{"internalType":"address","name":"from","type":"address","indexed":true},{"internalType":"address","name":"to","type":"address","indexed":true},{"internalType":"uint256","name":"amount","type":"uint256","indexed":false}],"type":"event","name":"Transfer","anonymous":false},{"inputs":[],"stateMutability":"view","type":"function","name":"DOMAIN_SEPARATOR","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}]},{"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function","name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}]},{"inputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function","name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"burn"},{"inputs":[],"stateMutability":"view","type":"function","name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}]},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"mint"},{"inputs":[],"stateMutability":"view","type":"function","name":"name","outputs":[{"internalType":"string","name":"","type":"string"}]},{"inputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function","name":"nonces","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"stateMutability":"nonpayable","type":"function","name":"permit"},{"inputs":[],"stateMutability":"view","type":"function","name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}]},{"inputs":[],"stateMutability":"view","type":"function","name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}]},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}]}],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"remappings":["/tmp/opencode/gasbench/lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/:ds-test/=lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","/tmp/opencode/gasbench/lib/v4-core/lib/solmate/:ds-test/=lib/v4-core/lib/solmate/lib/ds-test/src/","@ensdomains/=lib/v4-core/node_modules/@ensdomains/","@openzeppelin/=lib/v4-core/lib/openzeppelin-contracts/","ds-test/=lib/v4-core/lib/forge-std/lib/ds-test/src/","erc4626-tests/=lib/v4-core/lib/openzeppelin-contracts/lib/erc4626-tests/","forge-std/=lib/forge-std/src/","hardhat/=lib/v4-core/node_modules/hardhat/","openzeppelin-contracts/=lib/v4-core/lib/openzeppelin-contracts/","solady/=lib/solady/src/","solmate/=lib/v4-core/lib/solmate/","v4-core/=lib/v4-core/","v4-src/=lib/v4-core/src/","lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/:ds-test/=lib/v4-core/lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/","lib/v4-core/lib/solmate/:ds-test/=lib/v4-core/lib/solmate/lib/ds-test/src/"],"optimizer":{"enabled":true,"runs":44444444},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol":"MockERC20"},"evmVersion":"cancun","libraries":{},"viaIR":true},"sources":{"lib/v4-core/lib/solmate/src/test/utils/mocks/MockERC20.sol":{"keccak256":"0x525129ccb4103c9ecb8f235618d2ff4a8ffde4b8654f7c17766ad175d10ef466","urls":["bzz-raw://45692a21807ba815c0cf0bfc7d633a14a6cb9ba863ab53f18fc639375a72125e","dweb:/ipfs/QmSrqxyodjDtSU1Dc6G3ScnNc9CFjMnAeHpK8a8guM3nJu"],"license":"AGPL-3.0-only"},"lib/v4-core/lib/solmate/src/tokens/ERC20.sol":{"keccak256":"0xcdfd8db76b2a3415620e4d18cc5545f3d50de792dbf2c3dd5adb40cbe6f94b10","urls":["bzz-raw://57b3ab70cde374af1cf2c9888636e8de6cf660f087b1c9abd805e9271e19fa35","dweb:/ipfs/QmNrLDBAHYFjpjSd12jerm1AdBkDqEYUUaXgnT854BUZ97"],"license":"AGPL-3.0-only"}},"version":1},"id":20} \ No newline at end of file diff --git a/benchmarks/prod/calldata/ekubo-minimal-1pool.hex b/benchmarks/prod/calldata/ekubo-minimal-1pool.hex new file mode 100644 index 0000000..0c2bd17 --- /dev/null +++ b/benchmarks/prod/calldata/ekubo-minimal-1pool.hex @@ -0,0 +1 @@ +0xb2887f8a000000000000000000000000000000000000000000000000000000000000008000000000000000000000000011111111111111111111111111111111111111110000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000000000000100000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000c49ba5e353f7ce80001770 \ No newline at end of file diff --git a/benchmarks/prod/calldata/ekubo-minimal-2pools.hex b/benchmarks/prod/calldata/ekubo-minimal-2pools.hex new file mode 100644 index 0000000..cfd3cf9 --- /dev/null +++ b/benchmarks/prod/calldata/ekubo-minimal-2pools.hex @@ -0,0 +1 @@ +0xb2887f8a000000000000000000000000000000000000000000000000000000000000008000000000000000000000000011111111111111111111111111111111111111110000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000000000000200000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000022222222222222222222222222222222222222220000000000000000000000003333333333333333333333333333333333333333000000000000000000000000000000000000000000c49ba5e353f7ce80001770 \ No newline at end of file diff --git a/benchmarks/prod/calldata/ekubo-minimal-3pools.hex b/benchmarks/prod/calldata/ekubo-minimal-3pools.hex new file mode 100644 index 0000000..e6e9a76 --- /dev/null +++ b/benchmarks/prod/calldata/ekubo-minimal-3pools.hex @@ -0,0 +1 @@ +0xb2887f8a000000000000000000000000000000000000000000000000000000000000008000000000000000000000000011111111111111111111111111111111111111110000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000000000000300000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000022222222222222222222222222222222222222220000000000000000000000003333333333333333333333333333333333333333000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000033333333333333333333333333333333333333330000000000000000000000004444444444444444444444444444444444444444000000000000000000000000000000000000000000c49ba5e353f7ce80001770 \ No newline at end of file diff --git a/benchmarks/prod/calldata/ekubo-minimal-native.hex b/benchmarks/prod/calldata/ekubo-minimal-native.hex new file mode 100644 index 0000000..b6dadc5 --- /dev/null +++ b/benchmarks/prod/calldata/ekubo-minimal-native.hex @@ -0,0 +1 @@ +0xb2887f8a000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000c49ba5e353f7ce80001770 \ No newline at end of file diff --git a/benchmarks/prod/calldata/ekubo-yul-1pool.hex b/benchmarks/prod/calldata/ekubo-yul-1pool.hex new file mode 100644 index 0000000..dc74bec --- /dev/null +++ b/benchmarks/prod/calldata/ekubo-yul-1pool.hex @@ -0,0 +1 @@ +0x01001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000c7d713b49da00007fa9385be102ac3eac297483dd6233d62b3e149600000000000000000de0b6b3a7640000000011111111111111111111111111111111111111112222222222222222222222222222222222222222000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/prod/calldata/ekubo-yul-2pools.hex b/benchmarks/prod/calldata/ekubo-yul-2pools.hex new file mode 100644 index 0000000..343704e --- /dev/null +++ b/benchmarks/prod/calldata/ekubo-yul-2pools.hex @@ -0,0 +1 @@ +0x01001111111111111111111111111111111111111111333333333333333333333333333333333333333300000000000000000c7d713b49da00007fa9385be102ac3eac297483dd6233d62b3e149600000000000000000de0b6b3a7640000010011111111111111111111111111111111111111112222222222222222222222222222222222222222000000000000000000000000000000000000000000c49ba5e353f7ce80001770000000000000000000000000000000000022222222222222222222222222222222222222223333333333333333333333333333333333333333000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/prod/calldata/ekubo-yul-3pools.hex b/benchmarks/prod/calldata/ekubo-yul-3pools.hex new file mode 100644 index 0000000..5145863 --- /dev/null +++ b/benchmarks/prod/calldata/ekubo-yul-3pools.hex @@ -0,0 +1 @@ +0x01001111111111111111111111111111111111111111444444444444444444444444444444444444444400000000000000000c7d713b49da00007fa9385be102ac3eac297483dd6233d62b3e149600000000000000000de0b6b3a7640000020011111111111111111111111111111111111111112222222222222222222222222222222222222222000000000000000000000000000000000000000000c49ba5e353f7ce80001770000000000000000000000000000000000022222222222222222222222222222222222222223333333333333333333333333333333333333333000000000000000000000000000000000000000000c49ba5e353f7ce80001770000000000000000000000000000000000033333333333333333333333333333333333333334444444444444444444444444444444444444444000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/prod/calldata/ekubo-yul-native.hex b/benchmarks/prod/calldata/ekubo-yul-native.hex new file mode 100644 index 0000000..7dcb58e --- /dev/null +++ b/benchmarks/prod/calldata/ekubo-yul-native.hex @@ -0,0 +1 @@ +0x01000000000000000000000000000000000000000000222222222222222222222222222222222222222200000000000000000c7d713b49da00007fa9385be102ac3eac297483dd6233d62b3e149600000000000000000de0b6b3a7640000000000000000000000000000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000c49ba5e353f7ce8000177000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v3-minimal-1pool.hex b/benchmarks/prod/calldata/v3-minimal-1pool.hex new file mode 100644 index 0000000..eeef9af --- /dev/null +++ b/benchmarks/prod/calldata/v3-minimal-1pool.hex @@ -0,0 +1 @@ +0x0b1d6cab000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000000000000000000000000000000000000000000001000000000000000000000000550e26c349515b8c85306c603a1a0bd6a124712600000000000000000000000000000000000000000000000000000000000000010000000000000000000000001111111111111111111111111111111111111111 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v3-minimal-2pools.hex b/benchmarks/prod/calldata/v3-minimal-2pools.hex new file mode 100644 index 0000000..0748c45 --- /dev/null +++ b/benchmarks/prod/calldata/v3-minimal-2pools.hex @@ -0,0 +1 @@ +0x0b1d6cab000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000000000000000000000000000000000000000000002000000000000000000000000550e26c349515b8c85306c603a1a0bd6a12471260000000000000000000000007a3b5ec5c068ecbf8a87e30b3ed57243b888f2dc000000000000000000000000000000000000000000000000000000000000000200000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v3-minimal-3pools.hex b/benchmarks/prod/calldata/v3-minimal-3pools.hex new file mode 100644 index 0000000..705440a --- /dev/null +++ b/benchmarks/prod/calldata/v3-minimal-3pools.hex @@ -0,0 +1 @@ +0x0b1d6cab000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e14960000000000000000000000000000000000000000000000000000000000000003000000000000000000000000550e26c349515b8c85306c603a1a0bd6a12471260000000000000000000000007a3b5ec5c068ecbf8a87e30b3ed57243b888f2dc00000000000000000000000091df1e818bc3ebcab7840fa87ea7517d2a6729b90000000000000000000000000000000000000000000000000000000000000003000000000000000000000000111111111111111111111111111111111111111100000000000000000000000022222222222222222222222222222222222222220000000000000000000000003333333333333333333333333333333333333333 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v3-swaprouter-1pool.hex b/benchmarks/prod/calldata/v3-swaprouter-1pool.hex new file mode 100644 index 0000000..c4c27f1 --- /dev/null +++ b/benchmarks/prod/calldata/v3-swaprouter-1pool.hex @@ -0,0 +1 @@ +0x414bf389000000000000000000000000111111111111111111111111111111111111111100000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000bb80000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v3-swaprouter-2pools.hex b/benchmarks/prod/calldata/v3-swaprouter-2pools.hex new file mode 100644 index 0000000..053b217 --- /dev/null +++ b/benchmarks/prod/calldata/v3-swaprouter-2pools.hex @@ -0,0 +1 @@ +0xc04b8d59000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000421111111111111111111111111111111111111111000bb82222222222222222222222222222222222222222000bb83333333333333333333333333333333333333333000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v3-swaprouter-3pools.hex b/benchmarks/prod/calldata/v3-swaprouter-3pools.hex new file mode 100644 index 0000000..51e9a94 --- /dev/null +++ b/benchmarks/prod/calldata/v3-swaprouter-3pools.hex @@ -0,0 +1 @@ +0xc04b8d59000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000591111111111111111111111111111111111111111000bb82222222222222222222222222222222222222222000bb83333333333333333333333333333333333333333000bb8444444444444444444444444444444444444444400000000000000 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v3-swaprouter-native.hex b/benchmarks/prod/calldata/v3-swaprouter-native.hex new file mode 100644 index 0000000..b81cf6c --- /dev/null +++ b/benchmarks/prod/calldata/v3-swaprouter-native.hex @@ -0,0 +1 @@ +0x414bf389000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000bb80000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v4-minimal-1pool.hex b/benchmarks/prod/calldata/v4-minimal-1pool.hex new file mode 100644 index 0000000..1fbc5de --- /dev/null +++ b/benchmarks/prod/calldata/v4-minimal-1pool.hex @@ -0,0 +1 @@ +0x19305657000000000000000000000000000000000000000000000000000000000000008000000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222fffffffffffffffffffffffffffffffffffffffffffffffff21f494c589c00000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000111111111111111111111111111111111111111100000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000276a4 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v4-minimal-2pools.hex b/benchmarks/prod/calldata/v4-minimal-2pools.hex new file mode 100644 index 0000000..b20da50 --- /dev/null +++ b/benchmarks/prod/calldata/v4-minimal-2pools.hex @@ -0,0 +1 @@ +0x19305657000000000000000000000000000000000000000000000000000000000000008000000000000000000000000011111111111111111111111111111111111111110000000000000000000000003333333333333333333333333333333333333333fffffffffffffffffffffffffffffffffffffffffffffffff21f494c589c00000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000111111111111111111111111111111111111111100000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000276a4000000000000000000000000222222222222222222222222222222222222222200000000000000000000000033333333333333333333333333333333333333330000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000276a4 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v4-minimal-3pools.hex b/benchmarks/prod/calldata/v4-minimal-3pools.hex new file mode 100644 index 0000000..5a86032 --- /dev/null +++ b/benchmarks/prod/calldata/v4-minimal-3pools.hex @@ -0,0 +1 @@ +0x19305657000000000000000000000000000000000000000000000000000000000000008000000000000000000000000011111111111111111111111111111111111111110000000000000000000000004444444444444444444444444444444444444444fffffffffffffffffffffffffffffffffffffffffffffffff21f494c589c00000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000111111111111111111111111111111111111111100000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000276a4000000000000000000000000222222222222222222222222222222222222222200000000000000000000000033333333333333333333333333333333333333330000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000276a4000000000000000000000000333333333333333333333333333333333333333300000000000000000000000044444444444444444444444444444444444444440000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000276a4 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v4-minimal-native.hex b/benchmarks/prod/calldata/v4-minimal-native.hex new file mode 100644 index 0000000..6ecf1c0 --- /dev/null +++ b/benchmarks/prod/calldata/v4-minimal-native.hex @@ -0,0 +1 @@ +0x19305657000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002222222222222222222222222222222222222222fffffffffffffffffffffffffffffffffffffffffffffffff21f494c589c00000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000276a4 \ No newline at end of file diff --git a/benchmarks/prod/calldata/v4-universal-router-1pool.hex b/benchmarks/prod/calldata/v4-universal-router-1pool.hex new file mode 100644 index 0000000..2c18b30 --- /dev/null +++ b/benchmarks/prod/calldata/v4-universal-router-1pool.hex @@ -0,0 +1 @@ +0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000340000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003060c0f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000111111111111111111111111111111111111111100000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000011111111111111111111111111111111111111110000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/benchmarks/prod/encode-prod-routes.mjs b/benchmarks/prod/encode-prod-routes.mjs new file mode 100644 index 0000000..85c616d --- /dev/null +++ b/benchmarks/prod/encode-prod-routes.mjs @@ -0,0 +1,32 @@ +// Generates the Yul-router calldata for ProdEkubo.t.sol with the yul-router SDK. +// Token addresses and the pool config are the constants in test/ProdCommon.sol and +// test/ProdEkubo.t.sol; the recipient is the forge test contract address. +// Run: bun encode-prod-routes.mjs (writes calldata/ekubo-yul-*.hex) +import { writeFileSync } from "node:fs"; +import { encodeRoute } from "/home/sendmoodz/Work/ekubo/yul-router/sdk/src/index.ts"; + +const A = "0x1111111111111111111111111111111111111111"; +const B = "0x2222222222222222222222222222222222222222"; +const C = "0x3333333333333333333333333333333333333333"; +const D = "0x4444444444444444444444444444444444444444"; +const NATIVE = "0x0000000000000000000000000000000000000000"; +const config = "0x000000000000000000000000000000000000000000c49ba5e353f7ce80001770"; // 0.3%, spacing 6000, no extension +const recipient = "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496"; +const hop = (token0, token1) => ({ type: "core", poolKey: { token0, token1, config } }); + +const routes = { + "ekubo-yul-1pool": { specifiedToken: A, calculatedToken: B, hops: [hop(A, B)] }, + "ekubo-yul-2pools": { specifiedToken: A, calculatedToken: C, hops: [hop(A, B), hop(B, C)] }, + "ekubo-yul-3pools": { specifiedToken: A, calculatedToken: D, hops: [hop(A, B), hop(B, C), hop(C, D)] }, + "ekubo-yul-native": { specifiedToken: NATIVE, calculatedToken: B, hops: [hop(NATIVE, B)] }, +}; +for (const [file, r] of Object.entries(routes)) { + const data = encodeRoute({ + ...r, + specifiedAmount: 1000000000000000000n, + calculatedAmountThreshold: 900000000000000000n, // 90% of input: 0.3% fee per hop cannot trip it + recipient, + }); + writeFileSync(`calldata/${file}.hex`, data); + console.log(file, (data.length - 2) / 2, "bytes"); +} diff --git a/benchmarks/prod/foundry.toml b/benchmarks/prod/foundry.toml new file mode 100644 index 0000000..d9af8b5 --- /dev/null +++ b/benchmarks/prod/foundry.toml @@ -0,0 +1,16 @@ +[profile.default] +src = "src" +test = "test" +out = "out" +libs = ["lib"] +# No `solc` pin: foundry auto-detects per file (=0.8.26 for the Uniswap-side files, +# =0.8.33 for the Ekubo sources). A pin would force one version on everything. +optimizer = true +optimizer_runs = 1000000 +via_ir = true +evm_version = "osaka" +verbosity = 2 +fs_permissions = [ + { access = "read", path = "./artifacts" }, + { access = "read-write", path = "./calldata" }, +] diff --git a/benchmarks/prod/remappings.txt b/benchmarks/prod/remappings.txt new file mode 100644 index 0000000..4272ddc --- /dev/null +++ b/benchmarks/prod/remappings.txt @@ -0,0 +1,5 @@ +forge-std/=lib/forge-std/src/ +solady/=lib/solady/src/ +solmate/=lib/v4-core/lib/solmate/ +v4-core/=lib/v4-core/ +ekubo/=lib/ekubo/ diff --git a/benchmarks/prod/run.sh b/benchmarks/prod/run.sh new file mode 100755 index 0000000..30acd5e --- /dev/null +++ b/benchmarks/prod/run.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +# Runs the production-contract bench on a fork of the latest mainnet block. +# RPC=https://eth.drpc.org ./run.sh # latest block (default) +# RPC=... BLOCK=25999935 ./run.sh # reproduce a recorded run +# Dependencies (lib/, git-ignored): forge-std, solady, EkuboProtocol/evm-contracts at +# v3.2.0 (as lib/ekubo, with its lib/forge-std and lib/solady), Uniswap/v4-core (with its +# lib/solmate). Routes: `bun encode-prod-routes.mjs` regenerates calldata/ekubo-yul-*.hex. +set -eu +RPC="${RPC:-https://eth.drpc.org}" +BLOCK="${BLOCK:-$(cast block-number -r "$RPC")}" +echo "fork block $BLOCK" +forge test --fork-url "$RPC" --fork-block-number "$BLOCK" -vv "$@" diff --git a/benchmarks/prod/snapshots/ProdEkuboTest.json b/benchmarks/prod/snapshots/ProdEkuboTest.json new file mode 100644 index 0000000..cac0388 --- /dev/null +++ b/benchmarks/prod/snapshots/ProdEkuboTest.json @@ -0,0 +1,52 @@ +{ + "prod ekubo Yul router 1 pool A->B": "87663", + "prod ekubo Yul router 1 pool A->B | calldata bytes": "184", + "prod ekubo Yul router 1 pool A->B | calldata floor gas (EIP-7623, 10 per token)": "5530", + "prod ekubo Yul router 1 pool A->B | calldata gas (4/16 per byte)": "2212", + "prod ekubo Yul router 1 pool A->B | fine ticks moved": "2", + "prod ekubo Yul router 1 pool A->B | output (wei)": "1000042490223401887", + "prod ekubo Yul router 1 pool ETH->B": "73764", + "prod ekubo Yul router 1 pool ETH->B | calldata bytes": "184", + "prod ekubo Yul router 1 pool ETH->B | calldata floor gas (EIP-7623, 10 per token)": "4330", + "prod ekubo Yul router 1 pool ETH->B | calldata gas (4/16 per byte)": "1732", + "prod ekubo Yul router 1 pool ETH->B | fine ticks moved": "2", + "prod ekubo Yul router 1 pool ETH->B | output (wei)": "1000042490223401887", + "prod ekubo Yul router 2 pools A->B->C": "109850", + "prod ekubo Yul router 2 pools A->B->C | calldata bytes": "273", + "prod ekubo Yul router 2 pools A->B->C | calldata floor gas (EIP-7623, 10 per token)": "7950", + "prod ekubo Yul router 2 pools A->B->C | calldata gas (4/16 per byte)": "3180", + "prod ekubo Yul router 2 pools A->B->C | fine ticks moved": "2", + "prod ekubo Yul router 2 pools A->B->C | output (wei)": "1000084982120610903", + "prod ekubo Yul router 3 pools A->B->C->D": "132025", + "prod ekubo Yul router 3 pools A->B->C->D | calldata bytes": "362", + "prod ekubo Yul router 3 pools A->B->C->D | calldata floor gas (EIP-7623, 10 per token)": "10340", + "prod ekubo Yul router 3 pools A->B->C->D | calldata gas (4/16 per byte)": "4136", + "prod ekubo Yul router 3 pools A->B->C->D | fine ticks moved": "2", + "prod ekubo Yul router 3 pools A->B->C->D | output (wei)": "1000127475691875278", + "prod ekubo minimal locker 1 pool A->B": "89733", + "prod ekubo minimal locker 1 pool A->B | calldata bytes": "260", + "prod ekubo minimal locker 1 pool A->B | calldata floor gas (EIP-7623, 10 per token)": "5660", + "prod ekubo minimal locker 1 pool A->B | calldata gas (4/16 per byte)": "2264", + "prod ekubo minimal locker 1 pool A->B | fine ticks moved": "2", + "prod ekubo minimal locker 1 pool A->B | output (wei)": "1000042490223401887", + "prod ekubo minimal locker 1 pool ETH->B": "74585", + "prod ekubo minimal locker 1 pool ETH->B | calldata bytes": "260", + "prod ekubo minimal locker 1 pool ETH->B | calldata floor gas (EIP-7623, 10 per token)": "4460", + "prod ekubo minimal locker 1 pool ETH->B | calldata gas (4/16 per byte)": "1784", + "prod ekubo minimal locker 1 pool ETH->B | fine ticks moved": "2", + "prod ekubo minimal locker 1 pool ETH->B | output (wei)": "1000042490223401887", + "prod ekubo minimal locker 2 pools A->B->C": "112843", + "prod ekubo minimal locker 2 pools A->B->C | calldata bytes": "356", + "prod ekubo minimal locker 2 pools A->B->C | calldata floor gas (EIP-7623, 10 per token)": "8120", + "prod ekubo minimal locker 2 pools A->B->C | calldata gas (4/16 per byte)": "3248", + "prod ekubo minimal locker 2 pools A->B->C | fine ticks moved": "2", + "prod ekubo minimal locker 2 pools A->B->C | output (wei)": "1000084982120610903", + "prod ekubo minimal locker 3 pools A->B->C->D": "135952", + "prod ekubo minimal locker 3 pools A->B->C->D | calldata bytes": "452", + "prod ekubo minimal locker 3 pools A->B->C->D | calldata floor gas (EIP-7623, 10 per token)": "10580", + "prod ekubo minimal locker 3 pools A->B->C->D | calldata gas (4/16 per byte)": "4232", + "prod ekubo minimal locker 3 pools A->B->C->D | fine ticks moved": "2", + "prod ekubo minimal locker 3 pools A->B->C->D | output (wei)": "1000127475691875278", + "prod fork block number": "25999935", + "prod fork block timestamp": "1789680959" +} \ No newline at end of file diff --git a/benchmarks/prod/snapshots/ProdV3Test.json b/benchmarks/prod/snapshots/ProdV3Test.json new file mode 100644 index 0000000..17fdc7b --- /dev/null +++ b/benchmarks/prod/snapshots/ProdV3Test.json @@ -0,0 +1,46 @@ +{ + "prod fork block number": "25999935", + "prod fork block timestamp": "1789680959", + "prod v3 SwapRouter exactInput 2 pools A->B->C": "165719", + "prod v3 SwapRouter exactInput 2 pools A->B->C | calldata bytes": "324", + "prod v3 SwapRouter exactInput 2 pools A->B->C | calldata floor gas (EIP-7623, 10 per token)": "7110", + "prod v3 SwapRouter exactInput 2 pools A->B->C | calldata gas (4/16 per byte)": "2844", + "prod v3 SwapRouter exactInput 2 pools A->B->C | output (wei)": "999984682148650249", + "prod v3 SwapRouter exactInput 2 pools A->B->C | ticks moved": "0", + "prod v3 SwapRouter exactInput 3 pools A->B->C->D": "230478", + "prod v3 SwapRouter exactInput 3 pools A->B->C->D | calldata bytes": "324", + "prod v3 SwapRouter exactInput 3 pools A->B->C->D | calldata floor gas (EIP-7623, 10 per token)": "7770", + "prod v3 SwapRouter exactInput 3 pools A->B->C->D | calldata gas (4/16 per byte)": "3108", + "prod v3 SwapRouter exactInput 3 pools A->B->C->D | output (wei)": "999977023339428738", + "prod v3 SwapRouter exactInput 3 pools A->B->C->D | ticks moved": "0", + "prod v3 SwapRouter exactInputSingle 1 pool A->B": "100299", + "prod v3 SwapRouter exactInputSingle 1 pool A->B | calldata bytes": "260", + "prod v3 SwapRouter exactInputSingle 1 pool A->B | calldata floor gas (EIP-7623, 10 per token)": "5720", + "prod v3 SwapRouter exactInputSingle 1 pool A->B | calldata gas (4/16 per byte)": "2288", + "prod v3 SwapRouter exactInputSingle 1 pool A->B | output (wei)": "999992341035506990", + "prod v3 SwapRouter exactInputSingle 1 pool A->B | ticks moved": "0", + "prod v3 SwapRouter exactInputSingle 1 pool ETH(wrapped in flight)->B": "103504", + "prod v3 SwapRouter exactInputSingle 1 pool ETH(wrapped in flight)->B | calldata bytes": "260", + "prod v3 SwapRouter exactInputSingle 1 pool ETH(wrapped in flight)->B | calldata floor gas (EIP-7623, 10 per token)": "5720", + "prod v3 SwapRouter exactInputSingle 1 pool ETH(wrapped in flight)->B | calldata gas (4/16 per byte)": "2288", + "prod v3 SwapRouter exactInputSingle 1 pool ETH(wrapped in flight)->B | output (wei)": "994010658021103223", + "prod v3 SwapRouter exactInputSingle 1 pool ETH(wrapped in flight)->B | ticks moved": "0", + "prod v3 minimal router 1 pool A->B": "94078", + "prod v3 minimal router 1 pool A->B | calldata bytes": "260", + "prod v3 minimal router 1 pool A->B | calldata floor gas (EIP-7623, 10 per token)": "4820", + "prod v3 minimal router 1 pool A->B | calldata gas (4/16 per byte)": "1928", + "prod v3 minimal router 1 pool A->B | output (wei)": "999992341035506990", + "prod v3 minimal router 1 pool A->B | ticks moved": "0", + "prod v3 minimal router 2 pools A->B->C": "152774", + "prod v3 minimal router 2 pools A->B->C | calldata bytes": "324", + "prod v3 minimal router 2 pools A->B->C | calldata floor gas (EIP-7623, 10 per token)": "6660", + "prod v3 minimal router 2 pools A->B->C | calldata gas (4/16 per byte)": "2664", + "prod v3 minimal router 2 pools A->B->C | output (wei)": "999984682148650249", + "prod v3 minimal router 2 pools A->B->C | ticks moved": "0", + "prod v3 minimal router 3 pools A->B->C->D": "211470", + "prod v3 minimal router 3 pools A->B->C->D | calldata bytes": "388", + "prod v3 minimal router 3 pools A->B->C->D | calldata floor gas (EIP-7623, 10 per token)": "8500", + "prod v3 minimal router 3 pools A->B->C->D | calldata gas (4/16 per byte)": "3400", + "prod v3 minimal router 3 pools A->B->C->D | output (wei)": "999977023339428738", + "prod v3 minimal router 3 pools A->B->C->D | ticks moved": "0" +} \ No newline at end of file diff --git a/benchmarks/prod/snapshots/ProdV4Test.json b/benchmarks/prod/snapshots/ProdV4Test.json new file mode 100644 index 0000000..7f74f4d --- /dev/null +++ b/benchmarks/prod/snapshots/ProdV4Test.json @@ -0,0 +1,34 @@ +{ + "prod fork block number": "25999935", + "prod fork block timestamp": "1789680959", + "prod v4 Universal Router V4_SWAP 1 pool A->B (Permit2)": "117114", + "prod v4 Universal Router V4_SWAP 1 pool A->B (Permit2) | calldata bytes": "1092", + "prod v4 Universal Router V4_SWAP 1 pool A->B (Permit2) | calldata floor gas (EIP-7623, 10 per token)": "15690", + "prod v4 Universal Router V4_SWAP 1 pool A->B (Permit2) | calldata gas (4/16 per byte)": "6276", + "prod v4 Universal Router V4_SWAP 1 pool A->B (Permit2) | output (wei)": "999992341035507518", + "prod v4 Universal Router V4_SWAP 1 pool A->B (Permit2) | ticks moved": "0", + "prod v4 minimal locker 1 pool A->B": "107384", + "prod v4 minimal locker 1 pool A->B | calldata bytes": "356", + "prod v4 minimal locker 1 pool A->B | calldata floor gas (EIP-7623, 10 per token)": "7250", + "prod v4 minimal locker 1 pool A->B | calldata gas (4/16 per byte)": "2900", + "prod v4 minimal locker 1 pool A->B | output (wei)": "999992341035507518", + "prod v4 minimal locker 1 pool A->B | ticks moved": "0", + "prod v4 minimal locker 1 pool ETH->B": "92472", + "prod v4 minimal locker 1 pool ETH->B | calldata bytes": "356", + "prod v4 minimal locker 1 pool ETH->B | calldata floor gas (EIP-7623, 10 per token)": "6050", + "prod v4 minimal locker 1 pool ETH->B | calldata gas (4/16 per byte)": "2420", + "prod v4 minimal locker 1 pool ETH->B | output (wei)": "999992341035507518", + "prod v4 minimal locker 1 pool ETH->B | ticks moved": "0", + "prod v4 minimal locker 2 pools A->B->C": "141795", + "prod v4 minimal locker 2 pools A->B->C | calldata bytes": "548", + "prod v4 minimal locker 2 pools A->B->C | calldata floor gas (EIP-7623, 10 per token)": "10580", + "prod v4 minimal locker 2 pools A->B->C | calldata gas (4/16 per byte)": "4232", + "prod v4 minimal locker 2 pools A->B->C | output (wei)": "999984682148651304", + "prod v4 minimal locker 2 pools A->B->C | ticks moved": "0", + "prod v4 minimal locker 3 pools A->B->C->D": "176206", + "prod v4 minimal locker 3 pools A->B->C->D | calldata bytes": "740", + "prod v4 minimal locker 3 pools A->B->C->D | calldata floor gas (EIP-7623, 10 per token)": "13910", + "prod v4 minimal locker 3 pools A->B->C->D | calldata gas (4/16 per byte)": "5564", + "prod v4 minimal locker 3 pools A->B->C->D | output (wei)": "999977023339430321", + "prod v4 minimal locker 3 pools A->B->C->D | ticks moved": "0" +} \ No newline at end of file diff --git a/benchmarks/prod/test/ProdCommon.sol b/benchmarks/prod/test/ProdCommon.sol new file mode 100644 index 0000000..ef9c474 --- /dev/null +++ b/benchmarks/prod/test/ProdCommon.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.26; + +import {Test} from "forge-std/Test.sol"; + +interface IFreshToken { + function mint(address to, uint256 amount) external; + function approve(address spender, uint256 amount) external returns (bool); + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); +} + +/// @notice Shared fixture for the production-contract benches: four fresh ERC20 tokens +/// (the same solmate MockERC20 artifact as the lab harnesses) etched at fixed, +/// address-ordered, all-nonzero-byte addresses on a fork of the latest block, so +/// every run is reproducible without a block pin and the SDK-generated router +/// calldata can embed the token addresses. Nothing in this file is measured. +abstract contract ProdCommon is Test { + // Ordered A < B < C < D. All bytes nonzero, so calldata that carries these addresses + // pays the same 16 gas per byte as a real token address would (a real address has + // about one zero byte in 256; these have none, which is marginally pessimistic). + address constant TOKEN_A = 0x1111111111111111111111111111111111111111; + address constant TOKEN_B = 0x2222222222222222222222222222222222222222; + address constant TOKEN_C = 0x3333333333333333333333333333333333333333; + address constant TOKEN_D = 0x4444444444444444444444444444444444444444; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + uint256 constant LIQ_AMOUNT = 1_000_000 ether; // per side, per pool + uint256 constant SWAP_AMOUNT = 1 ether; // exact input, every measured swap + + function _deployTokens() internal { + address[4] memory ts = [TOKEN_A, TOKEN_B, TOKEN_C, TOKEN_D]; + string[4] memory names = ["TokenA", "TokenB", "TokenC", "TokenD"]; + string[4] memory syms = ["A", "B", "C", "D"]; + for (uint256 i = 0; i < 4; i++) { + assertEq(ts[i].code.length, 0, "fresh token address already has code on this chain"); + deployCodeTo("artifacts/MockERC20.json", abi.encode(names[i], syms[i], uint8(18)), ts[i]); + IFreshToken(ts[i]).mint(address(this), 10_000_000 ether); + } + vm.deal(address(this), 10_000_000 ether); + vm.snapshotValue("prod fork block number", block.number); + vm.snapshotValue("prod fork block timestamp", block.timestamp); + } + + /// @dev Records the exact bytes of a measured call next to its gas: byte count, the + /// EIP-2028 calldata gas (4 per zero byte, 16 per nonzero byte) and the EIP-7623 + /// floor tokens, so the page can add calldata to execution gas without modeling. + function _recordCalldata(string memory name, string memory file, bytes memory data) internal { + uint256 zeros; + for (uint256 i = 0; i < data.length; i++) { + if (data[i] == 0) zeros++; + } + uint256 nonzeros = data.length - zeros; + vm.snapshotValue(string.concat(name, " | calldata bytes"), data.length); + vm.snapshotValue(string.concat(name, " | calldata gas (4/16 per byte)"), 4 * zeros + 16 * nonzeros); + vm.snapshotValue(string.concat(name, " | calldata floor gas (EIP-7623, 10 per token)"), 10 * (zeros + 4 * nonzeros)); + vm.writeFile(string.concat("calldata/", file, ".hex"), vm.toString(data)); + } + + receive() external payable {} +} diff --git a/benchmarks/prod/test/ProdEkubo.t.sol b/benchmarks/prod/test/ProdEkubo.t.sol new file mode 100644 index 0000000..64f9d1c --- /dev/null +++ b/benchmarks/prod/test/ProdEkubo.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.33; + +import {Vm} from "forge-std/Test.sol"; +import {ProdCommon, IFreshToken} from "./ProdCommon.sol"; +import {ICore} from "ekubo/src/interfaces/ICore.sol"; +import {PoolKey, toPoolId} from "ekubo/src/types/poolKey.sol"; +import {PoolId} from "ekubo/src/types/poolId.sol"; +import {PoolConfig, createConcentratedPoolConfig} from "ekubo/src/types/poolConfig.sol"; +import {PoolState} from "ekubo/src/types/poolState.sol"; +import {SqrtRatio, MIN_SQRT_RATIO, MAX_SQRT_RATIO} from "ekubo/src/types/sqrtRatio.sol"; +import {NATIVE_TOKEN_ADDRESS} from "ekubo/src/math/constants.sol"; +import {SwapParameters, createSwapParameters} from "ekubo/src/types/swapParameters.sol"; +import {PoolBalanceUpdate} from "ekubo/src/types/poolBalanceUpdate.sol"; +import {BaseLocker} from "ekubo/src/base/BaseLocker.sol"; +import {FlashAccountantLib} from "ekubo/src/libraries/FlashAccountantLib.sol"; +import {CoreLib} from "ekubo/src/libraries/CoreLib.sol"; + +interface IPositionsLike { + function name() external view returns (string memory); + function mintAndDepositWithSalt( + bytes32 salt, + PoolKey memory poolKey, + int32 tickLower, + int32 tickUpper, + uint128 maxAmount0, + uint128 maxAmount1, + uint128 minLiquidity + ) external payable returns (uint256 id, uint128 liquidity, uint128 amount0, uint128 amount1); +} + +/// @notice True-minimal single-lock exact-input locker over the production Core, the same +/// shape as the v4 minimal locker: lock, swap each hop, pay the input once, withdraw +/// the output once (net settlement). Native input rides on msg.value. +contract MinimalEkuboRouter is BaseLocker { + using FlashAccountantLib for *; + using CoreLib for ICore; + + constructor(ICore core) BaseLocker(core) {} + + function swapExactIn(PoolKey[] memory keys, address tokenIn, int128 amountIn, address payer) + external + payable + returns (int128 amountOut) + { + amountOut = abi.decode(lock(abi.encode(keys, tokenIn, amountIn, payer, msg.sender, msg.value)), (int128)); + } + + function handleLockData(uint256, bytes memory data) internal override returns (bytes memory result) { + (PoolKey[] memory keys, address tokenIn, int128 amount, address payer, address recipient, uint256 value) = + abi.decode(data, (PoolKey[], address, int128, address, address, uint256)); + ICore core = ICore(payable(address(ACCOUNTANT))); + address firstToken = tokenIn; + int128 firstAmount = amount; + for (uint256 i = 0; i < keys.length; i++) { + bool isToken1 = tokenIn == keys[i].token1; + require(isToken1 || tokenIn == keys[i].token0, "hop token"); + SwapParameters params = + createSwapParameters(isToken1 ? MAX_SQRT_RATIO : MIN_SQRT_RATIO, amount, isToken1, 0); + (PoolBalanceUpdate bu,) = core.swap(i == 0 ? value : 0, keys[i], params); + if (isToken1) { + require(bu.delta1() == amount && bu.delta0() < 0, "hop"); + amount = -bu.delta0(); + tokenIn = keys[i].token0; + } else { + require(bu.delta0() == amount && bu.delta1() < 0, "hop"); + amount = -bu.delta1(); + tokenIn = keys[i].token1; + } + } + if (firstToken != NATIVE_TOKEN_ADDRESS) ACCOUNTANT.payFrom(payer, firstToken, uint128(firstAmount)); + ACCOUNTANT.withdraw(tokenIn, recipient, uint128(amount)); + result = abi.encode(amount); + } +} + +/// @notice Production Ekubo contracts on a fork of the latest mainnet block, fresh tokens: +/// Core (all pools), Positions (capitalizes the pools in setup, unmeasured) and the +/// deployed Yul router with SDK-generated calldata (`encode-prod-routes.mjs`). The +/// minimal locker above is the like-for-like counterpart of the v3/v4 minimal +/// routers. One identical unmeasured call precedes every measurement; every call is +/// isolated, so the snapshot is the execution gas of the measured call net of +/// refunds, excluding the 21,000 base and the calldata recorded next to it. +contract ProdEkuboTest is ProdCommon { + using CoreLib for ICore; + + ICore constant CORE = ICore(payable(0x00000000000014aA86C5d3c41765bb24e11bd701)); + IPositionsLike constant POSITIONS = IPositionsLike(0x02D9876A21AF7545f8632C3af76eC90b5ad4b66D); + address constant YUL_ROUTER = 0x7B2aA7Ecc0B5936b7C52E6259A19C3BA557d0748; + address constant RECIPIENT = 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496; // this test contract, embedded in the routes + + uint64 constant FEE_0_3 = 55340232221128654; // floor(0.003 * 2^64), the Uniswap 3000 tier + uint32 constant TICK_SPACING = 6000; // Uniswap spacing 60, Ekubo ticks are 100x finer + int32 constant RANGE_LOWER = -88722000; + int32 constant RANGE_UPPER = 88722000; + int32 constant INIT_TICK = 3050; // strictly inside a tick (Uniswap tick 30.5), never on a spacing multiple + bytes32 constant CONFIG = 0x000000000000000000000000000000000000000000c49ba5e353f7ce80001770; + + PoolKey keyAB; + PoolKey keyBC; + PoolKey keyCD; + PoolKey keyNative; + MinimalEkuboRouter minimal; + + function _key(address t0, address t1) internal pure returns (PoolKey memory k) { + k = PoolKey({token0: t0, token1: t1, config: createConcentratedPoolConfig(FEE_0_3, TICK_SPACING, address(0))}); + require(PoolConfig.unwrap(k.config) == CONFIG, "config word differs from the one in the SDK routes"); + } + + function _setup() internal { + assertEq(block.chainid, 1, "mainnet fork"); + assertEq(address(this), RECIPIENT, "route recipient is this contract"); + assertGt(address(CORE).code.length, 0, "Core deployed"); + assertGt(YUL_ROUTER.code.length, 0, "Yul router deployed"); + assertEq(POSITIONS.name(), "Ekubo Positions", "production Positions"); + _deployTokens(); + + keyAB = _key(TOKEN_A, TOKEN_B); + keyBC = _key(TOKEN_B, TOKEN_C); + keyCD = _key(TOKEN_C, TOKEN_D); + keyNative = _key(NATIVE_TOKEN_ADDRESS, TOKEN_B); + PoolKey[4] memory keys = [keyAB, keyBC, keyCD, keyNative]; + for (uint256 i = 0; i < 4; i++) { + assertFalse(CORE.poolState(toPoolId(keys[i])).isInitialized(), "pool must not exist yet"); + CORE.initializePool(keys[i], INIT_TICK); + } + address[4] memory ts = [TOKEN_A, TOKEN_B, TOKEN_C, TOKEN_D]; + for (uint256 i = 0; i < 4; i++) { + IFreshToken(ts[i]).approve(address(POSITIONS), type(uint256).max); + } + // Capitalize through the production Positions manager (unmeasured): one full-range + // position per pool, 1M tokens a side, so no measured swap crosses an initialized tick. + // Explicit salts: the deployed manager's `mintAndDeposit` derives its salt from + // prevrandao and remaining gas, which back-to-back mints in one context share. + for (uint256 i = 0; i < 3; i++) { + (, uint128 liq,,) = POSITIONS.mintAndDepositWithSalt( + bytes32(i + 1), keys[i], RANGE_LOWER, RANGE_UPPER, uint128(LIQ_AMOUNT), uint128(LIQ_AMOUNT), 0 + ); + assertGt(liq, 0); + } + POSITIONS.mintAndDepositWithSalt{value: LIQ_AMOUNT}( + bytes32(uint256(4)), keyNative, RANGE_LOWER, RANGE_UPPER, uint128(LIQ_AMOUNT), uint128(LIQ_AMOUNT), 0 + ); + + minimal = new MinimalEkuboRouter(CORE); + IFreshToken(TOKEN_A).approve(YUL_ROUTER, type(uint256).max); + IFreshToken(TOKEN_A).approve(address(minimal), type(uint256).max); + assertEq(IFreshToken(TOKEN_A).allowance(address(this), YUL_ROUTER), type(uint256).max, "max approval"); + } + + function _tick(PoolKey memory k) internal view returns (int32 tick) { + (, tick,) = CORE.poolState(toPoolId(k)).parse(); + } + + /// @dev Calls `to` with `data` (and `value`), snapshots the call when `name` is set, and + /// checks the swap moved the last pool by at most one Uniswap tick (100 fine ticks). + function _call(string memory name, address to, uint256 value, bytes memory data, PoolKey memory lastPool, address tokenOut) + internal + returns (uint256 out, bytes memory ret) + { + int32 before = _tick(lastPool); + uint256 balBefore = IFreshToken(tokenOut).balanceOf(address(this)); + assertGt(balBefore, 0, "recipient already holds the output token (nonzero -> nonzero balance write)"); + bool ok; + (ok, ret) = to.call{value: value}(data); + if (bytes(name).length > 0) vm.snapshotGasLastCall(name); + assertTrue(ok, "call reverted"); + out = IFreshToken(tokenOut).balanceOf(address(this)) - balBefore; + int256 moved = int256(_tick(lastPool)) - int256(before); + if (moved < 0) moved = -moved; + assertLe(uint256(moved), 100, "moved more than one Uniswap tick"); + if (bytes(name).length > 0) { + vm.snapshotValue(string.concat(name, " | output (wei)"), out); + vm.snapshotValue(string.concat(name, " | fine ticks moved"), uint256(moved)); + } + } + + function _route(string memory file) internal view returns (bytes memory) { + return vm.parseBytes(vm.readFile(string.concat("calldata/", file, ".hex"))); + } + + // ---- production Yul router, SDK calldata ---- + + function _lastPool(uint256 n) internal view returns (PoolKey memory) { + // resolved after _setup(): the keys are storage, so they must not be read before it + return n == 0 ? keyNative : (n == 1 ? keyAB : (n == 2 ? keyBC : keyCD)); + } + + function _yul(string memory file, string memory name, uint256 value, uint256 n, address tokenOut, uint256 minOut) internal { + _setup(); + PoolKey memory lastPool = _lastPool(n); + bytes memory route = _route(file); + _call("", YUL_ROUTER, value, route, lastPool, tokenOut); // warm-up, unmeasured + (uint256 out, bytes memory ret) = _call(name, YUL_ROUTER, value, route, lastPool, tokenOut); + (,, int256 specified, int256 calculated) = abi.decode(ret, (address, address, int256, int256)); + assertEq(uint256(specified), SWAP_AMOUNT, "exact input"); + assertEq(uint256(calculated), out, "router-reported output equals balance change"); + assertGe(out, minOut, "output sanity"); + assertLe(out, (SWAP_AMOUNT * 101) / 100, "output sanity"); // pools sit at tick 30.5, so 1 A buys ~1.003 B before the 0.3% fee + _recordCalldata(name, file, route); + } + + /// forge-config: default.isolate = true + function test_prod_ekubo_yul_1pool() public { + _yul("ekubo-yul-1pool", "prod ekubo Yul router 1 pool A->B", 0, 1, TOKEN_B, 0.99e18); + } + + /// forge-config: default.isolate = true + function test_prod_ekubo_yul_2pools() public { + _yul("ekubo-yul-2pools", "prod ekubo Yul router 2 pools A->B->C", 0, 2, TOKEN_C, 0.98e18); + } + + /// forge-config: default.isolate = true + function test_prod_ekubo_yul_3pools() public { + _yul("ekubo-yul-3pools", "prod ekubo Yul router 3 pools A->B->C->D", 0, 3, TOKEN_D, 0.97e18); + } + + /// forge-config: default.isolate = true + function test_prod_ekubo_yul_native() public { + _yul("ekubo-yul-native", "prod ekubo Yul router 1 pool ETH->B", SWAP_AMOUNT, 0, TOKEN_B, 0.99e18); + } + + // ---- minimal single-lock locker (like-for-like with the v4 minimal locker) ---- + + function _min(uint256 n, string memory name, address tokenIn, uint256 value, address tokenOut, uint256 minOut) internal { + _setup(); + PoolKey[] memory keys = new PoolKey[](n); + PoolKey memory last; + if (tokenIn == NATIVE_TOKEN_ADDRESS) { + keys[0] = keyNative; + last = keyNative; + } else { + keys[0] = keyAB; + if (n > 1) keys[1] = keyBC; + if (n > 2) keys[2] = keyCD; + last = keys[n - 1]; + } + bytes memory data = abi.encodeCall(minimal.swapExactIn, (keys, tokenIn, int128(int256(SWAP_AMOUNT)), address(this))); + _call("", address(minimal), value, data, last, tokenOut); + (uint256 out,) = _call(name, address(minimal), value, data, last, tokenOut); + assertGe(out, minOut, "output sanity"); + _recordCalldata(name, tokenIn == NATIVE_TOKEN_ADDRESS ? "ekubo-minimal-native" : string.concat("ekubo-minimal-", vm.toString(n), n == 1 ? "pool" : "pools"), data); + } + + /// forge-config: default.isolate = true + function test_prod_ekubo_minimal_1pool() public { + _min(1, "prod ekubo minimal locker 1 pool A->B", TOKEN_A, 0, TOKEN_B, 0.99e18); + } + + /// forge-config: default.isolate = true + function test_prod_ekubo_minimal_2pools() public { + _min(2, "prod ekubo minimal locker 2 pools A->B->C", TOKEN_A, 0, TOKEN_C, 0.98e18); + } + + /// forge-config: default.isolate = true + function test_prod_ekubo_minimal_3pools() public { + _min(3, "prod ekubo minimal locker 3 pools A->B->C->D", TOKEN_A, 0, TOKEN_D, 0.97e18); + } + + /// forge-config: default.isolate = true + function test_prod_ekubo_minimal_native() public { + _min(1, "prod ekubo minimal locker 1 pool ETH->B", NATIVE_TOKEN_ADDRESS, SWAP_AMOUNT, TOKEN_B, 0.99e18); + } +} diff --git a/benchmarks/prod/test/ProdV3.t.sol b/benchmarks/prod/test/ProdV3.t.sol new file mode 100644 index 0000000..9f68f46 --- /dev/null +++ b/benchmarks/prod/test/ProdV3.t.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {ProdCommon, IFreshToken} from "./ProdCommon.sol"; + +interface IUniswapV3Factory { + function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool); +} + +interface IUniswapV3Pool { + function token0() external view returns (address); + function token1() external view returns (address); + function initialize(uint160 sqrtPriceX96) external; + function mint(address recipient, int24 tickLower, int24 tickUpper, uint128 amount, bytes calldata data) + external + returns (uint256 amount0, uint256 amount1); + function swap(address recipient, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, bytes calldata data) + external + returns (int256 amount0, int256 amount1); + function slot0() + external + view + returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked); + function liquidity() external view returns (uint128); +} + +interface ISwapRouter { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + struct ExactInputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + } + + function factory() external view returns (address); + function WETH9() external view returns (address); + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); +} + +/// @notice Minimal N-hop exact-input router over v3 pools with the production SwapRouter's +/// token flow (first hop paid straight from the user, intermediate outputs held by +/// the router and paid forward, last hop delivered to the recipient): 2 transfers +/// per pool. Same contract as in the lab harness. +contract V3MinimalRouter { + uint160 constant MIN_LIMIT = 4295128740; // MIN_SQRT_RATIO + 1 + + function multiHop(IUniswapV3Pool[] memory pools, address[] memory tokensIn, int256 amountIn, address recipient) + external + returns (int256 out) + { + int256 amount = amountIn; + address payer = msg.sender; + for (uint256 i = 0; i < pools.length; i++) { + bool last = i == pools.length - 1; + (, int256 a1) = + pools[i].swap(last ? recipient : address(this), true, amount, MIN_LIMIT, abi.encode(tokensIn[i], payer)); + amount = -a1; + require(amount > 0, "no hop output"); + payer = address(this); + } + out = amount; + } + + function uniswapV3SwapCallback(int256 amount0Delta, int256, bytes calldata data) external { + (address tokenIn, address payer) = abi.decode(data, (address, address)); + uint256 owed = uint256(amount0Delta); // every hop is zeroForOne + if (payer == address(this)) IFreshToken(tokenIn).transfer(msg.sender, owed); + else IFreshToken(tokenIn).transferFrom(payer, msg.sender, owed); + } +} + +/// @notice Production Uniswap v3 on a fork of the latest mainnet block, fresh tokens: the +/// deployed factory creates the pools, the deployed SwapRouter is the production +/// path, the minimal router above is the lab counterpart. Full-range positions of +/// the liquidity that 1M/1M tokens buy at the init price (matching the other two +/// harnesses), minted directly in setup (unmeasured). One identical unmeasured call +/// precedes every measurement; every call is isolated (execution gas net of +/// refunds, excluding the 21,000 base and the calldata recorded next to it). +contract ProdV3Test is ProdCommon { + IUniswapV3Factory constant FACTORY = IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984); + ISwapRouter constant ROUTER = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); + + uint24 constant FEE = 3000; + int24 constant TICK_LOWER = -887220; + int24 constant TICK_UPPER = 887220; + uint160 constant INIT_SQRT_PRICE = 79347087983665984109010747392; // tick 30, strictly inside (spacing 60) + uint128 constant LIQUIDITY = 998501199320305883812938; // 1M/1M full-range at the init price + + IUniswapV3Pool poolAB; + IUniswapV3Pool poolBC; + IUniswapV3Pool poolCD; + IUniswapV3Pool poolWethB; + V3MinimalRouter minimal; + + function _setup() internal { + assertEq(block.chainid, 1, "mainnet fork"); + assertEq(ROUTER.factory(), address(FACTORY), "production SwapRouter and factory"); + assertEq(ROUTER.WETH9(), WETH, "WETH9"); + _deployTokens(); + deal(WETH, address(this), 10_000_000 ether); + + poolAB = _pool(TOKEN_A, TOKEN_B); + poolBC = _pool(TOKEN_B, TOKEN_C); + poolCD = _pool(TOKEN_C, TOKEN_D); + poolWethB = _pool(TOKEN_B, WETH); // TOKEN_B < WETH by address + minimal = new V3MinimalRouter(); + IFreshToken(TOKEN_A).approve(address(ROUTER), type(uint256).max); + IFreshToken(TOKEN_A).approve(address(minimal), type(uint256).max); + assertEq(IFreshToken(TOKEN_A).allowance(address(this), address(ROUTER)), type(uint256).max, "max approval"); + } + + function _pool(address t0, address t1) internal returns (IUniswapV3Pool pool) { + assertLt(uint160(t0), uint160(t1), "ordered"); + assertEq(FACTORY.getPool(t0, t1, FEE), address(0), "pool must not exist yet"); + pool = IUniswapV3Pool(FACTORY.createPool(t0, t1, FEE)); + pool.initialize(INIT_SQRT_PRICE); + pool.mint(address(this), TICK_LOWER, TICK_UPPER, LIQUIDITY, ""); + assertEq(pool.liquidity(), LIQUIDITY); + } + + function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { + if (amount0Owed > 0) IFreshToken(IUniswapV3Pool(msg.sender).token0()).transfer(msg.sender, amount0Owed); + if (amount1Owed > 0) IFreshToken(IUniswapV3Pool(msg.sender).token1()).transfer(msg.sender, amount1Owed); + } + + function _tick(IUniswapV3Pool pool) internal view returns (int24 tick) { + (, tick,,,,,) = pool.slot0(); + } + + function _call(string memory name, address to, uint256 value, bytes memory data, IUniswapV3Pool lastPool, address tokenOut) + internal + returns (uint256 out) + { + int24 before = _tick(lastPool); + uint256 balBefore = IFreshToken(tokenOut).balanceOf(address(this)); + assertGt(balBefore, 0, "recipient already holds the output token"); + (bool ok, bytes memory ret) = to.call{value: value}(data); + if (bytes(name).length > 0) vm.snapshotGasLastCall(name); + assertTrue(ok, "call reverted"); + out = IFreshToken(tokenOut).balanceOf(address(this)) - balBefore; + if (ret.length == 32) assertEq(abi.decode(ret, (uint256)), out, "router-reported output equals balance change"); + int256 moved = int256(_tick(lastPool)) - int256(before); + if (moved < 0) moved = -moved; + assertLe(uint256(moved), 1, "moved more than one tick"); + if (bytes(name).length > 0) { + vm.snapshotValue(string.concat(name, " | output (wei)"), out); + vm.snapshotValue(string.concat(name, " | ticks moved"), uint256(moved)); + } + } + + // ---- production SwapRouter ---- + + function _single(address tokenIn, address tokenOut, uint256 value) internal pure returns (bytes memory) { + return abi.encodeCall( + ISwapRouter.exactInputSingle, + ( + ISwapRouter.ExactInputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + fee: FEE, + recipient: 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, + deadline: type(uint256).max, + amountIn: value == 0 ? SWAP_AMOUNT : value, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }) + ) + ); + } + + function _path(uint256 n) internal pure returns (bytes memory path) { + path = abi.encodePacked(TOKEN_A, FEE, TOKEN_B); + if (n > 1) path = abi.encodePacked(path, FEE, TOKEN_C); + if (n > 2) path = abi.encodePacked(path, FEE, TOKEN_D); + } + + function _router(string memory file, string memory name, bytes memory data, uint256 value, uint256 n, address tokenOut, uint256 minOut) + internal + { + _setup(); + // resolved after _setup(): the pools are storage, so they must not be read before it + IUniswapV3Pool last = n == 0 ? poolWethB : (n == 1 ? poolAB : (n == 2 ? poolBC : poolCD)); + _call("", address(ROUTER), value, data, last, tokenOut); // warm-up, unmeasured + uint256 out = _call(name, address(ROUTER), value, data, last, tokenOut); + assertGe(out, minOut, "output sanity"); + assertLe(out, (SWAP_AMOUNT * 101) / 100, "output sanity"); // pools sit at tick 30.5, so 1 A buys ~1.003 B before the 0.3% fee + _recordCalldata(name, file, data); + } + + /// forge-config: default.isolate = true + function test_prod_v3_swapRouter_1pool() public { + _router("v3-swaprouter-1pool", "prod v3 SwapRouter exactInputSingle 1 pool A->B", _single(TOKEN_A, TOKEN_B, 0), 0, 1, TOKEN_B, 0.99e18); + } + + /// forge-config: default.isolate = true + function test_prod_v3_swapRouter_2pools() public { + bytes memory data = abi.encodeCall( + ISwapRouter.exactInput, + (ISwapRouter.ExactInputParams({path: _path(2), recipient: address(this), deadline: type(uint256).max, amountIn: SWAP_AMOUNT, amountOutMinimum: 0})) + ); + _router("v3-swaprouter-2pools", "prod v3 SwapRouter exactInput 2 pools A->B->C", data, 0, 2, TOKEN_C, 0.98e18); + } + + /// forge-config: default.isolate = true + function test_prod_v3_swapRouter_3pools() public { + bytes memory data = abi.encodeCall( + ISwapRouter.exactInput, + (ISwapRouter.ExactInputParams({path: _path(3), recipient: address(this), deadline: type(uint256).max, amountIn: SWAP_AMOUNT, amountOutMinimum: 0})) + ); + _router("v3-swaprouter-3pools", "prod v3 SwapRouter exactInput 3 pools A->B->C->D", data, 0, 3, TOKEN_D, 0.97e18); + } + + /// @dev v3 pools hold WETH, so a native-ETH user sends value to the SwapRouter, which + /// wraps it in flight (`pay`: tokenIn == WETH9 and the value covers the input). + /// forge-config: default.isolate = true + function test_prod_v3_swapRouter_native() public { + _router("v3-swaprouter-native", "prod v3 SwapRouter exactInputSingle 1 pool ETH(wrapped in flight)->B", _single(WETH, TOKEN_B, SWAP_AMOUNT), SWAP_AMOUNT, 0, TOKEN_B, 0.99e18); + } + + // ---- minimal router ---- + + function _min(uint256 n, string memory name, address tokenOut, uint256 minOut) internal { + _setup(); + IUniswapV3Pool[] memory pools = new IUniswapV3Pool[](n); + address[] memory tokensIn = new address[](n); + pools[0] = poolAB; + tokensIn[0] = TOKEN_A; + if (n > 1) { + pools[1] = poolBC; + tokensIn[1] = TOKEN_B; + } + if (n > 2) { + pools[2] = poolCD; + tokensIn[2] = TOKEN_C; + } + bytes memory data = abi.encodeCall(minimal.multiHop, (pools, tokensIn, int256(SWAP_AMOUNT), address(this))); + _call("", address(minimal), 0, data, pools[n - 1], tokenOut); + uint256 out = _call(name, address(minimal), 0, data, pools[n - 1], tokenOut); + assertGe(out, minOut, "output sanity"); + _recordCalldata(name, string.concat("v3-minimal-", vm.toString(n), n == 1 ? "pool" : "pools"), data); + } + + /// forge-config: default.isolate = true + function test_prod_v3_minimal_1pool() public { + _min(1, "prod v3 minimal router 1 pool A->B", TOKEN_B, 0.99e18); + } + + /// forge-config: default.isolate = true + function test_prod_v3_minimal_2pools() public { + _min(2, "prod v3 minimal router 2 pools A->B->C", TOKEN_C, 0.98e18); + } + + /// forge-config: default.isolate = true + function test_prod_v3_minimal_3pools() public { + _min(3, "prod v3 minimal router 3 pools A->B->C->D", TOKEN_D, 0.97e18); + } +} diff --git a/benchmarks/prod/test/ProdV4.t.sol b/benchmarks/prod/test/ProdV4.t.sol new file mode 100644 index 0000000..b55d4ea --- /dev/null +++ b/benchmarks/prod/test/ProdV4.t.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.26; + +import {ProdCommon, IFreshToken} from "./ProdCommon.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol"; +import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol"; +import {SwapParams, ModifyLiquidityParams} from "v4-core/src/types/PoolOperation.sol"; +import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; +import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; +import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol"; +import {TransientStateLibrary} from "v4-core/src/libraries/TransientStateLibrary.sol"; +import {CurrencySettler} from "v4-core/test/utils/CurrencySettler.sol"; +import {LiquidityAmounts} from "v4-core/test/utils/LiquidityAmounts.sol"; +import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol"; + +interface IPermit2 { + function approve(address token, address spender, uint160 amount, uint48 expiration) external; +} + +interface IUniversalRouter { + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; +} + +/// @notice Minimal single-lock N-hop exact-input router (e.g. A->B->C->D) with net +/// settlement: unlock, swap each hop, settle the input once, take the output once. +/// Same contract as in the lab harness; the like-for-like counterpart of the +/// Ekubo minimal locker. +contract V4MinimalRouter is IUnlockCallback { + using CurrencyLibrary for Currency; + using CurrencySettler for Currency; + using TransientStateLibrary for IPoolManager; + + IPoolManager public immutable manager; + + constructor(IPoolManager _manager) { + manager = _manager; + } + + struct Hop { + PoolKey key; + uint160 sqrtPriceLimitX96; + } + + function multiHop(Hop[] memory hops, Currency currencyIn, Currency currencyOut, int256 amountIn) + external + payable + returns (uint256 amountOut) + { + bytes memory result = manager.unlock(abi.encode(hops, currencyIn, currencyOut, amountIn, msg.sender, msg.sender)); + amountOut = abi.decode(result, (uint256)); + uint256 ethBalance = address(this).balance; + if (ethBalance > 0) CurrencyLibrary.ADDRESS_ZERO.transfer(msg.sender, ethBalance); + } + + function unlockCallback(bytes calldata rawData) external returns (bytes memory) { + require(msg.sender == address(manager)); + (Hop[] memory hops, Currency currencyIn, Currency currencyOut, int256 amountIn, address payer, address recipient) = + abi.decode(rawData, (Hop[], Currency, Currency, int256, address, address)); + + Currency currencyOwed = currencyIn; + int256 amountOwed = amountIn; + for (uint256 i = 0; i < hops.length; i++) { + bool zeroForOne = currencyOwed == hops[i].key.currency0; + manager.swap( + hops[i].key, + SwapParams({zeroForOne: zeroForOne, amountSpecified: amountOwed, sqrtPriceLimitX96: hops[i].sqrtPriceLimitX96}), + "" + ); + currencyOwed = zeroForOne ? hops[i].key.currency1 : hops[i].key.currency0; + amountOwed = -manager.currencyDelta(address(this), currencyOwed); + require(amountOwed < 0, "no hop output"); + } + + int256 inDelta = manager.currencyDelta(address(this), currencyIn); + int256 outDelta = manager.currencyDelta(address(this), currencyOut); + require(inDelta <= 0, "inDelta positive"); + require(outDelta >= 0, "outDelta negative"); + if (inDelta < 0) currencyIn.settle(manager, payer, uint256(-inDelta), false); + if (outDelta > 0) currencyOut.take(manager, recipient, uint256(outDelta), false); + return abi.encode(uint256(outDelta)); + } +} + +/// @notice Production Uniswap v4 on a fork of the latest mainnet block, fresh tokens: the +/// deployed PoolManager holds the pools, the deployed Universal Router (funded +/// through Permit2, the path the Uniswap interface sends) is the production path, +/// the minimal router above is the lab counterpart. Full-range positions of 1M/1M +/// tokens per pool, added through v4-core's PoolModifyLiquidityTest in setup +/// (unmeasured). One identical unmeasured call precedes every measurement; every +/// call is isolated (execution gas net of refunds, excluding the 21,000 base and +/// the calldata recorded next to it). +contract ProdV4Test is ProdCommon { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + + IPoolManager constant MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90); + IUniversalRouter constant UR = IUniversalRouter(0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af); + IPermit2 constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3); + + int24 constant TICK_LOWER = -887220; + int24 constant TICK_UPPER = 887220; + int24 constant INIT_TICK = 30; + + // Universal Router command / v4 action bytes (universal-router Commands.sol, v4-periphery Actions.sol) + bytes1 constant CMD_V4_SWAP = 0x10; + uint8 constant ACT_SWAP_EXACT_IN_SINGLE = 0x06; + uint8 constant ACT_SETTLE_ALL = 0x0c; + uint8 constant ACT_TAKE_ALL = 0x0f; + + // IV4Router.ExactInputSingleParams as deployed in the 2025 Universal Router (no minHopPriceX36) + struct ExactInputSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 amountIn; + uint128 amountOutMinimum; + bytes hookData; + } + + PoolKey keyAB; + PoolKey keyBC; + PoolKey keyCD; + PoolKey keyNative; + V4MinimalRouter minimal; + PoolModifyLiquidityTest mintRouter; + + function _key(address c0, address c1) internal pure returns (PoolKey memory) { + return PoolKey({currency0: Currency.wrap(c0), currency1: Currency.wrap(c1), fee: 3000, tickSpacing: 60, hooks: IHooks(address(0))}); + } + + function _setup() internal { + assertEq(block.chainid, 1, "mainnet fork"); + assertGt(address(MANAGER).code.length, 0, "PoolManager deployed"); + assertGt(address(UR).code.length, 0, "Universal Router deployed"); + _deployTokens(); + + mintRouter = new PoolModifyLiquidityTest(MANAGER); + minimal = new V4MinimalRouter(MANAGER); + address[4] memory ts = [TOKEN_A, TOKEN_B, TOKEN_C, TOKEN_D]; + for (uint256 i = 0; i < 4; i++) { + IFreshToken(ts[i]).approve(address(mintRouter), type(uint256).max); + } + keyAB = _key(TOKEN_A, TOKEN_B); + keyBC = _key(TOKEN_B, TOKEN_C); + keyCD = _key(TOKEN_C, TOKEN_D); + keyNative = _key(address(0), TOKEN_B); + _init(keyAB, 0); + _init(keyBC, 0); + _init(keyCD, 0); + _init(keyNative, LIQ_AMOUNT); + + IFreshToken(TOKEN_A).approve(address(minimal), type(uint256).max); + IFreshToken(TOKEN_A).approve(address(PERMIT2), type(uint256).max); + PERMIT2.approve(TOKEN_A, address(UR), type(uint160).max, uint48(block.timestamp + 1 days)); + assertEq(IFreshToken(TOKEN_A).allowance(address(this), address(minimal)), type(uint256).max, "max approval"); + } + + function _init(PoolKey memory key, uint256 value) internal { + (uint160 sqrtPrice,,,) = MANAGER.getSlot0(key.toId()); + assertEq(sqrtPrice, 0, "pool must not exist yet"); + MANAGER.initialize(key, TickMath.getSqrtPriceAtTick(INIT_TICK)); + uint128 liq = LiquidityAmounts.getLiquidityForAmounts( + TickMath.getSqrtPriceAtTick(INIT_TICK), + TickMath.getSqrtPriceAtTick(TICK_LOWER), + TickMath.getSqrtPriceAtTick(TICK_UPPER), + LIQ_AMOUNT, + LIQ_AMOUNT + ); + mintRouter.modifyLiquidity{value: value}( + key, ModifyLiquidityParams({tickLower: TICK_LOWER, tickUpper: TICK_UPPER, liquidityDelta: int256(uint256(liq)), salt: bytes32(0)}), "" + ); + assertEq(MANAGER.getLiquidity(key.toId()), liq); + } + + function _tick(PoolKey memory key) internal view returns (int24 tick) { + (, tick,,) = MANAGER.getSlot0(key.toId()); + } + + function _call(string memory name, address to, uint256 value, bytes memory data, PoolKey memory lastPool, address tokenOut) + internal + returns (uint256 out) + { + int24 before = _tick(lastPool); + uint256 balBefore = IFreshToken(tokenOut).balanceOf(address(this)); + assertGt(balBefore, 0, "recipient already holds the output token"); + (bool ok, bytes memory ret) = to.call{value: value}(data); + if (bytes(name).length > 0) vm.snapshotGasLastCall(name); + assertTrue(ok, "call reverted"); + out = IFreshToken(tokenOut).balanceOf(address(this)) - balBefore; + if (ret.length == 32) assertEq(abi.decode(ret, (uint256)), out, "router-reported output equals balance change"); + int256 moved = int256(_tick(lastPool)) - int256(before); + if (moved < 0) moved = -moved; + assertLe(uint256(moved), 1, "moved more than one tick"); + if (bytes(name).length > 0) { + vm.snapshotValue(string.concat(name, " | output (wei)"), out); + vm.snapshotValue(string.concat(name, " | ticks moved"), uint256(moved)); + } + } + + // ---- minimal single-lock router ---- + + function _min(uint256 n, string memory name, address tokenIn, uint256 value, address tokenOut, uint256 minOut) internal { + _setup(); + V4MinimalRouter.Hop[] memory hops = new V4MinimalRouter.Hop[](n); + PoolKey memory last; + if (tokenIn == address(0)) { + hops[0] = V4MinimalRouter.Hop(keyNative, TickMath.MIN_SQRT_PRICE + 1); + last = keyNative; + } else { + hops[0] = V4MinimalRouter.Hop(keyAB, TickMath.MIN_SQRT_PRICE + 1); + if (n > 1) hops[1] = V4MinimalRouter.Hop(keyBC, TickMath.MIN_SQRT_PRICE + 1); + if (n > 2) hops[2] = V4MinimalRouter.Hop(keyCD, TickMath.MIN_SQRT_PRICE + 1); + last = hops[n - 1].key; + } + bytes memory data = abi.encodeCall( + minimal.multiHop, (hops, Currency.wrap(tokenIn), Currency.wrap(tokenOut), -int256(SWAP_AMOUNT)) + ); + _call("", address(minimal), value, data, last, tokenOut); + uint256 out = _call(name, address(minimal), value, data, last, tokenOut); + assertGe(out, minOut, "output sanity"); + _recordCalldata(name, tokenIn == address(0) ? "v4-minimal-native" : string.concat("v4-minimal-", vm.toString(n), n == 1 ? "pool" : "pools"), data); + } + + /// forge-config: default.isolate = true + function test_prod_v4_minimal_1pool() public { + _min(1, "prod v4 minimal locker 1 pool A->B", TOKEN_A, 0, TOKEN_B, 0.99e18); + } + + /// forge-config: default.isolate = true + function test_prod_v4_minimal_2pools() public { + _min(2, "prod v4 minimal locker 2 pools A->B->C", TOKEN_A, 0, TOKEN_C, 0.98e18); + } + + /// forge-config: default.isolate = true + function test_prod_v4_minimal_3pools() public { + _min(3, "prod v4 minimal locker 3 pools A->B->C->D", TOKEN_A, 0, TOKEN_D, 0.97e18); + } + + /// forge-config: default.isolate = true + function test_prod_v4_minimal_native() public { + _min(1, "prod v4 minimal locker 1 pool ETH->B", address(0), SWAP_AMOUNT, TOKEN_B, 0.99e18); + } + + // ---- production Universal Router, Permit2-funded ---- + + /// forge-config: default.isolate = true + function test_prod_v4_universalRouter_1pool() public { + _setup(); + bytes memory actions = abi.encodePacked(ACT_SWAP_EXACT_IN_SINGLE, ACT_SETTLE_ALL, ACT_TAKE_ALL); + bytes[] memory params = new bytes[](3); + params[0] = abi.encode( + ExactInputSingleParams({poolKey: keyAB, zeroForOne: true, amountIn: uint128(SWAP_AMOUNT), amountOutMinimum: 0, hookData: ""}) + ); + params[1] = abi.encode(Currency.wrap(TOKEN_A), SWAP_AMOUNT); + params[2] = abi.encode(Currency.wrap(TOKEN_B), uint256(0)); + bytes[] memory inputs = new bytes[](1); + inputs[0] = abi.encode(actions, params); + bytes memory data = abi.encodeCall(IUniversalRouter.execute, (abi.encodePacked(CMD_V4_SWAP), inputs, type(uint256).max)); + string memory name = "prod v4 Universal Router V4_SWAP 1 pool A->B (Permit2)"; + _call("", address(UR), 0, data, keyAB, TOKEN_B); + uint256 out = _call(name, address(UR), 0, data, keyAB, TOKEN_B); + assertGe(out, 0.99e18, "output sanity"); + assertLe(out, (SWAP_AMOUNT * 101) / 100, "output sanity"); // pools sit at tick 30.5, so 1 A buys ~1.003 B before the 0.3% fee + _recordCalldata(name, "v4-universal-router-1pool", data); + } +} From 7497f11de94808c96e91ec19954c145515d3aa50 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 17:49:36 -0400 Subject: [PATCH 13/17] Rebuild gas-efficiency page on production-contract bench and a 30-day pools-per-swap sample Headline is now a fresh ERC20 pair through each protocol's production router on a latest-block fork (Ekubo 110,875 vs v4 Universal Router 144,390 vs v3 SwapRouter 123,587 transaction gas), with minimal-router and native-ETH rows and USDC/USDT kept as the expensive-token bound. Pools per swap is measured over every 10th block of a month (21,600 blocks, 664,726 swap txs, 1.75 avg, 32.7% of gas), all AMM families, per-family and per-day breakdowns in the README. Blend, routes-per-block and chain-wide scenario tables recomputed from the raw files by benchmarks/page-numbers.mjs. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 98 +- benchmarks/page-numbers.mjs | 207 +++++ benchmarks/pools-per-swap/measure.mjs | 12 +- benchmarks/pools-per-swap/results-30d.json | 972 ++++++++++++++++++++ benchmarks/prod/encode-prod-routes.mjs | 32 +- src/content/docs/concepts/gas-efficiency.md | 310 +++---- 6 files changed, 1439 insertions(+), 192 deletions(-) create mode 100644 benchmarks/page-numbers.mjs create mode 100644 benchmarks/pools-per-swap/results-30d.json diff --git a/benchmarks/README.md b/benchmarks/README.md index 1950dd3..ddaa78b 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -373,25 +373,35 @@ Bare-tier mints (new position, boundary ticks initialized): Ekubo 179,020, v4 ## Pools per swap transaction (mainnet, JSON-RPC) The docs page's chain-wide blend uses a measured average of pools touched per swap -transaction. Dune was the intended source (a trailing-7-day query over `ethereum.logs`) -but no Dune API key or account was available to this repository, so the measurement was -taken from a public JSON-RPC endpoint instead: `https://ethereum-rpc.publicnode.com` -(`eth_getBlockReceipts` and `eth_getBlockByNumber` with full transactions; that endpoint -refuses `eth_getLogs` without an address filter, so receipts are read per block). The -window is 1,000 consecutive blocks, `25997046` to `25998045` (2026-09-17 11:55:11 to -15:16:11 UTC, tip pinned 12 blocks behind head at start), 2,174 RPC calls in total, no -rate limiting observed at 4 concurrent blocks. Script and raw output: -`pools-per-swap/measure.mjs` and `pools-per-swap/results.json`. An earlier 200-block run -(`25997689`–`25997888`, Uniswap v2/v3/v4 and Curve only) gave 5,341 swap transactions at -1.657 pools per swap and a 30.25% gas share; the wider event set and window below -supersede it. +transaction. Dune was the intended source (a query over `ethereum.logs`) but no Dune API +key or account was available to this repository, so the measurement is taken from public +JSON-RPC endpoints instead (`eth_getBlockReceipts` and `eth_getBlockByNumber` with full +transactions, read per block). The page uses the **30-day stride sample** below +(`results-30d.json`): every 10th block from `25783810` to `25999800`, 21,600 blocks +read out of the 215,991 spanned (2026-08-18 18:36:47 to 2026-09-17 21:08:59 UTC, tip +pinned 12 blocks behind head at start), so every hour of the month, every day of the +week and every gas regime in it is sampled uniformly. The earlier 1,000-consecutive-block +window `25997046`–`25998045` (`results.json`, 2026-09-17 11:55:11 to 15:16:11 UTC) and a +200-block run before it (Uniswap v2/v3/v4 and Curve only, 1.657 pools per swap) are kept +as the short-window cross-checks; the 30-day sample supersedes both. ```sh cd benchmarks/pools-per-swap -node measure.mjs --blocks 1000 --to 25998045 # reproduces results.json -node measure.mjs --blocks 1000 # fresh window ending 12 blocks behind head +node measure.mjs --blocks 21600 --stride 10 --to 25999800 --conc 6 --out results-30d.json # the 30-day sample +node measure.mjs --blocks 1000 --to 25998045 # the 1,000-block window +node measure.mjs --blocks 21600 --stride 10 # a fresh month ending 12 blocks behind head ``` +Endpoints for the 30-day run (rotated per request on any failure; `--rpc` overrides): +`https://eth.drpc.org`, `https://mainnet.gateway.tenderly.co`, `https://eth.merkle.io`, +`https://eth.rpc.blxrbdn.com`. `ethereum-rpc.publicnode.com`, used for the 1,000-block +window, returns `null` receipts for blocks older than a few days and cannot serve a +month. Every block is accepted only when the receipt list has exactly one entry per +transaction in the block, in order, with matching hashes; otherwise both calls are +retried on the next endpoint. The run made 54,013 RPC calls, 10,803 of them retries +(rate limiting and one endpoint intermittently answering with empty or partial receipt +lists, which the consistency check rejects), at 6 concurrent blocks in about 100 minutes. + Two measurements are taken over the same blocks: 1. **Receipts (all flow).** A transaction counts as a swap if it succeeded and emitted at @@ -582,6 +592,66 @@ found among the window's gas consumers; what remains uncounted is listed under " counted" above, so a transaction whose only swap is an order-book fill, a legacy Ekubo v2 swap, or a long-tail AMM is not counted. +### Results, 30-day stride sample (every 10th block, 25783810–25999800) + +All flow, from receipts: 5,724,722 transactions in the sampled blocks (5,643,974 +successful), of which 664,726 are swap transactions with 1,161,778 pool swap events, +**1.748 pools per swap transaction** (1,161,778 / 664,726 = 1.7478). The swap transactions used +32.68% of all gas in the sampled blocks (the ratio of summed `gasUsed`, so it is the +share of block space, not of fee burn). + +| Pools touched | Transactions | Share | +| ------------- | ------------ | ------ | +| 1 | 459,356 | 69.10% | +| 2 | 106,197 | 15.98% | +| 3 | 49,247 | 7.41% | +| 4 or more | 49,926 | 7.51% | + +Per family (a transaction touching two families is counted in both; "gas share" is the +gas of every transaction touching the family over all gas in the sampled blocks, so it +also overlaps): + +| Family | Transactions | Events of this family per tx | All pools per tx touching it | Gas share of window | +| ----------------- | ------------ | ---------------------------- | ---------------------------- | ------------------- | +| Uniswap v3 | 327,953 | 1.327 | 2.101 | 18.30% | +| Uniswap v4 | 237,383 | 1.507 | 2.493 | 15.51% | +| Uniswap v2 family | 178,611 | 1.241 | 2.212 | 9.50% | +| Curve | 37,104 | 1.391 | 4.086 | 4.61% | +| PancakeSwap v3 | 30,611 | 1.140 | 4.299 | 3.23% | +| Ekubo | 21,419 | 1.128 | 3.346 | 2.22% | +| Fluid DEX | 8,789 | 1.079 | 3.940 | 1.16% | +| DODO | 7,846 | 1.071 | 4.457 | 1.08% | +| Balancer v2 | 5,584 | 1.063 | 4.230 | 0.61% | +| Balancer v3 | 3,987 | 1.080 | 4.385 | 0.53% | +| Maverick v2 | 2,591 | 1.045 | 4.116 | 0.33% | +| Bancor v2.1 | 2,359 | 1.848 | 5.242 | 0.25% | +| Maverick v1 | 563 | 1.046 | 4.417 | 0.08% | +| Bancor v3 | 531 | 1.047 | 5.633 | 0.06% | +| Bancor Carbon | 218 | 1.009 | 2.844 | 0.02% | +| Solidly-style | 3 | 1.000 | 1.667 | 0.00% | + +Every catalogued family had at least one event in the month; none is missing. The +sanity counters read 0 failed transactions with swap logs and 0 topic-less Core logs of +a length other than 116 bytes. + +Per UTC day (`daily` in `results-30d.json`, 716–718 sampled blocks per full day; the +first and last days are partial): the average ranged from 1.640 (August 26) to 1.865 +(August 19) pools per swap transaction, and the swap-transaction gas share from 20.25% +(the 162-block partial first day) and 24.82% (August 29) to 41.03% (August 23). The +1,000-block window on September 17 (1.678, 32.67%) sits inside both ranges. + +Router flow, from calldata: 135,167 successful transactions to the verified routers were +decoded (8,303 opaque 1inch `swap` calls and 4,224 non-swap selectors excluded) at +**1.219 pools per router transaction**; cross-checked against their own receipts, +134,178 agree exactly, 989 have more pool events than the calldata declares (aggregator +executors adding hops) and none fewer. Direct router calls are lighter than the all-flow +average because aggregators, MEV bundles and other contracts carry most of the multi-pool +weight. + +Reading the histogram for the page: 69% of swap transactions touch one pool, but the +31% that touch more carry enough pools to lift the mean to 1.75, and the daily mean +never left the 1.64–1.87 band in a month that spanned a 2x range of swap gas share. + ## L2 fork methodology (Base, Arbitrum One) Same discipline as the mainnet fork (pinned block, one identical unmeasured warm-up swap, diff --git a/benchmarks/page-numbers.mjs b/benchmarks/page-numbers.mjs new file mode 100644 index 0000000..2808cd0 --- /dev/null +++ b/benchmarks/page-numbers.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node +// Derives every figure on the Gas efficiency page from the raw snapshot and measurement +// files in this directory, so each percentage can be re-checked without a calculator. +// Run: node page-numbers.mjs +import { readFileSync } from "node:fs"; +const j = (p) => JSON.parse(readFileSync(new URL(p, import.meta.url))); +const ek = j("./prod/snapshots/ProdEkuboTest.json"); +const v3 = j("./prod/snapshots/ProdV3Test.json"); +const v4 = j("./prod/snapshots/ProdV4Test.json"); +const month = j(process.env.MONTH ?? "./pools-per-swap/results-30d.json"); +const BASE = 21000; +const BLOCK_GAS = 60_000_000; +const tx = (snap, name) => + Number(snap[name]) + + BASE + + Number(snap[`${name} | calldata gas (4/16 per byte)`]); +const pct = (a, b) => ((100 * (b - a)) / b).toFixed(1); +const fmt = (n) => Math.round(n).toLocaleString("en-US"); + +// --- production routers and minimal lockers, 1 / 2 / 3 pools, fresh ERC20 pair +const legs = { + "Ekubo, Yul router": [1, 2, 3].map((n) => + tx( + ek, + `prod ekubo Yul router ${n} pool${n > 1 ? "s" : ""} A->B${n > 1 ? "->C" : ""}${n > 2 ? "->D" : ""}`, + ), + ), + "Ekubo, minimal locker": [1, 2, 3].map((n) => + tx( + ek, + `prod ekubo minimal locker ${n} pool${n > 1 ? "s" : ""} A->B${n > 1 ? "->C" : ""}${n > 2 ? "->D" : ""}`, + ), + ), + "v4, Universal Router": [ + tx(v4, "prod v4 Universal Router V4_SWAP 1 pool A->B (Permit2)"), + ], + "v4, minimal locker": [1, 2, 3].map((n) => + tx( + v4, + `prod v4 minimal locker ${n} pool${n > 1 ? "s" : ""} A->B${n > 1 ? "->C" : ""}${n > 2 ? "->D" : ""}`, + ), + ), + "v3, SwapRouter": [ + tx(v3, "prod v3 SwapRouter exactInputSingle 1 pool A->B"), + tx(v3, "prod v3 SwapRouter exactInput 2 pools A->B->C"), + tx(v3, "prod v3 SwapRouter exactInput 3 pools A->B->C->D"), + ], + "v3, minimal router": [1, 2, 3].map((n) => + tx( + v3, + `prod v3 minimal router ${n} pool${n > 1 ? "s" : ""} A->B${n > 1 ? "->C" : ""}${n > 2 ? "->D" : ""}`, + ), + ), +}; +console.log( + "Transaction gas (execution + 21,000 + calldata), fresh ERC20 pair, block", + ek["prod fork block number"], +); +for (const [k, v] of Object.entries(legs)) + console.log( + ` ${k.padEnd(24)} ${v.map(fmt).join(" / ")} routes per 60M block: ${v.map((g) => Math.floor(BLOCK_GAS / g)).join(" / ")}`, + ); +const E = legs["Ekubo, Yul router"][0], + V3 = legs["v3, SwapRouter"][0], + V4 = legs["v4, Universal Router"][0], + V4m = legs["v4, minimal locker"][0]; +console.log( + ` headline production: Ekubo ${fmt(E)} vs v3 ${fmt(V3)} (${pct(E, V3)}% less) vs v4 UR ${fmt(V4)} (${pct(E, V4)}% less); vs v4 minimal ${fmt(V4m)} (${pct(E, V4m)}% less)`, +); +const Em = legs["Ekubo, minimal locker"][0], + V3m = legs["v3, minimal router"][0]; +console.log( + ` like-for-like minimal: Ekubo ${fmt(Em)} vs v4 ${fmt(V4m)} (${pct(Em, V4m)}%) vs v3 ${fmt(V3m)} (${pct(Em, V3m)}%)`, +); + +// --- native ETH input +const nat = { + ekubo: tx(ek, "prod ekubo Yul router 1 pool ETH->B"), + v4: tx(v4, "prod v4 minimal locker 1 pool ETH->B"), + v3: tx( + v3, + "prod v3 SwapRouter exactInputSingle 1 pool ETH(wrapped in flight)->B", + ), +}; +console.log( + ` native ETH in: Ekubo ${fmt(nat.ekubo)} vs v4 minimal ${fmt(nat.v4)} (${pct(nat.ekubo, nat.v4)}%) vs v3 SwapRouter ${fmt(nat.v3)} (${pct(nat.ekubo, nat.v3)}%)`, +); + +// --- marginal cost per extra pool (minimal routers, transaction gas incl. calldata growth) and blend +const marg = Object.fromEntries( + Object.entries(legs) + .filter(([, v]) => v.length === 3) + .map(([k, v]) => [k, (v[2] - v[0]) / 2]), +); +console.log( + "Marginal transaction gas per extra pool:", + Object.entries(marg) + .map(([k, v]) => `${k} ${fmt(v)}`) + .join("; "), +); +const execMarg = { + ekuboYul: + (ek["prod ekubo Yul router 3 pools A->B->C->D"] - + ek["prod ekubo Yul router 1 pool A->B"]) / + 2, + ekuboMin: + (ek["prod ekubo minimal locker 3 pools A->B->C->D"] - + ek["prod ekubo minimal locker 1 pool A->B"]) / + 2, + v4Min: + (v4["prod v4 minimal locker 3 pools A->B->C->D"] - + v4["prod v4 minimal locker 1 pool A->B"]) / + 2, + v3Min: + (v3["prod v3 minimal router 3 pools A->B->C->D"] - + v3["prod v3 minimal router 1 pool A->B"]) / + 2, + v3SR: + (v3["prod v3 SwapRouter exactInput 3 pools A->B->C->D"] - + v3["prod v3 SwapRouter exactInputSingle 1 pool A->B"]) / + 2, +}; +console.log( + "Marginal execution gas per extra pool:", + Object.entries(execMarg) + .map(([k, v]) => `${k} ${fmt(v)}`) + .join("; "), +); + +const avg = month.receipts.avgPoolsPerSwapTx; +const blend = (p) => ({ + ekubo: + legs["Ekubo, minimal locker"][0] + (p - 1) * marg["Ekubo, minimal locker"], + v4: legs["v4, minimal locker"][0] + (p - 1) * marg["v4, minimal locker"], + v3: legs["v3, minimal router"][0] + (p - 1) * marg["v3, minimal router"], +}); +console.log( + `Blend (minimal routers), pools per swap -> Ekubo / v4 / v3 / saving vs v4 / vs v3`, +); +const rows = [avg, 1.0, 1.5, 2.0]; +const savings = {}; +for (const p of rows) { + const b = blend(p); + savings[p] = { v4: (b.v4 - b.ekubo) / b.v4, v3: (b.v3 - b.ekubo) / b.v3 }; + console.log( + ` ${p}${p === avg ? " (measured)" : ""}: ${fmt(b.ekubo)} / ${fmt(b.v4)} / ${fmt(b.v3)} / ${pct(b.ekubo, b.v4)}% / ${pct(b.ekubo, b.v3)}%`, + ); +} + +// --- pools per swap, 30-day sample +const r = month.receipts; +console.log( + `30-day sample: ${month.window.blocksRead} blocks, stride ${month.window.stride}, ${month.window.fromIso} .. ${month.window.toIso}, blocks ${month.window.fromBlock}..${month.window.toBlock}`, +); +console.log( + ` swap txs ${r.swapTxs.toLocaleString("en-US")}, avg ${avg} pools/swap tx, histogram`, + Object.entries(r.histogram) + .map(([k, v]) => `${k}: ${v.pct}%`) + .join(", "), + `gas share ${r.gasShare.pct}%`, +); +console.log( + " per family (txs / avg events of family per tx / avg total pools in those txs / gas share of window):", +); +for (const [f, s] of Object.entries(r.perFamily)) + console.log( + ` ${f.padEnd(14)} ${String(s.txs).padStart(7)} ${s.avgEventsOfThisFamilyPerTx} ${s.avgTotalPoolsPerTxTouchingFamily} ${((100 * Number(s.gasOfTxsTouchingFamily)) / Number(r.gasShare.allTxGas)).toFixed(2)}%`, + ); +if (month.daily) { + const d = Object.values(month.daily); + console.log( + ` daily avg pools/swap range ${Math.min(...d.map((x) => x.avgPoolsPerSwapTx))}..${Math.max(...d.map((x) => x.avgPoolsPerSwapTx))}, daily gas share range ${Math.min(...d.map((x) => x.swapTxGasSharePct))}%..${Math.max(...d.map((x) => x.swapTxGasSharePct))}%`, + ); +} +if (month.calldata) + console.log( + ` router-only (calldata-decoded) txs ${month.calldata.decodedTxs}, avg ${month.calldata.avgPoolsPerSwapTx}`, + ); + +// --- chain-wide scenarios: share of block gas freed = share x saving +const shares = [ + ["4.2%, measured DEX-router burn, lower bound", 0.042], + ["19.4%, ultrasound 'defi' bracket", 0.194], + [ + `${r.gasShare.pct}%, swap-event txs in the 30-day sample`, + r.gasShare.pct / 100, + ], + ["70%, stipulated AMM-trading upside (not measured)", 0.7], +]; +const cols = [ + ["headline production 1 pool", { v3: (V3 - E) / V3, v4: (V4 - E) / V4 }], + [`measured ${avg} pools`, savings[avg]], + ["2.0 pools", savings[2]], +]; +console.log( + "Scenarios (range = v3 saving .. v4 saving, share of block gas freed):", +); +for (const [label, sh] of shares) + console.log( + ` ${label.padEnd(52)} ` + + cols + .map( + ([c, s]) => + `${c}: ${(100 * sh * Math.min(s.v3, s.v4)).toFixed(1)}–${(100 * sh * Math.max(s.v3, s.v4)).toFixed(1)}%`, + ) + .join(" | "), + ); diff --git a/benchmarks/pools-per-swap/measure.mjs b/benchmarks/pools-per-swap/measure.mjs index 9b6ab31..a469329 100644 --- a/benchmarks/pools-per-swap/measure.mjs +++ b/benchmarks/pools-per-swap/measure.mjs @@ -560,7 +560,10 @@ function tallyBlock(block, rcpts) { const status = new Map( rcpts.map((r) => [r.transactionHash, r.status === "0x1"]), ); - const eventPools = tallyReceipts(rcpts, dayStat(Number(BigInt(block.timestamp)))); + const eventPools = tallyReceipts( + rcpts, + dayStat(Number(BigInt(block.timestamp))), + ); for (const tx of block.transactions) { const router = ROUTERS[(tx.to ?? "").toLowerCase()]; if (!router) continue; @@ -591,7 +594,8 @@ async function worker() { rcpts.every((r, i) => r.transactionHash === block.transactions[i].hash) ) break; - if (attempt >= 8) throw new Error(`block ${bn}: receipts do not match block`); + if (attempt >= 8) + throw new Error(`block ${bn}: receipts do not match block`); rpcRetries++; await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); } @@ -641,7 +645,9 @@ const out = { blocks: s.blocks, swapTxs: s.swapTxs, avgPoolsPerSwapTx: +(s.sumPools / (s.swapTxs || 1)).toFixed(3), - swapTxGasSharePct: s.gasAll ? +((10000n * s.gasSwapTxs) / s.gasAll).toString() / 100 : 0, + swapTxGasSharePct: s.gasAll + ? +((10000n * s.gasSwapTxs) / s.gasAll).toString() / 100 + : 0, }, ]), ), diff --git a/benchmarks/pools-per-swap/results-30d.json b/benchmarks/pools-per-swap/results-30d.json new file mode 100644 index 0000000..91fe4e2 --- /dev/null +++ b/benchmarks/pools-per-swap/results-30d.json @@ -0,0 +1,972 @@ +{ + "rpc": [ + "https://eth.drpc.org", + "https://mainnet.gateway.tenderly.co", + "https://eth.merkle.io", + "https://eth.rpc.blxrbdn.com" + ], + "window": { + "fromBlock": 25783810, + "toBlock": 25999800, + "stride": 10, + "blocksRead": 21600, + "blocksSpanned": 215991, + "blocks": 21600, + "fromTimestamp": 1787078207, + "toTimestamp": 1789679339, + "fromIso": "2026-08-18T18:36:47.000Z", + "toIso": "2026-09-17T21:08:59.000Z" + }, + "rpcCalls": 54013, + "rpcRetries": 10803, + "families": { + "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67": "univ3", + "0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822": "univ2-family", + "0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140": "curve", + "0xd013ca23e77a65003c2c659c5442c00c805371b7fc1ebd4c206c41d1536bd90b": "curve", + "0xb2e76ae99761dc136e598d4a629bb347eccb9532a5f8bbd72e18467c3c34cc98": "curve", + "0x143f1f8e861fbdeddd5b46e844b7d3ac7b86a122f36e8c463859ee6811b1f29c": "curve", + "0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f": "univ4", + "0x2170c741c41531aec20e7c107c24eecfdd15e69c9bb0a8dd37b1840b9e0b207b": "balancer-v2", + "0x0874b2d545cb271cdbda4e093020c452328b24af12382ed62c4d00f5c26709db": "balancer-v3", + "0x3b841dc9ab51e3104bda4f61b41e4271192d22cd19da5ee6e292dc8e2744f713": "maverick-v1", + "0x103ed084e94a44c8f5f6ba8e3011507c41063177e29949083c439777d8d63f60": "maverick-v2", + "0x19b47279256b2a23a1665c810c8d55a1758940ee09377d4f8d26497a3577dc83": "pancake-v3", + "0xc2c0245e056d5fb095f04cd6373bc770802ebd1e6c918eb78fdef843cdb37b0f": "dodo", + "0xd8648b6ac54162763c86fd54bf2005af8ecd2f9cb273a5775921fd7f91e17b2d": "dodo", + "0xe93ad76094f247c0dafc1c61adc2187de1ac2738f7a3b49cb20b2263420251a3": "dodo", + "0x5c02c2bb2d1d082317eb23916ca27b3e7c294398b60061a2ad54f1c3c018c318": "bancor-v3", + "0x95f3b01351225fea0e69a46f68b164c9dea10284f12cd4a907ce66510ab7af6a": "bancor-carbon", + "0x276856b36cbc45526a0ba64f44611557a2a8b68662c5388e9fe6d72e86e1c8cb": "bancor-v2", + "0xdc004dbca4ef9c966218431ee5d9133d337ad018dd5b5c5493722803f75c64f7": "fluid", + "0xb3e2773606abfd36b5bd91394b3a54d1398336c65005baf7bf7a05efeffaf75b": "solidly-style" + }, + "daily": { + "2026-08-18": { + "blocks": 162, + "swapTxs": 3093, + "avgPoolsPerSwapTx": 1.716, + "swapTxGasSharePct": 20.25 + }, + "2026-08-19": { + "blocks": 718, + "swapTxs": 21436, + "avgPoolsPerSwapTx": 1.865, + "swapTxGasSharePct": 32.76 + }, + "2026-08-20": { + "blocks": 717, + "swapTxs": 23867, + "avgPoolsPerSwapTx": 1.801, + "swapTxGasSharePct": 34.02 + }, + "2026-08-21": { + "blocks": 718, + "swapTxs": 27266, + "avgPoolsPerSwapTx": 1.801, + "swapTxGasSharePct": 39.43 + }, + "2026-08-22": { + "blocks": 717, + "swapTxs": 25728, + "avgPoolsPerSwapTx": 1.809, + "swapTxGasSharePct": 38.11 + }, + "2026-08-23": { + "blocks": 718, + "swapTxs": 28203, + "avgPoolsPerSwapTx": 1.831, + "swapTxGasSharePct": 41.03 + }, + "2026-08-24": { + "blocks": 718, + "swapTxs": 23088, + "avgPoolsPerSwapTx": 1.785, + "swapTxGasSharePct": 34.81 + }, + "2026-08-25": { + "blocks": 717, + "swapTxs": 25345, + "avgPoolsPerSwapTx": 1.719, + "swapTxGasSharePct": 34.51 + }, + "2026-08-26": { + "blocks": 717, + "swapTxs": 25078, + "avgPoolsPerSwapTx": 1.64, + "swapTxGasSharePct": 33.7 + }, + "2026-08-27": { + "blocks": 718, + "swapTxs": 23151, + "avgPoolsPerSwapTx": 1.68, + "swapTxGasSharePct": 32.74 + }, + "2026-08-28": { + "blocks": 718, + "swapTxs": 23946, + "avgPoolsPerSwapTx": 1.692, + "swapTxGasSharePct": 32.87 + }, + "2026-08-29": { + "blocks": 717, + "swapTxs": 17916, + "avgPoolsPerSwapTx": 1.671, + "swapTxGasSharePct": 24.82 + }, + "2026-08-30": { + "blocks": 718, + "swapTxs": 23422, + "avgPoolsPerSwapTx": 1.69, + "swapTxGasSharePct": 33.03 + }, + "2026-08-31": { + "blocks": 717, + "swapTxs": 21310, + "avgPoolsPerSwapTx": 1.666, + "swapTxGasSharePct": 38.55 + }, + "2026-09-01": { + "blocks": 717, + "swapTxs": 23334, + "avgPoolsPerSwapTx": 1.672, + "swapTxGasSharePct": 32.45 + }, + "2026-09-02": { + "blocks": 718, + "swapTxs": 21248, + "avgPoolsPerSwapTx": 1.713, + "swapTxGasSharePct": 31.01 + }, + "2026-09-03": { + "blocks": 717, + "swapTxs": 20996, + "avgPoolsPerSwapTx": 1.779, + "swapTxGasSharePct": 31.4 + }, + "2026-09-04": { + "blocks": 717, + "swapTxs": 21678, + "avgPoolsPerSwapTx": 1.746, + "swapTxGasSharePct": 32.78 + }, + "2026-09-05": { + "blocks": 718, + "swapTxs": 20022, + "avgPoolsPerSwapTx": 1.755, + "swapTxGasSharePct": 29.55 + }, + "2026-09-06": { + "blocks": 718, + "swapTxs": 21348, + "avgPoolsPerSwapTx": 1.779, + "swapTxGasSharePct": 32.22 + }, + "2026-09-07": { + "blocks": 716, + "swapTxs": 21942, + "avgPoolsPerSwapTx": 1.795, + "swapTxGasSharePct": 34.59 + }, + "2026-09-08": { + "blocks": 717, + "swapTxs": 21679, + "avgPoolsPerSwapTx": 1.731, + "swapTxGasSharePct": 31.85 + }, + "2026-09-09": { + "blocks": 718, + "swapTxs": 20183, + "avgPoolsPerSwapTx": 1.724, + "swapTxGasSharePct": 31.21 + }, + "2026-09-10": { + "blocks": 717, + "swapTxs": 19255, + "avgPoolsPerSwapTx": 1.691, + "swapTxGasSharePct": 29.16 + }, + "2026-09-11": { + "blocks": 717, + "swapTxs": 20262, + "avgPoolsPerSwapTx": 1.835, + "swapTxGasSharePct": 31.73 + }, + "2026-09-12": { + "blocks": 718, + "swapTxs": 19723, + "avgPoolsPerSwapTx": 1.831, + "swapTxGasSharePct": 32.61 + }, + "2026-09-13": { + "blocks": 718, + "swapTxs": 18625, + "avgPoolsPerSwapTx": 1.774, + "swapTxGasSharePct": 29.16 + }, + "2026-09-14": { + "blocks": 717, + "swapTxs": 19998, + "avgPoolsPerSwapTx": 1.701, + "swapTxGasSharePct": 28.48 + }, + "2026-09-15": { + "blocks": 717, + "swapTxs": 21488, + "avgPoolsPerSwapTx": 1.787, + "swapTxGasSharePct": 32.66 + }, + "2026-09-16": { + "blocks": 718, + "swapTxs": 21706, + "avgPoolsPerSwapTx": 1.703, + "swapTxGasSharePct": 30.78 + }, + "2026-09-17": { + "blocks": 632, + "swapTxs": 18390, + "avgPoolsPerSwapTx": 1.75, + "swapTxGasSharePct": 30.33 + } + }, + "ekuboCore": { + "address": "0x00000000000014aa86c5d3c41765bb24e11bd701", + "codeBytes": 18797, + "swapLog": "log0, no topics, 116 bytes of data (locker, poolId, balanceUpdate, stateAfter)" + }, + "routers": { + "0x66a9893cc07d91d95644aedd05d03f95e1dba8af": { + "name": "Universal Router (v2, 2025)", + "kind": "ur", + "verifiedSelectors": [ + "3593564c" + ], + "verifiedBytecodeContains": [], + "codeBytes": 19499 + }, + "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad": { + "name": "Universal Router (v1.2, 2023)", + "kind": "ur", + "verifiedSelectors": [ + "3593564c" + ], + "verifiedBytecodeContains": [], + "codeBytes": 17958 + }, + "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45": { + "name": "SwapRouter02", + "kind": "sr02", + "verifiedSelectors": [ + "b858183f", + "5ae401dc" + ], + "verifiedBytecodeContains": [], + "codeBytes": 24497 + }, + "0xe592427a0aece92de3edee1f18e0157c05861564": { + "name": "SwapRouter (v3 classic)", + "kind": "sr", + "verifiedSelectors": [ + "c04b8d59" + ], + "verifiedBytecodeContains": [], + "codeBytes": 12070 + }, + "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": { + "name": "Uniswap v2 Router02", + "kind": "v2r", + "verifiedSelectors": [ + "38ed1739" + ], + "verifiedBytecodeContains": [], + "codeBytes": 21943 + }, + "0x111111125421ca6dc452d289314280a0f8842a65": { + "name": "1inch AggregationRouter v6", + "kind": "oneinch", + "verifiedSelectors": [ + "07ed2379", + "83800a8e" + ], + "verifiedBytecodeContains": [], + "codeBytes": 24294 + }, + "0x4c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca": { + "name": "Universal Router (2026 deployment)", + "kind": "ur", + "verifiedSelectors": [ + "3593564c" + ], + "verifiedBytecodeContains": [], + "codeBytes": 24546 + }, + "0x7b2aa7ecc0b5936b7c52e6259a19c3ba557d0748": { + "name": "Ekubo Yul router", + "kind": "ekubo-yul", + "verifiedSelectors": [], + "verifiedBytecodeContains": [ + "00000000000014aa86c5d3c41765bb24e11bd701" + ], + "codeBytes": 2878 + } + }, + "receipts": { + "method": "eth_getBlockReceipts; successful txs with >=1 pool-level swap event; pools = number of such events in the tx", + "swapTxs": 664726, + "sumPools": 1161778, + "failedSwapTxsSeen": 0, + "avgPoolsPerSwapTx": 1.748, + "histogram": { + "1": { + "txs": 459356, + "pct": 69.1 + }, + "2": { + "txs": 106197, + "pct": 15.98 + }, + "3": { + "txs": 49247, + "pct": 7.41 + }, + "4+": { + "txs": 49926, + "pct": 7.51 + } + }, + "ekuboCoreOtherTopiclessLogs": 0, + "allTxs": 5724722, + "successfulTxs": 5643974, + "perFamily": { + "univ3": { + "txs": 327953, + "events": 435259, + "avgEventsOfThisFamilyPerTx": 1.327, + "avgTotalPoolsPerTxTouchingFamily": 2.101, + "gasOfTxsTouchingFamily": "119065061543", + "emitters": 5232, + "topEmitters": { + "0xe0554a476a092703abdb3ef35c80e0d76d32939f": 61019, + "0xc7bbec68d12a0d1830360f8ec58fa599ba1b0e9b": 37879, + "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640": 21193, + "0x11b815efb8f581194ae79006d24e0d814b7697f6": 10039, + "0xfaa318479b7755b2dbfdd34dc306cb28b420ad12": 8245 + } + }, + "univ4": { + "txs": 237383, + "events": 357639, + "avgEventsOfThisFamilyPerTx": 1.507, + "avgTotalPoolsPerTxTouchingFamily": 2.493, + "gasOfTxsTouchingFamily": "100861794702", + "emitters": 1, + "topEmitters": { + "0x000000000004444c5dc75cb358380d2e3de08a90": 237383 + } + }, + "univ2-family": { + "txs": 178611, + "events": 221663, + "avgEventsOfThisFamilyPerTx": 1.241, + "avgTotalPoolsPerTxTouchingFamily": 2.212, + "gasOfTxsTouchingFamily": "61822705206", + "emitters": 12930, + "topEmitters": { + "0x76a411f14a704099ba476ce8dffc288a53295218": 4028, + "0xc56c9d4aee66e16aa4a31a710a6d2fc720a626b6": 3114, + "0x69c7bd26512f52bf6f76fab834140d13dda673ca": 2939, + "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc": 2903, + "0x52c77b0cb827afbad022e6d6caf2c44452edbc39": 2751 + } + }, + "curve": { + "txs": 37104, + "events": 51606, + "avgEventsOfThisFamilyPerTx": 1.391, + "avgTotalPoolsPerTxTouchingFamily": 4.086, + "gasOfTxsTouchingFamily": "29977793714", + "emitters": 583, + "topEmitters": { + "0x35c9a4dae1ff05788f24b5b32721d89340cbb636": 6071, + "0x4ebdf703948ddcea3b11f675b4d1fba9d2414a14": 5317, + "0x7f86bf177dd4f3494b841a37e810a34dd56c829b": 3494, + "0xd51a44d3fae010294c616388b506acda1bfaae46": 3293, + "0x4f493b7de8aac7d55f71853688b1f7c8f0243c85": 1376 + } + }, + "pancake-v3": { + "txs": 30611, + "events": 34890, + "avgEventsOfThisFamilyPerTx": 1.14, + "avgTotalPoolsPerTxTouchingFamily": 4.299, + "gasOfTxsTouchingFamily": "20991198643", + "emitters": 200, + "topEmitters": { + "0x1445f32d1a74872ba41f3d8cf4022e9996120b31": 13953, + "0xacdb27b266142223e1e676841c1e809255fc6d07": 8512, + "0x6ca298d2983ab03aa1da7679389d955a4efee15c": 3579, + "0x1ac1a8feaaea1900c4166deeed0c11cc10669d36": 2408, + "0xbabe4490da9bcf0bbf062c40112aea2109d6ba7f": 1025 + } + }, + "ekubo": { + "txs": 21419, + "events": 24161, + "avgEventsOfThisFamilyPerTx": 1.128, + "avgTotalPoolsPerTxTouchingFamily": 3.346, + "gasOfTxsTouchingFamily": "14424619848", + "emitters": 1, + "topEmitters": { + "0x00000000000014aa86c5d3c41765bb24e11bd701": 21419 + } + }, + "fluid": { + "txs": 8789, + "events": 9481, + "avgEventsOfThisFamilyPerTx": 1.079, + "avgTotalPoolsPerTxTouchingFamily": 3.94, + "gasOfTxsTouchingFamily": "7517421114", + "emitters": 35, + "topEmitters": { + "0x667701e51b4d1ca244f17c78f7ab8744b4c99f9b": 3812, + "0x836951eb21f3df98273517b7249dceff270d34bf": 2588, + "0xea734b615888c669667038d11950f44b177f15c0": 748, + "0x3c0441b42195f4ad6aa9a0978e06096ea616cda7": 652, + "0x0b1a513ee24972daef112bc777a5610d4325c9e7": 211 + } + }, + "dodo": { + "txs": 7846, + "events": 8405, + "avgEventsOfThisFamilyPerTx": 1.071, + "avgTotalPoolsPerTxTouchingFamily": 4.457, + "gasOfTxsTouchingFamily": "7053994957", + "emitters": 506, + "topEmitters": { + "0x04571c32a4e1c5f39bc3a238cb95b215058c432c": 3836, + "0x3058ef90929cb8180174d74c507176cca6835d73": 924, + "0xc9f93163c99695c6526b799ebca2207fdf7d61ad": 409, + "0x201939f5c74f5fd3beef1cf03a9cfcc75beba9d9": 155, + "0x46cf01f14ff6f8c7ef4d6a6b139b1c3d45af9289": 90 + } + }, + "balancer-v2": { + "txs": 5584, + "events": 5934, + "avgEventsOfThisFamilyPerTx": 1.063, + "avgTotalPoolsPerTxTouchingFamily": 4.23, + "gasOfTxsTouchingFamily": "3988451309", + "emitters": 2, + "topEmitters": { + "0xba12222222228d8ba445958a75a0704d566bf2c8": 4492, + "0xd315a9c38ec871068fec378e4ce78af528c76293": 1094 + } + }, + "balancer-v3": { + "txs": 3987, + "events": 4306, + "avgEventsOfThisFamilyPerTx": 1.08, + "avgTotalPoolsPerTxTouchingFamily": 4.385, + "gasOfTxsTouchingFamily": "3438367528", + "emitters": 3, + "topEmitters": { + "0xba1333333333a1ba1108e8412f11850a5c319ba9": 3863, + "0x955244edc797a1c1b04134b600f819ac23c76081": 121, + "0x21e64c2a1c953a4202433ad584fe8eef283b56dd": 6 + } + }, + "maverick-v2": { + "txs": 2591, + "events": 2707, + "avgEventsOfThisFamilyPerTx": 1.045, + "avgTotalPoolsPerTxTouchingFamily": 4.116, + "gasOfTxsTouchingFamily": "2176138301", + "emitters": 18, + "topEmitters": { + "0x025a13752ec1fb6c0e441e5d7b1678bb5f9ca62f": 1093, + "0x6061cf96aa9f02aa072b77211a863989ca070c78": 507, + "0xdf9c440111a17eafbecf5db37dd04c948e878878": 475, + "0x31373595f40ea48a7aab6cbcb0d377c6066e2dca": 212, + "0x5b6a0771c752e35b2ca2aff4f22a66b1598a2bc5": 156 + } + }, + "bancor-v2": { + "txs": 2359, + "events": 4359, + "avgEventsOfThisFamilyPerTx": 1.848, + "avgTotalPoolsPerTxTouchingFamily": 5.242, + "gasOfTxsTouchingFamily": "1609101112", + "emitters": 135, + "topEmitters": { + "0xe331821bc94187c2649e932810a60204699d45cb": 1278, + "0xd4d5298136b52ec439ebfb23fda2ec500de69c71": 327, + "0x93334ab8a64b24df7b2fd2132e0e087239a21b63": 314, + "0xcba47689202d31575bfa204efe70e2c83d29da4d": 294, + "0x079ca3f710599739a22673c2856202f90d3a8806": 250 + } + }, + "maverick-v1": { + "txs": 563, + "events": 589, + "avgEventsOfThisFamilyPerTx": 1.046, + "avgTotalPoolsPerTxTouchingFamily": 4.417, + "gasOfTxsTouchingFamily": "539029948", + "emitters": 13, + "topEmitters": { + "0x11a653ddfbb61e0feff5484919f06d9d254bf65f": 245, + "0x352b186090068eb35d532428676ce510e17ab581": 167, + "0xd0b2f5018b5d22759724af6d4281ac0b13266360": 49, + "0x6c6fc818b25df89a8ada8da5a43669023bad1f4c": 30, + "0xab1e2d29699146a2e07c3a1c8ad8cb914a536f7a": 29 + } + }, + "bancor-v3": { + "txs": 531, + "events": 556, + "avgEventsOfThisFamilyPerTx": 1.047, + "avgTotalPoolsPerTxTouchingFamily": 5.633, + "gasOfTxsTouchingFamily": "400607619", + "emitters": 1, + "topEmitters": { + "0xeef417e1d5cc832e619ae18d2f140de2999dd4fb": 531 + } + }, + "bancor-carbon": { + "txs": 218, + "events": 220, + "avgEventsOfThisFamilyPerTx": 1.009, + "avgTotalPoolsPerTxTouchingFamily": 2.844, + "gasOfTxsTouchingFamily": "136615766", + "emitters": 1, + "topEmitters": { + "0xc537e898cd774e2dcba3b14ea6f34c93d5ea45e1": 218 + } + }, + "solidly-style": { + "txs": 3, + "events": 3, + "avgEventsOfThisFamilyPerTx": 1, + "avgTotalPoolsPerTxTouchingFamily": 1.667, + "gasOfTxsTouchingFamily": "1207060", + "emitters": 2, + "topEmitters": { + "0xfe0c14b4e614f40bf58845b4b04ced17fd116532": 2, + "0x7565756feaec1cbf51dfe71e04720b13f1c5104d": 1 + } + } + }, + "familiesWithNoEvents": [], + "gasShare": { + "swapTxGas": "212552446940", + "allTxGas": "650468728416", + "pct": 32.68 + } + }, + "calldata": { + "method": "eth_getBlockByNumber(full); successful txs to the verified routers; pools decoded from calldata", + "decodedTxs": 135167, + "sumPools": 164766, + "avgPoolsPerSwapTx": 1.219, + "histogram": { + "1": { + "txs": 116216, + "pct": 85.98 + }, + "2": { + "txs": 14123, + "pct": 10.45 + }, + "3": { + "txs": 2440, + "pct": 1.81 + }, + "4+": { + "txs": 2388, + "pct": 1.77 + } + }, + "opaqueTxs": 8303, + "undecodedTxs": 4224, + "failedTxs": 8023, + "crossCheckAgainstReceipts": { + "compared": 135167, + "equal": 134178, + "calldataLower": 989, + "calldataHigher": 0 + }, + "undecodedSelectors": { + "1inch AggregationRouter v6:0xb68fb020": 2725, + "1inch AggregationRouter v6:0xc3cf8043": 336, + "Uniswap v2 Router02:0xf305d719": 306, + "Uniswap v2 Router02:0x02751cec": 207, + "Uniswap v2 Router02:0xbaa2abde": 105, + "Uniswap v2 Router02:0xaf2979eb": 102, + "Universal Router (v2, 2025):0x3593564c": 98, + "Universal Router (v2, 2025):0x24856bc3": 75, + "Uniswap v2 Router02:0xe8e33700": 73, + "1inch AggregationRouter v6:0x89e7c650": 50, + "SwapRouter02:0xac9650d8": 36, + "1inch AggregationRouter v6:0x0d2c7c16": 34, + "1inch AggregationRouter v6:0x5816d723": 26, + "1inch AggregationRouter v6:0xea76dddf": 11, + "1inch AggregationRouter v6:0xcc713a04": 10, + "1inch AggregationRouter v6:0xe2c95c82": 6, + "Universal Router (v1.2, 2023):0x3593564c": 5, + "1inch AggregationRouter v6:0x9fda64bd": 4, + "1inch AggregationRouter v6:0x56a75868": 4, + "Universal Router (2026 deployment):0x3593564c": 4, + "Uniswap v2 Router02:0xded9382a": 2, + "Uniswap v2 Router02:0x5b0d5984": 2, + "SwapRouter02:0xdf2ab5bb": 1, + "1inch AggregationRouter v6:0xf497df75": 1, + "Uniswap v2 Router02:0x2195995c": 1 + }, + "perRouter": { + "SwapRouter02": { + "decodedTxs": 57070, + "avgPools": 1.071, + "histogram": { + "1": { + "txs": 53099, + "pct": 93.04 + }, + "2": { + "txs": 3906, + "pct": 6.84 + }, + "3": { + "txs": 47, + "pct": 0.08 + }, + "4+": { + "txs": 18, + "pct": 0.03 + } + }, + "opaque": 0, + "undecoded": 37, + "failed": 922, + "crossCheckAgainstReceipts": { + "compared": 57070, + "equal": 57070, + "calldataLower": 0, + "calldataHigher": 0 + }, + "mismatchSamples": [] + }, + "Universal Router (2026 deployment)": { + "decodedTxs": 20867, + "avgPools": 1.884, + "histogram": { + "1": { + "txs": 11869, + "pct": 56.88 + }, + "2": { + "txs": 4827, + "pct": 23.13 + }, + "3": { + "txs": 2031, + "pct": 9.73 + }, + "4+": { + "txs": 2140, + "pct": 10.26 + } + }, + "opaque": 0, + "undecoded": 4, + "failed": 750, + "crossCheckAgainstReceipts": { + "compared": 20867, + "equal": 20611, + "calldataLower": 256, + "calldataHigher": 0 + }, + "mismatchSamples": [ + { + "hash": "0x36348e4fb73d8a5b8832bca0da4a328955f45f6295a47b7af518363ceadd5cc0", + "calldata": 1, + "events": 2 + }, + { + "hash": "0x2d27bc4fb07a45227c171977c3bb92fd3f9c4ae492ff2f8ce8af324d79196580", + "calldata": 1, + "events": 2 + }, + { + "hash": "0x8ea6b0103d540f3ab103a4c3b8c52d2461a3fbe7629a1236b57c772d5a4fbd18", + "calldata": 1, + "events": 2 + }, + { + "hash": "0xea16c255284227d90b6a2dbc61191d5440ad4f4d7e97bbee330e876948b9269a", + "calldata": 2, + "events": 3 + }, + { + "hash": "0xe3c0310d80dcc0655dc6c4d90536c05c9830b97f2a343df897701a3baa3d217f", + "calldata": 1, + "events": 2 + } + ] + }, + "Universal Router (v2, 2025)": { + "decodedTxs": 31349, + "avgPools": 1.201, + "histogram": { + "1": { + "txs": 26103, + "pct": 83.27 + }, + "2": { + "txs": 4670, + "pct": 14.9 + }, + "3": { + "txs": 354, + "pct": 1.13 + }, + "4+": { + "txs": 222, + "pct": 0.71 + } + }, + "opaque": 0, + "undecoded": 173, + "failed": 4141, + "crossCheckAgainstReceipts": { + "compared": 31349, + "equal": 30795, + "calldataLower": 554, + "calldataHigher": 0 + }, + "mismatchSamples": [ + { + "hash": "0xf8e95975c298b42f0f6bb6e2158b718c05408f6f827032e317d37895e492559a", + "calldata": 1, + "events": 2 + }, + { + "hash": "0xa969b9330082242ea578b710df784036453541a7a0084493d22352b02a9e40ee", + "calldata": 1, + "events": 2 + }, + { + "hash": "0x513d5dc0bd4e41278200be5b18fbc2a855f5698c350449bf77d66bb0a25e9408", + "calldata": 1, + "events": 2 + }, + { + "hash": "0x5892102ad1c51701aa6d664e5ff0f155a4a2b36a7977819c32e4d2bdd7a46f19", + "calldata": 1, + "events": 3 + }, + { + "hash": "0x22af68dfad3d3ec0a792a40e47d722200862e1d03b239ff8756c5dfb539048a0", + "calldata": 1, + "events": 3 + } + ] + }, + "Uniswap v2 Router02": { + "decodedTxs": 19072, + "avgPools": 1.028, + "histogram": { + "1": { + "txs": 18534, + "pct": 97.18 + }, + "2": { + "txs": 536, + "pct": 2.81 + }, + "3": { + "txs": 2, + "pct": 0.01 + }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 0, + "undecoded": 798, + "failed": 1021, + "crossCheckAgainstReceipts": { + "compared": 19072, + "equal": 18895, + "calldataLower": 177, + "calldataHigher": 0 + }, + "mismatchSamples": [ + { + "hash": "0xa36abe4510265aa77dbe3d49149c5e695d942f4f60f5a18bc258b5a6589b399b", + "calldata": 1, + "events": 2 + }, + { + "hash": "0xd622bf435c0803810878dcc98d4e08fa72570ade3180b4f4050812e755512b90", + "calldata": 1, + "events": 2 + }, + { + "hash": "0x1f2c205eec27c70573b2bc5cdf67b1c3718dd81226a1a2376cc12d3268497166", + "calldata": 1, + "events": 2 + }, + { + "hash": "0xdbfa8169f999378e81956b728c21452994f1391ba92249f6f3188256a95dbacd", + "calldata": 1, + "events": 2 + }, + { + "hash": "0x01271e9b0653a84f8bacdd0c62a3564e9c23075763fe0e51dd375ef3cc383a86", + "calldata": 1, + "events": 2 + } + ] + }, + "1inch AggregationRouter v6": { + "decodedTxs": 453, + "avgPools": 1.245, + "histogram": { + "1": { + "txs": 345, + "pct": 76.16 + }, + "2": { + "txs": 105, + "pct": 23.18 + }, + "3": { + "txs": 3, + "pct": 0.66 + }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 8283, + "undecoded": 3207, + "failed": 941, + "crossCheckAgainstReceipts": { + "compared": 453, + "equal": 453, + "calldataLower": 0, + "calldataHigher": 0 + }, + "mismatchSamples": [] + }, + "SwapRouter (v3 classic)": { + "decodedTxs": 4813, + "avgPools": 1.017, + "histogram": { + "1": { + "txs": 4732, + "pct": 98.32 + }, + "2": { + "txs": 78, + "pct": 1.62 + }, + "3": { + "txs": 3, + "pct": 0.06 + }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 0, + "undecoded": 0, + "failed": 234, + "crossCheckAgainstReceipts": { + "compared": 4813, + "equal": 4813, + "calldataLower": 0, + "calldataHigher": 0 + }, + "mismatchSamples": [] + }, + "Universal Router (v1.2, 2023)": { + "decodedTxs": 1543, + "avgPools": 1.022, + "histogram": { + "1": { + "txs": 1534, + "pct": 99.42 + }, + "2": { + "txs": 1, + "pct": 0.06 + }, + "3": { + "txs": 0, + "pct": 0 + }, + "4+": { + "txs": 8, + "pct": 0.52 + } + }, + "opaque": 0, + "undecoded": 5, + "failed": 14, + "crossCheckAgainstReceipts": { + "compared": 1543, + "equal": 1541, + "calldataLower": 2, + "calldataHigher": 0 + }, + "mismatchSamples": [ + { + "hash": "0xcca4964ec899dfdd66316107236306c224cb784a572c3ddf484ed92ef67fb5c8", + "calldata": 1, + "events": 2 + }, + { + "hash": "0xdd819743acf4517182d5f8270f5f7c4d3999ac7e823cc20f7fb2193091c02fd6", + "calldata": 1, + "events": 2 + } + ] + }, + "Ekubo Yul router": { + "decodedTxs": 0, + "avgPools": null, + "histogram": { + "1": { + "txs": 0, + "pct": 0 + }, + "2": { + "txs": 0, + "pct": 0 + }, + "3": { + "txs": 0, + "pct": 0 + }, + "4+": { + "txs": 0, + "pct": 0 + } + }, + "opaque": 20, + "undecoded": 0, + "failed": 0, + "crossCheckAgainstReceipts": { + "compared": 0, + "equal": 0, + "calldataLower": 0, + "calldataHigher": 0 + }, + "mismatchSamples": [] + } + } + } +} diff --git a/benchmarks/prod/encode-prod-routes.mjs b/benchmarks/prod/encode-prod-routes.mjs index 85c616d..d9b611c 100644 --- a/benchmarks/prod/encode-prod-routes.mjs +++ b/benchmarks/prod/encode-prod-routes.mjs @@ -10,15 +10,35 @@ const B = "0x2222222222222222222222222222222222222222"; const C = "0x3333333333333333333333333333333333333333"; const D = "0x4444444444444444444444444444444444444444"; const NATIVE = "0x0000000000000000000000000000000000000000"; -const config = "0x000000000000000000000000000000000000000000c49ba5e353f7ce80001770"; // 0.3%, spacing 6000, no extension +const config = + "0x000000000000000000000000000000000000000000c49ba5e353f7ce80001770"; // 0.3%, spacing 6000, no extension const recipient = "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496"; -const hop = (token0, token1) => ({ type: "core", poolKey: { token0, token1, config } }); +const hop = (token0, token1) => ({ + type: "core", + poolKey: { token0, token1, config }, +}); const routes = { - "ekubo-yul-1pool": { specifiedToken: A, calculatedToken: B, hops: [hop(A, B)] }, - "ekubo-yul-2pools": { specifiedToken: A, calculatedToken: C, hops: [hop(A, B), hop(B, C)] }, - "ekubo-yul-3pools": { specifiedToken: A, calculatedToken: D, hops: [hop(A, B), hop(B, C), hop(C, D)] }, - "ekubo-yul-native": { specifiedToken: NATIVE, calculatedToken: B, hops: [hop(NATIVE, B)] }, + "ekubo-yul-1pool": { + specifiedToken: A, + calculatedToken: B, + hops: [hop(A, B)], + }, + "ekubo-yul-2pools": { + specifiedToken: A, + calculatedToken: C, + hops: [hop(A, B), hop(B, C)], + }, + "ekubo-yul-3pools": { + specifiedToken: A, + calculatedToken: D, + hops: [hop(A, B), hop(B, C), hop(C, D)], + }, + "ekubo-yul-native": { + specifiedToken: NATIVE, + calculatedToken: B, + hops: [hop(NATIVE, B)], + }, }; for (const [file, r] of Object.entries(routes)) { const data = encodeRoute({ diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index 99dfeff..77e3d5b 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -7,36 +7,115 @@ Ekubo is designed to make trading cheap: one singleton contract holds every pool and flash accounting settles only the net difference at the end of a transaction. This page measures what that means in practice — what a swap costs, how many swaps fit in a block, and what it would mean chain-wide — against Uniswap v4 and -Uniswap v3 on identical workloads. Every figure below runs production code -against production pools and routers on a mainnet fork wherever such a path -exists. The harnesses, raw snapshots, flamegraphs, and full methodology live in -`benchmarks/` in the docs repository. +Uniswap v3. Every figure runs the deployed production contracts of all three +protocols on a fork of the latest mainnet block, with fresh plain ERC20 tokens so +token quirks do not blur the comparison. Harnesses, raw snapshots, exact calldata +and the full method live in `benchmarks/` in the docs repository. ## Bottom line -1000 USDT to USDC on mainnet (block 25991868), each leg through that -protocol's production swap path on live pools, warmed, moving the price by at -most one tick: +One swap of 1 token through a single 0.3% pool of a fresh ERC20 pair, warmed, +moving the price by at most one tick, at block 25999935 (September 17, 2026). +Transaction gas is execution plus the 21,000 base plus the measured calldata: + +| Transaction, 1 pool | Ekubo | Uniswap v4 | Uniswap v3 | +| ------------------------------------------------------------------------- | ----------- | ---------- | ---------- | +| Production router (Yul router; Universal Router with Permit2; SwapRouter) | **110,875** | 144,390 | 123,587 | +| Minimal single-lock router on every side (favors Uniswap) | **112,997** | 131,284 | 117,006 | +| Native ETH in (Yul router; minimal locker; SwapRouter wrapping in flight) | **96,496** | 115,892 | 126,792 | + +Through the production routers, Ekubo's transaction costs 23.2% less than v4's +and 10.3% less than v3's. With every router stripped to the same minimal +lock-swap-settle shape the gap is 13.9% to v4 and 3.4% to v3: at one pool v3's +pool is nearly as cheap, and the difference is in the router and beyond the first +pool. With native ETH in, Ekubo is 16.7% below v4 and 23.9% below v3, which must +wrap. All legs receive the same output within 0.005%, so the gap is overhead, not +price. At the expensive end, 1000 USDT to USDC on the live mainnet pools (proxy +tokens, blocklists) costs 133,750 on Ekubo against 155,960 on v4 and 151,515 on +v3, 14.2% and 11.7% less: costly tokens compress the gap but do not close it. -| Transaction | Ekubo, Yul router | Uniswap v3, SwapRouter | Uniswap v4, minimal locker | -| ---------------------------------- | ----------------- | ---------------------- | -------------------------- | -| Execution + 21,000 base + calldata | **133,750** | 151,515 | 155,960 | +## Throughput per block + +Mainnet's block gas limit is 60 million. Real swap flow is routes, not single +pools: every extra pool costs Ekubo about 24k of transaction gas against v4's 36k +and v3's 59k, so the gap widens with route length. Dividing the limit by measured +route costs gives each protocol's ceiling on routes per block if a block held +nothing else (minimal routers on every side, which favors Uniswap): + +| Routes per 60M block, if a block held nothing else | Ekubo | Uniswap v4 | Uniswap v3 | +| -------------------------------------------------- | ----------------- | ------------- | ------------- | +| 1 pool, production routers | 110,875 → **541** | 144,390 → 415 | 123,587 → 485 | +| 1 pool, minimal routers | 112,997 → **530** | 131,284 → 457 | 117,006 → 512 | +| 2 pools, minimal routers | 137,091 → **437** | 167,027 → 359 | 176,438 → 340 | +| 3 pools, minimal routers | 161,184 → **372** | 202,770 → 295 | 235,870 → 254 | + +At three pools an Ekubo block holds 26% more routes than a v4 block and 46% more +than a v3 block. + +## What this means for the chain + +**Pools per swap, measured over a month.** Every 10th mainnet block from +August 18, 2026, 18:36 UTC to September 17, 2026, 21:08 UTC (21,600 blocks, 25783810 to 25999800) was +read through public JSON-RPC, and every successful transaction that emitted a +pool-level swap event was counted by the number of such events. The event set +covers Uniswap v2 and its forks, v3, v4, PancakeSwap v3, Curve (all generations), +Balancer v2 and v3, Maverick v1 and v2, Fluid, DODO, Bancor v2.1, v3 and Carbon, +Solidly-style pairs, and Ekubo: -Ekubo's transaction costs 11.7% less than v3's and 14.2% less than v4's. Output -matched the production quoter within 0.02% on the Ekubo leg, and all three legs -receive essentially the same output, so the gap is pure overhead, not price. +| Pools touched in the transaction | Share of swap transactions | +| -------------------------------- | -------------------------- | +| 1 | 69.1% | +| 2 | 16.0% | +| 3 | 7.4% | +| 4 or more | 7.5% | + +That is 664,726 swap transactions at an average of **1.75 pools per swap** +(daily averages 1.64 to 1.87; a 1,000-block window on September 17 gave +1.68). Those transactions used 32.7% of all gas in the sampled blocks +(daily 20% to 41%). Transactions sent straight to the Uniswap and +1inch routers average 1.22 pools; the rest is aggregators, MEV and other +contracts. Failed transactions emit no logs and are excluded by construction. + +A swap's transaction gas at _p_ pools is the one-pool minimal-router transaction +plus (_p_ − 1) times the measured marginal per extra pool: + +| Average pools per swap | Ekubo | Uniswap v4 | Uniswap v3 | Ekubo saving vs v4 / v3 | +| ---------------------- | ----------- | ---------- | ---------- | ----------------------- | +| **1.75 (measured)** | **131,019** | 158,020 | 161,461 | **17.1% / 18.9%** | +| 1.0 | 112,997 | 131,284 | 117,006 | 13.9% / 3.4% | +| 1.5 | 125,044 | 149,156 | 146,722 | 16.2% / 14.8% | +| 2.0 | 137,091 | 167,027 | 176,438 | 17.9% / 22.3% | + +Block-space shares: over the 30 days ending September 17, 2026, mainnet burned +1,255.7 ETH of base fees (ultrasound.money), and transactions to known DEX +routers were about 4.2% of it, a lower bound that misses aggregators, MEV and +direct pool calls; ultrasound's coarser "defi" category was 19.4%. The +32.7% measured above counts every transaction that touched a pool, +including MEV and aggregator flow whose gas is mostly not swapping. The 70% share +was stipulated for this page as an "all AMM trading" upside case; it is an +assumption, not a measurement. If a swap's share of the burn equals its share of +gas and the saving applies to all of it, moving that slice to Ekubo frees the +share times the saving (each cell spans the saving against v3 and against v4): + +| Block-space share given to swaps | Measured 1.75 pools (v3 18.9%, v4 17.1%) | 1 pool, production routers (v3 10.3%, v4 23.2%) | 2.0 pools (v3 22.3%, v4 17.9%) | +| ------------------------------------------------- | ---------------------------------------- | ----------------------------------------------- | ------------------------------ | +| 4.2%, measured DEX-router burn, lower bound | 0.7–0.8% | 0.4–1.0% | 0.8–0.9% | +| 19.4%, "defi" category bracket | 3.3–3.7% | 2.0–4.5% | 3.5–4.3% | +| 32.7%, swap-event transactions, measured | 5.6–6.2% | 3.4–7.6% | 5.9–7.3% | +| 70%, stipulated AMM-trading upside (not measured) | 12.0–13.2% | 7.2–16.2% | 12.5–15.6% | + +Induced demand would fill freed space. Read these as an order of magnitude, not a +forecast. ## On Base and Arbitrum -L2 fees have two parts: cheap L2 execution, and a separate charge for the L1 -data the sequencer posts. Both were measured on Base (block 51439900) and -Arbitrum One (block 506178800) for a 100 USDC to WETH swap through each -protocol's deployed contracts on the deepest live Uniswap WETH/USDC pool of each -chain. No live Ekubo pool on either chain could absorb the swap, so the Ekubo -leg runs on a pool created on the fork with the same price, fee tier and -liquidity as the v3 pool, through the production Yul router. The L1 data -component is what each chain's own fee oracle charges for the exact -transaction, converted to gas at the block's L2 base fee: +L2 fees are cheap L2 execution plus a charge for the L1 data the sequencer posts. +Both were measured on Base (block 51439900) and Arbitrum One (block 506178800) +for 100 USDC to WETH through each protocol's deployed contracts, warmed, on the +deepest live Uniswap WETH/USDC pool of each chain; no live Ekubo pool there could +absorb the swap, so the Ekubo leg runs on a pool created on the fork with the v3 +pool's price, fee and liquidity. The L1 data component is what each chain's own +fee oracle charges for the exact transaction, in gas at the block's L2 base fee: | Chain | Leg (100 USDC to WETH) | Execution gas | Calldata bytes | L1 data (gas-equivalent) | Total, incl. 21,000 base and calldata gas | | -------- | -------------------------- | ------------- | -------------- | ------------------------ | ----------------------------------------- | @@ -47,160 +126,53 @@ transaction, converted to gas at the block's L2 base fee: | Arbitrum | Uniswap v3, SwapRouter | 130,896 | 260 | 603 | 154,415 | | Arbitrum | Uniswap v4, minimal locker | 135,006 | 292 | 644 | 159,234 | -The mainnet pattern carries over almost unchanged. Ekubo's execution gas is -13.7% below v3 and 17.2% below v4 on Base, 13.6% and 16.2% on Arbitrum, -against 14.0% and 16.4% on the mainnet fork; the whole transaction is 11.4% -and 14.9% cheaper on Base and 11.4% and 14.0% on Arbitrum. Execution gaps do -not compress on L2s, because both chains run the same EVM at the same opcode -prices. - -The data component does not decide anything at these blocks: it is 0.2% to -0.4% of every leg, and on Base it is not even smallest for Ekubo. Base prices -data by estimated compressed size, and the dense 184-byte route compresses -worse than Uniswap's zero-padded 228 bytes (331 against 319 gas-equivalent); -Arbitrum's compressor favors the shorter route, by 42 gas. Compact calldata -buys nothing measurable on either chain while L1 data is this cheap; the saving -is in execution. Pools, block hashes, endpoints, oracle readings and formulas -are in `benchmarks/`. - -## Throughput per block - -Mainnet's block gas limit is 60 million. Real swap flow is routes, not single -pools: every extra pool costs Ekubo about 24k in gas against v4's 34k and v3's -59k, so the per-transaction gap widens with route length. Dividing the limit by -measured route costs gives each protocol's ceiling on routes per block if a -block held nothing else (lab routes plus the 21,000 base; the fork row uses full -transaction gas): - -| Routes per 60M block, if a block held nothing else | Ekubo | Uniswap v4 | Uniswap v3 (net) | -| -------------------------------------------------- | ----------------- | ------------- | ---------------- | -| 1 pool, fork single hop, transaction gas | 133,750 → **448** | 155,960 → 384 | 151,515 → 396 | -| 1 pool, lab route + 21,000 | 115,904 → **517** | 128,360 → 467 | 115,078 → 521 | -| 2 pools, lab route + 21,000 | 140,333 → **427** | 162,771 → 368 | 173,774 → 345 | -| 3 pools, lab route + 21,000 | 164,774 → **364** | 197,182 → 304 | 232,470 → 258 | - -At one pool the three protocols are within 12% of each other and v3's lab route -is marginally the cheapest; at three pools an Ekubo block holds 20% more routes -than a v4 block and 41% more than a v3 block. - -## What this means for the chain - -How much this matters chain-wide depends on the average number of pools a swap -touches and on how much block space swaps take. - -**Pools per swap, measured.** Over 1,000 consecutive mainnet blocks (25997046 -to 25998045, September 17, 2026, 11:55 to 15:16 UTC), every successful -transaction that emitted at least one pool-level swap event was counted by the -number of such events it contains. The event set covers Uniswap v2 and its -forks, v3, v4, PancakeSwap v3, Curve, Balancer v2 and v3, Maverick v1 and v2, -Fluid, DODO, Bancor (v2.1, v3, Carbon) and Ekubo itself: - -| Pools touched in the transaction | Share of swap transactions | -| -------------------------------- | -------------------------- | -| 1 | 72.1% | -| 2 | 14.8% | -| 3 | 6.3% | -| 4 or more | 6.9% | - -That is 32,417 swap transactions at an average of **1.68 pools per swap**, -confirming the 1.66 measured earlier over a 200-block window with only the -Uniswap and Curve families. The subset sent directly to the Uniswap and 1inch -routers (4,432 transactions, hop counts decoded from calldata and matched -against their events in all but 25 cases) averages 1.28 pools, so most -multi-pool weight comes from aggregators, MEV and other contracts rather than -from retail router calls. Failed transactions emit no logs and are excluded by -construction. Method, tables and script are in `benchmarks/`. - -A swap's transaction gas at an average of _p_ pools is the one-pool lab route -plus (_p_ − 1) times the marginal per extra pool, plus 21,000. The measured -average is the headline blend; the router-only average and the round values are -kept as context: - -| Average pools per swap | Ekubo | Uniswap v4 | Uniswap v3 (net) | Ekubo saving vs v4 / v3 | -| ---------------------- | ----------- | ---------- | ---------------- | ----------------------- | -| **1.68 (measured)** | **132,475** | 151,703 | 154,894 | **12.7% / 14.5%** | -| 1.28 (routers only) | 122,822 | 138,104 | 131,699 | 11.1% / 6.7% | -| 1.0 | 115,904 | 128,360 | 115,078 | 9.7% / −0.7% | -| 1.5 | 128,119 | 145,566 | 144,426 | 12.0% / 11.3% | -| 2.0 | 140,333 | 162,771 | 173,774 | 13.8% / 19.2% | - -For the block-space share: over the 30 days ending September 17, 2026, mainnet -burned 1,255.7 ETH of base fees (ultrasound.money); transactions to known DEX -router contracts accounted for about 4.2% of that burn, a lower bound because -router-address attribution misses swaps routed through aggregators, MEV bundles, -and direct pool calls. Ultrasound's coarser "defi" category, which includes -lending and everything else, was 19.4% over the same window and brackets it from -above. In the 1,000-block window above, the transactions that emitted a pool -swap event used 32.7% of all gas, which confirms the router figure is a floor. A -third, higher share of 70% is included below because it was stipulated for this -page as an "all AMM trading" upside case; it is an assumption handed to the -analysis, not a measurement. - -Scenario math, with the assumptions stated: if a swap's share of the burn equals -its share of gas and the per-transaction saving applies to all of it, moving -that slice to Ekubo would free the share times the saving. Each cell is the -range spanned by the saving against v3 and the saving against v4 (the two -savings are in the column header, v3 first): - -| Block-space share given to swaps | Fork single hop (v3 11.7%, v4 14.2%) | Measured 1.68 pools (v3 14.5%, v4 12.7%) | 2.0 pools (v3 19.2%, v4 13.8%) | -| ------------------------------------------------- | ------------------------------------ | ---------------------------------------- | ------------------------------ | -| 4.2%, measured DEX-router burn, lower bound | 0.5–0.6% | 0.5–0.6% | 0.6–0.8% | -| 19.4%, "defi" category bracket | 2.3–2.8% | 2.5–2.8% | 2.7–3.7% | -| 70%, stipulated AMM-trading upside (not measured) | 8.2–10.0% | 8.9–10.1% | 9.7–13.5% | - -At the measured blend and the measured lower bound, Ekubo frees about half a -percent of block gas chain-wide; at the stipulated 70% share it would be -roughly nine to ten percent. Induced demand would fill freed space. Read these -as an order of magnitude, not a forecast. +The mainnet pattern carries over: the whole transaction is 14.9% and 11.4% +cheaper than v4 and v3 on Base, 14.0% and 11.4% on Arbitrum, since both chains +run the same EVM at the same opcode prices. The data component decides nothing +at these blocks: 0.2% to 0.4% of every leg, and on Base not even smallest for +Ekubo, whose dense route compresses worse than Uniswap's zero-padded calldata. +Compact calldata buys nothing measurable while L1 data is this cheap. ## Why it is cheaper -Three mechanisms, each measured, details in `benchmarks/`: - - **Cheaper pool core.** One packed storage word and Q64 fixed-point math cost - about 20k gas per swap, against 29k for v4's multi-slot state and 37k for - v3's slot0, fee-growth, and tick bookkeeping. -- **Net settlement.** Ekubo and v4 settle once per transaction (2 token - transfers for a route of any length); v3 pays 2 transfers per pool. So each - extra pool costs about 24k on Ekubo, 34k on v4, 59k on v3. -- **Compact calldata where it counts.** The production Yul router packs a route - into 184 bytes; Uniswap's Universal Router needs 548 bytes for a single V3 - swap before any Permit2 funding command. (Ekubo's Solidity router is even - shorter at 132 bytes; its cost is execution-side instead.) + about 20k gas per swap, against 29k for v4's multi-slot state and 37k for v3's + slot0, fee-growth and tick bookkeeping (lab flamegraphs in `benchmarks/`). +- **Net settlement.** Ekubo and v4 settle once per transaction (2 token transfers + for a route of any length); v3 pays 2 per pool. On the production contracts + each extra pool costs about 22k of execution on Ekubo, 34k on v4, 59k on v3. +- **Compact calldata.** The Yul router packs a single-pool route into 184 bytes; + Uniswap's Universal Router needs 1,092 for a single v4 swap with its Permit2 + funding, v3's SwapRouter 260. ## Measured honestly -Providing liquidity is measured at the tier providers actually touch: -production position managers on the same fork pools. Minting a new ~10,000 -USDC/USDT position costs 244,513 on Ekubo against 297,390 on v4 (17.8% less) -and 381,653 on v3 (35.9% less). Bare direct-to-core calls are a different, -unsendable tier and live in `benchmarks/`; one-time costs (pool creation, tick -initialization, first-swap writes) are excluded by design everywhere on this -page. Native ETH swaps cost 72,580 on Ekubo against 88,864 on v4; v3 pools hold -WETH, so its users pay a 27,938 wrapping deposit first. - -Scope: EVM only, concentrated pools, no hooks or extensions on any side — any -v4 hook or Ekubo extension changes every number here. +Providing liquidity is measured where providers actually touch it: production +position managers on the live USDC/USDT pools. Minting a new ~10,000 USDC/USDT +position costs 244,513 on Ekubo against 297,390 on v4 (17.8% less) and 381,653 on +v3 (35.9% less). One-time costs (pool creation, tick initialization, first-swap +accumulator writes) are excluded by design everywhere on this page, and the v4 +Universal Router was measured only at one pool. Scope: EVM only, concentrated +pools, no hooks or extensions on any side — any v4 hook or Ekubo extension changes +every number here. ## How it was measured -- Production contracts, routers, and live pools on a mainnet fork pinned at - block 25991868; lab scaling on minimal single-lock routers with one shared - ERC20 token contract, economically matched liquidity, no tick crossings. -- Warmed steady state throughout: one identical unmeasured call precedes every - measurement. Both swap directions covered. -- Minimal routers on all sides, which favors Uniswap: production routers cost - more than the lab numbers. -- L2s: Base and Arbitrum One forks pinned by block, deployed Uniswap routers - and pools, the deployed Ekubo Yul router on a fresh pool mirroring the v3 - pool; L1 data read from each chain's fee oracle for the exact calldata, not - modeled. -- Pools per swap: 1,000 mainnet blocks read through a public JSON-RPC endpoint - (block receipts for the event count, full blocks for the router calldata - cross-check). Uniswap v2 and all its forks share one event signature and are - counted as one family; Ekubo swaps are counted from Core's topic-less swap - log. Not counted: order-book and RFQ fills (CoW, UniswapX, 1inch and Kyber - limit orders, 0x RFQ) unless they land in a pool, the legacy Ekubo v2 core, - and long-tail AMMs outside the listed families. -- Full harness sources, raw snapshots, reference calldata, flamegraphs, pool - identifiers, and reproduction commands: `benchmarks/` in the docs repository. +- Production Ekubo Core, Positions and Yul router; Uniswap v3 factory and + SwapRouter; v4 PoolManager and Universal Router, on a fork of the latest + mainnet block at run time, four fresh ERC20 tokens, 0.3% pools of matched + liquidity, no tick crossings. +- Warmed, isolated steady state: one identical unmeasured call first, every + touched slot already nonzero, each measured call its own transaction, so the + snapshot is execution gas net of refunds; exact calldata recorded per call. +- Minimal single-lock routers of the same shape on all sides for route scaling, + which favors Uniswap: its production routers cost more than the minimal + figures, Ekubo's costs less. +- L2s: Base and Arbitrum One forks pinned by block, deployed Uniswap routers and + pools, the deployed Ekubo Yul router on a fresh pool mirroring the v3 pool; L1 + data read from each chain's fee oracle, not modeled. +- Pools per swap: a uniform 10% sample of a month of mainnet block receipts over + public JSON-RPC, every event signature hashed from its protocol's source. Not + counted: order-book and RFQ fills unless they land in a pool, the legacy Ekubo + v2 core, long-tail AMMs. Sources, snapshots, calldata, pool identifiers and + reproduction commands: `benchmarks/` in the docs repository. From 8ce429ae6040bd1f44bc5cfe7aa2cbacf1dcdd89 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Thu, 17 Sep 2026 17:53:19 -0400 Subject: [PATCH 14/17] Exclude benchmark harness dependency dirs from astro check; format encoder script Co-Authored-By: Claude Fable 5.1 --- benchmarks/fork/encode-yul-route.mjs | 3 ++- tsconfig.json | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/benchmarks/fork/encode-yul-route.mjs b/benchmarks/fork/encode-yul-route.mjs index dbc7234..0cd6488 100644 --- a/benchmarks/fork/encode-yul-route.mjs +++ b/benchmarks/fork/encode-yul-route.mjs @@ -11,7 +11,8 @@ const data = encodeRoute({ poolKey: { token0: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", token1: "0xdAC17F958D2ee523a2206206994597C13D831ec7", - config: "0x0000000000000000000000000000000000000000000053e2d6238da480000032", + config: + "0x0000000000000000000000000000000000000000000053e2d6238da480000032", }, }, ], diff --git a/tsconfig.json b/tsconfig.json index 8bf91d3..60cddd6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,10 @@ { "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist"] + "exclude": [ + "dist", + "benchmarks/**/lib", + "benchmarks/**/out", + "benchmarks/**/cache" + ] } From 63e6025ab921c2f146ebe6c1b0115177c41c7eaa Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Fri, 18 Sep 2026 11:19:44 -0400 Subject: [PATCH 15/17] Note that pools-per-swap and swap gas share are lower bounds The catalogue of swap events cannot be complete, so uncatalogued AMMs add uncounted transactions, pools and gas; the chain-wide capacity gains inherit that bias and are underestimates. Co-Authored-By: Claude Fable 5.1 --- benchmarks/README.md | 8 ++++++++ src/content/docs/concepts/gas-efficiency.md | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/benchmarks/README.md b/benchmarks/README.md index ddaa78b..6872836 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -652,6 +652,14 @@ Reading the histogram for the page: 69% of swap transactions touch one pool, but 31% that touch more carry enough pools to lift the mean to 1.75, and the daily mean never left the 1.64–1.87 band in a month that spanned a 2x range of swap gas share. +Both the pools-per-swap average and the gas share are lower bounds. A transaction is +counted, and its pools are counted, only through swap events of the catalogued +families; any AMM outside the catalogue (long-tail forks with their own event +signatures, order-book fills that never touch a pool, the legacy Ekubo v2 core) +contributes uncounted transactions, uncounted pools inside counted transactions, and +uncounted gas. The chain-wide capacity figures on the page therefore understate the +effect of moving swap flow to Ekubo. + ## L2 fork methodology (Base, Arbitrum One) Same discipline as the mainnet fork (pinned block, one identical unmeasured warm-up swap, diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/concepts/gas-efficiency.md index 77e3d5b..37050d8 100644 --- a/src/content/docs/concepts/gas-efficiency.md +++ b/src/content/docs/concepts/gas-efficiency.md @@ -75,6 +75,10 @@ That is 664,726 swap transactions at an average of **1.75 pools per swap** (daily 20% to 41%). Transactions sent straight to the Uniswap and 1inch routers average 1.22 pools; the rest is aggregators, MEV and other contracts. Failed transactions emit no logs and are excluded by construction. +Both figures are lower bounds: the count only sees swap events in the catalogued +families, so any AMM outside the catalogue adds uncounted transactions and +uncounted pools, and the swap-transaction gas share can only be higher. The +chain-wide capacity gains below inherit that bias and are underestimates. A swap's transaction gas at _p_ pools is the one-pool minimal-router transaction plus (_p_ − 1) times the measured marginal per extra pool: From 46e4b2b4f34928e2075b1adefe1840abb7d6ca5c Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Fri, 18 Sep 2026 11:20:27 -0400 Subject: [PATCH 16/17] Move the gas-efficiency page to the Reference section Co-Authored-By: Claude Fable 5.1 --- astro.config.mjs | 2 +- benchmarks/README.md | 2 +- src/content/docs/about-ekubo/features.md | 2 +- src/content/docs/{concepts => reference}/gas-efficiency.md | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename src/content/docs/{concepts => reference}/gas-efficiency.md (100%) diff --git a/astro.config.mjs b/astro.config.mjs index 64a9139..5a1d786 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -195,7 +195,6 @@ export default defineConfig({ label: "Protocol architecture", slug: "concepts/architecture", }, - { label: "Gas efficiency", slug: "concepts/gas-efficiency" }, { label: "Extensions", slug: "concepts/extensions", @@ -249,6 +248,7 @@ export default defineConfig({ label: "Price representation", slug: "reference/price-representation", }, + { label: "Gas efficiency", slug: "reference/gas-efficiency" }, { label: "Contract addresses", slug: "reference/contracts", diff --git a/benchmarks/README.md b/benchmarks/README.md index 6872836..022e406 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,6 +1,6 @@ # Gas benchmark harnesses -Foundry tests behind the numbers on the [Gas efficiency](/concepts/gas-efficiency/) page. +Foundry tests behind the numbers on the [Gas efficiency](/reference/gas-efficiency/) page. Each protocol compiles under its own production toolchain settings, so the comparison measures code and architecture rather than compiler tuning. diff --git a/src/content/docs/about-ekubo/features.md b/src/content/docs/about-ekubo/features.md index 4944a42..aed4074 100644 --- a/src/content/docs/about-ekubo/features.md +++ b/src/content/docs/about-ekubo/features.md @@ -11,7 +11,7 @@ The core contracts are ownerless and permissionless. They are deployed to the sa Ekubo uses the ["till" pattern](/concepts/architecture/) and a singleton design to provide the cheapest trades across many pools. All pools live in a single contract, and token transfers are deferred until the end of the transaction. Advanced users don't have to transfer tokens at all: balances can be saved inside Ekubo for later use, avoiding repeated token transfers entirely. -The result is that you can execute many actions across many pools while making only the minimum number of token transfers. Combined with contracts optimized down to the storage-slot and calldata level (see [Price representation](/reference/price-representation/)), this keeps the gas cost of a trade low, which matters most for routes that touch several pools. Measured numbers against Uniswap v4 and v3 are on the [Gas efficiency](/concepts/gas-efficiency/) page. +The result is that you can execute many actions across many pools while making only the minimum number of token transfers. Combined with contracts optimized down to the storage-slot and calldata level (see [Price representation](/reference/price-representation/)), this keeps the gas cost of a trade low, which matters most for routes that touch several pools. Measured numbers against Uniswap v4 and v3 are on the [Gas efficiency](/reference/gas-efficiency/) page. ## Concentrated liquidity diff --git a/src/content/docs/concepts/gas-efficiency.md b/src/content/docs/reference/gas-efficiency.md similarity index 100% rename from src/content/docs/concepts/gas-efficiency.md rename to src/content/docs/reference/gas-efficiency.md From 864d4850a304c0dc9f4a2cbe97b85bb61004f0ca Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Fri, 18 Sep 2026 11:21:15 -0400 Subject: [PATCH 17/17] Write the sqrt-ratio formula as a code block; no math renderer is configured Co-Authored-By: Claude Fable 5.1 --- src/content/docs/reference/price-representation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/content/docs/reference/price-representation.md b/src/content/docs/reference/price-representation.md index 5336e2f..5d2ccb2 100644 --- a/src/content/docs/reference/price-representation.md +++ b/src/content/docs/reference/price-representation.md @@ -7,9 +7,9 @@ title: "Price representation" Every Ekubo pool tracks its current price as a **square root ratio**: the square root of the price of `token1` in terms of `token0`. -$$ -sqrt\_ratio = \sqrt{token1/token0} -$$ +``` +sqrt_ratio = sqrt(token1 / token0) +``` Working with the square root of the price (rather than the price itself) makes the concentrated liquidity math cheaper and more precise — see the [Math 1-pager](/reference/pool-math/) for why. Prices are supported over the enormous range `[2^-128, 2^128]`, so the sqrt ratio always lies in `[2^-64, 2^64]`.