From 2a385871fb7110a5188f1348d2a49a1b73ead2c0 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:55:56 -0400 Subject: [PATCH 001/169] feat(contracts): add Ethereum and Sepolia rows to the deploy chain table Appended after the existing rows so Monad and HyperEVM keep their index and bytes. 12 s slots: 600-block windows (25 on the demo grid), 50,400-block max pause. The table is part of the core's init code, so the core address changes; Sepolia deploys on its own salt until Monad and HyperEVM are redeployed. Co-Authored-By: Claude Opus 5.5 --- contracts/script/DeployConfig.sol | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/contracts/script/DeployConfig.sol b/contracts/script/DeployConfig.sol index 425fba7..109bd8c 100644 --- a/contracts/script/DeployConfig.sol +++ b/contracts/script/DeployConfig.sol @@ -16,6 +16,8 @@ library DeployConfig { uint256 internal constant HYPEREVM = 999; uint256 internal constant HYPEREVM_TESTNET = 998; uint256 internal constant ANVIL = 31_337; + uint256 internal constant ETHEREUM = 1; + uint256 internal constant SEPOLIA = 11_155_111; /// @notice Per-chain quote-token parameters: graduation baseline (per-expiry volume, blueprint §10) and the /// anti-dust minimum order notional (valued at K). Same index in all three arrays. @@ -38,8 +40,10 @@ library DeployConfig { /// @param preset grid and window preset (Demo shortens the windows too; Daily keeps the production windows) /// @param grad one entry per chain, in the order of the table below (Monad, Monad testnet, HyperEVM, - /// HyperEVM testnet, anvil). The whole table is part of the init code, so pass the same table on every chain. - function build(Preset preset, address[5] memory treasury, address guardian, Grad[5] memory grad) + /// HyperEVM testnet, anvil, Ethereum, Sepolia). The whole table is part of the init code, so pass the same table on + /// every chain. New chains are appended, so the earlier rows keep their index and their bytes; the core's address + /// still changes with every row added (docs/IMPLEMENTATION_NOTES.md). + function build(Preset preset, address[7] memory treasury, address guardian, Grad[7] memory grad) internal pure returns (Confluere.Config memory c) @@ -60,13 +64,16 @@ library DeployConfig { } c.maxOrdersPerSide = 128; // ~6.6M gas worst case: fits Monad blocks and HyperEVM big blocks c.maxBids = 64; - c.chains = new Confluere.ChainParams[](5); - // windowBlocks: ~2h (demo: ~5 min). Monad measured ~0.3 s/block, HyperEVM small blocks 1 s, anvil 1 s. + c.chains = new Confluere.ChainParams[](7); + // windowBlocks: ~2h (demo: ~5 min). Monad measured ~0.3 s/block, HyperEVM small blocks 1 s, anvil 1 s, + // Ethereum and Sepolia 12 s slots (a missed slot only lengthens the window). maxPauseBlocks: 7 days of blocks. c.chains[0] = _chain(MONAD, demo ? 1000 : 24_000, 2_016_000, treasury[0], guardian, grad[0]); c.chains[1] = _chain(MONAD_TESTNET, demo ? 1000 : 24_000, 2_016_000, treasury[1], guardian, grad[1]); c.chains[2] = _chain(HYPEREVM, demo ? 300 : 7200, 604_800, treasury[2], guardian, grad[2]); c.chains[3] = _chain(HYPEREVM_TESTNET, demo ? 300 : 7200, 604_800, treasury[3], guardian, grad[3]); c.chains[4] = _chain(ANVIL, demo ? 300 : 7200, 604_800, treasury[4], guardian, grad[4]); + c.chains[5] = _chain(ETHEREUM, demo ? 25 : 600, 50_400, treasury[5], guardian, grad[5]); + c.chains[6] = _chain(SEPOLIA, demo ? 25 : 600, 50_400, treasury[6], guardian, grad[6]); } function _chain(uint256 id, uint64 window, uint64 maxPause, address treasury, address guardian, Grad memory g) @@ -99,6 +106,8 @@ library DeployConfig { if (id == HYPEREVM) return "hyperevm"; if (id == HYPEREVM_TESTNET) return "hyperevm-testnet"; if (id == ANVIL) return "anvil"; + if (id == ETHEREUM) return "ethereum"; + if (id == SEPOLIA) return "sepolia"; revert("unsupported chain"); } } From 45e8c7dcef7ccfe4aa1ee62f998158f7de8d3ad0 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:55:56 -0400 Subject: [PATCH 002/169] feat(contracts): deploy script reads the seven-row table and GRAD rows for Ethereum and Sepolia Ethereum counts as a mainnet (guardian multisig, no demo or daily grid), Sepolia as a testnet; window checks require 600 blocks on both. Co-Authored-By: Claude Opus 5.5 --- contracts/script/Deploy.s.sol | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol index 7ffc2f8..f998899 100644 --- a/contracts/script/Deploy.s.sol +++ b/contracts/script/Deploy.s.sol @@ -41,12 +41,15 @@ contract Deploy is Script { address usdc; } - uint256[5] internal CHAIN_IDS = [ + /// @dev The order of DeployConfig.build's table; new chains are appended. + uint256[7] internal CHAIN_IDS = [ DeployConfig.MONAD, DeployConfig.MONAD_TESTNET, DeployConfig.HYPEREVM, DeployConfig.HYPEREVM_TESTNET, - DeployConfig.ANVIL + DeployConfig.ANVIL, + DeployConfig.ETHEREUM, + DeployConfig.SEPOLIA ]; struct Periphery { @@ -67,15 +70,15 @@ contract Deploy is Script { vm.startBroadcast(); (, address deployer,) = vm.readCallers(); Tokens memory t = mocks ? _mockTokens(salt) : Tokens(address(0), address(0), address(0)); - DeployConfig.Grad[5] memory grad = _grad(mocks, t); - // The core's treasury on each chain is that chain's SeatRegistry; precompute all five so the core's init + DeployConfig.Grad[7] memory grad = _grad(mocks, t); + // The core's treasury on each chain is that chain's SeatRegistry; precompute every row's so the core's init // code (and address) is the same everywhere. - address[5] memory registries; - for (uint256 i; i < 5; ++i) { + address[7] memory registries; + for (uint256 i; i < CHAIN_IDS.length; ++i) { registries[i] = vm.computeCreate2Address(salt, keccak256(_registryInit(deployer, grad[i]))); } SeatRegistry reg = _deployRegistry(salt, deployer, grad); - for (uint256 i; i < 5; ++i) { + for (uint256 i; i < CHAIN_IDS.length; ++i) { if (CHAIN_IDS[i] == block.chainid) require(address(reg) == registries[i], "registry address mismatch"); } Guardian g = _deployGuardian(salt, deployer); @@ -124,8 +127,9 @@ contract Deploy is Script { /// or mintable mock tokens on a production chain, nor leave a mainnet with a single-key guardian. function _guards(DeployConfig.Preset preset, bool mocks) internal view { uint256 id = block.chainid; - bool mainnet = id == DeployConfig.MONAD || id == DeployConfig.HYPEREVM; - bool test = id == DeployConfig.MONAD_TESTNET || id == DeployConfig.HYPEREVM_TESTNET || id == DeployConfig.ANVIL; + bool mainnet = id == DeployConfig.MONAD || id == DeployConfig.HYPEREVM || id == DeployConfig.ETHEREUM; + bool test = id == DeployConfig.MONAD_TESTNET || id == DeployConfig.HYPEREVM_TESTNET + || id == DeployConfig.SEPOLIA || id == DeployConfig.ANVIL; require(preset != DeployConfig.Preset.Demo || test, "demo: testnets and anvil only"); require(preset != DeployConfig.Preset.Daily || test, "daily grid: testnets and anvil only"); require(!mocks || id == DeployConfig.ANVIL, "mocks: anvil only"); @@ -159,6 +163,9 @@ contract Deploy is Script { if (block.chainid == DeployConfig.HYPEREVM || block.chainid == DeployConfig.HYPEREVM_TESTNET) { require(w == 7200, "HyperEVM window must be 7,200 blocks"); } + if (block.chainid == DeployConfig.ETHEREUM || block.chainid == DeployConfig.SEPOLIA) { + require(w == 600, "Ethereum window must be 600 blocks"); + } } } require(found, "no chain parameters for this chain id"); @@ -189,12 +196,12 @@ contract Deploy is Script { ); } - function _deployRegistry(bytes32 salt, address deployer, DeployConfig.Grad[5] memory grad) + function _deployRegistry(bytes32 salt, address deployer, DeployConfig.Grad[7] memory grad) internal returns (SeatRegistry reg) { DeployConfig.Grad memory g; - for (uint256 i; i < 5; ++i) { + for (uint256 i; i < CHAIN_IDS.length; ++i) { if (CHAIN_IDS[i] == block.chainid) g = grad[i]; } reg = SeatRegistry(vm.computeCreate2Address(salt, keccak256(_registryInit(deployer, g)))); @@ -224,8 +231,8 @@ contract Deploy is Script { /// @dev Demo: the two quote tokens graduate after 1 native / 10 USD of volume per expiry, on every chain; the /// smallest order is worth 0.1 native / 1 USD at K. - function _grad(bool mocks, Tokens memory t) internal view returns (DeployConfig.Grad[5] memory g) { - for (uint256 i; i < 5; ++i) { + function _grad(bool mocks, Tokens memory t) internal view returns (DeployConfig.Grad[7] memory g) { + for (uint256 i; i < CHAIN_IDS.length; ++i) { if (mocks) { g[i].quotes = new address[](2); g[i].volumes = new uint256[](2); From d0e7463c09c0aecb36b107e63b8714bda715cf4f Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:55:56 -0400 Subject: [PATCH 003/169] chore(contracts): Sepolia profile and Ethereum / Sepolia RPC endpoints Co-Authored-By: Claude Opus 5.5 --- contracts/foundry.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 9a73a11..3ff0829 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -44,11 +44,16 @@ eth_rpc_url = "https://testnet-rpc.monad.xyz" [profile.hyperevm] eth_rpc_url = "https://rpc.hyperliquid-testnet.xyz/evm" +[profile.sepolia] +eth_rpc_url = "https://ethereum-sepolia-rpc.publicnode.com" + [rpc_endpoints] monad_testnet = "https://testnet-rpc.monad.xyz" monad = "https://rpc.monad.xyz" hyperevm_testnet = "https://rpc.hyperliquid-testnet.xyz/evm" hyperevm = "https://rpc.hyperliquid.xyz/evm" +sepolia = "https://ethereum-sepolia-rpc.publicnode.com" +ethereum = "https://ethereum-rpc.publicnode.com" [lint] # Reviewed: remaining narrowing casts are bounded by values that were range-checked when escrowed. From 69a6868f30034ef37b83d32ecfe4f75c9b15642f Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:55:56 -0400 Subject: [PATCH 004/169] test(contracts): script checks for the Ethereum and Sepolia rows Chain 1 needs GUARDIAN_MEMBERS and refuses the demo and daily grids; Sepolia refuses mocks, needs GRAD_11155111, and gets the 600-block window on the daily grid. Co-Authored-By: Claude Opus 5.5 --- contracts/script/checks/ScriptChecks.s.sol | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/contracts/script/checks/ScriptChecks.s.sol b/contracts/script/checks/ScriptChecks.s.sol index b444e65..ccdb84c 100644 --- a/contracts/script/checks/ScriptChecks.s.sol +++ b/contracts/script/checks/ScriptChecks.s.sol @@ -17,6 +17,8 @@ import {Vault} from "../../src/periphery/Vault.sol"; /// forge script script/checks/ScriptChecks.s.sol /// - Deploy refuses a demo grid or mocks on production chains, a mainnet without GUARDIAN_MEMBERS, and a public chain /// without its GRAD_ row; DEMO defaults to false. +/// - Sepolia (the chain table's last row): no mocks, its GRAD_11155111 row is required, and the core picks the +/// 600-block window (25 on the demo grid) with that row's graduation and order-minimum values. /// - Deploy, CreatePools, CreateVaults and Seed can be re-run after a partial failure without reverting or doubling /// the book; Seed.collect() claims closed pools and withdraws pending balances. /// - A Deploy re-run keeps the core's deploy block (also from DEPLOYMENTS_FALLBACK), and refuses to guess one @@ -48,6 +50,7 @@ contract ScriptChecks is Script { _demoDefaultsOff(); _guards(); _dailyGrid(); + _sepolia(); _idempotentLifecycle(); vm.removeFile(file); @@ -63,8 +66,12 @@ contract ScriptChecks is Script { function _guards() internal { vm.chainId(143); _expectRevert(new Deploy(), "mainnet: GUARDIAN_MEMBERS required"); + vm.chainId(1); + _expectRevert(new Deploy(), "mainnet: GUARDIAN_MEMBERS required"); vm.setEnv("DEMO", "true"); _expectRevert(new Deploy(), "demo: testnets and anvil only"); + vm.chainId(143); + _expectRevert(new Deploy(), "demo: testnets and anvil only"); vm.chainId(999); _expectRevert(new Deploy(), "demo: testnets and anvil only"); vm.chainId(10_143); @@ -84,6 +91,8 @@ contract ScriptChecks is Script { vm.setEnv("GRID", "daily"); vm.chainId(999); _expectRevert(new Deploy(), "daily grid: testnets and anvil only"); + vm.chainId(1); + _expectRevert(new Deploy(), "daily grid: testnets and anvil only"); vm.setEnv("DEMO", "true"); vm.chainId(10_143); _expectRevert(new Deploy(), "DEMO and GRID=daily are exclusive"); @@ -162,6 +171,43 @@ contract ScriptChecks is Script { vm.removeFile(file); } + /// @dev Runs after _dailyGrid (MOCKS=true, GRID and DEMO off). Own salts: the registry does not depend on the grid, + /// so each grid needs its own (as on the real testnets). + function _sepolia() internal { + vm.chainId(11_155_111); + _expectRevert(new Deploy(), "mocks: anvil only"); + vm.setEnv("MOCKS", "false"); + vm.setEnv("GRID", "daily"); + vm.setEnv("GRAD_11155111", ""); + _expectRevert(new Deploy(), "set GRAD_11155111"); + address weth = address(0xE7); + address usdc = address(0x05DC); + vm.setEnv( + "GRAD_11155111", + string.concat( + vm.toString(weth), + ":100000000000000000:1000000000000000:100000000000000000,", + vm.toString(usdc), + ":1000:100:100000" + ) + ); + vm.setEnv("SALT", vm.toString(bytes32(uint256(0xc0f3)))); + Confluere core = new Deploy().run(); + require(core.WINDOW_BLOCKS() == 600 && core.grid().period == 1 days, "sepolia: window / grid"); + require(core.graduationVolume(weth) == 1e17 && core.minNotional(usdc) == 100, "sepolia: GRAD row"); + require(vm.parseJsonUint(vm.readFile(file), ".chainId") == 11_155_111, "sepolia: chain id not recorded"); + vm.setEnv("GRID", ""); + vm.setEnv("DEMO", "true"); + vm.setEnv("SALT", vm.toString(bytes32(uint256(0xc0f4)))); + core = new Deploy().run(); + require(core.WINDOW_BLOCKS() == 25 && core.grid().period == 1 hours, "sepolia demo: window / grid"); + + vm.setEnv("DEMO", "false"); + vm.setEnv("GRAD_11155111", ""); + vm.setEnv("SALT", vm.toString(bytes32(uint256(0xc0f1)))); + vm.removeFile(file); + } + function _idempotentLifecycle() internal { vm.chainId(31_337); vm.setEnv("DEMO", "true"); From 4b649fbddc3754527d1137512c5e7ee84f666681 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:55:56 -0400 Subject: [PATCH 005/169] docs(contracts): list the Sepolia pairs in the CreatePools header Co-Authored-By: Claude Opus 5.5 --- contracts/script/CreatePools.s.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/script/CreatePools.s.sol b/contracts/script/CreatePools.s.sol index 1423b02..1dddc36 100644 --- a/contracts/script/CreatePools.s.sol +++ b/contracts/script/CreatePools.s.sol @@ -11,7 +11,8 @@ import {DeployConfig} from "./DeployConfig.sol"; /// @notice Opens the launch pools on the nearest expiry: per pair 2 strikes x 2 kinds. Monad testnet: shMON/WMON and /// WMON/USDC (8 pools). HyperEVM testnet: WHYPE/USDC and the extra pair USDC/WHYPE (8 pools; LST=0, no mintable LST -/// there, so the extra pair is how the native coin is lent on that chain). +/// there, so the extra pair is how the native coin is lent on that chain). Sepolia: wstETH/WETH and WETH/USDC, plus +/// the extra pair wstETH/USDC. /// Optional extra pair (env EXTRA_ASSET, EXTRA_QUOTE, EXTRA_STRIKE_LOW / EXTRA_STRIKE_HIGH, EXTRA_BAND default 800 bps; /// none when EXTRA_ASSET is unset or zero): 2 strikes x 2 kinds more, listed after the launch pairs' pools, and recorded /// as `.pools.extraAsset` / `.pools.extraQuote` for Seed.s.sol and CreateVaults.s.sol. From 62e868cd0722304f47b75c2a9cdaa45765c785ab Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:04 -0400 Subject: [PATCH 006/169] feat(contracts): PositionMarket and SeatLP scripts honour DEPLOYMENTS_FILE So scripts/deploy-testnet.sh can run them on its staging copy like the other steps. Co-Authored-By: Claude Opus 5.5 --- contracts/script/DeployPositionMarket.s.sol | 6 +++--- contracts/script/DeploySeatLP.s.sol | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/script/DeployPositionMarket.s.sol b/contracts/script/DeployPositionMarket.s.sol index dd15f75..1f2ba20 100644 --- a/contracts/script/DeployPositionMarket.s.sol +++ b/contracts/script/DeployPositionMarket.s.sol @@ -8,11 +8,11 @@ import {DeployConfig} from "./DeployConfig.sol"; /// @notice Deploys the C/R secondary market (PositionMarket) for the core in deployments/.json (CREATE2 with /// the same salt as Deploy, so the address matches across chains that share the core) and records it under -/// .positionMarket. Skips the broadcast when it is already deployed. +/// .positionMarket. Skips the broadcast when it is already deployed. Reads and writes DEPLOYMENTS_FILE when set (the +/// staging copy of scripts/deploy-testnet.sh, step market). SALT must be the core's (testnets: TESTNET_SALT). contract DeployPositionMarket is Script { function run() external { - string memory path = - string.concat(vm.projectRoot(), "/../deployments/", DeployConfig.chainName(block.chainid), ".json"); + string memory path = DeployConfig.deploymentsFile(vm); string memory json = vm.readFile(path); IConfluere core = IConfluere(vm.parseJsonAddress(json, ".confluere")); bytes32 salt = vm.envOr("SALT", bytes32(uint256(0xc0f1))); diff --git a/contracts/script/DeploySeatLP.s.sol b/contracts/script/DeploySeatLP.s.sol index a710a2f..7a3b2ef 100644 --- a/contracts/script/DeploySeatLP.s.sol +++ b/contracts/script/DeploySeatLP.s.sol @@ -9,11 +9,11 @@ import {Kind} from "../src/libraries/PoolKey.sol"; import {DeployConfig} from "./DeployConfig.sol"; /// @notice Deploys one LP receipt token (SeatLPToken) per market of the pools in deployments/.json (CREATE2, -/// skipped when already deployed) and records them under .seatLPTokens as "asset-quote-kind" => token. +/// skipped when already deployed) and records them under .seatLPTokens as "asset-quote-kind" => token. Reads and writes +/// DEPLOYMENTS_FILE when set (the staging copy of scripts/deploy-testnet.sh, step seatlp). contract DeploySeatLP is Script { function run() external { - string memory path = - string.concat(vm.projectRoot(), "/../deployments/", DeployConfig.chainName(block.chainid), ".json"); + string memory path = DeployConfig.deploymentsFile(vm); string memory json = vm.readFile(path); IConfluere core = IConfluere(vm.parseJsonAddress(json, ".confluere")); SeatRegistry reg = SeatRegistry(vm.parseJsonAddress(json, ".seatRegistry")); From cfd9742a9c4c085721fa21d424f558336ccbfa4c Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:16 -0400 Subject: [PATCH 007/169] feat(scripts): Sepolia token addresses and the seven-row testnet salt WETH, USDC and wstETH on Sepolia, verified on chain and on a fork. The seven-row chain table gets TESTNET_SALT_NEXT (0xc0f3) so Sepolia can deploy without touching the live 0xc0f2 deployments on Monad and HyperEVM. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/testnet.sh | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/lib/testnet.sh b/scripts/lib/testnet.sh index a60e049..3e33e94 100644 --- a/scripts/lib/testnet.sh +++ b/scripts/lib/testnet.sh @@ -1,11 +1,15 @@ # Shared by scripts/deploy-testnet.sh and scripts/roll-testnet.sh (sourced, not run). Expects ROOT and the chain -# name (monad | hyperevm) as $1 of testnet_env; works from $ROOT/contracts. +# name (monad | hyperevm | sepolia) as $1 of testnet_env; works from $ROOT/contracts. # # Real tokens (verified on-chain 2026-10-12): # Monad testnet: WMON 0xFb8b…C541 (deposit() wraps MON), USDC 0x534b…43A3 (Circle faucet), # shMON 0x282B…45C9 (FastLane, deposit(assets, receiver) payable; 1 shMON ≈ 12.9 MON) # HyperEVM testnet: WHYPE 0x5555…5555 (deposit()), USDC 0x2B33…D8Ab (Circle faucet). No mintable LST, so no loop pair; # instead the extra pair USDC/WHYPE (borrowers post USDC for HYPE) is how the native coin is lent. +# Verified 2026-10-23 (Sepolia reads and an anvil fork): +# Ethereum Sepolia: WETH 0xfFf9…6B14 (Uniswap's canonical Sepolia WETH, deposit()), USDC 0x1c7D…7238 (Circle faucet), +# wstETH 0xB823…067b (Lido; sending ETH to it stakes and wraps in one call, 1 wstETH ≈ 1.0377 ETH), +# stETH 0x3e3F…40Af (submit(address) payable). export PATH="$PATH:$HOME/.foundry/bin" M_WMON=0xFb8bf4c1CC7a94c73D209a149eA2AbEa852BC541 @@ -13,11 +17,23 @@ M_USDC=0x534b2f3A21130d7a60830c2Df862319e593943A3 M_SHMON=0x282BdDFF5e58793AcAb65438b257Dbd15A8745C9 H_WHYPE=0x5555555555555555555555555555555555555555 H_USDC=0x2B3370eE501B4a559b57D449569354196457D8Ab +S_WETH=0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14 +S_USDC=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 +S_WSTETH=0xB82381A3fBD3FaFA77B3a7bE693342618240067b ZERO=0x0000000000000000000000000000000000000000 # CREATE2 salt of the testnet deployment (Deploy.s.sol SALT). The SeatRegistry's address does not depend on the grid, # so a redeploy with the same GRAD rows but another grid needs a new salt (the registry of the old salt is bound to the # old core). 0xc0f1: the 2026-10-12 demo-grid core; 0xc0f2: the 2026-10-13 daily-grid core. +# Adding the Ethereum and Sepolia rows (2026-10-23) changed the core's init code, so the core address of 0xc0f2 changed +# too, while the SeatRegistry addresses of Monad testnet and HyperEVM testnet did not: a Deploy re-run there on 0xc0f2 +# would simulate a new core, then stop at "registry bound to another core" before broadcasting (the live 0x7E02…b1f7 +# deployments stay as they are; their later steps and scripts/roll-testnet.sh do not run Deploy). So the seven-row +# table gets its own salt, TESTNET_SALT_NEXT (0xc0f3): Sepolia deploys on it now (testnet_params sets TESTNET_SALT for +# it), and Monad and HyperEVM move to it only when the founder approves their redeploy +# (SALT=$TESTNET_SALT_NEXT scripts/deploy-testnet.sh monad): with the same GRAD rows, deployer and grid all three then +# share one core address again. docs/IMPLEMENTATION_NOTES.md records this as a pending deviation. TESTNET_SALT=0x000000000000000000000000000000000000000000000000000000000000c0f2 +TESTNET_SALT_NEXT=0x000000000000000000000000000000000000000000000000000000000000c0f3 LOG=$(mktemp "${TMPDIR:-/tmp}/confluere-step.XXXXXX") BIG=0 # 1 while HyperEVM big blocks may be on for this account # Seeded books (Seed.s.sol book layout, SEED_APR_BPS > 0): priced by time to expiry at SEED_APR_BPS (10% a year), a From 83ae6c51d8a5536634e12b6df753e53de102d87b Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:16 -0400 Subject: [PATCH 008/169] feat(scripts): Sepolia testnet parameters RPC (SEPOLIA_RPC override), strikes at about 90% of value (0.93 WETH per wstETH, 2100 USDC per WETH, 2200 USDC per wstETH), small seed units, 24-order Router batches, --skip-simulation for Amsterdam code pricing, and fork-read retries for flaky public RPCs. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/testnet.sh | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/lib/testnet.sh b/scripts/lib/testnet.sh index 3e33e94..923f90f 100644 --- a/scripts/lib/testnet.sh +++ b/scripts/lib/testnet.sh @@ -46,9 +46,11 @@ TRADE_ROUNDS=${TRADE_ROUNDS:-3} MATURITY_DAYS=${MATURITY_DAYS:-"1 7 14"} # Chain parameters without touching the network: RPC, strikes, the deployments file, HyperEVM's gas flags. Tokens -# default to the real ones above. scripts/test/grad-seed.test.sh reads them too. +# default to the real ones above. scripts/test/grad-seed.test.sh reads them too. LST_FUND=receive: sending native coin +# to the LST contract mints it (preflight's FUND=1 uses that); LST_HINT: how to get the LST by hand. testnet_params() { CHAIN=$1 + LST_FUND="" LST_HINT="" case "$CHAIN" in monad) R=https://testnet-rpc.monad.xyz @@ -67,6 +69,7 @@ testnet_params() { # (the wallet holds little shMON; neither pair has to graduate) export SEED_UNIT_BPS=${SEED_UNIT_BPS:-200} LST_SEED_UNIT_BPS=${LST_SEED_UNIT_BPS:-20} export EXTRA_SEED_UNIT_BPS=${EXTRA_SEED_UNIT_BPS:-50} + LST_HINT="FastLane deposit(assets, receiver), ~12.9 MON per shMON" EXTRA=() ;; hyperevm) @@ -88,6 +91,43 @@ testnet_params() { EXTRA=(--disable-block-gas-limit) # big blocks toggled per step export FOUNDRY_GAS_LIMIT=30000000 ;; + sepolia) + R=${SEPOLIA_RPC:-https://ethereum-sepolia-rpc.publicnode.com} # SEPOLIA_RPC=https://sepolia.drpc.org also works + NAME=sepolia + export LST=$S_WSTETH NATIVE=$S_WETH USDC=$S_USDC + # The seven-row chain table's salt (see TESTNET_SALT above): a new core address, apart from the live testnets. + TESTNET_SALT=$TESTNET_SALT_NEXT + # One buy-back price per pair at about 90% of the collateral's normal market value on the 2-digit grid + # (2026-10-23). wstETH is worth what it redeems for on this chain, 1.0377 ETH (stEthPerToken: unlike shMON's, + # the Sepolia rate is what staking pays here): 0.93 WETH (90%). ETH ≈ 2375 USD (Ethereum mainnet Chainlink + # ETH/USD; the Sepolia DEX books are testnet artefacts): 2100 USDC per WETH (88%). + export LST_STRIKE_HIGH=930000000000000000 LST_STRIKE_LOW=930000000000000000 # 0.93 WETH per wstETH + export USD_STRIKE_LOW=2100000000 USD_STRIKE_HIGH=2100000000 # 2100 USDC per WETH + # Extra pair wstETH/USDC (as shMON/USDC on Monad): 1.0377 x 2375 = 2465 USD, 2200 USDC per wstETH (89%). + export EXTRA_ASSET=$S_WSTETH EXTRA_QUOTE=$S_USDC EXTRA_BAND=800 + export EXTRA_STRIKE_LOW=2200000000 EXTRA_STRIKE_HIGH=2200000000 + # Seed units, small because faucet Sepolia ETH and USDC are scarce: WETH/USDC and wstETH/USDC 0.0001 (the + # smallest unit; ~0.21 USDC per order clears the 0.001 USDC graduation row many times over); wstETH/WETH + # 0.0012 wstETH (0.00112 WETH at K, just above the 0.001 WETH order minimum; like shMON/WMON it need not + # graduate). The launch run (3 expiries, 3 trade rounds) needs ~0.04 wstETH, ~0.04 WETH and ~13 USDC. + export SEED_UNIT_BPS=${SEED_UNIT_BPS:-1} LST_SEED_UNIT_BPS=${LST_SEED_UNIT_BPS:-12} + export EXTRA_SEED_UNIT_BPS=${EXTRA_SEED_UNIT_BPS:-1} + # Router.placeOrders batches of 24 orders: ~10M gas each at Amsterdam's storage prices (48 took 20M on a fork). + # Sepolia does take transactions above 2^24 gas (30M-limit transactions land, 2026-10-23); smaller batches just + # lose less to a dropped one + export SEED_CHUNK=${SEED_CHUNK:-24} + # native ETH kept for gas by preflight's FUND=1 (pools, seed, trade rounds, vaults and later rolls) + export GAS_RESERVE=${GAS_RESERVE:-50000000000000000} + LST_FUND=receive + LST_HINT="send Sepolia ETH to wstETH $S_WSTETH (its receive() stakes and wraps, ~1.04 ETH per wstETH), or FUND=1" + # Sepolia runs Amsterdam's state-creation pricing (~1,550 gas per byte of deployed code, 31M gas for a 20 KB + # contract; checked with eth_estimateGas 2026-10-23), which forge's local simulation (evm_version cancun) does + # not know: its gas limits ran out on a fork. Skip it, so forge asks the node to estimate each transaction + # (--slow sends them one by one, so each estimate sees the previous ones). The script itself still runs first. + # Public Sepolia RPCs drop some of the thousands of storage reads a tick or trade script makes (each drop was a + # FatalExternalError before anything was sent, 2026-10-24): let forge retry a failed fork read. + EXTRA=(--skip-simulation --fork-retries 12 --fork-retry-backoff 2000) + ;; *) return 1 ;; esac J="$ROOT/deployments/$NAME.json" From f271c2c10b97e8c6b500857bcd936b35c2c82d61 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:23 -0400 Subject: [PATCH 009/169] feat(scripts): GRAD_11155111 graduation and order-minimum row for Sepolia Co-Authored-By: Claude Opus 5.5 --- scripts/lib/testnet.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/lib/testnet.sh b/scripts/lib/testnet.sh index 923f90f..3b093c4 100644 --- a/scripts/lib/testnet.sh +++ b/scripts/lib/testnet.sh @@ -153,16 +153,23 @@ testnet_env() { CHAIN_ID=$(rcast chain-id --rpc-url "$R") } -# The graduation / order-minimum rows of both testnets (Deploy.s.sol GRAD_): +# The graduation / order-minimum rows of the three testnets (Deploy.s.sol GRAD_): # quote:graduationVolume:minNotional:seatObligation, small testnet values (USDC min order 0.0001, native 0.001). # Graduation needs this volume in each of four consecutive expiries: 0.001 USDC and 0.1 native, so the small seeds of -# scripts/roll-testnet.sh (SEED_UNIT_BPS=100) graduate a market and let the vaults invest; scripts/test/grad-seed.test.sh -# checks one seed clears them. The whole table is part of the core's init code (both chains carry every row, so they -# share one core address): changing any value here and redeploying gives a NEW core address on both chains; redeploy -# both and regenerate the SDK and CRE configs (scripts/deploy-testnet.sh does). +# scripts/roll-testnet.sh graduate the native/USDC market and let the vaults invest; scripts/test/grad-seed.test.sh +# checks one seed clears them. The whole table is part of the core's init code (every chain carries every row, so they +# share one core address): changing any value here and redeploying gives a NEW core address on every chain. Adding +# GRAD_11155111 (2026-10-23) did exactly that: the live Monad / HyperEVM core 0x7E02…b1f7 was deployed without it, so +# Sepolia gets another address until both are redeployed on TESTNET_SALT_NEXT (founder's call; see TESTNET_SALT). +# A redeploy regenerates the SDK and CRE configs (scripts/deploy-testnet.sh does). +# Sepolia's native row is not cheap like WMON / WHYPE: 0.1 WETH (~240 USD) is both the per-expiry graduation volume +# of the WETH-quoted wstETH/WETH market and the seat obligation of a seat on it, all from faucet ETH, so that market +# (and the pegged vault) will in practice not graduate. It is in the init code of the 0xc0f3 core already deployed on +# Sepolia (2026-10-23); pick a smaller value (e.g. 0.01 WETH) before the next joint redeploy (IMPLEMENTATION_NOTES §7). testnet_grad() { export GRAD_10143="$M_WMON:100000000000000000:1000000000000000:100000000000000000,$M_USDC:1000:100:100000" export GRAD_998="$H_WHYPE:100000000000000000:1000000000000000:100000000000000000,$H_USDC:1000:100:100000" + export GRAD_11155111="$S_WETH:100000000000000000:1000000000000000:100000000000000000,$S_USDC:1000:100:100000" } # File mtime in seconds: GNU stat (Linux) first, BSD stat (macOS) second. From e60ae70a94a02be79f741ca7b695a90081e74417 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:23 -0400 Subject: [PATCH 010/169] feat(scripts): verify and keep PositionMarket and SeatLP records across steps Co-Authored-By: Claude Opus 5.5 --- scripts/lib/testnet.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/lib/testnet.sh b/scripts/lib/testnet.sh index 3b093c4..b00b258 100644 --- a/scripts/lib/testnet.sh +++ b/scripts/lib/testnet.sh @@ -201,8 +201,8 @@ verify_stage() { for a in $(python3 - "$f" <<'PY' import json, sys d = json.load(open(sys.argv[1])) -keys = ["confluere", "router", "wrapperFactory", "vaultFactory", "keeperReceiver", "seatRegistry"] -out = [d[k] for k in keys if k in d] + [v for v in (d.get("vaults") or {}).values()] +keys = ["confluere", "router", "wrapperFactory", "vaultFactory", "keeperReceiver", "seatRegistry", "positionMarket"] +out = [d[k] for k in keys if k in d] + [v for m in ("vaults", "seatLPTokens") for v in (d.get(m) or {}).values()] print(" ".join(a for a in out if int(a, 16) != 0)) PY ); do @@ -221,7 +221,7 @@ PY } # keep_records STAGE: a Deploy re-run rewrites the whole deployments file (Deploy.s.sol _write) and so leaves out the -# .pools, .vaults and .positionMarket that later steps recorded. When the staged file names the same core as deployments/.json, +# .pools, .vaults, .positionMarket and .seatLPTokens that later steps recorded. When the staged file names the same core as deployments/.json, # those records still describe live contracts of that core: copy them back into the stage (they are verified with it). # A stage that names another core starts empty, as it should. keep_records() { @@ -230,7 +230,7 @@ keep_records() { import json, sys old, new = (json.load(open(f)) for f in sys.argv[1:3]) if str(old.get("confluere", "")).lower() == str(new.get("confluere", "")).lower() != "": - kept = [k for k in ("pools", "vaults", "positionMarket") if k in old and k not in new] + kept = [k for k in ("pools", "vaults", "positionMarket", "seatLPTokens") if k in old and k not in new] for k in kept: new[k] = old[k] if kept: From 9c1f67274d0499f49b9f4af0cab338a1e4747c7c Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:23 -0400 Subject: [PATCH 011/169] feat(scripts): FUND=1 stakes the missing wstETH on Sepolia Sending ETH to wstETH stakes and wraps in one call; the amount comes from getStETHByWstETH and respects the gas reserve together with the WETH wrap. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/testnet.sh | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/scripts/lib/testnet.sh b/scripts/lib/testnet.sh index b00b258..7aa3248 100644 --- a/scripts/lib/testnet.sh +++ b/scripts/lib/testnet.sh @@ -358,8 +358,10 @@ trap 'exit 130' INT TERM # LST_SEED_UNIT_BPS, the extra pair EXTRA_SEED_UNIT_BPS. # Each buy escrows exactly what the core's placeOrder pulls (OrderLogic, BUY): # quoteUp(units * limit) + ttl, i.e. the quote rounded up per order plus 1 wei per batch of its ttl (SEED_TTL below). -# FUND=1 wraps the missing wrapped-native amount from the native balance (deposit()); USDC and shMON must be -# obtained by hand (Circle faucet; FastLane deposit). GAS_RESERVE (wei, default 0.5 native) stays unwrapped for gas. +# FUND=1 wraps the missing wrapped-native amount from the native balance (deposit()) and, where the LST mints for +# native coin sent to it (LST_FUND=receive: wstETH on Sepolia), stakes the missing LST the same way; USDC and shMON must +# be obtained by hand (Circle faucet; FastLane deposit). GAS_RESERVE (wei, default 0.5 native; testnet_params may set a +# chain's own) stays unwrapped for gas. # Public testnet RPCs drop connections now and then (HyperEVM: "tls handshake eof"): retry a read a few times before # giving up, so a flaky read does not stop a deployment between steps. rcast() { @@ -435,25 +437,43 @@ print("gas", int(reserve), int(gas), max(0, int(reserve) - int(gas)), "-") PY ) echo "$plan" | awk '{printf " %-11s need %s have %s short %s\n", $1, $2, $3, $4}' - local short_nat gas_have reserve + local short_nat avail reserve eth short_nat=$(echo "$plan" | awk '$1=="native"{print $4}') - gas_have=$(echo "$plan" | awk '$1=="gas"{print $3}') + avail=$(echo "$plan" | awk '$1=="gas"{print $3}') # native coin left after what FUND=1 already spent reserve=$(echo "$plan" | awk '$1=="gas"{print $2}') + # spend AMOUNT: true when the native coin left after AMOUNT still covers GAS_RESERVE (wei, beyond 64-bit shell math) + spend() { python3 -c "import sys; a, x, r = map(int, sys.argv[1:]); sys.exit(0 if a - x >= r else 1)" "$avail" "$1" "$reserve"; } local fail=0 lbl need have short addr while read -r lbl need have short addr; do [ "$short" != 0 ] || continue case $lbl in native) - if [ "${FUND:-0}" = 1 ] && python3 -c "import sys; sys.exit(0 if $gas_have - $short_nat >= $reserve else 1)"; then + if [ "${FUND:-0}" = 1 ] && spend "$short_nat"; then echo " FUND=1: wrapping $short_nat wei into $NATIVE" cast send "$NATIVE" "deposit()" --value "$short_nat" --rpc-url "$R" "${SIGNER[@]}" >/dev/null /dev/null Date: Fri, 23 Oct 2026 23:56:23 -0400 Subject: [PATCH 012/169] feat(scripts): one CRE config per chain when syncing off-chain configs Monad testnet keeps config.staging.json, Sepolia writes config.sepolia.json, scanning logs through an archive RPC because publicnode prunes old logs. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/testnet.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/lib/testnet.sh b/scripts/lib/testnet.sh index 7aa3248..8217ec1 100644 --- a/scripts/lib/testnet.sh +++ b/scripts/lib/testnet.sh @@ -529,13 +529,19 @@ trade_rounds() { run "tick: settle due pools" script/Seed.s.sol 0 --sig "tick()" } -# Regenerate what reads deployments/*.json: SDK addresses and, on Monad, the CRE workflow config (receiver, vaults, -# live pools; the workflow targets monad-testnet only). +# Regenerate what reads deployments/*.json: SDK addresses and the CRE workflow config (receiver, vaults, live pools). +# One CRE config per chain (services/keeper-cre/scripts/sync-config.mjs CRE_CHAINS): Monad testnet writes +# config.staging.json, Sepolia config.sepolia.json (created on its first sync). Sepolia's scan from deployBlock does not +# use $R: publicnode prunes logs older than ~10,000 blocks, so sync-config takes its own archive RPC (1,000-block +# chunks) unless SEPOLIA_LOGS_RPC names another. HyperEVM testnet has no CRE workflow. sync_offchain() { (cd "$ROOT/packages/sdk" && node scripts/sync-deployments.mjs && pnpm -s build) if [ "$CHAIN" = monad ]; then (cd "$ROOT" && DEPLOYMENT=monad-testnet RPC_URL="$R" pnpm --filter @confluere/keeper-cre -s sync-config) + elif [ "$CHAIN" = sepolia ]; then + (cd "$ROOT" && env DEPLOYMENT=sepolia CRE_CONFIG=config.sepolia.json ${SEPOLIA_LOGS_RPC:+RPC_URL="$SEPOLIA_LOGS_RPC"} \ + pnpm --filter @confluere/keeper-cre -s sync-config) else - echo "CRE config not touched: the workflow targets monad-testnet only" + echo "CRE config not touched: no CRE workflow for $NAME" fi } From c15aa8ccfb58c001b54cde5dd89a00dbf58580d7 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:30 -0400 Subject: [PATCH 013/169] feat(scripts): deploy-testnet accepts sepolia and deploys PositionMarket and SeatLP tokens Two new steps after vaults (market, seatlp), so a fresh testnet gets the C/R secondary market and the seat LP receipts that Monad and HyperEVM got by hand. Co-Authored-By: Claude Opus 5.5 --- scripts/deploy-testnet.sh | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh index f7560b9..26f5c30 100755 --- a/scripts/deploy-testnet.sh +++ b/scripts/deploy-testnet.sh @@ -1,38 +1,43 @@ #!/usr/bin/env bash -# Testnet deployment on real tokens (no mocks). Usage: scripts/deploy-testnet.sh monad|hyperevm [step] -# Reads PRIVATE_KEY from contracts/.env. Steps run in order: deploy fund pools seed vaults; the optional second argument -# starts from that step. Every step can be repeated: Deploy reuses CREATE2 contracts that already have code, -# CreatePools keeps existing pools, Seed skips pools the deployer already seeded this batch, CreateVaults reuses its -# vaults, and deployments/.json is replaced only after a step's broadcast landed. RESUME=1 finishes the -# interrupted broadcast of the first step run, and only that step's own (e.g. RESUME=1 scripts/deploy-testnet.sh -# hyperevm pools); a failed step prints the exact command. A failed Deploy attempt's staged file is kept for the re-run, -# so the log-scan start block stays the core's; without it Deploy asks for DEPLOY_BLOCK (the core's creation block). +# Testnet deployment on real tokens (no mocks). Usage: scripts/deploy-testnet.sh monad|hyperevm|sepolia [step] +# Reads PRIVATE_KEY from contracts/.env. Steps run in order: deploy fund pools seed trade vaults market seatlp; the +# optional second argument starts from that step. Every step can be repeated: Deploy reuses CREATE2 contracts that +# already have code, CreatePools keeps existing pools, Seed skips pools the deployer already seeded this batch, +# CreateVaults reuses its vaults, and deployments/.json is replaced only after a step's broadcast landed. +# RESUME=1 finishes the interrupted broadcast of the first step run, and only that step's own (e.g. RESUME=1 +# scripts/deploy-testnet.sh hyperevm pools); a failed step prints the exact command. A failed Deploy attempt's staged +# file is kept for the re-run, so the log-scan start block stays the core's; without it Deploy asks for DEPLOY_BLOCK +# (the core's creation block). # fund: checks the balances one seed needs and stops before any pool exists if they fall short (FUND=1 wraps the -# missing wrapped native). Later expiries: scripts/roll-testnet.sh (four consecutive expiries let a market graduate). +# missing wrapped native, and on Sepolia stakes the missing wstETH). Later expiries: scripts/roll-testnet.sh (four +# consecutive expiries let a market graduate). Salt: TESTNET_SALT of scripts/lib/testnet.sh (Sepolia: the seven-row +# table's TESTNET_SALT_NEXT); SALT overrides it. # Daily grid (GRID=daily: daily expiries listed 15 days ahead, 2 min batches, production ~2 h windows): the pools step # opens 1-, 7- and 14-day maturities at once (MATURITY_DAYS, launch_expiries in scripts/lib/testnet.sh), one strike x # both repay styles per pair; seed leaves a two-sided book in every pool and trade runs TRADE_ROUNDS small crossing -# rounds so every pool has fills and a price. UNTIL= stops after that step. +# rounds so every pool has fills and a price. market deploys the C/R secondary market (PositionMarket) and seatlp one +# SeatLPToken per market (DeployPositionMarket.s.sol, DeploySeatLP.s.sol; both reuse what is already deployed). +# UNTIL= stops after that step. # Chain parameters and the real token addresses live in scripts/lib/testnet.sh. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT/contracts" set -a; . ./.env; set +a . "$ROOT/scripts/lib/testnet.sh" -testnet_env "${1:-}" || { echo "usage: $0 monad|hyperevm [deploy|fund|pools|seed|vaults]"; exit 1; } +testnet_env "${1:-}" || { echo "usage: $0 monad|hyperevm|sepolia [deploy|fund|pools|seed|trade|vaults|market|seatlp]"; exit 1; } ME=$(cast wallet address "${SIGNER[@]}") -# Graduation rows of both testnets (USDC 1000 = 0.001 USDC, native 1e17 per expiry): see testnet_grad in +# Graduation rows of the three testnets (USDC 1000 = 0.001 USDC, native 1e17 per expiry): see testnet_grad in # scripts/lib/testnet.sh. They are part of the core's init code: changing them and redeploying gives a NEW core -# address on both chains (redeploy both; this script regenerates the SDK and CRE configs at the end). +# address on every chain (redeploy them all; this script regenerates the SDK and CRE configs at the end). testnet_grad # The grid must be explicit: Deploy.s.sol defaults to the production grid and refuses demo / daily / mocks off testnets. export TREASURY=$ME GUARDIAN=$ME DEMO=false GRID=daily MOCKS=false SALT=${SALT:-$TESTNET_SALT} FROM=${2:-deploy} -steps=(deploy fund pools seed trade vaults); started=0 +steps=(deploy fund pools seed trade vaults market seatlp); started=0 for st in "${steps[@]}"; do [ "$st" = "$FROM" ] && started=1; done -[ $started = 1 ] || { echo "unknown step $FROM (deploy | fund | pools | seed | trade | vaults)"; exit 1; } +[ $started = 1 ] || { echo "unknown step $FROM (deploy | fund | pools | seed | trade | vaults | market | seatlp)"; exit 1; } started=0 for st in "${steps[@]}"; do [ "$st" = "$FROM" ] && started=1 @@ -47,6 +52,8 @@ for st in "${steps[@]}"; do seed) run "seed two-sided books" script/Seed.s.sol 0 ;; trade) trade_rounds ;; vaults) bigblocks on; run "vaults" script/CreateVaults.s.sol 1; bigblocks off ;; + market) bigblocks on; run "position market" script/DeployPositionMarket.s.sol 1; bigblocks off ;; + seatlp) bigblocks on; run "seat LP tokens" script/DeploySeatLP.s.sol 1; bigblocks off ;; esac unset RESUME # only the first step resumes [ "${UNTIL:-}" != "$st" ] || break From 136c5657c0a84668215a24e13e637ff255ff57be Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:30 -0400 Subject: [PATCH 014/169] feat(scripts): roll-testnet accepts sepolia Co-Authored-By: Claude Opus 5.5 --- scripts/roll-testnet.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/roll-testnet.sh b/scripts/roll-testnet.sh index 629940b..eb85bf1 100755 --- a/scripts/roll-testnet.sh +++ b/scripts/roll-testnet.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Opens the next expiry on a testnet deployed by scripts/deploy-testnet.sh. -# Usage: scripts/roll-testnet.sh monad|hyperevm [collect|fund|pools|seed|sync] +# Usage: scripts/roll-testnet.sh monad|hyperevm|sepolia [collect|fund|pools|seed|trade|sync] # A market graduates after four consecutive expiries that each reached the graduation volume, so each run opens the # expiry exactly one period after the one the last run opened (EXPIRY for CreatePools), not merely the nearest one: # four runs give four consecutive expiries however early they start. Up to four expiries may be listed ahead, so the @@ -17,8 +17,9 @@ # 4. seed: a two-sided book in every new pool at the chain's seed units (scripts/lib/testnet.sh: SEED_UNIT_BPS, # LST_SEED_UNIT_BPS, EXTRA_SEED_UNIT_BPS; pools already seeded are skipped), then trade: TRADE_ROUNDS rounds of # small crossing trades, one batch apart, -# 5. sync: regenerates the SDK addresses and, on Monad, the CRE workflow config (pnpm sync-config: receiver, vaults -# and every pool that is not Closed, so maturing and closing the earlier expiries stays in the CRE tick). +# 5. sync: regenerates the SDK addresses and, on Monad and Sepolia, the chain's CRE workflow config (pnpm sync-config: +# receiver, vaults and every pool that is not Closed, so maturing and closing the earlier expiries stays in the +# CRE tick; see sync_offchain in scripts/lib/testnet.sh). # The optional step starts from there (after a failure). RESUME=1 resumes only that first step's interrupted broadcast; # a failed step prints the exact command. The keeper (or CRE) settles, matures and closes the pools; graduation is # permissionless and the keeper calls it. Reads PRIVATE_KEY from contracts/.env. Pool ids of earlier runs and the @@ -28,7 +29,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT/contracts" set -a; . ./.env; set +a . "$ROOT/scripts/lib/testnet.sh" -testnet_env "${1:-}" || { echo "usage: $0 monad|hyperevm [collect|fund|pools|seed|sync]"; exit 1; } +testnet_env "${1:-}" || { echo "usage: $0 monad|hyperevm|sepolia [collect|fund|pools|seed|trade|sync]"; exit 1; } [ -f "$J" ] && [ -n "$(jget "$J" pools.native)" ] || { echo "no pools in deployments/$NAME.json: run scripts/deploy-testnet.sh $CHAIN first"; exit 1; } FROM=${2:-collect} steps=(collect fund pools seed trade sync) @@ -40,7 +41,8 @@ for st in "${steps[@]}"; do [ "$st" = "$FROM" ] && started=1; done LST=$(jget "$J" pools.lst); NATIVE=$(jget "$J" pools.native); USDC=$(jget "$J" pools.usdc) export LST NATIVE USDC # The extra pair (CreatePools EXTRA_*) this deployment already opens (.pools.extraAsset), else the chain's default from -# scripts/lib/testnet.sh (HyperEVM: USDC/WHYPE; Monad: none), so a roll can add the pair to a running deployment. +# scripts/lib/testnet.sh (HyperEVM: USDC/WHYPE; Monad: shMON/USDC; Sepolia: wstETH/USDC), so a roll can add the pair +# to a running deployment. XA=$(jget "$J" pools.extraAsset) if [ -n "$XA" ]; then EXTRA_ASSET=$XA; EXTRA_QUOTE=$(jget "$J" pools.extraQuote); export EXTRA_ASSET EXTRA_QUOTE; fi CORE=$(jget "$J" confluere) From d4ca5ad5d147635d0cc72bd7d2f294f368cf5210 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:30 -0400 Subject: [PATCH 015/169] test(scripts): one Sepolia seed clears its graduation row Co-Authored-By: Claude Opus 5.5 --- scripts/test/grad-seed.test.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/test/grad-seed.test.sh b/scripts/test/grad-seed.test.sh index 71ce54a..143fc49 100644 --- a/scripts/test/grad-seed.test.sh +++ b/scripts/test/grad-seed.test.sh @@ -10,15 +10,16 @@ ROOT="$(cd "$(dirname "$0")/../.." && pwd)" testnet_grad trap 'rm -f "$ROOT/deployments/.grad-check.tmp"; testnet_cleanup' EXIT cd "$ROOT/contracts" -for chain in monad hyperevm; do +for chain in monad hyperevm sepolia; do unset SEED_UNIT_BPS LST_SEED_UNIT_BPS EXTRA_SEED_UNIT_BPS # each chain's own default (the previous iteration exported its value) testnet_params "$chain" export TRADE_ROUNDS unset FOUNDRY_GAS_LIMIT # HyperEVM broadcast setting; the in-memory check deploys the whole script contract - # The loop pair shMON/WMON and both extra pairs are seeded just above the order minimum (little shMON; ~2.3 USDC per - # order would be needed for USDC/WHYPE's 0.1 WHYPE row), so they need not graduate; native/USDC is held to its row + # The loop pairs (shMON/WMON, wstETH/WETH) and the extra pairs are seeded just above the order minimum (little LST; + # ~2.3 USDC per order would be needed for USDC/WHYPE's 0.1 WHYPE row), so they need not graduate; native/USDC is held + # to its row export EXTRA_ASSET=$ZERO LST=$ZERO - id=10143; [ "$chain" = hyperevm ] && id=998 + case $chain in monad) id=10143 ;; hyperevm) id=998 ;; sepolia) id=11155111 ;; esac out=$(forge script script/checks/ScriptChecks.s.sol --sig "gradCovered(uint256)" "$id" 2>&1) \ || { echo "$out" | grep -E "Error|revert|below" | head -5; echo "FAIL: $chain: one seed does not clear GRAD_$id"; exit 1; } echo "$out" | grep -E "market volume|grad check OK" | sed "s/^/ $chain: /" From b9c51d8d062cd884a73bbec2b5bf547c7e0ed41b Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:30 -0400 Subject: [PATCH 016/169] test(scripts): Sepolia preflight, FUND=1 staking, salts and the market / seatlp steps Co-Authored-By: Claude Opus 5.5 --- scripts/test/testnet-lib.test.sh | 140 +++++++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 15 deletions(-) diff --git a/scripts/test/testnet-lib.test.sh b/scripts/test/testnet-lib.test.sh index 1123a15..cd79f66 100644 --- a/scripts/test/testnet-lib.test.sh +++ b/scripts/test/testnet-lib.test.sh @@ -19,7 +19,7 @@ echo "cast $*" >> "$CALLS" # FAKE_CAST_FAIL=: that read fails every time (an RPC that stays down through rcast's retries) [ "${FAKE_CAST_FAIL:-}" != "$1" ] || { echo "Error: connection refused" >&2; exit 1; } case "$1" in - chain-id) case "$*" in *monad*) echo 10143 ;; *) echo 998 ;; esac ;; + chain-id) case "$*" in *monad*) echo 10143 ;; *sepolia*) echo 11155111 ;; *) echo 998 ;; esac ;; wallet) echo 0x00000000000000000000000000000000000000Aa ;; balance) echo "${FAKE_GAS:-0}" ;; code) echo 0x6080 ;; @@ -28,14 +28,15 @@ case "$1" in call) case "$3" in "decimals()(uint8)") - case "$2" in 0x2B3370eE501B4a559b57D449569354196457D8Ab|0x534b2f3A21130d7a60830c2Df862319e593943A3) echo 6 ;; *) echo 18 ;; esac ;; + case "$2" in 0x2B3370eE501B4a559b57D449569354196457D8Ab|0x534b2f3A21130d7a60830c2Df862319e593943A3|0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238) echo 6 ;; *) echo 18 ;; esac ;; "balanceOf(address)(uint256)") case "$2" in - 0x2B3370eE501B4a559b57D449569354196457D8Ab|0x534b2f3A21130d7a60830c2Df862319e593943A3) echo "${FAKE_BAL_USDC:-${FAKE_BAL:-0}} [x]" ;; + 0x2B3370eE501B4a559b57D449569354196457D8Ab|0x534b2f3A21130d7a60830c2Df862319e593943A3|0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238) echo "${FAKE_BAL_USDC:-${FAKE_BAL:-0}} [x]" ;; *) echo "${FAKE_BAL:-0} [x]" ;; esac ;; "pool(bytes32)") if [ -n "${FAKE_POOL_SHORT:-}" ]; then echo 0x; else printf '0x%0320d%064x\n' 0 "${FAKE_STATUS:-1}"; fi ;; "grid()((uint64,uint64,uint64,uint32,uint8))") echo "(1791532800 [1.791e9], 3600, 120, 4, 2)" ;; + "getStETHByWstETH(uint256)(uint256)") echo "1037717825760305089 [1.037e18]" ;; # a fixed answer, whatever the input esac ;; esac EOF @@ -46,15 +47,16 @@ EOF # FAKE_BLOCK (Deploy.s.sol _deployBlock). cat > "$T/bin/forge" <<'EOF' #!/usr/bin/env bash -script=$2 sig=run prev="" resume=0 cid=998 +script=$2 sig=run prev="" resume=0 cid=998 sim=1 for a in "$@"; do if [ "$prev" = --sig ]; then sig=${a%%(*}; fi [ "$a" = --resume ] && resume=1 - case "$a" in *monad*) cid=10143 ;; esac + [ "$a" = --skip-simulation ] && sim=0 + case "$a" in *monad*) cid=10143 ;; *sepolia*) cid=11155111 ;; esac prev=$a done key="$(basename "$script" .s.sol).$sig" -echo "forge $key resume=$resume EXPIRY=${EXPIRY:-} NATIVE=${NATIVE:-} CLAIM_POOLS=${CLAIM_POOLS:-} BPS=${SEED_UNIT_BPS:-} EXTRA=${EXTRA_ASSET:-} XQ=${EXTRA_QUOTE:-} XBPS=${EXTRA_SEED_UNIT_BPS:-}" >> "$CALLS" +echo "forge $key resume=$resume EXPIRY=${EXPIRY:-} NATIVE=${NATIVE:-} CLAIM_POOLS=${CLAIM_POOLS:-} BPS=${SEED_UNIT_BPS:-} EXTRA=${EXTRA_ASSET:-} XQ=${EXTRA_QUOTE:-} XBPS=${EXTRA_SEED_UNIT_BPS:-} LBPS=${LST_SEED_UNIT_BPS:-} SALT=${SALT:-} GRAD=${GRAD_11155111:+sepolia} sim=$sim" >> "$CALLS" if [ -n "${DEPLOYMENTS_FILE:-}" ] && [ $resume = 0 ]; then python3 - "$key" "$cid" <<'PY' import json, os, sys @@ -80,6 +82,10 @@ elif key == "CreatePools.run": xa = os.environ.get("EXTRA_ASSET", "") if xa and int(xa, 16) != 0: # CreatePools records the extra pair only when there is one d["pools"].update(extraAsset=xa, extraQuote=os.environ["EXTRA_QUOTE"], poolIds=["0x%064x" % e, "0x%064x" % (e + 1)]) +elif key == "DeployPositionMarket.run": + d["positionMarket"] = "0x00000000000000000000000000000000000000d0" +elif key == "DeploySeatLP.run": + d["seatLPTokens"] = {"%s-%s-0" % (d["pools"]["native"], d["pools"]["usdc"]): "0x00000000000000000000000000000000000000d1"} elif key == "CreateVaults.run": d["vaults"] = {"usdLow": "0x00000000000000000000000000000000000000e0"} p = d.get("pools", {}) @@ -100,7 +106,7 @@ echo "pools created 1" EOF cat > "$T/bin/pnpm" <<'EOF' #!/usr/bin/env bash -echo "pnpm cwd=$(basename "$PWD") CHAINS=${CHAINS:-} DEPLOYMENT=${DEPLOYMENT:-} $*" >> "$CALLS" +echo "pnpm cwd=$(basename "$PWD") CHAINS=${CHAINS:-} DEPLOYMENT=${DEPLOYMENT:-} CRE_CONFIG=${CRE_CONFIG:-} RPC=${RPC_URL:-} $*" >> "$CALLS" echo '"ok"' EOF cat > "$T/bin/node" <<'EOF' @@ -199,27 +205,31 @@ for c in "FAKE_CAST_FAIL=code" "FAKE_CAST_FAIL=call" "FAKE_POOL_SHORT=1"; do rm -rf "$DEP/.staging" done -# run Deploy again over a deployment of the same core: .pools and .vaults (recorded by later steps) are kept; over a +# run Deploy again over a deployment of the same core: .pools, .vaults, .positionMarket and .seatLPTokens (recorded by +# later steps) are kept; over a # file naming another core they are dropped python3 - "$DEP/hyperevm-testnet.json" <<'PY' import json, sys json.dump({"chainId": 998, "confluere": "0x00000000000000000000000000000000000000c0", "deployBlock": 7, "pools": {"native": "0x00000000000000000000000000000000000000e1", "usdc": "0x2B3370eE501B4a559b57D449569354196457D8Ab", "lst": "0x0000000000000000000000000000000000000000", "expiry": 5, "poolIds": ["0x%064x" % 5]}, - "vaults": {"usdLow": "0x00000000000000000000000000000000000000e0"}}, open(sys.argv[1], "w")) + "vaults": {"usdLow": "0x00000000000000000000000000000000000000e0"}, + "positionMarket": "0x00000000000000000000000000000000000000d0", + "seatLPTokens": {"a-b-0": "0x00000000000000000000000000000000000000d1"}}, open(sys.argv[1], "w")) PY lib 'run deploy script/Deploy.s.sol 1' > "$T/out" 2>&1 || fail "Deploy re-run failed" -python3 - "$DEP/hyperevm-testnet.json" <<'PY' || fail "Deploy re-run dropped .pools / .vaults of the same core" +python3 - "$DEP/hyperevm-testnet.json" <<'PY' || fail "Deploy re-run dropped .pools / .vaults / .positionMarket / .seatLPTokens of the same core" import json, sys d = json.load(open(sys.argv[1])) assert d["pools"]["poolIds"] == ["0x%064x" % 5] and d["vaults"]["usdLow"].endswith("e0") and d["deployBlock"] == 7, d +assert d["positionMarket"].endswith("d0") and d["seatLPTokens"]["a-b-0"].endswith("d1"), d PY python3 - "$DEP/hyperevm-testnet.json" "$OLD" <<'PY' import json, sys d = json.load(open(sys.argv[1])); d["confluere"] = sys.argv[2]; json.dump(d, open(sys.argv[1], "w")) PY lib 'run deploy script/Deploy.s.sol 1' > "$T/out" 2>&1 || fail "Deploy over another core failed" -grep -qE '"pools"|"vaults"' "$DEP/hyperevm-testnet.json" && fail "pools of another core carried over" +grep -qE '"pools"|"vaults"|"positionMarket"|"seatLPTokens"' "$DEP/hyperevm-testnet.json" && fail "records of another core carried over" rm -rf "$DEP/.staging" # run: Seed.run and Seed.collect (--sig) keep separate markers: a finished collect does not clear the marker an @@ -280,14 +290,16 @@ FAKE_BAL=9000000 FUND=1 lib 'preflight' > "$T/out" 2>&1 || true grep -q 'cast send 0x5555555555555555555555555555555555555555 deposit()' "$CALLS" || fail "FUND=1 did not wrap" # ---------------------------------------------------------------- deploy-testnet.sh -# whole run: deploy (big blocks), fund, pools, seed, vaults (big blocks), then the SDK +# whole run: deploy (big blocks), fund, pools, seed, vaults, market and seatlp (big blocks), then the SDK rm -f "$DEP"/*.json : > "$CALLS" "$SCR/deploy-testnet.sh" hyperevm > "$T/out" 2>&1 || fail "deploy-testnet.sh hyperevm failed" -expect_seq "bb-on Deploy.run bb-off fund CreatePools.run Seed.run bb-on CreateVaults.run bb-off sdk" \ +expect_seq "bb-on Deploy.run bb-off fund CreatePools.run Seed.run bb-on CreateVaults.run bb-off bb-on DeployPositionMarket.run bb-off bb-on DeploySeatLP.run bb-off sdk" \ "deploy-testnet.sh hyperevm" grep -q 'BPS=10 ' <<<"$(forge_line Seed.run)" || fail "seed not at the chain's SEED_UNIT_BPS=10" grep -q '"usdLow"' "$DEP/hyperevm-testnet.json" || fail "vaults not recorded" +grep -q '"positionMarket"' "$DEP/hyperevm-testnet.json" || fail "PositionMarket not recorded" +grep -q '"seatLPTokens"' "$DEP/hyperevm-testnet.json" || fail "SeatLP tokens not recorded" grep -q "EXTRA=0x2B3370eE501B4a559b57D449569354196457D8Ab XQ=0x5555555555555555555555555555555555555555 " \ <<<"$(forge_line CreatePools.run)" || fail "HyperEVM pools without the USDC/WHYPE extra pair" grep -q 'XBPS=2000' <<<"$(forge_line Seed.run)" || fail "extra pair not seeded at EXTRA_SEED_UNIT_BPS=2000" @@ -296,7 +308,7 @@ grep -q '"nativeLend"' "$DEP/hyperevm-testnet.json" || fail "extra-pair vault no # Monad also regenerates the CRE workflow config (it targets monad-testnet) : > "$CALLS" FAKE_BLOCK=40 "$SCR/deploy-testnet.sh" monad > "$T/out" 2>&1 || fail "deploy-testnet.sh monad failed" -expect_seq "Deploy.run fund CreatePools.run Seed.run CreateVaults.run sdk cre:monad-testnet" \ +expect_seq "Deploy.run fund CreatePools.run Seed.run CreateVaults.run DeployPositionMarket.run DeploySeatLP.run sdk cre:monad-testnet" \ "deploy-testnet.sh monad" grep -qi "EXTRA=0x282BdDFF5e58793AcAb65438b257Dbd15A8745C9 " <<<"$(forge_line CreatePools.run)" \ || fail "Monad pools miss the shMON/USDC extra pair" @@ -308,7 +320,7 @@ if FAKE_FAIL=Seed.run "$SCR/deploy-testnet.sh" monad seed > "$T/out" 2>&1; then grep -q "RESUME=1 $SCR/deploy-testnet.sh monad seed" "$T/out" || fail "deploy hint does not name the seed step" : > "$CALLS" RESUME=1 "$SCR/deploy-testnet.sh" monad seed > "$T/out" 2>&1 || fail "RESUME=1 seed failed" -expect_seq "Seed.run! CreateVaults.run sdk cre:monad-testnet" "RESUME=1 deploy-testnet.sh monad seed" +expect_seq "Seed.run! CreateVaults.run DeployPositionMarket.run DeploySeatLP.run sdk cre:monad-testnet" "RESUME=1 deploy-testnet.sh monad seed" # a Deploy that landed the core and then failed: the re-run (without RESUME) keeps that attempt's deploy block echo "{\"chainId\":10143,\"confluere\":\"$OLD\",\"deployBlock\":1}" > "$DEP/monad-testnet.json" @@ -479,4 +491,102 @@ PY FAKE_NOW=$((E0 - 1700)) "$SCR/roll-testnet.sh" hyperevm > "$T/out" 2>&1 || fail "second hyperevm roll failed" grep -q "EXTRA=$XO " <<<"$(forge_line CreatePools.run)" || fail "roll ignored the recorded extra pair" +# ---------------------------------------------------------------- Sepolia +SW=0xB82381A3fBD3FaFA77B3a7bE693342618240067b SE=0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14 +SU=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 +rm -rf "$DEP/.staging" "$T/state"/* +# preflight, book layout, one expiry, 3 trade rounds (10 sells and 10 buys of one unit per strike): wstETH/WETH at +# 0.0012 wstETH, K 0.93 WETH: 10 x (1.116e15 + 1000) WETH and 10 x 1.2e15 wstETH; WETH/USDC at 0.0001 WETH, K 2100: +# 10 x (210000 + 1000) USDC and 10 x 1e14 WETH; wstETH/USDC at 0.0001 wstETH, K 2200: 10 x (220000 + 1000) USDC and +# 10 x 1e14 wstETH. wstETH, WETH and USDC are each counted once. +: > "$CALLS" +TRADE_ROUNDS=3 LIBCHAIN=sepolia lib 'preflight' > "$T/out" 2>&1 || fail "sepolia preflight failed when funded" +grep -qE "lst +need 13000000000000000 " "$T/out" || fail "Sepolia wstETH need: $(grep lst "$T/out")" +grep -qE "native +need 12160000000010000 " "$T/out" || fail "Sepolia WETH need: $(grep native "$T/out")" +grep -qE "usdc +need 4320000 " "$T/out" || fail "Sepolia USDC need: $(grep usdc "$T/out")" +grep -qE "^ extra" "$T/out" && fail "Sepolia: the extra pair's tokens (wstETH, USDC) were counted as separate tokens" +grep -q "balanceOf(address)(uint256)" <<<"$(grep "$SW" "$CALLS")" || fail "Sepolia preflight did not read wstETH" +# no USDC: a clear stop naming the Circle faucet; FUND=1 stakes the missing wstETH (ETH sent to wstETH, at its rate +# plus 1 gwei) and wraps the missing WETH, and keeps GAS_RESERVE (Sepolia default 0.05 ETH) for gas +: > "$CALLS" +if TRADE_ROUNDS=3 LIBCHAIN=sepolia FAKE_BAL=0 FAKE_GAS=2000000000000000000 FUND=1 lib 'preflight' > "$T/out" 2>&1; then + fail "sepolia preflight passed without USDC" +fi +grep -q "faucet.circle.com" "$T/out" || fail "Sepolia: no USDC faucet hint" +grep -q "cast send $SW --value 1037717826760305089 " "$CALLS" || fail "FUND=1 did not stake wstETH: $(grep 'cast send' "$CALLS")" +grep -q "cast send $SE deposit() --value 12160000000010000 " "$CALLS" || fail "FUND=1 did not wrap WETH" +# enough native coin to stake but not to wrap as well: the wrap would eat into the gas reserve, so it stops there +: > "$CALLS" +if TRADE_ROUNDS=3 LIBCHAIN=sepolia FAKE_BAL=0 FAKE_GAS=1095000000000000000 FUND=1 lib 'preflight' > "$T/out" 2>&1; then + fail "sepolia preflight passed short" +fi +grep -q "cast send $SW" "$CALLS" || fail "FUND=1 did not stake wstETH with enough ETH" +grep -q "cast send $SE" "$CALLS" && fail "FUND=1 wrapped WETH into the gas reserve after staking" +grep -q "short of wrapped native" "$T/out" || fail "no WETH hint once the reserve is reached" +: > "$CALLS" +if TRADE_ROUNDS=3 LIBCHAIN=sepolia FAKE_BAL=0 FAKE_GAS=1000000000000000000 FUND=1 lib 'preflight' > "$T/out" 2>&1; then + fail "sepolia preflight passed short" +fi +grep -q "cast send $SW" "$CALLS" && fail "FUND=1 staked into the gas reserve" +grep -q "short of LST $SW by .*receive() stakes and wraps" "$T/out" || fail "no wstETH hint: $(grep LST "$T/out")" + +# deploy-testnet.sh sepolia: no big blocks, the seven-row salt (not the live testnets' 0xc0f2), its GRAD row, the +# wstETH/USDC extra pair, its own seed units, its own CRE config +: > "$CALLS" +"$SCR/deploy-testnet.sh" sepolia > "$T/out" 2>&1 || fail "deploy-testnet.sh sepolia failed" +expect_seq "Deploy.run fund CreatePools.run Seed.run CreateVaults.run DeployPositionMarket.run DeploySeatLP.run sdk cre:sepolia" \ + "deploy-testnet.sh sepolia" +# PositionMarket and the SeatLP tokens go on the core's own salt (the scripts' default 0xc0f1 is no testnet core's) +for k in DeployPositionMarket.run DeploySeatLP.run; do + grep -q "SALT=0x0*c0f3 " <<<"$(forge_line $k)" || fail "Sepolia $k: $(forge_line $k)" +done +grep -q '"positionMarket"' "$DEP/sepolia.json" && grep -q '"seatLPTokens"' "$DEP/sepolia.json" \ + || fail "Sepolia PositionMarket / SeatLP tokens not recorded" +grep -q "SALT=0x0*c0f3 GRAD=sepolia" <<<"$(forge_line Deploy.run)" || fail "Sepolia deploy: $(forge_line Deploy.run)" +grep -q "NATIVE=$SE .*EXTRA=$SW XQ=$SU " <<<"$(forge_line CreatePools.run)" || fail "Sepolia pools: $(forge_line CreatePools.run)" +grep -q 'BPS=1 .*XBPS=1 LBPS=12 ' <<<"$(forge_line Seed.run)" || fail "Sepolia seed units: $(forge_line Seed.run)" +# forge's local simulation does not know Sepolia's state-creation gas: every Sepolia step takes the node's estimates +grep '^forge ' "$CALLS" | grep -q 'sim=1' && fail "a Sepolia step kept forge's local gas simulation" +[ "$(python3 -c "import json;print(json.load(open('$DEP/sepolia.json'))['chainId'])")" = 11155111 ] \ + || fail "deployments/sepolia.json not written" +grep -q '"nativeLend"' "$DEP/sepolia.json" && fail "Sepolia records a native-coin lending vault (its extra pair lends USDC)" +# the live testnets keep their salt, and every chain's Deploy carries the Sepolia GRAD row (one table everywhere) +: > "$CALLS" +FAKE_BLOCK=40 UNTIL=deploy "$SCR/deploy-testnet.sh" monad > "$T/out" 2>&1 || fail "monad deploy step failed" +grep -q "SALT=0x0*c0f2 GRAD=sepolia" <<<"$(forge_line Deploy.run)" || fail "Monad deploy: $(forge_line Deploy.run)" +# a bad chain name prints the usage naming all three +if "$SCR/deploy-testnet.sh" goerli > "$T/out" 2>&1; then fail "unknown chain accepted"; fi +grep -q "monad|hyperevm|sepolia" "$T/out" || fail "deploy usage does not name sepolia" +if "$SCR/roll-testnet.sh" goerli > "$T/out" 2>&1; then fail "unknown chain accepted by the roll"; fi +grep -q "monad|hyperevm|sepolia" "$T/out" || fail "roll usage does not name sepolia" + +# the Sepolia CRE config is its own file, scanned through sync-config's archive RPC unless SEPOLIA_LOGS_RPC is set +: > "$CALLS" +"$SCR/deploy-testnet.sh" sepolia vaults > "$T/out" 2>&1 || fail "sepolia vaults step failed" +grep -q "DEPLOYMENT=sepolia CRE_CONFIG=config.sepolia.json RPC= .*sync-config" "$CALLS" \ + || fail "Sepolia CRE sync not on config.sepolia.json with sync-config's own RPC" +: > "$CALLS" +SEPOLIA_LOGS_RPC=http://archive.example "$SCR/deploy-testnet.sh" sepolia vaults > "$T/out" 2>&1 || fail "sepolia vaults step failed" +expect_seq "CreateVaults.run DeployPositionMarket.run DeploySeatLP.run sdk cre:sepolia" "Sepolia CRE sync" +grep -q "RPC=http://archive.example .*sync-config" "$CALLS" || fail "SEPOLIA_LOGS_RPC not passed to sync-config" +# a run that stopped after vaults finishes with the market step alone +: > "$CALLS" +"$SCR/deploy-testnet.sh" sepolia market > "$T/out" 2>&1 || fail "sepolia market step failed" +expect_seq "DeployPositionMarket.run DeploySeatLP.run sdk cre:sepolia" "deploy-testnet.sh sepolia market" + +# roll-testnet.sh sepolia: tokens from .pools, the next consecutive expiry, its own state files +python3 - "$DEP/sepolia.json" "$E0" "$SW" "$SE" "$SU" <<'PY' +import json, sys +f, e, sw, se, su = sys.argv[1], int(sys.argv[2]), *sys.argv[3:6] +d = {"chainId": 11155111, "confluere": "0x00000000000000000000000000000000000000c0", "gridPeriod": 3600, "deployBlock": 1, + "pools": {"lst": sw, "native": se, "usdc": su, "expiry": e, "extraAsset": sw, "extraQuote": su, + "poolIds": ["0x%064x" % e]}} +json.dump(d, open(f, "w")) +PY +: > "$CALLS" +FAKE_NOW=$((E0 - 1800)) "$SCR/roll-testnet.sh" sepolia > "$T/out" 2>&1 || fail "sepolia roll failed" +expect_seq "Seed.collect fund CreatePools.run Seed.run sdk cre:sepolia" "roll-testnet.sh sepolia" +grep -q "EXPIRY=$((E0 + P)) NATIVE=$SE .*EXTRA=$SW XQ=$SU " <<<"$(forge_line CreatePools.run)" || fail "sepolia roll pools" +[ -f "$T/state/confluere/roll-sepolia-0x00000000000000000000000000000000000000c0.txt" ] || fail "sepolia roll state" + echo "testnet-lib tests OK" From cf049ea76e3bf1aafce5a5fb305a9a7726c02f90 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:37 -0400 Subject: [PATCH 017/169] feat(sdk): Sepolia and Ethereum mainnet in the supported chains Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/chains.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/chains.ts b/packages/sdk/src/chains.ts index d5e0316..a4723fe 100644 --- a/packages/sdk/src/chains.ts +++ b/packages/sdk/src/chains.ts @@ -1,8 +1,21 @@ -import { anvil, hyperEvm, hyperliquidEvmTestnet, monad, monadTestnet } from "viem/chains"; +import { anvil, hyperEvm, hyperliquidEvmTestnet, mainnet, monad, monadTestnet, sepolia } from "viem/chains"; import type { Chain } from "viem"; -/** Chains in the core's constructor table. Same CREATE2 addresses on all of them. */ -export const supportedChains = [monadTestnet, hyperliquidEvmTestnet, monad, hyperEvm, anvil] as const satisfies readonly Chain[]; +/** + * Chains in the core's constructor table. Monad and HyperEVM share one CREATE2 address; Ethereum (Sepolia, mainnet) + * rows were added later, which changes the core's init code, so a Sepolia core has its own address until Monad and + * HyperEVM are redeployed with the same table (docs/IMPLEMENTATION_NOTES.md). A chain listed here may have no + * deployment yet: `deployments[id]` is then undefined (Sepolia until deployments/sepolia.json exists). + */ +export const supportedChains = [ + monadTestnet, + hyperliquidEvmTestnet, + sepolia, + monad, + hyperEvm, + mainnet, + anvil, +] as const satisfies readonly Chain[]; export const chainById = (id: number): Chain | undefined => supportedChains.find((c) => c.id === id); From c87474b8a7b83f66bed9359800da3c2674c584b6 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:38 -0400 Subject: [PATCH 018/169] feat(sdk): 1,000-block log range on Ethereum chains viem's default Sepolia RPC rejects more than 1,000 blocks, and publicnode keeps only ~10,000 blocks of logs, so scans from deployBlock use 1,000-block chunks against an archive RPC. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/chains.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/sdk/src/chains.ts b/packages/sdk/src/chains.ts index a4723fe..ef7b333 100644 --- a/packages/sdk/src/chains.ts +++ b/packages/sdk/src/chains.ts @@ -25,9 +25,14 @@ export const pollingOnly = (id: number): boolean => id === hyperEvm.id || id === /** * Widest block range one eth_getLogs call may cover on the chain's public RPC: Monad's public endpoints reject * anything above 100 blocks, HyperEVM's above 1,000 (and rate-limit bursts); anvil accepts far more. + * Ethereum (Sepolia, mainnet): viem's default RPC (thirdweb) rejects more than 1,000 blocks. publicnode accepts + * 10,000 but keeps only about the last 10,000 blocks of logs ("pruned history unavailable", measured 2026-10-23), so a + * scan from deployBlock needs an RPC with full log history; 1,000 fits both. At 12 s slots that is 3.3 h per request, + * 8 requests a day. */ export function logRange(chainId: number): bigint { if (chainId === 143 || chainId === 10143) return 100n; if (chainId === 999 || chainId === 998) return 1000n; + if (chainId === 1 || chainId === 11155111) return 1000n; return 5000n; } From 3e50a9700b46d2033fb3dd7331fe6e1851170fef Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:38 -0400 Subject: [PATCH 019/169] refactor(sdk): deployments codegen keyed by chainId, chains without a file stay absent Co-Authored-By: Claude Opus 5.5 --- packages/sdk/scripts/sync-deployments.mjs | 41 +++++++++++++++-------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/sdk/scripts/sync-deployments.mjs b/packages/sdk/scripts/sync-deployments.mjs index 48e29e3..eeef831 100644 --- a/packages/sdk/scripts/sync-deployments.mjs +++ b/packages/sdk/scripts/sync-deployments.mjs @@ -1,20 +1,33 @@ // Copies ../../deployments/*.json into src/deployments.ts so every package imports addresses from one place. +// Every file in deployments/ is picked up, keyed by its chainId: a chain without a file (Sepolia before its first +// deploy) is simply absent, so `deployments[id]` is undefined for it and nothing else changes. import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; -const here = dirname(fileURLToPath(import.meta.url)); -const dir = join(here, "../../../deployments"); -const out = join(here, "../src/deployments.ts"); -const all = {}; -for (const f of readdirSync(dir).filter((f) => f.endsWith(".json"))) { - const d = JSON.parse(readFileSync(join(dir, f), "utf8")); - all[d.chainId] = d; +/** Deployment files in `dir` keyed by chain id (integer keys, so they serialise in ascending chain id order). */ +export function collect(dir) { + const all = {}; + for (const f of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) { + const d = JSON.parse(readFileSync(join(dir, f), "utf8")); + if (!Number.isInteger(d.chainId)) throw new Error(`${f}: no numeric chainId`); + if (all[d.chainId]) throw new Error(`${f}: chain ${d.chainId} already read from another file`); + all[d.chainId] = d; + } + return all; } -writeFileSync( - out, - `// Generated by scripts/sync-deployments.mjs from deployments/*.json. Do not edit.\n` + + +export function render(all) { + return ( + `// Generated by scripts/sync-deployments.mjs from deployments/*.json. Do not edit.\n` + `import type { Deployment } from "./types.js";\n\n` + - `export const deployments: Record = ${JSON.stringify(all, null, 2)} as const as unknown as Record;\n`, -); -console.log(`deployments for chains: ${Object.keys(all).join(", ") || "(none)"}`); + `export const deployments: Record = ${JSON.stringify(all, null, 2)} as const as unknown as Record;\n` + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const here = dirname(fileURLToPath(import.meta.url)); + const all = collect(join(here, "../../../deployments")); + writeFileSync(join(here, "../src/deployments.ts"), render(all)); + console.log(`deployments for chains: ${Object.keys(all).join(", ") || "(none)"}`); +} From b9e9c3e04159213799d94e57d3fbc107a6ba7770 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:38 -0400 Subject: [PATCH 020/169] test(sdk): Sepolia chain table, log range and deployments codegen Co-Authored-By: Claude Opus 5.5 --- packages/sdk/test/sdk.test.ts | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index 2a3584e..5fe7ad0 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1,6 +1,24 @@ import { describe, expect, it } from "vitest"; -import { existsSync, readFileSync } from "node:fs"; -import { poolId, rTokenId, cTokenId, Kind, quoteUp, quoteDown, spreadFee, isGridStrike, rolloverTopUp } from "../src/index.js"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + poolId, + rTokenId, + cTokenId, + Kind, + quoteUp, + quoteDown, + spreadFee, + isGridStrike, + rolloverTopUp, + chainById, + deployments, + logRange, + pollingOnly, + supportedChains, +} from "../src/index.js"; +import { collect, render } from "../scripts/sync-deployments.mjs"; describe("ids", () => { it("matches the pool ids the contracts created on anvil", () => { @@ -57,3 +75,49 @@ describe("math", () => { expect(rolloverTopUp(scale, 3000_000000n, 2850_000000n, 500, scale)).toBe(150_000000n + 1000n); }); }); + +describe("chains", () => { + it("serves Ethereum Sepolia and mainnet next to Monad and HyperEVM", () => { + expect(chainById(11155111)?.name).toBe("Sepolia"); + expect(chainById(1)?.id).toBe(1); + expect(supportedChains.map((c) => c.id)).toEqual([10143, 998, 11155111, 143, 999, 1, 31337]); + }); + it("polls only on HyperEVM", () => { + expect([998, 999].every(pollingOnly)).toBe(true); + expect([10143, 143, 11155111, 1, 31337].some(pollingOnly)).toBe(false); + }); + it("caps getLogs at each chain's public-RPC limit", () => { + expect(logRange(10143)).toBe(100n); + expect(logRange(998)).toBe(1000n); + expect(logRange(11155111)).toBe(1000n); + expect(logRange(1)).toBe(1000n); + expect(logRange(31337)).toBe(5000n); + }); +}); + +describe("deployments codegen", () => { + const write = (dir: string, name: string, d: object) => writeFileSync(join(dir, name), JSON.stringify(d)); + + it("leaves a chain without a deployments file undefined", () => { + if (existsSync(new URL("../../../deployments/sepolia.json", import.meta.url))) return; // deployed since + expect(deployments[11155111]).toBeUndefined(); + }); + it("picks up deployments/sepolia.json by its chainId once it exists", () => { + const dir = mkdtempSync(join(tmpdir(), "confluere-deployments-")); + try { + write(dir, "monad-testnet.json", { chainId: 10143, confluere: "0x01" }); + expect(Object.keys(collect(dir))).toEqual(["10143"]); + write(dir, "sepolia.json", { chainId: 11155111, confluere: "0x02" }); + const all = collect(dir); + expect(Object.keys(all)).toEqual(["10143", "11155111"]); + expect(render(all)).toContain(`"11155111": {`); + write(dir, "sepolia-copy.json", { chainId: 11155111 }); + expect(() => collect(dir)).toThrow(/already read/); + rmSync(join(dir, "sepolia-copy.json")); + write(dir, "notes.json", { hello: 1 }); + expect(() => collect(dir)).toThrow(/no numeric chainId/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); From fb209d7019ac0d7a041952b74e19fa3b37cae1e8 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:38 -0400 Subject: [PATCH 021/169] feat(keeper): run on Sepolia (CHAIN_ID 11155111, RPC env) Co-Authored-By: Claude Opus 5.5 --- services/keeper/.env.example | 8 ++++++-- services/keeper/src/config.ts | 19 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/services/keeper/.env.example b/services/keeper/.env.example index 7341f0b..7dbe616 100644 --- a/services/keeper/.env.example +++ b/services/keeper/.env.example @@ -1,10 +1,14 @@ # Chain to keep. The deployment is read from deployments/.json through @confluere/sdk. CHAIN_ID=10143 RPC_URL=https://testnet-rpc.monad.xyz +# HyperEVM testnet: CHAIN_ID=998, RPC_URL=https://rpc.hyperliquid-testnet.xyz/evm +# Ethereum Sepolia: CHAIN_ID=11155111. Leave RPC_URL unset for viem's default (thirdweb, full log history, 1,000-block +# getLogs) or use a provider with full log history: the keeper scans PoolCreated from deployBlock on every start, and +# https://ethereum-sepolia-rpc.publicnode.com keeps only about the last 10,000 blocks of logs. # Hot key that pays gas. Every task is permissionless; this key holds no protocol power. PRIVATE_KEY=0x... # Poll interval in ms. Leave unset for the per-chain default: 15000 on HyperEVM (no WebSocket, public RPC allows -# about 100 requests a minute per IP), 3000 elsewhere. +# about 100 requests a minute per IP), 6000 on Ethereum (12 s blocks), 3000 elsewhere. # POLL_MS=3000 # Seconds after a batch boundary before settling, and before a boundary to poke vaults. SETTLE_DELAY_S=5 @@ -15,5 +19,5 @@ CLOSE_MAX_BIDS=16 # Hint HTTP server port (0 disables). HINT_PORT=8787 # getLogs block range per request. Leave unset to use the chain's public-RPC cap (SDK logRange: Monad 100, -# HyperEVM 1000); a larger value is clamped to that cap. +# HyperEVM 1000, Sepolia 1000); a larger value is clamped to that cap. # LOG_CHUNK=100 diff --git a/services/keeper/src/config.ts b/services/keeper/src/config.ts index fb2a0ef..5b284cd 100644 --- a/services/keeper/src/config.ts +++ b/services/keeper/src/config.ts @@ -22,19 +22,20 @@ function req(env: NodeJS.ProcessEnv, name: string): string { return v; } -export function loadConfig(env = process.env): Config { +/** `known` defaults to the SDK's deployments; a chain the SDK supports but nobody deployed to yet (Sepolia before + * deployments/sepolia.json exists) stops here with a clear message instead of a missing-address error later. */ +export function loadConfig(env = process.env, known: Record = deployments): Config { const chainId = Number(env.CHAIN_ID ?? "31337"); const chain = chainById(chainId); if (!chain) throw new Error(`unsupported chain ${chainId}`); - const deployment = deployments[chainId]; + const deployment = known[chainId]; if (!deployment) throw new Error(`no deployment for chain ${chainId}; run the deploy script and pnpm codegen`); return { chain, rpcUrl: env.RPC_URL ?? chain.rpcUrls.default.http[0]!, privateKey: req(env, "PRIVATE_KEY") as Hex, deployment, - // HyperEVM's public RPC allows about 100 requests a minute per IP and has no WebSocket: poll slower there. - pollMs: Number(env.POLL_MS ?? (pollingOnly(chain.id) ? 15_000 : 3_000)), + pollMs: Number(env.POLL_MS ?? defaultPollMs(chain.id)), settleDelayS: Number(env.SETTLE_DELAY_S ?? 5), settleEmpty: env.SETTLE_EMPTY === "1", settleTouchedOnly: env.SETTLE_TOUCHED_ONLY === "1", @@ -45,6 +46,16 @@ export function loadConfig(env = process.env): Config { }; } +/** + * HyperEVM's public RPC allows about 100 requests a minute per IP and has no WebSocket: poll slower there. Ethereum + * (Sepolia, mainnet) makes a block every 12 s, so polling faster than every 6 s only spends the RPC's rate limit. + */ +export function defaultPollMs(chainId: number): number { + if (pollingOnly(chainId)) return 15_000; + if (chainId === 1 || chainId === 11155111) return 6_000; + return 3_000; +} + /** getLogs range: LOG_CHUNK may narrow the chain's public-RPC cap (SDK logRange) but never widen it. */ function logChunk(raw: string | undefined, chainId: number): bigint { const cap = logRange(chainId); From 4f0e5b0c08ce9a004d2da0b1d6873df9485edde8 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:38 -0400 Subject: [PATCH 022/169] test(keeper): Sepolia config Co-Authored-By: Claude Opus 5.5 --- services/keeper/test/config.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/services/keeper/test/config.test.ts b/services/keeper/test/config.test.ts index 7c3cdfa..747ffb1 100644 --- a/services/keeper/test/config.test.ts +++ b/services/keeper/test/config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { Deployment } from "@confluere/sdk"; import { loadConfig } from "../src/config.js"; // anvil key 0: test-only @@ -21,6 +22,17 @@ describe("loadConfig", () => { expect(loadConfig({ PRIVATE_KEY, CHAIN_ID: "10143" }).pollMs).toBe(3_000); expect(loadConfig({ PRIVATE_KEY, CHAIN_ID: "998", POLL_MS: "5000" }).pollMs).toBe(5_000); }); + it("keeps Sepolia: 1,000-block getLogs, 6 s polls, a clear error until it is deployed", () => { + const sepolia = { 11155111: { chainId: 11155111, deployBlock: 1 } as Deployment }; + const cfg = loadConfig({ PRIVATE_KEY, CHAIN_ID: "11155111" }, sepolia); + expect(cfg.chain.id).toBe(11155111); + expect(cfg.logChunk).toBe(1000n); + expect(cfg.pollMs).toBe(6_000); + expect(cfg.rpcUrl).toMatch(/^https:/); + expect(loadConfig({ PRIVATE_KEY, CHAIN_ID: "11155111", LOG_CHUNK: "5000" }, sepolia).logChunk).toBe(1000n); + expect(() => loadConfig({ PRIVATE_KEY, CHAIN_ID: "11155111" }, {})).toThrow(/no deployment for chain 11155111/); + expect(() => loadConfig({ PRIVATE_KEY, CHAIN_ID: "11155420" })).toThrow(/unsupported chain/); + }); it("reads PRIVATE_KEY from the env it is given", () => { expect(() => loadConfig({ CHAIN_ID: "10143" })).toThrow(/PRIVATE_KEY/); }); From 38c7e929247199cce5497445fb46684d1f450174 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:43 -0400 Subject: [PATCH 023/169] feat(bootstrap-mm): classify a pool's band from its pair An LST lent against its own native coin (shMON/WMON, wstETH/WETH) is pegged, every other pair main. Co-Authored-By: Claude Opus 5.5 --- services/bootstrap-mm/src/rules.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/services/bootstrap-mm/src/rules.ts b/services/bootstrap-mm/src/rules.ts index de3dce7..2d4f940 100644 --- a/services/bootstrap-mm/src/rules.ts +++ b/services/bootstrap-mm/src/rules.ts @@ -11,6 +11,20 @@ export type PoolClass = "pegged" | "main"; export const SPREAD_BPS: Record = { pegged: 10n, main: 50n }; +export const isPoolClass = (s: string): s is PoolClass => Object.hasOwn(SPREAD_BPS, s); + +/** + * Band of a market named "poolId:auto" in POOLS (a bare poolId is main): a liquid staking token lent against its own + * native coin is pegged (shMON / WMON on Monad, wstETH / WETH on Sepolia: `pools.lst` / `pools.native` of the + * deployment); every other pair is main (WETH / USDC and wstETH / USDC on Sepolia, WMON / USDC, USDC / WHYPE). A deployment without an LST + * (HyperEVM testnet, lst = 0) has no pegged market. + */ +export function classOf(asset: string, quote: string, pair?: { lst: string; native: string }): PoolClass { + const eq = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + if (!pair || /^0x0{40}$/i.test(pair.lst)) return "main"; + return eq(asset, pair.lst) && eq(quote, pair.native) ? "pegged" : "main"; +} + export type Inputs = { cls: PoolClass; lastClearingPrice: bigint; // 0 = no reference yet From 19806a5efcddd7539ffd57584fded417b38cb1f8 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:43 -0400 Subject: [PATCH 024/169] feat(bootstrap-mm): POOLS entries may say poolId:auto, clearer errors for an undeployed chain A bare poolId stays main, so Monad and HyperEVM behave as before. Co-Authored-By: Claude Opus 5.5 --- services/bootstrap-mm/.env.example | 5 ++++- services/bootstrap-mm/src/index.ts | 27 +++++++++++++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/services/bootstrap-mm/.env.example b/services/bootstrap-mm/.env.example index 913834d..cf01407 100644 --- a/services/bootstrap-mm/.env.example +++ b/services/bootstrap-mm/.env.example @@ -2,7 +2,10 @@ CHAIN_ID=10143 RPC_URL=https://testnet-rpc.monad.xyz # Treasury-funded key. The rules below are public; the bot has no protocol power. PRIVATE_KEY=0x... -# Pools to support: comma separated "poolId:class" where class is pegged or main. +# Pools to support: comma separated "poolId:class" where class is pegged (0.1%) or main (0.5%); a bare poolId is main. +# "poolId:auto" takes the band from the pair: the LST against its native coin is pegged (shMON / WMON on Monad, +# wstETH / WETH on Sepolia), every other pair main (WETH / USDC, wstETH / USDC, WMON / USDC, USDC / WHYPE). +# Ethereum Sepolia: CHAIN_ID=11155111, RPC_URL unset (viem default) or any Sepolia RPC with full log history. POOLS=0xabc...:pegged,0xdef...:main # Quote per pool per batch, and in total per pool, in quote base units. The total counts quote paid for fills (plus # bids still resting); escrow refunded from unfilled bids is withdrawn each round and does not use up the cap. diff --git a/services/bootstrap-mm/src/index.ts b/services/bootstrap-mm/src/index.ts index 0df3dbc..b2f5b3c 100644 --- a/services/bootstrap-mm/src/index.ts +++ b/services/bootstrap-mm/src/index.ts @@ -1,9 +1,9 @@ -import { deployments, chainById } from "@confluere/sdk"; +import { deployments, chainById, confluereAbi, Status } from "@confluere/sdk"; import { createPublicClient, createWalletClient, http, type Hex, type PublicClient } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { Bot, type Saved } from "./bot.js"; -import type { PoolClass } from "./rules.js"; +import { classOf, isPoolClass, type PoolClass } from "./rules.js"; const log = (msg: string, extra?: Record) => console.log(JSON.stringify({ t: new Date().toISOString(), msg, ...extra }, (_, v) => (typeof v === "bigint" ? v.toString() : v))); @@ -12,15 +12,26 @@ const env = process.env; const chainId = Number(env.CHAIN_ID ?? 31337); const chain = chainById(chainId); const d = deployments[chainId]; -if (!chain || !d) throw new Error(`no chain/deployment for ${chainId}`); +if (!chain) throw new Error(`unsupported chain ${chainId}`); +if (!d) throw new Error(`no deployment for chain ${chainId}; run the deploy script and pnpm codegen`); const account = privateKeyToAccount(env.PRIVATE_KEY as Hex); const transport = http(env.RPC_URL ?? chain.rpcUrls.default.http[0]); const pub = createPublicClient({ chain, transport }) as PublicClient; const wallet = createWalletClient({ chain, transport, account }); -const pools = (env.POOLS ?? "").split(",").filter(Boolean).map((s) => { - const [id, cls] = s.split(":"); - return { id: id as Hex, cls: (cls ?? "main") as PoolClass }; -}); +// "poolId:class"; a bare poolId is main, as it always was; "poolId:auto" takes the band from the pair (rules.ts +// classOf: shMON / WMON and wstETH / WETH pegged, every other pair main). +const pools: { id: Hex; cls: PoolClass }[] = []; +for (const s of (env.POOLS ?? "").split(",").filter(Boolean)) { + const [id, cls = "main"] = s.split(":") as [Hex, string | undefined]; + if (cls !== "auto") { + if (!isPoolClass(cls)) throw new Error(`POOLS: unknown class ${cls} for ${id}; use pegged, main or auto`); + pools.push({ id, cls }); + continue; + } + const v = await pub.readContract({ address: d.confluere, abi: confluereAbi, functionName: "pool", args: [id] }); + if (v.status === Status.None) throw new Error(`POOLS: ${id} is not a pool on chain ${chainId}`); + pools.push({ id, cls: classOf(v.key.asset, v.key.quote, d.pools) }); +} // Cumulative spend per pool, the first start and the exit must survive restarts, or the published caps and the // 8-week limit would reset on every redeploy. const stateFile = env.STATE_FILE ?? `bootstrap-mm-state-${chainId}.json`; @@ -43,7 +54,7 @@ const bot = new Bot( ); const once = process.argv.includes("--once"); -log("bootstrap-mm up", { chain: chainId, pools: pools.length, endAt: Number(env.END_AT ?? 0) }); +log("bootstrap-mm up", { chain: chainId, pools: pools.map((p) => `${p.id}:${p.cls}`), endAt: Number(env.END_AT ?? 0) }); do { try { await bot.tick(); From 09f7c96ed804f4c3fff6a20869c6d25a9469f46e Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:43 -0400 Subject: [PATCH 025/169] test(bootstrap-mm): band classification for Sepolia and the existing pairs Co-Authored-By: Claude Opus 5.5 --- services/bootstrap-mm/test/rules.test.ts | 31 +++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/services/bootstrap-mm/test/rules.test.ts b/services/bootstrap-mm/test/rules.test.ts index ea37274..dfe874b 100644 --- a/services/bootstrap-mm/test/rules.test.ts +++ b/services/bootstrap-mm/test/rules.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { exitReason, quote, RUN_S } from "../src/rules.js"; +import { classOf, exitReason, isPoolClass, quote, RUN_S } from "../src/rules.js"; const base = { cls: "main" as const, @@ -29,6 +29,35 @@ describe("bootstrap rules", () => { }); }); +describe("bootstrap bands by pair", () => { + // Sepolia: wstETH (Lido), WETH (Uniswap's canonical), USDC (Circle) + const WSTETH = "0xB82381A3fBD3FaFA77B3a7bE693342618240067b"; + const WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; + const USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"; + const sepolia = { lst: WSTETH, native: WETH }; + it("quotes wstETH / WETH in the pegged band and both USDC markets in the main band", () => { + expect(classOf(WSTETH, WETH, sepolia)).toBe("pegged"); + expect(classOf(WSTETH.toLowerCase(), WETH.toUpperCase().replace("0X", "0x"), sepolia)).toBe("pegged"); + expect(classOf(WETH, USDC, sepolia)).toBe("main"); + expect(classOf(WSTETH, USDC, sepolia)).toBe("main"); + expect(classOf(WETH, WSTETH, sepolia)).toBe("main"); // the reverse pair is not the LST loop + }); + it("has no pegged market without an LST (HyperEVM testnet) or without pool addresses", () => { + const ZERO = "0x0000000000000000000000000000000000000000"; + expect(classOf(ZERO, WETH, { lst: ZERO, native: WETH })).toBe("main"); + expect(classOf(WSTETH, WETH)).toBe("main"); + }); + it("prices a wstETH / WETH bid 0.1% under the last clear (about 1.0377 WETH per wstETH)", () => { + const last = 1_037_700_000_000_000_000n; + const q = quote({ ...base, cls: classOf(WSTETH, WETH, sepolia), lastClearingPrice: last, perBatchQuote: last })!; + expect(q.limit).toBe((last * 9_990n) / 10_000n); + }); + it("accepts only the two published classes from POOLS", () => { + expect(isPoolClass("pegged") && isPoolClass("main")).toBe(true); + expect(isPoolClass("tight") || isPoolClass("toString") || isPoolClass("__proto__")).toBe(false); + }); +}); + describe("bootstrap exit", () => { const e = { now: 1_000, startedAt: 1_000, endAt: 0, marketsSeated: [true, false] }; it("keeps running inside eight weeks while a market has no seat holder", () => { From f7bf76738dade954801c7930a59707f6d491e2be Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:49 -0400 Subject: [PATCH 026/169] feat(keeper-cre): sepolia-settings target on ethereum-testnet-sepolia Co-Authored-By: Claude Opus 5.5 --- services/keeper-cre/project.yaml | 9 +++++++++ services/keeper-cre/workflow.yaml | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/services/keeper-cre/project.yaml b/services/keeper-cre/project.yaml index 9966fc3..677e3fa 100644 --- a/services/keeper-cre/project.yaml +++ b/services/keeper-cre/project.yaml @@ -4,7 +4,16 @@ staging-settings: - chain-name: monad-testnet url: https://testnet-rpc.monad.xyz +# Ethereum Sepolia (config.sepolia.json, written by `DEPLOYMENT=sepolia pnpm sync-config` once deployments/sepolia.json +# exists). +sepolia-settings: + rpcs: + - chain-name: ethereum-testnet-sepolia + url: https://ethereum-sepolia-rpc.publicnode.com + production-settings: rpcs: - chain-name: monad-testnet url: https://testnet-rpc.monad.xyz + - chain-name: ethereum-testnet-sepolia + url: https://ethereum-sepolia-rpc.publicnode.com diff --git a/services/keeper-cre/workflow.yaml b/services/keeper-cre/workflow.yaml index 218155f..cbfc886 100644 --- a/services/keeper-cre/workflow.yaml +++ b/services/keeper-cre/workflow.yaml @@ -1,4 +1,5 @@ # CRE workflow settings: the Confluere keeper (cron -> signed report of pool / vault ids -> KeeperReceiver). +# One target per chain; each config names its chain (chainSelectorName) and receiver. staging-settings: user-workflow: workflow-name: "confluere-keeper-staging" @@ -6,3 +7,13 @@ staging-settings: workflow-path: "./src/main.ts" config-path: "./config.staging.json" secrets-path: "" + +# Ethereum Sepolia. config.sepolia.json does not exist until `DEPLOYMENT=sepolia pnpm sync-config` has run against +# deployments/sepolia.json. +sepolia-settings: + user-workflow: + workflow-name: "confluere-keeper-sepolia" + workflow-artifacts: + workflow-path: "./src/main.ts" + config-path: "./config.sepolia.json" + secrets-path: "" From 71ede1e8dcebd842d06a497ec5346823a9b32c0b Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:49 -0400 Subject: [PATCH 027/169] feat(keeper-cre): simulate:sepolia and sync-config:sepolia scripts Co-Authored-By: Claude Opus 5.5 --- services/keeper-cre/package.json | 2 ++ services/keeper-cre/src/main.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/services/keeper-cre/package.json b/services/keeper-cre/package.json index 6578590..adc1e1e 100644 --- a/services/keeper-cre/package.json +++ b/services/keeper-cre/package.json @@ -6,7 +6,9 @@ "scripts": { "compile": "bun x cre-compile src/main.ts dist/workflow.wasm", "simulate": "cre workflow simulate . --target staging-settings --non-interactive --trigger-index 0", + "simulate:sepolia": "cre workflow simulate . --target sepolia-settings --non-interactive --trigger-index 0", "sync-config": "node scripts/sync-config.mjs", + "sync-config:sepolia": "DEPLOYMENT=sepolia node scripts/sync-config.mjs", "typecheck": "tsc --noEmit", "test": "vitest run" }, diff --git a/services/keeper-cre/src/main.ts b/services/keeper-cre/src/main.ts index 1442b80..d5d6d60 100644 --- a/services/keeper-cre/src/main.ts +++ b/services/keeper-cre/src/main.ts @@ -16,7 +16,9 @@ const configSchema = z.object({ // "5 */2 * * * *" (second 5 of every 2nd minute) for the 120 s demo grid; a 30-minute mainnet grid would use // "5 */30 * * * *". schedule: z.string(), - chainSelectorName: z.string(), // "monad-testnet" | "hyperliquid-testnet" + // CRE chain-selector name: "monad-testnet" (config.staging.json) or "ethereum-testnet-sepolia" (config.sepolia.json); + // scripts/sync-config.mjs CRE_CHAINS lists the targets and their forwarders. + chainSelectorName: z.string(), isTestnet: z.boolean(), receiver: z.string(), // KeeperReceiver address from deployments/.json pools: z.array(z.string()), From 0234ed9794b3b80c4ade92427016b49e37318d78 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:57 -0400 Subject: [PATCH 028/169] feat(keeper-cre): sync-config per chain with forwarder, gas limit and archive log scan CRE_CHAINS lists Monad testnet (config.staging.json) and Sepolia (config.sepolia.json, 30M report gas, 1,000-block scans through an archive RPC); a chain without a deployments file is skipped. Co-Authored-By: Claude Opus 5.5 --- services/keeper-cre/scripts/sync-config.mjs | 123 +++++++++++++++++--- 1 file changed, 109 insertions(+), 14 deletions(-) diff --git a/services/keeper-cre/scripts/sync-config.mjs b/services/keeper-cre/scripts/sync-config.mjs index 70fd4b1..778e8a2 100644 --- a/services/keeper-cre/scripts/sync-config.mjs +++ b/services/keeper-cre/scripts/sync-config.mjs @@ -7,16 +7,94 @@ * The workflow carries ids only, so this is the step that keeps later expiries (opened by CreatePools.s.sol or the * /create wizard) in the CRE tick. Re-run it after opening pools. Read-only: no transaction is sent. * - * Env: DEPLOYMENT (default monad-testnet), RPC_URL (default https://testnet-rpc.monad.xyz), - * SKIP_SCAN=1 (use deployments pools.poolIds instead of scanning logs), CHUNK (default 100 blocks, the - * Monad testnet eth_getLogs limit), DELAY_MS (pause between requests, default 150, for the public RPC rate limit). + * One workflow config per chain (CRE_CHAINS): Monad testnet writes config.staging.json (target staging-settings), + * Ethereum Sepolia config.sepolia.json (target sepolia-settings). A chain without deployments/.json yet is + * skipped with a message and exit code 0, so deploy scripts can call this before the chain is deployed. + * + * Env: DEPLOYMENT (monad-testnet | sepolia, default monad-testnet), RPC_URL (default: the chain's row below), + * SKIP_SCAN=1 (use deployments pools.poolIds instead of scanning logs), CHUNK (default: the chain's eth_getLogs + * cap, 100 blocks on Monad testnet, 1,000 on Sepolia), DELAY_MS (pause between requests, default 150, for the + * public RPC rate limit), CRE_CONFIG (config file to write, relative to this package, default: the chain's row). */ -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createPublicClient, http, parseAbi, parseAbiItem } from "viem"; const ZERO = "0x0000000000000000000000000000000000000000"; + +/** + * Chains the CRE workflow targets, keyed by the deployments/.json basename. `chainSelectorName` is the CRE / CCIP + * chain-selector name getNetwork() resolves (@chainlink/cre-sdk dist/generated/chain-selectors). The forwarders + * (CRE docs, Forwarder Directory) are informational: KeeperReceiver accepts onReport from anyone, so neither address + * is configured at deploy time. `mock` is the MockKeystoneForwarder `cre workflow simulate --broadcast` writes + * through, `keystone` the KeystoneForwarder of a deployed workflow. + * Sepolia RPC: viem's default (thirdweb) keeps the full log history but caps eth_getLogs at 1,000 blocks; publicnode + * accepts wider ranges but prunes logs older than about 10,000 blocks, too short for a scan from deployBlock. + * `gasLimit` is the report's gas for a chain's first config. Sepolia prices new storage far higher (Amsterdam): on an + * anvil fork (2026-10-23) a tick over 24 pools that first settled a new expiry's 6 seeded pools used 8.4M gas, the first + * settlement of 18 freshly seeded pools 24.7M, a steady-state tick of 18 pools 5.0M. KeeperReceiver catches a pool that + * runs out of gas, but the ones after it then wait for the next tick, so Sepolia gets 30M (its transactions go up to + * ~57M, blocks are ~200M). An existing config keeps its own gasLimit. + */ +export const CRE_CHAINS = { + "monad-testnet": { + chainId: 10143, + chainSelectorName: "monad-testnet", + isTestnet: true, + rpc: "https://testnet-rpc.monad.xyz", + chunk: 100n, + config: "config.staging.json", + target: "staging-settings", + gasLimit: "8000000", + forwarder: { mock: "0xB9F79d863261869B234c481D1f9A7af84AeAd192", keystone: "0xF8344CFd5c43616a4366C34E3EEE75af79a74482" }, + }, + sepolia: { + chainId: 11155111, + chainSelectorName: "ethereum-testnet-sepolia", + isTestnet: true, + rpc: "https://11155111.rpc.thirdweb.com", + chunk: 1000n, + config: "config.sepolia.json", + target: "sepolia-settings", + gasLimit: "30000000", + forwarder: { mock: "0x15fC6ae953E024d975e77382eEeC56A9101f9F88", keystone: "0xF8344CFd5c43616a4366C34E3EEE75af79a74482" }, + }, +}; + +/** The CRE_CHAINS row of a deployments basename; anything else is not a CRE target. */ +export function creChain(name) { + if (!Object.hasOwn(CRE_CHAINS, name)) { + throw new Error(`${name} is not a CRE target; use one of ${Object.keys(CRE_CHAINS).join(", ")}`); + } + return CRE_CHAINS[name]; +} + +/** + * Six-field cron (with seconds) firing 5 s after every batch boundary of the deployment's grid: second 5 of every 2nd + * minute for the 120 s grid. Batch intervals that do not divide an hour or a day evenly have no such cron. + */ +export function scheduleFor(batchInterval) { + const s = Number(batchInterval); + const m = s / 60; + const h = s / 3600; + if (Number.isInteger(m) && m >= 1 && m < 60 && 60 % m === 0) return m === 1 ? "5 * * * * *" : `5 */${m} * * * *`; + if (Number.isInteger(h) && h >= 1 && h <= 24 && 24 % h === 0) return h === 1 ? "5 0 * * * *" : h === 24 ? "5 0 0 * * *" : `5 0 */${h} * * *`; + throw new Error(`batchInterval ${batchInterval} s has no cron schedule; set "schedule" in the config by hand`); +} + +/** Starting config for a chain whose config file does not exist yet (its first sync). */ +export function defaultBase(row, deployment) { + return { + schedule: scheduleFor(deployment.batchInterval), + chainSelectorName: row.chainSelectorName, + isTestnet: row.isTestnet, + receiver: ZERO, + pools: [], + vaults: [], + gasLimit: row.gasLimit, + }; +} /** IConfluere.Status: None, Open, Window, Closing, Closed. */ export const STATUS_NONE = 0; export const STATUS_CLOSED = 4; @@ -69,12 +147,24 @@ export function livePools(ids, statusOf) { return out; } -/** New workflow config: keeps schedule / chain / gas settings from `base`, replaces receiver, pools and vaults. */ -export function buildConfig(base, deployment, pools) { +/** + * New workflow config: keeps schedule / gas settings from `base`, replaces receiver, pools and vaults. With `row` + * (a CRE_CHAINS entry) the chain selector comes from the row and the deployment must be on that chain, so a config + * can never send one chain's ids to another chain's forwarder. + */ +export function buildConfig(base, deployment, pools, row) { if (!deployment.keeperReceiver || deployment.keeperReceiver.toLowerCase() === ZERO) { throw new Error("deployment has no keeperReceiver"); } - return { ...base, receiver: deployment.keeperReceiver, pools, vaults: vaultsOf(deployment) }; + const chain = {}; + if (row) { + if (deployment.chainId !== row.chainId) { + throw new Error(`deployment is on chain ${deployment.chainId}, not ${row.chainSelectorName} (${row.chainId})`); + } + chain.chainSelectorName = row.chainSelectorName; + chain.isTestnet = row.isTestnet; + } + return { ...base, ...chain, receiver: deployment.keeperReceiver, pools, vaults: vaultsOf(deployment) }; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -132,17 +222,22 @@ async function main() { const here = dirname(fileURLToPath(import.meta.url)); const pkg = resolve(here, ".."); const chain = process.env.DEPLOYMENT ?? "monad-testnet"; - const rpc = process.env.RPC_URL ?? "https://testnet-rpc.monad.xyz"; - const chunk = BigInt(process.env.CHUNK ?? "100"); + const row = creChain(chain); + const rpc = process.env.RPC_URL ?? row.rpc; + const chunk = BigInt(process.env.CHUNK ?? row.chunk); const delayMs = Number(process.env.DELAY_MS ?? "150"); const skipScan = process.env.SKIP_SCAN === "1"; const deploymentPath = resolve(pkg, "../../deployments", `${chain}.json`); - const configPath = resolve(pkg, "config.staging.json"); + const configPath = resolve(pkg, process.env.CRE_CONFIG ?? row.config); + const log = (m) => console.error(`[sync-config] ${m}`); + if (!existsSync(deploymentPath)) { + log(`no deployments/${chain}.json yet: ${configPath} not touched (deploy ${chain} first)`); + return; + } const deployment = JSON.parse(readFileSync(deploymentPath, "utf8")); - const base = JSON.parse(readFileSync(configPath, "utf8")); + const base = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : defaultBase(row, deployment); const client = createPublicClient({ transport: http(rpc, { retryCount: 0, timeout: 30_000 }) }); - const log = (m) => console.error(`[sync-config] ${m}`); let ids; if (skipScan) { @@ -158,9 +253,9 @@ async function main() { const statuses = await readStatuses(client, deployment.confluere, ids, { delayMs }); const pools = livePools(ids, statuses); - const config = buildConfig(base, deployment, pools); + const config = buildConfig(base, deployment, pools, row); writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); - log(`wrote ${configPath}: receiver ${config.receiver}, ${pools.length}/${ids.length} pools not Closed, ${config.vaults.length} vaults`); + log(`wrote ${configPath} (target ${row.target}): receiver ${config.receiver}, ${pools.length}/${ids.length} pools not Closed, ${config.vaults.length} vaults`); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { From 7494174d957bb18d6617d30eecf9a92977ddf1f3 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:57 -0400 Subject: [PATCH 029/169] test(keeper-cre): Sepolia sync-config rows and the undeployed case Co-Authored-By: Claude Opus 5.5 --- services/keeper-cre/test/sync-config.test.ts | 71 +++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/services/keeper-cre/test/sync-config.test.ts b/services/keeper-cre/test/sync-config.test.ts index 8ba9976..7ced666 100644 --- a/services/keeper-cre/test/sync-config.test.ts +++ b/services/keeper-cre/test/sync-config.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import { buildConfig, + CRE_CHAINS, + creChain, + defaultBase, + scheduleFor, chunkRanges, livePools, readStatuses, @@ -110,4 +115,68 @@ describe("sync-config", () => { ).rejects.toThrow("down"); expect(n).toBe(3); }); + + it("targets Monad testnet and Sepolia, each with its own config file and getLogs cap", () => { + expect(creChain("monad-testnet")).toMatchObject({ chainId: 10143, config: "config.staging.json", chunk: 100n }); + expect(creChain("sepolia")).toMatchObject({ + chainId: 11155111, + chainSelectorName: "ethereum-testnet-sepolia", + config: "config.sepolia.json", + target: "sepolia-settings", + chunk: 1000n, + }); + expect(CRE_CHAINS.sepolia.forwarder.mock).toBe("0x15fC6ae953E024d975e77382eEeC56A9101f9F88"); + expect(() => creChain("hyperevm-testnet")).toThrow(/not a CRE target/); + expect(() => creChain("toString")).toThrow(/not a CRE target/); + }); + + it("uses chain-selector names the CRE SDK resolves to the same chain ids", async () => { + for (const row of Object.values(CRE_CHAINS)) { + const f = new URL( + `../node_modules/@chainlink/cre-sdk/dist/generated/chain-selectors/testnet/evm/${row.chainSelectorName}.js`, + import.meta.url, + ); + const n = (await import(f.href)).default as { chainId: string; chainSelector: { name: string } }; + expect(n.chainSelector.name).toBe(row.chainSelectorName); + expect(Number(n.chainId)).toBe(row.chainId); + } + }); + + it("derives the cron from the grid's batch interval", () => { + expect(scheduleFor(120)).toBe("5 */2 * * * *"); + expect(scheduleFor(60)).toBe("5 * * * * *"); + expect(scheduleFor(1800)).toBe("5 */30 * * * *"); + expect(scheduleFor(3600)).toBe("5 0 * * * *"); + expect(scheduleFor(21_600)).toBe("5 0 */6 * * *"); + expect(scheduleFor(86_400)).toBe("5 0 0 * * *"); + expect(() => scheduleFor(90)).toThrow(/no cron/); + expect(() => scheduleFor(420)).toThrow(/no cron/); + }); + + it("starts a Sepolia config from the deployment and never mixes chains", () => { + const row = creChain("sepolia"); + const sepolia = { chainId: 11155111, batchInterval: 120, keeperReceiver: "0x5149637A57030b2A07463032758F542fe987EB6A", vaults: {} }; + const base = defaultBase(row, sepolia); + expect(base).toMatchObject({ schedule: "5 */2 * * * *", chainSelectorName: "ethereum-testnet-sepolia", isTestnet: true }); + // Sepolia's storage pricing: a first settlement of a fresh expiry does not fit Monad's 8M report gas + expect(base.gasLimit).toBe("30000000"); + expect(defaultBase(creChain("monad-testnet"), { ...sepolia, chainId: 10143 }).gasLimit).toBe("8000000"); + expect(buildConfig(base, sepolia, [id("aa")], row)).toMatchObject({ receiver: sepolia.keeperReceiver, pools: [id("aa")] }); + // a config copied from Monad gets Sepolia's selector; a Monad deployment is refused for the Sepolia target + const monadBase = { ...base, chainSelectorName: "monad-testnet" }; + expect(buildConfig(monadBase, sepolia, [], row).chainSelectorName).toBe("ethereum-testnet-sepolia"); + expect(() => buildConfig(base, { ...sepolia, chainId: 10143 }, [], row)).toThrow(/not ethereum-testnet-sepolia/); + }); + + it("skips a chain that has no deployments file yet without touching anything", () => { + if (existsSync(new URL("../../../deployments/sepolia.json", import.meta.url))) return; // deployed since + const script = new URL("../scripts/sync-config.mjs", import.meta.url).pathname; + const out = execFileSync(process.execPath, [script], { + env: { ...process.env, DEPLOYMENT: "sepolia" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + expect(out).toBe(""); + expect(existsSync(new URL("../config.sepolia.json", import.meta.url))).toBe(false); + }); }); From 40fe3b190f4598e61d921b2691c2b0e3bbda8af2 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:57 -0400 Subject: [PATCH 030/169] feat(ops): CCTP USDC transfers to and from Sepolia (domain 0) Co-Authored-By: Claude Opus 5.5 --- packages/ops/src/cctp.ts | 10 +++++++--- packages/ops/test/cctp.test.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/ops/src/cctp.ts b/packages/ops/src/cctp.ts index 15efdbe..abff9ac 100644 --- a/packages/ops/src/cctp.ts +++ b/packages/ops/src/cctp.ts @@ -1,10 +1,13 @@ /** - * Testnet USDC between HyperEVM and Monad through Circle CCTP v2 (burn, Circle attestation, mint). No web faucet, - * no captcha: the attestation comes from Circle's public sandbox API. Verified 2026-10-12: 10 USDC HyperEVM → Monad. + * Testnet USDC between HyperEVM, Monad and Ethereum Sepolia through Circle CCTP v2 (burn, Circle attestation, mint). + * No web faucet, no captcha: the attestation comes from Circle's public sandbox API. Verified 2026-10-12: 10 USDC + * HyperEVM → Monad. Sepolia (domain 0) uses the same TokenMessengerV2 / MessageTransmitterV2 addresses (checked + * read-only 2026-10-23: identical code, localDomain() = 0); not yet exercised with a transfer. A burn on Sepolia waits + * for Ethereum finality (about 15 to 20 minutes) before Circle attests it. */ import { createPublicClient, createWalletClient, http, pad, parseAbi, type Hex } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { hyperliquidEvmTestnet, monadTestnet } from "viem/chains"; +import { hyperliquidEvmTestnet, monadTestnet, sepolia } from "viem/chains"; export const CCTP = { tokenMessenger: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA", @@ -15,6 +18,7 @@ export const CCTP = { export const CCTP_CHAINS = { hyperevm: { chain: hyperliquidEvmTestnet, domain: 19, usdc: "0x2B3370eE501B4a559b57D449569354196457D8Ab" }, monad: { chain: monadTestnet, domain: 15, usdc: "0x534b2f3A21130d7a60830c2Df862319e593943A3" }, + sepolia: { chain: sepolia, domain: 0, usdc: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" }, } as const; const abi = parseAbi([ diff --git a/packages/ops/test/cctp.test.ts b/packages/ops/test/cctp.test.ts index 8f662a5..2376abc 100644 --- a/packages/ops/test/cctp.test.ts +++ b/packages/ops/test/cctp.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { awaitAttestation, cctpChain, relay, resumeCommand, usdcUnits } from "../src/cctp"; +import { awaitAttestation, CCTP_CHAINS, cctpChain, relay, resumeCommand, usdcUnits } from "../src/cctp"; const BURN = `0x${"ab".repeat(32)}` as const; // anvil key 0: test-only @@ -34,6 +34,14 @@ describe("cctp", () => { expect(() => cctpChain("hyperliquid", "monad")).toThrow(/unknown chain hyperliquid/); for (const inherited of ["toString", "constructor", "__proto__", "hasOwnProperty"]) expect(() => cctpChain(inherited, "monad")).toThrow(/unknown chain/); }); + it("knows Ethereum Sepolia as CCTP domain 0 with Circle's USDC", () => { + expect(cctpChain("sepolia", "monad")).toBe("sepolia"); + expect(CCTP_CHAINS.sepolia.domain).toBe(0); + expect(CCTP_CHAINS.sepolia.chain.id).toBe(11155111); + expect(CCTP_CHAINS.sepolia.usdc).toBe("0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"); + expect(new Set(Object.values(CCTP_CHAINS).map((c) => c.domain)).size).toBe(Object.keys(CCTP_CHAINS).length); + expect(resumeCommand(BURN, "sepolia", "hyperevm")).toBe(`pnpm --filter @confluere/ops cctp-resume ${BURN} sepolia hyperevm`); + }); it("parses USDC amounts and rejects non-positive ones", () => { expect(usdcUnits("10.5")).toBe(10_500_000n); expect(() => usdcUnits("abc")).toThrow(); From 77c4c75b83c7454715f4ca1cb0b7e2224d24882c Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:57 -0400 Subject: [PATCH 031/169] feat(ops): Sepolia in status, balances and block-time calibration Co-Authored-By: Claude Opus 5.5 --- packages/ops/src/cli.ts | 22 +++++++++++++++++----- packages/ops/test/blocktime.test.ts | 4 ++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/ops/src/cli.ts b/packages/ops/src/cli.ts index 58e179a..25536e7 100644 --- a/packages/ops/src/cli.ts +++ b/packages/ops/src/cli.ts @@ -1,23 +1,30 @@ /** * Testnet operations. Reads PRIVATE_KEY from contracts/.env (never printed). * pnpm --filter @confluere/ops status - * pnpm --filter @confluere/ops hl-to-evm HYPE 5 # HyperCore → HyperEVM - * pnpm --filter @confluere/ops big-blocks on|off # around a HyperEVM deploy + * pnpm --filter @confluere/ops hl-to-evm HYPE 5 # HyperCore → HyperEVM (HyperEVM only) + * pnpm --filter @confluere/ops big-blocks on|off # around a HyperEVM deploy (HyperEVM only) * pnpm --filter @confluere/ops blocktime # 24 h block count -> WINDOW_BLOCKS (read-only) * pnpm --filter @confluere/ops spot-sell HYPE 1 # HyperCore testnet: sell HYPE for USDC (IOC, 5% below mid) * pnpm --filter @confluere/ops cctp 10 hyperevm monad # move testnet USDC between chains via Circle CCTP v2 + * pnpm --filter @confluere/ops cctp 10 hyperevm sepolia # chains: hyperevm, monad, sepolia * pnpm --filter @confluere/ops cctp-resume 0x hyperevm monad # finish a CCTP transfer after its burn */ import { readFileSync, existsSync } from "node:fs"; import { createPublicClient, erc20Abi, formatEther, formatUnits, http, type Hex } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { hyperEvm, hyperliquidEvmTestnet, monad, monadTestnet } from "viem/chains"; +import { hyperEvm, hyperliquidEvmTestnet, mainnet, monad, monadTestnet, sepolia } from "viem/chains"; import { measure } from "./blocktime.js"; import { cctp, cctpChain, relay, usdcUnits } from "./cctp.js"; import { hlPrice, post, signL1Action, signSpotSend, systemAddress } from "./hyperliquid.js"; const NET = "Testnet" as const; const HYPEREVM_TESTNET_USDC = "0x2b3370ee501b4a559b57d449569354196457d8ab"; +/** Tokens of the Sepolia markets (WETH / USDC, wstETH / WETH, wstETH / USDC): Circle USDC, Uniswap's WETH, Lido wstETH. */ +const SEPOLIA_TOKENS = [ + { symbol: "USDC", address: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", decimals: 6 }, + { symbol: "WETH", address: "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", decimals: 18 }, + { symbol: "wstETH", address: "0xB82381A3fBD3FaFA77B3a7bE693342618240067b", decimals: 18 }, +] as const; function loadKey(): Hex { const f = new URL("../../../contracts/.env", import.meta.url); @@ -32,7 +39,7 @@ function loadKey(): Hex { async function status() { const account = privateKeyToAccount(loadKey()); console.log(`address ${account.address}`); - for (const chain of [monadTestnet, hyperliquidEvmTestnet]) { + for (const chain of [monadTestnet, hyperliquidEvmTestnet, sepolia]) { const c = createPublicClient({ chain, transport: http() }); const bal = await c.getBalance({ address: account.address }).catch(() => undefined); console.log(`${chain.name.padEnd(26)} ${bal === undefined ? "rpc error" : `${formatEther(bal)} ${chain.nativeCurrency.symbol}`}`); @@ -42,6 +49,11 @@ async function status() { .readContract({ address: HYPEREVM_TESTNET_USDC, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }) .catch(() => undefined); console.log(`${"HyperEVM Testnet USDC".padEnd(26)} ${usdc === undefined ? "rpc error" : formatUnits(usdc, 6)}`); + const sep = createPublicClient({ chain: sepolia, transport: http() }); + for (const t of SEPOLIA_TOKENS) { + const b = await sep.readContract({ address: t.address, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }).catch(() => undefined); + console.log(`${`Sepolia ${t.symbol}`.padEnd(26)} ${b === undefined ? "rpc error" : formatUnits(b, t.decimals)}`); + } const core = await post(NET, "info", { type: "spotClearinghouseState", user: account.address }); for (const b of core.balances ?? []) console.log(`${("HyperCore " + b.coin).padEnd(26)} ${b.total}`); const big = await post(NET, "info", { type: "userAbstraction", user: account.address }).catch(() => undefined); @@ -89,7 +101,7 @@ async function spotSell(coin: string, size: string) { } async function blocktime() { - for (const chain of [monad, monadTestnet, hyperEvm, hyperliquidEvmTestnet]) { + for (const chain of [monad, monadTestnet, hyperEvm, hyperliquidEvmTestnet, mainnet, sepolia]) { const m = await measure(chain).catch((e) => String(e?.shortMessage ?? e).slice(0, 80)); if (typeof m === "string") console.log(`${chain.name.padEnd(26)} ${m}`); else console.log(`${chain.name.padEnd(26)} ${m.blocks} blocks in ${(m.seconds / 3600).toFixed(2)} h -> WINDOW_BLOCKS ~ ${m.window}`); diff --git a/packages/ops/test/blocktime.test.ts b/packages/ops/test/blocktime.test.ts index d765b89..4a533e3 100644 --- a/packages/ops/test/blocktime.test.ts +++ b/packages/ops/test/blocktime.test.ts @@ -8,6 +8,10 @@ describe("windowBlocks", () => { it("0.3 s blocks (Monad measured): 24,000", () => { expect(windowBlocks(288_000, 86_400)).toBe(24_000); }); + it("12 s slots (Ethereum Sepolia): 600, also with a few missed slots", () => { + expect(windowBlocks(7_200, 86_400)).toBe(600); + expect(windowBlocks(7_130, 86_400)).toBe(600); + }); it("rejects an empty sample", () => { expect(() => windowBlocks(0, 10)).toThrow(); }); From 3781a912c352c6eec7b9343257b340cae0806583 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:56:57 -0400 Subject: [PATCH 032/169] chore(deploy): Sepolia RPC variables for the self-hosted web image Co-Authored-By: Claude Opus 5.5 --- deploy/.env.example | 4 ++++ deploy/docker-compose.yml | 2 ++ deploy/web.Dockerfile | 2 ++ 3 files changed, 8 insertions(+) diff --git a/deploy/.env.example b/deploy/.env.example index bde0097..8967493 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -5,8 +5,12 @@ SITE_DOMAIN=app.confluere.xyz NEXT_PUBLIC_DEFAULT_CHAIN_ID=998 NEXT_PUBLIC_RPC_URL_998=https://rpcs.chain.link/hyperevm/testnet NEXT_PUBLIC_RPC_URL_10143=https://testnet-rpc.monad.xyz +NEXT_PUBLIC_RPC_URL_11155111=https://ethereum-sepolia-rpc.publicnode.com NEXT_PUBLIC_REOWN_PROJECT_ID= # optional server-side log sources for the chain index, "url|blockRange|concurrency,..." per chain LOGS_RPC_998= LOGS_RPC_10143= +# Sepolia: publicnode keeps only ~10,000 blocks of logs, so scan with a full-history source, e.g. +# https://11155111.rpc.thirdweb.com|1000|2 (1,000-block cap) or an archive provider of your own. +LOGS_RPC_11155111= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f9167cc..781a8d0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -13,6 +13,7 @@ services: NEXT_PUBLIC_DEFAULT_CHAIN_ID: ${NEXT_PUBLIC_DEFAULT_CHAIN_ID:-998} NEXT_PUBLIC_RPC_URL_998: ${NEXT_PUBLIC_RPC_URL_998:-https://rpcs.chain.link/hyperevm/testnet} NEXT_PUBLIC_RPC_URL_10143: ${NEXT_PUBLIC_RPC_URL_10143:-https://testnet-rpc.monad.xyz} + NEXT_PUBLIC_RPC_URL_11155111: ${NEXT_PUBLIC_RPC_URL_11155111:-https://ethereum-sepolia-rpc.publicnode.com} NEXT_PUBLIC_REOWN_PROJECT_ID: ${NEXT_PUBLIC_REOWN_PROJECT_ID:-} image: confluere-web:latest restart: unless-stopped @@ -21,6 +22,7 @@ services: # optional server-side log sources, "url|blockRange|concurrency,..." per chain LOGS_RPC_998: ${LOGS_RPC_998:-} LOGS_RPC_10143: ${LOGS_RPC_10143:-} + LOGS_RPC_11155111: ${LOGS_RPC_11155111:-} volumes: - web-index:/data expose: diff --git a/deploy/web.Dockerfile b/deploy/web.Dockerfile index 735ca6c..f62b492 100644 --- a/deploy/web.Dockerfile +++ b/deploy/web.Dockerfile @@ -12,10 +12,12 @@ ENV npm_config_fetch_retries=6 npm_config_fetch_retry_mintimeout=20000 npm_confi ARG NEXT_PUBLIC_DEFAULT_CHAIN_ID=998 ARG NEXT_PUBLIC_RPC_URL_998=https://rpcs.chain.link/hyperevm/testnet ARG NEXT_PUBLIC_RPC_URL_10143=https://testnet-rpc.monad.xyz +ARG NEXT_PUBLIC_RPC_URL_11155111=https://ethereum-sepolia-rpc.publicnode.com ARG NEXT_PUBLIC_REOWN_PROJECT_ID= ENV NEXT_PUBLIC_DEFAULT_CHAIN_ID=$NEXT_PUBLIC_DEFAULT_CHAIN_ID \ NEXT_PUBLIC_RPC_URL_998=$NEXT_PUBLIC_RPC_URL_998 \ NEXT_PUBLIC_RPC_URL_10143=$NEXT_PUBLIC_RPC_URL_10143 \ + NEXT_PUBLIC_RPC_URL_11155111=$NEXT_PUBLIC_RPC_URL_11155111 \ NEXT_PUBLIC_REOWN_PROJECT_ID=$NEXT_PUBLIC_REOWN_PROJECT_ID \ NEXT_TELEMETRY_DISABLED=1 COPY . . From 25fcddd55b6516d355ac5fe0a9177dc7f47c0e5e Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:16 -0400 Subject: [PATCH 033/169] feat(web): Sepolia as a selectable network Chain table entry (ETH, ?chain=sepolia, Etherscan links, RPC override with publicnode then thirdweb), wagmi / AppKit networks derived from it, and the network menu row with its name. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/layout/NetworkMenu.tsx | 17 ++++++++---- apps/web/src/lib/chains.ts | 26 ++++++++++++++++--- apps/web/src/lib/format.ts | 2 +- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/layout/NetworkMenu.tsx b/apps/web/src/components/layout/NetworkMenu.tsx index 2e10041..89be8af 100644 --- a/apps/web/src/components/layout/NetworkMenu.tsx +++ b/apps/web/src/components/layout/NetworkMenu.tsx @@ -23,6 +23,12 @@ const KNOWN: Record = { 1: "Ethereum", 10: "OP Mainnet", 56: "BN /** Name of a common EVM chain id, for "Your wallet is on Ethereum" (no full chain list in the bundle). */ const anyChainName = (id: number | undefined) => (id !== undefined && KNOWN[id]) || "another network"; +/** The menu's second line for a network: its open markets, or that it has no deployment yet. */ +export function marketLine(deployed: boolean, open: number | undefined): string { + if (!deployed) return "Not deployed yet"; + return open === undefined ? "Checking markets…" : open > 0 ? `${open} market${open > 1 ? "s" : ""} open` : "No open markets"; +} + /** Markets with a maturity still ahead, per chain, from the server index (no chain reads, so the menu opens instantly). */ export function openMarketCount(idx: PoolIndex | undefined, nowS: number): number | undefined { if (!idx) return undefined; @@ -45,7 +51,7 @@ function useMarketCounts(chains: ChainMeta[]) { })), }); const now = Date.now() / 1000; - return chains.map((c, i) => ({ chain: c, open: openMarketCount(qs[i]?.data, now) })); + return chains.map((c, i) => ({ chain: c, deployed: !!deploymentOf(c.id), open: openMarketCount(qs[i]?.data, now) })); } /** Chain health in one line, for the bottom of the menu. */ @@ -64,13 +70,14 @@ function HealthLine() { } /** - * Network switch: works without a wallet; with one, asks the wallet to follow. The pill itself carries the wallet's + * Network switch: works without a wallet; with one, asks the wallet to follow. Every served network is listed, one + * without a deployment yet (Sepolia before its deploy) too: picking it shows the "not deployed yet" page. The pill itself carries the wallet's * wrong-network state ("Wrong network", warn colour) and a spinner while the wallet is asked. */ export function NetworkMenu({ compact }: { compact?: boolean }) { const { chainId, meta, walletOnOtherChain, walletChainId } = useActiveChain(); const { switchNetwork, switching } = useSwitchNetwork(); - const rows = useMarketCounts(ENABLED.filter((c) => !!deploymentOf(c.id))); + const rows = useMarketCounts(ENABLED); return ( {(close) => (
- {rows.map(({ chain: c, open }) => { + {rows.map(({ chain: c, deployed, open }) => { const on = c.id === chainId; return ( diff --git a/apps/web/src/lib/chains.ts b/apps/web/src/lib/chains.ts index 584ca10..88beb9b 100644 --- a/apps/web/src/lib/chains.ts +++ b/apps/web/src/lib/chains.ts @@ -2,14 +2,18 @@ * Chain registry (pure, no React): which networks the app serves, their RPCs, deployments and display names. * Every data path looks its chain up here by id; nothing reads a build-time "the chain" constant. * - * - HyperEVM testnet (998) and Monad testnet (10143) are always on; anvil (31337) only with NEXT_PUBLIC_ENABLE_ANVIL=1. + * - HyperEVM testnet (998), Monad testnet (10143) and Ethereum Sepolia (11155111) are always on; anvil (31337) only + * with NEXT_PUBLIC_ENABLE_ANVIL=1. A served network may have no deployment yet (Sepolia until deployments/sepolia.json + * exists and `pnpm codegen` ran): it is still selectable, `deploymentOf` is undefined and the screens show a calm + * "not deployed yet" state (components/layout/NotDeployed); it lights up with the next build that has the deployment. * - Default read chain: NEXT_PUBLIC_DEFAULT_CHAIN_ID, else 998 (it has live graduated markets). * - RPC per chain: NEXT_PUBLIC_RPC_URL_ overrides the primary; HyperEVM defaults to Chainlink's public endpoint - * (the official one rate-limits per IP), with the official endpoint as a fallback. Multicall3 exists on both - * testnets (eth_getCode verified 2026-10-12), so reads batch into one eth_call. + * (the official one rate-limits per IP), with the official endpoint as a fallback; Sepolia to publicnode, then + * thirdweb (viem's default). Multicall3 exists on every served testnet (eth_getCode verified 2026-10-12, Sepolia's + * is in viem's chain definition), so reads batch into one eth_call. */ import { deployments, type Deployment } from "@confluere/sdk"; -import { anvil, hyperliquidEvmTestnet, monadTestnet } from "viem/chains"; +import { anvil, hyperliquidEvmTestnet, monadTestnet, sepolia } from "viem/chains"; import type { Chain } from "viem"; import { isDemo } from "./demo/mode"; import { demoDeploymentOf } from "./demo/deployments"; @@ -18,6 +22,7 @@ export const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11" as const; export const HYPER_TESTNET = 998; export const MONAD_TESTNET = 10143; +export const SEPOLIA = 11155111; export const ANVIL = 31337; const hyperTestnet: Chain = { @@ -53,6 +58,7 @@ const env = (k: string): string | undefined => { const ENV: Record = { NEXT_PUBLIC_RPC_URL_998: process.env.NEXT_PUBLIC_RPC_URL_998, NEXT_PUBLIC_RPC_URL_10143: process.env.NEXT_PUBLIC_RPC_URL_10143, + NEXT_PUBLIC_RPC_URL_11155111: process.env.NEXT_PUBLIC_RPC_URL_11155111, NEXT_PUBLIC_RPC_URL_31337: process.env.NEXT_PUBLIC_RPC_URL_31337, NEXT_PUBLIC_ENABLE_ANVIL: process.env.NEXT_PUBLIC_ENABLE_ANVIL, NEXT_PUBLIC_DEFAULT_CHAIN_ID: process.env.NEXT_PUBLIC_DEFAULT_CHAIN_ID, @@ -61,6 +67,8 @@ const ENV: Record = { const uniq = (xs: (string | undefined)[]) => [...new Set(xs.filter((x): x is string => !!x))]; export const CHAINLINK_HYPER_TESTNET_RPC = "https://rpcs.chain.link/hyperevm/testnet"; +/** Sepolia reads: publicnode (no key, generous limits; its log history is pruned, see lib/server/chainIndex). */ +export const PUBLICNODE_SEPOLIA_RPC = "https://ethereum-sepolia-rpc.publicnode.com"; const ALL: ChainMeta[] = [ { @@ -83,6 +91,16 @@ const ALL: ChainMeta[] = [ slug: "monad", rpcs: uniq([env("NEXT_PUBLIC_RPC_URL_10143"), "https://testnet-rpc.monad.xyz"]), }, + { + id: SEPOLIA, + chain: sepolia, + name: "Sepolia", + label: "Sepolia testnet", + native: "ETH", + testnet: true, + slug: "sepolia", + rpcs: uniq([env("NEXT_PUBLIC_RPC_URL_11155111"), PUBLICNODE_SEPOLIA_RPC, ...sepolia.rpcUrls.default.http]), + }, { id: ANVIL, chain: anvil, diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 8b454e8..116f4f3 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -155,7 +155,7 @@ export function every(seconds: number): string { export function nativeSymbol(chainId: number): string { if (chainId === 143 || chainId === 10143) return "MON"; if (chainId === 999 || chainId === 998) return "HYPE"; - return "ETH"; + return "ETH"; // Ethereum and Sepolia (11155111), anvil } /** Vault symbols say NATIVE; show the chain's coin instead (rvNATIVE-Pegged -> rvMON-Pegged). */ From 1c4a4cefa57a6c09ef035f9a640026d1f8c9676e Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 034/169] feat(web): pay with native ETH on Sepolia, wrapped to WETH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same flow as HYPE→WHYPE and MON→WMON; 0.01 ETH kept for gas. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/native.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/native.ts b/apps/web/src/lib/native.ts index ed0cdd1..5bdae3c 100644 --- a/apps/web/src/lib/native.ts +++ b/apps/web/src/lib/native.ts @@ -1,8 +1,8 @@ "use client"; /** - * The network's own coin as a payable asset (IA v3.1 item 11): HYPE on HyperEVM, MON on Monad, listed next to WHYPE / - * WMON. Markets trade the wrapped token; paying in the coin adds one "Wrap" step at the front of the same button flow + * The network's own coin as a payable asset (IA v3.1 item 11): HYPE on HyperEVM, MON on Monad, ETH on Sepolia, listed + * next to WHYPE / WMON / WETH. Markets trade the wrapped token; paying in the coin adds one "Wrap" step at the front of the same button flow * (WETH9-style `deposit()`, 1:1). There is no separate wrap or unwrap feature, no toggle, and nothing is unwrapped on * the way back: proceeds and refunds arrive as WHYPE / WMON and are shown as such. * @@ -32,8 +32,12 @@ export const isWrappedNative = (chainId: number, token: string | undefined) => ! /** The coin as a token row (zero address, 18 decimals, "HYPE" / "MON"). */ export const nativeTokenOf = (chainId: number): Token => ({ address: NATIVE, symbol: chainMeta(chainId).native, decimals: 18 }); -/** Kept back by Max so the wallet can still pay for the transactions (gas is paid in the same coin). */ -export const GAS_RESERVE: Record = { 998: 2n * 10n ** 16n, 10143: 2n * 10n ** 17n }; +/** + * Kept back by Max so the wallet can still pay for the transactions (gas is paid in the same coin). Sepolia: 0.01 ETH; + * its fork prices deployed code and big calls high (a 48-order Router batch used 20M gas on a fork, 2026-10-23), so the + * reserve covers a few such calls at 0.1 gwei. + */ +export const GAS_RESERVE: Record = { 998: 2n * 10n ** 16n, 10143: 2n * 10n ** 17n, 11155111: 10n ** 16n }; export const gasReserve = (chainId: number) => GAS_RESERVE[chainId] ?? 10n ** 16n; /** Spendable coin: the balance minus the gas reserve (never negative). */ From fca83ad8a45211dc95a65296dc9de9acc31322d5 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 035/169] feat(web): Sepolia chain and token logos (ETH, WETH, wstETH, USDC) Co-Authored-By: Claude Opus 5.5 --- apps/web/public/chains/11155111.png | Bin 0 -> 2984 bytes ...00000000000000000000000000000000000000.png | Bin 0 -> 2984 bytes ...7d4b196cb0c7b01d743fbc6116a902379c7238.svg | 6 ++++++ ...2381a3fbd3fafa77b3a7be693342618240067b.png | Bin 0 -> 41583 bytes apps/web/src/lib/logos.ts | 20 +++++++++++++----- 5 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 apps/web/public/chains/11155111.png create mode 100644 apps/web/public/tokens/11155111/0x0000000000000000000000000000000000000000.png create mode 100644 apps/web/public/tokens/11155111/0x1c7d4b196cb0c7b01d743fbc6116a902379c7238.svg create mode 100644 apps/web/public/tokens/11155111/0xb82381a3fbd3fafa77b3a7be693342618240067b.png diff --git a/apps/web/public/chains/11155111.png b/apps/web/public/chains/11155111.png new file mode 100644 index 0000000000000000000000000000000000000000..bf957d38c766d5e73df222d2f55eff5c8d00fac0 GIT binary patch literal 2984 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 Ca`wOg literal 0 HcmV?d00001 diff --git a/apps/web/public/tokens/11155111/0x0000000000000000000000000000000000000000.png b/apps/web/public/tokens/11155111/0x0000000000000000000000000000000000000000.png new file mode 100644 index 0000000000000000000000000000000000000000..bf957d38c766d5e73df222d2f55eff5c8d00fac0 GIT binary patch literal 2984 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 Ca`wOg literal 0 HcmV?d00001 diff --git a/apps/web/public/tokens/11155111/0x1c7d4b196cb0c7b01d743fbc6116a902379c7238.svg b/apps/web/public/tokens/11155111/0x1c7d4b196cb0c7b01d743fbc6116a902379c7238.svg new file mode 100644 index 0000000..0fac44c --- /dev/null +++ b/apps/web/public/tokens/11155111/0x1c7d4b196cb0c7b01d743fbc6116a902379c7238.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/tokens/11155111/0xb82381a3fbd3fafa77b3a7be693342618240067b.png b/apps/web/public/tokens/11155111/0xb82381a3fbd3fafa77b3a7be693342618240067b.png new file mode 100644 index 0000000000000000000000000000000000000000..49ab895e0f9d94041a39446ef00f4a5519ed5173 GIT binary patch literal 41583 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 = { // kHYPE (Kinetiq staked HYPE, CoinGecko's mark); the address is the demo's (lib/demo/deployments DEMO_ADDR.khype) "0xd3a0000000000000000000000000000000000a01": "/tokens/998/0xd3a0000000000000000000000000000000000a01.png", }, + 11155111: { + [ZERO]: "/tokens/11155111/0x0000000000000000000000000000000000000000.png", + "0xfff9976782d46cc05630d1f6ebab18b2324d6b14": "/tokens/11155111/0x0000000000000000000000000000000000000000.png", // WETH: the ETH logo plus the "W" mark + "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238": "/tokens/11155111/0x1c7d4b196cb0c7b01d743fbc6116a902379c7238.svg", + "0xb82381a3fbd3fafa77b3a7be693342618240067b": "/tokens/11155111/0xb82381a3fbd3fafa77b3a7be693342618240067b.png", + }, }; /** Symbol fallback for callers that only know the symbol, and for local mocks with the same symbols (anvil). */ @@ -32,6 +39,9 @@ const BY_SYMBOL: Record = { HYPE: TOKENS[998]![ZERO]!, WHYPE: TOKENS[998]!["0x5555555555555555555555555555555555555555"]!, KHYPE: TOKENS[998]!["0xd3a0000000000000000000000000000000000a01"]!, + ETH: TOKENS[11155111]![ZERO]!, + WETH: TOKENS[11155111]!["0xfff9976782d46cc05630d1f6ebab18b2324d6b14"]!, + WSTETH: TOKENS[11155111]!["0xb82381a3fbd3fafa77b3a7be693342618240067b"]!, }; export function tokenLogo(chainId: number | undefined, address?: string, symbol?: string): string | undefined { @@ -45,9 +55,9 @@ export function tokenLogo(chainId: number | undefined, address?: string, symbol? /** The token list's own wrapped-MON mark, kept for reference; the UI draws a small badge instead. */ export const WRAPPED_MARK_WMON = "/tokens/10143/0xfb8bf4c1cc7a94c73d209a149ea2abea852bc541.svg"; -const CHAINS: Record = { 10143: "/chains/10143.svg", 998: "/chains/998.png" }; +const CHAINS: Record = { 10143: "/chains/10143.svg", 998: "/chains/998.png", 11155111: "/chains/11155111.png" }; export const chainLogo = (id: number): string | undefined => CHAINS[id]; -const NAMES: Record = { MON: "Monad", WMON: "Wrapped MON", SHMON: "FastLane staked MON", USDC: "USD Coin", HYPE: "Hyperliquid", WHYPE: "Wrapped HYPE", KHYPE: "Kinetiq staked HYPE" }; +const NAMES: Record = { MON: "Monad", WMON: "Wrapped MON", SHMON: "FastLane staked MON", USDC: "USD Coin", HYPE: "Hyperliquid", WHYPE: "Wrapped HYPE", KHYPE: "Kinetiq staked HYPE", ETH: "Ether", WETH: "Wrapped Ether", WSTETH: "Lido wrapped staked ETH" }; /** Full token name for selector rows (launch tokens); undefined for others. */ export const tokenName = (symbol?: string) => (symbol ? NAMES[symbol.toUpperCase()] : undefined); From 40dc8eb1aecef313dba544b1988cc1c88c6240ea Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 036/169] feat(web): Sepolia token rules and vault names WETH wraps ETH 1:1, USDC from the Circle faucet, wstETH only in the Loop flow; vaults read ETH Pegged, USDC Conservative and USDC High-yield; the reference label names the mainnet ETH / USD spot. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/tokens.ts | 20 ++++++++++++++------ apps/web/src/lib/vocab.ts | 4 +++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/tokens.ts b/apps/web/src/lib/tokens.ts index 7a0f11c..dd7ac5b 100644 --- a/apps/web/src/lib/tokens.ts +++ b/apps/web/src/lib/tokens.ts @@ -2,9 +2,9 @@ /** * Getting a token the selected market needs, as the smallest possible step (spec requirement (a)): a link to Circle's - * faucet for testnet USDC; for WHYPE / WMON the step is "wrap", which the UI shows as "Pay with HYPE / MON instead" + * faucet for testnet USDC; for WHYPE / WMON / WETH the step is "wrap", which the UI shows as "Pay with HYPE / MON instead" * (IA v3.1 item 11: the native coin is a pay-with choice and the wrap runs inside the card's own button flow, never as - * a separate wrap button). Never staking: an LST (shMON) is only mentioned inside the Loop flow on a staked-token + * a separate wrap button). Never staking: an LST (shMON, wstETH) is only mentioned inside the Loop flow on a staked-token * market, through `stakeStep`. Mock tokens (anvil) have a public `mint`, proven by a dry run before it is offered. */ import { useQuery } from "@tanstack/react-query"; @@ -24,7 +24,7 @@ export const stakeAbi = parseAbi(["function deposit(uint256 assets, address rece export type Obtain = | { kind: "wrap"; note: string } - | { kind: "stake"; note: string; href?: string } + | { kind: "stake"; note: string; href?: string; /** Who stakes it ("FastLane", "Lido"); the Loop flow names them. */ provider: string } | { kind: "link"; note: string; href: string }; export const CIRCLE_FAUCET = "https://faucet.circle.com"; @@ -34,7 +34,14 @@ const KNOWN: Record> = { 10143: { "0xfb8bf4c1cc7a94c73d209a149ea2abea852bc541": { kind: "wrap", note: "WMON is MON, wrapped 1:1." }, "0x534b2f3a21130d7a60830c2df862319e593943a3": { kind: "link", note: "Testnet USDC comes from Circle's faucet.", href: CIRCLE_FAUCET }, - "0x282bddff5e58793acab65438b257dbd15a8745c9": { kind: "stake", note: "shMON is staked MON (FastLane).", href: "https://shmonad.xyz" }, + "0x282bddff5e58793acab65438b257dbd15a8745c9": { kind: "stake", note: "shMON is staked MON (FastLane).", href: "https://shmonad.xyz", provider: "FastLane" }, + }, + // Sepolia: Uniswap's canonical WETH9, Circle's USDC, Lido's wstETH (staked ETH, wrapped). Lido has no Sepolia staking + // site, so no link: the note says what to do (ETH sent to the wstETH contract is staked and wrapped in one step). + 11155111: { + "0xfff9976782d46cc05630d1f6ebab18b2324d6b14": { kind: "wrap", note: "WETH is ETH, wrapped 1:1." }, + "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238": { kind: "link", note: "Testnet USDC comes from Circle's faucet.", href: CIRCLE_FAUCET }, + "0xb82381a3fbd3fafa77b3a7be693342618240067b": { kind: "stake", note: "Send Sepolia ETH to the wstETH contract 0xB82381A3fBD3FaFA77B3a7bE693342618240067b; it stakes and wraps it.", provider: "Lido" }, }, 998: { "0x5555555555555555555555555555555555555555": { kind: "wrap", note: "WHYPE is HYPE, wrapped 1:1." }, @@ -45,6 +52,7 @@ const KNOWN: Record> = { export const NATIVE_FAUCET: Record = { 10143: "https://faucet.monad.xyz", 998: "https://app.hyperliquid-testnet.xyz/drip", + 11155111: "https://cloud.google.com/application/web3/faucet/ethereum/sepolia", }; /** @@ -74,9 +82,9 @@ export function getTokenStep(chainId: number, t: { address: Address; symbol: str } /** Loop flow only: one line on how the LST is made (staking happens outside Confluere). */ -export function stakeStep(chainId: number, lst: Address): { note: string; href?: string } | undefined { +export function stakeStep(chainId: number, lst: Address): { note: string; href?: string; provider: string } | undefined { const k = KNOWN[chainId]?.[lst.toLowerCase()]; - return k?.kind === "stake" ? { note: k.note, href: k.href } : undefined; + return k?.kind === "stake" ? { note: k.note, href: k.href, provider: k.provider } : undefined; } /** Transaction for the wrap step (WETH9-style deposit(), 1:1). */ diff --git a/apps/web/src/lib/vocab.ts b/apps/web/src/lib/vocab.ts index 7b20409..1164f72 100644 --- a/apps/web/src/lib/vocab.ts +++ b/apps/web/src/lib/vocab.ts @@ -65,7 +65,7 @@ export function vaultSentences(v: Pick, quote /** "1 WHYPE per 34.00 USDC": what backs a lending position, per buy-back price. */ export const backedBy = (asset: string, buyBack: string, quote: string) => `1 ${asset} per ${buyBack} ${quote}`; -/** Network names as the switcher shows them: "HyperEVM Testnet", "Monad Testnet", "Local (anvil)". */ +/** Network names as the switcher shows them: "HyperEVM Testnet", "Monad Testnet", "Sepolia Testnet", "Local (anvil)". */ export function networkName(m: { id: number; name: string; testnet: boolean }): string { if (m.id === 31337) return "Local (anvil)"; return m.testnet ? `${m.name} Testnet` : m.name; @@ -79,6 +79,8 @@ export function priceSource(chainId: number, asset: string, quote: string, o: { if (lst && native && a === lst.toLowerCase() && q === native.toLowerCase()) return `${o.assetSymbol ?? "the staked token"}'s own rate`; if (chainId === 998 || chainId === 999) return "HyperCore spot"; if (chainId === 10143 || chainId === 143) return "Kuru order book"; + // Sepolia has no market worth quoting: the reference is Ethereum mainnet's ETH / USD spot (lib/refPrice) + if (chainId === 11155111 || chainId === 1) return "ETH / USD spot (mainnet)"; return "reference feed"; } From 24109109c855f20ac477d33a2a1af11db202b758 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 037/169] feat(web): /api/ref/spot, a display-only ETH/USD reference from Coinbase then Kraken Cached 30 s, answers {price:null} when both fail so the page simply shows no reference; never read by contracts. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/api/ref/spot/route.ts | 30 ++++++++++++++++++++++++++ apps/web/src/lib/spot.ts | 27 +++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 apps/web/src/app/api/ref/spot/route.ts create mode 100644 apps/web/src/lib/spot.ts diff --git a/apps/web/src/app/api/ref/spot/route.ts b/apps/web/src/app/api/ref/spot/route.ts new file mode 100644 index 0000000..08923d3 --- /dev/null +++ b/apps/web/src/app/api/ref/spot/route.ts @@ -0,0 +1,30 @@ +/** + * Display-only mainnet spot price, for networks without a market worth quoting (Sepolia: ETH / USD for WETH / USDC and, + * times wstETH's own rate, wstETH / USDC). GET /api/ref/spot?symbol=ETH_USD -> { price, source }. Asks Coinbase, then + * Kraken; each answer is cached for 30 s, and when both fail the route answers `{ price: null }` (status 502) so the + * screens simply show no reference. Never used on-chain. + */ +import { SPOT_PAIRS, coinbaseSpot, krakenLast } from "@/lib/spot"; + +const TTL_S = 30; + +async function ask(url: string, parse: (j: unknown) => number | null): Promise { + try { + const r = await fetch(url, { next: { revalidate: TTL_S }, signal: AbortSignal.timeout(4_000) }); + return r.ok ? parse(await r.json()) : null; + } catch { + return null; + } +} + +export async function GET(req: Request) { + const symbol = new URL(req.url).searchParams.get("symbol") ?? ""; + const pair = Object.hasOwn(SPOT_PAIRS, symbol) ? SPOT_PAIRS[symbol] : undefined; // not toString / __proto__ + if (!pair) return Response.json({ price: null }, { status: 400 }); + const cache = { "cache-control": `public, max-age=${TTL_S}` }; + const cb = await ask(`https://api.coinbase.com/v2/prices/${pair.coinbase}/spot`, coinbaseSpot); + if (cb !== null) return Response.json({ price: cb, source: "coinbase" }, { headers: cache }); + const kr = await ask(`https://api.kraken.com/0/public/Ticker?pair=${pair.kraken}`, krakenLast); + if (kr !== null) return Response.json({ price: kr, source: "kraken" }, { headers: cache }); + return Response.json({ price: null }, { status: 502 }); +} diff --git a/apps/web/src/lib/spot.ts b/apps/web/src/lib/spot.ts new file mode 100644 index 0000000..c55067e --- /dev/null +++ b/apps/web/src/lib/spot.ts @@ -0,0 +1,27 @@ +/** + * Mainnet spot prices for chains whose own markets are not worth quoting (Sepolia), display only. Pure parsers for the + * two public sources /api/ref/spot asks, in order: Coinbase's spot price, then Kraken's last trade. Neither needs a key. + */ + +/** Pairs the route serves: `_` as the client asks for them, mapped to each source's own pair name. */ +export const SPOT_PAIRS: Record = { + ETH_USD: { coinbase: "ETH-USD", kraken: "ETHUSD" }, +}; + +const positive = (x: unknown): number | null => { + const n = typeof x === "string" ? Number(x) : typeof x === "number" ? x : NaN; + return Number.isFinite(n) && n > 0 ? n : null; +}; + +/** Coinbase `GET /v2/prices//spot` -> `{ data: { amount: "2379.84" } }`. */ +export function coinbaseSpot(j: unknown): number | null { + return positive((j as { data?: { amount?: unknown } } | null)?.data?.amount); +} + +/** Kraken `GET /0/public/Ticker?pair=` -> `{ error: [], result: { XETHZUSD: { c: ["2380.06", vol] } } }` (last trade). */ +export function krakenLast(j: unknown): number | null { + const r = j as { error?: unknown[]; result?: Record } | null; + if (!r?.result || (r.error && r.error.length > 0)) return null; + const first = Object.values(r.result)[0]; + return positive(first?.c?.[0]); +} From 93c3eb56d6fc49915330ff3803af3c9149e20868 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 038/169] feat(web): Sepolia reference prices WETH/USDC from mainnet ETH/USD, wstETH/WETH from the chain's own stEthPerToken, wstETH/USDC their product. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/refPrice.ts | 52 ++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/refPrice.ts b/apps/web/src/lib/refPrice.ts index 5739203..7541f69 100644 --- a/apps/web/src/lib/refPrice.ts +++ b/apps/web/src/lib/refPrice.ts @@ -11,6 +11,9 @@ * tokens stand in for MON and USDC, so the mainnet MON_USDC book is the reference. * - An LST against its own native coin (shMON/WMON on Monad): the LST's ERC-4626 `convertToAssets(1 LST)`, i.e. how * much native coin one LST redeems for. Still display only. + * - Sepolia (and Ethereum): no on-chain book worth quoting, so WETH is priced at Ethereum mainnet's ETH / USD spot + * (/api/ref/spot, Coinbase then Kraken, cached 30 s), USDC at 1 USD, and wstETH at its own `stEthPerToken()` read on + * the chain itself (stETH ≈ ETH), so wstETH / WETH needs no off-chain price and wstETH / USDC is the rate × ETH / USD. * - Elsewhere: an optional HTTP endpoint (NEXT_PUBLIC_REF_PRICE_URL?asset="e= -> {"price": number}). */ import { useQuery } from "@tanstack/react-query"; @@ -22,6 +25,31 @@ import { appFetch } from "./demo/client"; const SPOT_PX = "0x0000000000000000000000000000000000000808" as const; const erc4626Abi = parseAbi(["function convertToAssets(uint256 shares) view returns (uint256)"]); +const wstEthAbi = parseAbi(["function stEthPerToken() view returns (uint256)"]); + +/** Chains priced through mainnet ETH / USD (lib/spot): Ethereum and Sepolia. */ +export const ETH_REF_CHAINS = [1, 11155111]; + +/** What a Sepolia / Ethereum launch token is worth in: ETH itself (WETH), dollars (USDC), or wstETH (its rate × ETH). */ +export type EthLeg = "eth" | "usd" | "lst"; + +export function ethLeg(chainId: number, token: string): EthLeg | undefined { + if (!ETH_REF_CHAINS.includes(chainId) || !token) return undefined; + const t = launchTokensOf(chainId); + const x = token.toLowerCase(); + if (t.native && x === t.native.toLowerCase()) return "eth"; + if (t.usdc && x === t.usdc.toLowerCase()) return "usd"; + if (t.lst && x === t.lst.toLowerCase()) return "lst"; + return undefined; +} + +/** Price of 1 asset in quote from the legs' values: ETH = `ethUsd` dollars, wstETH = `lstRate` ETH, USDC = 1 dollar. */ +export function ethLegPrice(a: EthLeg, q: EthLeg, ethUsd: number | null, lstRate: number | null): number | null { + const inEth = (l: EthLeg) => (l === "eth" ? 1 : l === "lst" ? lstRate : ethUsd && ethUsd > 0 ? 1 / ethUsd : null); + const va = inEth(a); + const vq = inEth(q); + return va !== null && vq !== null && vq > 0 && va > 0 ? va / vq : null; +} type CorePair = { spotIndex: number; szDecimals: number }; @@ -54,6 +82,13 @@ export function spotPxToPrice(raw: bigint, szDecimals: number): number { return Number(raw) / 10 ** (8 - szDecimals); } +/** Mainnet spot from this app's /api/ref/spot (null when the route has no price). */ +async function spotPrice(symbol: string): Promise { + const r = await appFetch(`/api/ref/spot?symbol=${symbol}`); + const j = r.ok ? ((await r.json()) as { price?: number | null }) : {}; + return typeof j.price === "number" ? j.price : null; +} + async function corePrice(client: PublicClient | undefined, pair: CorePair): Promise { const data = encodeAbiParameters([{ type: "uint32" }], [pair.spotIndex]); const res = await client!.call({ to: SPOT_PX, data }); @@ -75,13 +110,26 @@ export function useRefPrice(asset: Address | undefined, quote: Address | undefin const kA = kuruSymbol(chainId, a); const kQ = kuruSymbol(chainId, q); const viaKuru = !viaCore && !viaCoreInv && !!kA && !!kQ; + const eA = ethLeg(chainId, a); + const eQ = ethLeg(chainId, q); + const viaEth = !!eA && !!eQ && eA !== eQ; const lt = launchTokensOf(chainId); - const viaLst = !viaCore && !viaCoreInv && !viaKuru && !!lt.lst && !!lt.native && a === lt.lst.toLowerCase() && q === lt.native.toLowerCase(); + const viaLst = !viaCore && !viaCoreInv && !viaKuru && !viaEth && !!lt.lst && !!lt.native && a === lt.lst.toLowerCase() && q === lt.native.toLowerCase(); return useQuery({ queryKey: ["ref", chainId, a, q], - enabled: !!client && !!a && !!q && (viaCore || viaCoreInv || viaKuru || viaLst || !!REF_PRICE_URL), + enabled: !!client && !!a && !!q && (viaCore || viaCoreInv || viaKuru || viaEth || viaLst || !!REF_PRICE_URL), refetchInterval: 15_000, queryFn: async (): Promise => { + if (viaEth) { + // ETH / USD only when a dollar leg is involved (wstETH / WETH is the rate alone); the rate only for wstETH + const needUsd = eA === "usd" || eQ === "usd"; + const needLst = eA === "lst" || eQ === "lst"; + const [usd, rate] = await Promise.all([ + needUsd ? spotPrice("ETH_USD") : Promise.resolve(null), + needLst ? client!.readContract({ address: lt.lst!, abi: wstEthAbi, functionName: "stEthPerToken" }).then((x) => Number(x as bigint) / 1e18) : Promise.resolve(null), + ]); + return ethLegPrice(eA!, eQ!, usd, rate); + } if (viaCoreInv) { const pq = await corePrice(client, coreQ!); return pq > 0 ? 1 / pq : null; From 5f30ac5b3a8746be9184c11ab8986ceaff5e3fa6 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 039/169] feat(web): Loop staking rate falls back to wstETH getWstETHByStETH Lido's wstETH is not an ERC-4626 vault, so the shMON-style convertToShares read returns nothing for it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/data.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/data.ts b/apps/web/src/lib/data.ts index 67f8efe..85e6049 100644 --- a/apps/web/src/lib/data.ts +++ b/apps/web/src/lib/data.ts @@ -700,8 +700,12 @@ export function useGrid() { } const erc4626Abi = parseAbi(["function convertToShares(uint256 assets) view returns (uint256)"]); +const wstEthAbi = parseAbi(["function getWstETHByStETH(uint256 stETHAmount) view returns (uint256)"]); -/** ERC-4626 `convertToShares(assets)` of a token (the loop's LST staking rate); null when the token is not a vault. */ +/** + * ERC-4626 `convertToShares(assets)` of a token (the loop's LST staking rate); for Lido's wstETH (Sepolia), which is no + * vault, `getWstETHByStETH(assets)` (stETH ≈ ETH 1:1). Null when the token is neither. + */ export function useConvertToShares(token: Address | undefined, assets: bigint | undefined) { const { chainId, client } = useChainCtx(); return useQuery({ @@ -712,7 +716,11 @@ export function useConvertToShares(token: Address | undefined, assets: bigint | try { return (await client!.readContract({ address: token!, abi: erc4626Abi, functionName: "convertToShares", args: [assets!] })) as bigint; } catch { - return null; + try { + return (await client!.readContract({ address: token!, abi: wstEthAbi, functionName: "getWstETHByStETH", args: [assets!] })) as bigint; + } catch { + return null; + } } }, }); From bae2f760c57f4826e37c967a4fa62a785067b773 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 040/169] fix(web): Loop card names the LST provider per token instead of always FastLane Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/borrow/LoopCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/borrow/LoopCard.tsx b/apps/web/src/components/borrow/LoopCard.tsx index c53416c..273fc8a 100644 --- a/apps/web/src/components/borrow/LoopCard.tsx +++ b/apps/web/src/components/borrow/LoopCard.tsx @@ -117,8 +117,8 @@ export function LoopCard({ pool, A, Q, embedded = false, top }: { pool: PoolRow; onMax={address && spendable ? () => setStart(toInput(spendable, A.decimals)) : undefined} /> {short && stake && ( -

- You need {A.symbol}. Stake {deployment ? Q.symbol.replace(/^W/, "") : ""} at FastLane (opens their site). +

+ You need {A.symbol}. {stake.href ? <>Stake {deployment ? Q.symbol.replace(/^W/, "") : ""} at {stake.provider} (opens their site). : stake.note} {stake.href && ( Open @@ -189,7 +189,7 @@ export function LoopCard({ pool, A, Q, embedded = false, top }: { pool: PoolRow;

  1. 1 Borrow {Q.symbol}
  2. 2 Withdraw it from Portfolio
  3. -
  4. {staking ? "3 Stake at FastLane (their site)" : `3 Turn it into ${P.symbol} where you usually trade`}
  5. +
  6. {staking ? (stake ? `3 Stake at ${stake.provider}${stake.href ? " (their site)" : `: send ${Q.symbol.replace(/^W/, "")} to the ${A.symbol} contract`}` : "3 Stake it (outside Confluere)") : `3 Turn it into ${P.symbol} where you usually trade`}
{plan &&

If you don't repay: you keep the {Q.symbol}, lenders keep the {A.symbol} you sold. No liquidation.

} @@ -258,7 +258,7 @@ export function LoopCard({ pool, A, Q, embedded = false, top }: { pool: PoolRow;
  1. 1 Borrow {Q.symbol}
  2. 2 Withdraw it from Portfolio
  3. -
  4. {staking ? "3 Stake at FastLane (their site)" : `3 Turn it into ${P.symbol} where you usually trade`}
  5. +
  6. {staking ? (stake ? `3 Stake at ${stake.provider}${stake.href ? " (their site)" : `: send ${Q.symbol.replace(/^W/, "")} to the ${A.symbol} contract`}` : "3 Stake it (outside Confluere)") : `3 Turn it into ${P.symbol} where you usually trade`}
{!open ? ( From a7803dcb6d525f678ee4ffb116f1c4c320149716 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 041/169] feat(web): server chain index scans Sepolia logs through an archive RPC in 1,000-block chunks Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/server/chainIndex.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/chainIndex.ts b/apps/web/src/lib/server/chainIndex.ts index 09aa2f6..d4393f0 100644 --- a/apps/web/src/lib/server/chainIndex.ts +++ b/apps/web/src/lib/server/chainIndex.ts @@ -15,7 +15,10 @@ import { mkdir, readFileSync, writeFile } from "./fsCompat"; import { join } from "node:path"; import { decodeEventLog, parseAbi, toEventSelector, type Hex } from "viem"; import { logRange, marketId as toMarketId } from "@confluere/sdk"; -import { CHAINLINK_HYPER_TESTNET_RPC, deploymentOf, isEnabledChain, rpcUrlsOf } from "../chains"; +import { CHAINLINK_HYPER_TESTNET_RPC, PUBLICNODE_SEPOLIA_RPC, deploymentOf, isEnabledChain, rpcUrlsOf } from "../chains"; + +/** viem's default Sepolia RPC: full log history, at most 1,000 blocks per eth_getLogs. */ +const THIRDWEB_SEPOLIA_RPC = "https://11155111.rpc.thirdweb.com"; import { fetchRpc, headOf, scanLogs, type LogSource, type RawLog, type Rpc } from "./logScan"; export const EVENTS = parseAbi([ @@ -91,6 +94,9 @@ export const orderKey = (pool: string, id: bigint | string) => `${lc(pool)}:${id * Log sources per chain, widest range first. Override with LOGS_RPC_="url|range|concurrency,...". * Monad: OnFinality's public endpoint serves 10,000-block ranges (one call at a time), thirdweb 1,000, the official * RPC 100 (logRange) at 25 req/s. HyperEVM: Chainlink's public RPC 1,000 blocks; the official one is rate-limited. + * Sepolia: thirdweb keeps the full log history at 1,000 blocks a call (logRange); publicnode, the read RPC, keeps only + * about the last 10,000 blocks of logs ("pruned history unavailable", measured 2026-10-23), so it comes after, for the + * recent chunks only. A NEXT_PUBLIC_RPC_URL_11155111 of your own (an archive node) is asked first. */ export function logSources(chainId: number): LogSource[] { const env = process.env[`LOGS_RPC_${chainId}`]; @@ -109,6 +115,14 @@ export function logSources(chainId: number): LogSource[] { ...rpcs.map((url) => ({ url, range: step, concurrency: 4 })), ]; } + if (chainId === 11155111) { + const own = rpcs.filter((u) => u !== PUBLICNODE_SEPOLIA_RPC && u !== THIRDWEB_SEPOLIA_RPC); + return [ + ...own.map((url) => ({ url, range: step, concurrency: 2 })), + { url: THIRDWEB_SEPOLIA_RPC, range: step, concurrency: 2 }, + { url: PUBLICNODE_SEPOLIA_RPC, range: step, concurrency: 2 }, + ]; + } if (chainId === 998) { const primary = rpcs[0] ?? CHAINLINK_HYPER_TESTNET_RPC; return [ From 8a15cd598075c2d463b03f8136a1836a4450c3a8 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 042/169] feat(web): API routes answer 404 notDeployed for a served network without contracts Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/server/http.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/http.ts b/apps/web/src/lib/server/http.ts index a7617b5..b713a1f 100644 --- a/apps/web/src/lib/server/http.ts +++ b/apps/web/src/lib/server/http.ts @@ -1,12 +1,19 @@ /** Small helpers shared by the index route handlers. */ import { servesChain } from "./chainIndex"; +import { deploymentOf, isEnabledChain } from "../chains"; export const NO_STORE = { "cache-control": "no-store" }; -/** `?chain=` as a served chain id, or a 400 response. */ +/** + * `?chain=` as a served chain id, or an error response: 404 `{ error, notDeployed: true }` for a network the app serves + * that has no deployment yet (Sepolia before its deploy; the client never asks then), 400 for anything else. + */ export function chainParam(req: Request): number | Response { const raw = new URL(req.url).searchParams.get("chain"); const id = Number(raw); + if (raw && Number.isInteger(id) && isEnabledChain(id) && !deploymentOf(id)) { + return Response.json({ error: `not deployed on chain ${id} yet`, notDeployed: true }, { status: 404, headers: NO_STORE }); + } if (!raw || !Number.isInteger(id) || !servesChain(id)) { return Response.json({ error: `unsupported chain ${raw ?? "(missing)"}` }, { status: 400, headers: NO_STORE }); } From 34eb0cf35947f82f62eb3a0a1b3a1f89d2627ac1 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 043/169] feat(web): calm 'not deployed yet' page state for a served network without contracts Every protocol page shows it instead of erroring, with buttons to switch network and a link to the demo; How it works and the demo entry still render. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/layout/AppShell.tsx | 10 +++- .../web/src/components/layout/NotDeployed.tsx | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/layout/NotDeployed.tsx diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index c3f9b48..2a389f7 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -12,6 +12,7 @@ import { NetworkStatus } from "../ui/NetworkStatus"; import { ToastRegion } from "../ui/Toast"; import { MoreMenu } from "./MoreMenu"; import { NetworkMenu } from "./NetworkMenu"; +import { NotDeployed, worksUndeployed } from "./NotDeployed"; import { WalletMenu, WalletPicker } from "./WalletMenu"; import { GITHUB_URL, PRIMARY, TABS, inMore, needsBack } from "./nav"; @@ -144,6 +145,13 @@ export function Footer() { ); } +/** The page, or the calm "not deployed yet" state on a served network without contracts (components/layout/NotDeployed). */ +function Page({ children }: { children: ReactNode }) { + const { deployment } = useActiveChain(); + const path = usePathname() ?? "/"; + return !deployment && !worksUndeployed(path) ? : <>{children}; +} + export function AppShell({ children }: { children: ReactNode }) { return ( <> @@ -154,7 +162,7 @@ export function AppShell({ children }: { children: ReactNode }) {
- {children} + {children}
diff --git a/apps/web/src/components/layout/NotDeployed.tsx b/apps/web/src/components/layout/NotDeployed.tsx new file mode 100644 index 0000000..a2667b1 --- /dev/null +++ b/apps/web/src/components/layout/NotDeployed.tsx @@ -0,0 +1,53 @@ +"use client"; + +/** + * A network the app serves that has no deployment yet (Sepolia until deployments/sepolia.json exists and the SDK is + * regenerated). Instead of empty screens and failing reads, every page that needs the contracts shows one calm state: + * what is true, the networks that are live, and the demo (which has a full Sepolia sample network). Nothing on this + * page reads the chain or the server index, so there is no retry line and no error. The state disappears by itself on + * the build that carries the deployment (lib/chains `deploymentOf`). + */ +import Link from "next/link"; +import { useActiveChain, useSwitchNetwork } from "@/lib/activeChain"; +import { ENABLED, chainMeta, deploymentOf } from "@/lib/chains"; +import { DEMO_PARAM } from "@/lib/demo/mode"; +import { networkName } from "@/lib/vocab"; +import { Button } from "../ui/Button"; +import { EmptyState } from "../ui/Surface"; + +/** Pages that work without a deployment: how it works, and the demo entry (its sample network has every chain). */ +export const worksUndeployed = (path: string) => path.startsWith("/how-it-works") || path.startsWith("/developers/demo"); + +export function NotDeployed() { + const { meta } = useActiveChain(); + const { switchNetwork } = useSwitchNetwork(); + const live = ENABLED.filter((c) => c.id !== meta.id && !!deploymentOf(c.id)); + return ( +
+ +

+ Confluere supports {networkName(meta)}, but its contracts are not deployed there yet. Markets, vaults and your positions on {meta.name} appear here as + soon as they are. +

+

{live.length > 0 ? `Meanwhile you can use ${live.map((c) => chainMeta(c.id).label).join(" or ")}, or look around in the demo.` : "Meanwhile you can look around in the demo."}

+ + } + actions={ + <> + {live.map((c) => ( + + ))} + + Try the demo on {meta.name} + + + } + /> +
+ ); +} From 7fad500493c0520f4c8c536a28bd74a51d87efaa Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 044/169] docs(web): How it works lists deployments per network from the deployments table Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/more/HowItWorks.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/more/HowItWorks.tsx b/apps/web/src/components/more/HowItWorks.tsx index 432805e..a87e6ee 100644 --- a/apps/web/src/components/more/HowItWorks.tsx +++ b/apps/web/src/components/more/HowItWorks.tsx @@ -3,6 +3,8 @@ /** How it works (More): what happens when you borrow, when you lend, and what never happens. Plain words only. */ import Link from "next/link"; import { Card } from "../ui"; +import { ANVIL, ENABLED, deploymentOf } from "@/lib/chains"; +import { networkName } from "@/lib/vocab"; /** One sentence, used verbatim on Borrow and here. */ export const PROMISE = "Fixed-term borrowing against your tokens. Post collateral, receive the loan now, buy the collateral back by the deadline, or keep the proceeds."; @@ -36,9 +38,24 @@ const SECURITY = [ "Contracts are immutable: no owner, no upgrade, no admin keys.", "Only privilege: a guardian may pause a new market for up to 7 days. It cannot move funds.", "Internal audit, October 2026 (docs/AUDIT_2026-10-11.md on GitHub). No external audit yet.", - "Testnet deployment on Monad Testnet and HyperEVM Testnet, same code at the same address. Test tokens have no value.", + deploymentLine(), ]; +/** + * Where it runs, from the deployments this build has: "Testnet deployment on Monad Testnet and HyperEVM Testnet, same + * code at the same address." plus a served network that is not deployed yet (Sepolia before its deploy). + */ +export function deploymentLine(): string { + const nets = ENABLED.filter((c) => c.id !== ANVIL); + const live = nets.filter((c) => !!deploymentOf(c.id)); + const later = nets.filter((c) => !deploymentOf(c.id)); + const list = (xs: typeof nets) => xs.map(networkName).join(xs.length > 2 ? ", " : " and ").replace(/, ([^,]+)$/, " and $1"); + const cores = new Set(live.map((c) => deploymentOf(c.id)!.confluere.toLowerCase())); + const where = live.length === 0 ? "" : `Testnet deployment on ${list(live)}${live.length > 1 ? (cores.size === 1 ? ", same code at the same address" : ", same code") : ""}. `; + const soon = later.length === 0 ? "" : `${list(later)} ${later.length > 1 ? "are" : "is"} supported and not deployed yet. `; + return `${where}${soon}Test tokens have no value.`; +} + export function HowItWorks() { return (
From b58a5d06178b70b465483b4a61d46bdad826ccd8 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:17 -0400 Subject: [PATCH 045/169] feat(web): Sepolia demo network Real Sepolia token addresses with made-up contracts: WETH/USDC and wstETH/USDC in both repay styles, wstETH/WETH loop pair, several maturities incl. one in its window and one closed, books, 30 days of history, three vaults, LP slots and a full portfolio; answers Lido's stEthPerToken; switching among the three demo networks needs no wallet chain switch. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/demo/chain.ts | 76 +++++++++++++++++++++++++--- apps/web/src/lib/demo/client.ts | 4 +- apps/web/src/lib/demo/deployments.ts | 76 ++++++++++++++++++++++++++-- 3 files changed, 146 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/demo/chain.ts b/apps/web/src/lib/demo/chain.ts index 16a1987..3a9feb4 100644 --- a/apps/web/src/lib/demo/chain.ts +++ b/apps/web/src/lib/demo/chain.ts @@ -1,6 +1,6 @@ /** - * The demo's sample network (IA v3.1 item 13). A small in-browser model of the Confluere contracts on HyperEVM and - * Monad: tokens and balances, pools with their rounds, order books, buy-back rights and lender units, the repayment + * The demo's sample network (IA v3.1 item 13). A small in-browser model of the Confluere contracts on HyperEVM, + * Monad and Sepolia: tokens and balances, pools with their rounds, order books, buy-back rights and lender units, the repayment * window, vaults and LP seats. The demo client (lib/demo/client) answers the app's contract reads from it, and the * demo transaction path (lib/tx) applies each write to it after the wallet signs a plain message. * @@ -53,7 +53,8 @@ const now = () => Math.floor(Date.now() / 1000); // ───────────────────────────── sample network specs ───────────────────────────── -type TokenSpec = { address: Address; symbol: string; decimals: number; lst?: { native: Address; rate: number } }; +/** `lst.wsteth`: Lido's wstETH (Sepolia), read through `stEthPerToken` / `getWstETHByStETH`, not ERC-4626. */ +type TokenSpec = { address: Address; symbol: string; decimals: number; lst?: { native: Address; rate: number; wsteth?: boolean } }; type MarketSpec = { asset: Address; quote: Address; @@ -205,6 +206,51 @@ function specOf(chainId: number): ChainSpec | undefined { }, }; } + if (chainId === 11155111) { + // Sepolia: 12 s slots; ETH near its mainnet price (Coinbase, 2026-10-23) and wstETH at Sepolia's own rate (1.0377) + return { + id: 11155111, + spb: 12, + b0: 11_746_000, + tokens: [ + { address: native, symbol: "WETH", decimals: 18 }, + { address: usdc, symbol: "USDC", decimals: 6 }, + { address: lst, symbol: "wstETH", decimals: 18, lst: { native, rate: 1.0377, wsteth: true } }, + ], + refs: { + "WETH/USDC": { p: 2_380, vol: 0.0055 }, + "wstETH/WETH": { p: 1.0377, vol: 0.0002 }, + "wstETH/USDC": { p: 2_380 * 1.0377, vol: 0.0055, product: ["wstETH/WETH", "WETH/USDC"] }, + }, + markets: [ + { asset: native, quote: usdc, kind: Kind.American, band: 800, fee: 1000, graduated: true, strikes: [1_800, 2_000, 2_200], days: [1, 3, 7, 14, 28], ref: "WETH/USDC", size: [0.06, 2.2], pegged: false, activity: 1, window: 2, closed: 1, seat: 1, seatPrice: 24_000 }, + { asset: native, quote: usdc, kind: Kind.European, band: 800, fee: 1000, graduated: true, strikes: [1_900, 2_100], days: [7, 28], ref: "WETH/USDC", size: [0.04, 1.4], pegged: false, activity: 0.7, seat: "user", seatPrice: 12_500 }, + // the loop pair: staked ETH against its own coin (pegged), buy-back prices ~94% and ~96% of wstETH's rate + { asset: lst, quote: native, kind: Kind.American, band: 150, fee: 1000, graduated: true, strikes: [0.98, 1], days: [3, 7, 14, 28], ref: "wstETH/WETH", size: [0.08, 2.8], pegged: true, activity: 0.85, seat: 2, seatPrice: 7 }, + { asset: lst, quote: native, kind: Kind.European, band: 150, fee: 1000, graduated: false, strikes: [1], days: [14], ref: "wstETH/WETH", size: [0.04, 0.8], pegged: true, activity: 0.55 }, + // staked ETH against USDC: buy-back prices ~85% and ~89% of wstETH's value (the live strikes are 2100 and 2200) + { asset: lst, quote: usdc, kind: Kind.American, band: 800, fee: 1000, graduated: true, strikes: [2_100, 2_200], days: [3, 7, 14, 28], ref: "wstETH/USDC", size: [0.05, 1.8], pegged: false, activity: 0.75, seat: 4, seatPrice: 9_000 }, + { asset: lst, quote: usdc, kind: Kind.European, band: 800, fee: 1000, graduated: true, strikes: [2_100, 2_200], days: [7, 28], ref: "wstETH/USDC", size: [0.03, 1.1], pegged: false, activity: 0.55, seat: 0, seatPrice: 5_600 }, + ], + vaults: [ + { address: d.vaults!.pegged, name: "Confluere ETH Pegged", symbol: "rvETH-Pegged", quote: native, market: 2, tier: "any", tvl: 680, apr: 0.044, strikeRule: 2 }, + { address: d.vaults!.usdHigh!, name: "Confluere USDC High", symbol: "rvUSDC-High", quote: usdc, market: 0, tier: "high", tvl: 820_000, apr: 0.121, strikeRule: 1 }, + { address: d.vaults!.usdLow, name: "Confluere USDC Low", symbol: "rvUSDC-Low", quote: usdc, market: 0, tier: "low", tvl: 1_950_000, apr: 0.072, strikeRule: 0 }, + ], + obligation: { [lc(usdc)]: 300, [lc(native)]: 0.12 }, + minNotional: { [lc(usdc)]: 10, [lc(native)]: 0.004 }, + graduationVolume: { [lc(usdc)]: 25_000, [lc(native)]: 10 }, + user: { + native: 2.5, + tokens: [[native, 1.5], [usdc, 14_000], [lst, 3]], + loans: [{ m: 0, at: 7, s: 1, units: 1.6 }, { m: 0, at: "window", s: 2, units: 0.55 }, { m: 2, at: 7, s: 1, units: 2.4 }], + lends: [{ m: 0, at: 14, s: 0, units: 4.2 }, { m: 0, at: "closed", s: 1, units: 2.6 }], + order: { m: 0, at: 3, s: 1, units: 1.1, below: 0.035 }, + pending: [usdc, 118.42], + vaults: [{ v: 2, shares: 2_400, collect: 300 }, { v: 0, shares: 1.2 }], + }, + }; + } return undefined; } @@ -391,7 +437,7 @@ const de = (s: string): T => JSON.parse(s, (_, x) => (x && typeof x === "obje /** Forget the sample network (a new demo session starts fresh). */ export function resetDemoNetworks() { - for (const id of [...nets.keys(), 998, 10143]) { + for (const id of [...nets.keys(), 998, 10143, 11155111]) { try { globalThis.sessionStorage?.removeItem(STORE(id)); } catch {} @@ -1470,10 +1516,20 @@ export function readCall(chainId: number, c: Call): unknown { case "totalSupply": return 10n ** BigInt(tk.decimals + 9); case "convertToAssets": - if (tk.lst) return mulF(BigInt(args[0]), lstRate(net, tk)); + if (tk.lst && !tk.lst.wsteth) return mulF(BigInt(args[0]), lstRate(net, tk)); break; case "convertToShares": - if (tk.lst) return mulF(BigInt(args[0]), 1 / lstRate(net, tk)); + if (tk.lst && !tk.lst.wsteth) return mulF(BigInt(args[0]), 1 / lstRate(net, tk)); + break; + // Lido's wstETH: stETH per wstETH, and the conversions both ways (stETH counts as ETH here) + case "stEthPerToken": + if (tk.lst?.wsteth) return mulF(10n ** 18n, lstRate(net, tk)); + break; + case "getStETHByWstETH": + if (tk.lst?.wsteth) return mulF(BigInt(args[0]), lstRate(net, tk)); + break; + case "getWstETHByStETH": + if (tk.lst?.wsteth) return mulF(BigInt(args[0]), 1 / lstRate(net, tk)); break; } } @@ -1509,6 +1565,14 @@ export function spotPxRaw(chainId: number, spotIndex: number): B { return BigInt(Math.round(p * 1e6)); } +/** Mainnet-style spot for an "ETH_USD" symbol (Sepolia's reference, /api/ref/spot, display only). */ +export function spotMid(symbol: string): number | null { + const net = demoNet(11155111); + if (!net || symbol !== "ETH_USD") return null; + advance(net); + return net.st.refs["WETH/USDC"] ?? null; +} + /** Kuru-style mid for a "MON_USDC" symbol (Monad's reference, display only). */ export function kuruMid(symbol: string): number | null { const net = demoNet(10143); diff --git a/apps/web/src/lib/demo/client.ts b/apps/web/src/lib/demo/client.ts index 29fbf08..b93a30c 100644 --- a/apps/web/src/lib/demo/client.ts +++ b/apps/web/src/lib/demo/client.ts @@ -7,7 +7,7 @@ import { decodeAbiParameters, encodeAbiParameters, type Address, type Hex, type PublicClient } from "viem"; import { chainMeta } from "../chains"; import { isDemo } from "./mode"; -import { applyWrite, blockInfo, headBlock, indexBatches, indexOrders, indexPools, indexSeatQuotes, kuruMid, nativeBalance, readCall, spotPxRaw, type Call } from "./chain"; +import { applyWrite, blockInfo, headBlock, indexBatches, indexOrders, indexPools, indexSeatQuotes, kuruMid, nativeBalance, readCall, spotMid, spotPxRaw, type Call } from "./chain"; const SPOT_PX = "0x0000000000000000000000000000000000000808"; @@ -69,6 +69,8 @@ export function demoApi(url: string): unknown { return indexSeatQuotes(chain); case "/api/ref/kuru": return { price: kuruMid(u.searchParams.get("symbol") ?? "") }; + case "/api/ref/spot": + return { price: spotMid(u.searchParams.get("symbol") ?? ""), source: "demo" }; } return undefined; } diff --git a/apps/web/src/lib/demo/deployments.ts b/apps/web/src/lib/demo/deployments.ts index 821329a..311df65 100644 --- a/apps/web/src/lib/demo/deployments.ts +++ b/apps/web/src/lib/demo/deployments.ts @@ -2,7 +2,11 @@ * Deployments the demo runs on: the live testnet deployments (same core, router, registry and token addresses, so * logos and token rules stay exactly the same), with what a fuller network would have on top: on HyperEVM a staked HYPE * market (kHYPE, made-up address) and a kHYPE / USDC market (on Monad shMON / USDC), a HYPE lending market (USDC / WHYPE) with its HYPE Lending vault and a pegged vault, an LP token per demo market, daily maturities and - * the production repayment windows (7,200 blocks on HyperEVM, 24,000 on Monad). + * the production repayment windows (7,200 blocks on HyperEVM, 24,000 on Monad, 600 on Sepolia). + * + * Sepolia has no live deployment yet (deployments/sepolia.json comes with the founder's deploy), so its demo deployment + * uses the real Sepolia tokens (WETH, USDC, wstETH: real logos and token rules) with made-up contract addresses; once + * the SDK has the live deployment, the demo takes its contract addresses like on the other networks. */ import { deployments, type Deployment } from "@confluere/sdk"; import type { Address } from "viem"; @@ -31,6 +35,31 @@ export const DEMO_ADDR = { lpShmonMonEu10143: "0xd3a0000000000000000000000000000000000c0b", lpMonUsdcAm10143: "0xd3a0000000000000000000000000000000000c0c", lpMonUsdcEu10143: "0xd3a0000000000000000000000000000000000c0d", + // Sepolia: the contracts too until the live deployment exists + coreSepolia: "0xd3a0000000000000000000000000000000000d01", + routerSepolia: "0xd3a0000000000000000000000000000000000d02", + wrapperFactorySepolia: "0xd3a0000000000000000000000000000000000d03", + vaultFactorySepolia: "0xd3a0000000000000000000000000000000000d04", + seatRegistrySepolia: "0xd3a0000000000000000000000000000000000d05", + keeperReceiverSepolia: "0xd3a0000000000000000000000000000000000d06", + positionMarketSepolia: "0xd3a0000000000000000000000000000000000d07", + guardianSepolia: "0xd3a0000000000000000000000000000000000d08", + peggedVaultSepolia: "0xd3a0000000000000000000000000000000000b08", + usdLowVaultSepolia: "0xd3a0000000000000000000000000000000000b09", + usdHighVaultSepolia: "0xd3a0000000000000000000000000000000000b0a", + lpWstethWethAmSepolia: "0xd3a0000000000000000000000000000000000c0e", + lpWstethWethEuSepolia: "0xd3a0000000000000000000000000000000000c0f", + lpWethUsdcAmSepolia: "0xd3a0000000000000000000000000000000000c10", + lpWethUsdcEuSepolia: "0xd3a0000000000000000000000000000000000c11", + lpWstethUsdcAmSepolia: "0xd3a0000000000000000000000000000000000c12", + lpWstethUsdcEuSepolia: "0xd3a0000000000000000000000000000000000c13", +} as const satisfies Record; + +/** The real Sepolia tokens (scripts/lib/testnet.sh): Uniswap's WETH9, Circle's USDC, Lido's wstETH. */ +export const SEPOLIA_TOKENS = { + weth: "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", + usdc: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + wsteth: "0xB82381A3fBD3FaFA77B3a7bE693342618240067b", } as const satisfies Record; /** Daily maturities at 08:00 UTC, one round a minute. */ @@ -81,10 +110,51 @@ function monad(): Deployment { }; } +function sepolia(): Deployment { + const live: Deployment | undefined = deployments[11155111]; + const d: Deployment = live ?? { + chainId: 11155111, + demo: false, + confluere: DEMO_ADDR.coreSepolia, + router: DEMO_ADDR.routerSepolia, + wrapperFactory: DEMO_ADDR.wrapperFactorySepolia, + vaultFactory: DEMO_ADDR.vaultFactorySepolia, + keeperReceiver: DEMO_ADDR.keeperReceiverSepolia, + seatRegistry: DEMO_ADDR.seatRegistrySepolia, + positionMarket: DEMO_ADDR.positionMarketSepolia, + guardian: DEMO_ADDR.guardianSepolia, + windowBlocks: 600, + grid: "daily", + maxExpiries: 15, + gridAnchor: DEMO_GRID.anchor, + gridPeriod: DEMO_GRID.period, + batchInterval: 120, + deployBlock: 11_900_000, + }; + const { weth, usdc, wsteth } = SEPOLIA_TOKENS; + return { + ...d, + windowBlocks: 600, + gridAnchor: DEMO_GRID.anchor, + gridPeriod: DEMO_GRID.period, + batchInterval: DEMO_GRID.batchInterval, + pools: { expiry: DEMO_GRID.anchor, ...d.pools, lst: wsteth, native: weth, usdc, poolIds: [] }, + vaults: { pegged: DEMO_ADDR.peggedVaultSepolia, usdLow: DEMO_ADDR.usdLowVaultSepolia, usdHigh: DEMO_ADDR.usdHighVaultSepolia }, + seatLPTokens: { + [`${wsteth}-${weth}-0`]: DEMO_ADDR.lpWstethWethAmSepolia, + [`${wsteth}-${weth}-1`]: DEMO_ADDR.lpWstethWethEuSepolia, + [`${weth}-${usdc}-0`]: DEMO_ADDR.lpWethUsdcAmSepolia, + [`${weth}-${usdc}-1`]: DEMO_ADDR.lpWethUsdcEuSepolia, + [`${wsteth}-${usdc}-0`]: DEMO_ADDR.lpWstethUsdcAmSepolia, + [`${wsteth}-${usdc}-1`]: DEMO_ADDR.lpWstethUsdcEuSepolia, + }, + }; +} + let cache: Record | undefined; -/** The demo deployment of a network (HyperEVM testnet and Monad testnet only). */ +/** The demo deployment of a network (HyperEVM testnet, Monad testnet and Sepolia). */ export function demoDeploymentOf(id: number): Deployment | undefined { - cache ??= { 998: hyper(), 10143: monad() }; + cache ??= { 998: hyper(), 10143: monad(), 11155111: sepolia() }; return cache[id]; } From 2603a69c1749ee850f133c6e328487a9ffa5d0ed Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:23 -0400 Subject: [PATCH 046/169] test(web): Sepolia chain table, active-chain following and network names Co-Authored-By: Claude Opus 5.5 --- apps/web/test/activeChain.test.tsx | 28 ++++++++++++++++++++++++++++ apps/web/test/alias.test.ts | 11 ++++++++++- apps/web/test/chains.test.ts | 25 ++++++++++++++++++++++--- apps/web/test/format.test.ts | 1 + 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/apps/web/test/activeChain.test.tsx b/apps/web/test/activeChain.test.tsx index 4e14061..d50b098 100644 --- a/apps/web/test/activeChain.test.tsx +++ b/apps/web/test/activeChain.test.tsx @@ -16,6 +16,7 @@ vi.mock("wagmi", () => ({ const { ActiveChainProvider, setChainChoice, useActiveChain, useSwitchNetwork } = await import("../src/lib/activeChain"); const config = await import("../src/lib/config"); +const { deployments } = await import("@confluere/sdk"); beforeEach(() => { wallet.isConnected = false; @@ -61,6 +62,28 @@ describe("useActiveChain", () => { expect(replaced.some((u) => u.includes("chain=monad"))).toBe(true); }); + it("a wallet that connects on Sepolia is followed like the other served networks, deployed or not", async () => { + wallet.isConnected = true; + wallet.chainId = 11155111; + let v: ReturnType | undefined; + const Probe = () => { + v = useActiveChain(); + return null; + }; + await act(async () => { + render( + + + , + ); + }); + expect(v?.chainId).toBe(11155111); + expect(v?.meta.native).toBe("ETH"); + expect(v?.walletOnOtherChain).toBe(false); + expect(switchChain).not.toHaveBeenCalled(); + expect(replaced.some((u) => u.includes("chain=sepolia"))).toBe(true); + }); + it("a wallet on a foreign chain leaves the app on the stored choice and flags it", () => { wallet.isConnected = true; wallet.chainId = 1; @@ -87,6 +110,11 @@ describe("useSwitchNetwork", () => { expect(await result.current.sw.switchNetwork(10143)).toEqual({ ok: true }); }); expect(result.current.active.chainId).toBe(10143); + await act(async () => { + expect(await result.current.sw.switchNetwork(11155111)).toEqual({ ok: true }); + }); + expect(result.current.active.chainId).toBe(11155111); + expect(result.current.active.deployment).toBe(deployments[11155111]); // undefined until deployments/sepolia.json exists expect(switchChain).not.toHaveBeenCalled(); }); diff --git a/apps/web/test/alias.test.ts b/apps/web/test/alias.test.ts index 4734f0b..552e860 100644 --- a/apps/web/test/alias.test.ts +++ b/apps/web/test/alias.test.ts @@ -8,7 +8,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { deployments } from "@confluere/sdk"; import { onchainSymbol } from "../src/lib/data"; import { launchTokensOf } from "../src/lib/chains"; -import { tokenLogo } from "../src/lib/logos"; +import { chainLogo, tokenLogo } from "../src/lib/logos"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; import { lpSymbol } from "../src/lib/lp"; import { selectorTokens } from "../src/lib/product"; import { NATIVE } from "../src/lib/native"; @@ -41,6 +43,13 @@ describe("the wrapped coin is its own token", () => { expect(tokenLogo(998, WHYPE)).toBe(tokenLogo(998, ZERO)); expect(tokenLogo(10143, WMON)).toBe(tokenLogo(10143, ZERO)); expect(tokenLogo(10143, SHMON)).not.toBe(tokenLogo(10143, ZERO)); + // Sepolia: WETH carries the ETH logo (plus the W mark in TokenIcon), wstETH and USDC their own; every file exists + expect(tokenLogo(11155111, "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14")).toBe(tokenLogo(11155111, ZERO)); + expect(tokenLogo(11155111, "0xB82381A3fBD3FaFA77B3a7bE693342618240067b")).not.toBe(tokenLogo(11155111, ZERO)); + for (const a of [ZERO, "0xB82381A3fBD3FaFA77B3a7bE693342618240067b", "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"]) { + expect(existsSync(join(__dirname, "..", "public", tokenLogo(11155111, a)!))).toBe(true); + } + expect(existsSync(join(__dirname, "..", "public", chainLogo(11155111)!))).toBe(true); expect(tokenLogo(998, USDC_998)).not.toBe(tokenLogo(998, ZERO)); expect(tokenLogo(998, undefined, "kHYPE")).toBeTruthy(); }); diff --git a/apps/web/test/chains.test.ts b/apps/web/test/chains.test.ts index ea89468..17bec68 100644 --- a/apps/web/test/chains.test.ts +++ b/apps/web/test/chains.test.ts @@ -1,10 +1,13 @@ /** Chain registry: enabled networks, default, RPC order, Multicall3, explorers, launch tokens. */ import { describe, expect, it } from "vitest"; +import { deployments } from "@confluere/sdk"; import { CHAINLINK_HYPER_TESTNET_RPC, DEFAULT_CHAIN_ID, ENABLED_IDS, MULTICALL3, + PUBLICNODE_SEPOLIA_RPC, + chainIdFromSlug, chainMeta, deploymentOf, explorerTxUrl, @@ -14,8 +17,8 @@ import { } from "../src/lib/chains"; describe("chains", () => { - it("serves HyperEVM and Monad testnets; anvil only when enabled", () => { - expect(ENABLED_IDS).toEqual([998, 10143]); + it("serves HyperEVM and Monad testnets and Sepolia; anvil only when enabled", () => { + expect(ENABLED_IDS).toEqual([998, 10143, 11155111]); expect(isEnabledChain(31337)).toBe(false); expect(isEnabledChain(undefined)).toBe(false); }); @@ -30,9 +33,25 @@ describe("chains", () => { expect(rpcUrlsOf(10143)[0]).toBe("https://testnet-rpc.monad.xyz"); }); - it("both testnets batch reads through Multicall3", () => { + it("every served testnet batches reads through Multicall3", () => { expect(chainMeta(998).chain.contracts?.multicall3?.address).toBe(MULTICALL3); expect(chainMeta(10143).chain.contracts?.multicall3?.address).toBe(MULTICALL3); + expect(chainMeta(11155111).chain.contracts?.multicall3?.address.toLowerCase()).toBe(MULTICALL3.toLowerCase()); + }); + + it("Sepolia: ETH, a ?chain=sepolia slug, publicnode first then viem's default RPC, Etherscan links", () => { + const m = chainMeta(11155111); + expect(m).toMatchObject({ id: 11155111, name: "Sepolia", label: "Sepolia testnet", native: "ETH", testnet: true, slug: "sepolia" }); + expect(chainIdFromSlug("sepolia")).toBe(11155111); + expect(rpcUrlsOf(11155111)).toEqual([PUBLICNODE_SEPOLIA_RPC, "https://11155111.rpc.thirdweb.com"]); + expect(explorerTxUrl(11155111, "0xab")).toBe("https://sepolia.etherscan.io/tx/0xab"); + }); + + // holds until deployments/sepolia.json exists; afterwards the SDK's deployment is the answer + it.skipIf(!!deployments[11155111])("Sepolia is served before it is deployed: no deployment and no launch tokens until the SDK has one", () => { + expect(isEnabledChain(11155111)).toBe(true); + expect(deploymentOf(11155111)).toBeUndefined(); + expect(launchTokensOf(11155111)).toEqual({ lst: undefined, native: undefined, usdc: undefined }); }); it("same core on both testnets, per-chain launch tokens", () => { diff --git a/apps/web/test/format.test.ts b/apps/web/test/format.test.ts index 0f08a43..6112732 100644 --- a/apps/web/test/format.test.ts +++ b/apps/web/test/format.test.ts @@ -54,5 +54,6 @@ describe("time and blocks", () => { expect(pct(0.0333)).toBe("3.33%"); expect(vaultDisplayName("rvNATIVE-Pegged", 10143)).toBe("rvMON-Pegged"); expect(vaultDisplayName("rvUSD-NATIVE-Low", 998)).toBe("rvUSD-HYPE-Low"); + expect(vaultDisplayName("rvNATIVE-Pegged", 11155111)).toBe("rvETH-Pegged"); }); }); From 7398418309d6bc9d5fce79360aa66a9dba3cc467 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:23 -0400 Subject: [PATCH 047/169] test(web): Sepolia native wrap, token rules and logo files Co-Authored-By: Claude Opus 5.5 --- apps/web/test/native.test.tsx | 6 ++++++ apps/web/test/tokens.test.ts | 27 ++++++++++++++++++++++++++- apps/web/test/v3-lib.test.ts | 3 +++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/web/test/native.test.tsx b/apps/web/test/native.test.tsx index 0fbddff..a7a00ac 100644 --- a/apps/web/test/native.test.tsx +++ b/apps/web/test/native.test.tsx @@ -72,6 +72,12 @@ describe("lib/native (pure)", () => { expect(spendableNative(1n, 998)).toBe(0n); }); + it("ETH on Sepolia: the same coin row, a 0.01 ETH gas reserve", () => { + expect(nativeTokenOf(11155111)).toEqual({ address: "0x0000000000000000000000000000000000000000", symbol: "ETH", decimals: 18 }); + expect(gasReserve(11155111)).toBe(10n ** 16n); + expect(spendableNative(E18, 11155111)).toBe(E18 - 10n ** 16n); + }); + it("paying in the coin puts the wrap first, then the approval, then the action; otherwise nothing changes", async () => { const pool = hypePool("h1") as never; const whype = { address: WHYPE, symbol: "WHYPE", decimals: 18 }; diff --git a/apps/web/test/tokens.test.ts b/apps/web/test/tokens.test.ts index 05320e2..a1044e3 100644 --- a/apps/web/test/tokens.test.ts +++ b/apps/web/test/tokens.test.ts @@ -1,6 +1,6 @@ /** Faucet mint probe: a WETH9-style fallback must not pass for a public mint (audit #3). */ import { describe, expect, it, vi } from "vitest"; -import { probeMintable } from "../src/lib/tokens"; +import { NATIVE_FAUCET, getTokenStep, obtainInfo, probeMintable, stakeStep } from "../src/lib/tokens"; const ME = "0x00000000000000000000000000000000000000e1" as const; const revert = () => Promise.reject(new Error("execution reverted")); @@ -31,3 +31,28 @@ describe("probeMintable", () => { expect(await probeMintable(c, "0x00000000000000000000000000000000000000b2", ME, 31337)).toBe(false); }); }); + +describe("Sepolia tokens", () => { + const WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" as const; + const USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" as const; + const WSTETH = "0xB82381A3fBD3FaFA77B3a7bE693342618240067b" as const; + it("WETH wraps ETH, USDC comes from Circle's faucet, wstETH is never offered outside the Loop flow", () => { + expect(getTokenStep(11155111, { address: WETH, symbol: "WETH" })).toEqual({ kind: "wrap", token: WETH, native: "ETH", label: "Wrap ETH" }); + expect(getTokenStep(11155111, { address: USDC, symbol: "USDC" })).toMatchObject({ kind: "faucet", href: "https://faucet.circle.com" }); + expect(obtainInfo(WSTETH, "wstETH", 11155111)).toBeUndefined(); + expect(obtainInfo(WSTETH, "wstETH", 11155111, { allowStake: true })).toMatchObject({ kind: "stake", provider: "Lido" }); + }); + it("the Loop flow names Lido for wstETH and FastLane for shMON", () => { + expect(stakeStep(11155111, WSTETH)?.provider).toBe("Lido"); + // Lido has no Sepolia staking site: no link, the step itself says where to send the ETH + expect(stakeStep(11155111, WSTETH)?.href).toBeUndefined(); + expect(stakeStep(11155111, WSTETH)?.note).toMatch(/0xB82381A3fBD3FaFA77B3a7bE693342618240067b/); + expect(stakeStep(10143, "0x282BdDFF5e58793AcAb65438b257Dbd15A8745C9")?.provider).toBe("FastLane"); + }); + it("known real tokens are never probed for a mint; Sepolia ETH has a faucet link", async () => { + const c = client({ call: revert, simulate: async () => ({ result: undefined }) }); + expect(await probeMintable(c, WETH, ME, 11155111)).toBe(false); + expect(c.simulateContract).not.toHaveBeenCalled(); + expect(NATIVE_FAUCET[11155111]).toMatch(/sepolia/); + }); +}); diff --git a/apps/web/test/v3-lib.test.ts b/apps/web/test/v3-lib.test.ts index 2f19189..69a1341 100644 --- a/apps/web/test/v3-lib.test.ts +++ b/apps/web/test/v3-lib.test.ts @@ -154,6 +154,9 @@ describe("vocabulary", () => { expect(networkName({ id: 31337, name: "Anvil", testnet: true })).toBe("Local (anvil)"); expect(priceSource(998, WETH, USDC)).toBe("HyperCore spot"); expect(priceSource(10143, "0xa3", "0xa4", { lst: "0xA3", native: "0xa4", assetSymbol: "shMON" })).toBe("shMON's own rate"); + expect(networkName({ id: 11155111, name: "Sepolia", testnet: true })).toBe("Sepolia Testnet"); + expect(priceSource(11155111, "0xa1", "0xa2")).toBe("ETH / USD spot (mainnet)"); + expect(priceSource(11155111, "0xa3", "0xa4", { lst: "0xA3", native: "0xa4", assetSymbol: "wstETH" })).toBe("wstETH's own rate"); }); it("formats: dates without middle dots, waits in words, dollar tokens with two decimals", () => { expect(fmtExpiry(Date.UTC(2026, 9, 13, 9, 0) / 1000)).toBe("Tue 13 Oct, 09:00 UTC"); From 3172f9eaf0126c09a0d5800bccf3557679d2c23b Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:23 -0400 Subject: [PATCH 048/169] test(web): Sepolia reference prices and the spot parser Co-Authored-By: Claude Opus 5.5 --- apps/web/test/refPrice.test.ts | 59 ++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/web/test/refPrice.test.ts b/apps/web/test/refPrice.test.ts index 411796f..4efd099 100644 --- a/apps/web/test/refPrice.test.ts +++ b/apps/web/test/refPrice.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; -import { spotPxToPrice } from "../src/lib/refPrice"; +import { describe, expect, it, vi } from "vitest"; +import { deployments } from "@confluere/sdk"; +import { ETH_REF_CHAINS, ethLeg, ethLegPrice, spotPxToPrice } from "../src/lib/refPrice"; +import { SPOT_PAIRS, coinbaseSpot, krakenLast } from "../src/lib/spot"; describe("HyperCore spotPx scaling", () => { it("matches the testnet reading of 2026-10-11 (HYPE/USDC, szDecimals 2)", () => { @@ -9,3 +11,56 @@ describe("HyperCore spotPx scaling", () => { expect(spotPxToPrice(123_456n, 5)).toBeCloseTo(123.456, 6); }); }); + +describe("Sepolia reference legs (mainnet ETH / USD, wstETH at its own rate)", () => { + const E = 2_380; + const R = 1.0377; + it("WETH / USDC is ETH / USD; USDC / WETH its inverse", () => { + expect(ethLegPrice("eth", "usd", E, null)).toBeCloseTo(2_380, 6); + expect(ethLegPrice("usd", "eth", E, null)).toBeCloseTo(1 / 2_380, 12); + }); + it("wstETH / WETH is the rate alone (no off-chain price); wstETH / USDC the rate times ETH / USD", () => { + expect(ethLegPrice("lst", "eth", null, R)).toBeCloseTo(1.0377, 9); + expect(ethLegPrice("lst", "usd", E, R)).toBeCloseTo(2_380 * 1.0377, 6); + }); + it("no price when a needed leg is missing", () => { + expect(ethLegPrice("eth", "usd", null, null)).toBeNull(); + expect(ethLegPrice("lst", "eth", null, null)).toBeNull(); + }); + it("only Ethereum chains price through these legs, and only their launch tokens", () => { + expect(ETH_REF_CHAINS).toEqual([1, 11155111]); + expect(ethLeg(998, "0x5555555555555555555555555555555555555555")).toBeUndefined(); + // Sepolia has no deployment (launch tokens) until deployments/sepolia.json exists + expect(ethLeg(11155111, "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238")).toBe(deployments[11155111] ? "usd" : undefined); + }); +}); + +describe("mainnet spot parsers (/api/ref/spot)", () => { + it("Coinbase spot (live shape, 2026-10-23)", () => { + expect(coinbaseSpot({ data: { amount: "2379.845", base: "ETH", currency: "USD" } })).toBeCloseTo(2379.845, 6); + expect(coinbaseSpot({ errors: [{ id: "not_found" }] })).toBeNull(); + expect(coinbaseSpot({ data: { amount: "0" } })).toBeNull(); + }); + it("Kraken last trade (live shape, 2026-10-23)", () => { + expect(krakenLast({ error: [], result: { XETHZUSD: { a: ["2379.89000", "3", "3.000"], c: ["2380.06000", "0.07371254"] } } })).toBeCloseTo(2380.06, 6); + expect(krakenLast({ error: ["EQuery:Unknown asset pair"] })).toBeNull(); + expect(krakenLast(null)).toBeNull(); + }); + it("serves ETH_USD only", () => { + expect(Object.keys(SPOT_PAIRS)).toEqual(["ETH_USD"]); + }); + it("an inherited name (toString, __proto__, constructor) is unknown: 400, nothing asked upstream", async () => { + const { GET } = await import("../src/app/api/ref/spot/route"); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + try { + for (const symbol of ["toString", "__proto__", "constructor", "hasOwnProperty", ""]) { + const r = await GET(new Request(`http://x/api/ref/spot?symbol=${symbol}`)); + expect(r.status).toBe(400); + } + expect(fetch).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); From 81f3f742b7377d30690cae8467df00f1cf88638c Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:23 -0400 Subject: [PATCH 049/169] test(web): Sepolia log sources and the 404 notDeployed answer Co-Authored-By: Claude Opus 5.5 --- apps/web/test/chainIndex.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/test/chainIndex.test.ts b/apps/web/test/chainIndex.test.ts index 1ffbedf..04d18f0 100644 --- a/apps/web/test/chainIndex.test.ts +++ b/apps/web/test/chainIndex.test.ts @@ -7,6 +7,8 @@ import { encodeAbiParameters, encodeEventTopics, type Hex } from "viem"; import { deployments } from "@confluere/sdk"; import { EVENTS, _resetIndexes, applyLogs, batchesOf, getIndex, logSources, ordersOf, poolsOf, quoteKeysOf, servesChain, statusOf } from "../src/lib/server/chainIndex"; import type { RawLog, Rpc } from "../src/lib/server/logScan"; +import { chainParam } from "../src/lib/server/http"; +import { deploymentOf } from "../src/lib/chains"; const dir = mkdtempSync(join(tmpdir(), "cf-index-")); process.env.CONFLUERE_INDEX_DIR = dir; @@ -130,6 +132,12 @@ describe("logSources", () => { expect(s.some((x) => x.url.includes("hyperliquid-testnet"))).toBe(true); }); + it("Sepolia scans through a full-history RPC (thirdweb, 1,000 blocks) before publicnode, whose logs are pruned", () => { + const s = logSources(11155111); + expect(s[0]).toMatchObject({ url: "https://11155111.rpc.thirdweb.com", range: 1000 }); + expect(s.at(-1)).toMatchObject({ url: "https://ethereum-sepolia-rpc.publicnode.com", range: 1000 }); + }); + it("LOGS_RPC_ overrides the list", () => { process.env.LOGS_RPC_998 = "https://x.example|500|3,https://y.example|50"; expect(logSources(998)).toEqual([ @@ -139,7 +147,14 @@ describe("logSources", () => { delete process.env.LOGS_RPC_998; }); - it("does not serve chains that are not enabled", () => { + it("does not serve chains that are not enabled, nor Sepolia before its deploy (a calm 404 there)", async () => { + expect(servesChain(11155111)).toBe(!!deploymentOf(11155111)); + if (!deploymentOf(11155111)) { + const r = chainParam(new Request("http://x/api/pools?chain=11155111")) as Response; + expect(r.status).toBe(404); + expect(await r.json()).toMatchObject({ notDeployed: true }); + } + expect((chainParam(new Request("http://x/api/pools?chain=5")) as Response).status).toBe(400); expect(servesChain(1)).toBe(false); expect(servesChain(31337)).toBe(false); // anvil only with NEXT_PUBLIC_ENABLE_ANVIL=1 }); From fa5810ac6b0f1e1a41a152bac3562e49d7b5efb7 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:23 -0400 Subject: [PATCH 050/169] test(web): undeployed network renders calmly without reads, network menu row Sepolia-undeployed assertions are guarded by the deployments table so they keep passing once Sepolia is deployed. Co-Authored-By: Claude Opus 5.5 --- apps/web/test/shell.test.tsx | 17 +++- apps/web/test/undeployed.test.tsx | 144 ++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 apps/web/test/undeployed.test.tsx diff --git a/apps/web/test/shell.test.tsx b/apps/web/test/shell.test.tsx index a27f073..7086a05 100644 --- a/apps/web/test/shell.test.tsx +++ b/apps/web/test/shell.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; import { render } from "@testing-library/react"; +import { deployments } from "@confluere/sdk"; import { DS, resetDS, switchNetwork, wrap } from "./fixtures"; vi.mock("next/link", () => ({ default: ({ children, href, ...p }: any) => {children} })); @@ -25,10 +26,13 @@ vi.mock("@/lib/chains", async (orig) => { import { AppShell, TabBar, TopBar, Footer } from "../src/components/layout/AppShell"; import { NetworkStatus } from "../src/components/ui/NetworkStatus"; +const asked: string[] = []; + beforeEach(() => { resetDS(); switchNetwork.calls = []; - vi.stubGlobal("fetch", async () => ({ ok: true, json: async () => ({ status: { complete: true }, pools: [{ id: "0x1", expiry: 4_000_000_000, marketId: "0xm" }], last: {} }) })); + asked.length = 0; + vi.stubGlobal("fetch", async (u: string) => (asked.push(String(u)), { ok: true, json: async () => ({ status: { complete: true }, pools: [{ id: "0x1", expiry: 4_000_000_000, marketId: "0xm" }], last: {} }) })); }); afterEach(() => { cleanup(); @@ -52,7 +56,7 @@ describe("top bar", () => { expect(within(menu).queryAllByRole("menuitemradio")).toHaveLength(0); // dark only, no theme switch }); - it("the network menu lists both testnets with their open markets and switches without a wallet", async () => { + it("the network menu lists the testnets with their open markets and switches without a wallet", async () => { DS.connected = false; wrap(); fireEvent.click(screen.getAllByTestId("network-menu")[0]!); @@ -62,7 +66,14 @@ describe("top bar", () => { await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); - expect(within(menu).getAllByText("1 market open").length).toBe(2); + // every deployed network shows its open markets (the stubbed index answers one pool for any chain) + const sepoliaLive = !!deployments[11155111]; + expect(within(menu).getAllByText("1 market open").length).toBe(sepoliaLive ? 3 : 2); + if (!sepoliaLive) { + // Sepolia is listed before its deploy, says so, and its (missing) index is never asked for + expect(within(within(menu).getByTestId("network-11155111")).getByText("Not deployed yet")).toBeTruthy(); + expect(asked.some((u) => u.includes("chain=11155111"))).toBe(false); + } fireEvent.click(within(menu).getByTestId("network-10143")); expect(switchNetwork.calls).toEqual([10143]); expect(document.body.textContent).not.toMatch(/stake|faucet|get (test )?tokens/i); diff --git a/apps/web/test/undeployed.test.tsx b/apps/web/test/undeployed.test.tsx new file mode 100644 index 0000000..99ee0fa --- /dev/null +++ b/apps/web/test/undeployed.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +/** + * A served network without a deployment (Sepolia before deployments/sepolia.json exists): every page that needs the + * contracts shows one calm "not deployed yet" state, nothing asks the chain or the server index for it, and How it + * works and the demo entry still render. Runs on the real chain registry, active chain and data hooks. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, renderHook, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const nav = vi.hoisted(() => ({ path: "/earn" })); +const client = vi.hoisted(() => ({ readContract: vi.fn(), multicall: vi.fn(), getBlock: vi.fn(), getBlockNumber: vi.fn(), call: vi.fn(), chain: { id: 11155111 } })); + +vi.mock("next/link", () => ({ default: ({ children, href, ...p }: any) => {children} })); +vi.mock("next/navigation", () => ({ + usePathname: () => nav.path, + useSearchParams: () => new URLSearchParams(""), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), +})); +vi.mock("wagmi", () => ({ + useConnection: () => ({ address: undefined, isConnected: false, chainId: undefined }), + usePublicClient: () => client, + useBalance: () => ({ data: undefined }), + useSignMessage: () => ({ mutateAsync: vi.fn() }), + useWriteContract: () => ({ mutateAsync: vi.fn() }), + useSwitchChain: () => ({ mutateAsync: vi.fn(), isPending: false }), + useConnect: () => ({ mutate: vi.fn(), isPending: false }), + useConnectors: () => [], + useDisconnect: () => ({ disconnect: vi.fn() }), + useWatchAsset: () => ({ mutate: vi.fn(), isPending: false, isSuccess: false }), +})); +vi.mock("@/lib/walletModal", () => ({ walletModal: () => undefined, openWalletPicker: vi.fn(), walletPickerStore: { subscribe: () => () => {}, get: () => false }, setWalletModal: vi.fn() })); + +import { deployments } from "@confluere/sdk"; +import { ActiveChainProvider, setChainChoice, useActiveChain } from "../src/lib/activeChain"; +import { usePoolIndex, usePools, useVaults } from "../src/lib/data"; +import { AppShell } from "../src/components/layout/AppShell"; +import { deploymentLine } from "../src/components/more/HowItWorks"; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} +const fetched: string[] = []; +const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 30)))); + +beforeEach(() => { + nav.path = "/earn"; + fetched.length = 0; + for (const f of Object.values(client)) if (typeof f === "function") (f as any).mockReset(); + vi.stubGlobal("fetch", async (u: string) => (fetched.push(String(u)), { ok: false, status: 404, json: async () => ({}) })); + act(() => setChainChoice(11155111)); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + act(() => setChainChoice(998)); +}); + +// these hold until deployments/sepolia.json exists; then Sepolia is an ordinary deployed network +describe.skipIf(!!deployments[11155111])("Sepolia before its deploy", () => { + it("the active chain is Sepolia with no deployment", () => { + const { result } = renderHook(() => useActiveChain(), { wrapper }); + expect(result.current.chainId).toBe(11155111); + expect(result.current.deployment).toBeUndefined(); + }); + + it("a page shows the calm not-deployed state instead of its screen, with the live networks and the demo", async () => { + render( + +

the earn screen

+
, + { wrapper }, + ); + await settle(); + const box = screen.getByTestId("not-deployed"); + expect(screen.queryByText("the earn screen")).toBeNull(); + expect(within(box).getByText("Not deployed on Sepolia yet")).toBeTruthy(); + expect(box.textContent).toMatch(/supports Sepolia Testnet, but its contracts are not deployed there yet/); + expect(within(box).getByTestId("not-deployed-use-998").textContent).toBe("Use HyperEVM"); + expect(within(box).getByTestId("not-deployed-use-10143").textContent).toBe("Use Monad"); + expect(within(box).getByRole("link", { name: "Try the demo on Sepolia" }).getAttribute("href")).toBe("/developers/demo?chain=sepolia&demo=1"); + // no error line, no retry spam, and nothing asked about Sepolia (no index route, no contract read) + expect(screen.queryByTestId("network-status")?.textContent ?? "").not.toMatch(/retrying|Can't reach/); + expect(fetched.filter((u) => u.includes("11155111"))).toEqual([]); + expect(client.readContract).not.toHaveBeenCalled(); + expect(client.multicall).not.toHaveBeenCalled(); + // the testnet strip and the network menu stay; the menu lists Sepolia as not deployed yet + expect(screen.getByTestId("testnet-strip")).toBeTruthy(); + fireEvent.click(screen.getAllByTestId("network-menu")[0]!); + expect(within(screen.getByTestId("network-11155111")).getByText("Not deployed yet")).toBeTruthy(); + }); + + it("Use Monad switches the app to Monad testnet, where the page renders", async () => { + const { rerender } = render( + +

the earn screen

+
, + { wrapper }, + ); + fireEvent.click(screen.getByTestId("not-deployed-use-10143")); + await settle(); + rerender( + +

the earn screen

+
, + ); + expect(screen.queryByTestId("not-deployed")).toBeNull(); + expect(screen.getByText("the earn screen")).toBeTruthy(); + }); + + it("How it works and the demo entry render without a deployment", async () => { + for (const path of ["/how-it-works", "/developers/demo"]) { + nav.path = path; + render( + +

static page

+
, + { wrapper }, + ); + expect(screen.getByText("static page")).toBeTruthy(); + expect(screen.queryByTestId("not-deployed")).toBeNull(); + cleanup(); + } + expect(deploymentLine()).toBe("Testnet deployment on HyperEVM Testnet and Monad Testnet, same code at the same address. Sepolia Testnet is supported and not deployed yet. Test tokens have no value."); + }); + + it("the data hooks stay idle: no index request, no chain read, no error", async () => { + const { result } = renderHook(() => ({ ix: usePoolIndex(), pools: usePools(), vaults: useVaults() }), { wrapper }); + await settle(); + expect(result.current.ix.ids).toEqual([]); + expect(result.current.ix.isError).toBe(false); + expect(result.current.pools.data ?? []).toEqual([]); + expect(result.current.vaults.isError).toBe(false); + expect(fetched).toEqual([]); + expect(client.readContract).not.toHaveBeenCalled(); + expect(client.multicall).not.toHaveBeenCalled(); + }); +}); From 80fb1df3333d0c3598bdbb7430c552186a64ec1e Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 23 Oct 2026 23:57:23 -0400 Subject: [PATCH 051/169] test(web): Sepolia demo network pages and counts Co-Authored-By: Claude Opus 5.5 --- apps/web/test/demo-counts.test.tsx | 59 ++++++++++++++++++++++++------ apps/web/test/demo.test.tsx | 32 ++++++++++++++++ 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/apps/web/test/demo-counts.test.tsx b/apps/web/test/demo-counts.test.tsx index 5242755..96d373f 100644 --- a/apps/web/test/demo-counts.test.tsx +++ b/apps/web/test/demo-counts.test.tsx @@ -92,12 +92,19 @@ function dataset(chainId: number) { const touches = (chainId: number, ...a: string[]) => a.some((x) => x.toLowerCase() === launchTokensOf(chainId).native!.toLowerCase()); const listed = (chainId: number, xs: { asset: string; quote: string }[]) => xs.length + xs.filter((p) => touches(chainId, p.asset, p.quote)).length; +/** The three sample networks: name, coin, wrapped coin and staked coin. */ +const NETS: Record = { + 998: { name: "HyperEVM", coin: "HYPE", wrapped: "WHYPE", lst: "kHYPE" }, + 10143: { name: "Monad", coin: "MON", wrapped: "WMON", lst: "shMON" }, + 11155111: { name: "Sepolia", coin: "ETH", wrapped: "WETH", lst: "wstETH" }, +}; + const sym = (chainId: number, a: string) => { const t = launchTokensOf(chainId); const x = a.toLowerCase(); - if (x === t.native?.toLowerCase()) return chainId === 998 ? "WHYPE" : "WMON"; + if (x === t.native?.toLowerCase()) return NETS[chainId]!.wrapped; if (x === t.usdc?.toLowerCase()) return "USDC"; - if (x === t.lst?.toLowerCase()) return chainId === 998 ? "kHYPE" : "shMON"; + if (x === t.lst?.toLowerCase()) return NETS[chainId]!.lst; return "?"; }; @@ -163,8 +170,36 @@ describe("the demo's sample set", () => { expect(sym(10143, s.loopOpen[0]!.asset)).toBe("shMON"); }); + it("Sepolia: WETH / USDC (both styles), wstETH / WETH (pegged loop) and wstETH / USDC (both styles), several maturities each", () => { + setChainChoice(11155111); + const s = dataset(11155111); + const byMarket = (asset: string, quote: string, kind: number) => s.open.filter((p) => sym(11155111, p.asset) === asset && sym(11155111, p.quote) === quote && p.kind === kind); + expect(s.markets.size).toBe(6); + expect(byMarket("WETH", "USDC", Kind.American)).toHaveLength(15); + expect(byMarket("WETH", "USDC", Kind.European)).toHaveLength(4); + expect(byMarket("wstETH", "WETH", Kind.American)).toHaveLength(8); + expect(byMarket("wstETH", "WETH", Kind.European)).toHaveLength(1); + expect(byMarket("wstETH", "USDC", Kind.American)).toHaveLength(8); + expect(byMarket("wstETH", "USDC", Kind.European)).toHaveLength(4); + expect(s.open).toHaveLength(40); + expect(s.notClosed).toHaveLength(41); + expect(s.pools).toHaveLength(42); + // the real Sepolia tokens with made-up contracts (no live deployment yet) + expect(launchTokensOf(11155111)).toEqual({ lst: "0xB82381A3fBD3FaFA77B3a7bE693342618240067b", native: "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", usdc: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" }); + const pairs = new Set(s.pools.map((p) => [sym(11155111, p.asset), sym(11155111, p.quote)].sort().join("/"))); + expect([...pairs].sort()).toEqual(["USDC/WETH", "USDC/wstETH", "WETH/wstETH"]); + expect(s.loopOpen).toHaveLength(8 + 1 + 15 + 4); + expect(sym(11155111, s.loopOpen[0]!.asset)).toBe("wstETH"); + // wstETH answers like Lido's contract (stEthPerToken, not ERC-4626), at Sepolia's own rate + const lst = launchTokensOf(11155111).lst!; + const rate = Number(readCall(11155111, { address: lst, functionName: "stEthPerToken", args: [] }) as bigint) / 1e18; + expect(rate).toBeGreaterThan(1.03); + expect(rate).toBeLessThan(1.045); + expect(() => readCall(11155111, { address: lst, functionName: "convertToAssets", args: [10n ** 18n] })).toThrow(/reverted/); + }); + it("selectors offer exactly the tokens of open markets (plus the coin next to its wrapped token)", async () => { - for (const chainId of [998, 10143]) { + for (const chainId of [998, 10143, 11155111]) { setChainChoice(chainId); const s = dataset(chainId); const { result, unmount } = renderHook(() => usePools(), { wrapper }); @@ -178,10 +213,10 @@ describe("the demo's sample set", () => { const fromOpen = new Set(s.open.map((p) => `${p.asset}/${p.quote}`.toLowerCase())); expect(offered).toEqual(fromOpen); const tokens = new Set(pairs.flatMap((p) => [sym(chainId, p.asset), sym(chainId, p.quote)])); - expect([...tokens].sort()).toEqual(chainId === 998 ? ["USDC", "WHYPE", "kHYPE"] : ["USDC", "WMON", "shMON"]); + expect([...tokens].sort()).toEqual(["USDC", NETS[chainId]!.wrapped, NETS[chainId]!.lst].sort()); // the selectors: the tokens of open markets on each side, plus the coin (HYPE / MON) wherever its wrapped token is const wrapped = launchTokensOf(chainId).native!; - const coin = chainId === 998 ? "HYPE" : "MON"; + const coin = NETS[chainId]!.coin; const name = (a: string) => (isNative(a) ? coin : sym(chainId, a)); const expected = (side: "asset" | "quote") => { const xs = [...new Set(s.open.map((p) => sym(chainId, p[side])))]; @@ -195,14 +230,14 @@ describe("the demo's sample set", () => { // the coin row sits right before its wrapped token expect(collateral[collateral.indexOf(coin) + 1]).toBe(sym(chainId, wrapped)); // all of the network's tokens show up across the two sides: HYPE, WHYPE, USDC, kHYPE / MON, WMON, shMON, USDC - expect([...new Set([...collateral, ...loan])].sort()).toEqual(chainId === 998 ? ["HYPE", "USDC", "WHYPE", "kHYPE"] : ["MON", "USDC", "WMON", "shMON"]); + expect([...new Set([...collateral, ...loan])].sort()).toEqual([coin, "USDC", NETS[chainId]!.wrapped, NETS[chainId]!.lst].sort()); } }); }); describe("every page lists the whole sample set", () => { - for (const chainId of [998, 10143]) { - const net = chainId === 998 ? "HyperEVM" : "Monad"; + for (const chainId of [998, 10143, 11155111]) { + const net = NETS[chainId]!.name; it(`${net}: Borrow and Loop list three live maturities per pair at most, each touching the wrapped coin twice`, async () => { setChainChoice(chainId); @@ -244,11 +279,11 @@ describe("every page lists the whole sample set", () => { const names = chainId === 998 ? ["HYPE Pegged vault", "WHYPE Pegged vault", "HYPE Lending vault", "WHYPE Lending vault", "USDC Conservative vault", "USDC High-yield vault"] - : ["MON Pegged vault", "WMON Pegged vault", "USDC Conservative vault", "USDC High-yield vault"]; + : [`${NETS[chainId]!.coin} Pegged vault`, `${NETS[chainId]!.wrapped} Pegged vault`, "USDC Conservative vault", "USDC High-yield vault"]; await waitFor(() => expect(rows("vaults")).toBe(names.length), { timeout: 8000 }); await waitFor(() => expect(screen.getAllByTestId("vault-name").map((n) => n.textContent).sort()).toEqual([...names].sort()), { timeout: 8000 }); // the coin's row: the coin's logo without the wrapped mark; the wrapped row: the same logo with the W mark; USDC its own - const coin = chainId === 998 ? "HYPE" : "MON"; + const coin = NETS[chainId]!.coin; for (const row of screen.getAllByTestId("vaults-row")) { const n = row.querySelector('[data-testid="vault-name"]')!.textContent ?? ""; const img = row.querySelector("img")?.getAttribute("src"); @@ -268,8 +303,8 @@ describe("every page lists the whole sample set", () => { it(`${net}: every vault's drawer reads like its list row (title, icon, deposit token, amounts)`, async () => { setChainChoice(chainId); - const coin = chainId === 998 ? "HYPE" : "MON"; - const wrapped = chainId === 998 ? "WHYPE" : "WMON"; + const coin = NETS[chainId]!.coin; + const wrapped = NETS[chainId]!.wrapped; const { result, unmount } = renderHook(() => useVaults(), { wrapper }); await waitFor(() => expect(result.current.data?.length).toBeGreaterThan(0), { timeout: 6000 }); const vaults = result.current.data!; diff --git a/apps/web/test/demo.test.tsx b/apps/web/test/demo.test.tsx index cf541c0..ffa3a96 100644 --- a/apps/web/test/demo.test.tsx +++ b/apps/web/test/demo.test.tsx @@ -145,9 +145,41 @@ describe("demo mode: the same screens on sample data", () => { act(() => setChainChoice(10143)); await waitFor(() => expect(result.current.chainId).toBe(10143)); expect(result.current.deployment?.windowBlocks).toBe(24_000); + // Sepolia has a full sample network even before its live deploy (600-block window, the real Sepolia tokens) + act(() => setChainChoice(11155111)); + await waitFor(() => expect(result.current.chainId).toBe(11155111)); + expect(result.current.deployment?.windowBlocks).toBe(600); + expect(result.current.deployment?.pools?.native).toBe("0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"); expect(w.switchChain).not.toHaveBeenCalled(); }); + it("Sepolia: every page has the sample set (WETH / USDC, wstETH / WETH, wstETH / USDC, ETH and USDC vaults, LP slots, positions)", async () => { + setChainChoice(11155111); + show(); + await waitFor(() => expect(screen.getByTestId("borrow-swap").textContent).toMatch(/Buy back at \d/), { timeout: 4000 }); + cleanup(); + show(); + await waitFor(() => expect(document.body.textContent).toMatch(/wstETH \/ WETH/), { timeout: 4000 }); + expect(document.body.textContent).toMatch(/WETH \/ USDC/); + expect(document.body.textContent).toMatch(/wstETH \/ USDC/); + cleanup(); + show(); + await waitFor(() => expect(screen.getByTestId("vaults").textContent).toMatch(/ETH Pegged vault[\s\S]*USDC|USDC[\s\S]*ETH Pegged vault/), { timeout: 6000 }); + cleanup(); + show(); + await waitFor(() => expect(screen.getByTestId("lp-pools").textContent).toMatch(/wstETH/), { timeout: 4000 }); + cleanup(); + show(); + await waitFor(() => expect(screen.getByTestId("loans").textContent).toMatch(/WETH|ETH/), { timeout: 8000 }); + expect(screen.getByTestId("lending").textContent).toMatch(/ETH/); + // the reference price comes from the sample network's ETH / USD, labelled as the mainnet spot + const r = await appFetch("/api/ref/spot?symbol=ETH_USD"); + const j = (await r.json()) as { price: number }; + expect(j.price).toBeGreaterThan(1_500); + expect(j.price).toBeLessThan(4_000); + expect(liveCalls()).toBe(0); + }, 25_000); + it("an action asks for a message signature, never a contract write or a network switch, and updates the sample state", async () => { w.sign.mockResolvedValue("0xsig"); const client = demoClient(998); From b135309392d4b9210261674dd091c1c658682982 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 01:48:05 -0400 Subject: [PATCH 052/169] chore(deployments): record the Sepolia testnet deployment Core 0x4E4B4b1AeD2c6B8746Af6f10d07E1cA1284599AA on salt 0xc0f3 (seven-row chain table), daily grid, 600-block windows; 18 pools on wstETH/WETH, WETH/USDC and wstETH/USDC at 1, 7 and 14 days in both repay styles, seeded and cleared once; vaults pegged / usdLow / usdHigh, PositionMarket and six SeatLP tokens. Co-Authored-By: Claude Opus 5.5 --- deployments/sepolia.json | 65 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 deployments/sepolia.json diff --git a/deployments/sepolia.json b/deployments/sepolia.json new file mode 100644 index 0000000..ef6aa07 --- /dev/null +++ b/deployments/sepolia.json @@ -0,0 +1,65 @@ +{ + "batchInterval": 120, + "chainId": 11155111, + "confluere": "0x4E4B4b1AeD2c6B8746Af6f10d07E1cA1284599AA", + "demo": false, + "deployBlock": 11980911, + "grid": "daily", + "gridAnchor": 1791532800, + "gridPeriod": 86400, + "guardian": "0x5b38eF87c2be24041C62Ac97cAB1eB92f48eCDAE", + "keeperReceiver": "0x8b678D462eE84D4794893ed7904eFe3e11a07c06", + "maxExpiries": 15, + "router": "0x369E8a53C964A28FB498D07435D5550142E94506", + "seatRegistry": "0x39D03E2901aD498bE7053da853fFD8e1DB552782", + "vaultFactory": "0x62E89d6C5D8873f1838BCe884aF39092Bc098AB9", + "windowBlocks": 600, + "wrapperFactory": "0xC2A707FF784a89580e4810558f90952C6b4785cF", + "pools": { + "expiries": [ + 1792915200, + 1793433600, + 1794038400 + ], + "expiry": 1792915200, + "extraAsset": "0xB82381A3fBD3FaFA77B3a7bE693342618240067b", + "extraQuote": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "lst": "0xB82381A3fBD3FaFA77B3a7bE693342618240067b", + "native": "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", + "poolIds": [ + "0x9c453943cd925497eb0eca42457d76842ea9c4455b8d6ab24d4f06f6fa3fb700", + "0x7b732b3944b8f2a23a10d232477f093df8c03f1714f024e4fb4f371533a8646a", + "0x3a356f6947307466da3acae4760284db659e3d36fb6f56dc6773bc36a286ade8", + "0xd111effdf9eda60ca5544e28fa72cd386743d6de04a3fe8bdd8fc80e6056ee56", + "0x656c05fb88c8eb6eb006d32ae3528aa75a0c969e5ed6c54eba4802eafac3b0b8", + "0x513ca45e7d0579fc136a3feeb5c7c4d415fe122849466e98ed1a52af90677a84", + "0xfdbf38fb11999a6f964a2d12aa52fa4d7b0cc73abbb40dd7646226e73d05249e", + "0x102b7b3dc22e60169c847991ddb0dc387e30b6e9aae333719c5bfa5fdd422c76", + "0x3ec5251a0158f935fefcf71cc3cfecd6542afca1b40b47ad1a9a50e64e0d7728", + "0xb27993ca79e11790aa0dc58af7864876baa4ce0d03cc1080d9859158990c64f6", + "0xff7a21bf154f05afc896ec1e5881e177df9867235ef16e6845d589e61cdadb7e", + "0x318888363e9e9c2d6c16e3920f03abbed435c2171895714bb964c7f33261c6aa", + "0x0c67ceacdd8f1ad0616d5b04268a694031dfa49542a92bf36e832d65cf4b16b4", + "0xf53f5e36ec207623d62dd040c8068d274b08c3db3636af6de427a895f1bd9efa", + "0x82ffdf780298b80fdb3a145b2d4f39fc94f74f9578621b7752f519297115b15e", + "0xb8038f74f362ff3c5598744246bd2d746a650e1d5f0900d82569895450230aa6", + "0x937544e06a9ab53e6b329111e1d2ade0bd60a320427967b4d5bf0e71d001d6a0", + "0x3a8d12805d6a3c283877aede82847b610a387d7e6755ffd136f866385c0634a4" + ], + "usdc": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + }, + "vaults": { + "pegged": "0xaC1Bd55f342727E90cA03759a5103Addf4Ae7eaB", + "usdHigh": "0x95d7dfEc8760e5Dcbd9688355f7f548157A65bea", + "usdLow": "0x822aBb19eFc127449762b17D7152c22340C8DCc6" + }, + "positionMarket": "0xa9e57695e72C23f4011dD5C0aA7a7F6E678614cc", + "seatLPTokens": { + "0xB82381A3fBD3FaFA77B3a7bE693342618240067b-0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238-0": "0xDc69413F73Fa33Cf46147661F1F33A4E376a438c", + "0xB82381A3fBD3FaFA77B3a7bE693342618240067b-0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238-1": "0x7c848786828c73850091fB69A8A30AfA9E224000", + "0xB82381A3fBD3FaFA77B3a7bE693342618240067b-0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14-0": "0xaC05A8B71d02190Df08A2c502c13903C6F4e4d4F", + "0xB82381A3fBD3FaFA77B3a7bE693342618240067b-0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14-1": "0x521D04F25cAE52A1d9Dda1A583408794e9C48766", + "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14-0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238-0": "0x27fA4d02D7b25824Fc4F36Ae2aAA117838b82972", + "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14-0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238-1": "0x317402Ef4EA34cB6493003717Fb32aE7Ee2eaBBE" + } +} \ No newline at end of file From 8c99ea3f0ee35bed31e885116c9aaf8034998536 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 01:48:05 -0400 Subject: [PATCH 053/169] chore(sdk): regenerate deployments with Sepolia Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/deployments.ts | 65 +++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/sdk/src/deployments.ts b/packages/sdk/src/deployments.ts index d8b8c03..0aa7e97 100644 --- a/packages/sdk/src/deployments.ts +++ b/packages/sdk/src/deployments.ts @@ -167,5 +167,70 @@ export const deployments: Record = { "usdHigh": "0x54EE99296Fc889ac3cfbF0796861c66CB8dEB7a2", "usdLow": "0xD427d76ffF5Aba58aE35e913bD8BebAd30b04924" } + }, + "11155111": { + "batchInterval": 120, + "chainId": 11155111, + "confluere": "0x4E4B4b1AeD2c6B8746Af6f10d07E1cA1284599AA", + "demo": false, + "deployBlock": 11980911, + "grid": "daily", + "gridAnchor": 1791532800, + "gridPeriod": 86400, + "guardian": "0x5b38eF87c2be24041C62Ac97cAB1eB92f48eCDAE", + "keeperReceiver": "0x8b678D462eE84D4794893ed7904eFe3e11a07c06", + "maxExpiries": 15, + "router": "0x369E8a53C964A28FB498D07435D5550142E94506", + "seatRegistry": "0x39D03E2901aD498bE7053da853fFD8e1DB552782", + "vaultFactory": "0x62E89d6C5D8873f1838BCe884aF39092Bc098AB9", + "windowBlocks": 600, + "wrapperFactory": "0xC2A707FF784a89580e4810558f90952C6b4785cF", + "pools": { + "expiries": [ + 1792915200, + 1793433600, + 1794038400 + ], + "expiry": 1792915200, + "extraAsset": "0xB82381A3fBD3FaFA77B3a7bE693342618240067b", + "extraQuote": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "lst": "0xB82381A3fBD3FaFA77B3a7bE693342618240067b", + "native": "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", + "poolIds": [ + "0x9c453943cd925497eb0eca42457d76842ea9c4455b8d6ab24d4f06f6fa3fb700", + "0x7b732b3944b8f2a23a10d232477f093df8c03f1714f024e4fb4f371533a8646a", + "0x3a356f6947307466da3acae4760284db659e3d36fb6f56dc6773bc36a286ade8", + "0xd111effdf9eda60ca5544e28fa72cd386743d6de04a3fe8bdd8fc80e6056ee56", + "0x656c05fb88c8eb6eb006d32ae3528aa75a0c969e5ed6c54eba4802eafac3b0b8", + "0x513ca45e7d0579fc136a3feeb5c7c4d415fe122849466e98ed1a52af90677a84", + "0xfdbf38fb11999a6f964a2d12aa52fa4d7b0cc73abbb40dd7646226e73d05249e", + "0x102b7b3dc22e60169c847991ddb0dc387e30b6e9aae333719c5bfa5fdd422c76", + "0x3ec5251a0158f935fefcf71cc3cfecd6542afca1b40b47ad1a9a50e64e0d7728", + "0xb27993ca79e11790aa0dc58af7864876baa4ce0d03cc1080d9859158990c64f6", + "0xff7a21bf154f05afc896ec1e5881e177df9867235ef16e6845d589e61cdadb7e", + "0x318888363e9e9c2d6c16e3920f03abbed435c2171895714bb964c7f33261c6aa", + "0x0c67ceacdd8f1ad0616d5b04268a694031dfa49542a92bf36e832d65cf4b16b4", + "0xf53f5e36ec207623d62dd040c8068d274b08c3db3636af6de427a895f1bd9efa", + "0x82ffdf780298b80fdb3a145b2d4f39fc94f74f9578621b7752f519297115b15e", + "0xb8038f74f362ff3c5598744246bd2d746a650e1d5f0900d82569895450230aa6", + "0x937544e06a9ab53e6b329111e1d2ade0bd60a320427967b4d5bf0e71d001d6a0", + "0x3a8d12805d6a3c283877aede82847b610a387d7e6755ffd136f866385c0634a4" + ], + "usdc": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + }, + "vaults": { + "pegged": "0xaC1Bd55f342727E90cA03759a5103Addf4Ae7eaB", + "usdHigh": "0x95d7dfEc8760e5Dcbd9688355f7f548157A65bea", + "usdLow": "0x822aBb19eFc127449762b17D7152c22340C8DCc6" + }, + "positionMarket": "0xa9e57695e72C23f4011dD5C0aA7a7F6E678614cc", + "seatLPTokens": { + "0xB82381A3fBD3FaFA77B3a7bE693342618240067b-0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238-0": "0xDc69413F73Fa33Cf46147661F1F33A4E376a438c", + "0xB82381A3fBD3FaFA77B3a7bE693342618240067b-0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238-1": "0x7c848786828c73850091fB69A8A30AfA9E224000", + "0xB82381A3fBD3FaFA77B3a7bE693342618240067b-0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14-0": "0xaC05A8B71d02190Df08A2c502c13903C6F4e4d4F", + "0xB82381A3fBD3FaFA77B3a7bE693342618240067b-0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14-1": "0x521D04F25cAE52A1d9Dda1A583408794e9C48766", + "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14-0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238-0": "0x27fA4d02D7b25824Fc4F36Ae2aAA117838b82972", + "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14-0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238-1": "0x317402Ef4EA34cB6493003717Fb32aE7Ee2eaBBE" + } } } as const as unknown as Record; From c872046e923e68e41bea612f7ec5f7fe60bb051a Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 01:48:05 -0400 Subject: [PATCH 054/169] chore(keeper-cre): Sepolia workflow config (receiver, 18 pools, 3 vaults) Co-Authored-By: Claude Opus 5.5 --- services/keeper-cre/config.sepolia.json | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 services/keeper-cre/config.sepolia.json diff --git a/services/keeper-cre/config.sepolia.json b/services/keeper-cre/config.sepolia.json new file mode 100644 index 0000000..46229dc --- /dev/null +++ b/services/keeper-cre/config.sepolia.json @@ -0,0 +1,32 @@ +{ + "schedule": "5 */2 * * * *", + "chainSelectorName": "ethereum-testnet-sepolia", + "isTestnet": true, + "receiver": "0x8b678D462eE84D4794893ed7904eFe3e11a07c06", + "pools": [ + "0x9c453943cd925497eb0eca42457d76842ea9c4455b8d6ab24d4f06f6fa3fb700", + "0x7b732b3944b8f2a23a10d232477f093df8c03f1714f024e4fb4f371533a8646a", + "0x3a356f6947307466da3acae4760284db659e3d36fb6f56dc6773bc36a286ade8", + "0xd111effdf9eda60ca5544e28fa72cd386743d6de04a3fe8bdd8fc80e6056ee56", + "0x656c05fb88c8eb6eb006d32ae3528aa75a0c969e5ed6c54eba4802eafac3b0b8", + "0x513ca45e7d0579fc136a3feeb5c7c4d415fe122849466e98ed1a52af90677a84", + "0xfdbf38fb11999a6f964a2d12aa52fa4d7b0cc73abbb40dd7646226e73d05249e", + "0x102b7b3dc22e60169c847991ddb0dc387e30b6e9aae333719c5bfa5fdd422c76", + "0x3ec5251a0158f935fefcf71cc3cfecd6542afca1b40b47ad1a9a50e64e0d7728", + "0xb27993ca79e11790aa0dc58af7864876baa4ce0d03cc1080d9859158990c64f6", + "0xff7a21bf154f05afc896ec1e5881e177df9867235ef16e6845d589e61cdadb7e", + "0x318888363e9e9c2d6c16e3920f03abbed435c2171895714bb964c7f33261c6aa", + "0x0c67ceacdd8f1ad0616d5b04268a694031dfa49542a92bf36e832d65cf4b16b4", + "0xf53f5e36ec207623d62dd040c8068d274b08c3db3636af6de427a895f1bd9efa", + "0x82ffdf780298b80fdb3a145b2d4f39fc94f74f9578621b7752f519297115b15e", + "0xb8038f74f362ff3c5598744246bd2d746a650e1d5f0900d82569895450230aa6", + "0x937544e06a9ab53e6b329111e1d2ade0bd60a320427967b4d5bf0e71d001d6a0", + "0x3a8d12805d6a3c283877aede82847b610a387d7e6755ffd136f866385c0634a4" + ], + "vaults": [ + "0xaC1Bd55f342727E90cA03759a5103Addf4Ae7eaB", + "0x95d7dfEc8760e5Dcbd9688355f7f548157A65bea", + "0x822aBb19eFc127449762b17D7152c22340C8DCc6" + ], + "gasLimit": "30000000" +} From 81664d31571c5b893784950986d728a9df6a3937 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 055/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (contracts/script) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: - Spread share is a fixed 5% of (K - P*) per seller fill, withheld at clearing into the pool's feeEscrow (capped so a fill never reverts). At close the escrow splits once by repaidUnits / filledUnits: the repaid part to the treasury, the defaulted part to C holders (FeeEscrowSplit). repaidUnits counts repay, rollover fills (old pool) and abandonment- auction fills. repay no longer takes a fee; vwap / sumPU are gone. - Trade fee 0.015% per side. Buy escrow = cost at limit + fee + 2 wei per batch (Settlement.buyEscrow); NC_BUY budgets are all-in (Settlement.ncUnits). Router and SeatRegistry use the same helper. Rollover maxTopUp must also cover the seller's fees at minPrice. - Graduation counts treasury fees per expiry (MarketStats.fees: trade fees at clearing + treasury spread share at close) against graduationFee[quote] (was graduationVolume). marketFees now carries trade fees + spread share. - Non-competitive buys enter the price (simulation-pinned rule): cross competitive orders alone -> q0, P0; cap = 50% of q0; recross once with d = min(NC units at P0, cap) any-price units ahead of competitive buys; P* = midpoint of the marginal competitive pair of the recross; fall back to the competitive-only result if no competitive buy would fill. NC sells never price; they still trade at P* inside the remaining cap. BatchSettled carries the fees and capBase (q0). Why: BLUEPRINT v0.8 §4.2, §8.1, §8.2, §10; audit M-2 / M-3; the Simulation-Confluere pinned rules for fees and NC pricing. Invariants (ARCHITECTURE §2.11): 1 (feeEscrow added to the quote side), 3 (limits vs P*, NC cap now relative to q0), 4 (escrow leaves the pool once, at close), 5 (repay has no fee branch), 7 (all fees are ledger entries / pending). Part 1/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/script/DeployConfig.sol, contracts/script/checks/ScriptChecks.s.sol Co-Authored-By: Claude Opus 5.5 --- contracts/script/DeployConfig.sol | 2 +- contracts/script/checks/ScriptChecks.s.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/script/DeployConfig.sol b/contracts/script/DeployConfig.sol index 109bd8c..8613de6 100644 --- a/contracts/script/DeployConfig.sol +++ b/contracts/script/DeployConfig.sol @@ -88,7 +88,7 @@ library DeployConfig { p.guardian = guardian; // Empty means no market can graduate on that chain (and vaults never invest there), and no order minimum. p.graduationQuotes = g.quotes; - p.graduationVolumes = g.volumes; + p.graduationFees = g.volumes; p.minNotionalQuotes = g.quotes; p.minNotionals = g.minNotionals; } diff --git a/contracts/script/checks/ScriptChecks.s.sol b/contracts/script/checks/ScriptChecks.s.sol index ccdb84c..d4a0f5c 100644 --- a/contracts/script/checks/ScriptChecks.s.sol +++ b/contracts/script/checks/ScriptChecks.s.sol @@ -194,7 +194,7 @@ contract ScriptChecks is Script { vm.setEnv("SALT", vm.toString(bytes32(uint256(0xc0f3)))); Confluere core = new Deploy().run(); require(core.WINDOW_BLOCKS() == 600 && core.grid().period == 1 days, "sepolia: window / grid"); - require(core.graduationVolume(weth) == 1e17 && core.minNotional(usdc) == 100, "sepolia: GRAD row"); + require(core.graduationFee(weth) == 1e17 && core.minNotional(usdc) == 100, "sepolia: GRAD row"); require(vm.parseJsonUint(vm.readFile(file), ".chainId") == 11_155_111, "sepolia: chain id not recorded"); vm.setEnv("GRID", ""); vm.setEnv("DEMO", "true"); @@ -364,7 +364,7 @@ contract ScriptChecks is Script { } for (uint256 i; i < ids.length; ++i) { IConfluere.PoolView memory v = core.pool(ids[i]); - uint256 threshold = core.graduationVolume(v.key.quote); + uint256 threshold = core.graduationFee(v.key.quote); uint256 vol = core.marketStats(PoolKeyLib.marketId(v.key.asset, v.key.quote, v.key.kind), v.key.expiry).volume; require(threshold != 0, "no GRAD row for this quote"); From d05dd973fc77a92e2ecf8a65f142fc31eec976ff Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 056/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (Confluere.sol) Part 2/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/src/Confluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/Confluere.sol | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/contracts/src/Confluere.sol b/contracts/src/Confluere.sol index 5bc8c35..1219ff9 100644 --- a/contracts/src/Confluere.sol +++ b/contracts/src/Confluere.sol @@ -62,7 +62,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen address treasury; address guardian; address[] graduationQuotes; - uint256[] graduationVolumes; + uint256[] graduationFees; address[] minNotionalQuotes; // anti-dust: smallest order per quote token, valued at K uint256[] minNotionals; } @@ -86,8 +86,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen if (k == type(uint256).max) revert UnsupportedChain(); ChainParams memory cp = c.chains[k]; if ( - cp.windowBlocks == 0 || cp.treasury == address(0) - || cp.graduationQuotes.length != cp.graduationVolumes.length + cp.windowBlocks == 0 || cp.treasury == address(0) || cp.graduationQuotes.length != cp.graduationFees.length || cp.minNotionalQuotes.length != cp.minNotionals.length ) revert InvalidParams(); GRID_ANCHOR = c.grid.anchor; @@ -102,7 +101,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen treasury = cp.treasury; guardian = cp.guardian; for (uint256 i; i < cp.graduationQuotes.length; ++i) { - s.graduationVolume[cp.graduationQuotes[i]] = cp.graduationVolumes[i]; + s.graduationFee[cp.graduationQuotes[i]] = cp.graduationFees[i]; } for (uint256 i; i < cp.minNotionalQuotes.length; ++i) { s.minNotional[cp.minNotionalQuotes[i]] = cp.minNotionals[i]; @@ -188,7 +187,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen /// @inheritdoc IConfluere function repay(bytes32 poolId, uint96 units) external nonReentrant { - WindowLogic.repay(s, _params(), poolId, units); + WindowLogic.repay(s, poolId, units); } /// @inheritdoc IConfluere @@ -202,7 +201,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen /// @inheritdoc IConfluere function closeWindow(bytes32 poolId, uint256 maxBids) external nonReentrant returns (bool) { - return WindowLogic.closeWindow(s, poolId, maxBids); + return WindowLogic.closeWindow(s, _params(), poolId, maxBids); } /// @inheritdoc IConfluere @@ -236,10 +235,10 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen /// @inheritdoc IConfluere /// @dev Permissionless. A market graduates after GRADUATION_CYCLES consecutive past expiries that each reached - /// the per-quote volume baseline with no pause and no circuit breaker. Graduation is permanent. + /// the per-quote treasury-fee baseline (trade fees + spread share) with no pause and no circuit breaker. Graduation is permanent. function graduate(address asset, address quote, Kind kind, uint64 lastExpiry) external { bytes32 m = PoolKeyLib.marketId(asset, quote, kind); - uint256 threshold = s.graduationVolume[quote]; + uint256 threshold = s.graduationFee[quote]; GridConfig memory g = grid(); if (s.graduated[m] || threshold == 0 || !Grid.isOnGrid(g, lastExpiry) || lastExpiry >= block.timestamp) { revert InvalidParams(); @@ -247,7 +246,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen if (lastExpiry < g.anchor + uint256(g.period) * (GRADUATION_CYCLES - 1)) revert InvalidParams(); for (uint256 i; i < GRADUATION_CYCLES; ++i) { MarketStats storage st = s.stats[m][uint64(lastExpiry - i * g.period)]; - if (st.bad || st.volume < threshold) revert InvalidState(); + if (st.bad || st.fees < threshold) revert InvalidState(); } s.graduated[m] = true; emit PoolGraduated(m, lastExpiry); @@ -359,7 +358,9 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen v.quoteBal = ps.quoteBal; v.assetBal = ps.assetBal; v.surplusBal = ps.surplusBal; - v.vwap = CoreLib.vwap(ps); + v.feeEscrow = ps.feeEscrow; + v.filledUnits = ps.filledUnits; + v.repaidUnits = ps.repaidUnits; v.closeRemaining = ps.closeRemaining; v.options = PoolOptions(ps.cadence, ps.minFill, ps.firstLo, ps.firstHi); v.batchInterval = uint64(ps.interval(GRID_BATCH)); @@ -393,10 +394,6 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen return uint256(GRID_ANCHOR) + uint256(ps.lastSettledBatch + 1) * ps.interval(GRID_BATCH); } - function vwap(bytes32 poolId) external view returns (uint256) { - return CoreLib.vwap(s.pools[poolId]); - } - function expiryWindowEnd(uint64 expiry) external view returns (uint64) { return s.windowEnd[expiry]; } @@ -405,8 +402,8 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen return s.graduated[marketId]; } - function graduationVolume(address quote) external view returns (uint256) { - return s.graduationVolume[quote]; + function graduationFee(address quote) external view returns (uint256) { + return s.graduationFee[quote]; } /// @notice Cumulative spread share collected in a market (in its quote token). Lets the treasury split fees From 445f8d87b0eb7b5e6da4ef5c006f3d6f3e12e106 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 057/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (IConfluere.sol) Part 3/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/src/interfaces/IConfluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/interfaces/IConfluere.sol | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/contracts/src/interfaces/IConfluere.sol b/contracts/src/interfaces/IConfluere.sol index a7be39d..84e2a13 100644 --- a/contracts/src/interfaces/IConfluere.sol +++ b/contracts/src/interfaces/IConfluere.sol @@ -41,7 +41,9 @@ interface IConfluere { uint256 quoteBal; uint256 assetBal; uint256 surplusBal; - uint256 vwap; + uint256 feeEscrow; + uint256 filledUnits; + uint256 repaidUnits; uint256 closeRemaining; PoolOptions options; uint64 batchInterval; @@ -49,6 +51,7 @@ interface IConfluere { struct MarketStats { uint128 volume; // quote traded at this expiry across the market + uint128 fees; // treasury fees earned at this expiry: trade fees at clearing + spread share at close uint96 minStrike; // lowest strike with any fill at this expiry uint96 maxStrike; // highest strike with any fill at this expiry bool bad; // paused or circuit breaker tripped at this expiry @@ -67,11 +70,21 @@ interface IConfluere { uint32 deadline ); event OrderRemoved(bytes32 indexed poolId, uint64 indexed orderId, uint8 reason); - event OrderFilled(bytes32 indexed poolId, uint64 indexed orderId, uint256 units, uint256 quoteAmount); + event OrderFilled(bytes32 indexed poolId, uint64 indexed orderId, uint256 units, uint256 quoteAmount, uint256 fee); event BatchSettled( - bytes32 indexed poolId, uint32 indexed settleIndex, uint256 price, uint256 competitiveUnits, uint256 ncUnits + bytes32 indexed poolId, + uint32 indexed settleIndex, + uint256 price, + uint256 competitiveUnits, + uint256 ncUnits, + uint256 spreadToEscrow, + uint256 tradeFees, + uint256 capBase // competitive-only crossing volume q0: non-competitive units are capped at half of it + ); + event Repaid(bytes32 indexed poolId, address indexed payer, uint256 units, uint256 quotePaid); + event FeeEscrowSplit( + bytes32 indexed poolId, uint256 toTreasury, uint256 toLenders, uint256 repaidUnits, uint256 filledUnits ); - event Repaid(bytes32 indexed poolId, address indexed payer, uint256 units, uint256 quotePaid, uint256 fee); event RolledOver( bytes32 indexed oldPoolId, bytes32 indexed newPoolId, address indexed owner, uint256 units, uint256 topUp ); @@ -130,7 +143,6 @@ interface IConfluere { function bookHead(bytes32 poolId, uint8 side) external view returns (uint64 head, uint32 count); function findHint(bytes32 poolId, uint8 side, uint96 limit) external view returns (uint64); function nextBatchAt(bytes32 poolId) external view returns (uint256); - function vwap(bytes32 poolId) external view returns (uint256); function graduated(bytes32 marketId) external view returns (bool); /// @notice Block at which the shared repayment window of `expiry` ends; 0 until the first pool of it matures. function expiryWindowEnd(uint64 expiry) external view returns (uint64); From 20b48701e1181f241ba3256fb3acaa89e48a5af1 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 058/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (Router.sol) Part 4/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/src/periphery/Router.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/Router.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/src/periphery/Router.sol b/contracts/src/periphery/Router.sol index ead1b34..5de5404 100644 --- a/contracts/src/periphery/Router.sol +++ b/contracts/src/periphery/Router.sol @@ -6,6 +6,7 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {IConfluere} from "../interfaces/IConfluere.sol"; import {PoolKeyLib} from "../libraries/PoolKey.sol"; import {SELL, BUY, NC_SELL, NC_BUY} from "../libraries/OrderSet.sol"; +import {Settlement} from "../libraries/Settlement.sol"; import {Clearing} from "../libraries/Clearing.sol"; interface IConfluere6909 { @@ -75,6 +76,6 @@ contract Router { { if (p.side == SELL || p.side == NC_SELL) return (v.key.asset, p.units); if (p.side == NC_BUY) return (v.key.quote, p.budget); - return (v.key.quote, Clearing.quoteUp(p.units, p.limit, 10 ** uint256(v.assetDecimals)) + p.ttl); + return (v.key.quote, Settlement.buyEscrow(p.units, p.limit, 10 ** uint256(v.assetDecimals), p.ttl)); } } From 080c4cfefebd37b3d7fc73ec38972dd86856217e Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 059/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (SeatRegistry.sol) Part 5/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/src/periphery/SeatRegistry.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/SeatRegistry.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/src/periphery/SeatRegistry.sol b/contracts/src/periphery/SeatRegistry.sol index 6cecf25..eb8d60a 100644 --- a/contracts/src/periphery/SeatRegistry.sol +++ b/contracts/src/periphery/SeatRegistry.sol @@ -10,6 +10,7 @@ import {IConfluere} from "../interfaces/IConfluere.sol"; import {PoolKeyLib, Kind, Status} from "../libraries/PoolKey.sol"; import {GridConfig} from "../libraries/Grid.sol"; import {SELL, BUY} from "../libraries/OrderSet.sol"; +import {Settlement} from "../libraries/Settlement.sol"; import {Clearing} from "../libraries/Clearing.sol"; /// @title SeatRegistry @@ -199,7 +200,7 @@ contract SeatRegistry is ReentrancyGuardTransient { (address token, uint256 amount) = p.side == SELL ? (v.key.asset, uint256(p.units)) - : (v.key.quote, Clearing.quoteUp(p.units, p.limit, scale) + p.ttl); + : (v.key.quote, Settlement.buyEscrow(p.units, p.limit, scale, p.ttl)); IERC20(token).safeTransferFrom(msg.sender, address(this), amount); IERC20(token).forceApprove(address(core), amount); IConfluere.OrderParams memory o = p; From 9af795ea39673ad6f5dc43bc4e95db5025b3729e Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 060/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (Settlement.sol) Part 6/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/src/libraries/Settlement.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/Settlement.sol | 49 +++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/contracts/src/libraries/Settlement.sol b/contracts/src/libraries/Settlement.sol index 277b85a..8f3e04e 100644 --- a/contracts/src/libraries/Settlement.sol +++ b/contracts/src/libraries/Settlement.sol @@ -12,14 +12,53 @@ library Settlement { return FPM.fullMulDivUp(units, strike, scale); } - /// @notice Spread share: feeBps of the interest (K - vwap) on `units`. Zero when vwap >= K, never underflows. - function fee(uint256 units, uint256 strike, uint256 vwap, uint256 feeBps, uint256 scale) + uint256 internal constant SPREAD_SHARE_BPS = 500; // 5% of the interest K - P*, per fill (blueprint 8.1) + uint256 internal constant TRADE_FEE_PPM = 150; // 0.015% of notional on each side (blueprint 8.2) + uint256 internal constant PPM = 1_000_000; + + /// @notice Trade fee on a notional, rounded down. + function tradeFee(uint256 notional) internal pure returns (uint256) { + return notional * TRADE_FEE_PPM / PPM; + } + + /// @notice Spread share on `units` filled at `price`: 5% of (K - price), rounded down. Zero at or above K. + function spreadShare(uint256 units, uint256 strike, uint256 price, uint256 scale) internal pure returns (uint256) { + if (price >= strike) return 0; + return FPM.fullMulDiv(units * (strike - price), SPREAD_SHARE_BPS, scale * BPS); + } + + /// @notice Seller side of a fill: gross proceeds (rounded down), the spread share into the pool's escrow, the + /// seller's trade fee, and what the seller keeps. The spread is capped so a fill never reverts. + function sellSplit(uint256 units, uint256 price, uint256 strike, uint256 scale) internal pure - returns (uint256) + returns (uint256 gross, uint256 spread, uint256 trade, uint256 net) { - if (strike <= vwap) return 0; - return FPM.fullMulDiv(units * (strike - vwap), feeBps, scale * BPS); + gross = FPM.fullMulDiv(units, price, scale); + trade = tradeFee(gross); + spread = spreadShare(units, strike, price, scale); + if (spread > gross - trade) spread = gross - trade; + net = gross - trade - spread; + } + + /// @notice Escrow for a competitive buy: cost at the limit (rounded up), its trade fee, and 2 wei of rounding + /// reserve per batch it can trade in (1 for the cost, 1 for the fee). + function buyEscrow(uint256 units, uint256 limit, uint256 scale, uint256 ttl) internal pure returns (uint256) { + uint256 g = FPM.fullMulDivUp(units, limit, scale); + return g + tradeFee(g) + 2 * ttl; + } + + /// @notice Units an all-in quote budget (cost + trade fee) buys at `price`, rounded down with 1 wei reserve. + function ncUnits(uint256 budget, uint256 price, uint256 scale) internal pure returns (uint256) { + uint256 b = budget * PPM / (PPM + TRADE_FEE_PPM); + if (b <= 1) return 0; + return FPM.fullMulDiv(b - 1, scale, price); + } + + /// @notice Treasury part of a pool's spread escrow at close: the repaid fraction, rounded down. + function feeSplit(uint256 escrow, uint256 repaid, uint256 filled) internal pure returns (uint256) { + if (filled == 0) return 0; + return FPM.fullMulDiv(escrow, repaid, filled); } /// @notice Pro-rata share of a remaining balance. Computed on what is left, so the last claimer takes the dust. From eb2933c522764c55b0b35762ed14c8b2e61d00b1 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 061/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (State.sol) Part 7/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/src/libraries/State.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/State.sol | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/contracts/src/libraries/State.sol b/contracts/src/libraries/State.sol index fcb38b1..3f2dc44 100644 --- a/contracts/src/libraries/State.sol +++ b/contracts/src/libraries/State.sol @@ -31,8 +31,9 @@ struct PoolState { uint256 quoteBal; // quote owned by C holders uint256 assetBal; // asset owned by the pool (backs outstanding R, then C after close) uint256 surplusBal; // abandonment-auction surplus owed to R holders - uint256 sumPU; // sum(price * units) over fills, for vwap - uint256 sumUnits; + uint256 feeEscrow; // spread share withheld from sellers at clearing, split at close by the repaid fraction + uint256 filledUnits; // units ever filled (= C minted) + uint256 repaidUnits; // units whose K was paid in full: repay, rollover, abandonment-auction fill uint256 closeRemaining; // unrepaid R units still open to bids during Closing } @@ -46,9 +47,9 @@ struct CoreState { mapping(address => mapping(address => uint256)) pending; mapping(address => mapping(bytes32 => uint256)) lockedR; mapping(bytes32 => bool) graduated; - mapping(address => uint256) graduationVolume; + mapping(address => uint256) graduationFee; // per quote token: treasury fees an expiry must earn to count mapping(address => uint256) minNotional; // per quote token: smallest order, valued at K (anti-dust) - mapping(bytes32 => uint256) marketFees; // cumulative spread share per market, in its quote token (seat split) + mapping(bytes32 => uint256) marketFees; // cumulative treasury fees per market (trade fees + spread share) mapping(address => uint256) flashFees; // cumulative flash-loan fees per token (not tied to a market) mapping(uint64 => uint64) windowEnd; // per expiry: one repayment window for every pool, set by the first mature() // ERC-6909 ledger @@ -101,8 +102,11 @@ library CoreLib { return 10 ** uint256(ps.assetDecimals); } - function vwap(PoolState storage ps) internal view returns (uint256) { - return ps.sumUnits == 0 ? 0 : ps.sumPU / ps.sumUnits; + /// @notice Counts treasury fees toward the market's graduation at this expiry. Saturating: never blocks. + function addFees(CoreState storage s, PoolState storage ps, uint256 amount) internal { + IConfluere.MarketStats storage st = s.stats[ps.marketId][ps.key.expiry]; + uint256 f = uint256(st.fees) + amount; + st.fees = f > type(uint128).max ? type(uint128).max : uint128(f); } function newOrderId(CoreState storage s) internal returns (uint64 id) { From 6d796778f50b4e39dfc2021faec6cb58714008ba Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 062/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (libraries/logic) Part 8/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/src/libraries/logic/ClearingLogic.sol, contracts/src/libraries/logic/OrderLogic.sol, contracts/src/libraries/logic/WindowLogic.sol Co-Authored-By: Claude Opus 5.5 --- .../src/libraries/logic/ClearingLogic.sol | 177 ++++++++++++------ contracts/src/libraries/logic/OrderLogic.sol | 13 +- contracts/src/libraries/logic/WindowLogic.sol | 38 +++- 3 files changed, 157 insertions(+), 71 deletions(-) diff --git a/contracts/src/libraries/logic/ClearingLogic.sol b/contracts/src/libraries/logic/ClearingLogic.sol index bd1c1af..6ae3e91 100644 --- a/contracts/src/libraries/logic/ClearingLogic.sol +++ b/contracts/src/libraries/logic/ClearingLogic.sol @@ -11,10 +11,13 @@ import {CoreState, PoolState, Params, CoreLib} from "../State.sol"; /// @notice Uniform-price batch clearing. Linked library; runs in the core's context. /// -/// Pass 1 (read-only) walks both competitive books from the top to find the crossing volume Q and the marginal -/// pair; P* is their midpoint. If P* is inside the band, non-competitive orders then trade at P* against each other -/// and against competitive leftovers still willing at P*, capped at 50% of Q. Pass 2 applies the fills in book order, -/// skipping exactly the orders pass 1 skipped, so the allocations match. +/// Pass 1 (read-only) crosses the competitive books alone: volume q0 and price P0 (midpoint of the marginal pair). +/// If P0 is inside the band, non-competitive buy demand enters the price once (blueprint 4.2): d = min(NC budget at P0, +/// 50% of q0) units of any-price demand take the cheapest sells first, the competitive books recross on what is left, +/// and P* is the midpoint of the marginal competitive pair of that recross. When d would leave no competitive buy +/// filled there is no price from NC demand and the competitive-only result stands. NC sells never price. Then +/// non-competitive sells trade at P* against competitive leftovers and remaining NC budget, inside the same cap. +/// Pass 2 applies the fills in book order, skipping exactly the orders pass 1 skipped, so the allocations match. library ClearingLogic { using CoreLib for CoreState; using CoreLib for PoolState; @@ -33,6 +36,8 @@ library ClearingLogic { address treasury; address quote; uint256 strike; + uint256 spread; + uint256 trade; } struct Cursor { @@ -42,6 +47,8 @@ library ClearingLogic { struct Plan { uint256 q; + uint256 ncX; // NC buy units priced in by the recross + uint256 q0; // competitive-only crossing volume, the base of the 50% cap uint256 sellC2; uint256 buyC2; uint256 sellNC; @@ -70,15 +77,20 @@ library ClearingLogic { Plan memory pl = _plan(s, ps, bt); uint256 total; if (pl.q != 0) { - _fillSorted(s, ps, bt, SELL, pl.q + pl.sellC2); + _fillSorted(s, ps, bt, SELL, pl.q + pl.ncX + pl.sellC2); _fillSorted(s, ps, bt, BUY, pl.q + pl.buyC2); if (pl.sellNC != 0) _fillQueue(s, ps, bt, NC_SELL, pl.sellNC); - if (pl.buyNC != 0) _fillQueue(s, ps, bt, NC_BUY, pl.buyNC); + if (pl.ncX + pl.buyNC != 0) _fillQueue(s, ps, bt, NC_BUY, pl.ncX + pl.buyNC); - total = pl.q + pl.sellNC + pl.sellC2; + total = pl.q + pl.ncX + pl.sellNC + pl.sellC2; ps.quoteBal += bt.paid - bt.received; - ps.sumPU += bt.price * total; - ps.sumUnits += total; + ps.feeEscrow += bt.spread; + ps.filledUnits += total; + if (bt.trade != 0) { + s.pending[bt.quote][bt.treasury] += bt.trade; + s.marketFees[ps.marketId] += bt.trade; + CoreLib.addFees(s, ps, bt.trade); + } IConfluere.MarketStats storage st = s.stats[ps.marketId][ps.key.expiry]; // Saturating: the counter only feeds graduation and must never block clearing. uint256 vol = uint256(st.volume) + Clearing.quoteDown(total, bt.price, bt.scale); @@ -87,39 +99,79 @@ library ClearingLogic { if (st.minStrike == 0 || k < st.minStrike) st.minStrike = k; if (k > st.maxStrike) st.maxStrike = k; } - emit IConfluere.BatchSettled(poolId, bt.sc, bt.price, pl.q, total - pl.q); + emit IConfluere.BatchSettled(poolId, bt.sc, bt.price, pl.q, total - pl.q, bt.spread, bt.trade, pl.q0); } function _plan(CoreState storage s, PoolState storage ps, Batch memory bt) private returns (Plan memory pl) { Cursor memory cs; Cursor memory cb; - (pl.q, cs, cb) = _cross(s, bt); + (pl.q, cs, cb, bt.price) = _cross(s, bt, 0); if (pl.q == 0) return pl; // Minimum fill and first-auction bounds: too little competitive volume, or a first price outside the creator's // range, means the batch does not trade and every order rolls to the next batch. if ( pl.q < ps.minFill - || (ps.sumUnits == 0 && (bt.price < ps.firstLo || (ps.firstHi != 0 && bt.price > ps.firstHi))) + || (ps.filledUnits == 0 && (bt.price < ps.firstLo || (ps.firstHi != 0 && bt.price > ps.firstHi))) ) { pl.q = 0; bt.price = 0; return pl; } + pl.q0 = pl.q; + if (_bandAllowsNc(s, ps, bt.price)) { + uint256 cap = pl.q * NC_SHARE_BPS / Clearing.BPS; + _priceNc(s, bt, pl, cs, cb, cap); + if (pl.ncX != 0) ps.bandBase = SafeCast.toUint96(bt.price); + _sizeNc(s, bt, pl, cs, cb, cap); + } ps.lastClearingPrice = SafeCast.toUint96(bt.price); - if (_bandAllowsNc(s, ps, bt.price)) _sizeNc(s, bt, pl, cs, cb); } - /// @dev Pass 1: crossing volume and P*. Leaves the cursors where competitive matching stopped. - function _cross(CoreState storage s, Batch memory bt) + /// @dev Non-competitive buy demand in the price: one recross with d = min(NC units at P0, cap) any-price units + /// ahead of the competitive buys. Adopted only if a competitive buy still fills and some NC demand is eligible at + /// the new price; the units taken are what that demand affords at the new price, never more than d. + function _priceNc( + CoreState storage s, + Batch memory bt, + Plan memory pl, + Cursor memory cs, + Cursor memory cb, + uint256 cap + ) private view { + uint256 d = _sumQueue(s, bt.poolId, NC_BUY, bt, cap); + if (d > cap) d = cap; + if (d == 0) return; + (uint256 q1, Cursor memory cs1, Cursor memory cb1, uint256 p1) = _cross(s, bt, d); + if (q1 == 0) return; + uint256 p0 = bt.price; + bt.price = p1; + uint256 x = _sumQueue(s, bt.poolId, NC_BUY, bt, d); + if (x == 0) { + bt.price = p0; + return; + } + pl.q = q1; + pl.ncX = x < d ? x : d; + (cs.id, cs.rem, cb.id, cb.rem) = (cs1.id, cs1.rem, cb1.id, cb1.rem); + } + + /// @dev Pass 1: crossing volume and the midpoint of the marginal competitive pair, after `pre` units of + /// any-price demand have taken the cheapest sells. Leaves the cursors where competitive matching stopped. + function _cross(CoreState storage s, Batch memory bt, uint256 pre) private view - returns (uint256 q, Cursor memory cs, Cursor memory cb) + returns (uint256 q, Cursor memory cs, Cursor memory cb, uint256 price) { - bytes32 poolId = bt.poolId; - mapping(uint64 => Order) storage os = s.orders[poolId]; - cs = _firstLive(s, poolId, s.books[poolId][SELL].head, bt.sc); - cb = _firstLive(s, poolId, s.books[poolId][BUY].head, bt.sc); - uint256 lastS; + mapping(uint64 => Order) storage os = s.orders[bt.poolId]; + cs = _firstLive(s, bt.poolId, s.books[bt.poolId][SELL].head, bt.sc); + cb = _firstLive(s, bt.poolId, s.books[bt.poolId][BUY].head, bt.sc); + while (pre != 0 && cs.id != 0) { + uint256 t = cs.rem < pre ? cs.rem : pre; + pre -= t; + cs.rem -= t; + if (cs.rem == 0) cs = _firstLive(s, bt.poolId, os[cs.id].next, bt.sc); + } + if (pre != 0) return (0, cs, cb, 0); uint256 lastB; while (cs.id != 0 && cb.id != 0) { uint96 sl = os[cs.id].limit; @@ -127,31 +179,36 @@ library ClearingLogic { if (sl > bl) break; uint256 m = cs.rem < cb.rem ? cs.rem : cb.rem; q += m; - lastS = sl; + price = sl; // marginal sell; becomes the midpoint below lastB = bl; cs.rem -= m; cb.rem -= m; - if (cs.rem == 0) cs = _firstLive(s, poolId, os[cs.id].next, bt.sc); - if (cb.rem == 0) cb = _firstLive(s, poolId, os[cb.id].next, bt.sc); + if (cs.rem == 0) cs = _firstLive(s, bt.poolId, os[cs.id].next, bt.sc); + if (cb.rem == 0) cb = _firstLive(s, bt.poolId, os[cb.id].next, bt.sc); } - if (q != 0) bt.price = Clearing.midpoint(lastS, lastB); + if (q != 0) price = Clearing.midpoint(price, lastB); } - /// @dev Stage 2 sizing: non-competitive volume at P*, capped at 50% of the competitive volume. - function _sizeNc(CoreState storage s, Batch memory bt, Plan memory pl, Cursor memory cs, Cursor memory cb) - private - view - { - uint256 cap = pl.q * NC_SHARE_BPS / Clearing.BPS; - if (cap == 0) return; - uint256 ncS = _sumQueue(s, bt.poolId, NC_SELL, bt, cap); + /// @dev Stage 2 sizing: non-competitive volume at P*, inside what is left of the 50% cap after the recross. + function _sizeNc( + CoreState storage s, + Batch memory bt, + Plan memory pl, + Cursor memory cs, + Cursor memory cb, + uint256 cap + ) private view { + if (cap <= pl.ncX) return; + uint256 cap2 = cap - pl.ncX; + uint256 ncS = _sumQueue(s, bt.poolId, NC_SELL, bt, cap2); uint256 ncB = _sumQueue(s, bt.poolId, NC_BUY, bt, cap); - uint256 cSL = _sumLeftover(s, bt.poolId, SELL, cs, bt, cap); + ncB = ncB > pl.ncX ? ncB - pl.ncX : 0; + uint256 cSL = _sumLeftover(s, bt.poolId, SELL, cs, bt, cap2); // By construction at most one competitive side still has orders willing at P*; never match comp vs comp. - uint256 cBL = cSL != 0 ? 0 : _sumLeftover(s, bt.poolId, BUY, cb, bt, cap); + uint256 cBL = cSL != 0 ? 0 : _sumLeftover(s, bt.poolId, BUY, cb, bt, cap2); uint256 m2 = ncS + cSL; if (ncB + cBL < m2) m2 = ncB + cBL; - if (cap < m2) m2 = cap; + if (cap2 < m2) m2 = cap2; pl.sellNC = ncS < m2 ? ncS : m2; pl.sellC2 = m2 - pl.sellNC; pl.buyNC = ncB < m2 ? ncB : m2; @@ -209,7 +266,7 @@ library ClearingLogic { if (o.deadline <= bt.sc) { s.removeWithRefund(bt.poolId, ps, id, CoreLib.RM_EXPIRED); } else if (_ncEligible(o, side, bt)) { - uint256 avail = side == NC_SELL ? o.units : Clearing.unitsFor(o.escrow, bt.price, bt.scale); + uint256 avail = side == NC_SELL ? o.units : Settlement.ncUnits(o.escrow, bt.price, bt.scale); uint256 f = avail < target ? avail : target; if (f != 0) { target -= f; @@ -229,17 +286,15 @@ library ClearingLogic { Order storage o, uint256 f ) private { - uint256 proceeds = Clearing.quoteDown(f, bt.price, bt.scale); - bt.received += proceeds; + uint256 net = _sellProceeds(bt, id, f); address owner = o.owner; ps.assetBal += f; if (o.flags & FLAG_ROLLOVER != 0) { - _settleRollover(s, bt, id, o, f, proceeds); + _settleRollover(s, bt, id, o, f, net); } else { - s.pending[ps.key.quote][owner] += proceeds; + s.pending[ps.key.quote][owner] += net; } s.mint(owner, bt.poolId.rId(), f); - emit IConfluere.OrderFilled(bt.poolId, id, f, proceeds); uint96 rem = o.units - uint96(f); if (rem == 0) { o.units = 0; @@ -249,7 +304,20 @@ library ClearingLogic { } } - /// @dev New-pool proceeds pay K (less the spread share) into the old pool; the pre-funded top-up covers any gap. + /// @dev Seller side of a fill: gross at P*, less the spread share (to the pool's escrow) and the trade fee. + function _sellProceeds(Batch memory bt, uint64 id, uint256 f) private returns (uint256 net) { + uint256 gross; + uint256 spread; + uint256 trade; + (gross, spread, trade, net) = Settlement.sellSplit(f, bt.price, bt.strike, bt.scale); + bt.received += gross; + bt.spread += spread; + bt.trade += trade; + emit IConfluere.OrderFilled(bt.poolId, id, f, gross, spread + trade); + } + + /// @dev New-pool net proceeds pay K into the old pool (the old pool counts these units as repaid); the pre-funded + /// top-up covers any gap. /// The asset moves from the old pool to the new one and the old R is burned; the caller mints the new R. function _settleRollover( CoreState storage s, @@ -262,7 +330,7 @@ library ClearingLogic { bytes32 oldId = s.rolloverOld[bt.poolId][id]; PoolState storage op = s.pools[oldId]; address owner = o.owner; - (uint256 pay, uint256 fee) = _repayTerms(op, f); + uint256 pay = Settlement.repayAmount(f, op.key.strike, op.scale()); uint256 topUp; if (proceeds >= pay) { s.pending[bt.quote][owner] += proceeds - pay; @@ -270,30 +338,25 @@ library ClearingLogic { topUp = pay - proceeds; o.escrow -= SafeCast.toUint128(topUp); } - s.pending[bt.quote][bt.treasury] += fee; - if (fee != 0) s.marketFees[op.marketId] += fee; - op.quoteBal += pay - fee; + op.quoteBal += pay; + op.repaidUnits += f; op.assetBal -= f; s.lockedR[owner][oldId] -= f; s.burn(owner, oldId.rId(), f); emit IConfluere.RolledOver(oldId, bt.poolId, owner, f, topUp); } - function _repayTerms(PoolState storage op, uint256 f) private view returns (uint256 pay, uint256 fee) { - uint256 sc = op.scale(); - pay = Settlement.repayAmount(f, op.key.strike, sc); - fee = Settlement.fee(f, op.key.strike, op.vwap(), op.feeBps, sc); - } - function _fillBuy(CoreState storage s, PoolState storage ps, Batch memory bt, uint64 id, Order storage o, uint256 f) private { uint256 cost = Clearing.quoteUp(f, bt.price, bt.scale); + uint256 fee = Settlement.tradeFee(cost); bt.paid += cost; - uint256 esc = o.escrow - cost; + bt.trade += fee; + uint256 esc = o.escrow - cost - fee; address owner = o.owner; s.mint(owner, bt.poolId.cId(), f); - emit IConfluere.OrderFilled(bt.poolId, id, f, cost); + emit IConfluere.OrderFilled(bt.poolId, id, f, cost, fee); if (o.side == BUY) { uint256 rem = o.units - f; if (rem == 0) { @@ -304,7 +367,7 @@ library ClearingLogic { } // Keep exactly what the remainder can still cost, plus the per-batch rounding reserve. uint256 left = o.deadline > bt.sc + 1 ? o.deadline - bt.sc - 1 : 0; - uint256 req = Clearing.quoteUp(rem, o.limit, bt.scale) + left; + uint256 req = Settlement.buyEscrow(rem, o.limit, bt.scale, left); if (esc > req) { s.pending[ps.key.quote][owner] += esc - req; esc = req; @@ -382,7 +445,7 @@ library ClearingLogic { while (id != 0 && sum < cap) { Order storage o = os[id]; if (o.deadline > bt.sc && _ncEligible(o, side, bt)) { - sum += side == NC_SELL ? o.units : Clearing.unitsFor(o.escrow, bt.price, bt.scale); + sum += side == NC_SELL ? o.units : Settlement.ncUnits(o.escrow, bt.price, bt.scale); } id = o.next; } diff --git a/contracts/src/libraries/logic/OrderLogic.sol b/contracts/src/libraries/logic/OrderLogic.sol index d130747..c82dcc8 100644 --- a/contracts/src/libraries/logic/OrderLogic.sol +++ b/contracts/src/libraries/logic/OrderLogic.sol @@ -8,6 +8,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {Order, SELL, BUY, NC_SELL, NC_BUY, BID, FLAG_ROLLOVER, FLAG_LOCKED} from "../OrderSet.sol"; import {Clearing} from "../Clearing.sol"; +import {Settlement} from "../Settlement.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {CoreState, PoolState, Params, CoreLib} from "../State.sol"; @@ -104,8 +105,8 @@ library OrderLogic { } else if (p.side == BUY) { if (p.units == 0 || p.limit == 0 || p.budget != 0) revert IConfluere.InvalidParams(); s.requireMinNotional(ps, p.units); - // Escrow covers the full order at its limit plus 1 wei of rounding per batch it can trade in. - uint256 esc = Clearing.quoteUp(p.units, p.limit, ps.scale()) + p.ttl; + // Escrow covers the full order at its limit, its trade fee, and 2 wei of rounding per batch it can trade in. + uint256 esc = Settlement.buyEscrow(p.units, p.limit, ps.scale(), p.ttl); o.units = p.units; o.limit = p.limit; o.escrow = SafeCast.toUint128(esc); @@ -147,9 +148,13 @@ library OrderLogic { } if (a.units == 0 || a.minPrice == 0 || a.ttl == 0 || a.ttl > MAX_TTL) revert IConfluere.InvalidParams(); - // Each fill needs at most f*(K - minPrice)/scale + 2 wei of top-up (one wei of rounding on each leg). + // The top-up is largest when the new pool clears at minPrice: K - minPrice per unit, plus the seller's trade + // fee and spread share at minPrice, plus rounding (one wei on each leg and on each fee) per batch. + uint256 sc = op.scale(); uint256 k = op.key.strike; - uint256 need = (k > a.minPrice ? Clearing.quoteUp(a.units, k - a.minPrice, op.scale()) : 0) + 2 * uint256(a.ttl); + uint256 need = (k > a.minPrice ? Clearing.quoteUp(a.units, k - a.minPrice, sc) : 0) + + Settlement.tradeFee(Clearing.quoteUp(a.units, a.minPrice, sc)) + + Settlement.spreadShare(a.units, np.key.strike, a.minPrice, sc) + 4 * uint256(a.ttl); if (a.maxTopUp < need) revert IConfluere.InvalidParams(); s.requireMinNotional(op, a.units); diff --git a/contracts/src/libraries/logic/WindowLogic.sol b/contracts/src/libraries/logic/WindowLogic.sol index e5a566f..e803b4e 100644 --- a/contracts/src/libraries/logic/WindowLogic.sol +++ b/contracts/src/libraries/logic/WindowLogic.sol @@ -15,8 +15,8 @@ library WindowLogic { using PoolKeyLib for bytes32; /// @notice Pay K per whole asset (rounded up) to buy back `units`. American: any time until the window ends. - /// European: only inside the window. The spread share comes out of what is paid. - function repay(CoreState storage s, Params memory pr, bytes32 poolId, uint96 units) external { + /// European: only inside the window. All of K goes to C holders; the spread share was taken at clearing. + function repay(CoreState storage s, bytes32 poolId, uint96 units) external { PoolState storage ps = s.pool(poolId); bool early = ps.key.kind == Kind.American && ps.status == Status.Open && block.timestamp < ps.key.expiry; if (!(early || s.windowOpen(ps))) { @@ -25,22 +25,22 @@ library WindowLogic { if (units == 0) revert IConfluere.InvalidParams(); if (s.freeR(msg.sender, poolId) < units) revert IConfluere.Locked(); - uint256 sc = ps.scale(); - uint256 pay = Settlement.repayAmount(units, ps.key.strike, sc); - uint256 fee = Settlement.fee(units, ps.key.strike, ps.vwap(), ps.feeBps, sc); + uint256 pay = Settlement.repayAmount(units, ps.key.strike, ps.scale()); s.burn(msg.sender, poolId.rId(), units); - ps.quoteBal += pay - fee; + ps.quoteBal += pay; ps.assetBal -= units; - s.pending[ps.key.quote][pr.treasury] += fee; - if (fee != 0) s.marketFees[ps.marketId] += fee; + ps.repaidUnits += units; CoreLib.pullExact(ps.key.quote, pay); CoreLib.push(ps.key.asset, msg.sender, units); - emit IConfluere.Repaid(poolId, msg.sender, units, pay, fee); + emit IConfluere.Repaid(poolId, msg.sender, units, pay); } /// @notice Segmented close: each call fills at most `maxBids` bids from the top, then refunds them. /// Unrepaid units nobody bid on stay in the pool for C holders (default). Returns true once Closed. - function closeWindow(CoreState storage s, bytes32 poolId, uint256 maxBids) external returns (bool done) { + function closeWindow(CoreState storage s, Params memory pr, bytes32 poolId, uint256 maxBids) + external + returns (bool done) + { PoolState storage ps = s.pool(poolId); if (ps.status == Status.Window) { if (block.number < ps.windowEnd) revert IConfluere.InvalidState(); @@ -56,12 +56,29 @@ library WindowLogic { s.removeWithRefund(poolId, ps, id, CoreLib.RM_BID_CLOSED); } if (bk.head == 0) { + _splitEscrow(s, pr, ps, poolId); ps.status = Status.Closed; emit IConfluere.WindowClosed(poolId, ps.quoteBal, ps.assetBal, ps.surplusBal); return true; } } + /// @dev The spread escrow splits once, by the fraction of filled units whose K was paid in full: that part goes + /// to the treasury (and counts toward graduation), the defaulted part to C holders. + function _splitEscrow(CoreState storage s, Params memory pr, PoolState storage ps, bytes32 poolId) private { + uint256 esc = ps.feeEscrow; + if (esc == 0) return; + uint256 toT = Settlement.feeSplit(esc, ps.repaidUnits, ps.filledUnits); + ps.feeEscrow = 0; + ps.quoteBal += esc - toT; + if (toT != 0) { + s.pending[ps.key.quote][pr.treasury] += toT; + s.marketFees[ps.marketId] += toT; + CoreLib.addFees(s, ps, toT); + } + emit IConfluere.FeeEscrowSplit(poolId, toT, esc - toT, ps.repaidUnits, ps.filledUnits); + } + function _fillBid(CoreState storage s, PoolState storage ps, bytes32 poolId, uint64 id) private { Order storage o = s.orders[poolId][id]; uint256 rem = ps.closeRemaining; @@ -71,6 +88,7 @@ library WindowLogic { uint256 cost = Clearing.quoteUp(f, ps.key.strike + o.limit, sc); uint256 kPart = Clearing.quoteUp(f, ps.key.strike, sc); ps.closeRemaining = rem - f; + ps.repaidUnits += f; ps.quoteBal += kPart; ps.surplusBal += cost - kPart; ps.assetBal -= f; From 9a409a757c10b9444a33d80ef723a65b92d98f21 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 063/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (tests fuzz) Part 9/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/test/fuzz/ClearingFuzz.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/fuzz/ClearingFuzz.t.sol | 29 +++++++++++++------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/contracts/test/fuzz/ClearingFuzz.t.sol b/contracts/test/fuzz/ClearingFuzz.t.sol index b7effdf..f1383a3 100644 --- a/contracts/test/fuzz/ClearingFuzz.t.sol +++ b/contracts/test/fuzz/ClearingFuzz.t.sol @@ -22,7 +22,11 @@ contract ClearingFuzzTest is Base { assertEq(rSupply, _reference(), "volume == reference max crossing"); assertEq(core.totalSupply(_c(pid)), rSupply, "C == R"); assertEq(core.pool(pid).assetBal, rSupply); - assertEq(usdc.balanceOf(address(core)), _pendQ() + _escQ(pid) + core.pool(pid).quoteBal, "quote conserved"); + assertEq( + usdc.balanceOf(address(core)), + _pendQ() + _escQ(pid) + core.pool(pid).quoteBal + core.pool(pid).feeEscrow, + "quote conserved" + ); } function _placeRandom(bytes32 pid, uint256 seed) internal { @@ -58,7 +62,7 @@ contract ClearingFuzzTest is Base { function _pendQ() internal view returns (uint256) { return _pend(address(usdc), alice) + _pend(address(usdc), dave) + _pend(address(usdc), bob) - + _pend(address(usdc), carol); + + _pend(address(usdc), carol) + _pend(address(usdc), treasury); } function _escQ(bytes32 pid) internal view returns (uint256 escQ) { @@ -68,17 +72,12 @@ contract ClearingFuzzTest is Base { } } - function testFuzz_feeNeverExceedsRepay(uint96 units, uint96 strike, uint96 vwap_, uint16 feeBps, uint8 dec) - public - pure - { - dec = uint8(bound(dec, 0, 30)); - feeBps = uint16(bound(feeBps, 0, 10_000)); - uint256 scale = 10 ** uint256(dec); - uint256 pay = Settlement.repayAmount(units, strike, scale); - uint256 fee = Settlement.fee(units, strike, vwap_, feeBps, scale); - assertLe(fee, pay); - if (vwap_ >= strike) assertEq(fee, 0); + function testFuzz_sellSplitNeverUnderflows(uint96 units, uint96 price, uint96 strike, uint8 dec) public pure { + uint256 scale = 10 ** uint256(bound(dec, 0, 30)); + (uint256 gross, uint256 spread, uint256 trade, uint256 net) = Settlement.sellSplit(units, price, strike, scale); + assertEq(gross, spread + trade + net); + if (price >= strike) assertEq(spread, 0); + assertLe(spread, Settlement.spreadShare(units, strike, price, scale)); } function testFuzz_roundingFavoursPool(uint96 units, uint96 price, uint8 dec) public pure { @@ -96,12 +95,12 @@ contract ClearingFuzzTest is Base { } /// Solady's mulDiv equals plain arithmetic where the plain form cannot overflow: carries PlainMathHalmos over. - function testFuzz_soladyMatchesPlain(uint64 a, uint64 b, uint16 feeBps, uint64 c) public pure { + function testFuzz_soladyMatchesPlain(uint64 a, uint64 b, uint64 c) public pure { uint256 s = 1e18; assertEq(Clearing.quoteDown(a, b, s), uint256(a) * b / s); assertEq(Clearing.quoteUp(a, b, s), (uint256(a) * b + s - 1) / s); if (b > 0) assertEq(Clearing.unitsFor(a, b, s), uint256(a) * s / b); - if (c > b) assertEq(Settlement.fee(a, c, b, feeBps, s), uint256(a) * (c - b) * feeBps / (s * 10_000)); + if (c > b) assertEq(Settlement.spreadShare(a, c, b, s), uint256(a) * (c - b) * 500 / (s * 10_000)); assertEq(Settlement.repayAmount(a, c, s), (uint256(a) * c + s - 1) / s); } From 51bec8b76bd2aa6a6f8b94586128c92a566cb8dd Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 064/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (tests halmos) Part 10/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/test/halmos/ClearingHalmos.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/halmos/ClearingHalmos.t.sol | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/contracts/test/halmos/ClearingHalmos.t.sol b/contracts/test/halmos/ClearingHalmos.t.sol index 5d376d2..84af90a 100644 --- a/contracts/test/halmos/ClearingHalmos.t.sol +++ b/contracts/test/halmos/ClearingHalmos.t.sol @@ -32,18 +32,17 @@ contract ClearingHalmos is Test { assert(up >= down && up - down <= 1); } - /// Spread share is zero when vwap >= K, so repay can never underflow on an abnormal vwap. - function check_feeZeroWhenNoInterest(uint64 units, uint64 strike, uint64 vwap, uint16 feeBps) public pure { - vm.assume(vwap >= strike); - assert(Settlement.fee(units, strike, vwap, feeBps, E18) == 0); + /// Spread share is zero when P* >= K, so a fill at or above K never pays into the escrow. + function check_spreadZeroWhenNoInterest(uint64 units, uint64 strike, uint64 price) public pure { + vm.assume(price >= strike); + assert(Settlement.spreadShare(units, strike, price, E18) == 0); } - /// Spread share never exceeds what the borrower pays. - /// NOT in the check_ set: times out at 600 s per query (nonlinear 256-bit mul/div); covered by - /// testFuzz_feeNeverExceedsRepay. Run explicitly with `halmos --function slow_`. - function slow_feeBounded(uint64 units, uint64 strike, uint64 vwap, uint16 feeBps) public pure { - vm.assume(feeBps <= 10_000 && vwap < strike); - assert(Settlement.fee(units, strike, vwap, feeBps, E18) <= Settlement.repayAmount(units, strike, E18)); + /// The seller split always adds back to the gross: spread capped, fees never make a fill revert. + /// NOT in the check_ set (nonlinear mul/div); covered by testFuzz_sellSplitNeverUnderflows. + function slow_sellSplitConserves(uint64 units, uint64 strike, uint64 price) public pure { + (uint256 gross, uint256 spread, uint256 trade, uint256 net) = Settlement.sellSplit(units, price, strike, E18); + assert(gross == spread + trade + net); } /// A non-competitive buy sized from its budget never costs more than the budget. From 82d8cbb376a77f0a6572105645ae31f8fffa99f4 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 065/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (tests invariant) Part 11/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/test/invariant/Handler.sol, contracts/test/invariant/Invariants.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/invariant/Handler.sol | 5 +++-- contracts/test/invariant/Invariants.t.sol | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/test/invariant/Handler.sol b/contracts/test/invariant/Handler.sol index a00ab15..614811b 100644 --- a/contracts/test/invariant/Handler.sol +++ b/contracts/test/invariant/Handler.sol @@ -210,8 +210,9 @@ contract Handler is Test { uint256 price; for (uint256 j; j < logs.length; ++j) { if (logs[j].topics[0] == IConfluere.BatchSettled.selector) { - (uint256 pr, uint256 compUnits, uint256 ncUnits) = abi.decode(logs[j].data, (uint256, uint256, uint256)); - if (ncUnits * 2 > compUnits) ncCapViolated = true; + (uint256 pr, uint256 compUnits, uint256 ncUnits,,, uint256 capBase) = + abi.decode(logs[j].data, (uint256, uint256, uint256, uint256, uint256, uint256)); + if (ncUnits * 2 > capBase || compUnits > capBase) ncCapViolated = true; filledUnits[pid] += compUnits + ncUnits; price = pr; } diff --git a/contracts/test/invariant/Invariants.t.sol b/contracts/test/invariant/Invariants.t.sol index bedb978..1cd4a85 100644 --- a/contracts/test/invariant/Invariants.t.sol +++ b/contracts/test/invariant/Invariants.t.sol @@ -95,7 +95,7 @@ contract InvariantsTest is Test { for (uint256 i; i < h.poolsLength(); ++i) { IConfluere.PoolView memory v = core.pool(h.pools(i)); poolAsset += v.assetBal; - poolQuote += v.quoteBal + v.surplusBal; + poolQuote += v.quoteBal + v.surplusBal + v.feeEscrow; } (uint256 pendAsset, uint256 pendQuote) = _pending(); assertEq(weth.balanceOf(address(core)), escAsset + poolAsset + pendAsset, "asset accounting"); From 4d8b17b7431ecbfd748663ffd9e4809b86dbcfff Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 066/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (tests medusa) Part 12/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/test/medusa/MedusaHarness.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/medusa/MedusaHarness.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/test/medusa/MedusaHarness.sol b/contracts/test/medusa/MedusaHarness.sol index 8983ef8..37aa63e 100644 --- a/contracts/test/medusa/MedusaHarness.sol +++ b/contracts/test/medusa/MedusaHarness.sol @@ -176,7 +176,7 @@ contract MedusaHarness { for (uint256 i; i < 2; ++i) { IConfluere.PoolView memory v = core.pool(pools[i]); pa += v.assetBal; - pq += v.quoteBal + v.surplusBal; + pq += v.quoteBal + v.surplusBal + v.feeEscrow; } uint256 penA = core.pending(address(weth), TREASURY); uint256 penQ = core.pending(address(usdc), TREASURY); From b9e53b96c0e7b84bcb05dde2bfe853d2c2458502 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:21 -0400 Subject: [PATCH 067/169] feat(core): v0.8 fees, fee-based graduation, NC buys in pricing (tests unit) Part 13/13 of: feat(core): v0.8 fees, fee-based graduation, NC buys in pricing Files: contracts/test/unit/Clearing.t.sol, contracts/test/unit/Lifecycle.t.sol, contracts/test/unit/Periphery.t.sol, contracts/test/unit/Pools.t.sol, contracts/test/unit/Seats.t.sol, contracts/test/unit/Vault.t.sol, contracts/test/unit/Window.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/Clearing.t.sol | 26 +++++++++++++++++--------- contracts/test/unit/Lifecycle.t.sol | 17 ++++++++++------- contracts/test/unit/Periphery.t.sol | 4 ++-- contracts/test/unit/Pools.t.sol | 6 ++++-- contracts/test/unit/Seats.t.sol | 8 ++++---- contracts/test/unit/Vault.t.sol | 12 ++++++------ contracts/test/unit/Window.t.sol | 25 ++++++++++++++----------- 7 files changed, 57 insertions(+), 41 deletions(-) diff --git a/contracts/test/unit/Clearing.t.sol b/contracts/test/unit/Clearing.t.sol index 2f3b63c..ea16060 100644 --- a/contracts/test/unit/Clearing.t.sol +++ b/contracts/test/unit/Clearing.t.sol @@ -31,10 +31,14 @@ contract ClearingTest is Base { assertEq(v.lastClearingPrice, 2900e6, "P* = midpoint"); assertEq(core.balanceOf(alice, _r(pid)), ONE, "R to borrower"); assertEq(core.balanceOf(bob, _c(pid)), ONE, "C to lender"); - assertEq(_pend(address(usdc), alice), 2900e6, "borrower receives P*"); - assertEq(_pend(address(usdc), bob), 50e6 + 10, "lender refunded limit - P* and rounding reserve"); + // seller: P* less 0.015% trade fee (435,000) and 5% of K - P* (5e6) into the pool's spread escrow + assertEq(_pend(address(usdc), alice), 2900e6 - 435_000 - 5e6, "borrower receives P* net of fees"); + // escrow 2950 + 0.015% + 2 wei x ttl 10; paid 2900 + 0.015% + assertEq(_pend(address(usdc), bob), 50e6 + 7500 + 20, "lender refunded limit - P*, fee difference, reserve"); + assertEq(v.feeEscrow, 5e6); + assertEq(_pend(address(usdc), treasury), 2 * 435_000, "trade fee from each side"); assertEq(v.assetBal, ONE); - assertEq(v.vwap, 2900e6); + assertEq(v.filledUnits, ONE); } function test_multiOrder_partialMarginal() public { @@ -60,8 +64,8 @@ contract ClearingTest is Base { (address yo,,,,,,) = core.order(pid, y); assertEq(yo, carol, "unfilled stays"); // every filled seller got at least its limit, every filled buyer paid at most its limit - assertEq(_pend(address(usdc), alice), 2950e6); - assertEq(_pend(address(usdc), dave), 1475e6); + assertEq(_pend(address(usdc), alice), 2950e6 - 442_500 - 2.5e6, "P* net of trade fee and spread share"); + assertEq(_pend(address(usdc), dave), 1475e6 - 221_250 - 1.25e6); } function test_noCross_noFill() public { @@ -116,7 +120,7 @@ contract ClearingTest is Base { (,, uint96 units,, uint128 esc, uint32 deadline,) = core.order(pid, x); assertEq(units, ONE); uint256 left = deadline - core.pool(pid).settleCount; - assertEq(esc, 2900e6 + left, "remaining escrow = remainder at limit + reserve"); + assertEq(esc, 2900e6 + 435_000 + 2 * left, "remaining escrow = remainder at limit + fee + reserve"); } // ------------------------------------------------------------ non-competitive orders and band @@ -147,16 +151,20 @@ contract ClearingTest is Base { assertEq(core.balanceOf(dave, _r(pid)), 3 * ONE, "leftover seller served the nc buyer"); } - function test_nc_sellVsNcBuy() public { + /// NC buy demand enters the price ahead of competitive buys (capped at 50% of q0), so it takes the competitive + /// sells first and uses up the cap; the NC sell has no room left this batch. + function test_nc_buyPricedInBeforeNcSell() public { _seedPrice(2900e6); _sell(alice, pid, 2 * ONE, 2900e6); _buy(bob, pid, 2 * ONE, 2900e6); _order(dave, pid, NC_SELL, ONE, 0, 0, 10); _order(carol, pid, NC_BUY, 0, 0, 100_000e6, 10); _settle(pid); - assertEq(core.balanceOf(dave, _r(pid)), ONE); + assertEq(core.pool(pid).lastClearingPrice, 2900e6); + assertEq(core.balanceOf(alice, _r(pid)), 2 * ONE); assertEq(core.balanceOf(carol, _c(pid)), ONE); - assertEq(_pend(address(usdc), dave), 2900e6); + assertEq(core.balanceOf(bob, _c(pid)), ONE); + assertEq(core.balanceOf(dave, _r(pid)), 0); } function test_band_outOfBand_rollsNc_andBreaker() public { diff --git a/contracts/test/unit/Lifecycle.t.sol b/contracts/test/unit/Lifecycle.t.sol index a6483da..800657a 100644 --- a/contracts/test/unit/Lifecycle.t.sol +++ b/contracts/test/unit/Lifecycle.t.sol @@ -12,9 +12,9 @@ contract LifecycleTest is Base { function _config() internal view override returns (Confluere.Config memory c) { c = super._config(); c.chains[0].graduationQuotes = new address[](1); - c.chains[0].graduationVolumes = new uint256[](1); + c.chains[0].graduationFees = new uint256[](1); c.chains[0].graduationQuotes[0] = address(usdc); - c.chains[0].graduationVolumes[0] = 1000e6; + c.chains[0].graduationFees[0] = 0.8e6; // trade fees of one 1-ETH fill at 2900: 2 x 0.435 USDC } // ------------------------------------------------------------ rollover @@ -63,10 +63,13 @@ contract LifecycleTest is Base { assertEq(core.lockedR(alice, p1), 0); IConfluere.PoolView memory v1 = core.pool(p1); assertEq(v1.assetBal, 0); - assertEq(v1.quoteBal, 2990e6, "K less 10% spread share"); + assertEq(v1.quoteBal, 3000e6, "all of K; the old pool counts the units as repaid"); + assertEq(v1.repaidUnits, ONE); assertEq(core.pool(p2).assetBal, ONE); - assertEq(_pend(address(usdc), treasury), 10e6); - assertEq(_pend(address(usdc), alice), 2900e6 + 100e6, "first borrow + unused top-up"); + assertEq(core.pool(p2).feeEscrow, 5e6, "new pool's spread share at its own P*"); + assertEq(_pend(address(usdc), treasury), 4 * 435_000, "trade fees, both pools, both sides"); + // net per fill at 2900 = 2900 - 0.435 trade fee - 5 spread share; top-up = 3000 - 2894.565 + assertEq(_pend(address(usdc), alice), 2894.565e6 + (200e6 - 105.435e6), "first borrow + unused top-up"); } function test_rollover_needsEnoughTopUp() public { @@ -118,7 +121,7 @@ contract LifecycleTest is Base { uint256 amount = 1000e6; core.flashLoan(fb, address(usdc), amount, ""); assertEq(fb.seen(), amount + 1e6); - assertEq(_pend(address(usdc), treasury), 500_000, "0.05%"); + assertEq(_pend(address(usdc), treasury), 500_000 + 2 * 435_000, "0.05% on top of the trade fees"); } function test_flashLoan_mustRepay() public { @@ -204,7 +207,7 @@ contract LifecycleTest is Base { core.graduate(address(weth), address(usdc), Kind.American, ANCHOR + 4 * WEEK); } - function test_graduation_needsVolume() public { + function test_graduation_needsFees() public { for (uint64 i = 1; i <= 4; ++i) { uint64 e = ANCHOR + i * WEEK; vm.warp(e - 3 days); diff --git a/contracts/test/unit/Periphery.t.sol b/contracts/test/unit/Periphery.t.sol index a074905..eaf2ed6 100644 --- a/contracts/test/unit/Periphery.t.sol +++ b/contracts/test/unit/Periphery.t.sol @@ -137,7 +137,7 @@ contract PeripheryTest is Base { (address o1,,,, uint128 esc,,) = core.order(pid, ids[1]); assertEq(o0, dave); assertEq(o1, dave); - assertEq(esc, 2000e6 + 3); + assertEq(esc, 2000e6 + 300_000 + 6); assertEq(usdc.balanceOf(address(r)), 0); } @@ -160,7 +160,7 @@ contract PeripheryTest is Base { uint256 u0 = usdc.balanceOf(bob); r.claimMany(pids); vm.stopPrank(); - assertEq(usdc.balanceOf(bob) - u0, 2990e6); + assertEq(usdc.balanceOf(bob) - u0, 3000e6); } function test_vaultFactory() public { diff --git a/contracts/test/unit/Pools.t.sol b/contracts/test/unit/Pools.t.sol index 12a61f9..903e4f0 100644 --- a/contracts/test/unit/Pools.t.sol +++ b/contracts/test/unit/Pools.t.sol @@ -73,7 +73,9 @@ contract PoolsTest is Base { assertEq(weth.balanceOf(alice), w0 - ONE); uint256 u0 = usdc.balanceOf(bob); _buy(bob, pid, ONE, 2900e6); - assertEq(u0 - usdc.balanceOf(bob), 2900e6 + 10, "limit cost plus 1 wei per ttl batch"); + assertEq( + u0 - usdc.balanceOf(bob), 2900e6 + 435_000 + 20, "limit cost, its 0.015% trade fee, 2 wei per ttl batch" + ); } function test_placeOrder_validation() public { @@ -192,7 +194,7 @@ contract PoolsTest is Base { vm.prank(bob); core.cancelOrder(pid, b); assertEq(_pend(address(weth), alice), ONE); - assertEq(_pend(address(usdc), bob), 2800e6 + 10); + assertEq(_pend(address(usdc), bob), 2800e6 + 420_000 + 20); uint256 before = weth.balanceOf(alice); vm.prank(alice); core.withdraw(address(weth), alice); diff --git a/contracts/test/unit/Seats.t.sol b/contracts/test/unit/Seats.t.sol index d36719c..053f157 100644 --- a/contracts/test/unit/Seats.t.sol +++ b/contracts/test/unit/Seats.t.sol @@ -118,10 +118,10 @@ contract SeatsTest is Base { _buy(bob, pid, ONE, 2900e6); _settle(pid); vm.prank(dave); - core.repay(pid, uint96(ONE)); // fee = 10% of (3000 - 2900) = 10 USDC - assertEq(core.marketFees(market), 10e6); + core.repay(pid, uint96(ONE)); // no fee on repay; the spread share waits in the escrow until close + assertEq(core.marketFees(market), 2 * 435_000, "trade fees, 0.015% each side"); reg.split(address(weth), address(usdc), Kind.American); - assertEq(reg.claimable(address(usdc), alice), 3.5e6, "35% to the seat"); + assertEq(reg.claimable(address(usdc), alice), 304_500, "35% to the seat"); uint256 protocolBefore = reg.claimable(address(usdc), protocolMs); reg.split(address(weth), address(usdc), Kind.American); // idempotent assertEq(reg.claimable(address(usdc), protocolMs), protocolBefore); @@ -129,7 +129,7 @@ contract SeatsTest is Base { uint256 a0 = usdc.balanceOf(alice); vm.prank(alice); reg.withdraw(address(usdc)); // pulls the fees out of the core - assertEq(usdc.balanceOf(alice) - a0, 3.5e6); + assertEq(usdc.balanceOf(alice) - a0, 304_500); _assertSolvent(); } diff --git a/contracts/test/unit/Vault.t.sol b/contracts/test/unit/Vault.t.sol index 0c3287c..4a9fd2c 100644 --- a/contracts/test/unit/Vault.t.sol +++ b/contracts/test/unit/Vault.t.sol @@ -20,9 +20,9 @@ abstract contract VaultBase is Base { function _config() internal view override returns (Confluere.Config memory c) { c = super._config(); c.chains[0].graduationQuotes = new address[](1); - c.chains[0].graduationVolumes = new uint256[](1); + c.chains[0].graduationFees = new uint256[](1); c.chains[0].graduationQuotes[0] = address(usdc); - c.chains[0].graduationVolumes[0] = 1000e6; + c.chains[0].graduationFees[0] = 0.8e6; // trade fees of one 1-ETH fill at 2900: 2 x 0.435 USDC } function setUp() public override { @@ -154,9 +154,9 @@ contract VaultTest is VaultBase { vault.roll(); (uint256 nav, uint256 supply,) = vault.epochs(2); assertEq(supply, 10_000e6 * 1e6); - // interest: c units bought at 2900 return 2990 net of the 10% spread share - uint256 expected = 10_000e6 + c * 90e6 / ONE; - assertApproxEqAbs(nav, expected, 2); + // interest: c units bought at 2900 (plus the 0.015% buyer trade fee) return all of K = 3000 + uint256 expected = 10_000e6 + c * 100e6 / ONE - (c * 2900e6 / ONE) * 150 / 1e6; + assertApproxEqAbs(nav, expected, 10); // lp2 enters at the new price, so gets fewer shares than assets vm.prank(lp2); @@ -173,7 +173,7 @@ contract VaultTest is VaultBase { vm.prank(lp1); uint256 out = vault.redeem(s1, lp1, lp1); assertEq(usdc.balanceOf(lp1) - before, out); - assertApproxEqAbs(out, expected, 3); + assertApproxEqAbs(out, expected, 11); } function test_default_distributesAssets() public { diff --git a/contracts/test/unit/Window.t.sol b/contracts/test/unit/Window.t.sol index a3cb70d..9c5d39a 100644 --- a/contracts/test/unit/Window.t.sol +++ b/contracts/test/unit/Window.t.sol @@ -15,7 +15,9 @@ contract WindowTest is Base { _buy(bob, pid, ONE, 2900e6); _buy(carol, pid, ONE, 2900e6); _settle(pid); - assertEq(core.pool(pid).vwap, 2900e6); + assertEq(core.pool(pid).filledUnits, 2 * ONE); + // 5% of (K - P*) per unit sits in the escrow until close + assertEq(core.pool(pid).feeEscrow, 10e6); } function test_american_repayAnytime_withSpreadShare() public { @@ -26,9 +28,10 @@ contract WindowTest is Base { core.repay(pid, uint96(ONE)); assertEq(weth.balanceOf(alice), w0 + ONE, "asset back"); assertEq(u0 - usdc.balanceOf(alice), 3000e6, "paid K"); - // fee = 10% of (K - vwap) = 10e6 - assertEq(_pend(address(usdc), treasury), 10e6); - assertEq(core.pool(pid).quoteBal, 2990e6); + // repay carries no fee: all of K goes to C holders; treasury holds only the four trade fees (0.015% each) + assertEq(_pend(address(usdc), treasury), 4 * 435_000); + assertEq(core.pool(pid).quoteBal, 3000e6); + assertEq(core.pool(pid).repaidUnits, ONE); assertEq(core.balanceOf(alice, _r(pid)), 0); } @@ -83,11 +86,11 @@ contract WindowTest is Base { vm.prank(bob); (uint256 q, uint256 a) = core.claim(pid); assertEq(a, 0); - assertEq(q, 2990e6, "K minus spread share"); + assertEq(q, 3000e6, "all of K; the spread share was taken from the borrowers at clearing"); assertEq(usdc.balanceOf(bob), u0 + q); vm.prank(carol); (q,) = core.claim(pid); - assertEq(q, 2990e6); + assertEq(q, 3000e6); assertEq(core.pool(pid).quoteBal, 0); } @@ -97,9 +100,9 @@ contract WindowTest is Base { core.closeWindow(pid, 10); vm.prank(bob); (uint256 q, uint256 a) = core.claim(pid); - assertEq(q, 0); + assertEq(q, 5e6, "defaulted part of the spread escrow goes to C holders"); assertEq(a, ONE, "physical delivery"); - assertEq(_pend(address(usdc), treasury), 0, "no fee on default"); + assertEq(_pend(address(usdc), treasury), 4 * 435_000, "only trade fees on default"); } function test_abandonmentAuction_surplusToRHolders() public { @@ -134,7 +137,7 @@ contract WindowTest is Base { // lenders split: alice repaid 2990 net + eve paid 3000 vm.prank(bob); (q,) = core.claim(pid); - assertEq(q, 2995e6); + assertEq(q, 3000e6); } function test_bid_cancellableOnlyInWindow() public { @@ -199,7 +202,7 @@ contract WindowTest is Base { core.closeWindow(pid, 10); vm.prank(bob); (uint256 q, uint256 a) = core.claim(pid); - assertEq(q, 1495e6); + assertEq(q, 1502.5e6); // K/2 + half of the defaulted half of the escrow assertEq(a, 5e17); assertEq(core.balanceOf(bob, _c(pid)), 0); // transferred C claims the same @@ -207,7 +210,7 @@ contract WindowTest is Base { core.transfer(eve, _c(pid), ONE); vm.prank(eve); (q, a) = core.claim(pid); - assertEq(q, 1495e6); + assertEq(q, 1502.5e6); // K/2 + half of the defaulted half of the escrow assertEq(a, 5e17); } From 5720e8c6d59dc797904acf205f994881fd5ad2c3 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:28:22 -0400 Subject: [PATCH 068/169] chore(contracts): gas snapshot for v0.8 fees and NC pricing Fills now compute the seller split and buyer trade fee, and settleBatch may recross once for non-competitive demand. Co-Authored-By: Claude Opus 5.5 --- contracts/.gas-snapshot | 280 +++++++++++++++++++++------------------- 1 file changed, 145 insertions(+), 135 deletions(-) diff --git a/contracts/.gas-snapshot b/contracts/.gas-snapshot index 972e738..42f0d1a 100644 --- a/contracts/.gas-snapshot +++ b/contracts/.gas-snapshot @@ -1,146 +1,156 @@ -AuditFindingsTest:test_H1_protectiveCeilingOnNcBuy() (gas: 1750658) -AuditFindingsTest:test_H1_protectiveFloorOnNcSell() (gas: 1761845) -AuditFindingsTest:test_H1_washTradedPriceAboveK_doesNotFillNcBuyers() (gas: 1532302) -AuditFindingsTest:test_H2_bidBook_samePriceLargerSizeEvicts() (gas: 1765936) -AuditFindingsTest:test_H2_minNotional_rejectsDust() (gas: 473809) -AuditFindingsTest:test_H2_ncQueue_expiredEvictedFirst() (gas: 1667069) -AuditFindingsTest:test_H2_ncQueue_largerOrderEvictsSmallest() (gas: 1746026) -AuditFindingsTest:test_H2_sortedBook_expiredTailEvicted() (gas: 2064735) -AuditFindingsTest:test_H2_sortedBook_samePriceLargerSizeEvicts() (gas: 1952687) -ClearingTest:test_band_outOfBand_rollsNc_andBreaker() (gas: 3934931) -ClearingTest:test_band_widensAfterFailure() (gas: 2357083) -ClearingTest:test_buyEscrow_refundOnPartialKeepsReserve() (gas: 833263) -ClearingTest:test_multiOrder_partialMarginal() (gas: 1554439) -ClearingTest:test_nc_fillsAgainstLeftover_cappedAt50pct() (gas: 1765119) -ClearingTest:test_nc_needsPriorPrice() (gas: 979160) -ClearingTest:test_nc_sellVsNcBuy() (gas: 1769779) -ClearingTest:test_noCross_noFill() (gas: 565269) -ClearingTest:test_oneSided_rollsOver_thenFills() (gas: 910190) -ClearingTest:test_purge_expired() (gas: 559271) -ClearingTest:test_settle_notBeforeBoundary() (gas: 155947) -ClearingTest:test_singlePair_midpointPrice() (gas: 856956) -ClearingTest:test_ttl_expiresAndRefunds() (gas: 617780) -ClearingTest:test_volumeAndStrikeStats() (gas: 783235) -GasTest:test_gas_settle_128() (gas: 93397377) -GasTest:test_gas_settle_32() (gas: 15947123) -GasTest:test_gas_settle_64() (gas: 36373356) -KeeperReceiverTest:test_drivesTheWholeLifecycle_onlyWhenDue() (gas: 791352) +AuditFindingsTest:test_H1_protectiveCeilingOnNcBuy() (gas: 1823489) +AuditFindingsTest:test_H1_protectiveFloorOnNcSell() (gas: 1829638) +AuditFindingsTest:test_H1_washTradedPriceAboveK_doesNotFillNcBuyers() (gas: 1597871) +AuditFindingsTest:test_H2_bidBook_samePriceLargerSizeEvicts() (gas: 1815371) +AuditFindingsTest:test_H2_minNotional_rejectsDust() (gas: 473764) +AuditFindingsTest:test_H2_ncQueue_expiredEvictedFirst() (gas: 1667711) +AuditFindingsTest:test_H2_ncQueue_largerOrderEvictsSmallest() (gas: 1745599) +AuditFindingsTest:test_H2_sortedBook_expiredTailEvicted() (gas: 2065719) +AuditFindingsTest:test_H2_sortedBook_samePriceLargerSizeEvicts() (gas: 1952259) +ClearingFuzzTest:testFuzz_gridStrike(uint64,uint8) (runs: 2000, μ: 6553, ~: 6138) +ClearingFuzzTest:testFuzz_midpointBetween(uint96,uint96) (runs: 2000, μ: 807, ~: 803) +ClearingFuzzTest:testFuzz_ncBuyWithinBudget(uint128,uint96,uint8) (runs: 2000, μ: 5022, ~: 4786) +ClearingFuzzTest:testFuzz_roundingFavoursPool(uint96,uint96,uint8) (runs: 2000, μ: 2381, ~: 2132) +ClearingFuzzTest:testFuzz_sellSplitNeverUnderflows(uint96,uint96,uint96,uint8) (runs: 2000, μ: 2929, ~: 3046) +ClearingFuzzTest:testFuzz_soladyMatchesPlain(uint64,uint64,uint64) (runs: 2000, μ: 3109, ~: 3177) +ClearingFuzzTest:testFuzz_volumeMatchesReference(uint256) (runs: 2000, μ: 2843620, ~: 2900048) +ClearingTest:test_band_outOfBand_rollsNc_andBreaker() (gas: 4024036) +ClearingTest:test_band_widensAfterFailure() (gas: 2399529) +ClearingTest:test_buyEscrow_refundOnPartialKeepsReserve() (gas: 885688) +ClearingTest:test_multiOrder_partialMarginal() (gas: 1608383) +ClearingTest:test_nc_buyPricedInBeforeNcSell() (gas: 1851397) +ClearingTest:test_nc_fillsAgainstLeftover_cappedAt50pct() (gas: 1834621) +ClearingTest:test_nc_needsPriorPrice() (gas: 1030923) +ClearingTest:test_noCross_noFill() (gas: 575438) +ClearingTest:test_oneSided_rollsOver_thenFills() (gas: 963123) +ClearingTest:test_purge_expired() (gas: 560316) +ClearingTest:test_settle_notBeforeBoundary() (gas: 156950) +ClearingTest:test_singlePair_midpointPrice() (gas: 921009) +ClearingTest:test_ttl_expiresAndRefunds() (gas: 620171) +ClearingTest:test_volumeAndStrikeStats() (gas: 833108) +GasTest:test_gas_settle_128() (gas: 93664094) +GasTest:test_gas_settle_32() (gas: 16042042) +GasTest:test_gas_settle_64() (gas: 36525541) +KeeperReceiverTest:test_drivesTheWholeLifecycle_onlyWhenDue() (gas: 879045) KeeperReceiverTest:test_supportsReceiverInterface() (gas: 5646) -KeeperReceiverTest:test_unknownPoolOrRevertingVault_doesNotBlockOthers() (gas: 448934) -LifecycleTest:test_erc6909_allowanceAndOperator() (gas: 1126012) -LifecycleTest:test_flashLoan_feeToTreasury() (gas: 1167886) -LifecycleTest:test_flashLoan_mustRepay() (gas: 1083924) -LifecycleTest:test_graduation_afterFourCleanCycles_thenNoPause() (gas: 3757457) -LifecycleTest:test_graduation_blockedByPause() (gas: 3703234) -LifecycleTest:test_graduation_needsVolume() (gas: 2975237) -LifecycleTest:test_pause_blocksOrdersAndClearing_notCancelOrRepay() (gas: 1572256) -LifecycleTest:test_pause_limitedDuration() (gas: 242515) -LifecycleTest:test_rollover_failsAfterOldWindow_refunds() (gas: 1687229) -LifecycleTest:test_rollover_fillsInWindow_movesPosition() (gas: 2392702) -LifecycleTest:test_rollover_needsEnoughTopUp() (gas: 1223018) -LifecycleTest:test_rollover_rejectsMismatchedPools() (gas: 1221030) -LongTailTest:test_cadenceLongerThanPeriodRejected() (gas: 597190) -LongTailTest:test_cadence_24h() (gas: 262945) -LongTailTest:test_cadence_6hBatches() (gas: 1079473) -LongTailTest:test_cancelCutoff_doesNotApplyToMainstreamOrExpired() (gas: 1004738) -LongTailTest:test_cancelCutoff_holdsAfterBoundaryUntilSettled() (gas: 659030) -LongTailTest:test_cancelCutoff_lastTwelfthOfLongTailBatch() (gas: 552995) -LongTailTest:test_cancelCutoff_unsettledBatchDoesNotLockWhilePaused() (gas: 595310) -LongTailTest:test_firstAuctionBounds() (gas: 2127752) -LongTailTest:test_firstAuctionFloor() (gas: 712452) -LongTailTest:test_mainstreamDefaultUnchanged() (gas: 239836) -LongTailTest:test_minFill_thinBatchDoesNotTrade_thenTrades() (gas: 1595964) -LongTailTest:test_options_validation() (gas: 94444) -PeripheryTest:test_guardian_multisigPause() (gas: 761662) -PeripheryTest:test_router_placeOrders_ownedByCaller() (gas: 524430) -PeripheryTest:test_router_repayAndClaim() (gas: 736492) +KeeperReceiverTest:test_unknownPoolOrRevertingVault_doesNotBlockOthers() (gas: 505218) +LifecycleTest:test_erc6909_allowanceAndOperator() (gas: 1175729) +LifecycleTest:test_flashLoan_feeToTreasury() (gas: 1200503) +LifecycleTest:test_flashLoan_mustRepay() (gas: 1133619) +LifecycleTest:test_graduation_afterFourCleanCycles_thenNoPause() (gas: 3853794) +LifecycleTest:test_graduation_blockedByPause() (gas: 3799504) +LifecycleTest:test_graduation_needsFees() (gas: 3056000) +LifecycleTest:test_pause_blocksOrdersAndClearing_notCancelOrRepay() (gas: 1591617) +LifecycleTest:test_pause_limitedDuration() (gas: 242572) +LifecycleTest:test_rollover_failsAfterOldWindow_refunds() (gas: 1783334) +LifecycleTest:test_rollover_fillsInWindow_movesPosition() (gas: 2474378) +LifecycleTest:test_rollover_needsEnoughTopUp() (gas: 1273905) +LifecycleTest:test_rollover_rejectsMismatchedPools() (gas: 1270715) +LongTailTest:test_cadenceLongerThanPeriodRejected() (gas: 597072) +LongTailTest:test_cadence_24h() (gas: 267329) +LongTailTest:test_cadence_6hBatches() (gas: 1133541) +LongTailTest:test_cancelCutoff_doesNotApplyToMainstreamOrExpired() (gas: 1005671) +LongTailTest:test_cancelCutoff_holdsAfterBoundaryUntilSettled() (gas: 660045) +LongTailTest:test_cancelCutoff_lastTwelfthOfLongTailBatch() (gas: 552963) +LongTailTest:test_cancelCutoff_unsettledBatchDoesNotLockWhilePaused() (gas: 595301) +LongTailTest:test_firstAuctionBounds() (gas: 2199235) +LongTailTest:test_firstAuctionFloor() (gas: 713739) +LongTailTest:test_mainstreamDefaultUnchanged() (gas: 244285) +LongTailTest:test_minFill_thinBatchDoesNotTrade_thenTrades() (gas: 1653702) +LongTailTest:test_options_validation() (gas: 94314) +PeripheryTest:test_guardian_multisigPause() (gas: 770550) +PeripheryTest:test_router_placeOrders_ownedByCaller() (gas: 527661) +PeripheryTest:test_router_repayAndClaim() (gas: 747865) PeripheryTest:test_rwaWrapper_donationAttackFails() (gas: 505813) PeripheryTest:test_rwaWrapper_rebaseAccruesToShares() (gas: 335518) PeripheryTest:test_unsupportedChain() (gas: 11217) -PeripheryTest:test_vaultFactory() (gas: 4053130) -PeripheryTest:test_wrapper_roundTrip() (gas: 811686) -PoolsTest:test_buyBook_descending() (gas: 816117) -PoolsTest:test_cancel_refundsToPending_andWithdraw() (gas: 859940) -PoolsTest:test_createPool_ids() (gas: 249865) -PoolsTest:test_createPool_rejectsDuplicate() (gas: 253736) +PeripheryTest:test_vaultFactory() (gas: 4058527) +PeripheryTest:test_wrapper_roundTrip() (gas: 822339) +PoolsTest:test_buyBook_descending() (gas: 817177) +PoolsTest:test_cancel_refundsToPending_andWithdraw() (gas: 860355) +PoolsTest:test_createPool_ids() (gas: 254314) +PoolsTest:test_createPool_rejectsDuplicate() (gas: 253748) PoolsTest:test_createPool_rejectsFeeAndBandOutsideWhitelist() (gas: 85978) PoolsTest:test_createPool_rejectsFeeOnTransfer() (gas: 191922) PoolsTest:test_createPool_rejectsOffGridExpiry() (gas: 125963) PoolsTest:test_createPool_rejectsSameToken() (gas: 40148) -PoolsTest:test_createPool_strikeGrid() (gas: 451082) -PoolsTest:test_fullBook_evictsWorst() (gas: 2125246) -PoolsTest:test_hint_tooStale_reverts() (gas: 1277810) -PoolsTest:test_hint_wrongSide_reverts() (gas: 562366) -PoolsTest:test_ncQueue_full_reverts() (gas: 1700537) -PoolsTest:test_noOrdersAfterExpiry() (gas: 247949) -PoolsTest:test_operatorCanCancel() (gas: 524829) -PoolsTest:test_placeOrder_escrows() (gas: 640070) -PoolsTest:test_placeOrder_onBehalfOf() (gas: 482991) -PoolsTest:test_placeOrder_validation() (gas: 507078) -PoolsTest:test_sortedInsertion_andStaleHint() (gas: 1397244) -PositionMarketTest:test_R_europeanBeforeExpiry_tradable() (gas: 327791) -PositionMarketTest:test_R_otherPoolOfExpiryStartsWindow() (gas: 313686) -PositionMarketTest:test_R_stopsAfterWindow_C_tradesUntilClosed() (gas: 1321674) -PositionMarketTest:test_best_sorted() (gas: 883102) -PositionMarketTest:test_bid_hit_partial_then_full_refundsDust() (gas: 567005) -PositionMarketTest:test_bid_hit_paysSeller() (gas: 642854) -PositionMarketTest:test_cancelBid_wrongKind() (gas: 309643) -PositionMarketTest:test_cancel_returnsUnits_onlyMaker() (gas: 402519) -PositionMarketTest:test_expiredListing_cannotBeFilled_butCancels() (gas: 373339) -PositionMarketTest:test_fill_roundsUpAgainstBuyer() (gas: 422020) -PositionMarketTest:test_fill_slippage() (gas: 321629) -PositionMarketTest:test_list_fill_partial_then_full() (gas: 766499) -PositionMarketTest:test_list_lockedOrMissingUnitsReverts() (gas: 226983) -PositionMarketTest:test_list_rejects() (gas: 204912) -SeatLPTokenTest:test_balanceFollowsTheSeat() (gas: 681858) +PoolsTest:test_createPool_strikeGrid() (gas: 451106) +PoolsTest:test_fullBook_evictsWorst() (gas: 2124830) +PoolsTest:test_hint_tooStale_reverts() (gas: 1277552) +PoolsTest:test_hint_wrongSide_reverts() (gas: 562698) +PoolsTest:test_ncQueue_full_reverts() (gas: 1700189) +PoolsTest:test_noOrdersAfterExpiry() (gas: 247961) +PoolsTest:test_operatorCanCancel() (gas: 524840) +PoolsTest:test_placeOrder_escrows() (gas: 640357) +PoolsTest:test_placeOrder_onBehalfOf() (gas: 483091) +PoolsTest:test_placeOrder_validation() (gas: 507090) +PoolsTest:test_sortedInsertion_andStaleHint() (gas: 1397074) +PositionMarketTest:testFuzz_askConservation(uint96,uint128,uint96,uint96) (runs: 2000, μ: 656172, ~: 666048) +PositionMarketTest:testFuzz_bidEscrowConservation(uint96,uint128,uint96[4]) (runs: 2000, μ: 1483161, ~: 1508051) +PositionMarketTest:test_R_europeanBeforeExpiry_tradable() (gas: 341114) +PositionMarketTest:test_R_otherPoolOfExpiryStartsWindow() (gas: 315826) +PositionMarketTest:test_R_stopsAfterWindow_C_tradesUntilClosed() (gas: 1390928) +PositionMarketTest:test_best_sorted() (gas: 889816) +PositionMarketTest:test_bid_hit_partial_then_full_refundsDust() (gas: 573192) +PositionMarketTest:test_bid_hit_paysSeller() (gas: 651201) +PositionMarketTest:test_cancelBid_wrongKind() (gas: 311881) +PositionMarketTest:test_cancel_returnsUnits_onlyMaker() (gas: 404774) +PositionMarketTest:test_expiredListing_cannotBeFilled_butCancels() (gas: 375594) +PositionMarketTest:test_fill_roundsUpAgainstBuyer() (gas: 428626) +PositionMarketTest:test_fill_slippage() (gas: 326061) +PositionMarketTest:test_list_fill_partial_then_full() (gas: 741302) +PositionMarketTest:test_list_lockedOrMissingUnitsReverts() (gas: 229221) +PositionMarketTest:test_list_rejects() (gas: 209689) +SeatLPTokenTest:test_balanceFollowsTheSeat() (gas: 681753) SeatLPTokenTest:test_metadata() (gas: 28948) SeatLPTokenTest:test_nonTransferable() (gas: 46649) -SeatsTest:test_buyVacantSeat_postsDepositAndBond() (gas: 286969) -SeatsTest:test_buyerProtectedFromPriceFrontRun() (gas: 288427) -SeatsTest:test_buyout_paysHolder_andCooldownBlocksLowering() (gas: 656467) -SeatsTest:test_feeSplit_seatGets35pct() (gas: 1428862) -SeatsTest:test_feesBeforeBuyoutGoToTheHolderThen() (gas: 1347112) -SeatsTest:test_flashFeesGoToProtocol() (gas: 1115765) -SeatsTest:test_obligation_metEveryBatch_noSlash() (gas: 1292689) -SeatsTest:test_obligation_missedBatch_slashed() (gas: 788088) -SeatsTest:test_postOrder_mustBeNearTheClearingPrice() (gas: 1635923) -SeatsTest:test_postOrder_rules() (gas: 703654) -SeatsTest:test_postOrder_tokenBidDoesNotCount_beforeFirstTrade() (gas: 852302) -SeatsTest:test_release_returnsDepositAndBond() (gas: 350698) -SeatsTest:test_seatOrderCannotBeCancelledWhileLive() (gas: 662148) -SeatsTest:test_slashUsesTheEvidencePoolsBatchLength() (gas: 898868) -SeatsTest:test_taxAccrues_andForecloses() (gas: 404410) -SeatsTest:test_topUp_emitsDeposit() (gas: 256418) -StructureTest:test_invariant6_noSelfdestruct_delegatecallOnlyIntoLibraries() (gas: 15123275) -VaultLongTailTest:test_onePokePerClearingOfTheTargetPool() (gas: 2383155) -VaultTest:test_V1_donationAttackFails() (gas: 1321202) -VaultTest:test_default_distributesAssets() (gas: 3490959) -VaultTest:test_firstDepositRollsImmediately() (gas: 472436) -VaultTest:test_fullCycle_repaid_navUp_redeem() (gas: 3773601) -VaultTest:test_idleRollNeedsWork() (gas: 531222) +SeatsTest:test_buyVacantSeat_postsDepositAndBond() (gas: 286947) +SeatsTest:test_buyerProtectedFromPriceFrontRun() (gas: 288339) +SeatsTest:test_buyout_paysHolder_andCooldownBlocksLowering() (gas: 656335) +SeatsTest:test_feeSplit_seatGets35pct() (gas: 1447089) +SeatsTest:test_feesBeforeBuyoutGoToTheHolderThen() (gas: 1365303) +SeatsTest:test_flashFeesGoToProtocol() (gas: 1148348) +SeatsTest:test_obligation_metEveryBatch_noSlash() (gas: 1315489) +SeatsTest:test_obligation_missedBatch_slashed() (gas: 798603) +SeatsTest:test_postOrder_mustBeNearTheClearingPrice() (gas: 1741376) +SeatsTest:test_postOrder_rules() (gas: 721994) +SeatsTest:test_postOrder_tokenBidDoesNotCount_beforeFirstTrade() (gas: 870575) +SeatsTest:test_release_returnsDepositAndBond() (gas: 350659) +SeatsTest:test_seatOrderCannotBeCancelledWhileLive() (gas: 668265) +SeatsTest:test_slashUsesTheEvidencePoolsBatchLength() (gas: 912698) +SeatsTest:test_taxAccrues_andForecloses() (gas: 404366) +SeatsTest:test_topUp_emitsDeposit() (gas: 256352) +StructureTest:test_invariant6_noSelfdestruct_delegatecallOnlyIntoLibraries() (gas: 15625115) +VaultLongTailTest:test_onePokePerClearingOfTheTargetPool() (gas: 2447684) +VaultTest:testFuzz_roundTripNoLoss(uint64,uint64,uint64) (runs: 2000, μ: 2024427, ~: 2024427) +VaultTest:test_V1_donationAttackFails() (gas: 1321136) +VaultTest:test_default_distributesAssets() (gas: 3593536) +VaultTest:test_firstDepositRollsImmediately() (gas: 472414) +VaultTest:test_fullCycle_repaid_navUp_redeem() (gas: 3843927) +VaultTest:test_idleRollNeedsWork() (gas: 531178) VaultTest:test_operatorsAndAuth() (gas: 241841) -VaultTest:test_pokeInvests25pct_onlyNonCompetitive() (gas: 2670184) -VaultTest:test_redeemerKeepsEpochDefaultShare() (gas: 3751051) -VaultTest:test_rollPurgesEveryOrderOfTheEpoch() (gas: 2189428) -VaultTest:test_targetsLowestTradedStrikeInGraduatedMarket() (gas: 67275) -WindowAlignTest:test_americanCannotRepayAfterTheSharedWindowEvenIfNeverMatured() (gas: 1263843) -WindowAlignTest:test_americanRepaysAfterExpiryBeforeAnyMature() (gas: 1129649) -WindowAlignTest:test_laterPoolsJoinTheFirstWindow() (gas: 586082) -WindowAlignTest:test_purgeKeepsRolloverWhileSharedWindowRuns() (gas: 1785805) -WindowAlignTest:test_rolloverCannotStartAfterTheSharedWindow() (gas: 1479256) -WindowAlignTest:test_rolloverClearsAtExpiryBeforeTheOldPoolIsMatured() (gas: 2072931) -WindowAlignTest:test_unmaturedPoolRepaysInsideTheSharedWindowOnly() (gas: 1585981) -WindowTest:test_abandonmentAuction_surplusToRHolders() (gas: 2437341) -WindowTest:test_allRepay_lendersGetQuote() (gas: 2052292) -WindowTest:test_american_repayAnytime_withSpreadShare() (gas: 1742714) -WindowTest:test_bidBookFull_evictsLowestSurplus() (gas: 2568997) -WindowTest:test_bid_cancellableOnlyInWindow() (gas: 2011048) -WindowTest:test_claim_burnsAndPaysProRata_mixed() (gas: 2001942) -WindowTest:test_closeWindow_tooEarly() (gas: 1561384) -WindowTest:test_default_lendersGetAsset() (gas: 1718967) -WindowTest:test_european_repayOnlyInWindow() (gas: 1739508) -WindowTest:test_mature_onlyAtExpiry_andWindowInBlocks() (gas: 1805997) -WindowTest:test_noSettleAfterExpiry() (gas: 1509499) -WindowTest:test_segmentedClose() (gas: 2606733) -WrapperNamesTest:test_wrapper_hostileSymbols() (gas: 7196083) -WrapperNamesTest:test_wrapper_subDailyGridNamesCarryTime() (gas: 7251024) \ No newline at end of file +VaultTest:test_pokeInvests25pct_onlyNonCompetitive() (gas: 2724753) +VaultTest:test_redeemerKeepsEpochDefaultShare() (gas: 3850017) +VaultTest:test_rollPurgesEveryOrderOfTheEpoch() (gas: 2213284) +VaultTest:test_targetsLowestTradedStrikeInGraduatedMarket() (gas: 71844) +WindowAlignTest:test_americanCannotRepayAfterTheSharedWindowEvenIfNeverMatured() (gas: 1312261) +WindowAlignTest:test_americanRepaysAfterExpiryBeforeAnyMature() (gas: 1147918) +WindowAlignTest:test_laterPoolsJoinTheFirstWindow() (gas: 594937) +WindowAlignTest:test_purgeKeepsRolloverWhileSharedWindowRuns() (gas: 1836700) +WindowAlignTest:test_rolloverCannotStartAfterTheSharedWindow() (gas: 1528908) +WindowAlignTest:test_rolloverClearsAtExpiryBeforeTheOldPoolIsMatured() (gas: 2111578) +WindowAlignTest:test_unmaturedPoolRepaysInsideTheSharedWindowOnly() (gas: 1637114) +WindowTest:test_abandonmentAuction_surplusToRHolders() (gas: 2531598) +WindowTest:test_allRepay_lendersGetQuote() (gas: 2133323) +WindowTest:test_american_repayAnytime_withSpreadShare() (gas: 1849549) +WindowTest:test_bidBookFull_evictsLowestSurplus() (gas: 2664059) +WindowTest:test_bid_cancellableOnlyInWindow() (gas: 2106245) +WindowTest:test_claim_burnsAndPaysProRata_mixed() (gas: 2097924) +WindowTest:test_closeWindow_tooEarly() (gas: 1657857) +WindowTest:test_default_lendersGetAsset() (gas: 1865812) +WindowTest:test_european_repayOnlyInWindow() (gas: 1802090) +WindowTest:test_mature_onlyAtExpiry_andWindowInBlocks() (gas: 1870701) +WindowTest:test_noSettleAfterExpiry() (gas: 1604789) +WindowTest:test_segmentedClose() (gas: 2762257) +WrapperNamesTest:test_wrapper_hostileSymbols() (gas: 7228038) +WrapperNamesTest:test_wrapper_subDailyGridNamesCarryTime() (gas: 7282468) \ No newline at end of file From d1983b2e6f6cda37f14fa2317aaef77a4fe8cd84 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:32:52 -0400 Subject: [PATCH 069/169] refactor(core): move mature, pause and graduate bodies into WindowLogic Bytecode room for the v0.8 additions: the three entry points keep their signatures and checks, the core only forwards (pause keeps the guardian check in the core). Params gains period and maxPauseBlocks so the library can validate expiries and pause lengths. Behaviour unchanged. Invariants: 6 (delegatecall targets are still only the three logic libraries). Co-Authored-By: Claude Opus 5.5 --- contracts/src/Confluere.sol | 43 ++++++------------ contracts/src/libraries/State.sol | 2 + contracts/src/libraries/logic/WindowLogic.sol | 44 +++++++++++++++++++ 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/contracts/src/Confluere.sol b/contracts/src/Confluere.sol index 1219ff9..b878a14 100644 --- a/contracts/src/Confluere.sol +++ b/contracts/src/Confluere.sol @@ -175,14 +175,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen /// @inheritdoc IConfluere function mature(bytes32 poolId) external nonReentrant { - PoolState storage ps = s.pool(poolId); - if (ps.status != Status.Open || block.timestamp < ps.key.expiry) revert InvalidState(); - ps.status = Status.Window; - // one window per expiry: the first mature() of an expiry starts it, later pools join it - uint64 end = s.windowEnd[ps.key.expiry]; - if (end == 0) s.windowEnd[ps.key.expiry] = end = uint64(block.number) + WINDOW_BLOCKS; - ps.windowEnd = end; - emit Matured(poolId, end); + WindowLogic.mature(s, _params(), poolId); } /// @inheritdoc IConfluere @@ -224,32 +217,13 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen /// @dev Only for pools of markets that have not graduated. Blocks new orders and clearing, never cancel, /// repay, bids or claims. Marks the expiry as bad for graduation. function pause(bytes32 poolId, uint64 blocks) external { - PoolState storage ps = s.pool(poolId); if (msg.sender != guardian) revert NotOwner(); - if (s.graduated[ps.marketId] || blocks == 0 || blocks > MAX_PAUSE_BLOCKS) revert InvalidParams(); - uint64 until = uint64(block.number) + blocks; - ps.pausedUntil = until; - s.stats[ps.marketId][ps.key.expiry].bad = true; - emit Paused(poolId, until); + WindowLogic.pause(s, _params(), poolId, blocks); } /// @inheritdoc IConfluere - /// @dev Permissionless. A market graduates after GRADUATION_CYCLES consecutive past expiries that each reached - /// the per-quote treasury-fee baseline (trade fees + spread share) with no pause and no circuit breaker. Graduation is permanent. function graduate(address asset, address quote, Kind kind, uint64 lastExpiry) external { - bytes32 m = PoolKeyLib.marketId(asset, quote, kind); - uint256 threshold = s.graduationFee[quote]; - GridConfig memory g = grid(); - if (s.graduated[m] || threshold == 0 || !Grid.isOnGrid(g, lastExpiry) || lastExpiry >= block.timestamp) { - revert InvalidParams(); - } - if (lastExpiry < g.anchor + uint256(g.period) * (GRADUATION_CYCLES - 1)) revert InvalidParams(); - for (uint256 i; i < GRADUATION_CYCLES; ++i) { - MarketStats storage st = s.stats[m][uint64(lastExpiry - i * g.period)]; - if (st.bad || st.fees < threshold) revert InvalidState(); - } - s.graduated[m] = true; - emit PoolGraduated(m, lastExpiry); + WindowLogic.graduate(s, _params(), asset, quote, kind, lastExpiry); } // ================================================================ flash loans (ERC-3156) @@ -437,7 +411,16 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen // ================================================================ internals function _params() internal view returns (Params memory) { - return Params(treasury, MAX_ORDERS_PER_SIDE, MAX_BIDS, WINDOW_BLOCKS, GRID_ANCHOR, GRID_BATCH); + return Params( + treasury, + MAX_ORDERS_PER_SIDE, + MAX_BIDS, + WINDOW_BLOCKS, + GRID_ANCHOR, + GRID_BATCH, + GRID_PERIOD, + MAX_PAUSE_BLOCKS + ); } /// @dev R units locked in a pending rollover cannot move. diff --git a/contracts/src/libraries/State.sol b/contracts/src/libraries/State.sol index 3f2dc44..78d5386 100644 --- a/contracts/src/libraries/State.sol +++ b/contracts/src/libraries/State.sol @@ -68,6 +68,8 @@ struct Params { uint64 windowBlocks; uint64 anchor; // grid anchor uint64 batchInterval; // base batch interval + uint64 period; // distance between consecutive expiries + uint64 maxPauseBlocks; // guardian pause budget per pool } /// @notice Helpers shared by the core and every logic library. Internal, so each library embeds its own copy. diff --git a/contracts/src/libraries/logic/WindowLogic.sol b/contracts/src/libraries/logic/WindowLogic.sol index e803b4e..c3fae39 100644 --- a/contracts/src/libraries/logic/WindowLogic.sol +++ b/contracts/src/libraries/logic/WindowLogic.sol @@ -14,6 +14,50 @@ library WindowLogic { using CoreLib for PoolState; using PoolKeyLib for bytes32; + uint8 internal constant GRADUATION_CYCLES = 4; + + /// @notice Start (or join) the expiry's shared repayment window. + function mature(CoreState storage s, Params memory pr, bytes32 poolId) external { + PoolState storage ps = s.pool(poolId); + if (ps.status != Status.Open || block.timestamp < ps.key.expiry) revert IConfluere.InvalidState(); + ps.status = Status.Window; + // one window per expiry: the first mature() of an expiry starts it, later pools join it + uint64 end = s.windowEnd[ps.key.expiry]; + if (end == 0) s.windowEnd[ps.key.expiry] = end = uint64(block.number) + pr.windowBlocks; + ps.windowEnd = end; + emit IConfluere.Matured(poolId, end); + } + + /// @notice Guardian pause of an un-graduated pool (the core checks the caller). + function pause(CoreState storage s, Params memory pr, bytes32 poolId, uint64 blocks) external { + PoolState storage ps = s.pool(poolId); + if (s.graduated[ps.marketId] || blocks == 0 || blocks > pr.maxPauseBlocks) revert IConfluere.InvalidParams(); + uint64 until = uint64(block.number) + blocks; + ps.pausedUntil = until; + s.stats[ps.marketId][ps.key.expiry].bad = true; + emit IConfluere.Paused(poolId, until); + } + + /// @notice Permissionless. A market graduates after GRADUATION_CYCLES consecutive past expiries that each reached + /// the per-quote treasury-fee baseline (trade fees + spread share) with no pause and no circuit breaker. + function graduate(CoreState storage s, Params memory pr, address asset, address quote, Kind kind, uint64 lastExpiry) + external + { + bytes32 m = PoolKeyLib.marketId(asset, quote, kind); + uint256 threshold = s.graduationFee[quote]; + if ( + s.graduated[m] || threshold == 0 || lastExpiry < pr.anchor || (lastExpiry - pr.anchor) % pr.period != 0 + || lastExpiry >= block.timestamp + ) revert IConfluere.InvalidParams(); + if (lastExpiry < pr.anchor + uint256(pr.period) * (GRADUATION_CYCLES - 1)) revert IConfluere.InvalidParams(); + for (uint256 i; i < GRADUATION_CYCLES; ++i) { + IConfluere.MarketStats storage st = s.stats[m][uint64(lastExpiry - i * pr.period)]; + if (st.bad || st.fees < threshold) revert IConfluere.InvalidState(); + } + s.graduated[m] = true; + emit IConfluere.PoolGraduated(m, lastExpiry); + } + /// @notice Pay K per whole asset (rounded up) to buy back `units`. American: any time until the window ends. /// European: only inside the window. All of K goes to C holders; the spread share was taken at clearing. function repay(CoreState storage s, bytes32 poolId, uint96 units) external { From 36c55f687c224c9a1deba38fdbf06abe4025bb08 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:35:03 -0400 Subject: [PATCH 070/169] feat(core): cap cumulative guardian pause per pool (Confluere.sol) pause() now keeps pausedBlocks per pool: the blocks granted over the pool's life may not exceed MAX_PAUSE_BLOCKS, and a running pause cannot be renewed or extended (IsPaused). The budget counts blocks granted, not blocks actually paused, which is simplest to prove. Paused gains the cumulative total and PoolView exposes pausedBlocks. Tests: PauseCap.t.sol (cap, no renewal while paused, remaining budget after a pause ends, per-pool budget, fuzz); invariant_pauseCap in the invariant suite. Invariants: 3c (new). Part 1/6 of: feat(core): cap cumulative guardian pause per pool Files: contracts/src/Confluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/Confluere.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/src/Confluere.sol b/contracts/src/Confluere.sol index b878a14..56db3c2 100644 --- a/contracts/src/Confluere.sol +++ b/contracts/src/Confluere.sol @@ -327,6 +327,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen v.lastSettledBatch = ps.lastSettledBatch; v.windowEnd = ps.windowEnd; v.pausedUntil = ps.pausedUntil; + v.pausedBlocks = ps.pausedBlocks; v.lastClearingPrice = ps.lastClearingPrice; v.bandBase = ps.bandBase; v.quoteBal = ps.quoteBal; From 5be1462783c9f6a8979dfc6fa955120fe6ffe606 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:35:03 -0400 Subject: [PATCH 071/169] feat(core): cap cumulative guardian pause per pool (IConfluere.sol) Part 2/6 of: feat(core): cap cumulative guardian pause per pool Files: contracts/src/interfaces/IConfluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/interfaces/IConfluere.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/src/interfaces/IConfluere.sol b/contracts/src/interfaces/IConfluere.sol index 84e2a13..89f9c33 100644 --- a/contracts/src/interfaces/IConfluere.sol +++ b/contracts/src/interfaces/IConfluere.sol @@ -36,6 +36,7 @@ interface IConfluere { uint64 lastSettledBatch; uint64 windowEnd; uint64 pausedUntil; + uint64 pausedBlocks; uint96 lastClearingPrice; uint96 bandBase; uint256 quoteBal; @@ -93,7 +94,7 @@ interface IConfluere { event WindowClosed(bytes32 indexed poolId, uint256 quoteBal, uint256 assetBal, uint256 surplusBal); event Claimed(bytes32 indexed poolId, address indexed account, uint256 quoteOut, uint256 assetOut); event Withdrawn(address indexed token, address indexed account, address to, uint256 amount); - event Paused(bytes32 indexed poolId, uint64 untilBlock); + event Paused(bytes32 indexed poolId, uint64 untilBlock, uint64 pausedBlocks); event PoolGraduated(bytes32 indexed marketId, uint64 lastExpiry); event FlashLoan(address indexed receiver, address indexed token, uint256 amount, uint256 fee); From cd2e7a565d41dca54d726ce7d23f536fe5fc508b Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:35:03 -0400 Subject: [PATCH 072/169] feat(core): cap cumulative guardian pause per pool (State.sol) Part 3/6 of: feat(core): cap cumulative guardian pause per pool Files: contracts/src/libraries/State.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/State.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/src/libraries/State.sol b/contracts/src/libraries/State.sol index 78d5386..44c5f7e 100644 --- a/contracts/src/libraries/State.sol +++ b/contracts/src/libraries/State.sol @@ -22,6 +22,7 @@ struct PoolState { uint64 lastSettledBatch; uint64 windowEnd; uint64 pausedUntil; + uint64 pausedBlocks; // cumulative guardian pause granted to this pool, capped at MAX_PAUSE_BLOCKS uint96 lastClearingPrice; uint96 bandBase; uint8 cadence; // batch interval = base interval * {1, 12, 48}[cadence] From b00b8034ffe627f479d8584b1b5e8af669201fc4 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:35:03 -0400 Subject: [PATCH 073/169] feat(core): cap cumulative guardian pause per pool (libraries/logic) Part 4/6 of: feat(core): cap cumulative guardian pause per pool Files: contracts/src/libraries/logic/WindowLogic.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/logic/WindowLogic.sol | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/contracts/src/libraries/logic/WindowLogic.sol b/contracts/src/libraries/logic/WindowLogic.sol index c3fae39..4b672a8 100644 --- a/contracts/src/libraries/logic/WindowLogic.sol +++ b/contracts/src/libraries/logic/WindowLogic.sol @@ -28,14 +28,18 @@ library WindowLogic { emit IConfluere.Matured(poolId, end); } - /// @notice Guardian pause of an un-graduated pool (the core checks the caller). + /// @notice Guardian pause of an un-graduated pool (the core checks the caller). Each pool has a cumulative budget + /// of MAX_PAUSE_BLOCKS, counted on the blocks granted; a running pause cannot be extended (invariant 3c). function pause(CoreState storage s, Params memory pr, bytes32 poolId, uint64 blocks) external { PoolState storage ps = s.pool(poolId); - if (s.graduated[ps.marketId] || blocks == 0 || blocks > pr.maxPauseBlocks) revert IConfluere.InvalidParams(); + uint256 total = uint256(ps.pausedBlocks) + blocks; + if (s.graduated[ps.marketId] || blocks == 0 || total > pr.maxPauseBlocks) revert IConfluere.InvalidParams(); + if (block.number < ps.pausedUntil) revert IConfluere.IsPaused(); uint64 until = uint64(block.number) + blocks; ps.pausedUntil = until; + ps.pausedBlocks = uint64(total); s.stats[ps.marketId][ps.key.expiry].bad = true; - emit IConfluere.Paused(poolId, until); + emit IConfluere.Paused(poolId, until, uint64(total)); } /// @notice Permissionless. A market graduates after GRADUATION_CYCLES consecutive past expiries that each reached From 9e400c26353b1f6c6621431e908832cd02d00a52 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:35:03 -0400 Subject: [PATCH 074/169] feat(core): cap cumulative guardian pause per pool (tests invariant) Part 5/6 of: feat(core): cap cumulative guardian pause per pool Files: contracts/test/invariant/Handler.sol, contracts/test/invariant/Invariants.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/invariant/Handler.sol | 7 ++++++- contracts/test/invariant/Invariants.t.sol | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/contracts/test/invariant/Handler.sol b/contracts/test/invariant/Handler.sol index 614811b..bad0268 100644 --- a/contracts/test/invariant/Handler.sol +++ b/contracts/test/invariant/Handler.sol @@ -46,6 +46,7 @@ contract Handler is Test { bool public pushedDuringNoPushCall; bool public windowMisaligned; // blueprint 3.5: every pool of an expiry shares one window bool public lockedCancelled; // a live locked order was cancelled + bool public pauseExtended; // invariant 3c: a running pause was extended FlashBorrower public borrower = new FlashBorrower(); mapping(uint64 => bool) internal _locked; @@ -326,8 +327,12 @@ contract Handler is Test { function pause(uint256 p, uint256 blocks) external guarded { calls++; + bytes32 pid = _pool(p); + bool running = block.number < core.pool(pid).pausedUntil; vm.prank(address(0xdead)); // the guardian in Invariants.setUp - try core.pause(_pool(p), uint64(bound(blocks, 1, 1000))) {} catch {} + try core.pause(pid, uint64(bound(blocks, 1, 600))) { + if (running) pauseExtended = true; + } catch {} } function placeBid(uint256 a, uint256 p, uint256 units, uint256 surplus) external guarded { diff --git a/contracts/test/invariant/Invariants.t.sol b/contracts/test/invariant/Invariants.t.sol index 1cd4a85..5fef704 100644 --- a/contracts/test/invariant/Invariants.t.sol +++ b/contracts/test/invariant/Invariants.t.sol @@ -137,6 +137,14 @@ contract InvariantsTest is Test { assertFalse(h.pushedDuringNoPushCall(), "token left during a no-push call"); } + /// @dev Invariant 3c: the guardian's pause budget per pool is cumulative and a running pause cannot be extended. + function invariant_pauseCap() public view { + assertFalse(h.pauseExtended(), "running pause extended"); + for (uint256 i; i < h.poolsLength(); ++i) { + assertLe(core.pool(h.pools(i)).pausedBlocks, 1000, "pause budget exceeded"); + } + } + /// @dev Blueprint 3.5: pools of one expiry share a window however late each is matured. function invariant_windowsAligned() public view { assertFalse(h.windowMisaligned(), "window not aligned per expiry"); From 5ee90d4317d708790a7b70c19addf72538d823da Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:35:03 -0400 Subject: [PATCH 075/169] feat(core): cap cumulative guardian pause per pool (tests unit) Part 6/6 of: feat(core): cap cumulative guardian pause per pool Files: contracts/test/unit/PauseCap.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/PauseCap.t.sol | 75 ++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 contracts/test/unit/PauseCap.t.sol diff --git a/contracts/test/unit/PauseCap.t.sol b/contracts/test/unit/PauseCap.t.sol new file mode 100644 index 0000000..df774d5 --- /dev/null +++ b/contracts/test/unit/PauseCap.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {Base} from "../utils/Base.sol"; +import {IConfluere} from "../../src/interfaces/IConfluere.sol"; +import {Kind} from "../../src/libraries/PoolKey.sol"; + +/// @notice Guardian pause budget per pool (blueprint 10, ARCH 2.9, invariant 3c). +contract PauseCapTest is Base { + uint64 internal constant MAX = 7 days; // Base sets maxPauseBlocks = 7 days + + function _pause(bytes32 pid, uint64 blocks) internal { + vm.prank(guardian); + core.pause(pid, blocks); + } + + function test_pause_cumulativeCap() public { + bytes32 pid = _create(); + _pause(pid, MAX - 10); + vm.roll(block.number + MAX); + vm.prank(guardian); + vm.expectRevert(IConfluere.InvalidParams.selector); + core.pause(pid, 11); + _pause(pid, 10); + assertEq(core.pool(pid).pausedBlocks, MAX); + vm.roll(block.number + 10); + vm.prank(guardian); + vm.expectRevert(IConfluere.InvalidParams.selector); + core.pause(pid, 1); + } + + function test_pause_rejectedWhilePaused() public { + bytes32 pid = _create(); + _pause(pid, 100); + vm.roll(block.number + 99); + vm.prank(guardian); + vm.expectRevert(IConfluere.IsPaused.selector); + core.pause(pid, 1); + } + + function test_pause_afterExpiryOfPause_usesRemaining() public { + bytes32 pid = _create(); + _pause(pid, 100); + vm.roll(block.number + 100); + _pause(pid, 200); + IConfluere.PoolView memory v = core.pool(pid); + assertEq(v.pausedBlocks, 300); + assertEq(v.pausedUntil, block.number + 200); + } + + function test_pause_capIsPerPool() public { + bytes32 a = _create(); + bytes32 b = _create(K, Kind.European, E1); + _pause(a, MAX); + _pause(b, MAX); + assertEq(core.pool(b).pausedBlocks, MAX); + } + + function test_pause_onlyGuardian() public { + bytes32 pid = _create(); + vm.prank(eve); + vm.expectRevert(IConfluere.NotOwner.selector); + core.pause(pid, 1); + } + + function testFuzz_pause_neverExceedsCap(uint64[6] memory asks, uint8[6] memory gaps) public { + bytes32 pid = _create(); + for (uint256 i; i < asks.length; ++i) { + vm.roll(block.number + uint256(gaps[i]) * 1000); + vm.prank(guardian); + try core.pause(pid, asks[i] % (2 * MAX)) {} catch {} + assertLe(core.pool(pid).pausedBlocks, MAX); + } + } +} From def217274da44359ee2d4ddf8b0288e748abc2a2 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 076/169] feat(core): put band and cadence into PoolKey (contracts/script) PoolKey gains bandBps (150 / 800 / 1500) and cadence (0 / 1 / 2), so pools of one strike with different bands or batch cadences coexist under different ids and a first creator can no longer pick the tier for everyone (audit M-1). The unused feeBps whitelist is gone and the two creation entry points merge into createPool(key, opts). PoolOptions drops cadence and gains autoSell (stored now, acted on by the autoSell change); Status gains Selling between Closing and Closed. Events: PoolCreated(poolId, key, marketId); PoolOptionsSet(poolId, minFill, firstLo, firstHi, autoSell). PoolView drops feeBps / bandBps (read them from key). marketId is unchanged, so graduation, seats and marketFees stay per market. Periphery: SeatRegistry reads key.bandBps; Vault looks up its target in the 800 band at any cadence until it reads the main tier. CreatePools creates bootstrap pools with autoSell. Invariants: none (creation rules). Part 1/13 of: feat(core): put band and cadence into PoolKey Files: contracts/script/CreatePools.s.sol Co-Authored-By: Claude Opus 5.5 --- contracts/script/CreatePools.s.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/contracts/script/CreatePools.s.sol b/contracts/script/CreatePools.s.sol index 1dddc36..4bfb96d 100644 --- a/contracts/script/CreatePools.s.sol +++ b/contracts/script/CreatePools.s.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.37; import {Script, console2} from "forge-std/Script.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Confluere} from "../src/Confluere.sol"; +import {IConfluere} from "../src/interfaces/IConfluere.sol"; import {PoolKey, PoolKeyLib, Kind, Status} from "../src/libraries/PoolKey.sol"; import {GridConfig} from "../src/libraries/Grid.sol"; import {MockERC20} from "../src/mocks/MockERC20.sol"; @@ -170,9 +171,11 @@ contract CreatePools is Script { for (uint256 s; s < 2; ++s) { if (s == 1 && pair.strikes[1] == pair.strikes[0]) break; // single strike for (uint256 k; k < 2; ++k) { - PoolKey memory key = PoolKey(pair.asset, pair.quote, expiry, pair.strikes[s], Kind(k)); + PoolKey memory key = PoolKey(pair.asset, pair.quote, expiry, pair.strikes[s], Kind(k), band, 0); bytes32 id = PoolKeyLib.toId(key); - if (core.pool(id).status == Status.None) id = core.createPool(key, 1000, band); + if (core.pool(id).status == Status.None) { + id = core.createPool(key, IConfluere.PoolOptions(0, 0, 0, true)); + } ids[n++] = id; } } From 883e6f0350da1175814bd009ccc4e6c374ba57a0 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 077/169] feat(core): put band and cadence into PoolKey (Confluere.sol) Part 2/13 of: feat(core): put band and cadence into PoolKey Files: contracts/src/Confluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/Confluere.sol | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/contracts/src/Confluere.sol b/contracts/src/Confluere.sol index 56db3c2..f22f8fc 100644 --- a/contracts/src/Confluere.sol +++ b/contracts/src/Confluere.sol @@ -111,21 +111,8 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen // ================================================================ pools /// @inheritdoc IConfluere - function createPool(PoolKey calldata key, uint16 feeBps, uint16 bandBps) - external - nonReentrant - returns (bytes32 poolId) - { - return OrderLogic.createPool(s, grid(), key, feeBps, bandBps, PoolOptions(0, 0, 0, 0)); - } - - /// @inheritdoc IConfluere - function createPoolWithOptions(PoolKey calldata key, uint16 feeBps, uint16 bandBps, PoolOptions calldata opts) - external - nonReentrant - returns (bytes32 poolId) - { - return OrderLogic.createPool(s, grid(), key, feeBps, bandBps, opts); + function createPool(PoolKey calldata key, PoolOptions calldata opts) external nonReentrant returns (bytes32) { + return OrderLogic.createPool(s, grid(), key, opts); } // ================================================================ orders @@ -320,8 +307,6 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen v.key = ps.key; v.status = ps.status; v.assetDecimals = ps.assetDecimals; - v.feeBps = ps.feeBps; - v.bandBps = ps.bandBps; v.bandFailures = ps.bandFailures; v.settleCount = ps.settleCount; v.lastSettledBatch = ps.lastSettledBatch; @@ -337,7 +322,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen v.filledUnits = ps.filledUnits; v.repaidUnits = ps.repaidUnits; v.closeRemaining = ps.closeRemaining; - v.options = PoolOptions(ps.cadence, ps.minFill, ps.firstLo, ps.firstHi); + v.options = PoolOptions(ps.minFill, ps.firstLo, ps.firstHi, ps.autoSell); v.batchInterval = uint64(ps.interval(GRID_BATCH)); } From 8aeafce617bdea6190a456632eacb778d10e1d1e Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 078/169] feat(core): put band and cadence into PoolKey (IConfluere.sol) Part 3/13 of: feat(core): put band and cadence into PoolKey Files: contracts/src/interfaces/IConfluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/interfaces/IConfluere.sol | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/contracts/src/interfaces/IConfluere.sol b/contracts/src/interfaces/IConfluere.sol index 89f9c33..0f28867 100644 --- a/contracts/src/interfaces/IConfluere.sol +++ b/contracts/src/interfaces/IConfluere.sol @@ -16,21 +16,20 @@ interface IConfluere { address owner; } - /// @notice Creator-chosen pool options (blueprint §4): batch cadence for long-tail assets, a minimum - /// crossing volume below which a batch does not trade (minimum fill), and price bounds for the first auction. + /// @notice Creator-chosen pool options (blueprint §4), not part of the pool id: a minimum crossing volume below + /// which a batch does not trade (minimum fill), price bounds for the first auction, and autoSell (blueprint 3.6). + /// Band and cadence are part of the PoolKey. struct PoolOptions { - uint8 cadence; // 0: every base interval; 1: every 12; 2: every 48 (mainnet 30 min / 6 h / 24 h) uint96 minFill; // asset units; a batch whose competitive volume is below this does not trade uint96 firstLo; // until the first trade, P* must be >= firstLo ... uint96 firstHi; // ... and <= firstHi (0 = no upper bound) + bool autoSell; // at close, sell defaulted asset into the next expiry's pool of the same tier } struct PoolView { PoolKey key; Status status; uint8 assetDecimals; - uint16 feeBps; - uint16 bandBps; uint8 bandFailures; uint32 settleCount; uint64 lastSettledBatch; @@ -58,8 +57,8 @@ interface IConfluere { bool bad; // paused or circuit breaker tripped at this expiry } - event PoolCreated(bytes32 indexed poolId, PoolKey key, uint16 feeBps, uint16 bandBps, bytes32 indexed marketId); - event PoolOptionsSet(bytes32 indexed poolId, uint8 cadence, uint96 minFill, uint96 firstLo, uint96 firstHi); + event PoolCreated(bytes32 indexed poolId, PoolKey key, bytes32 indexed marketId); + event PoolOptionsSet(bytes32 indexed poolId, uint96 minFill, uint96 firstLo, uint96 firstHi, bool autoSell); event OrderPlaced( bytes32 indexed poolId, uint64 indexed orderId, @@ -109,10 +108,7 @@ interface IConfluere { error Locked(); error FlashFailed(); - function createPool(PoolKey calldata key, uint16 feeBps, uint16 bandBps) external returns (bytes32 poolId); - function createPoolWithOptions(PoolKey calldata key, uint16 feeBps, uint16 bandBps, PoolOptions calldata opts) - external - returns (bytes32 poolId); + function createPool(PoolKey calldata key, PoolOptions calldata opts) external returns (bytes32 poolId); function placeOrder(bytes32 poolId, OrderParams calldata p) external returns (uint64 orderId); function placeLockedOrder(bytes32 poolId, OrderParams calldata p) external returns (uint64 orderId); function cancelOrder(bytes32 poolId, uint64 orderId) external; From a079aa96ac3a527f1850c76d64a872b7a036fe93 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 079/169] feat(core): put band and cadence into PoolKey (PoolKey.sol) Part 4/13 of: feat(core): put band and cadence into PoolKey Files: contracts/src/libraries/PoolKey.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/PoolKey.sol | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contracts/src/libraries/PoolKey.sol b/contracts/src/libraries/PoolKey.sol index 6c9d1c8..52c90bf 100644 --- a/contracts/src/libraries/PoolKey.sol +++ b/contracts/src/libraries/PoolKey.sol @@ -7,12 +7,14 @@ enum Kind { European } -/// @notice Lifecycle of a pool. Closing is the segmented abandonment-auction phase after the window ends. +/// @notice Lifecycle of a pool. Closing is the segmented abandonment-auction phase after the window ends; Selling is +/// the autoSell sub-state after it, while the defaulted asset rests as a sell order in the next expiry's pool. enum Status { None, Open, Window, Closing, + Selling, Closed } @@ -23,6 +25,8 @@ struct PoolKey { uint64 expiry; // timestamp on the expiry grid uint128 strike; // K: quote base units per 1 whole asset (10 ** assetDecimals units) Kind kind; + uint16 bandBps; // price band of the pool: 150, 800 or 1500 + uint8 cadence; // batch interval = base interval * {1, 12, 48}[cadence] } /// @notice Pool id, C-Token id and R-Token id derivation. From e3f09b4b1b65a22234e2ac1889e0f7dabc6a5fe8 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 080/169] feat(core): put band and cadence into PoolKey (SeatRegistry.sol) Part 5/13 of: feat(core): put band and cadence into PoolKey Files: contracts/src/periphery/SeatRegistry.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/SeatRegistry.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/src/periphery/SeatRegistry.sol b/contracts/src/periphery/SeatRegistry.sol index eb8d60a..5193389 100644 --- a/contracts/src/periphery/SeatRegistry.sol +++ b/contracts/src/periphery/SeatRegistry.sol @@ -190,7 +190,9 @@ contract SeatRegistry is ReentrancyGuardTransient { uint256 last = v.lastClearingPrice; uint256 valuedAt = v.key.strike; if (last != 0) { - if (p.side == BUY ? p.limit < last * (BPS - v.bandBps) / BPS : p.limit > last * (BPS + v.bandBps) / BPS) { + if (p.side == BUY + ? p.limit < last * (BPS - v.key.bandBps) / BPS + : p.limit > last * (BPS + v.key.bandBps) / BPS) { revert InvalidParams(); } } else if (p.side == BUY && p.limit < valuedAt) { From 607fb0b500f6a9b010b3897a5cedeaf4d8866a7b Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 081/169] feat(core): put band and cadence into PoolKey (State.sol) Part 6/13 of: feat(core): put band and cadence into PoolKey Files: contracts/src/libraries/State.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/State.sol | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/contracts/src/libraries/State.sol b/contracts/src/libraries/State.sol index 44c5f7e..0bde21d 100644 --- a/contracts/src/libraries/State.sol +++ b/contracts/src/libraries/State.sol @@ -15,8 +15,7 @@ struct PoolState { bytes32 marketId; Status status; uint8 assetDecimals; - uint16 feeBps; - uint16 bandBps; + bool autoSell; // pool option: at close, defaulted asset is sold into the next expiry's pool of the same tier uint8 bandFailures; uint32 settleCount; uint64 lastSettledBatch; @@ -25,7 +24,6 @@ struct PoolState { uint64 pausedBlocks; // cumulative guardian pause granted to this pool, capped at MAX_PAUSE_BLOCKS uint96 lastClearingPrice; uint96 bandBase; - uint8 cadence; // batch interval = base interval * {1, 12, 48}[cadence] uint96 minFill; // minimum fill: smallest competitive volume (asset units) a batch may trade uint96 firstLo; // first-auction price bounds; firstHi = 0 means no upper bound uint96 firstHi; @@ -97,7 +95,7 @@ library CoreLib { /// @notice This pool's batch interval: the base interval times 1, 12 or 48 by cadence. function interval(PoolState storage ps, uint64 base) internal view returns (uint256) { - uint8 c = ps.cadence; + uint8 c = ps.key.cadence; return uint256(base) * (c == 0 ? 1 : c == 1 ? 12 : 48); } From c969f74b399e91c5c629e6fb120715eb4b8c2a89 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 082/169] feat(core): put band and cadence into PoolKey (Vault.sol) Part 7/13 of: feat(core): put band and cadence into PoolKey Files: contracts/src/periphery/Vault.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/Vault.sol | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/contracts/src/periphery/Vault.sol b/contracts/src/periphery/Vault.sol index 8afe15a..87bb907 100644 --- a/contracts/src/periphery/Vault.sol +++ b/contracts/src/periphery/Vault.sol @@ -182,10 +182,12 @@ contract Vault is ERC20, IERC7540, ReentrancyGuardTransient { IConfluere.MarketStats memory st = core.marketStats(m, targetExpiry - core.grid().period); uint96 strike = strikeRule == StrikeRule.LowestTraded ? st.minStrike : st.maxStrike; if (strike == 0) return (bytes32(0), 0, 0); - bytes32 pid = PoolKey(token, _quote, targetExpiry, strike, kind).toId(); - IConfluere.PoolView memory v = core.pool(pid); - if (v.status != Status.Open) return (bytes32(0), 0, 0); - return (pid, strike, v.settleCount); + for (uint8 c; c < 3; ++c) { + bytes32 pid = PoolKey(token, _quote, targetExpiry, strike, kind, 800, c).toId(); + IConfluere.PoolView memory v = core.pool(pid); + if (v.status == Status.Open) return (pid, strike, v.settleCount); + } + return (bytes32(0), 0, 0); } /// @notice Quote the vault may still invest this epoch: idle minus queued deposits, reserved redemptions and an From a5783ebfaa8a450c178f16959d924bda8afc283d Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 083/169] feat(core): put band and cadence into PoolKey (libraries/logic) Part 8/13 of: feat(core): put band and cadence into PoolKey Files: contracts/src/libraries/logic/ClearingLogic.sol, contracts/src/libraries/logic/OrderLogic.sol Co-Authored-By: Claude Opus 5.5 --- .../src/libraries/logic/ClearingLogic.sol | 2 +- contracts/src/libraries/logic/OrderLogic.sol | 22 ++++++++----------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/contracts/src/libraries/logic/ClearingLogic.sol b/contracts/src/libraries/logic/ClearingLogic.sol index 6ae3e91..cdc8a31 100644 --- a/contracts/src/libraries/logic/ClearingLogic.sol +++ b/contracts/src/libraries/logic/ClearingLogic.sol @@ -225,7 +225,7 @@ library ClearingLogic { ps.bandFailures = 0; return false; } - if (Clearing.withinBand(price, base, Clearing.widenedBand(ps.bandBps, f))) { + if (Clearing.withinBand(price, base, Clearing.widenedBand(ps.key.bandBps, f))) { ps.bandBase = SafeCast.toUint96(price); ps.bandFailures = 0; return true; diff --git a/contracts/src/libraries/logic/OrderLogic.sol b/contracts/src/libraries/logic/OrderLogic.sol index c82dcc8..d9c668f 100644 --- a/contracts/src/libraries/logic/OrderLogic.sol +++ b/contracts/src/libraries/logic/OrderLogic.sol @@ -26,18 +26,16 @@ library OrderLogic { CoreState storage s, GridConfig memory g, PoolKey calldata key, - uint16 feeBps, - uint16 bandBps, - IConfluere.PoolOptions memory opts + IConfluere.PoolOptions calldata opts ) external returns (bytes32 poolId) { if (key.asset == key.quote || key.asset.code.length == 0 || key.quote.code.length == 0) { revert IConfluere.BadToken(); } - if (feeBps != 1000 && feeBps != 1250 && feeBps != 1500) revert IConfluere.InvalidParams(); - if (bandBps != 150 && bandBps != 800 && bandBps != 1500) revert IConfluere.InvalidParams(); + uint16 band = key.bandBps; + if (band != 150 && band != 800 && band != 1500) revert IConfluere.InvalidParams(); if (!Grid.isListableExpiry(g, key.expiry, block.timestamp)) revert IConfluere.InvalidParams(); if (key.strike > type(uint96).max || !Grid.isGridStrike(g, key.strike)) revert IConfluere.InvalidParams(); - if (opts.cadence > 2 || (opts.firstHi != 0 && opts.firstLo > opts.firstHi)) revert IConfluere.InvalidParams(); + if (key.cadence > 2 || (opts.firstHi != 0 && opts.firstLo > opts.firstHi)) revert IConfluere.InvalidParams(); poolId = PoolKeyLib.toId(key); PoolState storage ps = s.pools[poolId]; @@ -52,18 +50,16 @@ library OrderLogic { ps.marketId = PoolKeyLib.marketId(key.asset, key.quote, key.kind); ps.status = Status.Open; ps.assetDecimals = dec; - ps.feeBps = feeBps; - ps.bandBps = bandBps; - ps.cadence = opts.cadence; + ps.autoSell = opts.autoSell; ps.minFill = opts.minFill; ps.firstLo = opts.firstLo; ps.firstHi = opts.firstHi; uint256 iv = ps.interval(g.batchInterval); if (iv > g.period) revert IConfluere.InvalidParams(); ps.lastSettledBatch = uint64((block.timestamp - g.anchor) / iv); - emit IConfluere.PoolCreated(poolId, key, feeBps, bandBps, ps.marketId); - if (opts.cadence != 0 || opts.minFill != 0 || opts.firstLo != 0 || opts.firstHi != 0) { - emit IConfluere.PoolOptionsSet(poolId, opts.cadence, opts.minFill, opts.firstLo, opts.firstHi); + emit IConfluere.PoolCreated(poolId, key, ps.marketId); + if (opts.minFill != 0 || opts.firstLo != 0 || opts.firstHi != 0 || opts.autoSell) { + emit IConfluere.PoolOptionsSet(poolId, opts.minFill, opts.firstLo, opts.firstHi, opts.autoSell); } } @@ -195,7 +191,7 @@ library OrderLogic { // it is still "just before clearing"). Anyone can settle to lift the latter; while paused nothing can settle, // so that rule is off then. if ( - ps.cadence != 0 && o.side != BID && o.deadline > ps.settleCount && ps.status == Status.Open + ps.key.cadence != 0 && o.side != BID && o.deadline > ps.settleCount && ps.status == Status.Open && block.timestamp < ps.key.expiry ) { uint256 iv = ps.interval(pr.batchInterval); From 05de149d029aacb3334c428a76faa9e7623e5e5f Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 084/169] feat(core): put band and cadence into PoolKey (tests audit) Part 9/13 of: feat(core): put band and cadence into PoolKey Files: contracts/test/audit/AuditFindings.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/audit/AuditFindings.t.sol | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/contracts/test/audit/AuditFindings.t.sol b/contracts/test/audit/AuditFindings.t.sol index 0e5dc32..96c1bf6 100644 --- a/contracts/test/audit/AuditFindings.t.sol +++ b/contracts/test/audit/AuditFindings.t.sol @@ -153,4 +153,17 @@ contract AuditFindingsTest is Base { core.bid(pid, uint96(ONE), 1e6, h2); assertGt(_pend(address(usdc), mallory), 0, "dust bid evicted by a larger bid at the same surplus"); } + + // ------------------------------------------------------------ M-1: band and cadence are part of the pool id + + /// A front-runner who creates the strike first cannot impose a band or cadence on others: another tier of the + /// same strike is a different pool with its own id. + function test_M1_tierIsPartOfId() public { + vm.prank(mallory); + bytes32 wide = _createKey(core, _keyT(K, Kind.American, E1, 1500, 2), _noOpts()); + assertTrue(wide != pid); + assertEq(core.pool(pid).key.bandBps, 800); + assertEq(core.pool(pid).key.cadence, 0); + assertEq(core.pool(wide).batchInterval, 48 * BATCH); + } } From 077f038720f7ca58386c0c3b8e92cf139f98a9cf Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 085/169] feat(core): put band and cadence into PoolKey (tests invariant) Part 10/13 of: feat(core): put band and cadence into PoolKey Files: contracts/test/invariant/Invariants.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/invariant/Invariants.t.sol | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/contracts/test/invariant/Invariants.t.sol b/contracts/test/invariant/Invariants.t.sol index 5fef704..0c6a237 100644 --- a/contracts/test/invariant/Invariants.t.sol +++ b/contracts/test/invariant/Invariants.t.sol @@ -46,10 +46,18 @@ contract InvariantsTest is Test { weth.approve(address(core), type(uint256).max); usdc.approve(address(core), type(uint256).max); bytes32[] memory ps = new bytes32[](3); - ps[0] = core.createPool(PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3000e6, Kind.American), 1000, 800); - ps[1] = - core.createPool(PoolKey(address(weth), address(usdc), ANCHOR + 2 * WEEK, 3000e6, Kind.American), 1000, 800); - ps[2] = core.createPool(PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3100e6, Kind.European), 1500, 150); + ps[0] = core.createPool( + PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3000e6, Kind.American, 800, 0), + IConfluere.PoolOptions(0, 0, 0, false) + ); + ps[1] = core.createPool( + PoolKey(address(weth), address(usdc), ANCHOR + 2 * WEEK, 3000e6, Kind.American, 800, 0), + IConfluere.PoolOptions(0, 0, 0, false) + ); + ps[2] = core.createPool( + PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3100e6, Kind.European, 150, 0), + IConfluere.PoolOptions(0, 0, 0, false) + ); address[] memory as_ = new address[](4); for (uint256 i; i < 4; ++i) { From bf4bce72894cb1d48daa814f5d1ee506d6d34e84 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 086/169] feat(core): put band and cadence into PoolKey (tests medusa) Part 11/13 of: feat(core): put band and cadence into PoolKey Files: contracts/test/medusa/MedusaHarness.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/medusa/MedusaHarness.sol | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/contracts/test/medusa/MedusaHarness.sol b/contracts/test/medusa/MedusaHarness.sol index 37aa63e..98c5b25 100644 --- a/contracts/test/medusa/MedusaHarness.sol +++ b/contracts/test/medusa/MedusaHarness.sol @@ -55,10 +55,14 @@ contract MedusaHarness { usdc.mint(address(this), 10); weth.approve(address(core), type(uint256).max); usdc.approve(address(core), type(uint256).max); - pools[0] = - core.createPool(PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3000e6, Kind.American), 1000, 800); - pools[1] = - core.createPool(PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3000e6, Kind.European), 1500, 150); + pools[0] = core.createPool( + PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3000e6, Kind.American, 800, 0), + IConfluere.PoolOptions(0, 0, 0, false) + ); + pools[1] = core.createPool( + PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3000e6, Kind.European, 150, 0), + IConfluere.PoolOptions(0, 0, 0, false) + ); for (uint256 i; i < 3; ++i) { weth.mint(actors[i], 1e24); usdc.mint(actors[i], 1e15); From f37dbdf91e427c668c1225c54a7af1cf99de639b Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 087/169] feat(core): put band and cadence into PoolKey (tests unit) Part 12/13 of: feat(core): put band and cadence into PoolKey Files: contracts/test/unit/LongTail.t.sol, contracts/test/unit/Periphery.t.sol, contracts/test/unit/Pools.t.sol, contracts/test/unit/Seats.t.sol, contracts/test/unit/Vault.t.sol, contracts/test/unit/WrapperNames.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/LongTail.t.sol | 17 +++++++---------- contracts/test/unit/Periphery.t.sol | 2 +- contracts/test/unit/Pools.t.sol | 23 ++++++++++++++++------- contracts/test/unit/Seats.t.sol | 3 +-- contracts/test/unit/Vault.t.sol | 4 +--- contracts/test/unit/WrapperNames.t.sol | 5 +++-- 6 files changed, 29 insertions(+), 25 deletions(-) diff --git a/contracts/test/unit/LongTail.t.sol b/contracts/test/unit/LongTail.t.sol index 2e2ec32..190bae9 100644 --- a/contracts/test/unit/LongTail.t.sol +++ b/contracts/test/unit/LongTail.t.sol @@ -12,9 +12,8 @@ import {SELL, BUY} from "../../src/libraries/OrderSet.sol"; /// (cancellation cutoff) and the creator's first-auction price bounds. contract LongTailTest is Base { function _createWith(uint8 cadence, uint96 minFill, uint96 lo, uint96 hi) internal returns (bytes32) { - return core.createPoolWithOptions( - _key(K, Kind.American, E1), 1000, 1500, IConfluere.PoolOptions(cadence, minFill, lo, hi) - ); + return + _createKey(core, _keyT(K, Kind.American, E1, 1500, cadence), IConfluere.PoolOptions(minFill, lo, hi, false)); } function _cross(bytes32 pid, uint256 units, uint256 price) internal { @@ -28,7 +27,7 @@ contract LongTailTest is Base { bytes32 pid = _createWith(1, 0, 0, 0); // 12 x 30 min IConfluere.PoolView memory v = core.pool(pid); assertEq(v.batchInterval, 6 hours); - assertEq(v.options.cadence, 1); + assertEq(v.key.cadence, 1); uint256 next = core.nextBatchAt(pid); assertEq((next - ANCHOR) % 6 hours, 0, "boundaries on the 6 h grid"); _cross(pid, ONE, 2900e6); @@ -50,7 +49,7 @@ contract LongTailTest is Base { bytes32 pid = _create(); IConfluere.PoolView memory v = core.pool(pid); assertEq(v.batchInterval, BATCH); - assertEq(v.options.cadence, 0); + assertEq(v.key.cadence, 0); assertEq(v.options.minFill, 0); } @@ -153,8 +152,7 @@ contract LongTailTest is Base { vm.prank(alice); core.cancelOrder(main, a); // mainstream: no cutoff - bytes32 pid = - core.createPoolWithOptions(_key(3100e6, Kind.American, E1), 1000, 1500, IConfluere.PoolOptions(1, 0, 0, 0)); // different strike: a separate pool from `main` + bytes32 pid = _createKey(core, _keyT(3100e6, Kind.American, E1, 1500, 1), _noOpts()); // different strike: a separate pool from `main` uint64 id = _order(alice, pid, SELL, ONE, 3500e6, 0, 1); vm.warp(core.nextBatchAt(pid)); core.settleBatch(pid); // its single batch passes untraded: expired @@ -181,9 +179,8 @@ contract LongTailTest is Base { weth.approve(address(demo), type(uint256).max); usdc.approve(address(demo), type(uint256).max); uint64 exp = ANCHOR + uint64((block.timestamp - ANCHOR) / 1 hours + 1) * 1 hours; - PoolKey memory key = PoolKey(address(weth), address(usdc), exp, K, Kind.American); vm.expectRevert(IConfluere.InvalidParams.selector); - demo.createPoolWithOptions(key, 1000, 800, IConfluere.PoolOptions(2, 0, 0, 0)); - demo.createPoolWithOptions(key, 1000, 800, IConfluere.PoolOptions(1, 0, 0, 0)); // 24 min fits + _createKey(demo, _keyT(K, Kind.American, exp, 800, 2), _noOpts()); + _createKey(demo, _keyT(K, Kind.American, exp, 800, 1), _noOpts()); // 24 min fits } } diff --git a/contracts/test/unit/Periphery.t.sol b/contracts/test/unit/Periphery.t.sol index eaf2ed6..f939431 100644 --- a/contracts/test/unit/Periphery.t.sol +++ b/contracts/test/unit/Periphery.t.sol @@ -98,7 +98,7 @@ contract PeripheryTest is Base { g.bind(core); // only the named binder, and only once weth.approve(address(core2), type(uint256).max); usdc.approve(address(core2), type(uint256).max); - bytes32 p2 = core2.createPool(_key(K, Kind.American, E1), 1000, 800); + bytes32 p2 = _createKey(IConfluere(address(core2)), _key(K, Kind.American, E1), _noOpts()); vm.prank(alice); g.approvePause(p2, 10, 1); diff --git a/contracts/test/unit/Pools.t.sol b/contracts/test/unit/Pools.t.sol index 903e4f0..3ae7edd 100644 --- a/contracts/test/unit/Pools.t.sol +++ b/contracts/test/unit/Pools.t.sol @@ -44,26 +44,35 @@ contract PoolsTest is Base { _create(98e16, Kind.European, E1); // pegged 0.98 in an 18-decimal quote } - function test_createPool_rejectsFeeAndBandOutsideWhitelist() public { + function test_createPool_rejectsBadBandOrCadence() public { vm.expectRevert(IConfluere.InvalidParams.selector); - core.createPool(_key(K, Kind.American, E1), 500, 800); + _createKey(core, _keyT(K, Kind.American, E1, 100, 0), _noOpts()); vm.expectRevert(IConfluere.InvalidParams.selector); - core.createPool(_key(K, Kind.American, E1), 1000, 100); + _createKey(core, _keyT(K, Kind.American, E1, 800, 3), _noOpts()); + } + + function test_sameStrike_differentBand_coexist() public { + bytes32 a = _createKey(core, _keyT(K, Kind.American, E1, 800, 0), _noOpts()); + bytes32 b = _createKey(core, _keyT(K, Kind.American, E1, 1500, 0), _noOpts()); + bytes32 c = _createKey(core, _keyT(K, Kind.American, E1, 800, 1), _noOpts()); + assertTrue(a != b && a != c && b != c); + assertEq(core.pool(b).key.bandBps, 1500); + assertEq(core.pool(c).batchInterval, 12 * BATCH); } function test_createPool_rejectsFeeOnTransfer() public { FeeOnTransferERC20 fot = new FeeOnTransferERC20(); fot.mint(address(this), ONE); fot.approve(address(core), type(uint256).max); - PoolKey memory key = PoolKey(address(fot), address(usdc), E1, K, Kind.American); + PoolKey memory key = PoolKey(address(fot), address(usdc), E1, K, Kind.American, 800, 0); vm.expectRevert(IConfluere.BadToken.selector); - core.createPool(key, 1000, 800); + _createKey(core, key, _noOpts()); } function test_createPool_rejectsSameToken() public { - PoolKey memory key = PoolKey(address(usdc), address(usdc), E1, K, Kind.American); + PoolKey memory key = PoolKey(address(usdc), address(usdc), E1, K, Kind.American, 800, 0); vm.expectRevert(IConfluere.BadToken.selector); - core.createPool(key, 1000, 800); + _createKey(core, key, _noOpts()); } function test_placeOrder_escrows() public { diff --git a/contracts/test/unit/Seats.t.sol b/contracts/test/unit/Seats.t.sol index 053f157..4e783c8 100644 --- a/contracts/test/unit/Seats.t.sol +++ b/contracts/test/unit/Seats.t.sol @@ -193,8 +193,7 @@ contract SeatsTest is Base { /// With a 6-hour long-tail pool as evidence, missing a 30-minute base batch is not a miss; a whole 6-hour batch is. function test_slashUsesTheEvidencePoolsBatchLength() public { - bytes32 slow = - core.createPoolWithOptions(_key(3100e6, Kind.American, E1), 1000, 1500, IConfluere.PoolOptions(1, 0, 0, 0)); + bytes32 slow = _createKey(core, _keyT(3100e6, Kind.American, E1, 1500, 1), _noOpts()); _buy(alice, PRICE, DEPOSIT); _post(alice, ONE, 2800e6); vm.warp(block.timestamp + 2 * BATCH); // two base batches without quoting diff --git a/contracts/test/unit/Vault.t.sol b/contracts/test/unit/Vault.t.sol index 4a9fd2c..002c9df 100644 --- a/contracts/test/unit/Vault.t.sol +++ b/contracts/test/unit/Vault.t.sol @@ -40,9 +40,7 @@ abstract contract VaultBase is Base { core.graduate(address(weth), address(usdc), Kind.American, E4); p5 = _cadence() == 0 ? _create(K, Kind.American, E5) - : core.createPoolWithOptions( - _key(K, Kind.American, E5), 1000, 800, IConfluere.PoolOptions(_cadence(), 0, 0, 0) - ); + : _createKey(core, _keyT(K, Kind.American, E5, 800, _cadence()), _noOpts()); address[] memory as_ = new address[](1); as_[0] = address(weth); diff --git a/contracts/test/unit/WrapperNames.t.sol b/contracts/test/unit/WrapperNames.t.sol index f9f06af..7d507dc 100644 --- a/contracts/test/unit/WrapperNames.t.sol +++ b/contracts/test/unit/WrapperNames.t.sol @@ -18,7 +18,8 @@ contract WrapperNamesTest is Base { weth.approve(address(hourly), 1); usdc.approve(address(hourly), 1); // now = 2026-10-10 08:00 UTC; next hourly expiry 09:00 - bytes32 id = hourly.createPool(_key(K, Kind.European, ANCHOR + 1 days + 1 hours), 1000, 800); + bytes32 id = + _createKey(IConfluere(address(hourly)), _key(K, Kind.European, ANCHOR + 1 days + 1 hours), _noOpts()); ERC6909WrapperFactory f = new ERC6909WrapperFactory(IConfluere(address(hourly))); ERC6909Wrapper w = ERC6909Wrapper(f.create(uint256(id) | 1)); assertEq(w.symbol(), "R-WETH-10OCT-0900"); @@ -43,7 +44,7 @@ contract WrapperNamesTest is Base { k.asset = address(t); t.mint(address(this), 1); t.approve(address(core), 1); - return ERC6909Wrapper(f.create(uint256(core.createPool(k, 1000, 800)))); + return ERC6909Wrapper(f.create(uint256(_createKey(core, k, _noOpts())))); } } From 3d491ba4d3eff1ee783bd3186cca03aa6a94ba67 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:37:52 -0400 Subject: [PATCH 088/169] feat(core): put band and cadence into PoolKey (tests utils) Part 13/13 of: feat(core): put band and cadence into PoolKey Files: contracts/test/utils/Base.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/utils/Base.sol | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/contracts/test/utils/Base.sol b/contracts/test/utils/Base.sol index bae1066..9323da8 100644 --- a/contracts/test/utils/Base.sol +++ b/contracts/test/utils/Base.sol @@ -70,11 +70,36 @@ abstract contract Base is Test { // ------------------------------------------------------------ helpers function _key(uint128 strike, Kind kind, uint64 expiry) internal view returns (PoolKey memory) { - return PoolKey({asset: address(weth), quote: address(usdc), expiry: expiry, strike: strike, kind: kind}); + return _keyT(strike, kind, expiry, 800, 0); + } + + function _keyT(uint128 strike, Kind kind, uint64 expiry, uint16 band, uint8 cadence) + internal + view + returns (PoolKey memory) + { + return PoolKey({ + asset: address(weth), + quote: address(usdc), + expiry: expiry, + strike: strike, + kind: kind, + bandBps: band, + cadence: cadence + }); + } + + function _noOpts() internal pure returns (IConfluere.PoolOptions memory) { + return IConfluere.PoolOptions(0, 0, 0, false); + } + + /// @dev Creates a pool on any core instance (the strike index is derived here once the log grid needs it). + function _createKey(IConfluere c, PoolKey memory key, IConfluere.PoolOptions memory o) internal returns (bytes32) { + return c.createPool(key, o); } function _create(uint128 strike, Kind kind, uint64 expiry) internal returns (bytes32) { - return core.createPool(_key(strike, kind, expiry), 1000, 800); + return _createKey(core, _key(strike, kind, expiry), _noOpts()); } function _create() internal returns (bytes32) { From dc8ed4e86efe01d636f3ad946c6a41e10d119691 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:47:01 -0400 Subject: [PATCH 089/169] feat(sdk): v0.8 fee math (trade fee, spread share, NC units, escrow split) Mirrors Settlement.sol: tradeFee 0.015% per side, buyEscrow = cost at limit + fee + 2 wei per batch, ncUnits on an all-in budget, sellSplit with the capped 5% spread share, feeEscrowSplit by repaid / filled units, and the rollover top-up with fees at minPrice. The vwap-based spreadFee is gone. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/math.ts | 80 +++++++++++++++++++++++++++++------ packages/sdk/test/sdk.test.ts | 52 ++++++++++++++++++++--- 2 files changed, 115 insertions(+), 17 deletions(-) diff --git a/packages/sdk/src/math.ts b/packages/sdk/src/math.ts index d9a0595..3060727 100644 --- a/packages/sdk/src/math.ts +++ b/packages/sdk/src/math.ts @@ -9,25 +9,81 @@ export const quoteDown = (units: bigint, price: bigint, scale: bigint): bigint = export const quoteUp = (units: bigint, price: bigint, scale: bigint): bigint => (units * price + scale - 1n) / scale; -/** Escrow a competitive buy order needs: units at its limit plus 1 wei of rounding reserve per batch of ttl. */ -export const buyEscrow = (units: bigint, limit: bigint, ttl: number, scale: bigint): bigint => - quoteUp(units, limit, scale) + BigInt(ttl); +/** v0.8 fees (contracts/src/libraries/Settlement.sol). */ +export const SPREAD_SHARE_BPS = 500n; // 5% of the interest K - P*, per seller fill, withheld into the pool's fee escrow +export const TRADE_FEE_PPM = 150n; // 0.015% of notional on each side (0.03% per trade in total) +export const PPM = 1_000_000n; -/** What a borrower pays to buy back `units` at strike K. */ -export const repayAmount = (units: bigint, strike: bigint, scale: bigint): bigint => quoteUp(units, strike, scale); +/** Trade fee on a notional, rounded down: floor(x * 150 / 1e6). */ +export const tradeFee = (notional: bigint): bigint => (notional * TRADE_FEE_PPM) / PPM; + +/** Spread share on `units` filled at `price`: floor(units * (K - price) * 500 / (scale * 1e4)); zero at or above K. */ +export function spreadShare(units: bigint, strike: bigint, price: bigint, scale: bigint): bigint { + if (price >= strike) return 0n; + return (units * (strike - price) * SPREAD_SHARE_BPS) / (scale * BPS); +} + +/** Seller side of a fill: gross (rounded down), trade fee, spread share (capped so a fill never reverts), net. */ +export function sellSplit(units: bigint, price: bigint, strike: bigint, scale: bigint): { gross: bigint; trade: bigint; spread: bigint; net: bigint } { + const gross = quoteDown(units, price, scale); + const trade = tradeFee(gross); + let spread = spreadShare(units, strike, price, scale); + if (spread > gross - trade) spread = gross - trade; + return { gross, trade, spread, net: gross - trade - spread }; +} + +/** What a seller (borrower) keeps from a fill: gross - tradeFee(gross) - capped spread share. */ +export const sellerNet = (units: bigint, price: bigint, strike: bigint, scale: bigint): bigint => sellSplit(units, price, strike, scale).net; + +/** Buyer side of a fill: cost (rounded up), its trade fee, and the total debited from escrow. */ +export function buyCost(units: bigint, price: bigint, scale: bigint): { cost: bigint; trade: bigint; total: bigint } { + const cost = quoteUp(units, price, scale); + const trade = tradeFee(cost); + return { cost, trade, total: cost + trade }; +} + +/** Escrow a competitive buy order needs: cost at its limit (rounded up) + its trade fee + 2 wei per batch of ttl. */ +export function buyEscrow(units: bigint, limit: bigint, ttl: number, scale: bigint): bigint { + const g = quoteUp(units, limit, scale); + return g + tradeFee(g) + 2n * BigInt(ttl); +} -/** Protocol spread share on a repayment: feeBps of (K - vwap), zero when vwap >= K. */ -export function spreadFee(units: bigint, strike: bigint, vwap: bigint, feeBps: number, scale: bigint): bigint { - if (strike <= vwap) return 0n; - return (units * (strike - vwap) * BigInt(feeBps)) / (scale * BPS); +/** Units an all-in non-competitive quote budget (cost + trade fee) buys at `price`, with 1 wei reserve. */ +export function ncUnits(budget: bigint, price: bigint, scale: bigint): bigint { + if (price <= 0n) return 0n; + const b = (budget * PPM) / (PPM + TRADE_FEE_PPM); + if (b <= 1n) return 0n; + return ((b - 1n) * scale) / price; } -/** Top-up a rollover order must pre-fund (mirrors the contract's check). */ -export function rolloverTopUp(units: bigint, strike: bigint, minPrice: bigint, ttl: number, scale: bigint): bigint { +/** Treasury part of a pool's fee escrow at close: escrow * repaidUnits / filledUnits; the rest goes to C holders. */ +export function feeEscrowSplit(escrow: bigint, repaidUnits: bigint, filledUnits: bigint): { toTreasury: bigint; toLenders: bigint } { + const toTreasury = filledUnits === 0n ? 0n : (escrow * repaidUnits) / filledUnits; + return { toTreasury, toLenders: escrow - toTreasury }; +} + +/** What a borrower pays to buy back `units` at strike K. Repay carries no fee since v0.8. */ +export const repayAmount = (units: bigint, strike: bigint, scale: bigint): bigint => quoteUp(units, strike, scale); + +/** + * Top-up a rollover order must pre-fund (mirrors OrderLogic.rollover): K_old - minPrice per unit, plus the seller's + * trade fee and spread share (at the new pool's strike) at minPrice, plus 4 wei per batch of ttl. + */ +export function rolloverTopUp(units: bigint, strike: bigint, minPrice: bigint, ttl: number, scale: bigint, newStrike: bigint = strike): bigint { const gap = strike > minPrice ? quoteUp(units, strike - minPrice, scale) : 0n; - return gap + 2n * BigInt(ttl); + return gap + tradeFee(quoteUp(units, minPrice, scale)) + spreadShare(units, newStrike, minPrice, scale) + 4n * BigInt(ttl); } +/** + * Lender's fixed return per unit if repaid: pays P + trade fee, receives K. The 5% spread share is withheld from the + * seller at clearing, so the lender's claim on repayment is K per unit; on default the lender keeps the asset plus the + * defaulted part of the fee escrow. + */ +export const lenderCostPerUnit = (price: bigint): bigint => price + tradeFee(price); + +/** Borrower's proceeds per whole unit at `price`: P - trade fee - 5% of (K - P). */ +export const borrowerNetPerUnit = (price: bigint, strike: bigint, scale: bigint): bigint => sellerNet(scale, price, strike, scale); + /** * Lender's simple annualised yield implied by buying at `price` and receiving K at expiry, if repaid. * The worst case (default) is receiving the asset instead; this number does not price that. diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index 5fe7ad0..65916ce 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -9,7 +9,16 @@ import { Kind, quoteUp, quoteDown, - spreadFee, + spreadShare, + tradeFee, + buyEscrow, + buyCost, + ncUnits, + sellerNet, + sellSplit, + feeEscrowSplit, + borrowerNetPerUnit, + lenderCostPerUnit, isGridStrike, rolloverTopUp, chainById, @@ -62,9 +71,41 @@ describe("math", () => { it("rounds buyers up and sellers down by at most 1", () => { expect(quoteUp(1n, 3n, 2n) - quoteDown(1n, 3n, 2n)).toBe(1n); }); - it("charges the spread share only on interest", () => { - expect(spreadFee(scale, 3000_000000n, 2900_000000n, 1000, scale)).toBe(10_000000n); - expect(spreadFee(scale, 3000_000000n, 3100_000000n, 1000, scale)).toBe(0n); + it("charges the 5% spread share only on interest", () => { + expect(spreadShare(scale, 3000_000000n, 2900_000000n, scale)).toBe(5_000000n); + expect(spreadShare(scale, 3000_000000n, 3000_000000n, scale)).toBe(0n); + expect(spreadShare(scale, 3000_000000n, 3100_000000n, scale)).toBe(0n); + expect(spreadShare(3n, 1000n, 999n, 1n)).toBe(0n); // 3 * 1 * 500 / 1e4 rounds down + }); + it("charges 0.015% trade fee per side, rounded down", () => { + expect(tradeFee(1_000_000n)).toBe(150n); + expect(tradeFee(10_000n)).toBe(1n); + expect(tradeFee(6_666n)).toBe(0n); + expect(tradeFee(2900_000000n)).toBe(435000n); + }); + it("escrows cost at limit + trade fee + 2 wei per batch", () => { + expect(buyEscrow(scale, 2900_000000n, 3, scale)).toBe(2900_435006n); + expect(buyEscrow(1n, 2900_000000n, 1, scale)).toBe(1n + 0n + 2n); // cost rounds up to 1 wei + expect(buyCost(scale, 2900_000000n, scale)).toEqual({ cost: 2900_000000n, trade: 435000n, total: 2900_435000n }); + }); + it("sizes an all-in NC budget", () => { + expect(ncUnits(1000_150000n, 2000_000000n, scale)).toBe(499_999_999_500_000_000n); + expect(ncUnits(1n, 2000_000000n, scale)).toBe(0n); + expect(ncUnits(2n, 2000_000000n, scale)).toBe(0n); // floor(2e6 / 1000150) = 1 -> nothing after the 1 wei reserve + expect(ncUnits(1000n, 0n, scale)).toBe(0n); + }); + it("splits a seller fill into gross, trade fee, spread share and net", () => { + expect(sellSplit(scale, 2900_000000n, 3000_000000n, scale)).toEqual({ gross: 2900_000000n, trade: 435000n, spread: 5_000000n, net: 2894_565000n }); + expect(sellerNet(scale, 2900_000000n, 3000_000000n, scale)).toBe(2894_565000n); + expect(borrowerNetPerUnit(2900_000000n, 3000_000000n, scale)).toBe(2894_565000n); + // capped so a fill never reverts: gross 1 wei, spread would be 149999999 + expect(sellSplit(scale, 1n, 3000_000000n, scale)).toEqual({ gross: 1n, trade: 0n, spread: 1n, net: 0n }); + expect(lenderCostPerUnit(2900_000000n)).toBe(2900_435000n); + }); + it("splits the fee escrow by repaid / filled units", () => { + expect(feeEscrowSplit(1000n, 3n, 4n)).toEqual({ toTreasury: 750n, toLenders: 250n }); + expect(feeEscrowSplit(1000n, 1n, 3n)).toEqual({ toTreasury: 333n, toLenders: 667n }); + expect(feeEscrowSplit(1000n, 0n, 0n)).toEqual({ toTreasury: 0n, toLenders: 1000n }); }); it("knows the strike grid", () => { expect(isGridStrike(3000_000000n)).toBe(true); @@ -72,7 +113,8 @@ describe("math", () => { expect(isGridStrike(98n * 10n ** 16n)).toBe(true); }); it("matches the rollover top-up rule", () => { - expect(rolloverTopUp(scale, 3000_000000n, 2850_000000n, 500, scale)).toBe(150_000000n + 1000n); + expect(rolloverTopUp(scale, 3000_000000n, 2850_000000n, 500, scale)).toBe(150_000000n + 427500n + 7_500000n + 2000n); + expect(rolloverTopUp(scale, 3000_000000n, 2850_000000n, 1, scale, 3100_000000n)).toBe(150_000000n + 427500n + 12_500000n + 4n); }); }); From a50d43165b19e1b303f31d3405c43400a43cb5ef Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 090/169] feat(core): one-percent log strike grid with canonical index (contracts/script) Strikes now sit on K = floor(10^quoteDecimals * 1.01^n), |n| <= 4095, K <= uint96 max. Grid.growth computes 1.01^m in RAY by repeated squaring with every product rounded down, so the SDK can copy the routine in bigint and agree to the unit. createPool takes the index as createPool(key, strikeIndex, opts) and the core checks strikeAt(n) == K and strikeAt(n - 1) < K: n must be the smallest index of K, since neighbours collide below ~100 base units. The two-significant- digit rule and GridConfig.strikeSigDigits are removed (grid() returns four fields). PoolCreated and PoolView carry strikeIndex. Tests move to grid strikes (Base K = 3_010_966_302, n = 805; K_HI n = 808, K_LO n = 798) and expectations derived from K; LogGrid.t.sol pins the vector table; a fuzz checks the grid is strictly increasing after dedup and that indexOf round-trips. CreatePools snaps configured strike prices down to the grid and passes the canonical index. Invariants: none (creation rules). Part 1/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/script/CreatePools.s.sol, contracts/script/DeployConfig.sol Co-Authored-By: Claude Opus 5.5 --- contracts/script/CreatePools.s.sol | 18 +++++++++++++++--- contracts/script/DeployConfig.sol | 12 +++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/contracts/script/CreatePools.s.sol b/contracts/script/CreatePools.s.sol index 4bfb96d..20929a4 100644 --- a/contracts/script/CreatePools.s.sol +++ b/contracts/script/CreatePools.s.sol @@ -6,7 +6,8 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Confluere} from "../src/Confluere.sol"; import {IConfluere} from "../src/interfaces/IConfluere.sol"; import {PoolKey, PoolKeyLib, Kind, Status} from "../src/libraries/PoolKey.sol"; -import {GridConfig} from "../src/libraries/Grid.sol"; +import {GridConfig, Grid} from "../src/libraries/Grid.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {MockERC20} from "../src/mocks/MockERC20.sol"; import {DeployConfig} from "./DeployConfig.sol"; @@ -171,10 +172,11 @@ contract CreatePools is Script { for (uint256 s; s < 2; ++s) { if (s == 1 && pair.strikes[1] == pair.strikes[0]) break; // single strike for (uint256 k; k < 2; ++k) { - PoolKey memory key = PoolKey(pair.asset, pair.quote, expiry, pair.strikes[s], Kind(k), band, 0); + (uint128 strike, int16 idx) = gridStrike(pair.strikes[s], IERC20Metadata(pair.quote).decimals()); + PoolKey memory key = PoolKey(pair.asset, pair.quote, expiry, strike, Kind(k), band, 0); bytes32 id = PoolKeyLib.toId(key); if (core.pool(id).status == Status.None) { - id = core.createPool(key, IConfluere.PoolOptions(0, 0, 0, true)); + id = core.createPool(key, idx, IConfluere.PoolOptions(0, 0, 0, true)); } ids[n++] = id; } @@ -182,6 +184,16 @@ contract CreatePools is Script { return n; } + /// @notice The 1% log-grid strike for a configured price: the value itself when it is on the grid, else the + /// largest grid strike below it (a slightly lower buy-back price), with its canonical index. + function gridStrike(uint256 value, uint8 quoteDecimals) public pure returns (uint128, int16) { + (int256 n, bool ok) = Grid.indexOf(value, quoteDecimals); + if (!ok) (n,) = Grid.indexOf(Grid.strikeAt(n - 1, quoteDecimals), quoteDecimals); + uint256 k = Grid.strikeAt(n, quoteDecimals); + require(k != 0 && Grid.isGridStrike(k, n, quoteDecimals), "strike below the log grid"); + return (uint128(k), int16(n)); + } + /// @dev EXPIRY (or the nearest expiry), then LATER_EXPIRIES; each checked, none listed twice. function _expiries(Confluere core) internal view returns (uint64[] memory out) { string memory later = vm.envOr("LATER_EXPIRIES", string("")); diff --git a/contracts/script/DeployConfig.sol b/contracts/script/DeployConfig.sol index 8613de6..d236d42 100644 --- a/contracts/script/DeployConfig.sol +++ b/contracts/script/DeployConfig.sol @@ -50,17 +50,11 @@ library DeployConfig { { bool demo = preset == Preset.Demo; if (preset == Preset.Demo) { - c.grid = GridConfig({ - anchor: FRIDAY_ANCHOR, period: 1 hours, batchInterval: 2 minutes, maxExpiries: 4, strikeSigDigits: 2 - }); + c.grid = GridConfig({anchor: FRIDAY_ANCHOR, period: 1 hours, batchInterval: 2 minutes, maxExpiries: 4}); } else if (preset == Preset.Daily) { - c.grid = GridConfig({ - anchor: FRIDAY_ANCHOR, period: 1 days, batchInterval: 2 minutes, maxExpiries: 15, strikeSigDigits: 2 - }); + c.grid = GridConfig({anchor: FRIDAY_ANCHOR, period: 1 days, batchInterval: 2 minutes, maxExpiries: 15}); } else { - c.grid = GridConfig({ - anchor: FRIDAY_ANCHOR, period: 7 days, batchInterval: 30 minutes, maxExpiries: 4, strikeSigDigits: 2 - }); + c.grid = GridConfig({anchor: FRIDAY_ANCHOR, period: 7 days, batchInterval: 30 minutes, maxExpiries: 4}); } c.maxOrdersPerSide = 128; // ~6.6M gas worst case: fits Monad blocks and HyperEVM big blocks c.maxBids = 64; From 0e48e18cf0f75ccd4c4835c21acb27f3213dba8f Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 091/169] feat(core): one-percent log strike grid with canonical index (Confluere.sol) Part 2/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/src/Confluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/Confluere.sol | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/contracts/src/Confluere.sol b/contracts/src/Confluere.sol index f22f8fc..8591e00 100644 --- a/contracts/src/Confluere.sol +++ b/contracts/src/Confluere.sol @@ -42,7 +42,6 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen uint64 internal immutable GRID_PERIOD; uint64 internal immutable GRID_BATCH; uint32 internal immutable GRID_MAX_EXPIRIES; - uint8 internal immutable GRID_SIG_DIGITS; uint64 public immutable WINDOW_BLOCKS; uint64 public immutable MAX_PAUSE_BLOCKS; @@ -93,7 +92,6 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen GRID_PERIOD = c.grid.period; GRID_BATCH = c.grid.batchInterval; GRID_MAX_EXPIRIES = c.grid.maxExpiries; - GRID_SIG_DIGITS = c.grid.strikeSigDigits; WINDOW_BLOCKS = cp.windowBlocks; MAX_PAUSE_BLOCKS = cp.maxPauseBlocks; MAX_ORDERS_PER_SIDE = c.maxOrdersPerSide; @@ -111,8 +109,12 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen // ================================================================ pools /// @inheritdoc IConfluere - function createPool(PoolKey calldata key, PoolOptions calldata opts) external nonReentrant returns (bytes32) { - return OrderLogic.createPool(s, grid(), key, opts); + function createPool(PoolKey calldata key, int16 strikeIndex, PoolOptions calldata opts) + external + nonReentrant + returns (bytes32) + { + return OrderLogic.createPool(s, grid(), key, strikeIndex, opts); } // ================================================================ orders @@ -299,7 +301,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen // ================================================================ views function grid() public view returns (GridConfig memory) { - return GridConfig(GRID_ANCHOR, GRID_PERIOD, GRID_BATCH, GRID_MAX_EXPIRIES, GRID_SIG_DIGITS); + return GridConfig(GRID_ANCHOR, GRID_PERIOD, GRID_BATCH, GRID_MAX_EXPIRIES); } function pool(bytes32 poolId) external view returns (PoolView memory v) { @@ -307,6 +309,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen v.key = ps.key; v.status = ps.status; v.assetDecimals = ps.assetDecimals; + v.strikeIndex = ps.strikeIndex; v.bandFailures = ps.bandFailures; v.settleCount = ps.settleCount; v.lastSettledBatch = ps.lastSettledBatch; From 493a9b275c1124f7417ed9e586539f85837e5514 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 092/169] feat(core): one-percent log strike grid with canonical index (Grid.sol) Part 3/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/src/libraries/Grid.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/Grid.sol | 55 ++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/contracts/src/libraries/Grid.sol b/contracts/src/libraries/Grid.sol index ede4550..4753bc0 100644 --- a/contracts/src/libraries/Grid.sol +++ b/contracts/src/libraries/Grid.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.37; +import {FixedPointMathLib as FPM} from "solady/utils/FixedPointMathLib.sol"; + /// @notice Deploy-time grid. Mainnet: Friday 08:00 UTC anchor, 1 week period, 30 minute batches. /// Demo deployments use minute-scale values so the full lifecycle can be shown live. struct GridConfig { @@ -8,7 +10,6 @@ struct GridConfig { uint64 period; // distance between consecutive expiries uint64 batchInterval; // distance between consecutive batch boundaries uint32 maxExpiries; // how many future expiries may be listed at once - uint8 strikeSigDigits; // strikes may have at most this many significant decimal digits } library Grid { @@ -17,8 +18,7 @@ library Grid { function validate(GridConfig memory g) internal view { if ( g.period == 0 || g.batchInterval == 0 || g.batchInterval > g.period || g.maxExpiries == 0 - || g.strikeSigDigits == 0 || g.strikeSigDigits > 30 || g.anchor > block.timestamp - || g.period % g.batchInterval != 0 + || g.anchor > block.timestamp || g.period % g.batchInterval != 0 ) revert InvalidGrid(); } @@ -42,13 +42,48 @@ library Grid { return uint256(g.anchor) + uint256(index) * g.batchInterval; } - /// @notice Scale-free strike grid without an oracle: strip trailing zeros, the rest must fit in `sigDigits` digits. - /// With 2 digits, ETH-class strikes in [1000, 9900] USD land on a 100 USD step and pegged ratios on 0.01 steps. - function isGridStrike(GridConfig memory g, uint256 strike) internal pure returns (bool) { - if (strike == 0) return false; - while (strike % 10 == 0) { - strike /= 10; + // ------------------------------------------------------------ strikes: 1% log grid (blueprint 3.3) + + uint256 internal constant RAY = 1e27; + uint256 internal constant STEP = 101e25; // 1.01 in RAY, exact + int256 internal constant MAX_STRIKE_INDEX = 4095; + + /// @notice 1.01^m in RAY, by repeated squaring with every product rounded down. The SDK copies this integer + /// routine line by line (bigint) so both sides agree to the last unit. + function growth(uint256 m) internal pure returns (uint256 r) { + r = RAY; + uint256 b = STEP; + while (m != 0) { + if (m & 1 == 1) r = FPM.fullMulDiv(r, b, RAY); + m >>= 1; + if (m != 0) b = FPM.fullMulDiv(b, b, RAY); + } + } + + /// @notice Strike at grid index n: one whole quote unit times 1.01^n, rounded down to the quote's base unit. + function strikeAt(int256 n, uint8 quoteDecimals) internal pure returns (uint256) { + uint256 unit = 10 ** uint256(quoteDecimals); + return n >= 0 ? FPM.fullMulDiv(unit, growth(uint256(n)), RAY) : FPM.fullMulDiv(unit, RAY, growth(uint256(-n))); + } + + /// @notice K is on the grid at index n iff |n| <= MAX_STRIKE_INDEX, 0 < K <= uint96 max, K == strikeAt(n) and n is + /// the smallest index giving K (rounding makes neighbours collide for tiny strikes; only the first counts). + function isGridStrike(uint256 strike, int256 n, uint8 quoteDecimals) internal pure returns (bool) { + if (n > MAX_STRIKE_INDEX || n < -MAX_STRIKE_INDEX || strike == 0 || strike > type(uint96).max) return false; + if (strikeAt(n, quoteDecimals) != strike) return false; + return n == -MAX_STRIKE_INDEX || strikeAt(n - 1, quoteDecimals) < strike; + } + + /// @notice Canonical index of `strike` (smallest n with strikeAt(n) >= strike) by binary search; `ok` tells whether + /// the strike is on the grid. For scripts and tests; the core only verifies a given index. + function indexOf(uint256 strike, uint8 quoteDecimals) internal pure returns (int256 n, bool ok) { + int256 lo = -MAX_STRIKE_INDEX; + int256 hi = MAX_STRIKE_INDEX; + while (lo < hi) { + int256 mid = (lo + hi) >> 1; + if (strikeAt(mid, quoteDecimals) >= strike) hi = mid; + else lo = mid + 1; } - return strike < 10 ** uint256(g.strikeSigDigits); + return (lo, isGridStrike(strike, lo, quoteDecimals)); } } From 6745246e890067b18471779387e83c40a2b8ed03 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 093/169] feat(core): one-percent log strike grid with canonical index (IConfluere.sol) Part 4/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/src/interfaces/IConfluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/interfaces/IConfluere.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/contracts/src/interfaces/IConfluere.sol b/contracts/src/interfaces/IConfluere.sol index 0f28867..9364c58 100644 --- a/contracts/src/interfaces/IConfluere.sol +++ b/contracts/src/interfaces/IConfluere.sol @@ -30,6 +30,7 @@ interface IConfluere { PoolKey key; Status status; uint8 assetDecimals; + int16 strikeIndex; uint8 bandFailures; uint32 settleCount; uint64 lastSettledBatch; @@ -57,7 +58,7 @@ interface IConfluere { bool bad; // paused or circuit breaker tripped at this expiry } - event PoolCreated(bytes32 indexed poolId, PoolKey key, bytes32 indexed marketId); + event PoolCreated(bytes32 indexed poolId, PoolKey key, int16 strikeIndex, bytes32 indexed marketId); event PoolOptionsSet(bytes32 indexed poolId, uint96 minFill, uint96 firstLo, uint96 firstHi, bool autoSell); event OrderPlaced( bytes32 indexed poolId, @@ -108,7 +109,9 @@ interface IConfluere { error Locked(); error FlashFailed(); - function createPool(PoolKey calldata key, PoolOptions calldata opts) external returns (bytes32 poolId); + function createPool(PoolKey calldata key, int16 strikeIndex, PoolOptions calldata opts) + external + returns (bytes32 poolId); function placeOrder(bytes32 poolId, OrderParams calldata p) external returns (uint64 orderId); function placeLockedOrder(bytes32 poolId, OrderParams calldata p) external returns (uint64 orderId); function cancelOrder(bytes32 poolId, uint64 orderId) external; From 1e800ba9b6b890c2a15f1eb3c4d6e6b5bb20273a Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 094/169] feat(core): one-percent log strike grid with canonical index (State.sol) Part 5/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/src/libraries/State.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/State.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/src/libraries/State.sol b/contracts/src/libraries/State.sol index 0bde21d..ae1897c 100644 --- a/contracts/src/libraries/State.sol +++ b/contracts/src/libraries/State.sol @@ -15,6 +15,7 @@ struct PoolState { bytes32 marketId; Status status; uint8 assetDecimals; + int16 strikeIndex; // n on the 1% log grid: strike == Grid.strikeAt(n, quoteDecimals) bool autoSell; // pool option: at close, defaulted asset is sold into the next expiry's pool of the same tier uint8 bandFailures; uint32 settleCount; From c7921b83d61924c1e53058c10658c35b9e2f5787 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 095/169] feat(core): one-percent log strike grid with canonical index (libraries/logic) Part 6/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/src/libraries/logic/OrderLogic.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/logic/OrderLogic.sol | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/contracts/src/libraries/logic/OrderLogic.sol b/contracts/src/libraries/logic/OrderLogic.sol index d9c668f..2db2cba 100644 --- a/contracts/src/libraries/logic/OrderLogic.sol +++ b/contracts/src/libraries/logic/OrderLogic.sol @@ -26,6 +26,7 @@ library OrderLogic { CoreState storage s, GridConfig memory g, PoolKey calldata key, + int16 strikeIndex, IConfluere.PoolOptions calldata opts ) external returns (bytes32 poolId) { if (key.asset == key.quote || key.asset.code.length == 0 || key.quote.code.length == 0) { @@ -34,7 +35,6 @@ library OrderLogic { uint16 band = key.bandBps; if (band != 150 && band != 800 && band != 1500) revert IConfluere.InvalidParams(); if (!Grid.isListableExpiry(g, key.expiry, block.timestamp)) revert IConfluere.InvalidParams(); - if (key.strike > type(uint96).max || !Grid.isGridStrike(g, key.strike)) revert IConfluere.InvalidParams(); if (key.cadence > 2 || (opts.firstHi != 0 && opts.firstLo > opts.firstHi)) revert IConfluere.InvalidParams(); poolId = PoolKeyLib.toId(key); @@ -42,7 +42,9 @@ library OrderLogic { if (ps.status != Status.None) revert IConfluere.InvalidPool(); uint8 dec = IERC20Metadata(key.asset).decimals(); - if (dec > 30) revert IConfluere.BadToken(); + uint8 qd = IERC20Metadata(key.quote).decimals(); + if (dec > 30 || qd > 30) revert IConfluere.BadToken(); + if (!Grid.isGridStrike(key.strike, strikeIndex, qd)) revert IConfluere.InvalidParams(); _probe(key.asset); _probe(key.quote); @@ -50,6 +52,7 @@ library OrderLogic { ps.marketId = PoolKeyLib.marketId(key.asset, key.quote, key.kind); ps.status = Status.Open; ps.assetDecimals = dec; + ps.strikeIndex = strikeIndex; ps.autoSell = opts.autoSell; ps.minFill = opts.minFill; ps.firstLo = opts.firstLo; @@ -57,7 +60,7 @@ library OrderLogic { uint256 iv = ps.interval(g.batchInterval); if (iv > g.period) revert IConfluere.InvalidParams(); ps.lastSettledBatch = uint64((block.timestamp - g.anchor) / iv); - emit IConfluere.PoolCreated(poolId, key, ps.marketId); + emit IConfluere.PoolCreated(poolId, key, strikeIndex, ps.marketId); if (opts.minFill != 0 || opts.firstLo != 0 || opts.firstHi != 0 || opts.autoSell) { emit IConfluere.PoolOptionsSet(poolId, opts.minFill, opts.firstLo, opts.firstHi, opts.autoSell); } From 68cae7ba1a31aa8241a84f8b4714671e0a32fbc8 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 096/169] feat(core): one-percent log strike grid with canonical index (tests fuzz) Part 7/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/test/fuzz/ClearingFuzz.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/fuzz/ClearingFuzz.t.sol | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/contracts/test/fuzz/ClearingFuzz.t.sol b/contracts/test/fuzz/ClearingFuzz.t.sol index f1383a3..80dc742 100644 --- a/contracts/test/fuzz/ClearingFuzz.t.sol +++ b/contracts/test/fuzz/ClearingFuzz.t.sol @@ -5,7 +5,7 @@ import {Base} from "../utils/Base.sol"; import {IConfluere} from "../../src/interfaces/IConfluere.sol"; import {Settlement} from "../../src/libraries/Settlement.sol"; import {Clearing} from "../../src/libraries/Clearing.sol"; -import {Grid, GridConfig} from "../../src/libraries/Grid.sol"; +import {Grid} from "../../src/libraries/Grid.sol"; contract ClearingFuzzTest is Base { uint256[] internal sl; @@ -111,12 +111,18 @@ contract ClearingFuzzTest is Base { assertLe(p, hi); } - function testFuzz_gridStrike(uint64 mantissa, uint8 zeros) public pure { - GridConfig memory g = GridConfig(0, 1, 1, 1, 2); - uint256 m_ = bound(mantissa, 1, 99); - uint256 z = bound(zeros, 0, 20); - assertTrue(Grid.isGridStrike(g, m_ * 10 ** z)); - uint256 three = bound(mantissa, 101, 999); - if (three % 10 != 0) assertFalse(Grid.isGridStrike(g, three * 10 ** z)); + /// After removing collisions (only the smallest index of a value counts) the grid is strictly increasing, and + /// every canonical index round-trips through indexOf. + function testFuzz_gridStrictlyIncreasingAfterDedup(int16 n, uint8 qdSeed) public pure { + uint8 qd = uint8(bound(qdSeed, 0, 24)); + int256 i = bound(int256(n), -4094, 2400); + uint256 a = Grid.strikeAt(i, qd); + uint256 b = Grid.strikeAt(i + 1, qd); + assertGe(b, a); + if (a == 0 || a > type(uint96).max) return; + (int256 j, bool ok) = Grid.indexOf(a, qd); + assertTrue(ok); + assertLe(j, i); + assertEq(Grid.strikeAt(j, qd), a); } } From 90934e15575b57cd2600351a6ca012d49365feb1 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 097/169] feat(core): one-percent log strike grid with canonical index (tests invariant) Part 8/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/test/invariant/Invariants.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/invariant/Invariants.t.sol | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/contracts/test/invariant/Invariants.t.sol b/contracts/test/invariant/Invariants.t.sol index 0c6a237..5aeb287 100644 --- a/contracts/test/invariant/Invariants.t.sol +++ b/contracts/test/invariant/Invariants.t.sol @@ -30,7 +30,7 @@ contract InvariantsTest is Test { weth = new MockERC20("WETH", "WETH", 18); usdc = new MockERC20("USDC", "USDC", 6); Confluere.Config memory c; - c.grid = GridConfig(ANCHOR, WEEK, 30 minutes, 4, 2); + c.grid = GridConfig(ANCHOR, WEEK, 30 minutes, 4); c.maxOrdersPerSide = 8; c.maxBids = 4; c.chains = new Confluere.ChainParams[](1); @@ -47,15 +47,18 @@ contract InvariantsTest is Test { usdc.approve(address(core), type(uint256).max); bytes32[] memory ps = new bytes32[](3); ps[0] = core.createPool( - PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3000e6, Kind.American, 800, 0), + PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3_010_966_302, Kind.American, 800, 0), + 805, IConfluere.PoolOptions(0, 0, 0, false) ); ps[1] = core.createPool( - PoolKey(address(weth), address(usdc), ANCHOR + 2 * WEEK, 3000e6, Kind.American, 800, 0), + PoolKey(address(weth), address(usdc), ANCHOR + 2 * WEEK, 3_010_966_302, Kind.American, 800, 0), + 805, IConfluere.PoolOptions(0, 0, 0, false) ); ps[2] = core.createPool( - PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3100e6, Kind.European, 150, 0), + PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3_102_201_591, Kind.European, 150, 0), + 808, IConfluere.PoolOptions(0, 0, 0, false) ); From 691851ed64dbfff3de6b7d26e457d691aa9dcfb0 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 098/169] feat(core): one-percent log strike grid with canonical index (tests medusa) Part 9/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/test/medusa/MedusaHarness.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/medusa/MedusaHarness.sol | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/contracts/test/medusa/MedusaHarness.sol b/contracts/test/medusa/MedusaHarness.sol index 98c5b25..fa1b0aa 100644 --- a/contracts/test/medusa/MedusaHarness.sol +++ b/contracts/test/medusa/MedusaHarness.sol @@ -41,7 +41,7 @@ contract MedusaHarness { weth = new MockERC20("WETH", "WETH", 18); usdc = new MockERC20("USDC", "USDC", 6); Confluere.Config memory c; - c.grid = GridConfig(ANCHOR, WEEK, 30 minutes, 4, 2); + c.grid = GridConfig(ANCHOR, WEEK, 30 minutes, 4); c.maxOrdersPerSide = 6; c.maxBids = 4; c.chains = new Confluere.ChainParams[](1); @@ -56,11 +56,13 @@ contract MedusaHarness { weth.approve(address(core), type(uint256).max); usdc.approve(address(core), type(uint256).max); pools[0] = core.createPool( - PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3000e6, Kind.American, 800, 0), + PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3_010_966_302, Kind.American, 800, 0), + 805, IConfluere.PoolOptions(0, 0, 0, false) ); pools[1] = core.createPool( - PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3000e6, Kind.European, 150, 0), + PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3_010_966_302, Kind.European, 150, 0), + 805, IConfluere.PoolOptions(0, 0, 0, false) ); for (uint256 i; i < 3; ++i) { From e3f894050fc98df1b81c1a24798dea7d0ceee678 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 099/169] feat(core): one-percent log strike grid with canonical index (tests unit) Part 10/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/test/unit/Clearing.t.sol, contracts/test/unit/Lifecycle.t.sol, contracts/test/unit/LogGrid.t.sol, contracts/test/unit/LongTail.t.sol, contracts/test/unit/Periphery.t.sol, contracts/test/unit/Pools.t.sol, contracts/test/unit/PositionMarket.t.sol, contracts/test/unit/Seats.t.sol, contracts/test/unit/Vault.t.sol, contracts/test/unit/Window.t.sol, contracts/test/unit/WindowAlign.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/Clearing.t.sol | 8 +-- contracts/test/unit/Lifecycle.t.sol | 11 ++-- contracts/test/unit/LogGrid.t.sol | 81 ++++++++++++++++++++++++ contracts/test/unit/LongTail.t.sol | 5 +- contracts/test/unit/Periphery.t.sol | 2 +- contracts/test/unit/Pools.t.sol | 5 +- contracts/test/unit/PositionMarket.t.sol | 2 +- contracts/test/unit/Seats.t.sol | 2 +- contracts/test/unit/Vault.t.sol | 2 +- contracts/test/unit/Window.t.sol | 26 ++++---- contracts/test/unit/WindowAlign.t.sol | 8 +-- 11 files changed, 117 insertions(+), 35 deletions(-) create mode 100644 contracts/test/unit/LogGrid.t.sol diff --git a/contracts/test/unit/Clearing.t.sol b/contracts/test/unit/Clearing.t.sol index ea16060..21c36e3 100644 --- a/contracts/test/unit/Clearing.t.sol +++ b/contracts/test/unit/Clearing.t.sol @@ -32,10 +32,10 @@ contract ClearingTest is Base { assertEq(core.balanceOf(alice, _r(pid)), ONE, "R to borrower"); assertEq(core.balanceOf(bob, _c(pid)), ONE, "C to lender"); // seller: P* less 0.015% trade fee (435,000) and 5% of K - P* (5e6) into the pool's spread escrow - assertEq(_pend(address(usdc), alice), 2900e6 - 435_000 - 5e6, "borrower receives P* net of fees"); + assertEq(_pend(address(usdc), alice), 2900e6 - 435_000 - 5_548_315, "borrower receives P* net of fees"); // escrow 2950 + 0.015% + 2 wei x ttl 10; paid 2900 + 0.015% assertEq(_pend(address(usdc), bob), 50e6 + 7500 + 20, "lender refunded limit - P*, fee difference, reserve"); - assertEq(v.feeEscrow, 5e6); + assertEq(v.feeEscrow, 5_548_315); assertEq(_pend(address(usdc), treasury), 2 * 435_000, "trade fee from each side"); assertEq(v.assetBal, ONE); assertEq(v.filledUnits, ONE); @@ -64,8 +64,8 @@ contract ClearingTest is Base { (address yo,,,,,,) = core.order(pid, y); assertEq(yo, carol, "unfilled stays"); // every filled seller got at least its limit, every filled buyer paid at most its limit - assertEq(_pend(address(usdc), alice), 2950e6 - 442_500 - 2.5e6, "P* net of trade fee and spread share"); - assertEq(_pend(address(usdc), dave), 1475e6 - 221_250 - 1.25e6); + assertEq(_pend(address(usdc), alice), 2950e6 - 442_500 - 3_048_315, "P* net of trade fee and spread share"); + assertEq(_pend(address(usdc), dave), 1475e6 - 221_250 - 1_524_157); } function test_noCross_noFill() public { diff --git a/contracts/test/unit/Lifecycle.t.sol b/contracts/test/unit/Lifecycle.t.sol index 800657a..3aa2ca2 100644 --- a/contracts/test/unit/Lifecycle.t.sol +++ b/contracts/test/unit/Lifecycle.t.sol @@ -57,19 +57,20 @@ contract LifecycleTest is Base { vm.warp(E1); core.mature(p1); _settle(p2); - // P* = (2850 + 2950)/2 = 2900; old pool gets K = 3000 with 100 from top-up + // P* = (2850 + 2950)/2 = 2900; old pool gets all of K, the gap from the top-up assertEq(core.balanceOf(alice, _r(p2)), ONE); assertEq(core.balanceOf(alice, _r(p1)), 0); assertEq(core.lockedR(alice, p1), 0); IConfluere.PoolView memory v1 = core.pool(p1); assertEq(v1.assetBal, 0); - assertEq(v1.quoteBal, 3000e6, "all of K; the old pool counts the units as repaid"); + assertEq(v1.quoteBal, K, "all of K; the old pool counts the units as repaid"); assertEq(v1.repaidUnits, ONE); assertEq(core.pool(p2).assetBal, ONE); - assertEq(core.pool(p2).feeEscrow, 5e6, "new pool's spread share at its own P*"); + assertEq(core.pool(p2).feeEscrow, 5_548_315, "new pool's spread share at its own P*"); assertEq(_pend(address(usdc), treasury), 4 * 435_000, "trade fees, both pools, both sides"); - // net per fill at 2900 = 2900 - 0.435 trade fee - 5 spread share; top-up = 3000 - 2894.565 - assertEq(_pend(address(usdc), alice), 2894.565e6 + (200e6 - 105.435e6), "first borrow + unused top-up"); + // net per fill at 2900 = 2900 - 0.435 trade fee - spread share; top-up = K - net + uint256 net = 2900e6 - 435_000 - 5_548_315; + assertEq(_pend(address(usdc), alice), net + (200e6 - (K - net)), "first borrow + unused top-up"); } function test_rollover_needsEnoughTopUp() public { diff --git a/contracts/test/unit/LogGrid.t.sol b/contracts/test/unit/LogGrid.t.sol new file mode 100644 index 0000000..9a30467 --- /dev/null +++ b/contracts/test/unit/LogGrid.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {Base} from "../utils/Base.sol"; +import {IConfluere} from "../../src/interfaces/IConfluere.sol"; +import {PoolKey, Kind} from "../../src/libraries/PoolKey.sol"; +import {Grid} from "../../src/libraries/Grid.sol"; + +/// @notice The 1% log strike grid (blueprint 3.3): K = floor(10^qd * 1.01^n) with the integer routine the SDK copies. +contract LogGridTest is Base { + /// Vectors computed independently (Python, same integer algorithm); the SDK asserts the same table. + function test_vectors() public pure { + assertEq(Grid.strikeAt(0, 6), 1_000_000); + assertEq(Grid.strikeAt(1, 6), 1_010_000); + assertEq(Grid.strikeAt(-1, 6), 990_099); + assertEq(Grid.strikeAt(-2, 6), 980_296); + assertEq(Grid.strikeAt(-3, 6), 970_590); + assertEq(Grid.strikeAt(-384, 6), 21_907); + assertEq(Grid.strikeAt(434, 6), 75_071_695); + assertEq(Grid.strikeAt(769, 6), 2_104_439_470); + assertEq(Grid.strikeAt(773, 6), 2_189_888_152); + assertEq(Grid.strikeAt(805, 6), 3_010_966_302); + assertEq(Grid.strikeAt(806, 6), 3_041_075_965); + assertEq(Grid.strikeAt(1157, 6), 99_960_740_922); + assertEq(Grid.strikeAt(-7, 18), 932_718_054_707_135_373); + assertEq(Grid.strikeAt(-8, 18), 923_483_222_482_312_251); + assertEq(Grid.strikeAt(-11, 18), 896_323_717_517_805_234); + assertEq(Grid.strikeAt(-453, 18), 11_025_992_086_967_947); + assertLe(Grid.strikeAt(2522, 18), type(uint96).max); + assertGt(Grid.strikeAt(2523, 18), type(uint96).max); + } + + function test_createPool_storesIndex() public { + bytes32 pid = _create(); + assertEq(core.pool(pid).strikeIndex, K_INDEX); + } + + function test_rejectsOffGrid() public { + vm.expectRevert(IConfluere.InvalidParams.selector); + core.createPool(_key(K + 1, Kind.American, E1), K_INDEX, _noOpts()); + } + + function test_rejectsWrongIndex() public { + vm.expectRevert(IConfluere.InvalidParams.selector); + core.createPool(_key(K, Kind.American, E1), K_INDEX + 1, _noOpts()); + } + + /// Below ~100 base units neighbouring indices round to the same strike; only the smallest index is canonical. + function test_rejectsNonCanonicalIndex() public { + int256 n = -1000; + while (Grid.strikeAt(n, 6) != Grid.strikeAt(n + 1, 6)) ++n; + uint256 k = Grid.strikeAt(n, 6); + (int256 canon, bool ok) = Grid.indexOf(k, 6); + assertTrue(ok); + assertEq(canon, n); + assertTrue(Grid.isGridStrike(k, n, 6)); + assertFalse(Grid.isGridStrike(k, n + 1, 6)); + vm.expectRevert(IConfluere.InvalidParams.selector); + core.createPool(_key(uint128(k), Kind.American, E1), int16(n + 1), _noOpts()); + core.createPool(_key(uint128(k), Kind.American, E1), int16(n), _noOpts()); + } + + function test_rejectsIndexOutOfRange() public pure { + assertFalse(Grid.isGridStrike(Grid.strikeAt(4096, 0), 4096, 0)); + assertFalse(Grid.isGridStrike(Grid.strikeAt(-4096, 30), -4096, 30)); + } + + function test_rejectsAboveUint96() public pure { + uint256 k = Grid.strikeAt(2523, 18); + assertFalse(Grid.isGridStrike(k, 2523, 18)); + assertTrue(Grid.isGridStrike(Grid.strikeAt(2522, 18), 2522, 18)); + } + + function test_indexOf_roundTrips() public pure { + (int256 n, bool ok) = Grid.indexOf(3_010_966_302, 6); + assertTrue(ok); + assertEq(n, 805); + (, ok) = Grid.indexOf(3000e6, 6); + assertFalse(ok); + } +} diff --git a/contracts/test/unit/LongTail.t.sol b/contracts/test/unit/LongTail.t.sol index 190bae9..f5d74ee 100644 --- a/contracts/test/unit/LongTail.t.sol +++ b/contracts/test/unit/LongTail.t.sol @@ -152,7 +152,7 @@ contract LongTailTest is Base { vm.prank(alice); core.cancelOrder(main, a); // mainstream: no cutoff - bytes32 pid = _createKey(core, _keyT(3100e6, Kind.American, E1, 1500, 1), _noOpts()); // different strike: a separate pool from `main` + bytes32 pid = _createKey(core, _keyT(K_HI, Kind.American, E1, 1500, 1), _noOpts()); // different strike: a separate pool from `main` uint64 id = _order(alice, pid, SELL, ONE, 3500e6, 0, 1); vm.warp(core.nextBatchAt(pid)); core.settleBatch(pid); // its single batch passes untraded: expired @@ -173,8 +173,7 @@ contract LongTailTest is Base { function test_cadenceLongerThanPeriodRejected() public { // a demo grid: 1-hour expiries, 2-minute batches -> 48 x 2 min = 96 min > 1 h Confluere.Config memory c = _config(); - c.grid = - GridConfig({anchor: ANCHOR, period: 1 hours, batchInterval: 2 minutes, maxExpiries: 4, strikeSigDigits: 2}); + c.grid = GridConfig({anchor: ANCHOR, period: 1 hours, batchInterval: 2 minutes, maxExpiries: 4}); Confluere demo = new Confluere(c); weth.approve(address(demo), type(uint256).max); usdc.approve(address(demo), type(uint256).max); diff --git a/contracts/test/unit/Periphery.t.sol b/contracts/test/unit/Periphery.t.sol index f939431..1ed4808 100644 --- a/contracts/test/unit/Periphery.t.sol +++ b/contracts/test/unit/Periphery.t.sol @@ -160,7 +160,7 @@ contract PeripheryTest is Base { uint256 u0 = usdc.balanceOf(bob); r.claimMany(pids); vm.stopPrank(); - assertEq(usdc.balanceOf(bob) - u0, 3000e6); + assertEq(usdc.balanceOf(bob) - u0, K); } function test_vaultFactory() public { diff --git a/contracts/test/unit/Pools.t.sol b/contracts/test/unit/Pools.t.sol index 3ae7edd..15d32fa 100644 --- a/contracts/test/unit/Pools.t.sol +++ b/contracts/test/unit/Pools.t.sol @@ -38,10 +38,9 @@ contract PoolsTest is Base { } function test_createPool_strikeGrid() public { - _create(3100e6, Kind.American, E1); // two significant digits + _create(K_HI, Kind.American, E1); // n = 808 vm.expectRevert(IConfluere.InvalidParams.selector); - _create(3150e6, Kind.American, E1); // three - _create(98e16, Kind.European, E1); // pegged 0.98 in an 18-decimal quote + _create(3000e6, Kind.American, E1); // between 804 and 805: off the grid } function test_createPool_rejectsBadBandOrCadence() public { diff --git a/contracts/test/unit/PositionMarket.t.sol b/contracts/test/unit/PositionMarket.t.sol index 6f7bd5b..a1b64d8 100644 --- a/contracts/test/unit/PositionMarket.t.sol +++ b/contracts/test/unit/PositionMarket.t.sol @@ -235,7 +235,7 @@ contract PositionMarketTest is Base { } function test_R_otherPoolOfExpiryStartsWindow() public { - bytes32 other = _create(2800e6, Kind.American, E1); + bytes32 other = _create(K_LO, Kind.American, E1); vm.warp(E1); core.mature(other); // starts the shared window of E1 vm.roll(block.number + WINDOW); diff --git a/contracts/test/unit/Seats.t.sol b/contracts/test/unit/Seats.t.sol index 4e783c8..5a50575 100644 --- a/contracts/test/unit/Seats.t.sol +++ b/contracts/test/unit/Seats.t.sol @@ -193,7 +193,7 @@ contract SeatsTest is Base { /// With a 6-hour long-tail pool as evidence, missing a 30-minute base batch is not a miss; a whole 6-hour batch is. function test_slashUsesTheEvidencePoolsBatchLength() public { - bytes32 slow = _createKey(core, _keyT(3100e6, Kind.American, E1, 1500, 1), _noOpts()); + bytes32 slow = _createKey(core, _keyT(K_HI, Kind.American, E1, 1500, 1), _noOpts()); _buy(alice, PRICE, DEPOSIT); _post(alice, ONE, 2800e6); vm.warp(block.timestamp + 2 * BATCH); // two base batches without quoting diff --git a/contracts/test/unit/Vault.t.sol b/contracts/test/unit/Vault.t.sol index 002c9df..6056d38 100644 --- a/contracts/test/unit/Vault.t.sol +++ b/contracts/test/unit/Vault.t.sol @@ -153,7 +153,7 @@ contract VaultTest is VaultBase { (uint256 nav, uint256 supply,) = vault.epochs(2); assertEq(supply, 10_000e6 * 1e6); // interest: c units bought at 2900 (plus the 0.015% buyer trade fee) return all of K = 3000 - uint256 expected = 10_000e6 + c * 100e6 / ONE - (c * 2900e6 / ONE) * 150 / 1e6; + uint256 expected = 10_000e6 + c * (uint256(K) - 2900e6) / ONE - (c * 2900e6 / ONE) * 150 / 1e6; assertApproxEqAbs(nav, expected, 10); // lp2 enters at the new price, so gets fewer shares than assets diff --git a/contracts/test/unit/Window.t.sol b/contracts/test/unit/Window.t.sol index 9c5d39a..4ffd4fc 100644 --- a/contracts/test/unit/Window.t.sol +++ b/contracts/test/unit/Window.t.sol @@ -7,6 +7,8 @@ import {Kind, Status} from "../../src/libraries/PoolKey.sol"; import {BID} from "../../src/libraries/OrderSet.sol"; contract WindowTest is Base { + uint256 internal constant SP = 5_548_315; // spread share per unit at P* = 2900: 5% of (K - 2900e6), rounded down + function _funded(Kind kind) internal returns (bytes32 pid) { pid = _create(K, kind, E1); // alice and dave borrow 1 ETH each at 2900; bob and carol lend @@ -17,7 +19,7 @@ contract WindowTest is Base { _settle(pid); assertEq(core.pool(pid).filledUnits, 2 * ONE); // 5% of (K - P*) per unit sits in the escrow until close - assertEq(core.pool(pid).feeEscrow, 10e6); + assertEq(core.pool(pid).feeEscrow, 2 * SP); } function test_american_repayAnytime_withSpreadShare() public { @@ -27,10 +29,10 @@ contract WindowTest is Base { vm.prank(alice); core.repay(pid, uint96(ONE)); assertEq(weth.balanceOf(alice), w0 + ONE, "asset back"); - assertEq(u0 - usdc.balanceOf(alice), 3000e6, "paid K"); + assertEq(u0 - usdc.balanceOf(alice), K, "paid K"); // repay carries no fee: all of K goes to C holders; treasury holds only the four trade fees (0.015% each) assertEq(_pend(address(usdc), treasury), 4 * 435_000); - assertEq(core.pool(pid).quoteBal, 3000e6); + assertEq(core.pool(pid).quoteBal, K); assertEq(core.pool(pid).repaidUnits, ONE); assertEq(core.balanceOf(alice, _r(pid)), 0); } @@ -86,11 +88,11 @@ contract WindowTest is Base { vm.prank(bob); (uint256 q, uint256 a) = core.claim(pid); assertEq(a, 0); - assertEq(q, 3000e6, "all of K; the spread share was taken from the borrowers at clearing"); + assertEq(q, K, "all of K; the spread share was taken from the borrowers at clearing"); assertEq(usdc.balanceOf(bob), u0 + q); vm.prank(carol); (q,) = core.claim(pid); - assertEq(q, 3000e6); + assertEq(q, K); assertEq(core.pool(pid).quoteBal, 0); } @@ -100,7 +102,7 @@ contract WindowTest is Base { core.closeWindow(pid, 10); vm.prank(bob); (uint256 q, uint256 a) = core.claim(pid); - assertEq(q, 5e6, "defaulted part of the spread escrow goes to C holders"); + assertEq(q, SP, "defaulted part of the spread escrow goes to C holders"); assertEq(a, ONE, "physical delivery"); assertEq(_pend(address(usdc), treasury), 4 * 435_000, "only trade fees on default"); } @@ -121,8 +123,8 @@ contract WindowTest is Base { // only dave's 1 unit was unrepaid: eve (highest) takes it all assertEq(_pend(address(weth), eve), ONE); - assertEq(_pend(address(usdc), eve), 5e17 * 3100e6 / ONE, "unused half refunded"); - assertEq(_pend(address(usdc), carol) - carol0, 3040e6, "losing bid refunded"); + assertEq(_pend(address(usdc), eve), 5e17 * (uint256(K) + 100e6) / ONE, "unused half refunded"); + assertEq(_pend(address(usdc), carol) - carol0, uint256(K) + 40e6, "losing bid refunded"); (address o,,,,,,) = core.order(pid, b1); assertEq(o, address(0)); @@ -137,7 +139,7 @@ contract WindowTest is Base { // lenders split: alice repaid 2990 net + eve paid 3000 vm.prank(bob); (q,) = core.claim(pid); - assertEq(q, 3000e6); + assertEq(q, K); } function test_bid_cancellableOnlyInWindow() public { @@ -189,7 +191,7 @@ contract WindowTest is Base { vm.expectRevert(IConfluere.BookFull.selector); core.bid(pid, uint96(ONE), 5e6, uint64(hint)); _bid(carol, pid, ONE, 50e6); - assertEq(_pend(address(usdc), eve), 3010e6, "lowest bid evicted and refunded"); + assertEq(_pend(address(usdc), eve), uint256(K) + 10e6, "lowest bid evicted and refunded"); } function test_claim_burnsAndPaysProRata_mixed() public { @@ -202,7 +204,7 @@ contract WindowTest is Base { core.closeWindow(pid, 10); vm.prank(bob); (uint256 q, uint256 a) = core.claim(pid); - assertEq(q, 1502.5e6); // K/2 + half of the defaulted half of the escrow + assertEq(q, (uint256(K) + SP) / 2); // K/2 + half of the defaulted half of the escrow assertEq(a, 5e17); assertEq(core.balanceOf(bob, _c(pid)), 0); // transferred C claims the same @@ -210,7 +212,7 @@ contract WindowTest is Base { core.transfer(eve, _c(pid), ONE); vm.prank(eve); (q, a) = core.claim(pid); - assertEq(q, 1502.5e6); // K/2 + half of the defaulted half of the escrow + assertEq(q, (uint256(K) + SP + 1) / 2); // the last claimer takes the rounding dust assertEq(a, 5e17); } diff --git a/contracts/test/unit/WindowAlign.t.sol b/contracts/test/unit/WindowAlign.t.sol index a5bd49b..5ebbebc 100644 --- a/contracts/test/unit/WindowAlign.t.sol +++ b/contracts/test/unit/WindowAlign.t.sol @@ -17,7 +17,7 @@ contract WindowAlignTest is Base { function test_laterPoolsJoinTheFirstWindow() public { bytes32 a = _create(K, Kind.American, E1); - bytes32 b = _create(3100e6, Kind.European, E1); + bytes32 b = _create(K_HI, Kind.European, E1); vm.warp(E1); assertEq(core.expiryWindowEnd(E1), 0, "not started before the first mature"); core.mature(a); @@ -50,7 +50,7 @@ contract WindowAlignTest is Base { function test_americanCannotRepayAfterTheSharedWindowEvenIfNeverMatured() public { bytes32 a = _create(K, Kind.American, E1); - bytes32 b = _create(3100e6, Kind.American, E1); + bytes32 b = _create(K_HI, Kind.American, E1); _borrow(b, alice, ONE, 2900e6); vm.warp(E1); core.mature(a); @@ -87,7 +87,7 @@ contract WindowAlignTest is Base { function test_purgeKeepsRolloverWhileSharedWindowRuns() public { bytes32 p1 = _create(K, Kind.American, E1); - bytes32 other = _create(3100e6, Kind.American, E1); + bytes32 other = _create(K_HI, Kind.American, E1); bytes32 p2 = _create(K, Kind.American, E2); _borrow(p1, alice, ONE, 2900e6); uint64 hint = core.findHint(p2, SELL, 2850e6); @@ -106,7 +106,7 @@ contract WindowAlignTest is Base { function test_rolloverCannotStartAfterTheSharedWindow() public { bytes32 p1 = _create(K, Kind.American, E1); - bytes32 other = _create(3100e6, Kind.American, E1); + bytes32 other = _create(K_HI, Kind.American, E1); bytes32 p2 = _create(K, Kind.American, E2); _borrow(p1, alice, ONE, 2900e6); vm.warp(E1); From a955878f8a2d8e9131396e0a372b3c4c1987b4a9 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:51:00 -0400 Subject: [PATCH 100/169] feat(core): one-percent log strike grid with canonical index (tests utils) Part 11/11 of: feat(core): one-percent log strike grid with canonical index Files: contracts/test/utils/Base.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/utils/Base.sol | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/contracts/test/utils/Base.sol b/contracts/test/utils/Base.sol index 9323da8..97d6def 100644 --- a/contracts/test/utils/Base.sol +++ b/contracts/test/utils/Base.sol @@ -5,7 +5,8 @@ import {Test} from "forge-std/Test.sol"; import {Confluere} from "../../src/Confluere.sol"; import {IConfluere} from "../../src/interfaces/IConfluere.sol"; import {PoolKey, PoolKeyLib, Kind, Status} from "../../src/libraries/PoolKey.sol"; -import {GridConfig} from "../../src/libraries/Grid.sol"; +import {GridConfig, Grid} from "../../src/libraries/Grid.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {SELL, BUY, NC_SELL, NC_BUY, BID} from "../../src/libraries/OrderSet.sol"; import {MockERC20} from "../../src/mocks/MockERC20.sol"; @@ -18,7 +19,10 @@ abstract contract Base is Test { uint64 internal constant WINDOW = 7200; uint64 internal constant E1 = ANCHOR + WEEK; uint64 internal constant E2 = ANCHOR + 2 * WEEK; - uint128 internal constant K = 3000e6; // 3000 USDC per ETH + uint128 internal constant K = 3_010_966_302; // ~3011 USDC per ETH: 1.01^805 on the log grid + int16 internal constant K_INDEX = 805; + uint128 internal constant K_HI = 3_102_201_591; // n = 808 + uint128 internal constant K_LO = 2_808_382_632; // n = 798 uint256 internal constant ONE = 1e18; Confluere internal core; @@ -56,7 +60,7 @@ abstract contract Base is Test { } function _config() internal view virtual returns (Confluere.Config memory c) { - c.grid = GridConfig({anchor: ANCHOR, period: WEEK, batchInterval: BATCH, maxExpiries: 4, strikeSigDigits: 2}); + c.grid = GridConfig({anchor: ANCHOR, period: WEEK, batchInterval: BATCH, maxExpiries: 4}); c.maxOrdersPerSide = 8; c.maxBids = 4; c.chains = new Confluere.ChainParams[](1); @@ -93,9 +97,17 @@ abstract contract Base is Test { return IConfluere.PoolOptions(0, 0, 0, false); } - /// @dev Creates a pool on any core instance (the strike index is derived here once the log grid needs it). + /// @dev Creates a pool on any core instance, passing the strike's canonical log-grid index. function _createKey(IConfluere c, PoolKey memory key, IConfluere.PoolOptions memory o) internal returns (bytes32) { - return c.createPool(key, o); + return c.createPool(key, _index(key), o); + } + + function _index(PoolKey memory key) internal view returns (int16) { + // no external call when the quote is known, so vm.expectRevert still targets createPool + uint8 qd = + key.quote == address(usdc) ? 6 : key.quote == address(weth) ? 18 : IERC20Metadata(key.quote).decimals(); + (int256 n,) = Grid.indexOf(key.strike, qd); + return int16(n); } function _create(uint128 strike, Kind kind, uint64 expiry) internal returns (bytes32) { From 7d1bb48d0bccef9a9c82f2ef1b4da2313d98b7fd Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:53:08 -0400 Subject: [PATCH 101/169] feat(core): track tier volume and the main tier per market and expiry (Confluere.sol) Each settled batch adds its notional (P* x units, rounded down) to the market's volume at that expiry and to tierVolume[market][expiry][tier], tier = PoolKeyLib.tierKey(strike, bandBps, cadence). MarketStats keeps the main tier as an incremental argmax (mainVolume, mainStrike, mainIndex, mainBand, mainCadence; ties keep the first), O(1) per batch. minStrike / maxStrike are removed. Vaults and seat obligations read the main tier; tierVolume is a public view for the high-tier scan and the app. The Vault targets the main tier of the last expiry for now (both strike rules); the high-tier scan is the periphery change. Tests: TierStats.t.sol (main follows the largest volume, ties keep the first, band and cadence separate tiers, per expiry, fuzzed argmax). Invariants: none (statistics). Graduation fees (3a) are unchanged. Part 1/7 of: feat(core): track tier volume and the main tier per market and expiry Files: contracts/src/Confluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/Confluere.sol | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contracts/src/Confluere.sol b/contracts/src/Confluere.sol index 8591e00..5aa7de9 100644 --- a/contracts/src/Confluere.sol +++ b/contracts/src/Confluere.sol @@ -389,6 +389,10 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen return s.stats[marketId][expiry]; } + function tierVolume(bytes32 marketId, uint64 expiry, uint256 tierKey) external view returns (uint256) { + return s.tierVolume[marketId][expiry][tierKey]; + } + function pending(address token, address account) external view returns (uint256) { return s.pending[token][account]; } From 0ee43fedff023d82d633c8f46e833b38236c0f82 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:53:08 -0400 Subject: [PATCH 102/169] feat(core): track tier volume and the main tier per market and expiry (IConfluere.sol) Part 2/7 of: feat(core): track tier volume and the main tier per market and expiry Files: contracts/src/interfaces/IConfluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/interfaces/IConfluere.sol | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/contracts/src/interfaces/IConfluere.sol b/contracts/src/interfaces/IConfluere.sol index 9364c58..7902101 100644 --- a/contracts/src/interfaces/IConfluere.sol +++ b/contracts/src/interfaces/IConfluere.sol @@ -53,8 +53,11 @@ interface IConfluere { struct MarketStats { uint128 volume; // quote traded at this expiry across the market uint128 fees; // treasury fees earned at this expiry: trade fees at clearing + spread share at close - uint96 minStrike; // lowest strike with any fill at this expiry - uint96 maxStrike; // highest strike with any fill at this expiry + uint128 mainVolume; // traded volume of the main tier: the tier with the most volume at this expiry + uint96 mainStrike; // main tier = (strike, band, cadence); ties keep the tier that got there first + int16 mainIndex; + uint16 mainBand; + uint8 mainCadence; bool bad; // paused or circuit breaker tripped at this expiry } @@ -147,6 +150,8 @@ interface IConfluere { /// @notice Block at which the shared repayment window of `expiry` ends; 0 until the first pool of it matures. function expiryWindowEnd(uint64 expiry) external view returns (uint64); function marketStats(bytes32 marketId, uint64 expiry) external view returns (MarketStats memory); + /// @notice Quote traded at one tier (PoolKeyLib.tierKey(strike, bandBps, cadence)) of a market at an expiry. + function tierVolume(bytes32 marketId, uint64 expiry, uint256 tierKey) external view returns (uint256); function pending(address token, address account) external view returns (uint256); function marketFees(bytes32 marketId) external view returns (uint256); function flashFees(address token) external view returns (uint256); From 8d02c4566275b93c53f800f5183bbb95d3a00c42 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:53:08 -0400 Subject: [PATCH 103/169] feat(core): track tier volume and the main tier per market and expiry (PoolKey.sol) Part 3/7 of: feat(core): track tier volume and the main tier per market and expiry Files: contracts/src/libraries/PoolKey.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/PoolKey.sol | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contracts/src/libraries/PoolKey.sol b/contracts/src/libraries/PoolKey.sol index 52c90bf..d26cf75 100644 --- a/contracts/src/libraries/PoolKey.sol +++ b/contracts/src/libraries/PoolKey.sol @@ -44,6 +44,11 @@ library PoolKeyLib { return uint256(poolId) | 1; } + /// @notice A tier of a market at one expiry: strike, band and cadence packed into one key. + function tierKey(uint256 strike, uint16 bandBps, uint8 cadence) internal pure returns (uint256) { + return strike | uint256(bandBps) << 128 | uint256(cadence) << 144; + } + /// @notice A market groups every strike and expiry of one asset pair and kind. Graduation is tracked per market. function marketId(address asset, address quote, Kind kind) internal pure returns (bytes32) { return keccak256(abi.encode(asset, quote, kind)); From 1ffb18a4bc21e2bc4fa4e40181d9796e02cf37c7 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:53:08 -0400 Subject: [PATCH 104/169] feat(core): track tier volume and the main tier per market and expiry (State.sol) Part 4/7 of: feat(core): track tier volume and the main tier per market and expiry Files: contracts/src/libraries/State.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/State.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/src/libraries/State.sol b/contracts/src/libraries/State.sol index ae1897c..bfaf9d9 100644 --- a/contracts/src/libraries/State.sol +++ b/contracts/src/libraries/State.sol @@ -44,6 +44,7 @@ struct CoreState { mapping(bytes32 => mapping(uint64 => Order)) orders; mapping(bytes32 => mapping(uint64 => bytes32)) rolloverOld; mapping(bytes32 => mapping(uint64 => IConfluere.MarketStats)) stats; + mapping(bytes32 => mapping(uint64 => mapping(uint256 => uint256))) tierVolume; // market -> expiry -> tier -> quote mapping(address => mapping(address => uint256)) pending; mapping(address => mapping(bytes32 => uint256)) lockedR; mapping(bytes32 => bool) graduated; From 58a6a155e03de5ec61b92d38919d1f903754c62c Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:53:08 -0400 Subject: [PATCH 105/169] feat(core): track tier volume and the main tier per market and expiry (Vault.sol) Part 5/7 of: feat(core): track tier volume and the main tier per market and expiry Files: contracts/src/periphery/Vault.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/Vault.sol | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/contracts/src/periphery/Vault.sol b/contracts/src/periphery/Vault.sol index 87bb907..a5a52b1 100644 --- a/contracts/src/periphery/Vault.sol +++ b/contracts/src/periphery/Vault.sol @@ -180,13 +180,12 @@ contract Vault is ERC20, IERC7540, ReentrancyGuardTransient { bytes32 m = PoolKeyLib.marketId(token, _quote, kind); if (!core.graduated(m)) return (bytes32(0), 0, 0); IConfluere.MarketStats memory st = core.marketStats(m, targetExpiry - core.grid().period); - uint96 strike = strikeRule == StrikeRule.LowestTraded ? st.minStrike : st.maxStrike; + // main tier of the last expiry (both rules until the high-tier scan lands in the periphery change) + uint96 strike = st.mainStrike; if (strike == 0) return (bytes32(0), 0, 0); - for (uint8 c; c < 3; ++c) { - bytes32 pid = PoolKey(token, _quote, targetExpiry, strike, kind, 800, c).toId(); - IConfluere.PoolView memory v = core.pool(pid); - if (v.status == Status.Open) return (pid, strike, v.settleCount); - } + bytes32 pid = PoolKey(token, _quote, targetExpiry, strike, kind, st.mainBand, st.mainCadence).toId(); + IConfluere.PoolView memory v = core.pool(pid); + if (v.status == Status.Open) return (pid, strike, v.settleCount); return (bytes32(0), 0, 0); } From 82ab892385c141ed2f82e80d8a3bfaabc0cd6823 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:53:08 -0400 Subject: [PATCH 106/169] feat(core): track tier volume and the main tier per market and expiry (libraries/logic) Part 6/7 of: feat(core): track tier volume and the main tier per market and expiry Files: contracts/src/libraries/logic/ClearingLogic.sol Co-Authored-By: Claude Opus 5.5 --- .../src/libraries/logic/ClearingLogic.sol | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/contracts/src/libraries/logic/ClearingLogic.sol b/contracts/src/libraries/logic/ClearingLogic.sol index cdc8a31..41ae2fd 100644 --- a/contracts/src/libraries/logic/ClearingLogic.sol +++ b/contracts/src/libraries/logic/ClearingLogic.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.37; import {IConfluere} from "../../interfaces/IConfluere.sol"; -import {PoolKeyLib} from "../PoolKey.sol"; +import {PoolKey, PoolKeyLib} from "../PoolKey.sol"; import {Order, SELL, BUY, NC_SELL, NC_BUY, FLAG_ROLLOVER} from "../OrderSet.sol"; import {Clearing} from "../Clearing.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; @@ -91,17 +91,31 @@ library ClearingLogic { s.marketFees[ps.marketId] += bt.trade; CoreLib.addFees(s, ps, bt.trade); } - IConfluere.MarketStats storage st = s.stats[ps.marketId][ps.key.expiry]; - // Saturating: the counter only feeds graduation and must never block clearing. - uint256 vol = uint256(st.volume) + Clearing.quoteDown(total, bt.price, bt.scale); - st.volume = vol > type(uint128).max ? type(uint128).max : uint128(vol); - uint96 k = uint96(ps.key.strike); - if (st.minStrike == 0 || k < st.minStrike) st.minStrike = k; - if (k > st.maxStrike) st.maxStrike = k; + _countVolume(s, ps, Clearing.quoteDown(total, bt.price, bt.scale)); } emit IConfluere.BatchSettled(poolId, bt.sc, bt.price, pl.q, total - pl.q, bt.spread, bt.trade, pl.q0); } + /// @dev Market volume at this expiry, the pool's tier volume, and the main tier as an incremental argmax (O(1); + /// ties keep the tier that got there first). Saturating: the counters must never block clearing. + function _countVolume(CoreState storage s, PoolState storage ps, uint256 vol) private { + PoolKey storage k = ps.key; + IConfluere.MarketStats storage st = s.stats[ps.marketId][k.expiry]; + st.volume = _sat128(uint256(st.volume) + vol); + uint256 tv = s.tierVolume[ps.marketId][k.expiry][PoolKeyLib.tierKey(k.strike, k.bandBps, k.cadence)] += vol; + if (tv > st.mainVolume) { + st.mainVolume = _sat128(tv); + st.mainStrike = uint96(k.strike); + st.mainIndex = ps.strikeIndex; + st.mainBand = k.bandBps; + st.mainCadence = k.cadence; + } + } + + function _sat128(uint256 x) private pure returns (uint128) { + return x > type(uint128).max ? type(uint128).max : uint128(x); + } + function _plan(CoreState storage s, PoolState storage ps, Batch memory bt) private returns (Plan memory pl) { Cursor memory cs; Cursor memory cb; From f07972e768c2dc45ff7ed7eb017febb40be2593b Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:53:08 -0400 Subject: [PATCH 107/169] feat(core): track tier volume and the main tier per market and expiry (tests unit) Part 7/7 of: feat(core): track tier volume and the main tier per market and expiry Files: contracts/test/unit/Clearing.t.sol, contracts/test/unit/TierStats.t.sol, contracts/test/unit/Vault.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/Clearing.t.sol | 4 +- contracts/test/unit/TierStats.t.sol | 98 +++++++++++++++++++++++++++++ contracts/test/unit/Vault.t.sol | 3 +- 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 contracts/test/unit/TierStats.t.sol diff --git a/contracts/test/unit/Clearing.t.sol b/contracts/test/unit/Clearing.t.sol index 21c36e3..8833168 100644 --- a/contracts/test/unit/Clearing.t.sol +++ b/contracts/test/unit/Clearing.t.sol @@ -215,8 +215,8 @@ contract ClearingTest is Base { bytes32 m = PoolKeyLib.marketId(address(weth), address(usdc), Kind.American); IConfluere.MarketStats memory st = core.marketStats(m, E1); assertEq(st.volume, 2900e6); - assertEq(st.minStrike, K); - assertEq(st.maxStrike, K); + assertEq(st.mainStrike, K); + assertEq(st.mainVolume, st.volume); } function _seedPrice(uint256 p) internal { diff --git a/contracts/test/unit/TierStats.t.sol b/contracts/test/unit/TierStats.t.sol new file mode 100644 index 0000000..5fb9b11 --- /dev/null +++ b/contracts/test/unit/TierStats.t.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {Base} from "../utils/Base.sol"; +import {IConfluere} from "../../src/interfaces/IConfluere.sol"; +import {Kind, PoolKeyLib} from "../../src/libraries/PoolKey.sol"; +import {Clearing} from "../../src/libraries/Clearing.sol"; + +/// @notice Per-tier traded volume and the main tier (ARCH 2.8, spec 2.5). +contract TierStatsTest is Base { + bytes32 internal m; + + function setUp() public override { + super.setUp(); + m = PoolKeyLib.marketId(address(weth), address(usdc), Kind.American); + } + + function _trade(bytes32 pid, uint256 units, uint256 price) internal { + _sell(alice, pid, units, price); + _buy(bob, pid, units, price); + _settle(pid); + } + + function _tv(uint128 strike, uint16 band, uint8 cad) internal view returns (uint256) { + return core.tierVolume(m, E1, PoolKeyLib.tierKey(strike, band, cad)); + } + + function test_mainTier_followsLargestVolume() public { + bytes32 a = _create(K, Kind.American, E1); + bytes32 b = _create(K_HI, Kind.American, E1); + _trade(a, ONE, 2900e6); + IConfluere.MarketStats memory st = core.marketStats(m, E1); + assertEq(st.mainStrike, K); + assertEq(st.mainIndex, K_INDEX); + _trade(b, 2 * ONE, 2900e6); + st = core.marketStats(m, E1); + assertEq(st.mainStrike, K_HI); + assertEq(st.mainIndex, 808); + assertEq(st.mainBand, 800); + assertEq(st.mainCadence, 0); + assertEq(st.mainVolume, _tv(K_HI, 800, 0)); + assertEq(st.volume, _tv(K, 800, 0) + _tv(K_HI, 800, 0)); + assertEq(_tv(K, 800, 0), 2900e6); + assertEq(_tv(K_HI, 800, 0), 5800e6); + } + + function test_mainTier_tieKeepsFirst() public { + bytes32 a = _create(K, Kind.American, E1); + bytes32 b = _create(K_HI, Kind.American, E1); + _trade(a, ONE, 2900e6); + _trade(b, ONE, 2900e6); + assertEq(core.marketStats(m, E1).mainStrike, K, "equal volume: the first tier stays main"); + } + + function test_tierKey_separatesBandAndCadence() public { + bytes32 a = _create(K, Kind.American, E1); + bytes32 b = _createKey(core, _keyT(K, Kind.American, E1, 1500, 0), _noOpts()); + bytes32 c = _createKey(core, _keyT(K, Kind.American, E1, 800, 1), _noOpts()); + _trade(a, ONE, 2900e6); + _trade(b, 2 * ONE, 2900e6); + _trade(c, 3 * ONE, 2900e6); + assertEq(_tv(K, 800, 0), 2900e6); + assertEq(_tv(K, 1500, 0), 5800e6); + assertEq(_tv(K, 800, 1), 8700e6); + IConfluere.MarketStats memory st = core.marketStats(m, E1); + assertEq(st.mainBand, 800); + assertEq(st.mainCadence, 1); + assertEq(st.volume, 17_400e6); + assertTrue(PoolKeyLib.tierKey(K, 800, 0) != PoolKeyLib.tierKey(K, 800, 1)); + } + + function test_tierVolume_perExpiry() public { + bytes32 a = _create(K, Kind.American, E1); + bytes32 b = _create(K, Kind.American, E2); + _trade(a, ONE, 2900e6); + _trade(b, ONE, 2950e6); + assertEq(_tv(K, 800, 0), 2900e6); + assertEq(core.tierVolume(m, E2, PoolKeyLib.tierKey(K, 800, 0)), Clearing.quoteDown(ONE, 2950e6, ONE)); + } + + function testFuzz_mainIsArgmax(uint8[6] memory sizes, uint8[6] memory which) public { + bytes32[3] memory ps = + [_create(K, Kind.American, E1), _create(K_HI, Kind.American, E1), _create(K_LO, Kind.American, E1)]; + uint128[3] memory ks = [K, K_HI, K_LO]; + for (uint256 i; i < sizes.length; ++i) { + uint256 u = (uint256(sizes[i]) % 4 + 1) * 1e17; + _trade(ps[which[i] % 3], u, 2700e6); + } + IConfluere.MarketStats memory st = core.marketStats(m, E1); + uint256 max; + for (uint256 i; i < 3; ++i) { + uint256 v = _tv(ks[i], 800, 0); + if (v > max) max = v; + } + assertEq(st.mainVolume, max, "main volume is the largest tier volume"); + assertEq(_tv(st.mainStrike, st.mainBand, st.mainCadence), max); + } +} diff --git a/contracts/test/unit/Vault.t.sol b/contracts/test/unit/Vault.t.sol index 6056d38..52e95ba 100644 --- a/contracts/test/unit/Vault.t.sol +++ b/contracts/test/unit/Vault.t.sol @@ -31,7 +31,8 @@ abstract contract VaultBase is Base { for (uint64 i = 1; i <= 4; ++i) { uint64 e = ANCHOR + i * WEEK; vm.warp(e - 3 days); - bytes32 pid = _create(K, Kind.American, e); + // the target's tier: the vault invests in the main tier (strike, band, cadence) of the last expiry + bytes32 pid = _createKey(core, _keyT(K, Kind.American, e, 800, _cadence()), _noOpts()); _sell(alice, pid, ONE, 2900e6); _buy(bob, pid, ONE, 2900e6); _settle(pid); From 4ea2b4f705560c52abbb68c77c7a74d496f118ff Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 108/169] feat(core): let rollovers fill in the last batch before expiry (contracts/script) A rollover order is live while its old pool's window runs, and now also in the new pool's last batch before the old pool expires: settleBatch computes the next boundary (anchor + (batchIndex + 1) x interval of the new pool) once into the Batch, and both clearing passes judge every rollover by the same value. When it fills, the old pool receives all of K, counts the units as repaid and gives up the asset, exactly as inside the window; a European old pool is thereby repaid one batch early, which the blueprint accepts as the uniform rule. rolloverOld / rolloverSource are renamed linkedPool (the autoSell order will share the link), and order() also returns the flags. Tests: RolloverLastBatch.t.sol (earlier batch skipped, base cadence last batch, long-tail cadence with a European old pool, top-up floor covers the fees at minPrice). Invariants: 2 (old R burned with the asset it backs), 4 (old pool's asset leaves only against K), 5 (repay paths unchanged). Part 1/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/script/Seed.s.sol, contracts/script/checks/ScriptChecks.s.sol Co-Authored-By: Claude Opus 5.5 --- contracts/script/Seed.s.sol | 2 +- contracts/script/checks/ScriptChecks.s.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/script/Seed.s.sol b/contracts/script/Seed.s.sol index 3dc1946..9a4a7ea 100644 --- a/contracts/script/Seed.s.sol +++ b/contracts/script/Seed.s.sol @@ -128,7 +128,7 @@ contract Seed is Script { function _hasSell(Confluere core, bytes32 pid, address me) internal view returns (bool) { (uint64 id,) = core.bookHead(pid, SELL); while (id != 0) { - (address owner,,,,,, uint64 next) = core.order(pid, id); + (address owner,,,,,,, uint64 next) = core.order(pid, id); if (owner == me) return true; id = next; } diff --git a/contracts/script/checks/ScriptChecks.s.sol b/contracts/script/checks/ScriptChecks.s.sol index d4a0f5c..dc6b733 100644 --- a/contracts/script/checks/ScriptChecks.s.sol +++ b/contracts/script/checks/ScriptChecks.s.sol @@ -302,7 +302,7 @@ contract ScriptChecks is Script { require(vm.parseJsonAddress(vm.readFile(file), ".vaults.nativeLend") == x, "extra pair: vault moved"); new Seed().run(); (uint64 head,) = core.bookHead(ids[8], SELL); - (,, uint96 units,,,,) = core.order(ids[8], head); + (,,, uint96 units,,,,) = core.order(ids[8], head); require(units == 3 * 5e6, "extra pair: not seeded at EXTRA_SEED_UNIT_BPS"); // head = the 3-unit sell at 94% K vm.setEnv("EXTRA_ASSET", vm.toString(address(0))); vm.setEnv("EXTRA_SEED_UNIT_BPS", "10000"); From 634ed74a91bb813bf815cd914549d564956e8682 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 109/169] feat(core): let rollovers fill in the last batch before expiry (Confluere.sol) Part 2/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/src/Confluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/Confluere.sol | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/contracts/src/Confluere.sol b/contracts/src/Confluere.sol index 5aa7de9..b4e15ef 100644 --- a/contracts/src/Confluere.sol +++ b/contracts/src/Confluere.sol @@ -332,14 +332,23 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen function order(bytes32 poolId, uint64 orderId) external view - returns (address owner, uint8 side, uint96 units, uint96 limit, uint128 escrow, uint32 deadline, uint64 next) + returns ( + address owner, + uint8 side, + uint8 flags, + uint96 units, + uint96 limit, + uint128 escrow, + uint32 deadline, + uint64 next + ) { Order storage o = s.orders[poolId][orderId]; - return (o.owner, o.side, o.units, o.limit, o.escrow, o.deadline, o.next); + return (o.owner, o.side, o.flags, o.units, o.limit, o.escrow, o.deadline, o.next); } - function rolloverSource(bytes32 poolId, uint64 orderId) external view returns (bytes32) { - return s.rolloverOld[poolId][orderId]; + function linkedPool(bytes32 poolId, uint64 orderId) external view returns (bytes32) { + return s.linkedPool[poolId][orderId]; } function bookHead(bytes32 poolId, uint8 side) external view returns (uint64, uint32) { From d9368844ddab6a6de3e97b9a6738229b3420dcab Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 110/169] feat(core): let rollovers fill in the last batch before expiry (IConfluere.sol) Part 3/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/src/interfaces/IConfluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/interfaces/IConfluere.sol | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/contracts/src/interfaces/IConfluere.sol b/contracts/src/interfaces/IConfluere.sol index 7902101..0d42ec6 100644 --- a/contracts/src/interfaces/IConfluere.sol +++ b/contracts/src/interfaces/IConfluere.sol @@ -142,7 +142,18 @@ interface IConfluere { function order(bytes32 poolId, uint64 orderId) external view - returns (address owner, uint8 side, uint96 units, uint96 limit, uint128 escrow, uint32 deadline, uint64 next); + returns ( + address owner, + uint8 side, + uint8 flags, + uint96 units, + uint96 limit, + uint128 escrow, + uint32 deadline, + uint64 next + ); + /// @notice Source pool of a rollover order (or of an autoSell order) resting in `poolId`; 0 for other orders. + function linkedPool(bytes32 poolId, uint64 orderId) external view returns (bytes32); function bookHead(bytes32 poolId, uint8 side) external view returns (uint64 head, uint32 count); function findHint(bytes32 poolId, uint8 side, uint96 limit) external view returns (uint64); function nextBatchAt(bytes32 poolId) external view returns (uint256); From eddb49eddedb1490c9e885933eb9b0b0a615c1e5 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 111/169] feat(core): let rollovers fill in the last batch before expiry (State.sol) Part 4/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/src/libraries/State.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/State.sol | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/contracts/src/libraries/State.sol b/contracts/src/libraries/State.sol index bfaf9d9..c00cf30 100644 --- a/contracts/src/libraries/State.sol +++ b/contracts/src/libraries/State.sol @@ -42,7 +42,7 @@ struct CoreState { mapping(bytes32 => PoolState) pools; mapping(bytes32 => mapping(uint8 => Book)) books; mapping(bytes32 => mapping(uint64 => Order)) orders; - mapping(bytes32 => mapping(uint64 => bytes32)) rolloverOld; + mapping(bytes32 => mapping(uint64 => bytes32)) linkedPool; // order in a new pool -> its source pool (rollover) mapping(bytes32 => mapping(uint64 => IConfluere.MarketStats)) stats; mapping(bytes32 => mapping(uint64 => mapping(uint256 => uint256))) tierVolume; // market -> expiry -> tier -> quote mapping(address => mapping(address => uint256)) pending; @@ -210,8 +210,8 @@ library CoreLib { uint8 side = o.side; if (side == SELL || side == NC_SELL) { if (o.flags & FLAG_ROLLOVER != 0) { - s.lockedR[owner][s.rolloverOld[poolId][id]] -= o.units; - delete s.rolloverOld[poolId][id]; + s.lockedR[owner][s.linkedPool[poolId][id]] -= o.units; + delete s.linkedPool[poolId][id]; if (o.escrow != 0) s.pending[ps.key.quote][owner] += o.escrow; } else if (o.units != 0) { s.pending[ps.key.asset][owner] += o.units; @@ -223,15 +223,19 @@ library CoreLib { emit IConfluere.OrderRemoved(poolId, id, reason); } - /// @notice Whether an order may trade in the settlement numbered `sc`. - function live(CoreState storage s, bytes32 poolId, uint64 id, Order storage o, uint32 sc) + /// @notice Whether an order may trade in the settlement numbered `sc`, whose batch is followed by a boundary at + /// `nextBoundary`. A rollover trades while the old pool's window runs, and also in the new pool's last batch + /// before the old pool expires (blueprint 3.7 #5): the old pool's K is then paid in full one batch early. + function live(CoreState storage s, bytes32 poolId, uint64 id, Order storage o, uint32 sc, uint256 nextBoundary) internal view returns (bool) { if (o.deadline <= sc) return false; if (o.flags & FLAG_ROLLOVER == 0) return true; - return windowOpen(s, s.pools[s.rolloverOld[poolId][id]]); + PoolState storage op = s.pools[s.linkedPool[poolId][id]]; + return windowOpen(s, op) + || (op.status == Status.Open && block.timestamp < op.key.expiry && nextBoundary >= op.key.expiry); } /// @notice Whether the pool's repayment window is running. Windows are aligned per expiry: after expiry a pool From 846fe7e641eb7c0eadbf9ca72fec9ba9247c7a4a Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 112/169] feat(core): let rollovers fill in the last batch before expiry (libraries/logic) Part 5/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/src/libraries/logic/ClearingLogic.sol, contracts/src/libraries/logic/OrderLogic.sol Co-Authored-By: Claude Opus 5.5 --- .../src/libraries/logic/ClearingLogic.sol | 31 +++++++++---------- contracts/src/libraries/logic/OrderLogic.sol | 4 +-- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/contracts/src/libraries/logic/ClearingLogic.sol b/contracts/src/libraries/logic/ClearingLogic.sol index 41ae2fd..50b0ddf 100644 --- a/contracts/src/libraries/logic/ClearingLogic.sol +++ b/contracts/src/libraries/logic/ClearingLogic.sol @@ -38,6 +38,7 @@ library ClearingLogic { uint256 strike; uint256 spread; uint256 trade; + uint256 nextBoundary; // the batch boundary after this one: a rollover's last chance before its old pool expires } struct Cursor { @@ -58,7 +59,8 @@ library ClearingLogic { function settleBatch(CoreState storage s, Params memory pr, bytes32 poolId) external { PoolState storage ps = s.pool(poolId); ps.requireTrading(); - uint64 batchIndex = uint64((block.timestamp - pr.anchor) / ps.interval(pr.batchInterval)); + uint256 iv = ps.interval(pr.batchInterval); + uint64 batchIndex = uint64((block.timestamp - pr.anchor) / iv); if (batchIndex <= ps.lastSettledBatch) revert IConfluere.BatchNotReady(); ps.lastSettledBatch = batchIndex; @@ -69,6 +71,7 @@ library ClearingLogic { bt.treasury = pr.treasury; bt.quote = ps.key.quote; bt.strike = ps.key.strike; + bt.nextBoundary = pr.anchor + (uint256(batchIndex) + 1) * iv; ps.settleCount = bt.sc + 1; for (uint8 side; side <= NC_BUY; ++side) { @@ -177,13 +180,13 @@ library ClearingLogic { returns (uint256 q, Cursor memory cs, Cursor memory cb, uint256 price) { mapping(uint64 => Order) storage os = s.orders[bt.poolId]; - cs = _firstLive(s, bt.poolId, s.books[bt.poolId][SELL].head, bt.sc); - cb = _firstLive(s, bt.poolId, s.books[bt.poolId][BUY].head, bt.sc); + cs = _firstLive(s, bt, s.books[bt.poolId][SELL].head); + cb = _firstLive(s, bt, s.books[bt.poolId][BUY].head); while (pre != 0 && cs.id != 0) { uint256 t = cs.rem < pre ? cs.rem : pre; pre -= t; cs.rem -= t; - if (cs.rem == 0) cs = _firstLive(s, bt.poolId, os[cs.id].next, bt.sc); + if (cs.rem == 0) cs = _firstLive(s, bt, os[cs.id].next); } if (pre != 0) return (0, cs, cb, 0); uint256 lastB; @@ -197,8 +200,8 @@ library ClearingLogic { lastB = bl; cs.rem -= m; cb.rem -= m; - if (cs.rem == 0) cs = _firstLive(s, bt.poolId, os[cs.id].next, bt.sc); - if (cb.rem == 0) cb = _firstLive(s, bt.poolId, os[cb.id].next, bt.sc); + if (cs.rem == 0) cs = _firstLive(s, bt, os[cs.id].next); + if (cb.rem == 0) cb = _firstLive(s, bt, os[cb.id].next); } if (q != 0) price = Clearing.midpoint(price, lastB); } @@ -257,7 +260,7 @@ library ClearingLogic { while (target != 0 && id != 0) { Order storage o = os[id]; uint64 nxt = o.next; - if (!s.live(bt.poolId, id, o, bt.sc)) { + if (!s.live(bt.poolId, id, o, bt.sc, bt.nextBoundary)) { if (o.deadline <= bt.sc) s.removeWithRefund(bt.poolId, ps, id, CoreLib.RM_EXPIRED); } else { uint256 f = o.units < target ? o.units : target; @@ -341,7 +344,7 @@ library ClearingLogic { uint256 f, uint256 proceeds ) private { - bytes32 oldId = s.rolloverOld[bt.poolId][id]; + bytes32 oldId = s.linkedPool[bt.poolId][id]; PoolState storage op = s.pools[oldId]; address owner = o.owner; uint256 pay = Settlement.repayAmount(f, op.key.strike, op.scale()); @@ -414,13 +417,9 @@ library ClearingLogic { } } - function _firstLive(CoreState storage s, bytes32 poolId, uint64 id, uint32 sc) - private - view - returns (Cursor memory c) - { - mapping(uint64 => Order) storage os = s.orders[poolId]; - while (id != 0 && !s.live(poolId, id, os[id], sc)) { + function _firstLive(CoreState storage s, Batch memory bt, uint64 id) private view returns (Cursor memory c) { + mapping(uint64 => Order) storage os = s.orders[bt.poolId]; + while (id != 0 && !s.live(bt.poolId, id, os[id], bt.sc, bt.nextBoundary)) { id = os[id].next; } c.id = id; @@ -443,7 +442,7 @@ library ClearingLogic { Order storage o = os[id]; if (side == SELL ? o.limit > bt.price : o.limit < bt.price) break; sum += rem; - Cursor memory nx = _firstLive(s, poolId, o.next, bt.sc); + Cursor memory nx = _firstLive(s, bt, o.next); id = nx.id; rem = nx.rem; } diff --git a/contracts/src/libraries/logic/OrderLogic.sol b/contracts/src/libraries/logic/OrderLogic.sol index 2db2cba..2497b01 100644 --- a/contracts/src/libraries/logic/OrderLogic.sol +++ b/contracts/src/libraries/logic/OrderLogic.sol @@ -169,7 +169,7 @@ library OrderLogic { o.units = a.units; o.limit = a.minPrice; o.escrow = a.maxTopUp; - s.rolloverOld[a.newPoolId][id] = a.oldPoolId; + s.linkedPool[a.newPoolId][id] = a.oldPoolId; CoreLib.pullExact(np.key.quote, a.maxTopUp); s.insert(pr, a.newPoolId, np, id, a.hint); emit IConfluere.OrderPlaced(a.newPoolId, id, msg.sender, SELL, a.units, a.minPrice, a.maxTopUp, o.deadline); @@ -215,7 +215,7 @@ library OrderLogic { if (o.owner == address(0) || o.side == BID) continue; bool dead = tradingOver || o.deadline <= ps.settleCount; if (!dead && o.flags & FLAG_ROLLOVER != 0) { - PoolState storage op = s.pools[s.rolloverOld[poolId][ids[i]]]; + PoolState storage op = s.pools[s.linkedPool[poolId][ids[i]]]; dead = !s.windowOpen(op) && (op.status != Status.Open || block.timestamp >= op.key.expiry); } if (dead) s.removeWithRefund(poolId, ps, ids[i], CoreLib.RM_EXPIRED); From cf7338decfd5b454ea8fd615bb04c392f1e64df7 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 113/169] feat(core): let rollovers fill in the last batch before expiry (tests audit) Part 6/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/test/audit/AuditFindings.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/audit/AuditFindings.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/test/audit/AuditFindings.t.sol b/contracts/test/audit/AuditFindings.t.sol index 96c1bf6..4622797 100644 --- a/contracts/test/audit/AuditFindings.t.sol +++ b/contracts/test/audit/AuditFindings.t.sol @@ -133,7 +133,7 @@ contract AuditFindingsTest is Base { _buy(bob, pid, 4e16, 1000e6); // keeps the batch from crossing _settle(pid); // the tail (worst price, ttl 1) expires but is not at the head _sell(alice, pid, ONE, 2900e6); // worse than every live sell, but the tail is expired - (address o,,,,,,) = core.order(pid, 8); + (address o,,,,,,,) = core.order(pid, 8); assertEq(o, address(0), "expired tail evicted"); } From 40950afb6be31d92e120fd40c58cd997433d8209 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 114/169] feat(core): let rollovers fill in the last batch before expiry (tests fuzz) Part 7/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/test/fuzz/ClearingFuzz.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/fuzz/ClearingFuzz.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/test/fuzz/ClearingFuzz.t.sol b/contracts/test/fuzz/ClearingFuzz.t.sol index 80dc742..3f94448 100644 --- a/contracts/test/fuzz/ClearingFuzz.t.sol +++ b/contracts/test/fuzz/ClearingFuzz.t.sol @@ -67,7 +67,7 @@ contract ClearingFuzzTest is Base { function _escQ(bytes32 pid) internal view returns (uint256 escQ) { for (uint64 id = 1; id < 20; ++id) { - (address owner, uint8 side,,, uint128 esc,,) = core.order(pid, id); + (address owner, uint8 side,,,, uint128 esc,,) = core.order(pid, id); if (owner != address(0) && side == 1) escQ += esc; } } From 246eca739435f65e38b98e58408d09df23437046 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 115/169] feat(core): let rollovers fill in the last batch before expiry (tests invariant) Part 8/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/test/invariant/Handler.sol, contracts/test/invariant/Invariants.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/invariant/Handler.sol | 4 ++-- contracts/test/invariant/Invariants.t.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/test/invariant/Handler.sol b/contracts/test/invariant/Handler.sol index bad0268..e93e053 100644 --- a/contracts/test/invariant/Handler.sol +++ b/contracts/test/invariant/Handler.sol @@ -174,12 +174,12 @@ contract Handler is Test { calls++; if (refs.length == 0) return; Ref memory ref = refs[r % refs.length]; - (address owner,,,,,,) = core.order(ref.pid, ref.id); + (address owner,,,,,,,) = core.order(ref.pid, ref.id); if (owner == address(0)) return; uint256 w = weth.balanceOf(address(core)); uint256 u = usdc.balanceOf(address(core)); IConfluere.PoolView memory v = core.pool(ref.pid); - (,,,,, uint32 deadline,) = core.order(ref.pid, ref.id); + (,,,,,, uint32 deadline,) = core.order(ref.pid, ref.id); bool live = v.status == Status.Open && block.timestamp < v.key.expiry && deadline > v.settleCount; vm.prank(a % 2 == 0 ? owner : _actor(a)); try core.cancelOrder(ref.pid, ref.id) { diff --git a/contracts/test/invariant/Invariants.t.sol b/contracts/test/invariant/Invariants.t.sol index 5aeb287..0f8ca53 100644 --- a/contracts/test/invariant/Invariants.t.sol +++ b/contracts/test/invariant/Invariants.t.sol @@ -181,10 +181,10 @@ contract InvariantsTest is Test { function _escrows() internal view returns (uint256 a, uint256 q) { for (uint256 i; i < h.refsLength(); ++i) { (bytes32 pid, uint64 id) = h.refs(i); - (address owner, uint8 side, uint96 units,, uint128 escrow,,) = core.order(pid, id); + (address owner, uint8 side,, uint96 units,, uint128 escrow,,) = core.order(pid, id); if (owner == address(0)) continue; if (side == SELL || side == NC_SELL) { - if (core.rolloverSource(pid, id) != bytes32(0)) q += escrow; + if (core.linkedPool(pid, id) != bytes32(0)) q += escrow; else a += units; } else { q += escrow; From d240c3703a60f93c0413c20a64259c2c7b099b10 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 116/169] feat(core): let rollovers fill in the last batch before expiry (tests medusa) Part 9/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/test/medusa/MedusaHarness.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/medusa/MedusaHarness.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/test/medusa/MedusaHarness.sol b/contracts/test/medusa/MedusaHarness.sol index fa1b0aa..c569794 100644 --- a/contracts/test/medusa/MedusaHarness.sol +++ b/contracts/test/medusa/MedusaHarness.sol @@ -250,7 +250,7 @@ contract MedusaHarness { function _escrows() internal view returns (uint256 a, uint256 q) { for (uint256 i; i < orderIds.length; ++i) { - (address owner, uint8 side, uint96 units,, uint128 escrow,,) = core.order(orderPools[i], orderIds[i]); + (address owner, uint8 side,, uint96 units,, uint128 escrow,,) = core.order(orderPools[i], orderIds[i]); if (owner == address(0)) continue; if (side == SELL || side == NC_SELL) a += units; else q += escrow; From 96637ab96acc244f3730cf1c6a5f6b24710d6827 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:55:42 -0400 Subject: [PATCH 117/169] feat(core): let rollovers fill in the last batch before expiry (tests unit) Part 10/10 of: feat(core): let rollovers fill in the last batch before expiry Files: contracts/test/unit/Clearing.t.sol, contracts/test/unit/Periphery.t.sol, contracts/test/unit/Pools.t.sol, contracts/test/unit/RolloverLastBatch.t.sol, contracts/test/unit/Seats.t.sol, contracts/test/unit/Window.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/Clearing.t.sol | 16 ++-- contracts/test/unit/Periphery.t.sol | 4 +- contracts/test/unit/Pools.t.sol | 8 +- contracts/test/unit/RolloverLastBatch.t.sol | 89 +++++++++++++++++++++ contracts/test/unit/Seats.t.sol | 2 +- contracts/test/unit/Window.t.sol | 2 +- 6 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 contracts/test/unit/RolloverLastBatch.t.sol diff --git a/contracts/test/unit/Clearing.t.sol b/contracts/test/unit/Clearing.t.sol index 8833168..f6e75eb 100644 --- a/contracts/test/unit/Clearing.t.sol +++ b/contracts/test/unit/Clearing.t.sol @@ -55,13 +55,13 @@ contract ClearingTest is Base { assertEq(core.balanceOf(dave, _r(pid)), 5e17); assertEq(core.balanceOf(bob, _c(pid)), 15e17); assertEq(core.balanceOf(carol, _c(pid)), 0); - (address ao,,,,,,) = core.order(pid, a); + (address ao,,,,,,,) = core.order(pid, a); assertEq(ao, address(0), "filled order removed"); - (,, uint96 bUnits,,,,) = core.order(pid, b); + (,,, uint96 bUnits,,,,) = core.order(pid, b); assertEq(bUnits, 15e17, "marginal partially filled"); - (address xo,,,,,,) = core.order(pid, x); + (address xo,,,,,,,) = core.order(pid, x); assertEq(xo, address(0)); - (address yo,,,,,,) = core.order(pid, y); + (address yo,,,,,,,) = core.order(pid, y); assertEq(yo, carol, "unfilled stays"); // every filled seller got at least its limit, every filled buyer paid at most its limit assertEq(_pend(address(usdc), alice), 2950e6 - 442_500 - 3_048_315, "P* net of trade fee and spread share"); @@ -94,7 +94,7 @@ contract ClearingTest is Base { _buy(bob, pid, ONE, 3100e6); _settle(pid); assertEq(core.balanceOf(alice, _r(pid)), 0, "expired order did not trade"); - (address o,,,,,,) = core.order(pid, s); + (address o,,,,,,,) = core.order(pid, s); assertEq(o, address(0)); assertEq(_pend(address(weth), alice), ONE); } @@ -107,8 +107,8 @@ contract ClearingTest is Base { (ids[0], ids[1]) = (s, keep); vm.prank(eve); core.purge(pid, ids); - (address o1,,,,,,) = core.order(pid, s); - (address o2,,,,,,) = core.order(pid, keep); + (address o1,,,,,,,) = core.order(pid, s); + (address o2,,,,,,,) = core.order(pid, keep); assertEq(o1, address(0)); assertEq(o2, dave, "live order not purgeable"); } @@ -117,7 +117,7 @@ contract ClearingTest is Base { _sell(alice, pid, ONE, 2900e6); uint64 x = _buy(bob, pid, 2 * ONE, 2900e6); _settle(pid); - (,, uint96 units,, uint128 esc, uint32 deadline,) = core.order(pid, x); + (,,, uint96 units,, uint128 esc, uint32 deadline,) = core.order(pid, x); assertEq(units, ONE); uint256 left = deadline - core.pool(pid).settleCount; assertEq(esc, 2900e6 + 435_000 + 2 * left, "remaining escrow = remainder at limit + fee + reserve"); diff --git a/contracts/test/unit/Periphery.t.sol b/contracts/test/unit/Periphery.t.sol index 1ed4808..c054e45 100644 --- a/contracts/test/unit/Periphery.t.sol +++ b/contracts/test/unit/Periphery.t.sol @@ -133,8 +133,8 @@ contract PeripheryTest is Base { ps[1] = _params(BUY, ONE, 2000e6, 0, 3, core.findHint(pid, BUY, 2000e6)); uint64[] memory ids = r.placeOrders(pids, ps); vm.stopPrank(); - (address o0,,,,,,) = core.order(pid, ids[0]); - (address o1,,,, uint128 esc,,) = core.order(pid, ids[1]); + (address o0,,,,,,,) = core.order(pid, ids[0]); + (address o1,,,,, uint128 esc,,) = core.order(pid, ids[1]); assertEq(o0, dave); assertEq(o1, dave); assertEq(esc, 2000e6 + 300_000 + 6); diff --git a/contracts/test/unit/Pools.t.sol b/contracts/test/unit/Pools.t.sol index 15d32fa..e48f9c4 100644 --- a/contracts/test/unit/Pools.t.sol +++ b/contracts/test/unit/Pools.t.sol @@ -106,7 +106,7 @@ contract PoolsTest is Base { p.owner = dave; vm.prank(alice); uint64 id = core.placeOrder(pid, p); - (address owner,,,,,,) = core.order(pid, id); + (address owner,,,,,,,) = core.order(pid, id); assertEq(owner, dave); vm.prank(dave); core.cancelOrder(pid, id); @@ -127,7 +127,7 @@ contract PoolsTest is Base { uint64 d = core.placeOrder(pid, _params(SELL, ONE, 2950e6, 0, 1, staleHint)); (uint64 head,) = core.bookHead(pid, SELL); assertEq(head, a); - (,,,,,, uint64 nextOfD) = core.order(pid, d); + (,,,,,,, uint64 nextOfD) = core.order(pid, d); assertEq(nextOfD, b); } @@ -175,7 +175,7 @@ contract PoolsTest is Base { _sell(dave, pid, ONE, 2050e6); (, uint32 count) = core.bookHead(pid, SELL); assertEq(count, 8); - (address owner,,,,,,) = core.order(pid, worst); + (address owner,,,,,,,) = core.order(pid, worst); assertEq(owner, address(0)); assertEq(_pend(address(weth), alice), ONE); } @@ -239,7 +239,7 @@ contract PoolsTest is Base { (uint64 cur,) = core.bookHead(pid, side); for (uint256 i; i < ids.length; ++i) { assertEq(cur, ids[i], "book order"); - (,,,,,, cur) = core.order(pid, cur); + (,,,,,,, cur) = core.order(pid, cur); } } } diff --git a/contracts/test/unit/RolloverLastBatch.t.sol b/contracts/test/unit/RolloverLastBatch.t.sol new file mode 100644 index 0000000..3b45af9 --- /dev/null +++ b/contracts/test/unit/RolloverLastBatch.t.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {Base} from "../utils/Base.sol"; +import {IConfluere} from "../../src/interfaces/IConfluere.sol"; +import {Kind, Status} from "../../src/libraries/PoolKey.sol"; +import {SELL} from "../../src/libraries/OrderSet.sol"; +import {Clearing} from "../../src/libraries/Clearing.sol"; +import {Settlement} from "../../src/libraries/Settlement.sol"; + +/// @notice A rollover also fills in the new pool's last batch before the old pool expires (blueprint 3.7 #5). +contract RolloverLastBatchTest is Base { + function _borrow(bytes32 pid, uint256 units, uint256 price) internal { + _sell(alice, pid, units, price); + _buy(bob, pid, units, price); + _settle(pid); + } + + function _roll(bytes32 oldP, bytes32 newP, uint256 units, uint256 minPrice, uint256 topUp) + internal + returns (uint64 id) + { + uint64 hint = core.findHint(newP, SELL, uint96(minPrice)); + vm.prank(alice); + id = core.rollover(oldP, newP, uint96(units), uint96(minPrice), uint128(topUp), 500, hint); + } + + function _setup(Kind kind, uint8 cadence) internal returns (bytes32 p1, bytes32 p2) { + p1 = _create(K, kind, E1); + p2 = _createKey(core, _keyT(K, kind, E2, 800, cadence), _noOpts()); + _borrow(p1, ONE, 2900e6); + _roll(p1, p2, ONE, 2850e6, 200e6); + _buy(carol, p2, ONE, 2950e6); + } + + function test_rollover_earlierBatch_skipped() public { + (bytes32 p1, bytes32 p2) = _setup(Kind.American, 0); + vm.warp(E1 - 2 * BATCH); + core.settleBatch(p2); // next boundary E1 - BATCH < E1: not the last batch + assertEq(core.balanceOf(alice, _r(p2)), 0); + assertEq(core.pool(p1).assetBal, ONE); + } + + function test_rollover_baseCadence_lastBatch() public { + (bytes32 p1, bytes32 p2) = _setup(Kind.American, 0); + vm.warp(E1 - BATCH); + core.settleBatch(p2); // next boundary is E1: the last chance before the old pool expires + assertEq(core.balanceOf(alice, _r(p2)), ONE); + IConfluere.PoolView memory v1 = core.pool(p1); + assertEq(uint8(v1.status), uint8(Status.Open), "old pool still trading"); + assertEq(v1.assetBal, 0); + assertEq(v1.quoteBal, K, "all of K paid one batch early"); + assertEq(v1.repaidUnits, ONE); + assertEq(core.balanceOf(alice, _r(p1)), 0); + assertEq(core.lockedR(alice, p1), 0); + } + + function test_rollover_lastBatchBeforeExpiry_fills() public { + // a long-tail new pool (one clearing per 12 base batches): its last batch before E1 starts 6 h earlier + (bytes32 p1, bytes32 p2) = _setup(Kind.European, 1); + vm.warp(E1 - 12 hours); + core.settleBatch(p2); + assertEq(core.balanceOf(alice, _r(p2)), 0, "next boundary E1 - 6 h: not yet"); + vm.warp(E1 - 6 hours); + core.settleBatch(p2); + assertEq(core.balanceOf(alice, _r(p2)), ONE, "European old pool repaid early by the rollover"); + assertEq(core.pool(p1).repaidUnits, ONE); + assertEq(core.pool(p1).assetBal, 0); + } + + function test_rollover_topUpCoversFees() public { + bytes32 p1 = _create(K, Kind.American, E1); + bytes32 p2 = _create(K_HI, Kind.American, E2); + _borrow(p1, ONE, 2900e6); + uint256 minP = 2850e6; + uint256 need = Clearing.quoteUp(ONE, K - minP, ONE) + Settlement.tradeFee(Clearing.quoteUp(ONE, minP, ONE)) + + Settlement.spreadShare(ONE, K_HI, minP, ONE) + 4 * 500; + uint64 hint = core.findHint(p2, SELL, uint96(minP)); + vm.prank(alice); + vm.expectRevert(IConfluere.InvalidParams.selector); + core.rollover(p1, p2, uint96(ONE), uint96(minP), uint128(need - 1), 500, hint); + _roll(p1, p2, ONE, minP, need); + _buy(carol, p2, ONE, minP); // clears exactly at minPrice: the largest top-up + vm.warp(E1 - BATCH); + core.settleBatch(p2); + assertEq(core.balanceOf(alice, _r(p2)), ONE); + assertEq(core.pool(p1).quoteBal, K); + } +} diff --git a/contracts/test/unit/Seats.t.sol b/contracts/test/unit/Seats.t.sol index 5a50575..6dc6e0c 100644 --- a/contracts/test/unit/Seats.t.sol +++ b/contracts/test/unit/Seats.t.sol @@ -219,7 +219,7 @@ contract SeatsTest is Base { // the order belongs to the holder in the core _post(alice, ONE, 2800e6); (uint64 head,) = core.bookHead(pid, BUY); - (address owner,,,,,,) = core.order(pid, head); + (address owner,,,,,,,) = core.order(pid, head); assertEq(owner, alice); } diff --git a/contracts/test/unit/Window.t.sol b/contracts/test/unit/Window.t.sol index 4ffd4fc..02ba9e2 100644 --- a/contracts/test/unit/Window.t.sol +++ b/contracts/test/unit/Window.t.sol @@ -125,7 +125,7 @@ contract WindowTest is Base { assertEq(_pend(address(weth), eve), ONE); assertEq(_pend(address(usdc), eve), 5e17 * (uint256(K) + 100e6) / ONE, "unused half refunded"); assertEq(_pend(address(usdc), carol) - carol0, uint256(K) + 40e6, "losing bid refunded"); - (address o,,,,,,) = core.order(pid, b1); + (address o,,,,,,,) = core.order(pid, b1); assertEq(o, address(0)); IConfluere.PoolView memory v = core.pool(pid); From f4c9bd3fdf056c34b03746fc976fa1c8101ff786 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:56:36 -0400 Subject: [PATCH 118/169] feat(web): v0.8 fee math in quotes, pool rows and the demo chain (web src/lib) Borrow quotes are net of the 0.015% trade fee and the 5% spread share; lender returns pay the trade fee and get the full buy-back price; lend budgets are all-in (ncUnits). PoolRow drops vwap for feeEscrow / filledUnits / repaidUnits, read through lib/v08.ts until the ABIs are regenerated (graduation by fees, falling back to volume on older cores). The demo chain withholds the spread into a per-pool escrow, charges trade fees, splits the escrow at close and answers graduationFee and MarketStats.fees on all three networks. Part 1/2 of: feat(web): v0.8 fee math in quotes, pool rows and the demo chain Files: apps/web/src/lib/actions.ts, apps/web/src/lib/data.ts, apps/web/src/lib/demo/chain.ts, apps/web/src/lib/due.ts, apps/web/src/lib/order.ts, apps/web/src/lib/product.ts, apps/web/src/lib/quote.ts, apps/web/src/lib/rollover.ts, apps/web/src/lib/v08.ts Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/actions.ts | 2 +- apps/web/src/lib/data.ts | 34 +++++++---- apps/web/src/lib/demo/chain.ts | 105 +++++++++++++++++++++++---------- apps/web/src/lib/due.ts | 10 +++- apps/web/src/lib/order.ts | 16 ++--- apps/web/src/lib/product.ts | 24 +++++--- apps/web/src/lib/quote.ts | 55 ++++++++++++----- apps/web/src/lib/rollover.ts | 9 ++- apps/web/src/lib/v08.ts | 30 ++++++++++ 9 files changed, 204 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/lib/v08.ts diff --git a/apps/web/src/lib/actions.ts b/apps/web/src/lib/actions.ts index e704373..51dcfe7 100644 --- a/apps/web/src/lib/actions.ts +++ b/apps/web/src/lib/actions.ts @@ -169,7 +169,7 @@ export function rolloverPlan(i: Omit, windowBlocks: numb const minPrice = i.minPrice ?? borrowFloor(i.to, i.slippageBps); const tries = rolloverTries(windowBlocks, i.spb, i.to.batchInterval); const { ttl, capped } = rolloverTtl({ now: i.now, fromExpiry: i.from.expiry, targetInterval: i.to.batchInterval, tries }); - const maxTopUp = minPrice > 0n ? rolloverMaxTopUp(i.from, i.units, minPrice, ttl) : 0n; + const maxTopUp = minPrice > 0n ? rolloverMaxTopUp(i.from, i.units, minPrice, ttl, i.to) : 0n; return { minPrice, maxTopUp, ttl, capped, tries }; } diff --git a/apps/web/src/lib/data.ts b/apps/web/src/lib/data.ts index 85e6049..9f0a8fe 100644 --- a/apps/web/src/lib/data.ts +++ b/apps/web/src/lib/data.ts @@ -30,6 +30,7 @@ import { MAX_TTL } from "./window"; import { appFetch } from "./demo/client"; import { useDemoClientOr } from "./demo/hooks"; import { useViewAddress } from "./demo/hooks"; +import { feeFields, v07StatsAbi, v08Abi, type GraduationBasis } from "./v08"; export const REFRESH_MS = 8000; @@ -53,12 +54,14 @@ export type PoolRow = { kind: Kind; status: Status; assetDecimals: number; - feeBps: number; + feeBps: number; // pre-v0.8 cores only (0 on v0.8: fees are fixed, Settlement.sol) bandBps: number; bandFailures: number; // consecutive batches where at-price orders could not fill; the band widens 1.5x each bandBase: bigint; // centre of the at-price band lastClearingPrice: bigint; - vwap: bigint; + feeEscrow: bigint; // v0.8: 5% of (K − P) withheld from borrowers at clearing, split at close by repaid share + filledUnits: bigint; // v0.8: units ever filled in this pool (denominator of the escrow split) + repaidUnits: bigint; // v0.8: repaid + rolled + bid-filled units (numerator: the treasury's part) settleCount: number; windowEnd: bigint; pausedUntil: bigint; @@ -168,12 +171,12 @@ export async function readPools(client: PublicClient, ids: Hex[], core?: Address kind: v.key.kind, status: v.status, assetDecimals: v.assetDecimals, - feeBps: v.feeBps, - bandBps: v.bandBps, + feeBps: Number(v.feeBps ?? 0), + bandBps: Number(v.bandBps ?? v.key.bandBps ?? 0), bandFailures: Number(v.bandFailures), bandBase: v.bandBase, lastClearingPrice: v.lastClearingPrice, - vwap: v.vwap, + ...feeFields(v), settleCount: v.settleCount, windowEnd: v.windowEnd, pausedUntil: v.pausedUntil, @@ -653,9 +656,12 @@ export function usePoolSupplies(ids: Hex[]) { }); } -export type ExpiryStats = { expiry: number; volume: bigint; bad: boolean }; +export type ExpiryStats = { expiry: number; volume: bigint; fees: bigint; bad: boolean }; -/** Per-expiry market stats (graduation inputs) and the published volume threshold for the market's quote token. */ +/** + * Per-expiry market stats (graduation inputs) and the published threshold for the market's quote token. v0.8 cores + * graduate on treasury fees (`basis: "fees"`, MarketStats.fees vs graduationFee); older ones on volume. + */ export function useMarketStats(market: { id: Hex; quote: Address } | undefined, expiries: number[]) { const { chainId, client, deployment, ready } = useChainCtx(); return useQuery({ @@ -664,15 +670,19 @@ export function useMarketStats(market: { id: Hex; quote: Address } | undefined, refetchInterval: REFRESH_MS * 4, queryFn: async () => { const core = deployment!.confluere; + const [fee] = await readManySafe(client!, [{ address: core, abi: v08Abi, functionName: "graduationFee", args: [market!.quote] } as const]); + const basis: GraduationBasis = fee === undefined ? "volume" : "fees"; const r = await readMany(client!, [ - { address: core, abi: confluereAbi, functionName: "graduationVolume", args: [market!.quote] } as const, - ...expiries.map((e) => ({ address: core, abi: confluereAbi, functionName: "marketStats", args: [market!.id, BigInt(e)] }) as const), + basis === "fees" + ? ({ address: core, abi: v08Abi, functionName: "graduationFee", args: [market!.quote] } as const) + : ({ address: core, abi: v08Abi, functionName: "graduationVolume", args: [market!.quote] } as const), + ...expiries.map((e) => ({ address: core, abi: basis === "fees" ? v08Abi : v07StatsAbi, functionName: "marketStats", args: [market!.id, BigInt(e)] }) as const), ]); const stats: ExpiryStats[] = expiries.map((expiry, i) => { - const st = r[i + 1] as { volume: bigint; bad: boolean }; - return { expiry, volume: st.volume, bad: st.bad }; + const st = r[i + 1] as { volume: bigint; fees?: bigint; bad: boolean }; + return { expiry, volume: st.volume, fees: st.fees ?? 0n, bad: st.bad }; }); - return { threshold: r[0] as bigint, stats }; + return { basis, threshold: r[0] as bigint, stats }; }, }); } diff --git a/apps/web/src/lib/demo/chain.ts b/apps/web/src/lib/demo/chain.ts index 3a9feb4..d9349ff 100644 --- a/apps/web/src/lib/demo/chain.ts +++ b/apps/web/src/lib/demo/chain.ts @@ -36,7 +36,10 @@ import { rTokenId, scaleOf, seatRegistryAbi, - spreadFee, + spreadShare, + tradeFee, + feeEscrowSplit, + ncUnits, buyEscrow, vaultAbi, isGridStrike, @@ -104,7 +107,8 @@ type ChainSpec = { vaults: VaultSpec[]; obligation: Record; minNotional: Record; - graduationVolume: Record; + /** Treasury fees (trade fees + spread share) an expiry must earn to count toward graduation, per quote token. */ + graduationFee: Record; user: UserPlan; }; @@ -151,7 +155,7 @@ function specOf(chainId: number): ChainSpec | undefined { ], obligation: { [lc(usdc)]: 400, [lc(native)]: 12 }, minNotional: { [lc(usdc)]: 10, [lc(native)]: 0.25 }, - graduationVolume: { [lc(usdc)]: 25_000, [lc(native)]: 600 }, + graduationFee: { [lc(usdc)]: 7.5, [lc(native)]: 0.18 }, user: { native: 120, tokens: [[native, 30], [usdc, 18_500], [lst, 45]], @@ -194,7 +198,7 @@ function specOf(chainId: number): ChainSpec | undefined { ], obligation: { [lc(usdc)]: 300, [lc(native)]: 12_000 }, minNotional: { [lc(usdc)]: 10, [lc(native)]: 400 }, - graduationVolume: { [lc(usdc)]: 25_000, [lc(native)]: 1_000_000 }, + graduationFee: { [lc(usdc)]: 7.5, [lc(native)]: 300 }, user: { native: 4_000, tokens: [[native, 1_500], [usdc, 12_000], [lst, 2_200]], @@ -239,7 +243,7 @@ function specOf(chainId: number): ChainSpec | undefined { ], obligation: { [lc(usdc)]: 300, [lc(native)]: 0.12 }, minNotional: { [lc(usdc)]: 10, [lc(native)]: 0.004 }, - graduationVolume: { [lc(usdc)]: 25_000, [lc(native)]: 10 }, + graduationFee: { [lc(usdc)]: 7.5, [lc(native)]: 0.003 }, user: { native: 2.5, tokens: [[native, 1.5], [usdc, 14_000], [lst, 3]], @@ -277,8 +281,12 @@ type PoolS = { settleCount: number; windowEnd: B; lastPrice: B; - vwapNum: B; - vwapDen: B; + /** v0.8: 5% of (K − P) withheld from sellers at clearing, split at close by repaidUnits / filledUnits. */ + feeEscrow: B; + filledUnits: B; + repaidUnits: B; + /** Treasury fees this pool earned (trade fees + treasury part of the spread escrow): MarketStats.fees input. */ + fees: B; quoteBal: B; assetBal: B; volume: B; @@ -702,7 +710,8 @@ function buildHistory(net: Net) { if (!firstTime) continue; // pool state from its history const sum = merged.reduce((s, x) => s + x.units, 0n); - const vwapNum = merged.reduce((s, x) => s + x.price * x.units, 0n); + const escrow = merged.reduce((s, x) => s + spreadShare(x.units, strike, x.price, scaleOf(ad)), 0n); + const trade = merged.reduce((s, x) => s + 2n * tradeFee(quoteDown(x.units, x.price, scaleOf(ad))), 0n); const volume = merged.reduce((s, x) => s + quoteDown(x.units, x.price, scaleOf(ad)), 0n); const last = merged[merged.length - 1]; const repaidF = plan.special === "closed" ? 0.8 : plan.special === "window" ? 0.45 : m.kind === Kind.American ? 0.08 + r() * 0.17 : 0; @@ -720,6 +729,8 @@ function buildHistory(net: Net) { windowEnd = BigInt(blockAt(spec, plan.expiry) + demoDeploymentOf(spec.id)!.windowBlocks); st.expiryWindowEnd[String(plan.expiry)] = windowEnd; } + // a closed pool has split its fee escrow: the repaid part to the treasury, the defaulted part to C holders + const split = plan.special === "closed" ? feeEscrowSplit(escrow, repaid, sum) : { toTreasury: 0n, toLenders: 0n }; const pool: PoolS = { id, asset: m.asset, @@ -736,9 +747,11 @@ function buildHistory(net: Net) { settleCount: Math.max(0, Math.floor((end - listed) / iv)), windowEnd, lastPrice: last?.price ?? 0n, - vwapNum, - vwapDen: sum, - quoteBal: keep(repayAmount(repaid, strike, scaleOf(ad))), + feeEscrow: plan.special === "closed" ? 0n : escrow, + filledUnits: sum, + repaidUnits: repaid, + fees: trade + split.toTreasury, + quoteBal: keep(repayAmount(repaid, strike, scaleOf(ad)) + split.toLenders), assetBal: keep(sum - repaid), volume, noise, @@ -757,10 +770,7 @@ function buildHistory(net: Net) { st.supply6909[rTokenId(id).toString()] = plan.special === "closed" ? 0n : sum - repaid; const mid = marketId(m.asset, m.quote, m.kind); st.graduated[mid] = m.graduated; - if (repaid > 0n && last) { - const fee = spreadFee(repaid, strike, vwapNum / (sum || 1n), m.fee, scaleOf(ad)); - st.marketFees[mid] = (st.marketFees[mid] ?? 0n) + fee; - } + if (pool.fees > 0n) st.marketFees[mid] = (st.marketFees[mid] ?? 0n) + pool.fees; } } @@ -1053,6 +1063,7 @@ function runRound(net: Net, T: number) { dropOrders(net, p, (o) => o.side !== Side.Bid); } else if (p.status === Status.Window && BigInt(blockT) >= p.windowEnd) { p.status = Status.Closed; + splitFeeEscrow(net, p); st.supply6909[rTokenId(p.id).toString()] = 0n; dropOrders(net, p, () => true); } else if (p.status === Status.Open && T < p.expiry) settlePool(net, p, T); @@ -1146,7 +1157,13 @@ function settlePool(net: Net, p: PoolS, T: number) { const owner = lc(o.owner); if (sell) { const u = o.units; - const proceeds = quoteDown(u, P, sc); + // v0.8 seller split: gross − 0.015% trade fee − 5% of (K − P) into the pool's fee escrow (capped) + const gross = quoteDown(u, P, sc); + const tf = tradeFee(gross); + const spread = spreadShare(u, p.strike, P, sc) > gross - tf ? gross - tf : spreadShare(u, p.strike, P, sc); + const proceeds = gross - tf - spread; + p.feeEscrow += spread; + addTradeFee(net, p, tf); if (o.rollFrom) { const old = st.pools[o.rollFrom]; const oldPay = old ? repayAmount(u, old.strike, scaleOf(old.assetDecimals)) : 0n; @@ -1157,6 +1174,7 @@ function settlePool(net: Net, p: PoolS, T: number) { if (old) { old.quoteBal += oldPay; old.assetBal = sub0(old.assetBal, u); + old.repaidUnits += u; // a rollover fill counts as repaid in the old pool } credit(st.pending, `${lc(p.quote)}|${owner}`, sub0(proceeds + o.escrow, oldPay)); } else credit(st.pending, `${lc(p.quote)}|${owner}`, proceeds); @@ -1168,9 +1186,11 @@ function settlePool(net: Net, p: PoolS, T: number) { sold += u; if (o.side === Side.NcSell) nc += u; } else { - const u = o.side === Side.NcBuy ? (o.budget * sc) / P : o.units; + const u = o.side === Side.NcBuy ? ncUnits(o.budget, P, sc) : o.units; const cost = quoteUp(u, P, sc); - credit(st.pending, `${lc(p.quote)}|${owner}`, sub0(o.escrow, cost)); + const tf = tradeFee(cost); + addTradeFee(net, p, tf); + credit(st.pending, `${lc(p.quote)}|${owner}`, sub0(o.escrow, cost + tf)); credit(st.b6909, `${cTokenId(p.id)}|${owner}`, u); credit(st.supply6909, cTokenId(p.id).toString(), u); credit(st.supply6909, rTokenId(p.id).toString(), u); @@ -1202,13 +1222,29 @@ function settlePool(net: Net, p: PoolS, T: number) { const impact = (x: B) => Math.min(0.012, (Number((x * 1000n) / typical) / 1000) * (m.pegged ? 0.0006 : 0.0025)); p.noise += impact(sold) - impact(bought); p.lastPrice = P; - p.vwapNum += P * filled; - p.vwapDen += filled; + p.filledUnits += filled; p.volume += quoteDown(filled, P, sc); p.rows.push({ index: st.round, price: P, units: filled, nc, block: blockAt(spec, T) }); if (p.rows.length > 900) p.rows = [...p.rows.slice(0, 450).filter((_, i) => i % 3 === 0), ...p.rows.slice(450)]; } +/** closeWindow (v0.8): the fee escrow splits once by repaidUnits / filledUnits, treasury vs C holders. */ +function splitFeeEscrow(net: Net, p: PoolS) { + if (p.feeEscrow <= 0n) return; + const { toTreasury, toLenders } = feeEscrowSplit(p.feeEscrow, p.repaidUnits, p.filledUnits); + p.feeEscrow = 0n; + p.quoteBal += toLenders; + addTradeFee(net, p, toTreasury); +} + +/** A trade fee goes straight to the treasury: market fees and the pool's graduation fees. */ +function addTradeFee(net: Net, p: PoolS, fee: B) { + if (fee <= 0n) return; + const mid = marketId(p.asset, p.quote, p.kind); + net.st.marketFees[mid] = (net.st.marketFees[mid] ?? 0n) + fee; + p.fees += fee; +} + // ───────────────────────────── errors ───────────────────────────── const ERRORS = [ @@ -1263,7 +1299,9 @@ function poolView(net: Net, p: PoolS) { quoteBal: p.quoteBal, assetBal: p.assetBal, surplusBal: 0n, - vwap: p.vwapDen > 0n ? p.vwapNum / p.vwapDen : 0n, + feeEscrow: p.feeEscrow, + filledUnits: p.filledUnits, + repaidUnits: p.repaidUnits, closeRemaining: 0n, options: { cadence: p.cadence, minFill: p.minFill, firstLo: p.firstLo, firstHi: p.firstHi }, batchInterval: BigInt(iv), @@ -1286,7 +1324,9 @@ const EMPTY_POOL = { quoteBal: 0n, assetBal: 0n, surplusBal: 0n, - vwap: 0n, + feeEscrow: 0n, + filledUnits: 0n, + repaidUnits: 0n, closeRemaining: 0n, options: { cadence: 0, minFill: 0n, firstLo: 0n, firstHi: 0n }, batchInterval: 0n, @@ -1356,10 +1396,10 @@ export function readCall(chainId: number, c: Call): unknown { case "marketStats": { const ps = Object.values(st.pools).filter((p) => marketId(p.asset, p.quote, p.kind) === args[0] && p.expiry === Number(args[1])); const strikes = ps.map((p) => p.strike).sort((x, y) => (x < y ? -1 : 1)); - return { volume: ps.reduce((s, p) => s + p.volume, 0n), minStrike: strikes[0] ?? 0n, maxStrike: strikes[strikes.length - 1] ?? 0n, bad: false }; + return { volume: ps.reduce((s, p) => s + p.volume, 0n), fees: ps.reduce((s, p) => s + p.fees, 0n), minStrike: strikes[0] ?? 0n, maxStrike: strikes[strikes.length - 1] ?? 0n, bad: false }; } - case "graduationVolume": { - return toUnits(spec.graduationVolume[lc(args[0])] ?? 0, quoteDecOf(net, args[0])); + case "graduationFee": { + return toUnits(spec.graduationFee[lc(args[0])] ?? 0, quoteDecOf(net, args[0])); } case "grid": return { anchor: BigInt(DEMO_GRID.anchor), period: BigInt(DEMO_GRID.period), batchInterval: BigInt(iv), maxExpiries: DEMO_GRID.maxExpiries, strikeSigDigits: DEMO_GRID.strikeSigDigits }; @@ -1739,12 +1779,10 @@ function run(net: Net, me: Address, w: WriteCall) { const pay = repayAmount(units, p.strike, sc); pull(net, p.quote, me, core, pay, fn); burn6909(net, rTokenId(p.id), me, units, fn); - const vwap = p.vwapDen > 0n ? p.vwapNum / p.vwapDen : 0n; - const fee = spreadFee(units, p.strike, vwap, p.feeBps, sc); - p.quoteBal += pay - fee; + // v0.8: repay carries no fee; it counts toward the repaid share of the pool's fee escrow + p.quoteBal += pay; p.assetBal = sub0(p.assetBal, units); - const mid = marketId(p.asset, p.quote, p.kind); - st.marketFees[mid] = (st.marketFees[mid] ?? 0n) + fee; + p.repaidUnits += units; give(net, p.asset, me, units); return; } @@ -1819,8 +1857,10 @@ function run(net: Net, me: Address, w: WriteCall) { settleCount: 0, windowEnd: 0n, lastPrice: 0n, - vwapNum: 0n, - vwapDen: 0n, + feeEscrow: 0n, + filledUnits: 0n, + repaidUnits: 0n, + fees: 0n, quoteBal: 0n, assetBal: 0n, volume: 0n, @@ -1894,6 +1934,7 @@ function run(net: Net, me: Address, w: WriteCall) { const p = poolOrRevert(net, args[0], fn); if (p.status !== Status.Window || BigInt(blockAt(spec, t)) < p.windowEnd) revert("InvalidState", fn); p.status = Status.Closed; + splitFeeEscrow(net, p); dropOrders(net, p, () => true); return; } diff --git a/apps/web/src/lib/due.ts b/apps/web/src/lib/due.ts index caa5e24..1617262 100644 --- a/apps/web/src/lib/due.ts +++ b/apps/web/src/lib/due.ts @@ -92,7 +92,11 @@ export function slashEvidence(pools: PoolRow[], anchor: number, interval: number }); } -/** Graduation check over the last four expiries: every one must have volume >= threshold and no bad flag. */ -export function canGraduate(stats: { volume: bigint; bad: boolean }[], threshold: bigint, cycles = 4): boolean { - return threshold > 0n && stats.length >= cycles && stats.slice(0, cycles).every((s) => !s.bad && s.volume >= threshold); +/** + * Graduation check over the last four expiries: every one must have no bad flag and reach the threshold. v0.8 counts + * treasury fees (trade fees + spread share, MarketStats.fees vs graduationFee); `basis: "volume"` is the pre-v0.8 rule. + */ +export function canGraduate(stats: { volume: bigint; fees?: bigint; bad: boolean }[], threshold: bigint, cycles = 4, basis: "fees" | "volume" = "fees"): boolean { + const v = (s: { volume: bigint; fees?: bigint }) => (basis === "fees" ? (s.fees ?? 0n) : s.volume); + return threshold > 0n && stats.length >= cycles && stats.slice(0, cycles).every((s) => !s.bad && v(s) >= threshold); } diff --git a/apps/web/src/lib/order.ts b/apps/web/src/lib/order.ts index 622c987..5611bb0 100644 --- a/apps/web/src/lib/order.ts +++ b/apps/web/src/lib/order.ts @@ -2,7 +2,7 @@ * Order math for the order form and its review (FRONTEND_DESIGN §3.1, §3.3). All bigint; the APR is the only float. * Prices are quote base units per one whole asset, like the contract. */ -import { Side, buyEscrow, impliedApr, quoteDown, repayAmount, scaleOf, spreadFee } from "@confluere/sdk"; +import { Side, buyEscrow, impliedApr, ncUnits, quoteDown, repayAmount, scaleOf, sellSplit, tradeFee } from "@confluere/sdk"; import type { PoolRow } from "./data"; import { isPegged } from "./markets"; @@ -36,11 +36,12 @@ export type OrderMath = { price: bigint; // the price the review assumes: the limit, or last P* for at-price orders estimate: boolean; // true when the review relies on last P* expectedUnits: bigint; // R (borrow) or C (lend) if filled - proceeds: bigint; // quote received by a borrower if filled (>= for limit orders) + proceeds: bigint; // quote received by a borrower if filled, net of fees (>= for limit orders) buyBack: bigint; // repayAmount(units, K) cost: bigint; // buy-back minus proceeds apr: number; // (K − price)/price annualised to expiry - protocolShare: bigint; // spread share inside the buy-back + spreadShare: bigint; // 5% of (K − price) withheld from the borrower at clearing (pool fee escrow) + tradeFee: bigint; // 0.015% of the notional, charged on this side at clearing notional: bigint; // value checked against minNotional: at K, or the budget }; @@ -53,12 +54,12 @@ export function orderMath(i: OrderInput): OrderMath { const orderSide = lend ? (market ? Side.NcBuy : Side.Buy) : market ? Side.NcSell : Side.Sell; const budget = lend && market ? i.amount : 0n; const units = lend && market ? 0n : i.amount; - const expectedUnits = lend && market ? (price > 0n ? (i.amount * scale) / price : 0n) : i.amount; + const expectedUnits = lend && market ? (price > 0n ? ncUnits(i.amount, price, scale) : 0n) : i.amount; const escrow = !lend ? i.amount : market ? i.amount : buyEscrow(i.amount, i.limit, i.ttl, scale); - const proceeds = price > 0n ? quoteDown(expectedUnits, price, scale) : 0n; + const split = price > 0n ? sellSplit(expectedUnits, price, p.strike, scale) : { gross: 0n, trade: 0n, spread: 0n, net: 0n }; + const proceeds = split.net; const buyBack = repayAmount(expectedUnits, p.strike, scale); const cost = buyBack > proceeds ? buyBack - proceeds : 0n; - const vwap = price > 0n ? price : p.vwap; return { orderSide, units, @@ -73,7 +74,8 @@ export function orderMath(i: OrderInput): OrderMath { buyBack, cost, apr: price > 0n ? impliedApr(price, p.strike, p.expiry - i.now) : 0, - protocolShare: spreadFee(expectedUnits, p.strike, vwap, p.feeBps, scale), + spreadShare: lend ? 0n : split.spread, + tradeFee: lend ? tradeFee(quoteDown(expectedUnits, price, scale)) : split.trade, notional: lend && market ? i.amount : quoteDown(i.amount, p.strike, scale), }; } diff --git a/apps/web/src/lib/product.ts b/apps/web/src/lib/product.ts index ca22879..5ee54d0 100644 --- a/apps/web/src/lib/product.ts +++ b/apps/web/src/lib/product.ts @@ -9,7 +9,7 @@ * Protocol names stay in code only: strike = buy-back price per 1 whole collateral, lastClearingPrice = last traded * price per 1 whole collateral (what a borrower received), C = lender units, R = borrower's buy-back right. */ -import { Status, haircut, impliedApr, quoteDown, repayAmount, scaleOf } from "@confluere/sdk"; +import { Status, borrowerNetPerUnit, haircut, impliedApr, lenderCostPerUnit, ncUnits, quoteDown, repayAmount, scaleOf, sellerNet } from "@confluere/sdk"; import type { Address, Hex } from "viem"; import type { PoolRow, VaultRow } from "./data"; import type { Position } from "./position"; @@ -88,9 +88,9 @@ export function offerOf(p: PoolRow, o: { now: number; block: bigint; ref?: numbe strike: p.strike, price, priced, - receivePerUnit: price, + receivePerUnit: priced ? borrowerNetPerUnit(price, p.strike, scale) : 0n, // after the 0.015% trade fee and 5% spread share repayPerUnit: repayAmount(scale, p.strike, scale), - apr: priced ? impliedApr(price, p.strike, p.expiry - o.now) : 0, + apr: priced ? impliedApr(lenderCostPerUnit(price), p.strike, p.expiry - o.now) : 0, // lender APR net of the trade fee ref, haircutVsRef: ref && priced && qd !== undefined ? 1 - toFloat(price, qd) / ref : undefined, repayVsRef: ref && qd !== undefined ? toFloat(p.strike, qd) / ref : undefined, @@ -171,23 +171,29 @@ export function pickLendOffer(terms: Term[], o: { expiry?: number; goal: "apr" | // ───────────────────────────── amounts ───────────────────────────── -/** Quote a borrower receives for `units` of collateral at the offer's last price (rounded down). */ -export const borrowReceive = (o: Pick, units: bigint) => quoteDown(units, o.price, scaleOf(o.pool.assetDecimals)); +/** Quote a borrower receives for `units` of collateral at the offer's last price, net of the trade fee and spread share. */ +export const borrowReceive = (o: Pick, units: bigint) => + o.price > 0n ? sellerNet(units, o.price, o.pool.strike, scaleOf(o.pool.assetDecimals)) : 0n; /** Quote to repay for `units` (buy-back price, rounded up like the contract). */ export const borrowRepay = (o: Pick, units: bigint) => repayAmount(units, o.strike, scaleOf(o.pool.assetDecimals)); -/** Collateral needed to receive at least `quote` at the last price (rounded up); 0 when unpriced. */ +/** Collateral needed to receive at least `quote` (net of fees) at the last price (rounded up); 0 when unpriced. */ export function unitsForQuote(o: Pick, quote: bigint): bigint { if (o.price <= 0n || quote <= 0n) return 0n; const s = scaleOf(o.pool.assetDecimals); - return (quote * s + o.price - 1n) / o.price; + const per = borrowerNetPerUnit(o.price, o.pool.strike, s); + if (per <= 0n) return 0n; + let u = (quote * s + per - 1n) / per; + // per-unit rounding can leave the net a few wei short: step up until the contract's own split covers `quote` + for (let k = 0; k < 4 && borrowReceive(o, u) < quote; k++) u += 1n + u / 1_000_000_000n; + return u; } -/** Lender units a quote budget buys at the last price (rounded down); 0 when unpriced. */ +/** Lender units an all-in quote budget (cost + 0.015% trade fee) buys at the last price; 0 when unpriced. */ export function lendUnits(o: Pick, budget: bigint): bigint { if (o.price <= 0n) return 0n; - return (budget * scaleOf(o.pool.assetDecimals)) / o.price; + return ncUnits(budget, o.price, scaleOf(o.pool.assetDecimals)); } /** What `units` lender units pay if repaid (buy-back price, rounded down as claims are). */ diff --git a/apps/web/src/lib/quote.ts b/apps/web/src/lib/quote.ts index 73ce6f4..a2b3e86 100644 --- a/apps/web/src/lib/quote.ts +++ b/apps/web/src/lib/quote.ts @@ -5,8 +5,12 @@ * A borrower sells `units` of collateral now at the round's price and buys them back for the buy-back price per unit * before the repayment window closes. A lender buys lender units at the round's price and is paid the buy-back price * per unit if the borrower buys back; otherwise the lender receives the collateral. + * + * Fees (v0.8, Settlement.sol): every fill pays a 0.015% trade fee on each side; the borrower's side also has 5% of + * the interest (K − price) withheld into the pool's fee escrow. Repayment carries no fee. At close the escrow splits by + * the repaid share: the repaid part goes to the treasury, the defaulted part to lenders. */ -import { impliedApr, quoteDown, repayAmount, scaleOf } from "@confluere/sdk"; +import { impliedApr, lenderCostPerUnit, ncUnits, quoteDown, quoteUp, repayAmount, scaleOf, sellSplit, tradeFee } from "@confluere/sdk"; import type { PoolRow } from "./data"; import { isPegged } from "./markets"; import { fmtWait, pct } from "./format"; @@ -31,8 +35,16 @@ export type BorrowQuote = { price: bigint; /** True when the price is not the pool's own last traded price. */ estimate: boolean; + /** What the borrower gets: gross − trade fee − 5% spread share. */ receive: bigint; + /** units × price before fees. */ + gross: bigint; + /** 0.015% trade fee on the gross. */ + tradeFee: bigint; + /** 5% of (buy-back − price) × units, withheld into the pool's fee escrow. */ + spreadShare: bigint; repay: bigint; + /** repay − receive: the interest plus every fee. */ cost: bigint; /** cost / receive over this term. */ periodReturn: number; @@ -51,19 +63,24 @@ export function borrowQuote(i: { pool: PoolRow; units: bigint; ref?: bigint; now // a round never clears at or above the buy-back price (a lender is paid at most that at maturity), so an estimate // from the reference price is capped just below it; the rate is then 0, never negative const price = raw >= p.strike && p.strike > 0n ? p.strike - 1n : raw; - const receive = price > 0n ? quoteDown(i.units, price, scale) : 0n; + const split = price > 0n ? sellSplit(i.units, price, p.strike, scale) : { gross: 0n, trade: 0n, spread: 0n, net: 0n }; + const receive = split.net; const repay = repayAmount(i.units, p.strike, scale); const cost = repay > receive ? repay - receive : 0n; const left = p.expiry - i.now; const move = BigInt(i.moveBps ?? defaultMoveBps(p)); + const periodReturn = receive > 0n ? Number(cost) / Number(receive) : 0; return { price, estimate: !own || (!!i.limit && i.limit > 0n), receive, + gross: split.gross, + tradeFee: split.trade, + spreadShare: split.spread, repay, cost, - periodReturn: receive > 0n ? Number(cost) / Number(receive) : 0, - apr: price > 0n ? Math.max(0, impliedApr(price, p.strike, left)) : 0, + periodReturn, + apr: receive > 0n && left > 0 ? Math.max(0, (periodReturn * 365 * DAY) / left) : 0, showApr: left >= DAY, guard: (price * (BPS - move)) / BPS, }; @@ -72,8 +89,12 @@ export function borrowQuote(i: { pool: PoolRow; units: bigint; ref?: bigint; now export type LendQuote = { price: bigint; estimate: boolean; - /** Lender units the amount buys at that price. */ + /** Lender units the amount buys at that price, the amount being all-in (cost + 0.015% trade fee). */ units: bigint; + /** units × price, rounded up as the core charges a buyer. */ + cost: bigint; + /** 0.015% trade fee on the cost. */ + tradeFee: bigint; /** Paid if repaid: units × buy-back price. */ payout: bigint; periodReturn: number; @@ -89,16 +110,21 @@ export function lendQuote(i: { pool: PoolRow; amount: bigint; ref?: bigint; now: // a round never clears at or above the buy-back price (a lender is paid at most that at maturity), so an estimate // from the reference price is capped just below it; the rate is then 0, never negative const price = raw >= p.strike && p.strike > 0n ? p.strike - 1n : raw; - const units = price > 0n ? (i.amount * scale) / price : 0n; + const units = price > 0n ? ncUnits(i.amount, price, scale) : 0n; + const cost = price > 0n ? quoteUp(units, price, scale) : 0n; + const fee = tradeFee(cost); const payout = quoteDown(units, p.strike, scale); const left = p.expiry - i.now; + const paid = lenderCostPerUnit(price); return { price, estimate: !own || (!!i.limit && i.limit > 0n), units, + cost, + tradeFee: fee, payout, - periodReturn: price > 0n ? Math.max(0, Number(p.strike - price) / Number(price)) : 0, - apr: price > 0n ? Math.max(0, impliedApr(price, p.strike, left)) : 0, + periodReturn: price > 0n ? Math.max(0, Number(p.strike - paid) / Number(paid)) : 0, + apr: price > 0n ? Math.max(0, impliedApr(paid, p.strike, left)) : 0, showApr: left >= DAY, }; } @@ -119,15 +145,16 @@ export function minUnits(p: Pick, minNotion } /** - * The lender's fixed return net of the protocol's spread share: if repaid the lender is paid K − feeBps × (K − price) - * per unit (Settlement.fee, taken only on repayment, zero on default). Same shape as `fixedReturn`. + * The lender's fixed return net of fees (v0.8): the lender pays price + 0.015% trade fee per unit and is paid the full + * buy-back price K if repaid; the 5% spread share is withheld from the borrower's side, not the lender's (on default + * the lender also gets the defaulted part of the pool's fee escrow). Same shape as `fixedReturn`. */ -export function netReturn(p: Pick, now: number, price = p.lastClearingPrice): { period: number; apr: number; showApr: boolean; priced: boolean; netPerUnit: bigint } { +export function netReturn(p: Pick, now: number, price = p.lastClearingPrice): { period: number; apr: number; showApr: boolean; priced: boolean; netPerUnit: bigint } { if (price <= 0n) return { period: 0, apr: 0, showApr: false, priced: false, netPerUnit: 0n }; - const fee = p.strike > price ? ((p.strike - price) * BigInt(p.feeBps ?? 0)) / BPS : 0n; - const net = p.strike - fee; + const net = p.strike; + const paid = lenderCostPerUnit(price); const left = p.expiry - now; - const period = Number(net - price) / Number(price); + const period = Number(net - paid) / Number(paid); return { period, apr: left > 0 ? (period * 365 * DAY) / left : 0, showApr: left >= DAY, priced: true, netPerUnit: net }; } diff --git a/apps/web/src/lib/rollover.ts b/apps/web/src/lib/rollover.ts index e7ba79b..1e70fb9 100644 --- a/apps/web/src/lib/rollover.ts +++ b/apps/web/src/lib/rollover.ts @@ -43,6 +43,9 @@ export function defaultRolloverTarget(from: PoolRow, pools: PoolRow[]): PoolRow return [...next].sort((a, b) => (dist(a) < dist(b) ? -1 : dist(a) > dist(b) ? 1 : 0))[0]; } -/** Most the borrower may have to add (unused part refunded to Withdraw): gap between old buy-back and the floor. */ -export const rolloverMaxTopUp = (from: PoolRow, units: bigint, minPrice: bigint, ttl: number) => - rolloverTopUp(units, from.strike, minPrice, ttl, scaleOf(from.assetDecimals)); +/** + * Most the borrower may have to add (unused part refunded to Withdraw): gap between old buy-back and the floor, plus + * the seller's 0.015% trade fee and 5% spread share (at the new pool's buy-back price) at the floor (v0.8). + */ +export const rolloverMaxTopUp = (from: PoolRow, units: bigint, minPrice: bigint, ttl: number, to?: Pick) => + rolloverTopUp(units, from.strike, minPrice, ttl, scaleOf(from.assetDecimals), to?.strike ?? from.strike); diff --git a/apps/web/src/lib/v08.ts b/apps/web/src/lib/v08.ts new file mode 100644 index 0000000..2a2a97a --- /dev/null +++ b/apps/web/src/lib/v08.ts @@ -0,0 +1,30 @@ +/** + * Adapter for core v0.8 reads whose ABIs are not regenerated into @confluere/sdk yet (BLUEPRINT v0.8 §8, §10; + * contracts commit 8549262). Once `pnpm codegen` runs against the v0.8 core, replace these fragments with + * `confluereAbi` and delete this file; the shapes below match IConfluere.sol. + * + * The live testnet cores predate v0.8, so every reader here falls back to the pre-v0.8 shape (vwap, graduation by + * volume) when the v0.8 call reverts. + */ +import { parseAbi } from "viem"; + +export const v08Abi = parseAbi([ + "function graduationFee(address quote) view returns (uint256)", + "function graduationVolume(address quote) view returns (uint256)", + "struct MarketStats08 { uint128 volume; uint128 fees; uint96 minStrike; uint96 maxStrike; bool bad; }", + "function marketStats(bytes32 marketId, uint64 expiry) view returns (MarketStats08)", +]); + +export const v07StatsAbi = parseAbi([ + "struct MarketStats07 { uint128 volume; uint96 minStrike; uint96 maxStrike; bool bad; }", + "function marketStats(bytes32 marketId, uint64 expiry) view returns (MarketStats07)", +]); + +/** v0.8 PoolView fields, read defensively from a decoded view (absent on a pre-v0.8 core: zero). */ +export function feeFields(v: Record): { feeEscrow: bigint; filledUnits: bigint; repaidUnits: bigint } { + const b = (x: unknown) => (typeof x === "bigint" ? x : 0n); + return { feeEscrow: b(v.feeEscrow), filledUnits: b(v.filledUnits), repaidUnits: b(v.repaidUnits) }; +} + +/** Graduation basis: "fees" on a v0.8 core (MarketStats.fees vs graduationFee), "volume" on an older one. */ +export type GraduationBasis = "fees" | "volume"; From 89070fe36830fafa6f88b9e4de1164b7259d3ede Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 02:56:36 -0400 Subject: [PATCH 119/169] feat(web): v0.8 fee math in quotes, pool rows and the demo chain (web test) Part 2/2 of: feat(web): v0.8 fee math in quotes, pool rows and the demo chain Files: apps/web/test/actions.test.ts, apps/web/test/borrow.test.tsx, apps/web/test/fixtures.tsx, apps/web/test/home.test.tsx, apps/web/test/native.test.tsx, apps/web/test/product.test.ts, apps/web/test/v3-lib.test.ts Co-Authored-By: Claude Opus 5.5 --- apps/web/test/actions.test.ts | 4 ++-- apps/web/test/borrow.test.tsx | 8 ++++---- apps/web/test/fixtures.tsx | 6 ++++-- apps/web/test/home.test.tsx | 4 ++-- apps/web/test/native.test.tsx | 2 +- apps/web/test/product.test.ts | 11 ++++++----- apps/web/test/v3-lib.test.ts | 25 +++++++++++++++++-------- 7 files changed, 36 insertions(+), 24 deletions(-) diff --git a/apps/web/test/actions.test.ts b/apps/web/test/actions.test.ts index e172fc4..4a6cae8 100644 --- a/apps/web/test/actions.test.ts +++ b/apps/web/test/actions.test.ts @@ -63,7 +63,7 @@ describe("lendWrites", () => { }); it("limit: escrow covers units at the limit plus the rounding reserve", async () => { const w = await lendWrites(ctx(), { pool: row(pid("01")) as never, amount: 0n, quote, mode: "limit", units: E18, limit: 2800_000000n, ttl: 4 }); - expect((w[0]!.args as any[])[1]).toBe(2800_000004n); + expect((w[0]!.args as any[])[1]).toBe(2800_000000n + 420000n + 8n); // cost at limit + 0.015% fee + 2 wei per batch expect((w[1]!.args as any[])[1]).toMatchObject({ side: Side.Buy, units: E18, limit: 2800_000000n, hint: 42n }); }); }); @@ -87,7 +87,7 @@ describe("rollover", () => { it("plan: floor from the new pool's price, top-up for the gap, ttl within bounds", () => { const p = rolloverPlan({ from, to, units: E18, now: NOW, spb: 1 }, 300); expect(p.minPrice).toBe(2871_000000n); - expect(p.maxTopUp).toBe(129_000000n + 2n * BigInt(p.ttl)); + expect(p.maxTopUp).toBe(129_000000n + 430650n + 6_450000n + 4n * BigInt(p.ttl)); // gap + trade fee + 5% of (K − floor) expect(p.ttl).toBeGreaterThanOrEqual(200); expect(p.tries).toBe(2); }); diff --git a/apps/web/test/borrow.test.tsx b/apps/web/test/borrow.test.tsx index 6d2fc88..3950496 100644 --- a/apps/web/test/borrow.test.tsx +++ b/apps/web/test/borrow.test.tsx @@ -142,8 +142,8 @@ describe("market drawer", () => { fireEvent.change(within(card).getByTestId("pool-borrow-input"), { target: { value: "20" } }); expect(btn().textContent).toBe("Not enough WETH"); fireEvent.change(within(card).getByTestId("pool-borrow-input"), { target: { value: "1" } }); - expect(btn().textContent).toBe("Borrow 2,900.00 USDC"); - expect(within(card).getByTestId("pool-borrow-receive").textContent).toMatch(/You receive about 2,900\.00 USDC and 1\.00 R-WETH, your right to buy it back\./); + expect(btn().textContent).toBe("Borrow 2,894.57 USDC"); // 2,900 − 0.015% trade fee − 5% of the interest + expect(within(card).getByTestId("pool-borrow-receive").textContent).toMatch(/You receive about 2,894\.57 USDC and 1\.00 R-WETH, your right to buy it back\./); fireEvent.click(btn()); const m = screen.getByTestId("review-modal"); fireEvent.click(within(m).getByTestId("place-order")); @@ -159,8 +159,8 @@ describe("market drawer", () => { expect(within(card).getByTestId("trade-ft").getAttribute("aria-checked")).toBe("true"); expect(within(card).getByTestId("trade-lend").getAttribute("aria-checked")).toBe("true"); expect(within(card).getByTestId("lend-terms").textContent).toMatch(/Paid back per 1 WETH/); - fireEvent.change(within(card).getByTestId("lend-input"), { target: { value: "2900" } }); - expect(within(card).getByTestId("lend-button").textContent).toBe("Lend 2,900.00 USDC"); + fireEvent.change(within(card).getByTestId("lend-input"), { target: { value: "2900.44" } }); // all-in: 2,900 plus the 0.015% trade fee + expect(within(card).getByTestId("lend-button").textContent).toBe("Lend 2,900.44 USDC"); expect(within(card).getByTestId("outcome-strip").textContent).toContain("You receive 1.00 WETH instead"); expect(within(card).getByTestId("lend-receive").textContent).toMatch(/≈ 1\.00 C-WETH/); }); diff --git a/apps/web/test/fixtures.tsx b/apps/web/test/fixtures.tsx index a8e3a7c..91cf5d3 100644 --- a/apps/web/test/fixtures.tsx +++ b/apps/web/test/fixtures.tsx @@ -37,12 +37,14 @@ export const row = (id: string, over: Record = {}) => ({ kind: Kind.American, status: Status.Open, assetDecimals: 18, - feeBps: 1000, + feeBps: 0, bandBps: 800, bandFailures: 0, bandBase: 2900_000000n, lastClearingPrice: 2900_000000n, - vwap: 2900_000000n, + feeEscrow: 0n, + filledUnits: E18, + repaidUnits: 0n, settleCount: 3, windowEnd: 0n, pausedUntil: 0n, diff --git a/apps/web/test/home.test.tsx b/apps/web/test/home.test.tsx index 43cb616..557f14c 100644 --- a/apps/web/test/home.test.tsx +++ b/apps/web/test/home.test.tsx @@ -103,8 +103,8 @@ describe("home: borrow", () => { expect(btn().textContent).toBe("Approve WETH"); DS.allowance = undefined; pay("2"); - expect(btn().textContent).toBe("Borrow 5,900.00 USDC"); - expect((screen.getByTestId("receive-output") as HTMLInputElement).value).toBe("5,900.00"); + expect(btn().textContent).toBe("Borrow 5,884.12 USDC"); // 5,900 − 0.015% trade fee − 5% of the interest + expect((screen.getByTestId("receive-output") as HTMLInputElement).value).toBe("5,884.12"); expect(screen.getByTestId("borrow-terms").textContent).toMatch(/Repay6,200\.00 USDC/); }); diff --git a/apps/web/test/native.test.tsx b/apps/web/test/native.test.tsx index a7a00ac..dddd23d 100644 --- a/apps/web/test/native.test.tsx +++ b/apps/web/test/native.test.tsx @@ -145,7 +145,7 @@ describe("home card on HyperEVM", () => { expect(rows[0]!.textContent).toMatch(/5\.00/); fireEvent.click(rows[1]!); fireEvent.change(screen.getByTestId("pay-input"), { target: { value: "1" } }); - expect(screen.getByTestId("borrow-button").textContent).toMatch(/^Borrow 38\.00 USDC/); + expect(screen.getByTestId("borrow-button").textContent).toMatch(/^Borrow 37\.89 USDC/); }); it("short of WHYPE but holding HYPE: offers to pay with HYPE instead, never a separate wrap button", () => { diff --git a/apps/web/test/product.test.ts b/apps/web/test/product.test.ts index 0ec2fc5..4f777dd 100644 --- a/apps/web/test/product.test.ts +++ b/apps/web/test/product.test.ts @@ -48,9 +48,9 @@ describe("termsFor", () => { it("prices each offer: receive and repay per unit, fixed APR, gap to the reference", () => { const o = termsFor(pools as never, { ...q, ref: 3100, quoteDecimals: 6 })[0]!.offers[1]!; - expect(o.receivePerUnit).toBe(2900_000000n); + expect(o.receivePerUnit).toBe(2894_565000n); // 2,900 − 0.015% trade fee − 5% of the 100 interest expect(o.repayPerUnit).toBe(3000_000000n); - expect(o.apr).toBeCloseTo((100 / 2900) * (365 / 3), 6); + expect(o.apr).toBeCloseTo((99.565 / 2900.435) * (365 / 3), 6); // lender pays the trade fee too expect(o.haircutVsRef).toBeCloseTo(1 - 2900 / 3100, 9); expect(o.repayVsRef).toBeCloseTo(3000 / 3100, 9); expect(o.discount).toBeCloseTo(100 / 3000, 9); @@ -106,11 +106,12 @@ describe("pickLendOffer", () => { describe("amounts", () => { const o = termsFor(pools as never, q)[0]!.offers[1]!; // price 2900, buy-back 3000, 18-decimal collateral it("receive, repay, units for a target and lender units round like the contract", () => { - expect(borrowReceive(o, E18 / 2n)).toBe(1450_000000n); + expect(borrowReceive(o, E18 / 2n)).toBe(1447_282500n); // 1,450 − 0.2175 − 2.5 expect(borrowRepay(o, 1n)).toBe(1n); // rounds up - expect(unitsForQuote(o, 2900_000000n)).toBe(E18); + expect(unitsForQuote(o, 2894_565000n)).toBe(E18); + expect(borrowReceive(o, unitsForQuote(o, 1234_567890n))).toBeGreaterThanOrEqual(1234_567890n); expect(unitsForQuote(o, 1n) * 2900_000000n >= 1n * E18).toBe(true); - expect(lendUnits(o, 2900_000000n)).toBe(E18); + expect(lendUnits(o, 2900_435002n)).toBe(E18); // all-in budget: cost + 0.015% trade fee expect(lendPayout(o, E18)).toBe(3000_000000n); }); }); diff --git a/apps/web/test/v3-lib.test.ts b/apps/web/test/v3-lib.test.ts index 69a1341..b00e5ed 100644 --- a/apps/web/test/v3-lib.test.ts +++ b/apps/web/test/v3-lib.test.ts @@ -1,6 +1,6 @@ /** Pure helpers behind the v3 screens: quotes, the pool pick, deadlines, the vocabulary, order status. */ import { describe, expect, it } from "vitest"; -import { Kind, Status, impliedApr, quoteDown, repayAmount, rolloverTopUp } from "@confluere/sdk"; +import { Kind, Status, impliedApr, lenderCostPerUnit, quoteDown, repayAmount, rolloverTopUp, sellerNet } from "@confluere/sdk"; import { NOW, pid, row } from "./fixtures"; import { borrowQuote, defaultBorrowLimit, defaultMoveBps, fixedReturn, lendQuote, minUnits, refToUnits, DAY } from "../src/lib/quote"; import { defaultLevel, levelChoices, pickOffer, termChoices, LEVEL_LABEL } from "../src/lib/bestPool"; @@ -22,11 +22,16 @@ describe("borrow quote (§8.1.4) matches the SDK", () => { const q = borrowQuote({ pool: p, units: 2n * E18, now: NOW }); expect(q.price).toBe(2900_000000n); expect(q.estimate).toBe(false); - expect(q.receive).toBe(quoteDown(2n * E18, 2900_000000n, E18)); + // v0.8: gross 5,800 − 0.015% trade fee (0.87) − 5% of the interest (10) = 5,789.13 + expect(q.gross).toBe(quoteDown(2n * E18, 2900_000000n, E18)); + expect(q.tradeFee).toBe(870000n); + expect(q.spreadShare).toBe(10_000000n); + expect(q.receive).toBe(sellerNet(2n * E18, 2900_000000n, 3000_000000n, E18)); + expect(q.receive).toBe(5789_130000n); expect(q.repay).toBe(repayAmount(2n * E18, 3000_000000n, E18)); - expect(q.cost).toBe(200_000000n); - expect(q.periodReturn).toBeCloseTo(100 / 2900, 9); - expect(q.apr).toBeCloseTo(impliedApr(2900_000000n, 3000_000000n, 3 * DAY), 9); + expect(q.cost).toBe(210_870000n); + expect(q.periodReturn).toBeCloseTo(210.87 / 5789.13, 9); + expect(q.apr).toBeCloseTo(((210.87 / 5789.13) * 365) / 3, 9); expect(q.showApr).toBe(true); expect(q.guard).toBe((2900_000000n * 9900n) / 10_000n); // 1% move in a mainstream market }); @@ -34,7 +39,7 @@ describe("borrow quote (§8.1.4) matches the SDK", () => { const u = { ...p, lastClearingPrice: 0n }; const q = borrowQuote({ pool: u, units: E18, now: NOW, ref: 2800_000000n }); expect(q.estimate).toBe(true); - expect(q.receive).toBe(2800_000000n); + expect(q.receive).toBe(2800_000000n - 420000n - 10_000000n); // fees on the estimate too expect(borrowQuote({ pool: u, units: E18, now: NOW }).receive).toBe(0n); }); it("a reference price above the buy-back price is capped below it: the rate is never negative", () => { @@ -70,10 +75,14 @@ describe("borrow quote (§8.1.4) matches the SDK", () => { describe("lend quote", () => { it("units bought below the buy-back price, paid the buy-back price if repaid", () => { const p = row(pid("a1")) as any; - const q = lendQuote({ pool: p, amount: 2900_000000n, now: NOW }); + // the amount is all-in: 2,900.435 buys one unit at 2,900 plus the 0.015% trade fee (plus the 1 wei reserve, rounded through the fee) + const q = lendQuote({ pool: p, amount: 2900_435002n, now: NOW }); expect(q.units).toBe(E18); + expect(q.cost).toBe(2900_000000n); + expect(q.tradeFee).toBe(435000n); expect(q.payout).toBe(3000_000000n); - expect(q.periodReturn).toBeCloseTo(100 / 2900, 9); + expect(q.periodReturn).toBeCloseTo(99.565 / 2900.435, 9); + expect(q.apr).toBeCloseTo(impliedApr(lenderCostPerUnit(2900_000000n), 3000_000000n, 3 * DAY), 9); expect(fixedReturn(p, NOW).priced).toBe(true); expect(fixedReturn({ ...p, lastClearingPrice: 0n }, NOW).priced).toBe(false); }); From 0085ed894d2073732bb286bec1313dc7e556e924 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:00:03 -0400 Subject: [PATCH 120/169] feat(web): show v0.8 fees and single-token vault withdrawals (web src/components) Borrow terms and review list the fees (0.015% trade fee + 5% of the interest, nothing on repay) and the at-market floor is net of them; the lend form shows the trade fee and a rate net of it; the pool drawer shows the fee model and the pool's fee escrow with its repaid share. How it works gains a Fees section (escrow split at close, graduation by fees), guardian pauses are capped at 7 days in total, and vault copy says withdrawals are paid in one token and vaults lend only into markets that sell unrepaid collateral. Part 1/3 of: feat(web): show v0.8 fees and single-token vault withdrawals Files: apps/web/src/components/borrow/LendForm.tsx, apps/web/src/components/borrow/borrowFlow.tsx, apps/web/src/components/earn/EarnScreen.tsx, apps/web/src/components/markets/PoolDrawer.tsx, apps/web/src/components/more/HowItWorks.tsx Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/borrow/LendForm.tsx | 10 +++++----- apps/web/src/components/borrow/borrowFlow.tsx | 11 +++++----- apps/web/src/components/earn/EarnScreen.tsx | 4 ++-- .../web/src/components/markets/PoolDrawer.tsx | 5 +++-- apps/web/src/components/more/HowItWorks.tsx | 20 +++++++++++++++---- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/borrow/LendForm.tsx b/apps/web/src/components/borrow/LendForm.tsx index 5271471..a6e90e1 100644 --- a/apps/web/src/components/borrow/LendForm.tsx +++ b/apps/web/src/components/borrow/LendForm.tsx @@ -8,7 +8,7 @@ import Link from "next/link"; import { useState } from "react"; import { useConnection } from "wagmi"; import type { Hex } from "viem"; -import { quoteDown, scaleOf } from "@confluere/sdk"; +import { quoteDown, quoteUp, scaleOf, tradeFee } from "@confluere/sdk"; import { useActiveChain } from "@/lib/activeChain"; import { launchTokensOf } from "@/lib/chains"; import { useAccountOrders, useMinNotional, usePools, useTokens, tokenOf, type PoolRow } from "@/lib/data"; @@ -134,10 +134,10 @@ function LendFormFor({ pool, onClose, embedded }: { pool: PoolRow; onClose?: () // facts per 1 unit of collateral until an amount is typed (Pendle PT panel: rate, maturity, payout before input) const units = amt > 0n && q.units > 0n ? q.units : 10n ** BigInt(A.decimals); - const paid = amt > 0n ? amt : quoteDown(units, price, scale); + const fee = tradeFee(quoteUp(units, price, scale)); // v0.8: 0.015% on the lender's side, included in the amount + const paid = amt > 0n ? amt : quoteUp(units, price, scale) + fee; const payout = quoteDown(units, f.netPerUnit || bbPrice, scale); const per = amt > 0n ? "" : ` per 1 ${A.symbol}`; - const fee = (quoteDown(units, bbPrice, scale) - payout); const ft = positionName("ft", pool, A, Q, { all: allPools ?? [] }); return ( @@ -164,12 +164,12 @@ function LendFormFor({ pool, onClose, embedded }: { pool: PoolRow; onClose?: () 0n && q.units > 0n && { k: "You receive", v: ≈ {fmtToken(q.units, { symbol: ft.short, decimals: A.decimals })}, hint: `1 C = 1 ${A.symbol} of backing`, testId: "lend-receive" }, - { k: "Fixed rate", v: price > 0n ? rateText(f, pool.expiry - now) : "—", hint: "net of the protocol's share" }, + { k: "Fixed rate", v: price > 0n ? rateText(f, pool.expiry - now) : "—", hint: "net of the 0.015% trade fee" }, { k: `You pay${per}`, v: <>≈ {fmtToken(paid, Q)}, hint: price > 0n ? `${fmtPer(price, Q)} ${Q.symbol} per ${A.symbol}` : undefined }, { k: `Paid back${per}`, v: fmtToken(payout, Q), hint: `on ${fmtChip(pool.expiry)} UTC, if repaid` }, !own && { k: "Worst case", v: worst.priced ? rateText(worst, pool.expiry - now) : "—", hint: `you pay at most ${fmtPer(cap, Q)} per ${A.symbol}` }, { k: "Collateral", v: `${A.symbol}, bought back at ${fmtPer(bbPrice, Q)}`, hint: refUnits ? `${pct(Number(bbPrice - refUnits) / Number(refUnits), 1)} vs market (reference)` : undefined }, - { k: "Fee", v: fee > 0n ? fmtToken(fee, Q) : "None", hint: "share of the interest, only if repaid" }, + { k: "Trade fee", v: fee > 0n ? fmtToken(fee, Q) : "None", hint: "0.015%, included in what you pay; the 5% share of the interest comes from the borrower's side", testId: "lend-fee" }, { k: "Repayment", v: repaysAnyTime(pool) ? "Any time" : "At maturity", hint: repaysAnyTime(pool) ? "you may be paid back early" : undefined }, ]} /> diff --git a/apps/web/src/components/borrow/borrowFlow.tsx b/apps/web/src/components/borrow/borrowFlow.tsx index c164dd6..c02058e 100644 --- a/apps/web/src/components/borrow/borrowFlow.tsx +++ b/apps/web/src/components/borrow/borrowFlow.tsx @@ -17,7 +17,7 @@ import { launchTokensOf } from "@/lib/chains"; import { useAccountOrders, useBlockTime, useMinNotional, type PoolRow, type Token } from "@/lib/data"; import { useBalances, usePending, usePositions } from "@/lib/position"; import { useActionCtx, useChainClock } from "@/lib/useProduct"; -import { borrowQuote, defaultBorrowLimit, defaultMoveBps, minUnits, refToUnits, type BorrowQuote } from "@/lib/quote"; +import { borrowQuote, defaultBorrowLimit, defaultMoveBps, minUnits, netAtPrice, refToUnits, type BorrowQuote } from "@/lib/quote"; import { repayDeadline, type Deadline } from "@/lib/deadline"; import { borrowWrites } from "@/lib/actions"; import { defaultTtl } from "@/lib/order"; @@ -115,7 +115,8 @@ export function useBorrowOrder({ pool, A, Q, amount, ref, payNative = false, rec const limitTooHigh = ownPrice && !!pool && limit >= bbPrice; const limitLow = ownPrice && !!anchor && limit > 0n && limit < (anchor * 9n) / 10n; const scale = pool ? scaleOf(pool.assetDecimals) : 1n; - const floor = q && pool && !ownPrice ? quoteDown(show, q.guard, scale) : undefined; + // net of the v0.8 fees at the guard price: 0.015% trade fee and 5% of (buy-back − price) + const floor = q && pool && !ownPrice ? netAtPrice(pool, show, q.guard) : undefined; const sym = payToken?.symbol ?? ""; let label: string; @@ -256,9 +257,9 @@ export function BorrowTerms({ o, testId = "borrow-terms", collapsible = false, t { k: `You receive${per}`, v: <>{o.ownPrice ? "at least " : "≈ "}{fmtToken(qs.receive, Q)}, hint: o.floor !== undefined && o.units > 0n ? `at least ${fmtToken(o.floor, Q)} (price may move ${o.move / 100}%)` : undefined }, { k: `Repay${per}`, v: fmtToken(qs.repay, Q), testId: "row-repay" }, { k: "Repay by", v: , hint: left > 0 ? `in ${fmtWait(left)}${repaysAnyTime(pool) ? ", any time before" : ", only after maturity"}` : undefined }, - { k: "Interest", v: fmtToken(qs.repay - qs.receive > 0n ? qs.repay - qs.receive : 0n, Q) }, + { k: "Interest", v: fmtToken(qs.repay - qs.receive > 0n ? qs.repay - qs.receive : 0n, Q), hint: "fees included" }, vsMarket !== undefined && { k: "Buy-back price", v: `${fmtPer(o.bbPrice, Q)} ${Q.symbol}`, hint: `${vsMarket >= 0 ? "+" : ""}${pct(vsMarket, 1)} vs market (reference)` }, - { k: "Fee", v: "None" }, + { k: "Fees", v: fmtToken(qs.tradeFee + qs.spreadShare, Q), hint: `0.015% trade fee ${fmtToken(qs.tradeFee, Q)} + 5% of the interest ${fmtToken(qs.spreadShare, Q)}; nothing on repay`, testId: "row-fees" }, ]} /> ); @@ -333,7 +334,7 @@ export function BorrowReview({ o, onPlaced }: { o: BorrowOrder; onPlaced?: () => { k: "Repay by", v: , hint: anyTime ? "any time before" : "only after maturity" }, o.floor !== undefined && { k: "You receive at least", v: fmtToken(o.floor, Q), hint: "or the order waits for the next round" }, { k: "Valid for", v: `${o.tries} round${o.tries > 1 ? "s" : ""}`, hint: `≈ ${fmtWait(o.tries * roundSeconds(pool))}` }, - { k: "Fee", v: "None" }, + { k: "Fees", v: fmtToken(q.tradeFee + q.spreadShare, Q), hint: "0.015% trade fee + 5% of the interest, taken from what you receive; nothing on repay" }, ]} /> Vaults setVault(a, { pay })} />

- Rates are what the vault would earn right now, after the protocol's share of the interest. Prefer to choose a maturity yourself?{" "} + Rates are what the vault would earn right now, after the 0.015% trade fee. Withdrawals are paid in one token. Prefer to choose a maturity yourself?{" "} Lend in one market diff --git a/apps/web/src/components/markets/PoolDrawer.tsx b/apps/web/src/components/markets/PoolDrawer.tsx index 78fbeb2..6cc426b 100644 --- a/apps/web/src/components/markets/PoolDrawer.tsx +++ b/apps/web/src/components/markets/PoolDrawer.tsx @@ -18,7 +18,7 @@ import { poolDue } from "@/lib/due"; import { netReturn, rateText, refToUnits } from "@/lib/quote"; import { ADVANCE_LABEL, advanceWrite, offerHeld, offerWrites } from "@/lib/proActions"; import { windowLeft } from "@/lib/deadline"; -import { fmtChip, fmtClock, fmtExpiry, fmtPer, fmtToken, fmtWait, parseAmount } from "@/lib/format"; +import { fmtChip, fmtClock, fmtExpiry, fmtPer, fmtToken, fmtWait, parseAmount, pct } from "@/lib/format"; import { buyBack, lastTraded, misses, nextRoundAt, poolState, priceGuardBps, roundSeconds, unitScale } from "@/lib/view"; import { positionName, priceSource, type PositionKind } from "@/lib/vocab"; import { sortBest, tokenIdOf, usePositionBook } from "@/lib/secondary"; @@ -83,7 +83,8 @@ function PoolBody({ pool, A, Q }: { pool: PoolRow; A: Token; Q: Token }) { now ? `in ${fmtWait(pool.expiry - now)} (UTC)` : "UTC"} /> - + + 0n ? fmtToken(pool.feeEscrow, Q, true, { compact: true }) : "—"} note={pool.filledUnits > 0n ? `${pct(Number(pool.repaidUnits) / Number(pool.filledUnits), 0)} repaid so far; the unpaid part goes to lenders at close` : "split at close by the share repaid"} />

diff --git a/apps/web/src/components/more/HowItWorks.tsx b/apps/web/src/components/more/HowItWorks.tsx index a87e6ee..4db869d 100644 --- a/apps/web/src/components/more/HowItWorks.tsx +++ b/apps/web/src/components/more/HowItWorks.tsx @@ -28,15 +28,15 @@ const DIFFERENT = [ const LIMITS = [ "Once a position starts it is never closed early. A roll-over to the next maturity can fail to fill; the current position then stays as it is.", "In markets where you can repay any time, lenders may be paid back before the date.", - "Vault withdrawals can include borrowers' tokens from loans that were not repaid.", + "Vault withdrawals are paid in one token. A vault only lends into markets that sell unrepaid collateral into the next maturity, so a withdrawal can wait for that sale.", "Every loan of the same date shares one repayment window, counted in blocks after the date. A pause of the network pauses it too.", "If a token's issuer can freeze transfers, repaying, claiming and withdrawing that token stop while it is frozen.", - "Anyone can open a market. New markets are marked, vaults don't lend there yet, and a guardian may pause them for up to 7 days at a time.", + "Anyone can open a market. New markets are marked, vaults don't lend there yet, and a guardian may pause them for up to 7 days in total.", ]; const SECURITY = [ "Contracts are immutable: no owner, no upgrade, no admin keys.", - "Only privilege: a guardian may pause a new market for up to 7 days. It cannot move funds.", + "Only privilege: a guardian may pause a new market for up to 7 days in total. It cannot move funds.", "Internal audit, October 2026 (docs/AUDIT_2026-10-11.md on GitHub). No external audit yet.", deploymentLine(), ]; @@ -96,10 +96,22 @@ export function HowItWorks() {

When you lend

- You buy lender units below the buy-back price. If the borrower repays, you are paid the buy-back price, which is your fixed rate to maturity (minus the protocol's share of the interest). If not, you receive the collateral instead. A vault + You buy lender units below the buy-back price. If the borrower repays, you are paid the buy-back price, which is your fixed rate to maturity. If not, you receive the collateral instead. A vault lends for you by a rule fixed at deployment.

+
+

Fees

+
    +
  • Every trade pays 0.015% of its value on each side (0.03% in all). It goes to the protocol, repaid or not.
  • +
  • + 5% of the interest (buy-back price minus the round's price) is taken from what the borrower receives and held in the market. When the repayment window + closes it is split by the share that was repaid: that part goes to the protocol, the rest goes to lenders, who kept the collateral instead. +
  • +
  • Repaying costs nothing beyond the buy-back price. Quotes on every card already include these fees.
  • +
  • A new market becomes established after four clean maturities in a row, each earning the protocol a fixed minimum in fees.
  • +
+

What never happens

    From 294587a8f538b571f896607738154294f69769c0 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:00:03 -0400 Subject: [PATCH 121/169] feat(web): show v0.8 fees and single-token vault withdrawals (web src/lib) Part 2/3 of: feat(web): show v0.8 fees and single-token vault withdrawals Files: apps/web/src/lib/quote.ts, apps/web/src/lib/vocab.ts Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/quote.ts | 4 ++++ apps/web/src/lib/vocab.ts | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/quote.ts b/apps/web/src/lib/quote.ts index a2b3e86..88dac91 100644 --- a/apps/web/src/lib/quote.ts +++ b/apps/web/src/lib/quote.ts @@ -86,6 +86,10 @@ export function borrowQuote(i: { pool: PoolRow; units: bigint; ref?: bigint; now }; } +/** What a borrower keeps from `units` sold at `price`, net of the trade fee and the 5% spread share (0 when unpriced). */ +export const netAtPrice = (p: Pick, units: bigint, price: bigint): bigint => + price > 0n ? sellSplit(units, price, p.strike, scaleOf(p.assetDecimals)).net : 0n; + export type LendQuote = { price: bigint; estimate: boolean; diff --git a/apps/web/src/lib/vocab.ts b/apps/web/src/lib/vocab.ts index 1164f72..baeed81 100644 --- a/apps/web/src/lib/vocab.ts +++ b/apps/web/src/lib/vocab.ts @@ -57,8 +57,8 @@ export function vaultSentences(v: Pick, quote const cap = `${(v.capBps / 100).toLocaleString("en-US", { maximumFractionDigits: 2 })}%`; return [ `This vault lends ${quote} to borrowers who sold ${asset} into the market. It never buys or holds ${asset} on purpose.`, - `If a borrower doesn't repay, the vault receives their ${asset}. When you withdraw, you may get some ${asset} along with ${quote}.`, - `Rule, fixed at deployment: lend at the ${vaultLevel(v)} buy-back price, at most ${cap} of idle funds each round, only into established markets. Nobody can change it.`, + `If a borrower doesn't repay, their ${asset} is sold into the next maturity and the vault gets ${quote} back. You always withdraw in ${quote} only.`, + `Rule, fixed at deployment: lend at the ${vaultLevel(v)} buy-back price, at most ${cap} of idle funds each round, only into established markets that sell unrepaid collateral. Nobody can change it.`, ]; } @@ -108,7 +108,7 @@ export function vaultExplainer(v: Pick, quote * C (id = poolId, the lender's side) and R (id = poolId | 1, the borrower's buy-back right); the screen never does. * * Named after the blueprint's C-Token and R-Token: - * - C, "Lender token": pays the buy-back price (less the protocol's share of the interest) at maturity if the + * - C, "Lender token": pays the buy-back price at maturity if the * borrower buys back, otherwise 1 unit of collateral. C matures. * - R, "Buy-back right": buys 1 unit of collateral for the buy-back price until the repayment window ends, worth * nothing after. R expires. From 72f1cf2a2afd80416d9fcf186ae66deace5046c1 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:00:03 -0400 Subject: [PATCH 122/169] feat(web): show v0.8 fees and single-token vault withdrawals (web test) Part 3/3 of: feat(web): show v0.8 fees and single-token vault withdrawals Files: apps/web/test/borrow.test.tsx, apps/web/test/earn.test.tsx, apps/web/test/v3-lib.test.ts Co-Authored-By: Claude Opus 5.5 --- apps/web/test/borrow.test.tsx | 2 ++ apps/web/test/earn.test.tsx | 4 ++-- apps/web/test/v3-lib.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/test/borrow.test.tsx b/apps/web/test/borrow.test.tsx index 3950496..baeec40 100644 --- a/apps/web/test/borrow.test.tsx +++ b/apps/web/test/borrow.test.tsx @@ -143,6 +143,7 @@ describe("market drawer", () => { expect(btn().textContent).toBe("Not enough WETH"); fireEvent.change(within(card).getByTestId("pool-borrow-input"), { target: { value: "1" } }); expect(btn().textContent).toBe("Borrow 2,894.57 USDC"); // 2,900 − 0.015% trade fee − 5% of the interest + expect(within(card).getByTestId("row-fees").textContent).toMatch(/Fees.*5\.4\d? USDC/); // 0.435 trade fee + 5.00 spread share expect(within(card).getByTestId("pool-borrow-receive").textContent).toMatch(/You receive about 2,894\.57 USDC and 1\.00 R-WETH, your right to buy it back\./); fireEvent.click(btn()); const m = screen.getByTestId("review-modal"); @@ -161,6 +162,7 @@ describe("market drawer", () => { expect(within(card).getByTestId("lend-terms").textContent).toMatch(/Paid back per 1 WETH/); fireEvent.change(within(card).getByTestId("lend-input"), { target: { value: "2900.44" } }); // all-in: 2,900 plus the 0.015% trade fee expect(within(card).getByTestId("lend-button").textContent).toBe("Lend 2,900.44 USDC"); + expect(within(card).getByTestId("lend-fee").textContent).toMatch(/Trade fee.*0\.4\d* USDC/); expect(within(card).getByTestId("outcome-strip").textContent).toContain("You receive 1.00 WETH instead"); expect(within(card).getByTestId("lend-receive").textContent).toMatch(/≈ 1\.00 C-WETH/); }); diff --git a/apps/web/test/earn.test.tsx b/apps/web/test/earn.test.tsx index 8d81275..1e3a51c 100644 --- a/apps/web/test/earn.test.tsx +++ b/apps/web/test/earn.test.tsx @@ -96,8 +96,8 @@ describe("earn", () => { const d = screen.getByTestId("vault-drawer"); const s = within(d).getByTestId("vault-sentences").textContent!; expect(s).toContain("This vault lends USDC to borrowers who sold WETH into the market. It never buys or holds WETH on purpose."); - expect(s).toContain("If a borrower doesn't repay, the vault receives their WETH. When you withdraw, you may get some WETH along with USDC."); - expect(s).toContain("Rule, fixed at deployment: lend at the lower buy-back price, at most 25% of idle funds each round, only into established markets. Nobody can change it."); + expect(s).toContain("If a borrower doesn't repay, their WETH is sold into the next maturity and the vault gets USDC back. You always withdraw in USDC only."); + expect(s).toContain("Rule, fixed at deployment: lend at the lower buy-back price, at most 25% of idle funds each round, only into established markets that sell unrepaid collateral. Nobody can change it."); expect(within(d).getByText("Total deposits")).toBeTruthy(); expect(within(d).getByText("1.00 USDC")).toBeTruthy(); // your position: 1e12 shares × 4.84 / 4.84e12 expect(within(d).getByText(/Deposits are collected now and start lending at the next period/)).toBeTruthy(); diff --git a/apps/web/test/v3-lib.test.ts b/apps/web/test/v3-lib.test.ts index b00e5ed..1f52101 100644 --- a/apps/web/test/v3-lib.test.ts +++ b/apps/web/test/v3-lib.test.ts @@ -154,8 +154,8 @@ describe("vocabulary", () => { expect(vaultLine("pegged", { strikeRule: 0 }, "WMON", "shMON", "MON")).toBe("Lends MON to shMON borrowers"); const [a, b, c] = vaultSentences({ strikeRule: 1, capBps: 2500 }, "USDC", "WHYPE"); expect(a).toBe("This vault lends USDC to borrowers who sold WHYPE into the market. It never buys or holds WHYPE on purpose."); - expect(b).toBe("If a borrower doesn't repay, the vault receives their WHYPE. When you withdraw, you may get some WHYPE along with USDC."); - expect(c).toBe("Rule, fixed at deployment: lend at the higher buy-back price, at most 25% of idle funds each round, only into established markets. Nobody can change it."); + expect(b).toBe("If a borrower doesn't repay, their WHYPE is sold into the next maturity and the vault gets USDC back. You always withdraw in USDC only."); + expect(c).toBe("Rule, fixed at deployment: lend at the higher buy-back price, at most 25% of idle funds each round, only into established markets that sell unrepaid collateral. Nobody can change it."); for (const s of [a, b, c]) expect(s).not.toMatch(/stake|LST|liquidat/i); }); it("network names and price sources", () => { From f13102c94870f45854516c48786aa2e045132158 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:03:44 -0400 Subject: [PATCH 123/169] feat(periphery): Specials physical lending and margin-aware Router repay Specials keeps per-borrower loans over the core's restricted lend / returnLent / topUpLent (decision A): European pools only, at most half the pool's asset, 120% margin at the last clearing price, voluntary top-up, return before the cutoff; after it the core books unreturned units as shortUnits / shortMargin lazily, so Specials needs no keeper call. Lending fee goes to the pool (C holders), with an optional annualised floor (founder approval pending, spec 3.5). Router.repay forwards the asset actually delivered and withdraws the forfeited margin credited for short units to the caller. Invariants: 3b (sum of loans == core lentUnits / lentMargin), 5 (repay never reverts on short units), 7 (margin leaves through pending). Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/Router.sol | 10 +- contracts/src/periphery/Specials.sol | 156 +++++++++++++++++++++++ contracts/test/unit/Specials.t.sol | 181 +++++++++++++++++++++++++++ 3 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 contracts/src/periphery/Specials.sol create mode 100644 contracts/test/unit/Specials.t.sol diff --git a/contracts/src/periphery/Router.sol b/contracts/src/periphery/Router.sol index 5de5404..0ae81b4 100644 --- a/contracts/src/periphery/Router.sol +++ b/contracts/src/periphery/Router.sol @@ -44,14 +44,18 @@ contract Router { } /// @notice Repay on behalf of the caller: moves the caller's R here, pays K from the caller, returns the asset. - function repay(bytes32 poolId, uint96 units) external { + /// Units lent through Specials and not returned by the cutoff are paid in forfeited margin instead (decision A): + /// the core credits it to this router's pending, which holds nothing else between calls, so it is withdrawn + /// straight to the caller. + function repay(bytes32 poolId, uint96 units) external returns (uint256 assetOut, uint256 marginOut) { IConfluere.PoolView memory v = core.pool(poolId); uint256 pay = Clearing.quoteUp(units, v.key.strike, 10 ** uint256(v.assetDecimals)); IConfluere6909(address(core)).transferFrom(msg.sender, address(this), poolId.rId(), units); IERC20(v.key.quote).safeTransferFrom(msg.sender, address(this), pay); IERC20(v.key.quote).forceApprove(address(core), pay); - core.repay(poolId, units); - IERC20(v.key.asset).safeTransfer(msg.sender, units); + (assetOut, marginOut) = core.repay(poolId, units); + if (assetOut != 0) IERC20(v.key.asset).safeTransfer(msg.sender, assetOut); + if (marginOut != 0) core.withdraw(v.key.quote, msg.sender); } /// @notice Claim several closed pools at once and forward everything to the caller. diff --git a/contracts/src/periphery/Specials.sol b/contracts/src/periphery/Specials.sol new file mode 100644 index 0000000..befcc6a --- /dev/null +++ b/contracts/src/periphery/Specials.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IConfluere} from "../interfaces/IConfluere.sol"; +import {Kind, Status} from "../libraries/PoolKey.sol"; +import {Clearing} from "../libraries/Clearing.sol"; + +/// @notice Physical lending of a European pool's asset (blueprint 7.9, ARCH 2.6.1, decision A). The only caller of +/// the core's restricted `lend` / `returnLent` / `topUpLent`. The core enforces the hard limits (European only, +/// at most half of the pool's asset lent, margin >= 120% at the last clearing price, before the cutoff +/// `expiry - LEND_CUTOFF`); this contract keeps the per-borrower loans the core only sees in aggregate. +/// After the cutoff nothing needs to happen here: the core lazily books unreturned units as `shortUnits` and their +/// whole margin as `shortMargin` (repayers are paid out of it, the rest goes to the C holders at window end). +/// Holds nothing between calls: margin and fee go straight to the core, released margin to the core's `pending`. +contract Specials { + using SafeERC20 for IERC20; + + uint256 internal constant BPS = 10_000; + uint256 internal constant MARGIN_BPS = 12_000; // mirrors the core's LEND_MARGIN_BPS + uint256 internal constant YEAR = 365 days; + + struct Loan { + address borrower; + bytes32 poolId; + uint96 units; // units still out + uint128 margin; // margin posted for them (core-held) + } + + IConfluere public core; + address private immutable _binder; + /// @notice Lending fee floor, annualised on the units' value at the last clearing price (spec 3.5, founder + /// approval pending). The fee is paid into the pool's quoteBal: it belongs to all C holders. + uint16 public immutable minFeeBpsPerYear; + + uint256 public nextId = 1; + mapping(uint256 => Loan) public loans; + + event Opened( + uint256 indexed loanId, + bytes32 indexed poolId, + address indexed borrower, + uint96 units, + uint128 margin, + uint128 fee + ); + event ToppedUp(uint256 indexed loanId, uint128 amount); + event Closed(uint256 indexed loanId, uint96 units, uint128 marginOut); + + error AlreadyBound(); + error InvalidParams(); + error NotBorrower(); + error FeeTooLow(); + + /// @param binder_ the account allowed to call `bind` once (CREATE2 factory deployments: msg.sender is the + /// factory). The core stores this contract's address as SPECIALS, so the two cannot both be immutable. + constructor(address binder_, uint16 minFeeBpsPerYear_) { + if (binder_ == address(0)) revert InvalidParams(); + _binder = binder_; + minFeeBpsPerYear = minFeeBpsPerYear_; + } + + function bind(IConfluere core_) external { + if (msg.sender != _binder || address(core) != address(0) || core_.SPECIALS() != address(this)) { + revert AlreadyBound(); + } + core = core_; + } + + // ================================================================ views + + /// @notice Minimum margin the core accepts: 120% of the units at the pool's last clearing price, rounded up. + function minMargin(bytes32 poolId, uint96 units) public view returns (uint128) { + IConfluere.PoolView memory v = core.pool(poolId); + return uint128( + Clearing.quoteUp(units, uint256(v.lastClearingPrice) * MARGIN_BPS, 10 ** uint256(v.assetDecimals) * BPS) + ); + } + + /// @notice Minimum lending fee: units at the last clearing price × minFeeBpsPerYear × time to expiry. + function minFee(bytes32 poolId, uint96 units) public view returns (uint128) { + IConfluere.PoolView memory v = core.pool(poolId); + if (block.timestamp >= v.key.expiry || minFeeBpsPerYear == 0) return 0; + uint256 value = Clearing.quoteUp(units, v.lastClearingPrice, 10 ** uint256(v.assetDecimals)); + return uint128(value * minFeeBpsPerYear * (v.key.expiry - block.timestamp) / (YEAR * BPS)); + } + + /// @notice Lending ends at expiry - LEND_CUTOFF (mainnet: Thursday 20:00 UTC of the expiry week). + function cutoff(bytes32 poolId) public view returns (uint256) { + return core.pool(poolId).key.expiry - core.LEND_CUTOFF(); + } + + /// @notice A loan with units still out at the cutoff is defaulted: the core has booked it as short and the + /// whole margin as forfeited. No transaction is needed for that. + function defaulted(uint256 loanId) external view returns (bool) { + Loan storage l = loans[loanId]; + return l.units != 0 && block.timestamp >= cutoff(l.poolId); + } + + /// @notice Lendable units left in a pool: half of its asset minus what is already out. + function available(bytes32 poolId) external view returns (uint256) { + IConfluere.PoolView memory v = core.pool(poolId); + if (v.key.kind != Kind.European || v.status != Status.Open) return 0; + uint256 cap = v.assetBal * 5000 / BPS; + return cap > v.lentUnits ? cap - v.lentUnits : 0; + } + + // ================================================================ actions + + /// @notice Borrow `units` of the pool's asset to `to`, posting `margin` (>= minMargin) and paying `fee` + /// (>= minFee) to the pool's C holders. Pulls margin + fee from the caller. + function open(bytes32 poolId, uint96 units, uint128 margin, uint128 fee, address to) + external + returns (uint256 loanId) + { + if (units == 0 || to == address(0) || margin < minMargin(poolId, units)) revert InvalidParams(); + if (fee < minFee(poolId, units)) revert FeeTooLow(); + IERC20 quote = IERC20(core.pool(poolId).key.quote); + uint256 amount = uint256(margin) + fee; + quote.safeTransferFrom(msg.sender, address(this), amount); + quote.forceApprove(address(core), amount); + core.lend(poolId, units, to, margin, fee); + loanId = nextId++; + loans[loanId] = Loan(msg.sender, poolId, units, margin); + emit Opened(loanId, poolId, msg.sender, units, margin, fee); + } + + /// @notice Voluntary margin top-up (anyone may add to a loan; it stays the borrower's margin). + function topUp(uint256 loanId, uint128 amount) external { + Loan storage l = loans[loanId]; + if (l.units == 0 || amount == 0) revert InvalidParams(); + IERC20 quote = IERC20(core.pool(l.poolId).key.quote); + quote.safeTransferFrom(msg.sender, address(this), amount); + quote.forceApprove(address(core), amount); + core.topUpLent(l.poolId, amount); + l.margin += amount; + emit ToppedUp(loanId, amount); + } + + /// @notice Return `units` of the asset before the cutoff. The margin share is released to the borrower's + /// `pending` on the core (withdraw from the core). Returning everything releases the whole margin. + function close(uint256 loanId, uint96 units) external { + Loan storage l = loans[loanId]; + if (msg.sender != l.borrower) revert NotBorrower(); + if (units == 0 || units > l.units) revert InvalidParams(); + uint128 marginOut = units == l.units ? l.margin : uint128(uint256(l.margin) * units / l.units); + l.units -= units; + l.margin -= marginOut; + IERC20 asset = IERC20(core.pool(l.poolId).key.asset); + asset.safeTransferFrom(msg.sender, address(this), units); + asset.forceApprove(address(core), units); + core.returnLent(l.poolId, units, marginOut, l.borrower); + emit Closed(loanId, units, marginOut); + } +} diff --git a/contracts/test/unit/Specials.t.sol b/contracts/test/unit/Specials.t.sol new file mode 100644 index 0000000..bb537a9 --- /dev/null +++ b/contracts/test/unit/Specials.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {Base} from "../utils/Base.sol"; +import {IConfluere} from "../../src/interfaces/IConfluere.sol"; +import {Kind} from "../../src/libraries/PoolKey.sol"; +import {Specials} from "../../src/periphery/Specials.sol"; +import {Router} from "../../src/periphery/Router.sol"; + +/// @notice Specials periphery against the real core (decision A, invariant 3b): per-loan accounting matches the +/// core's aggregate, partial / full return, top-up, cutoff, and repayment from forfeited margin after a default. +contract SpecialsTest is Base { + Specials internal sp; + bytes32 internal pid; + address internal frank = makeAddr("frank"); // a second borrower of the asset + + function setUp() public override { + sp = new Specials(address(this), 0); + specials = address(sp); + super.setUp(); + sp.bind(core); + pid = _create(K, Kind.European, E1); + _sell(alice, pid, 10 * ONE, 2900e6); + _buy(bob, pid, 10 * ONE, 2950e6); + _settle(pid); + assertEq(core.pool(pid).assetBal, 10 * ONE); + + address[2] memory bs = [dave, frank]; + for (uint256 i; i < 2; ++i) { + usdc.mint(bs[i], 1_000_000e6); + vm.startPrank(bs[i]); + usdc.approve(address(sp), type(uint256).max); + weth.approve(address(sp), type(uint256).max); + vm.stopPrank(); + } + } + + function _open(address who, uint96 units, uint128 fee) internal returns (uint256 id, uint128 margin) { + margin = sp.minMargin(pid, units); + vm.prank(who); + id = sp.open(pid, units, margin, fee, who); + } + + function _assertLedger(uint256[] memory ids) internal view { + uint256 u; + uint256 m; + for (uint256 i; i < ids.length; ++i) { + (,, uint96 lu, uint128 lm) = sp.loans(ids[i]); + u += lu; + m += lm; + } + IConfluere.PoolView memory v = core.pool(pid); + assertEq(u, v.lentUnits, "sum units"); + assertEq(m, v.lentMargin, "sum margin"); + } + + function test_bind_onlyOnce() public { + vm.expectRevert(Specials.AlreadyBound.selector); + sp.bind(core); + } + + function test_open_deliversAsset_feeToLenders() public { + uint256 qb = core.pool(pid).quoteBal; + uint256 w = weth.balanceOf(dave); + (uint256 id, uint128 margin) = _open(dave, uint96(2 * ONE), 5e6); + assertEq(weth.balanceOf(dave), w + 2 * ONE); + assertEq(core.pool(pid).quoteBal, qb + 5e6); + assertGe(uint256(margin) * 10_000, uint256(2) * core.pool(pid).lastClearingPrice * 12_000); + assertEq(usdc.balanceOf(address(sp)), 0); + uint256[] memory ids = new uint256[](1); + ids[0] = id; + _assertLedger(ids); + assertEq(sp.available(pid), 3 * ONE); + } + + function test_open_belowMinMargin_reverts() public { + uint128 m = sp.minMargin(pid, uint96(ONE)); + vm.prank(dave); + vm.expectRevert(Specials.InvalidParams.selector); + sp.open(pid, uint96(ONE), m - 1, 0, dave); + } + + function test_open_capHalf() public { + _open(dave, uint96(3 * ONE), 0); + uint128 m = sp.minMargin(pid, uint96(3 * ONE)); + vm.prank(frank); + vm.expectRevert(IConfluere.InvalidParams.selector); + sp.open(pid, uint96(3 * ONE), m, 0, frank); + } + + function test_open_american_reverts() public { + bytes32 a = _create(K, Kind.American, E1); + _sell(alice, a, 10 * ONE, 2900e6); + _buy(bob, a, 10 * ONE, 2950e6); + _settle(a); + uint128 m = sp.minMargin(a, uint96(ONE)); + vm.prank(dave); + vm.expectRevert(IConfluere.InvalidState.selector); + sp.open(a, uint96(ONE), m, 0, dave); + } + + function test_topUp_partialAndFullClose() public { + (uint256 a, uint128 ma) = _open(dave, uint96(2 * ONE), 0); + (uint256 b,) = _open(frank, uint96(ONE), 0); + vm.prank(dave); + sp.topUp(a, 100e6); + ma += 100e6; + uint256[] memory ids = new uint256[](2); + ids[0] = a; + ids[1] = b; + _assertLedger(ids); + + vm.prank(dave); + sp.close(a, uint96(ONE)); + assertEq(_pend(address(usdc), dave), ma / 2); + _assertLedger(ids); + + vm.prank(frank); + vm.expectRevert(Specials.NotBorrower.selector); + sp.close(a, uint96(ONE)); + + vm.prank(dave); + sp.close(a, uint96(ONE)); + assertEq(_pend(address(usdc), dave), ma); + vm.prank(frank); + sp.close(b, uint96(ONE)); + _assertLedger(ids); + assertEq(core.pool(pid).lentUnits, 0); + assertEq(core.pool(pid).lentMargin, 0); + assertEq(weth.balanceOf(address(core)), 10 * ONE); + } + + function test_close_afterCutoff_reverts_andDefaulted() public { + (uint256 a,) = _open(dave, uint96(2 * ONE), 0); + assertFalse(sp.defaulted(a)); + vm.warp(sp.cutoff(pid)); + assertTrue(sp.defaulted(a)); + vm.prank(dave); + vm.expectRevert(IConfluere.InvalidState.selector); + sp.close(a, uint96(ONE)); + vm.prank(dave); + vm.expectRevert(IConfluere.InvalidState.selector); + sp.topUp(a, 1e6); + } + + function test_overdue_repayGetsAssetThenMargin() public { + (, uint128 margin) = _open(dave, uint96(2 * ONE), 0); + vm.warp(E1); + core.mature(pid); + uint256 w = weth.balanceOf(alice); + uint256 p0 = _pend(address(usdc), alice); + vm.prank(alice); + (uint256 assetOut, uint256 marginOut) = core.repay(pid, uint96(10 * ONE)); + assertEq(assetOut, 8 * ONE); + assertEq(marginOut, margin); + assertEq(weth.balanceOf(alice), w + 8 * ONE); + assertEq(_pend(address(usdc), alice), p0 + margin); + } + + function test_router_repay_withShortUnits_forwardsMargin() public { + (, uint128 margin) = _open(dave, uint96(2 * ONE), 0); + Router r = new Router(core); + vm.warp(E1); + core.mature(pid); + vm.startPrank(alice); + core.setOperator(address(r), true); + usdc.approve(address(r), type(uint256).max); + uint256 w = weth.balanceOf(alice); + uint256 q = usdc.balanceOf(alice); + (uint256 assetOut, uint256 marginOut) = r.repay(pid, uint96(9 * ONE)); + vm.stopPrank(); + // 8 units are physical; the 9th is short: paid K, receives the whole margin (it is half of 2 short units) + assertEq(assetOut, 8 * ONE); + assertEq(marginOut, margin / 2); + assertEq(weth.balanceOf(alice), w + 8 * ONE); + uint256 pay = uint256(9) * K; // K per whole unit + assertEq(usdc.balanceOf(alice), q - pay + margin / 2); + assertEq(weth.balanceOf(address(r)), 0); + assertEq(usdc.balanceOf(address(r)), 0); + } +} From 3be33ff77c3bdcb4a584a24ce1d529214650c7fe Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:06:42 -0400 Subject: [PATCH 124/169] feat(sdk): v0.8 PoolKey, 1% log grid, Specials and autoSell views (sdk/src/src) PoolKey carries bandBps and cadence (pool id hashes them, vector checked against cast); Status gains Selling. strikeAt / isGridStrike / gridIndexOf copy Grid.sol's integer routine and assert the spec's vector table. missedBatchEnd defaults to two missed batches (seat slash rule). New views: lendMinMargin, shortMarginShare, repayOutcome, autoSellLimit, standardBand, tierKey. ABIs regenerated from the current core. Part 1/2 of: feat(sdk): v0.8 PoolKey, 1% log grid, Specials and autoSell views Files: packages/sdk/src/generated.ts, packages/sdk/src/ids.ts, packages/sdk/src/math.ts, packages/sdk/src/types.ts Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/generated.ts | 374 ++++++++++++++++++++++++++++------ packages/sdk/src/ids.ts | 6 + packages/sdk/src/math.ts | 118 ++++++++++- packages/sdk/src/types.ts | 8 +- 4 files changed, 430 insertions(+), 76 deletions(-) diff --git a/packages/sdk/src/generated.ts b/packages/sdk/src/generated.ts index 1baab99..8ac06d9 100644 --- a/packages/sdk/src/generated.ts +++ b/packages/sdk/src/generated.ts @@ -20,11 +20,18 @@ export const confluereAbi = [ { name: 'period', internalType: 'uint64', type: 'uint64' }, { name: 'batchInterval', internalType: 'uint64', type: 'uint64' }, { name: 'maxExpiries', internalType: 'uint32', type: 'uint32' }, - { name: 'strikeSigDigits', internalType: 'uint8', type: 'uint8' }, ], }, { name: 'maxOrdersPerSide', internalType: 'uint32', type: 'uint32' }, { name: 'maxBids', internalType: 'uint32', type: 'uint32' }, + { + name: 'autoSellDiscountBps', + internalType: 'uint16', + type: 'uint16', + }, + { name: 'autoSellTtl', internalType: 'uint16', type: 'uint16' }, + { name: 'lendCutoff', internalType: 'uint64', type: 'uint64' }, + { name: 'specials', internalType: 'address', type: 'address' }, { name: 'chains', internalType: 'struct Confluere.ChainParams[]', @@ -45,7 +52,7 @@ export const confluereAbi = [ type: 'address[]', }, { - name: 'graduationVolumes', + name: 'graduationFees', internalType: 'uint256[]', type: 'uint256[]', }, @@ -66,6 +73,20 @@ export const confluereAbi = [ ], stateMutability: 'nonpayable', }, + { + type: 'function', + inputs: [], + name: 'AUTOSELL_DISCOUNT_BPS', + outputs: [{ name: '', internalType: 'uint16', type: 'uint16' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'AUTOSELL_TTL', + outputs: [{ name: '', internalType: 'uint16', type: 'uint16' }], + stateMutability: 'view', + }, { type: 'function', inputs: [], @@ -80,6 +101,13 @@ export const confluereAbi = [ outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], stateMutability: 'view', }, + { + type: 'function', + inputs: [], + name: 'LEND_CUTOFF', + outputs: [{ name: '', internalType: 'uint64', type: 'uint64' }], + stateMutability: 'view', + }, { type: 'function', inputs: [], @@ -101,6 +129,13 @@ export const confluereAbi = [ outputs: [{ name: '', internalType: 'uint64', type: 'uint64' }], stateMutability: 'view', }, + { + type: 'function', + inputs: [], + name: 'SPECIALS', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, { type: 'function', inputs: [], @@ -208,46 +243,25 @@ export const confluereAbi = [ { name: 'expiry', internalType: 'uint64', type: 'uint64' }, { name: 'strike', internalType: 'uint128', type: 'uint128' }, { name: 'kind', internalType: 'enum Kind', type: 'uint8' }, + { name: 'bandBps', internalType: 'uint16', type: 'uint16' }, + { name: 'cadence', internalType: 'uint8', type: 'uint8' }, ], }, - { name: 'feeBps', internalType: 'uint16', type: 'uint16' }, - { name: 'bandBps', internalType: 'uint16', type: 'uint16' }, - ], - name: 'createPool', - outputs: [{ name: 'poolId', internalType: 'bytes32', type: 'bytes32' }], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [ - { - name: 'key', - internalType: 'struct PoolKey', - type: 'tuple', - components: [ - { name: 'asset', internalType: 'address', type: 'address' }, - { name: 'quote', internalType: 'address', type: 'address' }, - { name: 'expiry', internalType: 'uint64', type: 'uint64' }, - { name: 'strike', internalType: 'uint128', type: 'uint128' }, - { name: 'kind', internalType: 'enum Kind', type: 'uint8' }, - ], - }, - { name: 'feeBps', internalType: 'uint16', type: 'uint16' }, - { name: 'bandBps', internalType: 'uint16', type: 'uint16' }, + { name: 'strikeIndex', internalType: 'int16', type: 'int16' }, { name: 'opts', internalType: 'struct IConfluere.PoolOptions', type: 'tuple', components: [ - { name: 'cadence', internalType: 'uint8', type: 'uint8' }, { name: 'minFill', internalType: 'uint96', type: 'uint96' }, { name: 'firstLo', internalType: 'uint96', type: 'uint96' }, { name: 'firstHi', internalType: 'uint96', type: 'uint96' }, + { name: 'autoSell', internalType: 'bool', type: 'bool' }, ], }, ], - name: 'createPoolWithOptions', - outputs: [{ name: 'poolId', internalType: 'bytes32', type: 'bytes32' }], + name: 'createPool', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], stateMutability: 'nonpayable', }, { @@ -323,7 +337,7 @@ export const confluereAbi = [ { type: 'function', inputs: [{ name: 'quote', internalType: 'address', type: 'address' }], - name: 'graduationVolume', + name: 'graduationFee', outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], stateMutability: 'view', }, @@ -341,7 +355,6 @@ export const confluereAbi = [ { name: 'period', internalType: 'uint64', type: 'uint64' }, { name: 'batchInterval', internalType: 'uint64', type: 'uint64' }, { name: 'maxExpiries', internalType: 'uint32', type: 'uint32' }, - { name: 'strikeSigDigits', internalType: 'uint8', type: 'uint8' }, ], }, ], @@ -364,6 +377,29 @@ export const confluereAbi = [ outputs: [{ name: '', internalType: 'bool', type: 'bool' }], stateMutability: 'view', }, + { + type: 'function', + inputs: [ + { name: 'poolId', internalType: 'bytes32', type: 'bytes32' }, + { name: 'units', internalType: 'uint96', type: 'uint96' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'margin', internalType: 'uint128', type: 'uint128' }, + { name: 'fee', internalType: 'uint128', type: 'uint128' }, + ], + name: 'lend', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'poolId', internalType: 'bytes32', type: 'bytes32' }, + { name: 'orderId', internalType: 'uint64', type: 'uint64' }, + ], + name: 'linkedPool', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, { type: 'function', inputs: [ @@ -395,8 +431,12 @@ export const confluereAbi = [ type: 'tuple', components: [ { name: 'volume', internalType: 'uint128', type: 'uint128' }, - { name: 'minStrike', internalType: 'uint96', type: 'uint96' }, - { name: 'maxStrike', internalType: 'uint96', type: 'uint96' }, + { name: 'fees', internalType: 'uint128', type: 'uint128' }, + { name: 'mainVolume', internalType: 'uint128', type: 'uint128' }, + { name: 'mainStrike', internalType: 'uint96', type: 'uint96' }, + { name: 'mainIndex', internalType: 'int16', type: 'int16' }, + { name: 'mainBand', internalType: 'uint16', type: 'uint16' }, + { name: 'mainCadence', internalType: 'uint8', type: 'uint8' }, { name: 'bad', internalType: 'bool', type: 'bool' }, ], }, @@ -441,6 +481,7 @@ export const confluereAbi = [ outputs: [ { name: 'owner', internalType: 'address', type: 'address' }, { name: 'side', internalType: 'uint8', type: 'uint8' }, + { name: 'flags', internalType: 'uint8', type: 'uint8' }, { name: 'units', internalType: 'uint96', type: 'uint96' }, { name: 'limit', internalType: 'uint96', type: 'uint96' }, { name: 'escrow', internalType: 'uint128', type: 'uint128' }, @@ -535,33 +576,41 @@ export const confluereAbi = [ { name: 'expiry', internalType: 'uint64', type: 'uint64' }, { name: 'strike', internalType: 'uint128', type: 'uint128' }, { name: 'kind', internalType: 'enum Kind', type: 'uint8' }, + { name: 'bandBps', internalType: 'uint16', type: 'uint16' }, + { name: 'cadence', internalType: 'uint8', type: 'uint8' }, ], }, { name: 'status', internalType: 'enum Status', type: 'uint8' }, { name: 'assetDecimals', internalType: 'uint8', type: 'uint8' }, - { name: 'feeBps', internalType: 'uint16', type: 'uint16' }, - { name: 'bandBps', internalType: 'uint16', type: 'uint16' }, + { name: 'strikeIndex', internalType: 'int16', type: 'int16' }, { name: 'bandFailures', internalType: 'uint8', type: 'uint8' }, { name: 'settleCount', internalType: 'uint32', type: 'uint32' }, { name: 'lastSettledBatch', internalType: 'uint64', type: 'uint64' }, { name: 'windowEnd', internalType: 'uint64', type: 'uint64' }, { name: 'pausedUntil', internalType: 'uint64', type: 'uint64' }, + { name: 'pausedBlocks', internalType: 'uint64', type: 'uint64' }, { name: 'lastClearingPrice', internalType: 'uint96', type: 'uint96' }, { name: 'bandBase', internalType: 'uint96', type: 'uint96' }, { name: 'quoteBal', internalType: 'uint256', type: 'uint256' }, { name: 'assetBal', internalType: 'uint256', type: 'uint256' }, { name: 'surplusBal', internalType: 'uint256', type: 'uint256' }, - { name: 'vwap', internalType: 'uint256', type: 'uint256' }, + { name: 'feeEscrow', internalType: 'uint256', type: 'uint256' }, + { name: 'filledUnits', internalType: 'uint256', type: 'uint256' }, + { name: 'repaidUnits', internalType: 'uint256', type: 'uint256' }, { name: 'closeRemaining', internalType: 'uint256', type: 'uint256' }, + { name: 'lentUnits', internalType: 'uint128', type: 'uint128' }, + { name: 'lentMargin', internalType: 'uint128', type: 'uint128' }, + { name: 'shortUnits', internalType: 'uint128', type: 'uint128' }, + { name: 'shortMargin', internalType: 'uint128', type: 'uint128' }, { name: 'options', internalType: 'struct IConfluere.PoolOptions', type: 'tuple', components: [ - { name: 'cadence', internalType: 'uint8', type: 'uint8' }, { name: 'minFill', internalType: 'uint96', type: 'uint96' }, { name: 'firstLo', internalType: 'uint96', type: 'uint96' }, { name: 'firstHi', internalType: 'uint96', type: 'uint96' }, + { name: 'autoSell', internalType: 'bool', type: 'bool' }, ], }, { name: 'batchInterval', internalType: 'uint64', type: 'uint64' }, @@ -587,6 +636,21 @@ export const confluereAbi = [ { name: 'units', internalType: 'uint96', type: 'uint96' }, ], name: 'repay', + outputs: [ + { name: '', internalType: 'uint256', type: 'uint256' }, + { name: '', internalType: 'uint256', type: 'uint256' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'poolId', internalType: 'bytes32', type: 'bytes32' }, + { name: 'units', internalType: 'uint96', type: 'uint96' }, + { name: 'marginOut', internalType: 'uint128', type: 'uint128' }, + { name: 'marginTo', internalType: 'address', type: 'address' }, + ], + name: 'returnLent', outputs: [], stateMutability: 'nonpayable', }, @@ -605,16 +669,6 @@ export const confluereAbi = [ outputs: [{ name: '', internalType: 'uint64', type: 'uint64' }], stateMutability: 'nonpayable', }, - { - type: 'function', - inputs: [ - { name: 'poolId', internalType: 'bytes32', type: 'bytes32' }, - { name: 'orderId', internalType: 'uint64', type: 'uint64' }, - ], - name: 'rolloverSource', - outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], - stateMutability: 'view', - }, { type: 'function', inputs: [ @@ -639,6 +693,27 @@ export const confluereAbi = [ outputs: [{ name: '', internalType: 'bool', type: 'bool' }], stateMutability: 'pure', }, + { + type: 'function', + inputs: [ + { name: 'marketId', internalType: 'bytes32', type: 'bytes32' }, + { name: 'expiry', internalType: 'uint64', type: 'uint64' }, + { name: 'tierKey', internalType: 'uint256', type: 'uint256' }, + ], + name: 'tierVolume', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'poolId', internalType: 'bytes32', type: 'bytes32' }, + { name: 'amount', internalType: 'uint128', type: 'uint128' }, + ], + name: 'topUpLent', + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', inputs: [{ name: 'id', internalType: 'uint256', type: 'uint256' }], @@ -676,13 +751,6 @@ export const confluereAbi = [ outputs: [{ name: '', internalType: 'address', type: 'address' }], stateMutability: 'view', }, - { - type: 'function', - inputs: [{ name: 'poolId', internalType: 'bytes32', type: 'bytes32' }], - name: 'vwap', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, { type: 'function', inputs: [ @@ -753,6 +821,24 @@ export const confluereAbi = [ type: 'uint256', indexed: false, }, + { + name: 'spreadToEscrow', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'tradeFees', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'capBase', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, ], name: 'BatchSettled', }, @@ -824,6 +910,43 @@ export const confluereAbi = [ ], name: 'Claimed', }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'poolId', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: 'toTreasury', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'toLenders', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'repaidUnits', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'filledUnits', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'FeeEscrowSplit', + }, { type: 'event', anonymous: false, @@ -850,6 +973,83 @@ export const confluereAbi = [ ], name: 'FlashLoan', }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'poolId', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { name: 'to', internalType: 'address', type: 'address', indexed: true }, + { + name: 'units', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'margin', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { name: 'fee', internalType: 'uint256', type: 'uint256', indexed: false }, + ], + name: 'Lent', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'poolId', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: 'units', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'marginOut', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'marginTo', + internalType: 'address', + type: 'address', + indexed: false, + }, + ], + name: 'LentReturned', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'poolId', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: 'amount', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'LentToppedUp', + }, { type: 'event', anonymous: false, @@ -917,6 +1117,7 @@ export const confluereAbi = [ type: 'uint256', indexed: false, }, + { name: 'fee', internalType: 'uint256', type: 'uint256', indexed: false }, ], name: 'OrderFilled', }, @@ -996,6 +1197,12 @@ export const confluereAbi = [ type: 'uint64', indexed: false, }, + { + name: 'pausedBlocks', + internalType: 'uint64', + type: 'uint64', + indexed: false, + }, ], name: 'Paused', }, @@ -1019,19 +1226,15 @@ export const confluereAbi = [ { name: 'expiry', internalType: 'uint64', type: 'uint64' }, { name: 'strike', internalType: 'uint128', type: 'uint128' }, { name: 'kind', internalType: 'enum Kind', type: 'uint8' }, + { name: 'bandBps', internalType: 'uint16', type: 'uint16' }, + { name: 'cadence', internalType: 'uint8', type: 'uint8' }, ], indexed: false, }, { - name: 'feeBps', - internalType: 'uint16', - type: 'uint16', - indexed: false, - }, - { - name: 'bandBps', - internalType: 'uint16', - type: 'uint16', + name: 'strikeIndex', + internalType: 'int16', + type: 'int16', indexed: false, }, { @@ -1072,7 +1275,6 @@ export const confluereAbi = [ type: 'bytes32', indexed: true, }, - { name: 'cadence', internalType: 'uint8', type: 'uint8', indexed: false }, { name: 'minFill', internalType: 'uint96', @@ -1091,6 +1293,7 @@ export const confluereAbi = [ type: 'uint96', indexed: false, }, + { name: 'autoSell', internalType: 'bool', type: 'bool', indexed: false }, ], name: 'PoolOptionsSet', }, @@ -1122,7 +1325,18 @@ export const confluereAbi = [ type: 'uint256', indexed: false, }, - { name: 'fee', internalType: 'uint256', type: 'uint256', indexed: false }, + { + name: 'assetOut', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'marginOut', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, ], name: 'Repaid', }, @@ -1163,6 +1377,31 @@ export const confluereAbi = [ ], name: 'RolledOver', }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: 'poolId', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: 'shortUnits', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + { + name: 'shortMargin', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'ShortRecorded', + }, { type: 'event', anonymous: false, @@ -2065,7 +2304,10 @@ export const routerAbi = [ { name: 'units', internalType: 'uint96', type: 'uint96' }, ], name: 'repay', - outputs: [], + outputs: [ + { name: 'assetOut', internalType: 'uint256', type: 'uint256' }, + { name: 'marginOut', internalType: 'uint256', type: 'uint256' }, + ], stateMutability: 'nonpayable', }, { diff --git a/packages/sdk/src/ids.ts b/packages/sdk/src/ids.ts index d353d44..d4b9cf5 100644 --- a/packages/sdk/src/ids.ts +++ b/packages/sdk/src/ids.ts @@ -10,6 +10,8 @@ const KEY_ABI = [ { name: "expiry", type: "uint64" }, { name: "strike", type: "uint128" }, { name: "kind", type: "uint8" }, + { name: "bandBps", type: "uint16" }, + { name: "cadence", type: "uint8" }, ], }, ] as const; @@ -36,3 +38,7 @@ export function marketId(asset: Hex, quote: Hex, kind: Kind): Hex { ), ); } + +/** A tier of a market at one expiry: strike | bandBps << 128 | cadence << 144, as PoolKeyLib.tierKey. */ +export const tierKey = (strike: bigint, bandBps: number, cadence: number): bigint => + strike | (BigInt(bandBps) << 128n) | (BigInt(cadence) << 144n); diff --git a/packages/sdk/src/math.ts b/packages/sdk/src/math.ts index 3060727..c416145 100644 --- a/packages/sdk/src/math.ts +++ b/packages/sdk/src/math.ts @@ -98,12 +98,76 @@ export function impliedApr(price: bigint, strike: bigint, secondsToExpiry: numbe export const haircut = (price: bigint, strike: bigint): number => strike === 0n ? 0 : Number(strike - price) / Number(strike); -/** The contract's 2-significant-digit strike grid: strip trailing zeros, the rest must be < 10 ** digits. */ -export function isGridStrike(strike: bigint, digits = 2): boolean { - if (strike <= 0n) return false; - let s = strike; - while (s % 10n === 0n) s /= 10n; - return s < 10n ** BigInt(digits); +// ------------------------------------------------------------ 1% log strike grid (Grid.sol, bit-identical) + +export const GRID_RAY = 10n ** 27n; +export const GRID_STEP = 101n * 10n ** 25n; // 1.01 in RAY, exact +export const MAX_STRIKE_INDEX = 4095; +const UINT96_MAX = (1n << 96n) - 1n; + +/** 1.01^m in RAY by repeated squaring, every product rounded down; line-by-line copy of Grid.growth. */ +export function gridGrowth(m: number): bigint { + let r = GRID_RAY; + let b = GRID_STEP; + let k = BigInt(m); + while (k !== 0n) { + if ((k & 1n) === 1n) r = (r * b) / GRID_RAY; + k >>= 1n; + if (k !== 0n) b = (b * b) / GRID_RAY; + } + return r; +} + +/** Strike at grid index n: one whole quote unit times 1.01^n, rounded down (Grid.strikeAt). */ +export function strikeAt(n: number, quoteDecimals: number): bigint { + const unit = 10n ** BigInt(quoteDecimals); + return n >= 0 ? (unit * gridGrowth(n)) / GRID_RAY : (unit * GRID_RAY) / gridGrowth(-n); +} + +/** K is on the grid at index n: |n| <= 4095, 0 < K <= uint96 max, K == strikeAt(n) and n is the smallest such index. */ +export function isGridStrike(strike: bigint, n: number, quoteDecimals: number): boolean { + if (n > MAX_STRIKE_INDEX || n < -MAX_STRIKE_INDEX || strike <= 0n || strike > UINT96_MAX) return false; + if (strikeAt(n, quoteDecimals) !== strike) return false; + return n === -MAX_STRIKE_INDEX || strikeAt(n - 1, quoteDecimals) < strike; +} + +/** Smallest n with strikeAt(n) >= strike (Grid.indexOf binary search); `ok` tells whether strike is on the grid. */ +export function gridIndexOf(strike: bigint, quoteDecimals: number): { n: number; ok: boolean } { + let lo = -MAX_STRIKE_INDEX; + let hi = MAX_STRIKE_INDEX; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (strikeAt(mid, quoteDecimals) >= strike) hi = mid; + else lo = mid + 1; + } + return { n: lo, ok: isGridStrike(strike, lo, quoteDecimals) }; +} + +/** Canonical index of an on-grid strike, or undefined when the strike is off the grid. */ +export function strikeIndexOf(strike: bigint, quoteDecimals: number): number | undefined { + const { n, ok } = gridIndexOf(strike, quoteDecimals); + return ok ? n : undefined; +} + +/** The canonical grid strikes just below-or-at and just above `price` (quote base units per whole asset). */ +export function nearestGridStrikes(price: bigint, quoteDecimals: number): { below?: { n: number; strike: bigint }; above?: { n: number; strike: bigint } } { + const { n } = gridIndexOf(price, quoteDecimals); + const k = strikeAt(n, quoteDecimals); + const out: { below?: { n: number; strike: bigint }; above?: { n: number; strike: bigint } } = {}; + if (k === price) { + out.below = { n, strike: k }; + for (let m = n + 1; m <= MAX_STRIKE_INDEX; m++) { + const s = strikeAt(m, quoteDecimals); + if (s > k) { out.above = { n: m, strike: s }; break; } + } + } else { + if (k > price && k <= UINT96_MAX) out.above = { n: gridIndexOf(k, quoteDecimals).n, strike: k }; + if (n > -MAX_STRIKE_INDEX) { + const s = strikeAt(n - 1, quoteDecimals); + if (s > 0n) out.below = { n: gridIndexOf(s, quoteDecimals).n, strike: s }; + } + } + return out; } /** Next expiry strictly after `now` on the grid. */ @@ -116,13 +180,49 @@ export const nextBatchAt = (anchor: number, interval: number, lastSettledBatch: /** * End of the batch a seat holder missed, using the evidence pool's own batch length; mirrors SeatRegistry.slash. - * The missed batch is the pool's first full batch after the base batch of the holder's last quote. + * v0.8: the holder must miss `batches` (default 2) consecutive full batches of the pool after the base batch of the + * holder's last quote. */ -export function missedBatchEnd(anchor: number, baseInterval: number, lastQuotedBatch: number, poolInterval: number): number { +export function missedBatchEnd(anchor: number, baseInterval: number, lastQuotedBatch: number, poolInterval: number, batches = 2): number { const quotedEnd = anchor + (lastQuotedBatch + 1) * baseInterval; const start = anchor + Math.ceil((quotedEnd - anchor) / poolInterval) * poolInterval; - return start + poolInterval; + return start + batches * poolInterval; } /** Human label for a pool's batch cadence (0 / 1 / 2 -> every base interval, 12x, 48x). */ export const cadenceMultiplier = (cadence: number): number => (cadence === 0 ? 1 : cadence === 1 ? 12 : 48); + +/** Standard price band (bps) for a pair class: pegged 150, main 800, long tail 1500. */ +export const standardBand = (cls: "pegged" | "main" | "longtail"): number => (cls === "pegged" ? 150 : cls === "main" ? 800 : 1500); + +// ------------------------------------------------------------ Specials (in-kind lending, decision A) + +/** Minimum margin Specials must post to lend `units`: ceil(units * lastClearingPrice * 12000 / (scale * 10000)). */ +export const lendMinMargin = (units: bigint, lastPrice: bigint, scale: bigint): bigint => + (units * lastPrice * 12_000n + scale * BPS - 1n) / (scale * BPS); + +/** Quote margin a repayer gets for `uShort` overdue units: all of it for the last ones, else pro rata rounded down. */ +export function shortMarginShare(uShort: bigint, shortUnits: bigint, shortMargin: bigint): bigint { + if (uShort === 0n || shortUnits === 0n) return 0n; + return uShort >= shortUnits ? shortMargin : (shortMargin * uShort) / shortUnits; +} + +/** Physical asset a pool can hand out: assetBal - lentUnits - shortUnits. */ +export const physicalAsset = (assetBal: bigint, lentUnits: bigint, shortUnits: bigint): bigint => { + const x = assetBal - lentUnits - shortUnits; + return x > 0n ? x : 0n; +}; + +/** What repay(units) returns with overdue lending: asset first, the rest as K-equivalent margin compensation. */ +export function repayOutcome(units: bigint, assetBal: bigint, lentUnits: bigint, shortUnits: bigint, shortMargin: bigint): { assetOut: bigint; uShort: bigint; marginOut: bigint } { + const phys = physicalAsset(assetBal, lentUnits, shortUnits); + const assetOut = units < phys ? units : phys; + let uShort = units - assetOut; + if (uShort > shortUnits) uShort = shortUnits; + return { assetOut, uShort, marginOut: shortMarginShare(uShort, shortUnits, shortMargin) }; +} + +// ------------------------------------------------------------ autoSell (decision B) + +/** Limit of the pool-owned autoSell order: K * (10000 - discountBps) / 10000, rounded down. */ +export const autoSellLimit = (strike: bigint, discountBps: bigint | number): bigint => (strike * (BPS - BigInt(discountBps))) / BPS; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 0c61c7c..4deb363 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -50,6 +50,10 @@ export type PoolKey = { expiry: bigint; strike: bigint; kind: Kind; + /** Price band of the pool in bps: 150, 800 or 1500 (part of the pool id since v0.8). */ + bandBps: number; + /** Batch cadence: 0 / 1 / 2 -> 1 / 12 / 48 times the base interval (part of the pool id since v0.8). */ + cadence: number; }; export enum Kind { @@ -62,7 +66,9 @@ export enum Status { Open = 1, Window = 2, Closing = 3, - Closed = 4, + /** autoSell sub-state after the window: the defaulted asset rests as a sell order in the next expiry's pool. */ + Selling = 4, + Closed = 5, } export enum Side { From cf4ea8d498cf583656a3ff254846d782a3c15137 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:06:42 -0400 Subject: [PATCH 125/169] feat(sdk): v0.8 PoolKey, 1% log grid, Specials and autoSell views (sdk/test) Part 2/2 of: feat(sdk): v0.8 PoolKey, 1% log grid, Specials and autoSell views Files: packages/sdk/test/sdk.test.ts Co-Authored-By: Claude Opus 5.5 --- packages/sdk/test/sdk.test.ts | 81 +++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index 65916ce..2b39567 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -20,6 +20,15 @@ import { borrowerNetPerUnit, lenderCostPerUnit, isGridStrike, + strikeAt, + strikeIndexOf, + nearestGridStrikes, + tierKey, + lendMinMargin, + shortMarginShare, + repayOutcome, + autoSellLimit, + missedBatchEnd, rolloverTopUp, chainById, deployments, @@ -30,27 +39,19 @@ import { import { collect, render } from "../scripts/sync-deployments.mjs"; describe("ids", () => { - it("matches the pool ids the contracts created on anvil", () => { - const f = new URL("../../../deployments/anvil.json", import.meta.url); - if (!existsSync(f)) return; // only when a local deployment exists - const d = JSON.parse(readFileSync(f, "utf8")); - // CreatePools order: pair (lst/native, native/usdc) x strike (2) x kind (2) + it("hashes the v0.8 PoolKey (band and cadence) like PoolKeyLib.toId", () => { + // cast keccak $(cast abi-encode 'f((address,address,uint64,uint128,uint8,uint16,uint8))' '(0x..01,0x..02,1700000000,3010966302,1,800,2)') const id = poolId({ - asset: d.pools.lst, - quote: d.pools.native, - expiry: BigInt(d.pools.expiry), - strike: 98n * 10n ** 16n, - kind: Kind.American, - }); - expect(id).toBe(d.pools.poolIds[0]); - const id7 = poolId({ - asset: d.pools.native, - quote: d.pools.usdc, - expiry: BigInt(d.pools.expiry), - strike: 30_000_000n, + asset: "0x0000000000000000000000000000000000000001", + quote: "0x0000000000000000000000000000000000000002", + expiry: 1_700_000_000n, + strike: 3_010_966_302n, kind: Kind.European, + bandBps: 800, + cadence: 2, }); - expect(id7).toBe(d.pools.poolIds[7]); + expect(id).toBe("0x4fa5e8d03f4abcc004dcc405bdd3858c4474e9a52a40b90a56326246ab5419aa"); + expect(tierKey(3_010_966_302n, 800, 2)).toBe(3_010_966_302n | (800n << 128n) | (2n << 144n)); }); it("derives C and R ids from the low bit", () => { @@ -60,6 +61,8 @@ describe("ids", () => { expiry: 1n, strike: 1n, kind: Kind.American, + bandBps: 150, + cadence: 0, }); expect(cTokenId(pid) & 1n).toBe(0n); expect(rTokenId(pid)).toBe(cTokenId(pid) + 1n); @@ -107,10 +110,44 @@ describe("math", () => { expect(feeEscrowSplit(1000n, 1n, 3n)).toEqual({ toTreasury: 333n, toLenders: 667n }); expect(feeEscrowSplit(1000n, 0n, 0n)).toEqual({ toTreasury: 0n, toLenders: 1000n }); }); - it("knows the strike grid", () => { - expect(isGridStrike(3000_000000n)).toBe(true); - expect(isGridStrike(3150_000000n)).toBe(false); - expect(isGridStrike(98n * 10n ** 16n)).toBe(true); + it("computes the 1% log strike grid bit-identically to Grid.sol", () => { + const v6: [number, bigint][] = [ + [0, 1_000_000n], [1, 1_010_000n], [-1, 990_099n], [-2, 980_296n], [-3, 970_590n], [-384, 21_907n], + [434, 75_071_695n], [769, 2_104_439_470n], [773, 2_189_888_152n], [805, 3_010_966_302n], + [806, 3_041_075_965n], [1157, 99_960_740_922n], + ]; + for (const [n, k] of v6) { + expect(strikeAt(n, 6)).toBe(k); + expect(strikeIndexOf(k, 6)).toBe(n); + } + const v18: [number, bigint][] = [ + [-7, 932_718_054_707_135_373n], [-8, 923_483_222_482_312_251n], [-11, 896_323_717_517_805_234n], + [-453, 11_025_992_086_967_947n], + ]; + for (const [n, k] of v18) expect(strikeAt(n, 18)).toBe(k); + const max = (1n << 96n) - 1n; + expect(strikeAt(2522, 18) <= max).toBe(true); + expect(strikeAt(2523, 18) > max).toBe(true); + expect(isGridStrike(3_000_000_000n, 805, 6)).toBe(false); + expect(isGridStrike(3_010_966_302n, 805, 6)).toBe(true); + expect(strikeIndexOf(3_000_000_000n, 6)).toBeUndefined(); + // collisions at tiny strikes: only the smallest index is canonical + let n = -1400; + while (strikeAt(n, 6) !== strikeAt(n + 1, 6)) n++; + expect(isGridStrike(strikeAt(n, 6), n + 1, 6)).toBe(false); + expect(nearestGridStrikes(3_000_000_000n, 6)).toEqual({ + below: { n: 804, strike: strikeAt(804, 6) }, + above: { n: 805, strike: 3_010_966_302n }, + }); + }); + it("prices Specials margin and autoSell", () => { + expect(lendMinMargin(scale, 2000_000000n, scale)).toBe(2400_000000n); + expect(shortMarginShare(1n, 3n, 100n)).toBe(33n); + expect(shortMarginShare(3n, 3n, 100n)).toBe(100n); + expect(repayOutcome(5n, 10n, 0n, 7n, 70n)).toEqual({ assetOut: 3n, uShort: 2n, marginOut: 20n }); + expect(autoSellLimit(3_010_966_302n, 500)).toBe(2_860_417_986n); + expect(missedBatchEnd(0, 1800, 10, 1800)).toBe(13 * 1800); + expect(missedBatchEnd(0, 1800, 10, 1800, 1)).toBe(12 * 1800); }); it("matches the rollover top-up rule", () => { expect(rolloverTopUp(scale, 3000_000000n, 2850_000000n, 500, scale)).toBe(150_000000n + 427500n + 7_500000n + 2000n); From 8b6529ce69eb3f98e0ef46932f378181de56b593 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:06:43 -0400 Subject: [PATCH 126/169] feat(periphery): Looper intent accounts for multi-batch looping The core only trades in batches and its flash loan cannot re-enter it, so a loop cannot be atomic (spec 3.6, L). Looper deploys one LoopAccount per position: it owns its sell orders, R and core pending, so positions never mix. After each batch anyone may step(): collect proceeds, mint more asset through the owner's chosen minter (the asset's own mint path, never a DEX in the core), place the next sell. exit() cancels and sends asset, quote and R to the owner. Option 1 of spec 3.6, founder approval pending. Invariants: none in the core touched (periphery only, uses placeOrder / cancelOrder / withdraw as any user). Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/Looper.sol | 160 +++++++++++++++++++++++++++++ contracts/test/unit/Looper.t.sol | 82 +++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 contracts/src/periphery/Looper.sol create mode 100644 contracts/test/unit/Looper.t.sol diff --git a/contracts/src/periphery/Looper.sol b/contracts/src/periphery/Looper.sol new file mode 100644 index 0000000..b1e67cb --- /dev/null +++ b/contracts/src/periphery/Looper.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IConfluere} from "../interfaces/IConfluere.sol"; +import {PoolKeyLib} from "../libraries/PoolKey.sol"; +import {SELL} from "../libraries/OrderSet.sol"; + +/// @notice Converts quote into the pool's asset through the asset's own mint path (e.g. WETH -> wstETH via Lido +/// staking). Chosen by the position owner; the Looper never calls a DEX itself. +interface ILoopMinter { + function mint(address quote, address asset, uint256 amountIn, address to) external returns (uint256 assetOut); +} + +interface IConfluere6909Transfer { + function transfer(address receiver, uint256 id, uint256 amount) external returns (bool); + function balanceOf(address owner, uint256 id) external view returns (uint256); +} + +/// @notice One looping position (spec 3.6 option 1, founder approval pending). The core only trades in batches, so +/// "borrow -> mint -> borrow again" cannot happen inside one transaction (and the core's flash loan cannot re-enter +/// the core). The account instead owns its sell orders: after each batch anyone may call `step`, which collects the +/// proceeds, mints more asset through the owner's minter and places the next sell, until the rounds run out. R +/// tokens and every refund stay in the account (its own core `pending`, so positions never mix) until `exit`. +contract LoopAccount { + using SafeERC20 for IERC20; + using PoolKeyLib for bytes32; + + IConfluere public immutable core; + address public immutable owner; + bytes32 public immutable poolId; + ILoopMinter public immutable minter; + IERC20 public immutable asset; + IERC20 public immutable quote; + uint96 public immutable limit; // sell limit for every round + uint16 public immutable ttl; + + uint8 public roundsLeft; + uint64 public orderId; // live sell order, 0 when none + + event Stepped(uint8 roundsLeft, uint64 orderId, uint256 units); + event Exited(address indexed to, uint256 asset, uint256 quote, uint256 r); + + error NotOwner(); + error Busy(); + + constructor( + IConfluere core_, + address owner_, + bytes32 poolId_, + ILoopMinter minter_, + address asset_, + address quote_, + uint96 limit_, + uint16 ttl_, + uint8 rounds_ + ) { + core = core_; + owner = owner_; + poolId = poolId_; + minter = minter_; + asset = IERC20(asset_); + quote = IERC20(quote_); + limit = limit_; + ttl = ttl_; + roundsLeft = rounds_; + } + + /// @notice True while the current sell order is still on the book. + function live() public view returns (bool) { + if (orderId == 0) return false; + (address o,,,,,,,) = core.order(poolId, orderId); + return o == address(this); + } + + /// @notice Permissionless: once the previous order is off the book, collect, mint and place the next round. + function step() external { + if (live()) revert Busy(); + orderId = 0; + _collect(); + if (roundsLeft == 0) return; + uint256 q = quote.balanceOf(address(this)); + if (q != 0 && address(minter) != address(0)) { + quote.forceApprove(address(minter), q); + minter.mint(address(quote), address(asset), q, address(this)); + quote.forceApprove(address(minter), 0); + } + uint256 units = asset.balanceOf(address(this)); + if (units == 0 || units > type(uint96).max) return; + roundsLeft -= 1; + asset.forceApprove(address(core), units); + orderId = core.placeOrder( + poolId, + IConfluere.OrderParams({ + side: SELL, + units: uint96(units), + limit: limit, + budget: 0, + ttl: ttl, + hint: core.findHint(poolId, SELL, limit), + owner: address(this) + }) + ); + emit Stepped(roundsLeft, orderId, units); + } + + /// @notice Owner only: cancel the live order, stop looping and send every asset, quote and R unit to `to`. + function exit(address to) external { + if (msg.sender != owner) revert NotOwner(); + if (live()) core.cancelOrder(poolId, orderId); + orderId = 0; + roundsLeft = 0; + _collect(); + uint256 a = asset.balanceOf(address(this)); + uint256 q = quote.balanceOf(address(this)); + uint256 rId = poolId.rId(); + uint256 r = IConfluere6909Transfer(address(core)).balanceOf(address(this), rId); + if (a != 0) asset.safeTransfer(to, a); + if (q != 0) quote.safeTransfer(to, q); + if (r != 0) IConfluere6909Transfer(address(core)).transfer(to, rId, r); + emit Exited(to, a, q, r); + } + + function _collect() internal { + if (core.pending(address(quote), address(this)) != 0) core.withdraw(address(quote), address(this)); + if (core.pending(address(asset), address(this)) != 0) core.withdraw(address(asset), address(this)); + } +} + +/// @notice Opens looping positions: deploys a LoopAccount for the caller, funds it with the first asset tranche and +/// places round one. Holds nothing. +contract Looper { + using SafeERC20 for IERC20; + + IConfluere public immutable core; + + event Opened(address indexed owner, address indexed account, bytes32 indexed poolId, uint256 units, uint8 rounds); + + error InvalidParams(); + + constructor(IConfluere core_) { + core = core_; + } + + /// @param units first tranche of asset pulled from the caller + /// @param limit sell limit for every round (quote per asset unit) + /// @param rounds number of sell rounds including the first (1..32) + function open(bytes32 poolId, uint96 units, uint96 limit, uint16 ttl, uint8 rounds, ILoopMinter minter) + external + returns (LoopAccount acct) + { + if (units == 0 || limit == 0 || ttl == 0 || rounds == 0 || rounds > 32) revert InvalidParams(); + IConfluere.PoolView memory v = core.pool(poolId); + acct = new LoopAccount(core, msg.sender, poolId, minter, v.key.asset, v.key.quote, limit, ttl, rounds); + IERC20(v.key.asset).safeTransferFrom(msg.sender, address(acct), units); + acct.step(); + emit Opened(msg.sender, address(acct), poolId, units, rounds); + } +} diff --git a/contracts/test/unit/Looper.t.sol b/contracts/test/unit/Looper.t.sol new file mode 100644 index 0000000..3f60236 --- /dev/null +++ b/contracts/test/unit/Looper.t.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {Base} from "../utils/Base.sol"; +import {Kind} from "../../src/libraries/PoolKey.sol"; +import {Looper, LoopAccount, ILoopMinter} from "../../src/periphery/Looper.sol"; +import {MockERC20} from "../../src/mocks/MockERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @dev Stands in for an LST mint path: 1 asset per 3000 quote. +contract MockMinter is ILoopMinter { + function mint(address quote, address asset, uint256 amountIn, address to) external returns (uint256 out) { + IERC20(quote).transferFrom(msg.sender, address(this), amountIn); + out = amountIn * 1e18 / 3000e6; + MockERC20(asset).mint(to, out); + } +} + +contract LooperTest is Base { + Looper internal looper; + MockMinter internal minter; + bytes32 internal pid; + + function setUp() public override { + super.setUp(); + looper = new Looper(core); + minter = new MockMinter(); + pid = _create(K, Kind.American, E1); + vm.prank(alice); + weth.approve(address(looper), type(uint256).max); + } + + function test_loop_twoRounds_thenExit() public { + _buy(bob, pid, 100 * ONE, 2950e6); + vm.prank(alice); + LoopAccount acct = looper.open(pid, uint96(10 * ONE), 2900e6, 4, 2, minter); + assertTrue(acct.live()); + assertEq(acct.roundsLeft(), 1); + + _settle(pid); + assertFalse(acct.live()); + acct.step(); // anyone: collects ~29.5k USDC, mints ~9.8 ETH, sells again + assertTrue(acct.live()); + assertEq(acct.roundsLeft(), 0); + + vm.expectRevert(LoopAccount.Busy.selector); + acct.step(); + + _settle(pid); + acct.step(); // collects the last proceeds, no rounds left + assertFalse(acct.live()); + + vm.expectRevert(LoopAccount.NotOwner.selector); + acct.exit(bob); + + uint256 q = usdc.balanceOf(alice); + vm.prank(alice); + acct.exit(alice); + uint256 r = core.balanceOf(alice, _r(pid)); + assertGt(r, 19 * ONE); // 10 + ~9.8 units of repurchase right + assertGt(usdc.balanceOf(alice), q); + assertEq(core.balanceOf(address(acct), _r(pid)), 0); + assertEq(weth.balanceOf(address(acct)), 0); + assertEq(usdc.balanceOf(address(acct)), 0); + } + + function test_exit_cancelsLiveOrder() public { + uint256 w = weth.balanceOf(alice); + vm.startPrank(alice); + LoopAccount acct = looper.open(pid, uint96(10 * ONE), 2900e6, 4, 3, minter); + acct.exit(alice); + vm.stopPrank(); + assertEq(weth.balanceOf(alice), w); + assertFalse(acct.live()); + } + + function test_open_badParams_revert() public { + vm.prank(alice); + vm.expectRevert(Looper.InvalidParams.selector); + looper.open(pid, uint96(10 * ONE), 2900e6, 4, 0, minter); + } +} From d1aab43c94dcec1aa0b9ef803f061d7dbabb915c Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:08:25 -0400 Subject: [PATCH 127/169] feat(vault): main or high tier by volume, two traded expiries, autoSell pools, wash-steer guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vault targets last expiry's main tier (MainTier) or the highest tier within 40 grid steps with >= 20% of the main tier's volume (HighTier). The tier must have traded at the expiry before as well, and the target pool must be Open with autoSell. Vault guard: the reference is the main tier's clearing VWAP last expiry (tierVolume / filledUnits); high-tier candidates need a last clearing price within the main band of it, and the NC buy limit is min(K x maxPriceBps, K - 1, VWAP x (1 + band)). No reference, no investment. The order passes a findHint hint; budget is all-in (the core takes the buyer trade fee from it), so costBasis += budget stays exact. Test VaultTierTest reproduces a wash-steered tier (self-trades near its K with enough volume at two expiries) and shows the guard picking the honest high tier. Periphery only; no core invariant touched (§2.11 #7 pull refunds unchanged). Part 1/3 of: feat(vault): main or high tier by volume, two traded expiries, autoSell pools, wash-steer guard Files: contracts/script/CreateVaults.s.sol Co-Authored-By: Claude Opus 5.5 --- contracts/script/CreateVaults.s.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/script/CreateVaults.s.sol b/contracts/script/CreateVaults.s.sol index 863e4e1..07d9eb5 100644 --- a/contracts/script/CreateVaults.s.sol +++ b/contracts/script/CreateVaults.s.sol @@ -61,7 +61,7 @@ contract CreateVaults is Script { v[0] = _vault( f, me, - Vault.Strategy(native, a1, Kind.American, Vault.StrikeRule.LowestTraded, 2500, 9950), + Vault.Strategy(native, a1, Kind.American, Vault.StrikeRule.MainTier, 2500, 9950), "Confluere Pegged Loop Vault", "rvNATIVE-Pegged", bytes32("pegged") @@ -70,7 +70,7 @@ contract CreateVaults is Script { v[1] = _vault( f, me, - Vault.Strategy(usdc, a2, Kind.American, Vault.StrikeRule.LowestTraded, 2500, 9900), + Vault.Strategy(usdc, a2, Kind.American, Vault.StrikeRule.MainTier, 2500, 9900), "Confluere USD Low-Strike Vault", "rvUSD-NATIVE-Low", bytes32("usd-low") @@ -78,7 +78,7 @@ contract CreateVaults is Script { v[2] = _vault( f, me, - Vault.Strategy(usdc, a2, Kind.American, Vault.StrikeRule.HighestTraded, 2500, 9900), + Vault.Strategy(usdc, a2, Kind.American, Vault.StrikeRule.HighTier, 2500, 9900), "Confluere USD High-Strike Vault", "rvUSD-NATIVE-High", bytes32("usd-high") @@ -94,7 +94,7 @@ contract CreateVaults is Script { return _vault( f, me, - Vault.Strategy(quote, a, Kind.American, Vault.StrikeRule.LowestTraded, 2500, 9900), + Vault.Strategy(quote, a, Kind.American, Vault.StrikeRule.MainTier, 2500, 9900), "Confluere Native Lend Vault", "rvNATIVE-Lend", bytes32("native-lend") From 51e4845141d93a03f4965f7ca0bb3526998b1af8 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:08:25 -0400 Subject: [PATCH 128/169] feat(vault): main or high tier by volume, two traded expiries, autoSell pools, wash-steer guard Part 2/3 of: feat(vault): main or high tier by volume, two traded expiries, autoSell pools, wash-steer guard Files: contracts/src/periphery/Vault.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/Vault.sol | 89 ++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/contracts/src/periphery/Vault.sol b/contracts/src/periphery/Vault.sol index a5a52b1..a7c310a 100644 --- a/contracts/src/periphery/Vault.sol +++ b/contracts/src/periphery/Vault.sol @@ -19,7 +19,7 @@ import {IERC7575} from "@openzeppelin/community-contracts/interfaces/IERC7575.so import {IConfluere} from "../interfaces/IConfluere.sol"; import {PoolKey, PoolKeyLib, Kind, Status} from "../libraries/PoolKey.sol"; -import {GridConfig} from "../libraries/Grid.sol"; +import {GridConfig, Grid} from "../libraries/Grid.sol"; import {NC_BUY} from "../libraries/OrderSet.sol"; /// @title Vault @@ -38,9 +38,11 @@ contract Vault is ERC20, IERC7540, ReentrancyGuardTransient { using PoolKeyLib for PoolKey; using PoolKeyLib for bytes32; + /// MainTier: last expiry's tier (strike, band, cadence) with the most volume. HighTier: the highest strike within + /// `HIGH_SCAN` grid steps above the main tier whose volume is at least 20% of the main tier's. enum StrikeRule { - LowestTraded, - HighestTraded + MainTier, + HighTier } struct Strategy { @@ -82,6 +84,8 @@ contract Vault is ERC20, IERC7540, ReentrancyGuardTransient { event AssetsClaimed(address indexed account, address indexed asset, uint256 amount); uint256 internal constant ACC = 1e27; + uint256 internal constant HIGH_SCAN = 40; // grid steps scanned above the main tier (~ +49%) + uint256 internal constant HIGH_SHARE_BPS = 2000; // a high tier needs >= 20% of the main tier's volume uint256 internal constant BPS = 10_000; IConfluere public immutable core; @@ -155,14 +159,16 @@ contract Vault is ERC20, IERC7540, ReentrancyGuardTransient { uint256 budget = Math.mulDiv(investable(), perBatchCapBps, BPS); uint256 n = _assets.length; for (uint256 i; i < n; ++i) { - (bytes32 pid, uint96 strike, uint32 settleCount) = _target(_assets[i]); + (bytes32 pid, uint96 limit, uint32 settleCount) = _target(_assets[i]); if (pid == bytes32(0) || budget / n == 0 || pokedSettle[pid] > settleCount) continue; pokedSettle[pid] = settleCount + 1; // a ttl-1 order takes part in the pool's next settlement only IConfluere.OrderParams memory p; p.side = NC_BUY; + // all-in: the core takes the buyer's trade fee out of the budget, so costBasis += budget stays exact p.budget = uint128(budget / n); - p.limit = uint96(uint256(strike) * maxPriceBps / BPS); + p.limit = limit; p.ttl = 1; + p.hint = core.findHint(pid, NC_BUY, limit); uint64 id = core.placeOrder(pid, p); costBasis += budget / n; _track(pid, id); @@ -171,22 +177,79 @@ contract Vault is ERC20, IERC7540, ReentrancyGuardTransient { } /// @notice The pool the strategy targets for `asset` this epoch, or 0 if none qualifies. - /// The strike is last expiry's lowest (or highest) traded strike in that market; the market must be graduated. + /// The tier comes from last expiry's per-tier volume in a graduated market (main tier, or the highest tier with at + /// least 20% of its volume), must have traded at two consecutive expiries, and the pool must use autoSell. function targetPool(address token) public view returns (bytes32 pid) { (pid,,) = _target(token); } + /// @notice The price cap of this epoch's buys in `targetPool(token)` (0 if no target). + function targetLimit(address token) external view returns (uint96 limit) { + (, limit,) = _target(token); + } + + /// @dev Vault guard against wash-steering: the reference is the volume-weighted clearing price of last expiry's main + /// tier (the market's deepest tier, which a wash trade must outtrade to move). A tier qualifies only if its last + /// clearing price at that expiry lies within the main tier's band of the reference, and the vault's limit is + /// min(K x maxPriceBps, K - 1, reference x (1 + band)). No reference -> no investment. function _target(address token) internal view returns (bytes32, uint96, uint32) { bytes32 m = PoolKeyLib.marketId(token, _quote, kind); if (!core.graduated(m)) return (bytes32(0), 0, 0); - IConfluere.MarketStats memory st = core.marketStats(m, targetExpiry - core.grid().period); - // main tier of the last expiry (both rules until the high-tier scan lands in the periphery change) - uint96 strike = st.mainStrike; - if (strike == 0) return (bytes32(0), 0, 0); - bytes32 pid = PoolKey(token, _quote, targetExpiry, strike, kind, st.mainBand, st.mainCadence).toId(); + uint64 period = core.grid().period; + uint64 prev = targetExpiry - period; + IConfluere.MarketStats memory st = core.marketStats(m, prev); + if (st.mainVolume == 0) return (bytes32(0), 0, 0); + PoolKey memory pk = PoolKey(token, _quote, prev, st.mainStrike, kind, st.mainBand, st.mainCadence); + uint256 ref; + { + IConfluere.PoolView memory mv = core.pool(pk.toId()); + if (mv.filledUnits == 0) return (bytes32(0), 0, 0); + ref = Math.mulDiv(st.mainVolume, 10 ** mv.assetDecimals, mv.filledUnits); + } + if (ref == 0) return (bytes32(0), 0, 0); + uint256 cap = Math.mulDiv(ref, BPS + st.mainBand, BPS); + uint256 floor_ = Math.mulDiv(ref, BPS - st.mainBand, BPS); + uint128 strike = st.mainStrike; + if (strikeRule == StrikeRule.HighTier) { + uint128 hi = _highTier(m, pk, st.mainIndex, uint256(st.mainVolume) * HIGH_SHARE_BPS, floor_, cap); + if (hi != 0) strike = hi; + } + // two traded expiries in this tier + if (core.tierVolume(m, prev - period, PoolKeyLib.tierKey(strike, st.mainBand, st.mainCadence)) == 0) { + return (bytes32(0), 0, 0); + } + pk.expiry = targetExpiry; + pk.strike = strike; + bytes32 pid = pk.toId(); IConfluere.PoolView memory v = core.pool(pid); - if (v.status == Status.Open) return (pid, strike, v.settleCount); - return (bytes32(0), 0, 0); + if (v.status != Status.Open || !v.options.autoSell) return (bytes32(0), 0, 0); + uint256 limit = Math.min(Math.min(uint256(strike) * maxPriceBps / BPS, uint256(strike) - 1), cap); + if (limit == 0 || limit > type(uint96).max) return (bytes32(0), 0, 0); + return (pid, uint96(limit), v.settleCount); + } + + /// @dev Highest strike within HIGH_SCAN grid steps above the main tier (same band and cadence) with >= 20% of the + /// main tier's volume whose last clearing price at `pk.expiry` is inside [floor_, cap]; 0 if none. + /// `pk` is the main tier's key at the previous expiry (its strike is overwritten). + function _highTier(bytes32 m, PoolKey memory pk, int256 mainIndex, uint256 minVol, uint256 floor_, uint256 cap) + internal + view + returns (uint128) + { + for (uint256 j = HIGH_SCAN; j != 0; --j) { + if (mainIndex + int256(j) > Grid.MAX_STRIKE_INDEX) continue; + uint256 k = Grid.strikeAt(mainIndex + int256(j), IERC20Metadata(_quote).decimals()); + if (k == 0 || k > type(uint96).max) continue; + pk.strike = uint128(k); + if (_tierVolume(m, pk) * BPS < minVol) continue; + uint256 lcp = core.pool(pk.toId()).lastClearingPrice; + if (lcp >= floor_ && lcp <= cap) return uint128(k); + } + return 0; + } + + function _tierVolume(bytes32 m, PoolKey memory pk) internal view returns (uint256) { + return core.tierVolume(m, pk.expiry, PoolKeyLib.tierKey(pk.strike, pk.bandBps, pk.cadence)); } /// @notice Quote the vault may still invest this epoch: idle minus queued deposits, reserved redemptions and an From 166a6268010a1bb631d430e2023c635198cf97e3 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:08:25 -0400 Subject: [PATCH 129/169] feat(vault): main or high tier by volume, two traded expiries, autoSell pools, wash-steer guard Part 3/3 of: feat(vault): main or high tier by volume, two traded expiries, autoSell pools, wash-steer guard Files: contracts/test/unit/Periphery.t.sol, contracts/test/unit/Vault.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/Periphery.t.sol | 2 +- contracts/test/unit/Vault.t.sol | 87 +++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/contracts/test/unit/Periphery.t.sol b/contracts/test/unit/Periphery.t.sol index c054e45..ab3c1b9 100644 --- a/contracts/test/unit/Periphery.t.sol +++ b/contracts/test/unit/Periphery.t.sol @@ -168,7 +168,7 @@ contract PeripheryTest is Base { address[] memory as_ = new address[](1); as_[0] = address(weth); Vault v = f.create( - Vault.Strategy(address(usdc), as_, Kind.American, Vault.StrikeRule.HighestTraded, 2500, 9900), + Vault.Strategy(address(usdc), as_, Kind.American, Vault.StrikeRule.HighTier, 2500, 9900), "rvUSDC-ETH-High", "rvUSDC-ETH-High", bytes32(0) diff --git a/contracts/test/unit/Vault.t.sol b/contracts/test/unit/Vault.t.sol index 52e95ba..8f04320 100644 --- a/contracts/test/unit/Vault.t.sol +++ b/contracts/test/unit/Vault.t.sol @@ -7,6 +7,8 @@ import {IConfluere} from "../../src/interfaces/IConfluere.sol"; import {Kind, Status} from "../../src/libraries/PoolKey.sol"; import {NC_SELL, NC_BUY} from "../../src/libraries/OrderSet.sol"; import {Vault} from "../../src/periphery/Vault.sol"; +import {Grid} from "../../src/libraries/Grid.sol"; +import {PoolKeyLib} from "../../src/libraries/PoolKey.sol"; /// @dev Graduated WETH/USDC American market, target pool `p5` (cadence from `_cadence`), one vault. abstract contract VaultBase is Base { @@ -25,7 +27,7 @@ abstract contract VaultBase is Base { c.chains[0].graduationFees[0] = 0.8e6; // trade fees of one 1-ETH fill at 2900: 2 x 0.435 USDC } - function setUp() public override { + function setUp() public virtual override { super.setUp(); // four clean weeks graduate the WETH/USDC American market for (uint64 i = 1; i <= 4; ++i) { @@ -36,12 +38,12 @@ abstract contract VaultBase is Base { _sell(alice, pid, ONE, 2900e6); _buy(bob, pid, ONE, 2900e6); _settle(pid); + _extraTiers(e); } vm.warp(E4 + 1); core.graduate(address(weth), address(usdc), Kind.American, E4); - p5 = _cadence() == 0 - ? _create(K, Kind.American, E5) - : _createKey(core, _keyT(K, Kind.American, E5, 800, _cadence()), _noOpts()); + // vaults only invest in autoSell pools + p5 = _createKey(core, _keyT(K, Kind.American, E5, 800, _cadence()), IConfluere.PoolOptions(0, 0, 0, true)); address[] memory as_ = new address[](1); as_[0] = address(weth); @@ -51,7 +53,7 @@ abstract contract VaultBase is Base { quote: address(usdc), assets: as_, kind: Kind.American, - strikeRule: Vault.StrikeRule.LowestTraded, + strikeRule: Vault.StrikeRule.MainTier, perBatchCapBps: 2500, maxPriceBps: 9900 }), @@ -70,6 +72,9 @@ abstract contract VaultBase is Base { return 0; } + /// @dev Hook for more tiers traded at expiry `e` during setUp. + function _extraTiers(uint64 e) internal virtual {} + function _closeP5() internal { vm.warp(E5); core.mature(p5); @@ -102,7 +107,7 @@ abstract contract VaultBase is Base { } contract VaultTest is VaultBase { - function test_targetsLowestTradedStrikeInGraduatedMarket() public view { + function test_target_mainTier() public view { assertEq(vault.targetExpiry(), E5); assertEq(vault.targetPool(address(weth)), p5); } @@ -358,3 +363,73 @@ contract VaultLongTailTest is VaultBase { assertEq(10_000e6 - usdc.balanceOf(address(vault)), 2500e6 + 1875e6); } } + +/// @dev The wash-steer attack (blueprint 14.1): an attacker trades a far tier with itself at a price near its K, with +/// enough volume (>= 20% of the main tier) and at two expiries, to steer a HighTier vault into lending near K there. +/// The vault guard rejects tiers whose last clearing price is outside the main tier's band around the main VWAP. +contract VaultTierTest is VaultBase { + uint128 internal hiK; // honest high tier: 5 grid steps up, trades near the main price + uint128 internal washK; // attacker's tier: 30 grid steps up, self-trades at washK - 1 + bytes32 internal hi5; + bytes32 internal wash5; + Vault internal high; + + function _extraTiers(uint64 e) internal override { + if (e < ANCHOR + 3 * WEEK) return; + hiK = uint128(Grid.strikeAt(810, 6)); + washK = uint128(Grid.strikeAt(835, 6)); + bytes32 h = _createKey(core, _keyT(hiK, Kind.American, e, 800, 0), _noOpts()); + _sell(alice, h, 0.8e18, 2950e6); + _buy(bob, h, 0.8e18, 2950e6); + _settle(h); + bytes32 w = _createKey(core, _keyT(washK, Kind.American, e, 800, 0), _noOpts()); + _sell(dave, w, 0.5e18, washK - 1); + _buy(dave, w, 0.5e18, washK - 1); + _settle(w); + } + + function setUp() public override { + super.setUp(); + IConfluere.PoolOptions memory o = IConfluere.PoolOptions(0, 0, 0, true); + hi5 = _createKey(core, _keyT(hiK, Kind.American, E5, 800, 0), o); + wash5 = _createKey(core, _keyT(washK, Kind.American, E5, 800, 0), o); + address[] memory as_ = new address[](1); + as_[0] = address(weth); + high = new Vault( + core, Vault.Strategy(address(usdc), as_, Kind.American, Vault.StrikeRule.HighTier, 2500, 9900), "H", "H" + ); + } + + function test_washTierHasTheVolume() public view { + bytes32 m = PoolKeyLib.marketId(address(weth), address(usdc), Kind.American); + IConfluere.MarketStats memory st = core.marketStats(m, E4); + assertEq(st.mainStrike, K, "main tier unchanged"); + uint256 wv = core.tierVolume(m, E4, PoolKeyLib.tierKey(washK, 800, 0)); + assertGe(wv * 10_000, uint256(st.mainVolume) * 2000, "wash tier clears the 20% volume bar"); + assertGt( + core.tierVolume(m, ANCHOR + 3 * WEEK, PoolKeyLib.tierKey(washK, 800, 0)), 0, "and traded at two expiries" + ); + } + + function test_guard_stopsWashSteer_picksHonestHighTier() public view { + assertEq(high.targetPool(address(weth)), hi5, "highest in-band tier, not the wash tier"); + assertTrue(high.targetPool(address(weth)) != wash5); + // limit: min(K x 99%, K - 1, main VWAP x (1 + 8%)) + assertEq(high.targetLimit(address(weth)), uint96(uint256(2900e6) * 10_800 / 10_000)); + } + + function test_mainTier_limitIsKCap() public view { + assertEq(vault.targetPool(address(weth)), p5); + assertEq(vault.targetLimit(address(weth)), uint96(uint256(K) * 9900 / 10_000)); + } + + function test_target_needsAutoSell() public { + // a vault for the European market finds no autoSell pool: the market is not even graduated + address[] memory as_ = new address[](1); + as_[0] = address(weth); + Vault eu = new Vault( + core, Vault.Strategy(address(usdc), as_, Kind.European, Vault.StrikeRule.MainTier, 2500, 9900), "E", "E" + ); + assertEq(eu.targetPool(address(weth)), bytes32(0)); + } +} From 7afa5b866a7af0fbbc6ed7c3f6c51caa6c27fb82 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:08:57 -0400 Subject: [PATCH 130/169] feat(keeper): v0.8 PoolCreated, Selling close, autoSell-aware purge (keeper/src/src) PoolCreated carries the v0.8 key (bandBps, cadence) and strikeIndex; AutoSellPlaced marks the target pool touched. A Selling pool is due for closeWindow (finalises once its autoSell order is dead). order() now returns flags: only FLAG_ROLLOVER sells are checked against their old pool via linkedPool, so a live autoSell sell is never purged early. Seat slash needs two missed batches (SDK missedBatchEnd default). Graduation keeps asking the core, which now checks fee thresholds; Specials need no scheduled task (core books overdue units lazily). Part 1/2 of: feat(keeper): v0.8 PoolCreated, Selling close, autoSell-aware purge Files: services/keeper/src/discover.ts, services/keeper/src/plan.ts, services/keeper/src/tasks.ts Co-Authored-By: Claude Opus 5.5 --- services/keeper/src/discover.ts | 12 ++++++++++-- services/keeper/src/plan.ts | 4 +++- services/keeper/src/tasks.ts | 13 ++++++++----- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/services/keeper/src/discover.ts b/services/keeper/src/discover.ts index 356a212..8cb0569 100644 --- a/services/keeper/src/discover.ts +++ b/services/keeper/src/discover.ts @@ -2,11 +2,15 @@ import { parseAbiItem, type Address, type Hex } from "viem"; import type { Clients } from "./chain.js"; const poolCreated = parseAbiItem( - "event PoolCreated(bytes32 indexed poolId, (address asset, address quote, uint64 expiry, uint128 strike, uint8 kind) key, uint16 feeBps, uint16 bandBps, bytes32 indexed marketId)", + "event PoolCreated(bytes32 indexed poolId, (address asset, address quote, uint64 expiry, uint128 strike, uint8 kind, uint16 bandBps, uint8 cadence) key, int16 strikeIndex, bytes32 indexed marketId)", ); const orderPlaced = parseAbiItem( "event OrderPlaced(bytes32 indexed poolId, uint64 indexed orderId, address indexed owner, uint8 side, uint96 units, uint96 limit, uint128 escrow, uint32 deadline)", ); +/** v0.8 autoSell: a closed pool's defaulted asset rests as a core-owned sell order in the target pool's book. */ +const autoSellPlaced = parseAbiItem( + "event AutoSellPlaced(bytes32 indexed poolId, bytes32 indexed targetPoolId, uint64 orderId, uint256 units, uint256 limit)", +); const vaultCreated = parseAbiItem("event VaultCreated(address indexed vault, address indexed quote, string symbol)"); /** @@ -35,10 +39,14 @@ export class Discovery { while (this.from <= head) { const to = this.from + this.chunk - 1n < head ? this.from + this.chunk - 1n : head; const [p, v] = await Promise.all([ - this.c.pub.getLogs({ address: this.core, events: [poolCreated, orderPlaced], fromBlock: this.from, toBlock: to }), + this.c.pub.getLogs({ address: this.core, events: [poolCreated, orderPlaced, autoSellPlaced], fromBlock: this.from, toBlock: to }), this.c.pub.getLogs({ address: this.vaultFactory, event: vaultCreated, fromBlock: this.from, toBlock: to }), ]); for (const l of p) { + if (l.eventName === "AutoSellPlaced") { + if (l.args.targetPoolId) this.touched.add(l.args.targetPoolId); + continue; + } if (!l.args.poolId) continue; if (l.eventName === "PoolCreated") this.pools.add(l.args.poolId); else this.touched.add(l.args.poolId); diff --git a/services/keeper/src/plan.ts b/services/keeper/src/plan.ts index d6e9084..36101e5 100644 --- a/services/keeper/src/plan.ts +++ b/services/keeper/src/plan.ts @@ -28,7 +28,9 @@ export function dueAction(p: PoolSnapshot, nowS: number, block: bigint, settleDe return "none"; } if (p.status === Status.Window && block >= p.windowEnd) return "close"; - if (p.status === Status.Closing) return "close"; + // Closing: segmented abandonment auction; Selling: closeWindow finalises once the autoSell order is dead (a failing + // simulation only means the order still trades) + if (p.status === Status.Closing || p.status === Status.Selling) return "close"; return "none"; } diff --git a/services/keeper/src/tasks.ts b/services/keeper/src/tasks.ts index 926f06e..30a6369 100644 --- a/services/keeper/src/tasks.ts +++ b/services/keeper/src/tasks.ts @@ -19,6 +19,8 @@ export type Log = (msg: string, extra?: Record) => void; const ZERO32 = "0x0000000000000000000000000000000000000000000000000000000000000000"; const PURGE_CHUNK = 50; +/** OrderSet.FLAG_ROLLOVER: the order's units come from an old pool (linkedPool) and die with its window. */ +const FLAG_ROLLOVER = 1; export class Tasks { private lastPoke = new Map(); @@ -80,10 +82,11 @@ export class Tasks { const [head] = await this.c.pub.readContract({ address: core, abi: confluereAbi, functionName: "bookHead", args: [pid, side] }); for (let id = head, n = 0; id !== 0n && n < 1024; n++) { const o = await this.c.pub.readContract({ address: core, abi: confluereAbi, functionName: "order", args: [pid, id] }); - const bo: BookOrder = { id, side, deadline: Number(o[5]) }; - // only rollover sells can die through their old pool; skip the lookup when the order is dead anyway - if (side === Side.Sell && !tradingOver && bo.deadline > settleCount) { - const src = await this.c.pub.readContract({ address: core, abi: confluereAbi, functionName: "rolloverSource", args: [pid, id] }); + const bo: BookOrder = { id, side, deadline: Number(o[6]) }; + // only rollover sells (FLAG_ROLLOVER) can die through their old pool; autoSell sells (also linked) die by ttl or + // the target pool's expiry like any order. Skip the lookup when the order is dead anyway. + if (side === Side.Sell && (Number(o[2]) & FLAG_ROLLOVER) !== 0 && !tradingOver && bo.deadline > settleCount) { + const src = await this.c.pub.readContract({ address: core, abi: confluereAbi, functionName: "linkedPool", args: [pid, id] }); if (BigInt(src) !== 0n) { const old = oldPools.get(src) ?? (await this.oldPool(src)); oldPools.set(src, old); @@ -96,7 +99,7 @@ export class Tasks { } } orders.push(bo); - id = o[6]; + id = o[7]; } } const dead = deadOrders(orders, settleCount, tradingOver); From 16b835b195d849478ae83d60c03b6e2fc5227757 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:08:57 -0400 Subject: [PATCH 131/169] feat(keeper): v0.8 PoolCreated, Selling close, autoSell-aware purge (keeper/test) Part 2/2 of: feat(keeper): v0.8 PoolCreated, Selling close, autoSell-aware purge Files: services/keeper/test/discover.test.ts, services/keeper/test/mock.ts, services/keeper/test/plan.test.ts, services/keeper/test/tasks.test.ts Co-Authored-By: Claude Opus 5.5 --- services/keeper/test/discover.test.ts | 2 +- services/keeper/test/mock.ts | 13 +++++++------ services/keeper/test/plan.test.ts | 13 +++++++------ services/keeper/test/tasks.test.ts | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/services/keeper/test/discover.test.ts b/services/keeper/test/discover.test.ts index 048c968..e4b95f3 100644 --- a/services/keeper/test/discover.test.ts +++ b/services/keeper/test/discover.test.ts @@ -27,7 +27,7 @@ describe("Discovery", () => { const d = new Discovery(c, CORE, FACTORY, 0n, 100n); await d.scan(); expect(queries).toHaveLength(2); - expect(queries.find((q) => q.address === CORE)!.events!.map((e) => e.name)).toEqual(["PoolCreated", "OrderPlaced"]); + expect(queries.find((q) => q.address === CORE)!.events!.map((e) => e.name)).toEqual(["PoolCreated", "OrderPlaced", "AutoSellPlaced"]); expect([...d.pools]).toEqual([P1]); expect([...d.touched]).toEqual([P2]); }); diff --git a/services/keeper/test/mock.ts b/services/keeper/test/mock.ts index 96550e0..0c600d0 100644 --- a/services/keeper/test/mock.ts +++ b/services/keeper/test/mock.ts @@ -28,7 +28,7 @@ export type MockPool = { settleCount?: number; nextBatchAt: number; /** resting orders per side, head first */ - book?: Partial>; + book?: Partial>; }; export type MockVault = { @@ -86,7 +86,7 @@ export function mockChain(state: { switch (fn) { case "pool": return { - key: { asset: p?.asset ?? ZERO, quote: p?.quote ?? ZERO, expiry: BigInt(p?.expiry ?? 0), strike: 1n, kind: p?.kind ?? 0 }, + key: { asset: p?.asset ?? ZERO, quote: p?.quote ?? ZERO, expiry: BigInt(p?.expiry ?? 0), strike: 1n, kind: p?.kind ?? 0, bandBps: 800, cadence: 0 }, status: p?.status ?? Status.None, batchInterval: 0, windowEnd: p?.windowEnd ?? 0n, @@ -101,11 +101,12 @@ export function mockChain(state: { } case "order": { const f = orderOf(pid, args[1] as bigint); - if (!f) return [ZERO, 0, 0n, 0n, 0n, 0, 0n]; - return ["0x00000000000000000000000000000000000000aa", f.side, 1n, 1n, 1n, f.o.deadline, f.next]; + if (!f) return [ZERO, 0, 0, 0n, 0n, 0n, 0, 0n]; + const flags = f.o.rolloverSource ? 1 : f.o.autoSellSource ? 4 : 0; + return ["0x00000000000000000000000000000000000000aa", f.side, flags, 1n, 1n, 1n, f.o.deadline, f.next]; } - case "rolloverSource": - return orderOf(pid, args[1] as bigint)?.o.rolloverSource ?? ZERO32; + case "linkedPool": + return orderOf(pid, args[1] as bigint)?.o.rolloverSource ?? orderOf(pid, args[1] as bigint)?.o.autoSellSource ?? ZERO32; case "expiryWindowEnd": return state.expiryWindowEnd?.[Number(args[0])] ?? 0n; case "graduated": diff --git a/services/keeper/test/plan.test.ts b/services/keeper/test/plan.test.ts index 3256dcf..11b4de3 100644 --- a/services/keeper/test/plan.test.ts +++ b/services/keeper/test/plan.test.ts @@ -22,6 +22,7 @@ describe("dueAction", () => { expect(dueAction({ ...base, status: Status.Window, windowEnd: 100n }, 0, 99n, 5)).toBe("none"); expect(dueAction({ ...base, status: Status.Window, windowEnd: 100n }, 0, 100n, 5)).toBe("close"); expect(dueAction({ ...base, status: Status.Closing }, 0, 0n, 5)).toBe("close"); + expect(dueAction({ ...base, status: Status.Selling }, 0, 0n, 5)).toBe("close"); expect(dueAction({ ...base, status: Status.Closed }, 0, 0n, 5)).toBe("none"); }); }); @@ -36,13 +37,13 @@ describe("pokeDue", () => { }); describe("missedBatchEnd (mirrors SeatRegistry.slash)", () => { - it("mainstream pool: the base batch after the last quote", () => { - // quoted in base batch 10 (interval 1800): missed batch 11 ends at (12)*1800 - expect(missedBatchEnd(0, 1800, 10, 1800)).toBe(12 * 1800); + it("mainstream pool: two consecutive base batches after the last quote (v0.8)", () => { + // quoted in base batch 10 (interval 1800): missed batches 11 and 12, the second ends at 13*1800 + expect(missedBatchEnd(0, 1800, 10, 1800)).toBe(13 * 1800); }); - it("6 h long-tail pool: the first full 6 h batch after the quote", () => { - // quote ends at 11*1800 = 19800; next 6 h boundary is 21600; missed batch ends at 43200 - expect(missedBatchEnd(0, 1800, 10, 21600)).toBe(43200); + it("6 h long-tail pool: the first two full 6 h batches after the quote", () => { + // quote ends at 11*1800 = 19800; next 6 h boundary is 21600; two missed batches end at 64800 + expect(missedBatchEnd(0, 1800, 10, 21600)).toBe(64800); }); }); diff --git a/services/keeper/test/tasks.test.ts b/services/keeper/test/tasks.test.ts index ab4dd62..79b4a04 100644 --- a/services/keeper/test/tasks.test.ts +++ b/services/keeper/test/tasks.test.ts @@ -268,6 +268,20 @@ describe("keeper purge", () => { expect(w.map((c) => c.args)).toEqual([[pid(1), [7n]]]); }); + it("does not purge a live autoSell sell while its source pool is Selling", async () => { + const s = setup( + { + [pid(1)]: open(A, { settleCount: 5, book: { [Side.Sell]: [{ id: 7n, deadline: 50, autoSellSource: OLD }] } }), + [OLD]: open(A, { status: Status.Selling, expiry: NOW - 6000, windowEnd: 900n }), + }, + {}, + { passes: new Set(["purge"]) }, + ); + await s.run(); + expect(s.calls.filter((c) => c.method === "write" && c.fn === "purge")).toHaveLength(0); + expect(s.calls.filter((c) => c.fn === "linkedPool")).toHaveLength(0); + }); + it("retries a purge that failed instead of marking the pool done", async () => { let failures = 1; const s = setup({ [pid(1)]: open(A, { status: Status.Closed, book: { [Side.Buy]: [{ id: 9n, deadline: 99 }] } }) }, {}, { From 98794493787e90c3b9854c4ce3e94b1c30f4e459 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 132/169] feat(core): add the Specials lending interface with lazy short accounting (contracts/script) Decision A (2026-10-24). Specials (the only caller, Config.specials) may lend up to half of a European pool's asset before expiry - LEND_CUTOFF, against margin >= 120% of the units at the last clearing price that is escrowed in the core from the moment of lending (choice A1), and a lending fee that goes to the pool's C holders. returnLent (full return must release all margin, released margin goes to pending) and topUpLent work until the cutoff. After it the core lazily records the lent units and their whole margin as shortUnits / shortMargin at the first touch (repay, mature, closeWindow), so every repayer sees the same margin per unit. repay delivers asset still in the pool first; for short units the payer pays K and is credited shortMargin x units / shortUnits (the last takes the rest) via pending; repay now returns (assetOut, marginOut). Abandonment bids only take units that are here; short margin nobody claimed goes to C holders when the window ends. A rollover from a pool with lent or short units does not fill (choice A2). Config gains autoSellDiscountBps, autoSellTtl, lendCutoff, specials (the autoSell values are used by the autoSell change); the core exposes them as SPECIALS, LEND_CUTOFF, AUTOSELL_DISCOUNT_BPS, AUTOSELL_TTL. To stay well under EIP-170 the Params handed to the libraries are packed into three words (ParamsLib accessors): every forwarding call pushes three constants instead of twelve (core 17.2 KB). DeployConfig sets the cutoff per preset (12 h, daily 2 h, demo 10 min) and the autoSell defaults (90%, 48 settlements); Grad.volumes is now Grad.fees. Deploy binds the Specials periphery to the core. Events: Repaid(poolId, payer, units, quotePaid, assetOut, marginOut); Lent, LentReturned, LentToppedUp, ShortRecorded. PoolView gains lentUnits, lentMargin, shortUnits, shortMargin. Tests: SpecialsCore.t.sol (permissions, European only, 50% cap, 120% margin, cutoff, pause, partial and full return, top-up, lazy short, asset first then margin, last short takes all, bids only physical, forfeiture to C holders, rollover skipped); the invariant handler lends, returns, tops up and crosses the cutoff, with invariant_lending (3b) and the solvency equality counting lent / short units and margin. Invariants: 1 (asset side excludes lent and short units, quote side adds their margin), 2, 3b (new), 4 (lend takes margin), 5 (repay never fails for missing asset), 7 (lend is a new exit, to Specials' borrower). Part 1/9 of: feat(core): add the Specials lending interface with lazy short accounting Files: contracts/script/Deploy.s.sol, contracts/script/DeployConfig.sol Co-Authored-By: Claude Opus 5.5 --- contracts/script/Deploy.s.sol | 63 ++++++++++++++++++++++--------- contracts/script/DeployConfig.sol | 19 +++++++--- 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol index f998899..997af6c 100644 --- a/contracts/script/Deploy.s.sol +++ b/contracts/script/Deploy.s.sol @@ -9,6 +9,7 @@ import {ERC6909WrapperFactory} from "../src/periphery/ERC6909Wrapper.sol"; import {VaultFactory} from "../src/periphery/VaultFactory.sol"; import {KeeperReceiver} from "../src/periphery/KeeperReceiver.sol"; import {SeatRegistry} from "../src/periphery/SeatRegistry.sol"; +import {Specials} from "../src/periphery/Specials.sol"; import {Guardian} from "../src/periphery/Guardian.sol"; import {DeployConfig} from "./DeployConfig.sol"; import {MockERC20} from "../src/mocks/MockERC20.sol"; @@ -59,6 +60,7 @@ contract Deploy is Script { address keeperReceiver; address seatRegistry; address guardian; + address specials; } function run() external returns (Confluere core) { @@ -71,24 +73,18 @@ contract Deploy is Script { (, address deployer,) = vm.readCallers(); Tokens memory t = mocks ? _mockTokens(salt) : Tokens(address(0), address(0), address(0)); DeployConfig.Grad[7] memory grad = _grad(mocks, t); - // The core's treasury on each chain is that chain's SeatRegistry; precompute every row's so the core's init - // code (and address) is the same everywhere. - address[7] memory registries; - for (uint256 i; i < CHAIN_IDS.length; ++i) { - registries[i] = vm.computeCreate2Address(salt, keccak256(_registryInit(deployer, grad[i]))); - } - SeatRegistry reg = _deployRegistry(salt, deployer, grad); - for (uint256 i; i < CHAIN_IDS.length; ++i) { - if (CHAIN_IDS[i] == block.chainid) require(address(reg) == registries[i], "registry address mismatch"); - } + (address[7] memory registries, SeatRegistry reg) = _registries(salt, deployer, grad); Guardian g = _deployGuardian(salt, deployer); address guardian = address(g) != address(0) ? address(g) : vm.envAddress("GUARDIAN"); - Confluere.Config memory cfg = DeployConfig.build(preset, registries, guardian, grad); + Specials sp = _deploySpecials(salt, deployer); + Confluere.Config memory cfg = DeployConfig.build(preset, registries, guardian, grad, address(sp)); _preflight(cfg, preset); uint256 deployBlock; (core, deployBlock) = _deployCore(salt, cfg, reg, g); + _bindSpecials(sp, core); Periphery memory p = _periphery(IConfluere(address(core)), salt); p.seatRegistry = address(reg); + p.specials = address(sp); p.guardian = guardian; vm.stopBroadcast(); @@ -97,6 +93,38 @@ contract Deploy is Script { console2.log("Confluere", address(core)); } + /// @dev The core's treasury on each chain is that chain's SeatRegistry; precompute every row's so the core's init + /// code (and address) is the same everywhere, and deploy this chain's. + function _registries(bytes32 salt, address deployer, DeployConfig.Grad[7] memory grad) + internal + returns (address[7] memory registries, SeatRegistry reg) + { + for (uint256 i; i < CHAIN_IDS.length; ++i) { + registries[i] = vm.computeCreate2Address(salt, keccak256(_registryInit(deployer, grad[i]))); + } + reg = _deployRegistry(salt, deployer, grad); + for (uint256 i; i < CHAIN_IDS.length; ++i) { + if (CHAIN_IDS[i] == block.chainid) require(address(reg) == registries[i], "registry address mismatch"); + } + } + + function _bindSpecials(Specials sp, Confluere core) internal { + if (address(sp.core()) == address(0)) sp.bind(IConfluere(address(core))); + require(address(sp.core()) == address(core), "specials bound to another core"); + } + + /// @dev Specials (ARCH 2.6.1): CREATE2 with (deployer, SPECIALS_MIN_FEE_BPS), the same address on every chain, + /// deployed before the core (whose immutable SPECIALS names it) and bound to it afterwards. + function _deploySpecials(bytes32 salt, address deployer) internal returns (Specials sp) { + bytes memory init = abi.encodePacked( + type(Specials).creationCode, abi.encode(deployer, uint16(vm.envOr("SPECIALS_MIN_FEE_BPS", uint256(0)))) + ); + sp = Specials(vm.computeCreate2Address(salt, keccak256(init))); + if (address(sp).code.length == 0) { + sp = new Specials{salt: salt}(deployer, uint16(vm.envOr("SPECIALS_MIN_FEE_BPS", uint256(0)))); + } + } + /// @dev Guardian multisig (ARCH 8.2). Members are still open (blueprint 13 Q1); without GUARDIAN_MEMBERS nothing is /// deployed and GUARDIAN is used as is. function _deployGuardian(bytes32 salt, address deployer) internal returns (Guardian g) { @@ -235,12 +263,12 @@ contract Deploy is Script { for (uint256 i; i < CHAIN_IDS.length; ++i) { if (mocks) { g[i].quotes = new address[](2); - g[i].volumes = new uint256[](2); + g[i].fees = new uint256[](2); g[i].minNotionals = new uint256[](2); g[i].seatObligations = new uint256[](2); - (g[i].quotes[0], g[i].volumes[0], g[i].minNotionals[0], g[i].seatObligations[0]) = + (g[i].quotes[0], g[i].fees[0], g[i].minNotionals[0], g[i].seatObligations[0]) = (t.native, 1e18, 1e17, 1e18); - (g[i].quotes[1], g[i].volumes[1], g[i].minNotionals[1], g[i].seatObligations[1]) = + (g[i].quotes[1], g[i].fees[1], g[i].minNotionals[1], g[i].seatObligations[1]) = (t.usdc, 10e6, 1e6, 100e6); } else { g[i] = _parseGrad(vm.envOr(string.concat("GRAD_", vm.toString(CHAIN_IDS[i])), string(""))); @@ -251,20 +279,20 @@ contract Deploy is Script { function _parseGrad(string memory spec) internal pure returns (DeployConfig.Grad memory g) { if (bytes(spec).length == 0) { g.quotes = new address[](0); - g.volumes = new uint256[](0); + g.fees = new uint256[](0); g.minNotionals = new uint256[](0); g.seatObligations = new uint256[](0); return g; } string[] memory items = vm.split(spec, ","); g.quotes = new address[](items.length); - g.volumes = new uint256[](items.length); + g.fees = new uint256[](items.length); g.minNotionals = new uint256[](items.length); g.seatObligations = new uint256[](items.length); for (uint256 i; i < items.length; ++i) { string[] memory kv = vm.split(items[i], ":"); g.quotes[i] = vm.parseAddress(kv[0]); - g.volumes[i] = vm.parseUint(kv[1]); + g.fees[i] = vm.parseUint(kv[1]); g.minNotionals[i] = vm.parseUint(kv[2]); g.seatObligations[i] = vm.parseUint(kv[3]); } @@ -383,6 +411,7 @@ contract Deploy is Script { vm.serializeAddress(k, "keeperReceiver", p.keeperReceiver); vm.serializeAddress(k, "seatRegistry", p.seatRegistry); vm.serializeAddress(k, "guardian", p.guardian); + vm.serializeAddress(k, "specials", p.specials); string memory json = vm.serializeAddress(k, "confluere", address(core)); vm.writeJson(json, DeployConfig.deploymentsFile(vm)); } diff --git a/contracts/script/DeployConfig.sol b/contracts/script/DeployConfig.sol index d236d42..b399d6c 100644 --- a/contracts/script/DeployConfig.sol +++ b/contracts/script/DeployConfig.sol @@ -19,11 +19,12 @@ library DeployConfig { uint256 internal constant ETHEREUM = 1; uint256 internal constant SEPOLIA = 11_155_111; - /// @notice Per-chain quote-token parameters: graduation baseline (per-expiry volume, blueprint §10) and the - /// anti-dust minimum order notional (valued at K). Same index in all three arrays. + /// @notice Per-chain quote-token parameters: graduation baseline (treasury fees earned per expiry: trade fees at + /// clearing plus the treasury's spread share at close, blueprint §10) and the anti-dust minimum order notional + /// (valued at K). Same index in all arrays. struct Grad { address[] quotes; - uint256[] volumes; + uint256[] fees; uint256[] minNotionals; uint256[] seatObligations; // per-batch competitive notional a seat holder must post (blueprint §13 Q2 open) } @@ -43,7 +44,8 @@ library DeployConfig { /// HyperEVM testnet, anvil, Ethereum, Sepolia). The whole table is part of the init code, so pass the same table on /// every chain. New chains are appended, so the earlier rows keep their index and their bytes; the core's address /// still changes with every row added (docs/IMPLEMENTATION_NOTES.md). - function build(Preset preset, address[7] memory treasury, address guardian, Grad[7] memory grad) + /// @param specials the Specials periphery (CREATE2 address, the same on every chain): the only lending caller + function build(Preset preset, address[7] memory treasury, address guardian, Grad[7] memory grad, address specials) internal pure returns (Confluere.Config memory c) @@ -58,6 +60,13 @@ library DeployConfig { } c.maxOrdersPerSide = 128; // ~6.6M gas worst case: fits Monad blocks and HyperEVM big blocks c.maxBids = 64; + // autoSell (blueprint 3.6): defaulted asset is offered at 90% of K for 48 settlements of the next expiry's pool + // (deploy parameters, blueprint 13 Q3 still open). Lending ends 12 h before expiry (Thursday 20:00 UTC for the + // Friday 08:00 grid); 2 h on the daily grid, 10 min on the 1-hour demo grid. + c.autoSellDiscountBps = 9000; + c.autoSellTtl = 48; + c.lendCutoff = demo ? 10 minutes : preset == Preset.Daily ? 2 hours : 12 hours; + c.specials = specials; c.chains = new Confluere.ChainParams[](7); // windowBlocks: ~2h (demo: ~5 min). Monad measured ~0.3 s/block, HyperEVM small blocks 1 s, anvil 1 s, // Ethereum and Sepolia 12 s slots (a missed slot only lengthens the window). maxPauseBlocks: 7 days of blocks. @@ -82,7 +91,7 @@ library DeployConfig { p.guardian = guardian; // Empty means no market can graduate on that chain (and vaults never invest there), and no order minimum. p.graduationQuotes = g.quotes; - p.graduationFees = g.volumes; + p.graduationFees = g.fees; p.minNotionalQuotes = g.quotes; p.minNotionals = g.minNotionals; } From e8bd9606180b5881c3919c6bcfd31b6cffd936a3 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 133/169] feat(core): add the Specials lending interface with lazy short accounting (Confluere.sol) Part 2/9 of: feat(core): add the Specials lending interface with lazy short accounting Files: contracts/src/Confluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/Confluere.sol | 63 +++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/contracts/src/Confluere.sol b/contracts/src/Confluere.sol index b4e15ef..0fdce3e 100644 --- a/contracts/src/Confluere.sol +++ b/contracts/src/Confluere.sol @@ -49,6 +49,15 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen uint32 public immutable MAX_BIDS; address public immutable treasury; address public immutable guardian; + uint16 public immutable AUTOSELL_DISCOUNT_BPS; + uint16 public immutable AUTOSELL_TTL; + uint64 public immutable LEND_CUTOFF; + address public immutable SPECIALS; + + // Params for the logic libraries, packed (see Params in State.sol) + uint256 internal immutable PARAMS_A; + uint256 internal immutable PARAMS_B; + uint256 internal immutable PARAMS_C; CoreState internal s; @@ -70,6 +79,10 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen GridConfig grid; uint32 maxOrdersPerSide; uint32 maxBids; + uint16 autoSellDiscountBps; // autoSell limit = K x this / 10_000 + uint16 autoSellTtl; // settlements an autoSell order may take part in + uint64 lendCutoff; // lending ends at expiry - lendCutoff (mainnet 12 h: Thursday 20:00 UTC) + address specials; // the Specials periphery, the only caller of the lending entry points ChainParams[] chains; } @@ -77,7 +90,11 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen constructor(Config memory c) { Grid.validate(c.grid); - if (c.maxOrdersPerSide == 0 || c.maxBids == 0) revert InvalidParams(); + if ( + c.maxOrdersPerSide == 0 || c.maxBids == 0 || c.autoSellDiscountBps == 0 || c.autoSellDiscountBps > 10_000 + || c.autoSellTtl == 0 || c.autoSellTtl > 1000 || c.lendCutoff >= c.grid.period + || c.specials == address(0) + ) revert InvalidParams(); uint256 k = type(uint256).max; for (uint256 i; i < c.chains.length; ++i) { if (c.chains[i].chainId == block.chainid) k = i; @@ -98,6 +115,16 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen MAX_BIDS = c.maxBids; treasury = cp.treasury; guardian = cp.guardian; + if (cp.maxPauseBlocks > type(uint32).max || c.lendCutoff > type(uint32).max) revert InvalidParams(); + PARAMS_A = uint256(uint160(cp.treasury)) | uint256(c.maxOrdersPerSide) << 160 | uint256(c.maxBids) << 192 + | uint256(c.autoSellDiscountBps) << 224 | uint256(c.autoSellTtl) << 240; + PARAMS_B = uint256(cp.windowBlocks) | uint256(c.grid.anchor) << 64 | uint256(c.grid.batchInterval) << 128 + | uint256(c.grid.period) << 192; + PARAMS_C = uint256(uint160(c.specials)) | uint256(cp.maxPauseBlocks) << 160 | uint256(c.lendCutoff) << 192; + AUTOSELL_DISCOUNT_BPS = c.autoSellDiscountBps; + AUTOSELL_TTL = c.autoSellTtl; + LEND_CUTOFF = c.lendCutoff; + SPECIALS = c.specials; for (uint256 i; i < cp.graduationQuotes.length; ++i) { s.graduationFee[cp.graduationQuotes[i]] = cp.graduationFees[i]; } @@ -168,8 +195,23 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen } /// @inheritdoc IConfluere - function repay(bytes32 poolId, uint96 units) external nonReentrant { - WindowLogic.repay(s, poolId, units); + function repay(bytes32 poolId, uint96 units) external nonReentrant returns (uint256, uint256) { + return WindowLogic.repay(s, _params(), poolId, units); + } + + /// @inheritdoc IConfluere + function lend(bytes32 poolId, uint96 units, address to, uint128 margin, uint128 fee) external nonReentrant { + WindowLogic.lend(s, _params(), poolId, units, to, margin, fee); + } + + /// @inheritdoc IConfluere + function returnLent(bytes32 poolId, uint96 units, uint128 marginOut, address marginTo) external nonReentrant { + WindowLogic.returnLent(s, _params(), poolId, units, marginOut, marginTo); + } + + /// @inheritdoc IConfluere + function topUpLent(bytes32 poolId, uint128 amount) external nonReentrant { + WindowLogic.topUpLent(s, _params(), poolId, amount); } /// @inheritdoc IConfluere @@ -325,6 +367,10 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen v.filledUnits = ps.filledUnits; v.repaidUnits = ps.repaidUnits; v.closeRemaining = ps.closeRemaining; + v.lentUnits = ps.lentUnits; + v.lentMargin = ps.lentMargin; + v.shortUnits = ps.shortUnits; + v.shortMargin = ps.shortMargin; v.options = PoolOptions(ps.minFill, ps.firstLo, ps.firstHi, ps.autoSell); v.batchInterval = uint64(ps.interval(GRID_BATCH)); } @@ -413,16 +459,7 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen // ================================================================ internals function _params() internal view returns (Params memory) { - return Params( - treasury, - MAX_ORDERS_PER_SIDE, - MAX_BIDS, - WINDOW_BLOCKS, - GRID_ANCHOR, - GRID_BATCH, - GRID_PERIOD, - MAX_PAUSE_BLOCKS - ); + return Params(PARAMS_A, PARAMS_B, PARAMS_C); } /// @dev R units locked in a pending rollover cannot move. From 0ef14c6e2f4bdd13fc841d79f70cbbb103d1a74d Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 134/169] feat(core): add the Specials lending interface with lazy short accounting (IConfluere.sol) Part 3/9 of: feat(core): add the Specials lending interface with lazy short accounting Files: contracts/src/interfaces/IConfluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/interfaces/IConfluere.sol | 33 +++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/contracts/src/interfaces/IConfluere.sol b/contracts/src/interfaces/IConfluere.sol index 0d42ec6..01f53c9 100644 --- a/contracts/src/interfaces/IConfluere.sol +++ b/contracts/src/interfaces/IConfluere.sol @@ -46,6 +46,10 @@ interface IConfluere { uint256 filledUnits; uint256 repaidUnits; uint256 closeRemaining; + uint128 lentUnits; + uint128 lentMargin; + uint128 shortUnits; + uint128 shortMargin; PoolOptions options; uint64 batchInterval; } @@ -85,7 +89,20 @@ interface IConfluere { uint256 tradeFees, uint256 capBase // competitive-only crossing volume q0: non-competitive units are capped at half of it ); - event Repaid(bytes32 indexed poolId, address indexed payer, uint256 units, uint256 quotePaid); + /// @dev assetOut units of asset were delivered; for the rest (lent units not returned by the cutoff) the payer + /// was credited marginOut of forfeited margin (pending, pull). + event Repaid( + bytes32 indexed poolId, + address indexed payer, + uint256 units, + uint256 quotePaid, + uint256 assetOut, + uint256 marginOut + ); + event Lent(bytes32 indexed poolId, address indexed to, uint256 units, uint256 margin, uint256 fee); + event LentReturned(bytes32 indexed poolId, uint256 units, uint256 marginOut, address marginTo); + event LentToppedUp(bytes32 indexed poolId, uint256 amount); + event ShortRecorded(bytes32 indexed poolId, uint256 shortUnits, uint256 shortMargin); event FeeEscrowSplit( bytes32 indexed poolId, uint256 toTreasury, uint256 toLenders, uint256 repaidUnits, uint256 filledUnits ); @@ -120,7 +137,14 @@ interface IConfluere { function cancelOrder(bytes32 poolId, uint64 orderId) external; function purge(bytes32 poolId, uint64[] calldata orderIds) external; function settleBatch(bytes32 poolId) external; - function repay(bytes32 poolId, uint96 units) external; + function repay(bytes32 poolId, uint96 units) external returns (uint256 assetOut, uint256 marginOut); + /// @notice Specials only: lend `units` of a European pool's asset to `to` against `margin` (>= 120% of units at + /// the last clearing price) and a lending fee that goes to the pool's C holders. Before the cutoff only. + function lend(bytes32 poolId, uint96 units, address to, uint128 margin, uint128 fee) external; + /// @notice Specials only: return lent units before the cutoff and release `marginOut` to `marginTo` (pending). + function returnLent(bytes32 poolId, uint96 units, uint128 marginOut, address marginTo) external; + /// @notice Specials only: add margin to the pool's lent position before the cutoff. + function topUpLent(bytes32 poolId, uint128 amount) external; function mature(bytes32 poolId) external; function bid(bytes32 poolId, uint96 units, uint96 surplusPerUnit, uint64 hint) external returns (uint64 orderId); function closeWindow(bytes32 poolId, uint256 maxBids) external returns (bool done); @@ -170,4 +194,9 @@ interface IConfluere { function lockedR(address account, bytes32 poolId) external view returns (uint256); function grid() external view returns (GridConfig memory); function WINDOW_BLOCKS() external view returns (uint64); + function MAX_PAUSE_BLOCKS() external view returns (uint64); + function SPECIALS() external view returns (address); + function LEND_CUTOFF() external view returns (uint64); + function AUTOSELL_DISCOUNT_BPS() external view returns (uint16); + function AUTOSELL_TTL() external view returns (uint16); } From ed782a2f3d3a4fe15506acedfb9c0c0ddd848974 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 135/169] feat(core): add the Specials lending interface with lazy short accounting (State.sol) Part 4/9 of: feat(core): add the Specials lending interface with lazy short accounting Files: contracts/src/libraries/State.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/State.sol | 78 +++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/contracts/src/libraries/State.sol b/contracts/src/libraries/State.sol index c00cf30..1472a9e 100644 --- a/contracts/src/libraries/State.sol +++ b/contracts/src/libraries/State.sol @@ -35,6 +35,12 @@ struct PoolState { uint256 filledUnits; // units ever filled (= C minted) uint256 repaidUnits; // units whose K was paid in full: repay, rollover, abandonment-auction fill uint256 closeRemaining; // unrepaid R units still open to bids during Closing + // Specials lending (European pools only, ARCH 2.6.1). The asset part of assetBal that is physically here is + // assetBal - lentUnits - shortUnits. + uint128 lentUnits; // lent out through Specials, returnable until the cutoff + uint128 lentMargin; // their margin (quote), escrowed here from the moment of lending + uint128 shortUnits; // lent units not returned by the cutoff, recorded lazily at the first touch after it + uint128 shortMargin; // the forfeited margin: pays repayers who paid K for units that are not here } /// @notice The whole storage of the singleton. Logic libraries receive it by storage reference. @@ -61,16 +67,66 @@ struct CoreState { uint64 nextOrderId; } -/// @notice Deploy-time constants the logic libraries need (immutables live in the core contract). +/// @notice Deploy-time constants the logic libraries need (immutables live in the core contract), packed into three +/// words so every forwarding call in the core pushes three constants instead of twelve (core bytecode size). +/// a: treasury | maxOrdersPerSide << 160 | maxBids << 192 | autoSellDiscountBps << 224 | autoSellTtl << 240 +/// b: windowBlocks | anchor << 64 | batchInterval << 128 | period << 192 +/// c: specials | maxPauseBlocks << 160 | lendCutoff << 192 struct Params { - address treasury; - uint32 maxOrdersPerSide; - uint32 maxBids; - uint64 windowBlocks; - uint64 anchor; // grid anchor - uint64 batchInterval; // base batch interval - uint64 period; // distance between consecutive expiries - uint64 maxPauseBlocks; // guardian pause budget per pool + uint256 a; + uint256 b; + uint256 c; +} + +/// @notice Accessors for the packed Params. +library ParamsLib { + function treasury(Params memory p) internal pure returns (address) { + return address(uint160(p.a)); + } + + function maxOrdersPerSide(Params memory p) internal pure returns (uint32) { + return uint32(p.a >> 160); + } + + function maxBids(Params memory p) internal pure returns (uint32) { + return uint32(p.a >> 192); + } + + function autoSellDiscountBps(Params memory p) internal pure returns (uint16) { + return uint16(p.a >> 224); + } + + function autoSellTtl(Params memory p) internal pure returns (uint16) { + return uint16(p.a >> 240); + } + + function windowBlocks(Params memory p) internal pure returns (uint64) { + return uint64(p.b); + } + + function anchor(Params memory p) internal pure returns (uint64) { + return uint64(p.b >> 64); + } + + function batchInterval(Params memory p) internal pure returns (uint64) { + return uint64(p.b >> 128); + } + + function period(Params memory p) internal pure returns (uint64) { + return uint64(p.b >> 192); + } + + function specials(Params memory p) internal pure returns (address) { + return address(uint160(p.c)); + } + + function maxPauseBlocks(Params memory p) internal pure returns (uint32) { + return uint32(p.c >> 160); + } + + function lendCutoff(Params memory p) internal pure returns (uint32) { + return uint32(p.c >> 192); + } } /// @notice Helpers shared by the core and every logic library. Internal, so each library embeds its own copy. @@ -145,7 +201,7 @@ library CoreLib { Order storage o = os[id]; uint8 side = o.side; Book storage b = s.books[poolId][side]; - uint32 max = side == BID ? p.maxBids : p.maxOrdersPerSide; + uint32 max = side == BID ? ParamsLib.maxBids(p) : ParamsLib.maxOrdersPerSide(p); if (OrderSet.sorted(side)) { if (b.count >= max) { // Full book: evict the worst order if it has expired, or if the new order beats it on price, or @@ -234,6 +290,8 @@ library CoreLib { if (o.deadline <= sc) return false; if (o.flags & FLAG_ROLLOVER == 0) return true; PoolState storage op = s.pools[s.linkedPool[poolId][id]]; + // the old pool must hold every unit it backs: no rollover while lent units are out or recorded short + if (op.lentUnits != 0 || op.shortUnits != 0) return false; return windowOpen(s, op) || (op.status == Status.Open && block.timestamp < op.key.expiry && nextBoundary >= op.key.expiry); } From 3fbcb56fdfdcee37948ee7c5dc0612813f172540 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 136/169] feat(core): add the Specials lending interface with lazy short accounting (libraries/logic) Part 5/9 of: feat(core): add the Specials lending interface with lazy short accounting Files: contracts/src/libraries/logic/ClearingLogic.sol, contracts/src/libraries/logic/OrderLogic.sol, contracts/src/libraries/logic/WindowLogic.sol Co-Authored-By: Claude Opus 5.5 --- .../src/libraries/logic/ClearingLogic.sol | 11 +- contracts/src/libraries/logic/OrderLogic.sol | 9 +- contracts/src/libraries/logic/WindowLogic.sol | 143 ++++++++++++++++-- 3 files changed, 142 insertions(+), 21 deletions(-) diff --git a/contracts/src/libraries/logic/ClearingLogic.sol b/contracts/src/libraries/logic/ClearingLogic.sol index 50b0ddf..3099bba 100644 --- a/contracts/src/libraries/logic/ClearingLogic.sol +++ b/contracts/src/libraries/logic/ClearingLogic.sol @@ -7,7 +7,7 @@ import {Order, SELL, BUY, NC_SELL, NC_BUY, FLAG_ROLLOVER} from "../OrderSet.sol" import {Clearing} from "../Clearing.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Settlement} from "../Settlement.sol"; -import {CoreState, PoolState, Params, CoreLib} from "../State.sol"; +import {CoreState, PoolState, Params, ParamsLib, CoreLib} from "../State.sol"; /// @notice Uniform-price batch clearing. Linked library; runs in the core's context. /// @@ -21,6 +21,7 @@ import {CoreState, PoolState, Params, CoreLib} from "../State.sol"; library ClearingLogic { using CoreLib for CoreState; using CoreLib for PoolState; + using ParamsLib for Params; using PoolKeyLib for bytes32; uint256 internal constant NC_SHARE_BPS = 5000; @@ -59,8 +60,8 @@ library ClearingLogic { function settleBatch(CoreState storage s, Params memory pr, bytes32 poolId) external { PoolState storage ps = s.pool(poolId); ps.requireTrading(); - uint256 iv = ps.interval(pr.batchInterval); - uint64 batchIndex = uint64((block.timestamp - pr.anchor) / iv); + uint256 iv = ps.interval(pr.batchInterval()); + uint64 batchIndex = uint64((block.timestamp - pr.anchor()) / iv); if (batchIndex <= ps.lastSettledBatch) revert IConfluere.BatchNotReady(); ps.lastSettledBatch = batchIndex; @@ -68,10 +69,10 @@ library ClearingLogic { bt.poolId = poolId; bt.scale = ps.scale(); bt.sc = ps.settleCount; - bt.treasury = pr.treasury; + bt.treasury = pr.treasury(); bt.quote = ps.key.quote; bt.strike = ps.key.strike; - bt.nextBoundary = pr.anchor + (uint256(batchIndex) + 1) * iv; + bt.nextBoundary = pr.anchor() + (uint256(batchIndex) + 1) * iv; ps.settleCount = bt.sc + 1; for (uint8 side; side <= NC_BUY; ++side) { diff --git a/contracts/src/libraries/logic/OrderLogic.sol b/contracts/src/libraries/logic/OrderLogic.sol index 2497b01..ff3d8b6 100644 --- a/contracts/src/libraries/logic/OrderLogic.sol +++ b/contracts/src/libraries/logic/OrderLogic.sol @@ -10,12 +10,13 @@ import {Order, SELL, BUY, NC_SELL, NC_BUY, BID, FLAG_ROLLOVER, FLAG_LOCKED} from import {Clearing} from "../Clearing.sol"; import {Settlement} from "../Settlement.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; -import {CoreState, PoolState, Params, CoreLib} from "../State.sol"; +import {CoreState, PoolState, Params, ParamsLib, CoreLib} from "../State.sol"; /// @notice Order entry, cancellation and abandonment-auction bids. Linked library; runs in the core's context. library OrderLogic { using CoreLib for CoreState; using CoreLib for PoolState; + using ParamsLib for Params; using PoolKeyLib for bytes32; uint16 internal constant MAX_TTL = 1000; @@ -197,9 +198,9 @@ library OrderLogic { ps.key.cadence != 0 && o.side != BID && o.deadline > ps.settleCount && ps.status == Status.Open && block.timestamp < ps.key.expiry ) { - uint256 iv = ps.interval(pr.batchInterval); - uint256 cur = (block.timestamp - pr.anchor) / iv; - uint256 next = pr.anchor + (cur + 1) * iv; + uint256 iv = ps.interval(pr.batchInterval()); + uint256 cur = (block.timestamp - pr.anchor()) / iv; + uint256 next = pr.anchor() + (cur + 1) * iv; bool due = cur > ps.lastSettledBatch && block.number >= ps.pausedUntil; if (due || next - block.timestamp <= iv / 12) revert IConfluere.InvalidState(); } diff --git a/contracts/src/libraries/logic/WindowLogic.sol b/contracts/src/libraries/logic/WindowLogic.sol index 4b672a8..dd54891 100644 --- a/contracts/src/libraries/logic/WindowLogic.sol +++ b/contracts/src/libraries/logic/WindowLogic.sol @@ -6,24 +6,28 @@ import {PoolKeyLib, Kind, Status} from "../PoolKey.sol"; import {Order, Book, BID} from "../OrderSet.sol"; import {Clearing} from "../Clearing.sol"; import {Settlement} from "../Settlement.sol"; -import {CoreState, PoolState, Params, CoreLib} from "../State.sol"; +import {CoreState, PoolState, Params, ParamsLib, CoreLib} from "../State.sol"; /// @notice Repayment, the abandonment auction at window close, and final claims. Linked library. library WindowLogic { using CoreLib for CoreState; using CoreLib for PoolState; + using ParamsLib for Params; using PoolKeyLib for bytes32; uint8 internal constant GRADUATION_CYCLES = 4; + uint256 internal constant LEND_MAX_BPS = 5000; // at most half of a pool's asset may be lent + uint256 internal constant LEND_MARGIN_BPS = 12_000; // margin >= 120% of the lent units at the last clearing price /// @notice Start (or join) the expiry's shared repayment window. function mature(CoreState storage s, Params memory pr, bytes32 poolId) external { PoolState storage ps = s.pool(poolId); if (ps.status != Status.Open || block.timestamp < ps.key.expiry) revert IConfluere.InvalidState(); + _syncShort(ps, pr, poolId); ps.status = Status.Window; // one window per expiry: the first mature() of an expiry starts it, later pools join it uint64 end = s.windowEnd[ps.key.expiry]; - if (end == 0) s.windowEnd[ps.key.expiry] = end = uint64(block.number) + pr.windowBlocks; + if (end == 0) s.windowEnd[ps.key.expiry] = end = uint64(block.number) + pr.windowBlocks(); ps.windowEnd = end; emit IConfluere.Matured(poolId, end); } @@ -33,7 +37,7 @@ library WindowLogic { function pause(CoreState storage s, Params memory pr, bytes32 poolId, uint64 blocks) external { PoolState storage ps = s.pool(poolId); uint256 total = uint256(ps.pausedBlocks) + blocks; - if (s.graduated[ps.marketId] || blocks == 0 || total > pr.maxPauseBlocks) revert IConfluere.InvalidParams(); + if (s.graduated[ps.marketId] || blocks == 0 || total > pr.maxPauseBlocks()) revert IConfluere.InvalidParams(); if (block.number < ps.pausedUntil) revert IConfluere.IsPaused(); uint64 until = uint64(block.number) + blocks; ps.pausedUntil = until; @@ -50,12 +54,14 @@ library WindowLogic { bytes32 m = PoolKeyLib.marketId(asset, quote, kind); uint256 threshold = s.graduationFee[quote]; if ( - s.graduated[m] || threshold == 0 || lastExpiry < pr.anchor || (lastExpiry - pr.anchor) % pr.period != 0 - || lastExpiry >= block.timestamp + s.graduated[m] || threshold == 0 || lastExpiry < pr.anchor() + || (lastExpiry - pr.anchor()) % pr.period() != 0 || lastExpiry >= block.timestamp ) revert IConfluere.InvalidParams(); - if (lastExpiry < pr.anchor + uint256(pr.period) * (GRADUATION_CYCLES - 1)) revert IConfluere.InvalidParams(); + if (lastExpiry < pr.anchor() + uint256(pr.period()) * (GRADUATION_CYCLES - 1)) { + revert IConfluere.InvalidParams(); + } for (uint256 i; i < GRADUATION_CYCLES; ++i) { - IConfluere.MarketStats storage st = s.stats[m][uint64(lastExpiry - i * pr.period)]; + IConfluere.MarketStats storage st = s.stats[m][uint64(lastExpiry - i * pr.period())]; if (st.bad || st.fees < threshold) revert IConfluere.InvalidState(); } s.graduated[m] = true; @@ -64,7 +70,12 @@ library WindowLogic { /// @notice Pay K per whole asset (rounded up) to buy back `units`. American: any time until the window ends. /// European: only inside the window. All of K goes to C holders; the spread share was taken at clearing. - function repay(CoreState storage s, bytes32 poolId, uint96 units) external { + /// Asset still in the pool is delivered first; for units recorded short (lent and not returned by the cutoff) the + /// payer is credited their share of the forfeited margin instead (pending). Never reverts for missing asset. + function repay(CoreState storage s, Params memory pr, bytes32 poolId, uint96 units) + external + returns (uint256 assetOut, uint256 marginOut) + { PoolState storage ps = s.pool(poolId); bool early = ps.key.kind == Kind.American && ps.status == Status.Open && block.timestamp < ps.key.expiry; if (!(early || s.windowOpen(ps))) { @@ -72,15 +83,112 @@ library WindowLogic { } if (units == 0) revert IConfluere.InvalidParams(); if (s.freeR(msg.sender, poolId) < units) revert IConfluere.Locked(); + _syncShort(ps, pr, poolId); + uint256 phys = ps.assetBal - ps.shortUnits - ps.lentUnits; + assetOut = units < phys ? units : phys; + uint256 uShort = units - assetOut; + if (uShort != 0) { + uint256 su = ps.shortUnits; + marginOut = uShort == su ? ps.shortMargin : Settlement.share(uShort, ps.shortMargin, su); + ps.shortUnits = uint128(su - uShort); + ps.shortMargin -= uint128(marginOut); + } uint256 pay = Settlement.repayAmount(units, ps.key.strike, ps.scale()); s.burn(msg.sender, poolId.rId(), units); ps.quoteBal += pay; ps.assetBal -= units; ps.repaidUnits += units; + if (marginOut != 0) s.pending[ps.key.quote][msg.sender] += marginOut; CoreLib.pullExact(ps.key.quote, pay); - CoreLib.push(ps.key.asset, msg.sender, units); - emit IConfluere.Repaid(poolId, msg.sender, units, pay); + if (assetOut != 0) CoreLib.push(ps.key.asset, msg.sender, assetOut); + emit IConfluere.Repaid(poolId, msg.sender, units, pay, assetOut, marginOut); + } + + // ------------------------------------------------------------ Specials lending (European pools, ARCH 2.6.1) + + /// @notice Lend up to half of a European pool's asset through Specials, against margin escrowed here. + function lend( + CoreState storage s, + Params memory pr, + bytes32 poolId, + uint96 units, + address to, + uint128 margin, + uint128 fee + ) external { + PoolState storage ps = _lendable(s, pr, poolId); + if (ps.key.kind != Kind.European || ps.status != Status.Open || block.number < ps.pausedUntil) { + revert IConfluere.InvalidState(); + } + uint256 last = ps.lastClearingPrice; + uint256 lent = uint256(ps.lentUnits) + units; + if (units == 0 || last == 0 || to == address(0) || lent > ps.assetBal * LEND_MAX_BPS / Clearing.BPS) { + revert IConfluere.InvalidParams(); + } + if (margin < Clearing.quoteUp(units, last * LEND_MARGIN_BPS, ps.scale() * Clearing.BPS)) { + revert IConfluere.InvalidParams(); + } + CoreLib.pullExact(ps.key.quote, uint256(margin) + fee); + ps.lentUnits = uint128(lent); + ps.lentMargin += margin; + ps.quoteBal += fee; // the lending fee belongs to the C holders; the protocol takes none + CoreLib.push(ps.key.asset, to, units); + emit IConfluere.Lent(poolId, to, units, margin, fee); + } + + /// @notice Specials returns lent units before the cutoff; the released margin goes to `marginTo` (pending). + function returnLent( + CoreState storage s, + Params memory pr, + bytes32 poolId, + uint96 units, + uint128 marginOut, + address marginTo + ) external { + PoolState storage ps = _lendable(s, pr, poolId); + uint128 lent = ps.lentUnits; + uint128 m = ps.lentMargin; + if (units == 0 || units > lent || marginOut > m || (units == lent && marginOut != m)) { + revert IConfluere.InvalidParams(); + } + CoreLib.pullExact(ps.key.asset, units); + ps.lentUnits = lent - units; + ps.lentMargin = m - marginOut; + if (marginOut != 0) s.pending[ps.key.quote][marginTo] += marginOut; + emit IConfluere.LentReturned(poolId, units, marginOut, marginTo); + } + + /// @notice Specials adds margin to the pool's lent position before the cutoff. + function topUpLent(CoreState storage s, Params memory pr, bytes32 poolId, uint128 amount) external { + PoolState storage ps = _lendable(s, pr, poolId); + if (ps.lentUnits == 0 || amount == 0) revert IConfluere.InvalidParams(); + CoreLib.pullExact(ps.key.quote, amount); + ps.lentMargin += amount; + emit IConfluere.LentToppedUp(poolId, amount); + } + + /// @dev Caller is Specials and the pool is before its lending cutoff. + function _lendable(CoreState storage s, Params memory pr, bytes32 poolId) + private + view + returns (PoolState storage ps) + { + if (msg.sender != pr.specials()) revert IConfluere.NotOwner(); + ps = s.pool(poolId); + if (block.timestamp >= ps.key.expiry - pr.lendCutoff()) revert IConfluere.InvalidState(); + } + + /// @dev Lazily records lent units that missed the cutoff as short, with their whole margin (choice A1): from then + /// on the numbers never change, so every repayer sees the same margin per short unit. + function _syncShort(PoolState storage ps, Params memory pr, bytes32 poolId) private { + uint128 lent = ps.lentUnits; + if (lent == 0 || block.timestamp < ps.key.expiry - pr.lendCutoff()) return; + ps.shortUnits += lent; + ps.shortMargin += ps.lentMargin; + ps.lentUnits = 0; + ps.lentMargin = 0; + emit IConfluere.ShortRecorded(poolId, ps.shortUnits, ps.shortMargin); } /// @notice Segmented close: each call fills at most `maxBids` bids from the top, then refunds them. @@ -93,7 +201,18 @@ library WindowLogic { if (ps.status == Status.Window) { if (block.number < ps.windowEnd) revert IConfluere.InvalidState(); ps.status = Status.Closing; - ps.closeRemaining = s.totalSupply[poolId.rId()]; + _syncShort(ps, pr, poolId); + // bids only take units that are here; short units nobody repaid default to C holders as their margin + uint256 su = ps.shortUnits; + uint256 r = s.totalSupply[poolId.rId()]; + uint256 phys = ps.assetBal - su; + ps.closeRemaining = r < phys ? r : phys; + if (su != 0) { + ps.quoteBal += ps.shortMargin; + ps.assetBal -= su; + ps.shortUnits = 0; + ps.shortMargin = 0; + } } if (ps.status != Status.Closing) revert IConfluere.InvalidState(); @@ -120,7 +239,7 @@ library WindowLogic { ps.feeEscrow = 0; ps.quoteBal += esc - toT; if (toT != 0) { - s.pending[ps.key.quote][pr.treasury] += toT; + s.pending[ps.key.quote][pr.treasury()] += toT; s.marketFees[ps.marketId] += toT; CoreLib.addFees(s, ps, toT); } From f9d7804a8aa04c8d4f8f90c5ffc155264020d37b Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 137/169] feat(core): add the Specials lending interface with lazy short accounting (tests invariant) Part 6/9 of: feat(core): add the Specials lending interface with lazy short accounting Files: contracts/test/invariant/Handler.sol, contracts/test/invariant/Invariants.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/invariant/Handler.sol | 102 +++++++++++++++++++--- contracts/test/invariant/Invariants.t.sol | 40 ++++++++- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/contracts/test/invariant/Handler.sol b/contracts/test/invariant/Handler.sol index e93e053..c741939 100644 --- a/contracts/test/invariant/Handler.sol +++ b/contracts/test/invariant/Handler.sol @@ -57,15 +57,22 @@ contract Handler is Test { uint256 value; uint256 cSupply; uint256 rSupply; + uint256 out; // lent + short units: the forfeiture at close swaps them for their margin } + address public constant SPECIALS = address(0x5bec1a15); // Invariants.setUp makes this the core's Specials + bool public lendingRule; // invariant 3b: a lending call that must fail succeeded + mapping(bytes32 => uint256) public marginIn; // margin and top-ups escrowed per pool + mapping(bytes32 => uint256) public marginOut; // margin released, paid to repayers, or forfeited to C holders + /// @dev Invariant 4: a pool's value (quote + surplus + asset at K) never falls unless C or R was burned. modifier guarded() { Snap[] memory before = _snaps(); _; Snap[] memory afterS = _snaps(); for (uint256 i; i < before.length; ++i) { - bool burned = afterS[i].cSupply < before[i].cSupply || afterS[i].rSupply < before[i].rSupply; + bool burned = afterS[i].cSupply < before[i].cSupply || afterS[i].rSupply < before[i].rSupply + || afterS[i].out < before[i].out; if (!burned && afterS[i].value < before[i].value) valueExtracted = true; } } @@ -73,16 +80,18 @@ contract Handler is Test { function _snaps() internal view returns (Snap[] memory out) { out = new Snap[](pools.length); for (uint256 i; i < pools.length; ++i) { - IConfluere.PoolView memory v = core.pool(pools[i]); - uint256 scale = 10 ** uint256(v.assetDecimals); - out[i] = Snap( - v.quoteBal + v.surplusBal + v.assetBal * v.key.strike / scale, - core.totalSupply(pools[i].cId()), - core.totalSupply(pools[i].rId()) - ); + out[i] = _snap(pools[i]); } } + function _snap(bytes32 pid) internal view returns (Snap memory sn) { + IConfluere.PoolView memory v = core.pool(pid); + sn.value = v.quoteBal + v.surplusBal + v.assetBal * v.key.strike / 10 ** uint256(v.assetDecimals); + sn.cSupply = core.totalSupply(pid.cId()); + sn.rSupply = core.totalSupply(pid.rId()); + sn.out = uint256(v.lentUnits) + v.shortUnits; + } + constructor(Confluere c, MockERC20 w, MockERC20 u, address t, bytes32[] memory ps, address[] memory as_) { core = c; weth = w; @@ -249,8 +258,9 @@ contract Handler is Test { bool allowed = inWindow || (v.key.kind == Kind.American && v.status == Status.Open && block.timestamp < v.key.expiry); vm.prank(who); - try core.repay(pid, uint96(units)) {} - catch { + try core.repay(pid, uint96(units)) returns (uint256, uint256 m) { + marginOut[pid] += m; + } catch { if (allowed) repayFailed = true; } } @@ -335,6 +345,73 @@ contract Handler is Test { } catch {} } + function _marginOwed(bytes32 pid) internal view returns (Status, uint256) { + IConfluere.PoolView memory v = core.pool(pid); + return (v.status, uint256(v.lentMargin) + v.shortMargin); + } + + // ------------------------------------------------------------ Specials lending (invariant 3b) + + /// @dev Lend as Specials: bounded inside the rules most of the time, sometimes past the cap or under the margin + /// (those must revert), and sometimes from a non-Specials caller (must revert). + function lend(uint256 p, uint256 units, uint256 extra, uint256 fee, uint256 mode) external guarded { + calls++; + bytes32 pid = _pool(p); + (uint256 u, uint256 margin, bool legal) = _lendArgs(pid, units, extra, mode % 4); + vm.prank(mode % 4 == 3 ? _actor(mode) : SPECIALS); + try core.lend(pid, uint96(u), SPECIALS, uint128(margin), uint128(bound(fee, 0, 10e6))) { + if (!legal) lendingRule = true; + marginIn[pid] += margin; + } catch {} + } + + /// @dev mode 0: inside the rules; 1: past the 50% cap; 2: one wei under the margin floor; 3: not Specials. + function _lendArgs(bytes32 pid, uint256 units, uint256 extra, uint256 mode) + internal + view + returns (uint256 u, uint256 margin, bool legal) + { + IConfluere.PoolView memory v = core.pool(pid); + uint256 room = v.assetBal / 2 > v.lentUnits ? v.assetBal / 2 - v.lentUnits : 0; + u = mode == 1 ? room + 1 : bound(units, 1, room == 0 ? 1 : room); + uint256 need = (u * uint256(v.lastClearingPrice) * 12_000 + 1e22 - 1) / 1e22; + margin = mode == 2 ? (need == 0 ? 0 : need - 1) : need + bound(extra, 0, 1000e6); + legal = mode == 0 && room != 0 && v.lastClearingPrice != 0 && v.key.kind == Kind.European + && v.status == Status.Open && block.timestamp + 12 hours < v.key.expiry && block.number >= v.pausedUntil; + } + + function returnLent(uint256 p, uint256 units, uint256 all) external guarded { + calls++; + bytes32 pid = _pool(p); + IConfluere.PoolView memory v = core.pool(pid); + if (v.lentUnits == 0) return; + bool full = all % 3 == 0; + units = full ? v.lentUnits : bound(units, 1, v.lentUnits); + uint256 out = units == v.lentUnits ? v.lentMargin : uint256(v.lentMargin) * units / v.lentUnits; + vm.prank(SPECIALS); + try core.returnLent(pid, uint96(units), uint128(out), SPECIALS) { + marginOut[pid] += out; + if (block.timestamp + 12 hours >= v.key.expiry) lendingRule = true; + } catch {} + } + + function topUpLent(uint256 p, uint256 amount) external guarded { + calls++; + bytes32 pid = _pool(p); + amount = bound(amount, 1, 100e6); + vm.prank(SPECIALS); + try core.topUpLent(pid, uint128(amount)) { + marginIn[pid] += amount; + } catch {} + } + + /// @dev Jump past a pool's lending cutoff (but before its expiry) so short units get recorded. + function warpPastCutoff(uint256 p) external guarded { + calls++; + IConfluere.PoolView memory v = core.pool(_pool(p)); + if (block.timestamp + 12 hours < v.key.expiry) vm.warp(v.key.expiry - 6 hours); + } + function placeBid(uint256 a, uint256 p, uint256 units, uint256 surplus) external guarded { calls++; bytes32 pid = _pool(p); @@ -357,7 +434,10 @@ contract Handler is Test { bytes32 pid = _pool(p); uint256 w = weth.balanceOf(address(core)); uint256 u = usdc.balanceOf(address(core)); - try core.closeWindow(pid, bound(maxBids, 1, 3)) {} catch {} + (Status st, uint256 owed) = _marginOwed(pid); + try core.closeWindow(pid, bound(maxBids, 1, 3)) { + if (st == Status.Window) marginOut[pid] += owed; // forfeited to C holders when the window closes + } catch {} if (weth.balanceOf(address(core)) != w || usdc.balanceOf(address(core)) != u) pushedDuringNoPushCall = true; } diff --git a/contracts/test/invariant/Invariants.t.sol b/contracts/test/invariant/Invariants.t.sol index 0f8ca53..dde76d4 100644 --- a/contracts/test/invariant/Invariants.t.sol +++ b/contracts/test/invariant/Invariants.t.sol @@ -23,6 +23,7 @@ contract InvariantsTest is Test { MockERC20 internal usdc; Handler internal h; address internal treasury = makeAddr("treasury"); + address internal constant SPECIALS = address(0x5bec1a15); // the handler pranks as Specials function setUp() public { vm.warp(ANCHOR + 1 days); @@ -33,6 +34,10 @@ contract InvariantsTest is Test { c.grid = GridConfig(ANCHOR, WEEK, 30 minutes, 4); c.maxOrdersPerSide = 8; c.maxBids = 4; + c.autoSellDiscountBps = 9000; + c.autoSellTtl = 4; + c.lendCutoff = 12 hours; + c.specials = SPECIALS; c.chains = new Confluere.ChainParams[](1); c.chains[0].chainId = block.chainid; c.chains[0].windowBlocks = 7200; @@ -76,7 +81,13 @@ contract InvariantsTest is Test { vm.stopPrank(); } targetContract(address(h)); - bytes4[] memory sel = new bytes4[](18); + weth.mint(SPECIALS, 1e24); + usdc.mint(SPECIALS, 1e15); + vm.startPrank(SPECIALS); + weth.approve(address(core), type(uint256).max); + usdc.approve(address(core), type(uint256).max); + vm.stopPrank(); + bytes4[] memory sel = new bytes4[](22); sel[0] = Handler.place.selector; sel[1] = Handler.place.selector; sel[2] = Handler.cancel.selector; @@ -95,6 +106,10 @@ contract InvariantsTest is Test { sel[15] = Handler.purge.selector; sel[16] = Handler.pause.selector; sel[17] = Handler.placeLocked.selector; + sel[18] = Handler.lend.selector; + sel[19] = Handler.returnLent.selector; + sel[20] = Handler.topUpLent.selector; + sel[21] = Handler.warpPastCutoff.selector; targetSelector(FuzzSelector(address(h), sel)); } @@ -105,8 +120,8 @@ contract InvariantsTest is Test { uint256 poolQuote; for (uint256 i; i < h.poolsLength(); ++i) { IConfluere.PoolView memory v = core.pool(h.pools(i)); - poolAsset += v.assetBal; - poolQuote += v.quoteBal + v.surplusBal + v.feeEscrow; + poolAsset += v.assetBal - v.lentUnits - v.shortUnits; // lent and short units are not here + poolQuote += v.quoteBal + v.surplusBal + v.feeEscrow + v.lentMargin + v.shortMargin; } (uint256 pendAsset, uint256 pendQuote) = _pending(); assertEq(weth.balanceOf(address(core)), escAsset + poolAsset + pendAsset, "asset accounting"); @@ -156,6 +171,23 @@ contract InvariantsTest is Test { } } + /// @dev Invariant 3b: lending only in European pools, by Specials, before the cutoff, at most half of the pool, + /// and every unit of margin escrowed is accounted for: still lent or short, released, paid to a repayer, or + /// forfeited to C holders. + function invariant_lending() public view { + assertFalse(h.lendingRule(), "a lending call broke the rules"); + for (uint256 i; i < h.poolsLength(); ++i) { + bytes32 pid = h.pools(i); + IConfluere.PoolView memory v = core.pool(pid); + if (v.key.kind == Kind.American) { + assertEq(v.lentUnits + v.lentMargin + v.shortUnits + v.shortMargin, 0, "American pool lent"); + } + assertLe(v.lentUnits, v.assetBal / 2, "more than half lent"); + if (v.status == Status.Closed) assertEq(v.shortUnits + v.shortMargin, 0, "short left after close"); + assertEq(h.marginIn(pid), h.marginOut(pid) + v.lentMargin + v.shortMargin, "margin conservation"); + } + } + /// @dev Blueprint 3.5: pools of one expiry share a window however late each is matured. function invariant_windowsAligned() public view { assertFalse(h.windowMisaligned(), "window not aligned per expiry"); @@ -195,6 +227,8 @@ contract InvariantsTest is Test { function _pending() internal view returns (uint256 a, uint256 q) { a = core.pending(address(weth), treasury); q = core.pending(address(usdc), treasury); + a += core.pending(address(weth), SPECIALS); + q += core.pending(address(usdc), SPECIALS); for (uint256 i; i < h.actorsLength(); ++i) { address who = h.actors(i); a += core.pending(address(weth), who); From c9c8e966fc7b145487d0273e91f059403206e4fd Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 138/169] feat(core): add the Specials lending interface with lazy short accounting (tests medusa) Part 7/9 of: feat(core): add the Specials lending interface with lazy short accounting Files: contracts/test/medusa/MedusaHarness.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/medusa/MedusaHarness.sol | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contracts/test/medusa/MedusaHarness.sol b/contracts/test/medusa/MedusaHarness.sol index c569794..0a85007 100644 --- a/contracts/test/medusa/MedusaHarness.sol +++ b/contracts/test/medusa/MedusaHarness.sol @@ -44,6 +44,10 @@ contract MedusaHarness { c.grid = GridConfig(ANCHOR, WEEK, 30 minutes, 4); c.maxOrdersPerSide = 6; c.maxBids = 4; + c.autoSellDiscountBps = 9000; + c.autoSellTtl = 4; + c.lendCutoff = 12 hours; + c.specials = address(this); c.chains = new Confluere.ChainParams[](1); c.chains[0].chainId = block.chainid; c.chains[0].windowBlocks = 50; From 55bb26d9ded3d6250b035dce767b4aa1bf529e9b Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 139/169] feat(core): add the Specials lending interface with lazy short accounting (tests unit) Part 8/9 of: feat(core): add the Specials lending interface with lazy short accounting Files: contracts/test/unit/LongTail.t.sol, contracts/test/unit/SpecialsCore.t.sol, contracts/test/unit/WrapperNames.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/LongTail.t.sol | 1 + contracts/test/unit/SpecialsCore.t.sol | 264 +++++++++++++++++++++++++ contracts/test/unit/WrapperNames.t.sol | 1 + 3 files changed, 266 insertions(+) create mode 100644 contracts/test/unit/SpecialsCore.t.sol diff --git a/contracts/test/unit/LongTail.t.sol b/contracts/test/unit/LongTail.t.sol index f5d74ee..3e9ab07 100644 --- a/contracts/test/unit/LongTail.t.sol +++ b/contracts/test/unit/LongTail.t.sol @@ -174,6 +174,7 @@ contract LongTailTest is Base { // a demo grid: 1-hour expiries, 2-minute batches -> 48 x 2 min = 96 min > 1 h Confluere.Config memory c = _config(); c.grid = GridConfig({anchor: ANCHOR, period: 1 hours, batchInterval: 2 minutes, maxExpiries: 4}); + c.lendCutoff = 10 minutes; Confluere demo = new Confluere(c); weth.approve(address(demo), type(uint256).max); usdc.approve(address(demo), type(uint256).max); diff --git a/contracts/test/unit/SpecialsCore.t.sol b/contracts/test/unit/SpecialsCore.t.sol new file mode 100644 index 0000000..67289a3 --- /dev/null +++ b/contracts/test/unit/SpecialsCore.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {Base} from "../utils/Base.sol"; +import {IConfluere} from "../../src/interfaces/IConfluere.sol"; +import {Kind, Status} from "../../src/libraries/PoolKey.sol"; +import {SELL, BID} from "../../src/libraries/OrderSet.sol"; + +/// @notice The core side of Specials lending (decision A, ARCH 2.6.1, invariant 3b). `specials` stands in for the +/// Specials periphery. +contract SpecialsCoreTest is Base { + bytes32 internal pid; + uint256 internal constant MARGIN = 3480e6; // 120% of 1 ETH at the last clearing price 2900 + address internal taker = makeAddr("taker"); // the borrower of the lent asset + + function setUp() public override { + super.setUp(); + pid = _create(K, Kind.European, E1); + _sell(alice, pid, 2 * ONE, 2900e6); + _buy(bob, pid, 2 * ONE, 2900e6); + _settle(pid); + weth.mint(specials, 100 * ONE); + usdc.mint(specials, 1_000_000e6); + vm.startPrank(specials); + weth.approve(address(core), type(uint256).max); + usdc.approve(address(core), type(uint256).max); + vm.stopPrank(); + } + + function _lend(uint256 units, uint256 margin, uint256 fee) internal { + vm.prank(specials); + core.lend(pid, uint96(units), taker, uint128(margin), uint128(fee)); + } + + function _window() internal { + vm.warp(E1); + core.mature(pid); + } + + // ------------------------------------------------------------ permissions and conditions + + function test_lend_onlySpecials() public { + vm.prank(eve); + vm.expectRevert(IConfluere.NotOwner.selector); + core.lend(pid, uint96(ONE), taker, uint128(MARGIN), 0); + vm.prank(eve); + vm.expectRevert(IConfluere.NotOwner.selector); + core.topUpLent(pid, 1); + vm.prank(eve); + vm.expectRevert(IConfluere.NotOwner.selector); + core.returnLent(pid, 1, 0, eve); + } + + function test_lend_europeanOnly() public { + bytes32 am = _create(K, Kind.American, E1); + _sell(alice, am, ONE, 2900e6); + _buy(bob, am, ONE, 2900e6); + _settle(am); + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidState.selector); + core.lend(am, 1e17, taker, uint128(MARGIN), 0); + } + + function test_lend_movesAsset_andEscrowsMargin() public { + uint256 c0 = weth.balanceOf(address(core)); + _lend(ONE, MARGIN, 0); + assertEq(weth.balanceOf(taker), ONE); + assertEq(weth.balanceOf(address(core)), c0 - ONE); + IConfluere.PoolView memory v = core.pool(pid); + assertEq(v.lentUnits, ONE); + assertEq(v.lentMargin, MARGIN); + assertEq(v.assetBal, 2 * ONE, "lent units still back R"); + } + + function test_lend_capHalf() public { + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidParams.selector); + core.lend(pid, uint96(ONE + 1), taker, uint128(2 * MARGIN), 0); + _lend(5e17, MARGIN, 0); + _lend(5e17, MARGIN, 0); + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidParams.selector); + core.lend(pid, 1, taker, uint128(MARGIN), 0); + } + + function test_lend_minMargin120() public { + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidParams.selector); + core.lend(pid, uint96(ONE), taker, uint128(MARGIN - 1), 0); + _lend(ONE, MARGIN, 0); + } + + function test_lend_needsAPrice() public { + bytes32 fresh = _create(K_HI, Kind.European, E1); + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidParams.selector); + core.lend(fresh, 1, taker, uint128(MARGIN), 0); + } + + function test_lend_afterCutoff_reverts() public { + vm.warp(E1 - 12 hours); + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidState.selector); + core.lend(pid, uint96(ONE), taker, uint128(MARGIN), 0); + } + + function test_lend_whilePaused_reverts() public { + vm.prank(guardian); + core.pause(pid, 100); + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidState.selector); + core.lend(pid, uint96(ONE), taker, uint128(MARGIN), 0); + } + + function test_fee_toLenders() public { + uint256 q0 = core.pool(pid).quoteBal; + _lend(ONE, MARGIN, 10e6); + assertEq(core.pool(pid).quoteBal, q0 + 10e6); + assertEq(_pend(address(usdc), treasury), 4 * 435_000, "the protocol takes nothing from lending"); + } + + // ------------------------------------------------------------ returning + + function test_return_partial_marginToPending() public { + _lend(ONE, MARGIN, 0); + vm.prank(specials); + core.returnLent(pid, uint96(4e17), uint128(MARGIN * 2 / 5), taker); + assertEq(_pend(address(usdc), taker), MARGIN * 2 / 5); + IConfluere.PoolView memory v = core.pool(pid); + assertEq(v.lentUnits, 6e17); + assertEq(v.lentMargin, MARGIN - MARGIN * 2 / 5); + } + + function test_return_full_mustReleaseAll() public { + _lend(ONE, MARGIN, 0); + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidParams.selector); + core.returnLent(pid, uint96(ONE), uint128(MARGIN - 1), taker); + vm.prank(specials); + core.returnLent(pid, uint96(ONE), uint128(MARGIN), taker); + assertEq(core.pool(pid).lentUnits, 0); + assertEq(core.pool(pid).lentMargin, 0); + } + + function test_return_afterCutoff_reverts() public { + _lend(ONE, MARGIN, 0); + vm.warp(E1 - 12 hours); + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidState.selector); + core.returnLent(pid, uint96(ONE), uint128(MARGIN), taker); + } + + function test_topUp() public { + _lend(ONE, MARGIN, 0); + vm.prank(specials); + core.topUpLent(pid, 100e6); + assertEq(core.pool(pid).lentMargin, MARGIN + 100e6); + vm.warp(E1 - 1); + vm.prank(specials); + vm.expectRevert(IConfluere.InvalidState.selector); + core.topUpLent(pid, 1); + } + + // ------------------------------------------------------------ short units after the cutoff + + function test_short_recordedLazily_atFirstTouch() public { + _lend(ONE, MARGIN, 0); + vm.warp(E1 - 1 hours); // past the cutoff, nothing touched the pool yet + assertEq(core.pool(pid).lentUnits, ONE); + vm.warp(E1); + vm.expectEmit(true, false, false, true); + emit IConfluere.ShortRecorded(pid, ONE, MARGIN); + core.mature(pid); + IConfluere.PoolView memory v = core.pool(pid); + assertEq(v.lentUnits, 0); + assertEq(v.shortUnits, ONE); + assertEq(v.shortMargin, MARGIN); + } + + function test_repay_assetFirst_thenMargin() public { + _lend(ONE, MARGIN, 0); + _window(); + vm.prank(alice); + (uint256 a1, uint256 m1) = core.repay(pid, uint96(15e17)); + assertEq(a1, ONE, "the asset still here goes first"); + assertEq(m1, MARGIN / 2, "half of the short units: half of the margin"); + assertEq(_pend(address(usdc), alice) - (2 * 2900e6 - 2 * 435_000 - 2 * 5_548_315), MARGIN / 2); + vm.prank(alice); + (uint256 a2, uint256 m2) = core.repay(pid, uint96(5e17)); + assertEq(a2, 0); + assertEq(m2, MARGIN - MARGIN / 2, "the last short unit takes the rest"); + IConfluere.PoolView memory v = core.pool(pid); + assertEq(v.shortUnits, 0); + assertEq(v.shortMargin, 0); + assertEq(v.quoteBal, 2 * uint256(K), "C holders get K for every unit"); + assertEq(v.repaidUnits, 2 * ONE); + } + + function test_repay_lastShortTakesAllMargin() public { + _lend(ONE, MARGIN + 1, 0); // odd margin + _window(); + vm.prank(alice); + core.repay(pid, uint96(ONE)); // all physical + vm.prank(alice); + (, uint256 m1) = core.repay(pid, uint96(3e17)); + vm.prank(alice); + (, uint256 m2) = core.repay(pid, uint96(7e17)); + assertEq(m1 + m2, MARGIN + 1, "no dust left behind"); + } + + function test_repay_neverRevertsWithShort() public { + _lend(ONE, MARGIN, 0); + _window(); + vm.prank(alice); + core.repay(pid, uint96(2 * ONE)); + assertEq(core.balanceOf(alice, _r(pid)), 0); + } + + function test_bids_onlyPhysical() public { + _lend(ONE, MARGIN, 0); + _window(); + uint64 hint = core.findHint(pid, BID, 1e6); + vm.prank(eve); + core.bid(pid, uint96(2 * ONE), 1e6, hint); + vm.roll(block.number + WINDOW); + core.closeWindow(pid, 10); + assertEq(_pend(address(weth), eve), ONE, "only the unit still here can be bought"); + IConfluere.PoolView memory v = core.pool(pid); + assertEq(v.assetBal, 0); + assertEq( + v.quoteBal, uint256(K) + MARGIN + 2 * 5_548_315 / 2, "K for the bid unit, the margin, lenders' escrow half" + ); + } + + function test_close_unclaimedShortMarginToLenders() public { + _lend(ONE, MARGIN, 0); + _window(); + vm.roll(block.number + WINDOW); + assertTrue(core.closeWindow(pid, 10)); + IConfluere.PoolView memory v = core.pool(pid); + assertEq(uint8(v.status), uint8(Status.Closed)); + assertEq(v.shortUnits, 0); + assertEq(v.shortMargin, 0); + assertEq(v.assetBal, ONE, "the unit still here defaults in kind"); + assertEq(v.quoteBal, MARGIN + 2 * 5_548_315, "the forfeited margin and the defaulted escrow"); + vm.prank(bob); + (uint256 q, uint256 a) = core.claim(pid); + assertEq(q, MARGIN + 2 * 5_548_315); + assertEq(a, ONE); + } + + function test_rollover_skippedWhenOldPoolHasLent() public { + bytes32 p2 = _create(K, Kind.European, E2); + _lend(ONE, MARGIN, 0); + uint64 hint = core.findHint(p2, SELL, 2850e6); + vm.prank(alice); + core.rollover(pid, p2, uint96(ONE), 2850e6, 200e6, 500, hint); + _buy(carol, p2, ONE, 2950e6); + vm.warp(E1 - BATCH); + core.settleBatch(p2); // the last batch before E1, but the old pool's units are not all here + assertEq(core.balanceOf(alice, _r(p2)), 0); + assertEq(core.pool(pid).assetBal, 2 * ONE); + } +} diff --git a/contracts/test/unit/WrapperNames.t.sol b/contracts/test/unit/WrapperNames.t.sol index 7d507dc..38b9f3e 100644 --- a/contracts/test/unit/WrapperNames.t.sol +++ b/contracts/test/unit/WrapperNames.t.sol @@ -14,6 +14,7 @@ contract WrapperNamesTest is Base { function test_wrapper_subDailyGridNamesCarryTime() public { Confluere.Config memory cfg = _config(); cfg.grid.period = 1 hours; + cfg.lendCutoff = 10 minutes; Confluere hourly = new Confluere(cfg); weth.approve(address(hourly), 1); usdc.approve(address(hourly), 1); From e6d8c419a48fa58cb7079c49901cb5169a48480e Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 140/169] feat(core): add the Specials lending interface with lazy short accounting (tests utils) Part 9/9 of: feat(core): add the Specials lending interface with lazy short accounting Files: contracts/test/utils/Base.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/utils/Base.sol | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contracts/test/utils/Base.sol b/contracts/test/utils/Base.sol index 97d6def..78fad44 100644 --- a/contracts/test/utils/Base.sol +++ b/contracts/test/utils/Base.sol @@ -36,6 +36,7 @@ abstract contract Base is Test { address internal bob = makeAddr("bob"); // lender address internal carol = makeAddr("carol"); // lender address internal eve = makeAddr("eve"); // searcher / bidder + address internal specials = makeAddr("specials"); // stands in for the Specials periphery function setUp() public virtual { vm.warp(ANCHOR + 1 days); @@ -63,6 +64,10 @@ abstract contract Base is Test { c.grid = GridConfig({anchor: ANCHOR, period: WEEK, batchInterval: BATCH, maxExpiries: 4}); c.maxOrdersPerSide = 8; c.maxBids = 4; + c.autoSellDiscountBps = 9000; + c.autoSellTtl = 4; + c.lendCutoff = 12 hours; + c.specials = specials; c.chains = new Confluere.ChainParams[](1); c.chains[0].chainId = block.chainid; c.chains[0].windowBlocks = WINDOW; From 2077cb12b2dc7302b713fbb05cfd1b06770fc285 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:20 -0400 Subject: [PATCH 141/169] feat(seats): 70% seat share before graduation, 35% after; keeper finalizes Selling pools SeatRegistry._split pays the holder shareBps(m): SHARE_BEFORE_BPS (7000) while the market is not graduated, the deploy-time seatShareBps (3500) after. The constructor signature is unchanged so Deploy.s.sol and its CREATE2 address derivation keep working. KeeperReceiver._tick calls closeWindow on Selling pools as on Closing ones (autoSell end). Periphery only; no core invariant touched. Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/KeeperReceiver.sol | 5 ++++- contracts/src/periphery/SeatRegistry.sol | 12 ++++++++++-- contracts/test/unit/Seats.t.sol | 7 ++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/contracts/src/periphery/KeeperReceiver.sol b/contracts/src/periphery/KeeperReceiver.sol index fc01835..00eff9d 100644 --- a/contracts/src/periphery/KeeperReceiver.sol +++ b/contracts/src/periphery/KeeperReceiver.sol @@ -60,7 +60,10 @@ contract KeeperReceiver is IReceiver { settled++; } catch {} } - } else if ((v.status == Status.Window && block.number >= v.windowEnd) || v.status == Status.Closing) { + } else if ( + (v.status == Status.Window && block.number >= v.windowEnd) || v.status == Status.Closing + || v.status == Status.Selling + ) { try core.closeWindow(pid, maxBidsPerClose) { closed++; } catch {} diff --git a/contracts/src/periphery/SeatRegistry.sol b/contracts/src/periphery/SeatRegistry.sol index 5193389..c8168dc 100644 --- a/contracts/src/periphery/SeatRegistry.sol +++ b/contracts/src/periphery/SeatRegistry.sol @@ -54,7 +54,10 @@ contract SeatRegistry is ReentrancyGuardTransient { IConfluere public core; address internal immutable _deployer; address public immutable protocol; + /// Seat share of the market's spread share after graduation (blueprint 9: 35%). uint16 public immutable seatShareBps; + /// Seat share before graduation (blueprint 9: 70%), when the seat holder is carrying the market. + uint16 public constant SHARE_BEFORE_BPS = 7000; uint16 public immutable taxBps; mapping(address => uint256) public obligation; // per quote token, 0 = no seats for that quote @@ -309,7 +312,12 @@ contract SeatRegistry is ReentrancyGuardTransient { emit TaxPaid(m, owed); } - /// @dev New spread share since the last split: `seatShareBps` to the holder at this moment, the rest to the + /// @notice The seat's share of market `m`'s fees: 70% before graduation, `seatShareBps` (35%) after. + function shareBps(bytes32 m) public view returns (uint256) { + return core.graduated(m) ? seatShareBps : SHARE_BEFORE_BPS; + } + + /// @dev New spread share since the last split: `shareBps(m)` to the holder at this moment, the rest to the /// protocol. Run before any change of holder so each holder is paid for its own tenure. function _split(bytes32 m, address quote_) internal { uint256 total = core.marketFees(m); @@ -317,7 +325,7 @@ contract SeatRegistry is ReentrancyGuardTransient { if (fresh == 0) return; feesSplit[m] = total; address holder = seats[m].holder; - uint256 toSeat = holder == address(0) ? 0 : fresh * seatShareBps / BPS; + uint256 toSeat = holder == address(0) ? 0 : fresh * shareBps(m) / BPS; if (toSeat != 0) claimable[quote_][holder] += toSeat; claimable[quote_][protocol] += fresh - toSeat; emit FeesSplit(m, toSeat, fresh - toSeat); diff --git a/contracts/test/unit/Seats.t.sol b/contracts/test/unit/Seats.t.sol index 6dc6e0c..7cf579c 100644 --- a/contracts/test/unit/Seats.t.sol +++ b/contracts/test/unit/Seats.t.sol @@ -112,7 +112,7 @@ contract SeatsTest is Base { _assertSolvent(); } - function test_feeSplit_seatGets35pct() public { + function test_split_70before_graduation() public { _buy(alice, PRICE, DEPOSIT); _sell(dave, pid, ONE, 2900e6); _buy(bob, pid, ONE, 2900e6); @@ -121,7 +121,8 @@ contract SeatsTest is Base { core.repay(pid, uint96(ONE)); // no fee on repay; the spread share waits in the escrow until close assertEq(core.marketFees(market), 2 * 435_000, "trade fees, 0.015% each side"); reg.split(address(weth), address(usdc), Kind.American); - assertEq(reg.claimable(address(usdc), alice), 304_500, "35% to the seat"); + assertEq(reg.shareBps(market), 7000, "not graduated"); + assertEq(reg.claimable(address(usdc), alice), 609_000, "70% to the seat before graduation"); uint256 protocolBefore = reg.claimable(address(usdc), protocolMs); reg.split(address(weth), address(usdc), Kind.American); // idempotent assertEq(reg.claimable(address(usdc), protocolMs), protocolBefore); @@ -129,7 +130,7 @@ contract SeatsTest is Base { uint256 a0 = usdc.balanceOf(alice); vm.prank(alice); reg.withdraw(address(usdc)); // pulls the fees out of the core - assertEq(usdc.balanceOf(alice) - a0, 304_500); + assertEq(usdc.balanceOf(alice) - a0, 609_000); _assertSolvent(); } From 1f6d59085ae1bd8590ea97b907769c370ae4570a Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:09:33 -0400 Subject: [PATCH 142/169] feat(keeper-cre): sync-config reads the v0.8 PoolKey and keeps Selling pools PoolCreated and pool() decode the key with bandBps and cadence (plus strikeIndex in the event). Status gains Selling (4, still needs closeWindow) and Closed moves to 5, so Selling pools stay in the workflow's pool list until they close. Co-Authored-By: Claude Opus 5.5 --- services/keeper-cre/scripts/sync-config.mjs | 11 ++++++----- services/keeper-cre/test/sync-config.test.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/services/keeper-cre/scripts/sync-config.mjs b/services/keeper-cre/scripts/sync-config.mjs index 778e8a2..7675bbb 100644 --- a/services/keeper-cre/scripts/sync-config.mjs +++ b/services/keeper-cre/scripts/sync-config.mjs @@ -2,7 +2,7 @@ /** * Rebuilds config.staging.json from deployments/.json and the chain itself: * receiver = keeperReceiver, vaults = the non-zero vault addresses, - * pools = every pool the core created (PoolCreated logs from deployBlock) that is not Closed. + * pools = every pool the core created (PoolCreated logs from deployBlock) that is not Closed (Selling counts as live). * * The workflow carries ids only, so this is the step that keeps later expiries (opened by CreatePools.s.sol or the * /create wizard) in the CRE tick. Re-run it after opening pools. Read-only: no transaction is sent. @@ -95,17 +95,18 @@ export function defaultBase(row, deployment) { gasLimit: row.gasLimit, }; } -/** IConfluere.Status: None, Open, Window, Closing, Closed. */ +/** IConfluere.Status (v0.8): None, Open, Window, Closing, Selling, Closed. Selling still needs closeWindow. */ export const STATUS_NONE = 0; -export const STATUS_CLOSED = 4; +export const STATUS_SELLING = 4; +export const STATUS_CLOSED = 5; export const poolCreated = parseAbiItem( - "event PoolCreated(bytes32 indexed poolId, (address asset, address quote, uint64 expiry, uint128 strike, uint8 kind) key, uint16 feeBps, uint16 bandBps, bytes32 indexed marketId)", + "event PoolCreated(bytes32 indexed poolId, (address asset, address quote, uint64 expiry, uint128 strike, uint8 kind, uint16 bandBps, uint8 cadence) key, int16 strikeIndex, bytes32 indexed marketId)", ); // Only the leading fields of IConfluere.PoolView are needed; the struct is returned by value, so the head decodes // from the first two words (key is a static tuple, status follows it). const poolAbi = parseAbi([ - "struct PoolKey { address asset; address quote; uint64 expiry; uint128 strike; uint8 kind; }", + "struct PoolKey { address asset; address quote; uint64 expiry; uint128 strike; uint8 kind; uint16 bandBps; uint8 cadence; }", "function pool(bytes32 poolId) view returns (PoolKey key, uint8 status)", ]); diff --git a/services/keeper-cre/test/sync-config.test.ts b/services/keeper-cre/test/sync-config.test.ts index 7ced666..f361e4a 100644 --- a/services/keeper-cre/test/sync-config.test.ts +++ b/services/keeper-cre/test/sync-config.test.ts @@ -12,6 +12,7 @@ import { readStatuses, scanPoolIds, STATUS_CLOSED, + STATUS_SELLING, vaultsOf, withRetry, } from "../scripts/sync-config.mjs"; @@ -31,16 +32,18 @@ describe("sync-config", () => { expect(chunkRanges(6n, 5n, 100n)).toEqual([]); }); - it("keeps pools that are Open, Window or Closing and drops Closed, unknown and duplicate ids", () => { - const ids = [id("aa"), id("bb"), id("cc"), id("dd"), id("ee"), id("AA")]; + it("keeps pools that are Open, Window, Closing or Selling and drops Closed, unknown and duplicate ids", () => { + const ids = [id("aa"), id("bb"), id("cc"), id("dd"), id("ee"), id("ff"), id("AA")]; const status = new Map([ [id("aa"), 1], [id("bb"), STATUS_CLOSED], [id("cc"), 2], [id("dd"), 0], [id("ee"), 3], + [id("ff"), STATUS_SELLING], ]); - expect(livePools(ids, status)).toEqual([id("aa"), id("cc"), id("ee")]); + expect(STATUS_CLOSED).toBe(5); + expect(livePools(ids, status)).toEqual([id("aa"), id("cc"), id("ee"), id("ff")]); }); it("takes receiver and non-zero vaults from the deployment and keeps the schedule settings", () => { From aa1ad61aa2d2080c56fe75894835c4fcdaf8e64e Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:10:11 -0400 Subject: [PATCH 143/169] feat(web): v0.8 PoolKey, 1% log grid and auto-sell in create market (web src/components) Create market now validates buy-back prices against the 1% log grid (strikeAt / strikeIndexOf from the SDK), offers the neighbouring grid prices, defaults the price range to the pair's standard band (1.5 / 8 / 15% by asset class), drops the fee tier (v0.8 fees are fixed) and adds the auto-sell option. Pool ids include band and cadence; the order read follows the v0.8 Order layout (flags field); the demo chain snaps plan strikes to the grid and checks created pools the same way. Part 1/3 of: feat(web): v0.8 PoolKey, 1% log grid and auto-sell in create market Files: apps/web/src/components/more/CreateMarketCard.tsx Co-Authored-By: Claude Opus 5.5 --- .../src/components/more/CreateMarketCard.tsx | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/more/CreateMarketCard.tsx b/apps/web/src/components/more/CreateMarketCard.tsx index 790a720..86381dc 100644 --- a/apps/web/src/components/more/CreateMarketCard.tsx +++ b/apps/web/src/components/more/CreateMarketCard.tsx @@ -4,18 +4,17 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { isAddress, type Address } from "viem"; -import { Kind, cadenceMultiplier, isGridStrike, poolId } from "@confluere/sdk"; +import { Kind, cadenceMultiplier, poolId } from "@confluere/sdk"; import { useActiveChain } from "@/lib/activeChain"; import { onchainSymbol, tokenOf, useGrid, usePools, usePoolStatus, useTokens } from "@/lib/data"; import { isNative, isWrappedNative, nativeTokenOf, NATIVE, useNativeBalance, wrappedNativeOf } from "@/lib/native"; import { tokenName } from "@/lib/logos"; import { launchTokens, wrapWrite } from "@/lib/tokens"; import { useBalances } from "@/lib/position"; -import { BAND_TIERS, FEE_TIERS, gridExpiries, nearestGridStrikes } from "@/lib/grid"; +import { BAND_TIERS, gridExpiries, isLogGridStrike, nearestGridStrikes, standardBand } from "@/lib/grid"; import { fmtChip, fmtPer, fmtToken, fmtWait, parseAmount, toInput } from "@/lib/format"; import { marketKey, openMarketWrite } from "@/lib/proActions"; import { STYLE_LABEL } from "@/lib/vocab"; -import { priceDigits } from "@/lib/view"; import { useTx, type Write } from "@/lib/tx"; import { AmountInput, Button, Card, ChipRow, Disclosure, Segmented, TokenButton, TokenSelect, TxErrorLine, useLiveNow } from "../ui"; import { WalletAction } from "../layout/WalletMenu"; @@ -36,7 +35,10 @@ export function CreateMarketCard() { const [kind, setKind] = useState(Kind.American); const [expiry, setExpiry] = useState(); const [bbStr, setBb] = useState(""); - const [fee, setFee] = useState(1000); + // pre-v0.8 cores still take a feeBps argument; v0.8 fixes the spread share at 5% and ignores the tier + const fee = 1000; + const [autoSell, setAutoSell] = useState(true); + const [guardTouched, setGuardTouched] = useState(false); const [guard, setGuard] = useState(800); const [slow, setSlow] = useState(0); const [minVol, setMinVol] = useState(""); @@ -55,7 +57,6 @@ export function CreateMarketCard() { /** What the token button shows: the coin when it was picked, the wrapped token's own name when that was. */ const shown = (picked: string, t?: { symbol: string; onchainSymbol?: string }) => (!t ? undefined : isNative(picked) ? nat.symbol : isWrappedNative(chainId, picked) ? onchainSymbol(t) : t.symbol); const nativeBal = useNativeBalance(chainId); - const digits = priceDigits(grid); const base = grid?.batchInterval ?? deployment?.batchInterval ?? 1800; const period = grid?.period ?? deployment?.gridPeriod ?? 3600; const expiries = now ? gridExpiries(grid?.anchor ?? deployment?.gridAnchor ?? 0, period, now, Math.min(grid?.maxExpiries ?? 8, 8)) : []; @@ -64,13 +65,17 @@ export function CreateMarketCard() { if (!expiry && firstExpiry) setExpiry(firstExpiry); }, [expiry, firstExpiry]); const bb = Q ? parseAmount(bbStr, Q.decimals) : 0n; - const bbOk = bb > 0n && isGridStrike(bb, digits); - const near = Q && bb > 0n ? nearestGridStrikes(bb, digits) : undefined; + const bbOk = !!Q && bb > 0n && isLogGridStrike(bb, Q.decimals); + const near = Q && bb > 0n ? nearestGridStrikes(bb, Q.decimals) : undefined; + const std = A && Q ? standardBand(A.symbol, Q.symbol) : 800; + useEffect(() => { + if (!guardTouched) setGuard(std); + }, [std, guardTouched]); const loV = Q ? parseAmount(lo, Q.decimals) : 0n; const hiV = Q ? parseAmount(hi, Q.decimals) : 0n; const minV = A ? parseAmount(minVol, A.decimals) : 0n; const tooSlow = (c: number) => base * cadenceMultiplier(c) > period; - const m = A && Q && expiry && bbOk ? { asset: A.address, quote: Q.address, expiry, buyBack: bb, kind, feeBps: fee, guardBps: guard, slow, minVolume: minV, firstLow: loV, firstHigh: hiV } : undefined; + const m = A && Q && expiry && bbOk ? { asset: A.address, quote: Q.address, expiry, buyBack: bb, kind, feeBps: fee, guardBps: guard, autoSell, slow, minVolume: minV, firstLow: loV, firstHigh: hiV } : undefined; const pid = m ? poolId(marketKey(m)) : undefined; const exists = (usePoolStatus(pid).data ?? 0) !== 0; const bal = useBalances(useMemo(() => [...new Set([...known, ...(A ? [A.address] : []), ...(Q ? [Q.address] : [])].map((a) => a.toLowerCase()))] as Address[], [known, A, Q])).data; @@ -137,7 +142,7 @@ export function CreateMarketCard() { onChange={setBb} decimals={Q?.decimals ?? 18} token={} - error={bb > 0n && !bbOk ? `Use at most ${digits} significant digits` : undefined} + error={bb > 0n && !bbOk ? "Not on the 1% price grid" : undefined} /> {near && Q && (

    @@ -152,10 +157,17 @@ export function CreateMarketCard() {

    )}
- ({ value: f.bps, label: f.label }))} /> +

Fees are fixed for every market: borrowers pay 5% of the interest plus 0.015% of the amount traded; lenders pay 0.015%.

+
- ({ value: b.bps, label: `±${b.bps / 100}%` }))} /> -

{GUARD_NAME[guard]}. At-market orders only fill this close to last traded.

+ { setGuardTouched(true); setGuard(g); }} options={BAND_TIERS.map((b) => ({ value: b.bps, label: `±${b.bps / 100}%` }))} /> +

{GUARD_NAME[guard]}. At-market orders only fill this close to last traded.{guard === std ? " Standard for this pair: listed on Borrow and Lend." : " Not the standard range for this pair: the market shows only under Markets."}

@@ -168,14 +180,14 @@ export function CreateMarketCard() {

- One market per set of settings; the first creator sets the fee and the market-order price range. New markets are not vault-eligible until they have traded cleanly for four maturities, and the guardian may pause them for up to 7 days at a time. + One market per set of settings; the first creator sets the market-order price range and auto-sell. New markets are not vault-eligible until they have traded cleanly for four maturities, and the guardian may pause them for up to 7 days at a time.

{exists &&

A market with these exact settings already exists.

} {A && Q && !hasUnits &&

You need a tiny amount of both tokens: the contract checks that each one transfers normally.

} From fdf922b7922bcd236e719ec773ab883a452a7d05 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:10:11 -0400 Subject: [PATCH 144/169] feat(web): v0.8 PoolKey, 1% log grid and auto-sell in create market (web src/lib) Part 2/3 of: feat(web): v0.8 PoolKey, 1% log grid and auto-sell in create market Files: apps/web/src/lib/data.ts, apps/web/src/lib/demo/chain.ts, apps/web/src/lib/grid.ts, apps/web/src/lib/proActions.ts Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/data.ts | 6 ++- apps/web/src/lib/demo/chain.ts | 21 ++++++--- apps/web/src/lib/grid.ts | 84 +++++++++++++++++++++++++++------- apps/web/src/lib/proActions.ts | 5 +- 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/apps/web/src/lib/data.ts b/apps/web/src/lib/data.ts index 9f0a8fe..c8c8c47 100644 --- a/apps/web/src/lib/data.ts +++ b/apps/web/src/lib/data.ts @@ -74,6 +74,7 @@ export type PoolRow = { minFill: bigint; firstLo: bigint; firstHi: bigint; + autoSell?: boolean; // v0.8 PoolOptions.autoSell (decision B); absent on a pre-v0.8 core graduated: boolean; // market passed four clean cycles; ungraduated pools are shown greyed with a notice expiryWindowEnd: bigint; // shared repayment window of this expiry (0 until the first pool of it matures) block: bigint; // block the row was read at (pause and window checks) @@ -263,8 +264,9 @@ export async function walk(client: PublicClient, pid: Hex, side: Side, max = 128 const out: BookOrder[] = []; while (id !== 0n && out.length < max) { const o = await client.readContract({ address: c, abi: confluereAbi, functionName: "order", args: [pid, id] }); - out.push({ id, owner: o[0], side: o[1], units: o[2], limit: o[3], escrow: o[4], deadline: o[5] }); - id = o[6]; + // v0.8 Order: owner, side, flags, units, limit, escrow, deadline, next + out.push({ id, owner: o[0], side: o[1], units: o[3], limit: o[4], escrow: o[5], deadline: o[6] }); + id = o[7]; } return out; } diff --git a/apps/web/src/lib/demo/chain.ts b/apps/web/src/lib/demo/chain.ts index d9349ff..4c460d9 100644 --- a/apps/web/src/lib/demo/chain.ts +++ b/apps/web/src/lib/demo/chain.ts @@ -19,6 +19,7 @@ * * Nothing here talks to a chain. Words in this file follow the contracts; the screens keep their own vocabulary. */ +import { nearestGridStrikes } from "@/lib/grid"; import { ContractFunctionRevertedError, encodeErrorResult, parseAbi, zeroAddress, type Abi, type Address, type Hex } from "viem"; import { Kind, @@ -42,7 +43,7 @@ import { ncUnits, buyEscrow, vaultAbi, - isGridStrike, + strikeIndexOf, } from "@confluere/sdk"; import { DEMO_ADDR, DEMO_GRID, demoDeploymentOf } from "./deployments"; import { buyCost, sellOut, sellPrice } from "./sell"; @@ -114,6 +115,11 @@ type ChainSpec = { const T0 = 1_790_000_000; +/** Demo plan strikes are round numbers; v0.8 pools sit on the 1% log grid, so take the grid strike at or below. */ +function snapToGrid(k: bigint, qd: number): bigint { + return strikeIndexOf(k, qd) !== undefined ? k : (nearestGridStrikes(k, qd)?.lower ?? k); +} + function specOf(chainId: number): ChainSpec | undefined { const d = demoDeploymentOf(chainId); if (!d?.pools) return undefined; @@ -660,8 +666,8 @@ function buildHistory(net: Net) { const m = spec.markets[plan.m]!; const qd = quoteDecOf(net, m.quote); const ad = net.tokens.get(lc(m.asset))?.decimals ?? 18; - const strike = toUnits(plan.strike, qd); - const id = poolId({ asset: m.asset, quote: m.quote, expiry: BigInt(plan.expiry), strike, kind: m.kind }); + const strike = snapToGrid(toUnits(plan.strike, qd), qd); + const id = poolId({ asset: m.asset, quote: m.quote, expiry: BigInt(plan.expiry), strike, kind: m.kind, bandBps: m.band, cadence: 0 }); const r = rng(st.seed ^ hashStr(id)); const listed = Math.max(st.s0 - 30 * DAY, plan.expiry - 45 * DAY); const end = Math.min(st.s0, plan.expiry); @@ -1368,10 +1374,10 @@ export function readCall(chainId: number, c: Call): unknown { const pid = args[0] as string; const id = Number(args[1]); const o = st.orders.find((x) => x.id === id && lc(x.pool) === lc(pid)); - if (!o) return [zeroAddress, 0, 0n, 0n, 0n, 0, 0n]; + if (!o) return [zeroAddress, 0, 0, 0n, 0n, 0n, 0, 0n]; const xs = bookSide(st, pid, o.side); const nx = xs[xs.indexOf(o) + 1]; - return [o.owner, o.side, o.units, o.limit, o.escrow, o.deadline, BigInt(nx?.id ?? 0)]; + return [o.owner, o.side, 0, o.units, o.limit, o.escrow, o.deadline, BigInt(nx?.id ?? 0)]; } case "rolloverSource": { const o = st.orders.find((x) => x.id === Number(args[1]) && lc(x.pool) === lc(args[0])); @@ -1824,11 +1830,12 @@ function run(net: Net, me: Address, w: WriteCall) { } case "createPoolWithOptions": { const key = args[0] as { asset: Address; quote: Address; expiry: B; strike: B; kind: number }; - const id = poolId({ asset: key.asset, quote: key.quote, expiry: BigInt(key.expiry), strike: BigInt(key.strike), kind: key.kind }); + const cad = Number((args[3] as { cadence?: number } | undefined)?.cadence ?? 0); + const id = poolId({ asset: key.asset, quote: key.quote, expiry: BigInt(key.expiry), strike: BigInt(key.strike), kind: key.kind, bandBps: Number(args[2]), cadence: cad }); if (st.pools[id]) revert("InvalidPool", fn); const e = Number(key.expiry); const onGrid = (e - DEMO_GRID.anchor) % DEMO_GRID.period === 0 && e > t && e <= t + DEMO_GRID.period * DEMO_GRID.maxExpiries; - if (!onGrid || !isGridStrike(BigInt(key.strike), DEMO_GRID.strikeSigDigits)) revert("InvalidParams", fn); + if (!onGrid || strikeIndexOf(BigInt(key.strike), quoteDecOf(net, key.quote)) === undefined) revert("InvalidParams", fn); const at = net.tokens.get(lc(key.asset)); if (!at || !net.tokens.get(lc(key.quote))) revert("BadToken", fn); const band = Number(args[2]); diff --git a/apps/web/src/lib/grid.ts b/apps/web/src/lib/grid.ts index cca49b6..6f2457b 100644 --- a/apps/web/src/lib/grid.ts +++ b/apps/web/src/lib/grid.ts @@ -1,13 +1,45 @@ -/** Grid helpers for the create wizard (mirror the core's rules; the core re-checks everything). */ -import { isGridStrike, nextExpiry } from "@confluere/sdk"; - -/** Nearest on-grid strikes below and above `s` (`digits` significant digits after stripping trailing zeros). */ -export function nearestGridStrikes(s: bigint, digits = 2): { lower: bigint; upper: bigint } | undefined { - if (s <= 0n || isGridStrike(s, digits)) return undefined; - const len = s.toString().length; - const f = 10n ** BigInt(len - digits); - const lower = (s / f) * f; - return { lower, upper: lower + f }; +/** + * Grid helpers for the create wizard and the strike pickers (BLUEPRINT v0.8 §3.3, V08 spec §2.7). Strikes sit on a + * 1% log grid: K(n) = 10^quoteDecimals × 1.01^n, computed with the core's exact RAY integer algorithm so the web and + * Grid.sol agree bit for bit. A strike is valid only at its smallest index (the canonical n); the core re-checks. + */ +import { nextExpiry, strikeAt, strikeIndexOf } from "@confluere/sdk"; + +export const MAX_STRIKE_INDEX = 4095; + +export { strikeAt, strikeIndexOf }; + +export const isLogGridStrike = (strike: bigint, quoteDecimals: number) => strikeIndexOf(strike, quoteDecimals) !== undefined; + +function approxIndex(strike: bigint, quoteDecimals: number): number { + const x = Number(strike) / 10 ** quoteDecimals; + return Math.round(Math.log(x) / Math.log(1.01)); +} + +/** Nearest grid strikes below and above `s` (undefined when `s` is already on the grid). */ +export function nearestGridStrikes(s: bigint, quoteDecimals: number): { lower: bigint; upper: bigint } | undefined { + if (s <= 0n || isLogGridStrike(s, quoteDecimals)) return undefined; + let n = Math.max(-MAX_STRIKE_INDEX, Math.min(MAX_STRIKE_INDEX, approxIndex(s, quoteDecimals))); + while (n > -MAX_STRIKE_INDEX && strikeAt(n, quoteDecimals) > s) n--; + while (n < MAX_STRIKE_INDEX && strikeAt(n + 1, quoteDecimals) <= s) n++; + const lower = strikeAt(n, quoteDecimals); + let m = n + 1; + while (m < MAX_STRIKE_INDEX && strikeAt(m, quoteDecimals) === lower) m++; + return { lower, upper: strikeAt(m, quoteDecimals) }; +} + +/** `count` distinct grid strikes centred on `s` (for the Borrow / Lend / Markets strike pickers). */ +export function gridStrikesAround(s: bigint, quoteDecimals: number, count = 7): { n: number; strike: bigint }[] { + if (s <= 0n) return []; + const c = approxIndex(s, quoteDecimals); + const out: { n: number; strike: bigint }[] = []; + const half = Math.floor(count / 2); + for (let n = c - half; n <= c + half; n++) { + if (Math.abs(n) > MAX_STRIKE_INDEX) continue; + const k = strikeAt(n, quoteDecimals); + if (k > 0n && !out.some((o) => o.strike === k)) out.push({ n, strike: k }); + } + return out; } /** The next `n` expiries on the grid after `now`. */ @@ -17,14 +49,34 @@ export function gridExpiries(anchor: number, period: number, now: number, n: num return Array.from({ length: Math.max(0, n) }, (_, i) => first + i * period); } -export const FEE_TIERS = [ - { bps: 1000, label: "10% of interest" }, - { bps: 1250, label: "12.5% of interest" }, - { bps: 1500, label: "15% of interest" }, -]; - export const BAND_TIERS = [ { bps: 150, label: "±1.5% (pegged)", note: "For pairs that track each other, like an LST against its native coin." }, { bps: 800, label: "±8% (mainstream)", note: "For liquid majors against a dollar token." }, { bps: 1500, label: "±15% (long-tail)", note: "For new or volatile assets; pair with a slower cadence." }, ]; + +export type AssetClass = "pegged" | "major" | "longtail"; + +const MAJORS = new Set(["ETH", "WETH", "BTC", "WBTC", "UBTC", "HYPE", "WHYPE", "MON", "WMON", "SOL", "USOL", "UETH", "STETH", "WSTETH", "SHMON", "KHYPE", "STHYPE"]); +const DOLLARS = /^(usd|dai|feusd)/i; + +/** Asset class of a pair: pegged (two dollars, or a staked token against its own coin), major, or long-tail. */ +export function assetClass(assetSymbol: string, quoteSymbol: string): AssetClass { + const a = assetSymbol.toUpperCase(); + const q = quoteSymbol.toUpperCase(); + if (DOLLARS.test(a) && DOLLARS.test(q)) return "pegged"; + const base = q.startsWith("W") ? q.slice(1) : q; + if (!DOLLARS.test(q) && a !== q && base.length >= 3 && a.includes(base)) return "pegged"; + if (MAJORS.has(a)) return "major"; + return "longtail"; +} + +/** Standard band for a pair (BLUEPRINT v0.8 §3.3: 1.5% / 8% / 15% by asset class); lists show only this tier at cadence 1×. */ +export function standardBand(assetSymbol: string, quoteSymbol: string): number { + const c = assetClass(assetSymbol, quoteSymbol); + return c === "pegged" ? 150 : c === "major" ? 800 : 1500; +} + +/** A pool is in the standard tier when its band matches the pair's class and it trades at base cadence. */ +export const isStandardTier = (p: { bandBps?: number; cadence?: number }, assetSymbol: string, quoteSymbol: string) => + (p.bandBps === undefined || p.bandBps === standardBand(assetSymbol, quoteSymbol)) && (p.cadence ?? 0) === 0; diff --git a/apps/web/src/lib/proActions.ts b/apps/web/src/lib/proActions.ts index 59de99c..5ef5c59 100644 --- a/apps/web/src/lib/proActions.ts +++ b/apps/web/src/lib/proActions.ts @@ -94,13 +94,16 @@ export type NewMarket = { kind: Kind; feeBps: number; guardBps: number; + /** v0.8 PoolOptions.autoSell; the pre-v0.8 createPool ABI has no slot for it, so it is sent once the SDK is regenerated. */ + autoSell?: boolean; slow: number; minVolume: bigint; firstLow: bigint; firstHigh: bigint; }; -export const marketKey = (m: Pick) => ({ asset: m.asset, quote: m.quote, expiry: BigInt(m.expiry), strike: m.buyBack, kind: m.kind }); +/** v0.8 PoolKey: band and cadence are part of the pool id (V08 spec §2.3). */ +export const marketKey = (m: Pick) => ({ asset: m.asset, quote: m.quote, expiry: BigInt(m.expiry), strike: m.buyBack, kind: m.kind, bandBps: m.guardBps, cadence: m.slow }); export const openMarketWrite = (core: Address, m: NewMarket): Write => ({ label: "Open market", From e13f6b66548fd24006a64f78bf8088e23b6f0d7d Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:10:11 -0400 Subject: [PATCH 145/169] feat(web): v0.8 PoolKey, 1% log grid and auto-sell in create market (web test) Part 3/3 of: feat(web): v0.8 PoolKey, 1% log grid and auto-sell in create market Files: apps/web/test/more.test.tsx Co-Authored-By: Claude Opus 5.5 --- apps/web/test/more.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/test/more.test.tsx b/apps/web/test/more.test.tsx index 63f9f8f..020182b 100644 --- a/apps/web/test/more.test.tsx +++ b/apps/web/test/more.test.tsx @@ -97,11 +97,11 @@ describe("markets pro view", () => { }); describe("create market", () => { - it("one card: collateral and loan asset, repayment, maturity, buy-back price with the grid check, fee and price range; the button names the next step", () => { + it("one card: collateral and loan asset, repayment, maturity, buy-back price with the grid check, fixed fees, auto-sell and price range; the button names the next step", () => { wrap(); - for (const t of ["Collateral asset", "Loan asset", "Repayment", "Fee", "Market-order price range"]) expect(screen.getAllByText(t).length).toBeGreaterThan(0); + for (const t of ["Collateral asset", "Loan asset", "Repayment", "Auto-sell defaulted collateral", "Market-order price range"]) expect(screen.getAllByText(t).length).toBeGreaterThan(0); expect(screen.getByRole("button", { name: "Select both assets" })).toBeTruthy(); - expect(screen.getByText(/One market per set of settings; the first creator sets the fee and the market-order price range\./)).toBeTruthy(); + expect(screen.getByText(/One market per set of settings; the first creator sets the market-order price range and auto-sell\./)).toBeTruthy(); expect(document.body.textContent).not.toMatch(/strike|band|cadence|batch/i); }); }); From e6f1de21cb170e0a0cc0a83177ee6fd450739bb9 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:10:11 -0400 Subject: [PATCH 146/169] feat(web): Specials page, selling status and quote-only vault copy (web src/app) Specials (decision A): lists European pools that can lend the asset, with the 120% margin per unit, the Thursday return deadline and the overdue rule (no buy-in, margin forfeited, short repay gets asset first then K plus margin share); lend/return stay disabled until the deployment carries the Specials address. An auto-sell pool past its window reads as selling defaulted collateral with claims later. Vault copy says withdrawals are paid in the loan token and vaults lend only into auto-sell markets. Tests cover grid vectors, standard bands, Specials math and the selling phase. Part 1/4 of: feat(web): Specials page, selling status and quote-only vault copy Files: apps/web/src/app/specials/page.tsx Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/specials/page.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/web/src/app/specials/page.tsx diff --git a/apps/web/src/app/specials/page.tsx b/apps/web/src/app/specials/page.tsx new file mode 100644 index 0000000..fff658b --- /dev/null +++ b/apps/web/src/app/specials/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { SpecialsScreen } from "@/components/specials/SpecialsScreen"; + +export const metadata: Metadata = { title: "Specials | Confluere" }; + +export default function Page() { + return ( + + + + ); +} From 2e9c17001b595e4a0536daf71566344eca9f274a Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:10:11 -0400 Subject: [PATCH 147/169] feat(web): Specials page, selling status and quote-only vault copy (web src/components) Part 2/4 of: feat(web): Specials page, selling status and quote-only vault copy Files: apps/web/src/components/earn/EarnScreen.tsx, apps/web/src/components/earn/VaultDrawer.tsx, apps/web/src/components/layout/nav.ts, apps/web/src/components/specials/SpecialsScreen.tsx Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/earn/EarnScreen.tsx | 2 +- apps/web/src/components/earn/VaultDrawer.tsx | 2 +- apps/web/src/components/layout/nav.ts | 1 + .../components/specials/SpecialsScreen.tsx | 72 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/specials/SpecialsScreen.tsx diff --git a/apps/web/src/components/earn/EarnScreen.tsx b/apps/web/src/components/earn/EarnScreen.tsx index 7cd75d1..808e1e3 100644 --- a/apps/web/src/components/earn/EarnScreen.tsx +++ b/apps/web/src/components/earn/EarnScreen.tsx @@ -60,7 +60,7 @@ export function EarnScreen() { Vaults setVault(a, { pay })} />

- Rates are what the vault would earn right now, after the 0.015% trade fee. Withdrawals are paid in one token. Prefer to choose a maturity yourself?{" "} + Rates are what the vault would earn right now, after the 0.015% trade fee. Withdrawals are paid in the loan token: vaults lend only into auto-sell markets, so defaulted collateral is sold in the next maturity's rounds first. Prefer to choose a maturity yourself?{" "} Lend in one market diff --git a/apps/web/src/components/earn/VaultDrawer.tsx b/apps/web/src/components/earn/VaultDrawer.tsx index c0a9798..b3e1bf5 100644 --- a/apps/web/src/components/earn/VaultDrawer.tsx +++ b/apps/web/src/components/earn/VaultDrawer.tsx @@ -209,7 +209,7 @@ function VaultBody({ v, quote, asset, role, native, wrapped = false }: { v: Vaul } /> {rw.picker} -

Withdrawals are requested now and paid at the end of this period ({periodEnd}), first come first served. If the vault's funds are out on loan, they are paid when those loans reach maturity.

+

Withdrawals are requested now and paid at the end of this period ({periodEnd}), first come first served. If the vault's funds are out on loan, they are paid when those loans reach maturity. You are paid in the loan token: the vault lends only into markets that auto-sell defaulted collateral in the next maturity's rounds; any small part that does not sell is claimable by share.

{me && me.pendingRedeem > 0n && Withdrawal requested · payable after {ended ? "the next roll" : }.} {me && me.claimAssets > 0n && ( + + ); + })} + + )} + +
+ ); +} From 79d3016f32e00c26837adea5aa5f43667187431d Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:10:11 -0400 Subject: [PATCH 148/169] feat(web): Specials page, selling status and quote-only vault copy (web src/lib) Part 3/4 of: feat(web): Specials page, selling status and quote-only vault copy Files: apps/web/src/lib/specials.ts, apps/web/src/lib/window.ts Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/specials.ts | 66 ++++++++++++++++++++++++++++++++++++ apps/web/src/lib/window.ts | 6 ++-- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/specials.ts diff --git a/apps/web/src/lib/specials.ts b/apps/web/src/lib/specials.ts new file mode 100644 index 0000000..d496a48 --- /dev/null +++ b/apps/web/src/lib/specials.ts @@ -0,0 +1,66 @@ +/** + * Specials: borrow the asset itself out of a European pool (BLUEPRINT v0.8 §7.9, decision A, V08 spec §2.11 / §3.5). + * Pure math mirroring Specials.open / close and the core's overdue accounting; the core re-checks everything. + * + * - Margin: at least 120% of the units' value at the pool's last clearing price, rounded up, posted in quote. + * - Deadline: expiry − LEND_CUTOFF (Thursday 20:00 UTC for a Friday expiry on mainnet). Return the same token before it. + * - No buy-in. Units not returned by the deadline become `shortUnits` and the whole margin is forfeited into the pool + * as `shortMargin`. A borrower who repays then gets the pool's asset first; for units the pool cannot deliver, the + * repayment K is credited back plus compensation shortMargin × units / shortUnits (pull, via pending). Short margin + * nobody claimed goes to the C holders when the window closes. + */ +import { Kind } from "@confluere/sdk"; + +export const MARGIN_BPS = 12_000n; // 120% +export const DEFAULT_LEND_CUTOFF = 12 * 3600; // mainnet: 12 h before a Friday 08:00 UTC expiry = Thursday 20:00 UTC + +const ceilDiv = (a: bigint, b: bigint) => (a + b - 1n) / b; + +/** Minimum margin in quote for `units` at `price` (quote per whole asset), asset scale `scale`. */ +export function minMargin(units: bigint, price: bigint, scale: bigint): bigint { + if (units <= 0n || price <= 0n) return 0n; + return ceilDiv(units * price * MARGIN_BPS, scale * 10_000n); +} + +/** Deadline to return lent units: expiry − cutoff. */ +export const lendDeadline = (expiry: number, cutoff = DEFAULT_LEND_CUTOFF) => expiry - cutoff; + +/** Only European pools still trading before the deadline, with a price and asset in the pool, can lend. */ +export function canLend(p: { kind: Kind; expiry: number; lastClearingPrice: bigint; assetBal: bigint }, now: number, cutoff = DEFAULT_LEND_CUTOFF): boolean { + return p.kind === Kind.European && p.lastClearingPrice > 0n && p.assetBal > 0n && now < lendDeadline(p.expiry, cutoff); +} + +/** Margin returned when `units` of a loan are handed back (full return releases the whole margin). */ +export const marginOut = (loan: { units: bigint; margin: bigint }, units: bigint) => + units >= loan.units ? loan.margin : (loan.margin * units) / loan.units; + +export type LoanState = "open" | "due-soon" | "overdue" | "closed"; + +export function loanState(loan: { units: bigint }, expiry: number, now: number, cutoff = DEFAULT_LEND_CUTOFF): LoanState { + if (loan.units === 0n) return "closed"; + const d = lendDeadline(expiry, cutoff); + if (now >= d) return "overdue"; + return d - now < 24 * 3600 ? "due-soon" : "open"; +} + +/** + * What a borrower repaying `units` R receives when the pool is short (decision A): asset from the pool first; for the + * rest, K back in quote plus a share of the forfeited margin. + */ +export function shortRepay( + units: bigint, + pool: { assetAvailable: bigint; shortUnits: bigint; shortMargin: bigint; strike: bigint }, + scale: bigint, +): { asset: bigint; quoteBack: bigint; compensation: bigint } { + const asset = units < pool.assetAvailable ? units : pool.assetAvailable; + const short = units - asset; + if (short === 0n || pool.shortUnits === 0n) return { asset, quoteBack: 0n, compensation: 0n }; + return { asset, quoteBack: ceilDiv(short * pool.strike, scale), compensation: (pool.shortMargin * short) / pool.shortUnits }; +} + +export const LOAN_LABEL: Record = { + open: "Open", + "due-soon": "Return soon", + overdue: "Overdue · margin forfeited", + closed: "Returned", +}; diff --git a/apps/web/src/lib/window.ts b/apps/web/src/lib/window.ts index 28d1037..c4fd1e9 100644 --- a/apps/web/src/lib/window.ts +++ b/apps/web/src/lib/window.ts @@ -15,13 +15,14 @@ export function canRepay(p: PoolRow, now: number, block: bigint): boolean { return windowOpen(p, now, block) || (p.kind === Kind.American && p.status === Status.Open && now < p.expiry); } -export type Phase = "trading" | "awaiting" | "window" | "closing" | "closed" | "none"; +export type Phase = "trading" | "awaiting" | "window" | "closing" | "selling" | "closed" | "none"; /** Lifecycle phase as the UI names it (FRONTEND_DESIGN §6.0 status language). */ export function phase(p: PoolRow, now: number): Phase { if (p.status === Status.Open) return now < p.expiry ? "trading" : "awaiting"; if (p.status === Status.Window) return "window"; - if (p.status === Status.Closing) return "closing"; + // v0.8 decision B: an auto-sell pool past its window is selling its defaulted asset in the next maturity + if (p.status === Status.Closing) return p.autoSell ? "selling" : "closing"; if (p.status === Status.Closed) return "closed"; return "none"; } @@ -31,6 +32,7 @@ export const PHASE_LABEL: Record = { awaiting: "Expired · awaiting maturity", window: "Repayment window", closing: "Closing", + selling: "Selling defaulted collateral · claims open later", closed: "Closed", none: "Unknown", }; From 7ba66f20784c9dc32bf952dbfb462a4cf427ce1e Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:10:11 -0400 Subject: [PATCH 149/169] feat(web): Specials page, selling status and quote-only vault copy (web test) Part 4/4 of: feat(web): Specials page, selling status and quote-only vault copy Files: apps/web/test/shell.test.tsx, apps/web/test/v08-web.test.tsx Co-Authored-By: Claude Opus 5.5 --- apps/web/test/shell.test.tsx | 4 +- apps/web/test/v08-web.test.tsx | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 apps/web/test/v08-web.test.tsx diff --git a/apps/web/test/shell.test.tsx b/apps/web/test/shell.test.tsx index 7086a05..4375c9c 100644 --- a/apps/web/test/shell.test.tsx +++ b/apps/web/test/shell.test.tsx @@ -48,11 +48,11 @@ describe("top bar", () => { expect(within(nav).getByRole("link", { name: "Earn" }).getAttribute("aria-current")).toBe("page"); }); - it("More holds Demo and Developers, no theme switch", () => { + it("More holds Specials, Demo and Developers, no theme switch", () => { wrap(); fireEvent.click(screen.getByTestId("more-menu")); const menu = screen.getByRole("menu", { name: "More" }); - expect(within(menu).getAllByRole("menuitem").map((a) => a.textContent)).toEqual(["Demo", "Developers"]); + expect(within(menu).getAllByRole("menuitem").map((a) => a.textContent)).toEqual(["Specials", "Demo", "Developers"]); expect(within(menu).queryAllByRole("menuitemradio")).toHaveLength(0); // dark only, no theme switch }); diff --git a/apps/web/test/v08-web.test.tsx b/apps/web/test/v08-web.test.tsx new file mode 100644 index 0000000..21fa620 --- /dev/null +++ b/apps/web/test/v08-web.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { Kind, Status } from "@confluere/sdk"; +import { assetClass, gridStrikesAround, isLogGridStrike, nearestGridStrikes, standardBand, strikeAt, strikeIndexOf } from "@/lib/grid"; +import { canLend, lendDeadline, loanState, marginOut, minMargin, shortRepay } from "@/lib/specials"; +import { phase, PHASE_LABEL } from "@/lib/window"; +import type { PoolRow } from "@/lib/data"; + +describe("1% log grid (V08 spec §2.7 vectors)", () => { + it("matches the core's integer algorithm", () => { + const v: [number, number, bigint][] = [ + [0, 6, 1_000_000n], + [1, 6, 1_010_000n], + [-1, 6, 990_099n], + [-384, 6, 21_907n], + [769, 6, 2_104_439_470n], + [805, 6, 3_010_966_302n], + [1157, 6, 99_960_740_922n], + [-7, 18, 932_718_054_707_135_373n], + [-453, 18, 11_025_992_086_967_947n], + ]; + for (const [n, d, k] of v) { + expect(strikeAt(n, d)).toBe(k); + expect(strikeIndexOf(k, d)).toBe(n); + } + }); + it("rejects off-grid strikes and offers the neighbours", () => { + expect(isLogGridStrike(3_000_000_000n, 6)).toBe(false); + const near = nearestGridStrikes(3_000_000_000n, 6)!; + expect(near.lower).toBe(strikeAt(804, 6)); + expect(near.upper).toBe(strikeAt(805, 6)); + expect(nearestGridStrikes(strikeAt(805, 6), 6)).toBeUndefined(); + const around = gridStrikesAround(3_000_000_000n, 6, 5); + expect(around.length).toBe(5); + expect(around.every((s) => isLogGridStrike(s.strike, 6))).toBe(true); + }); + it("standard band by asset class", () => { + expect(standardBand("wstETH", "WETH")).toBe(150); + expect(standardBand("shMON", "WMON")).toBe(150); + expect(standardBand("USDC", "USDT0")).toBe(150); + expect(standardBand("WETH", "USDC")).toBe(800); + expect(standardBand("WHYPE", "USDT0")).toBe(800); + expect(assetClass("PURR", "USDC")).toBe("longtail"); + expect(standardBand("PURR", "USDC")).toBe(1500); + }); +}); + +describe("Specials (decision A)", () => { + const S = 10n ** 18n; + it("120% margin, rounded up", () => { + expect(minMargin(S, 3_000_000_000n, S)).toBe(3_600_000_000n); + expect(minMargin(1n, 1n, S)).toBe(1n); + }); + it("only European pools before the deadline lend", () => { + const p = { kind: Kind.European, expiry: 1_000_000, lastClearingPrice: 1n, assetBal: 5n }; + expect(canLend(p, lendDeadline(p.expiry) - 1)).toBe(true); + expect(canLend(p, lendDeadline(p.expiry))).toBe(false); + expect(canLend({ ...p, kind: Kind.American }, 0)).toBe(false); + expect(loanState({ units: 1n }, p.expiry, lendDeadline(p.expiry))).toBe("overdue"); + expect(loanState({ units: 0n }, p.expiry, 0)).toBe("closed"); + }); + it("partial return releases margin pro rata", () => { + expect(marginOut({ units: 4n, margin: 10n }, 1n)).toBe(2n); + expect(marginOut({ units: 4n, margin: 10n }, 4n)).toBe(10n); + }); + it("short pool: asset first, then K back plus margin share", () => { + const r = shortRepay(3n * S, { assetAvailable: S, shortUnits: 4n * S, shortMargin: 4_000n, strike: 1_000n }, S); + expect(r).toEqual({ asset: S, quoteBack: 2_000n, compensation: 2_000n }); + }); +}); + +describe("auto-sell status", () => { + it("an auto-sell pool past its window reads as selling", () => { + const p = { status: Status.Closing, autoSell: true } as unknown as PoolRow; + expect(phase(p, 0)).toBe("selling"); + expect(PHASE_LABEL.selling).toMatch(/claims open later/); + expect(phase({ ...p, autoSell: false }, 0)).toBe("closing"); + }); +}); From 37ff156a1f5b87b2cbd950411b2b8972113c2b45 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:11:16 -0400 Subject: [PATCH 150/169] feat(bootstrap-mm): per-market exit by seat or weekly volume, buyer fee in spend v0.8 (spec 4.4): the fixed 8-week run is gone. A market leaves bootstrap for good once it has a seat holder or its weekly volume (marketStats volume over the expiries of the last 7 days) reaches EXIT_WEEKLY_VOLUME, a deploy parameter (0 = seats only); the process exits when every market is done or at END_AT. Spend counts OrderFilled quoteAmount + fee, units are sized so cost + the 0.015% buyer fee fit the batch budget, and the order() tuple is read with the new flags field. Part 1/2 of: feat(bootstrap-mm): per-market exit by seat or weekly volume, buyer fee in spend Files: services/bootstrap-mm/src/bot.ts, services/bootstrap-mm/src/index.ts, services/bootstrap-mm/src/rules.ts Co-Authored-By: Claude Opus 5.5 --- services/bootstrap-mm/src/bot.ts | 51 +++++++++++++++++++++++------- services/bootstrap-mm/src/index.ts | 3 +- services/bootstrap-mm/src/rules.ts | 24 +++++++++----- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/services/bootstrap-mm/src/bot.ts b/services/bootstrap-mm/src/bot.ts index 0021851..154d4ee 100644 --- a/services/bootstrap-mm/src/bot.ts +++ b/services/bootstrap-mm/src/bot.ts @@ -11,7 +11,7 @@ import { type Deployment, } from "@confluere/sdk"; import { erc20Abi, getAbiItem, maxUint256, parseEventLogs, type Account, type Address, type Hex, type PublicClient, type WalletClient } from "viem"; -import { committed, exitReason, quote, type PoolClass } from "./rules.js"; +import { committed, exitReason, marketDone, quote, type MarketExit, type PoolClass } from "./rules.js"; export type Io = { pub: PublicClient; wallet: WalletClient; account: Account; d: Deployment; chainId: number }; @@ -21,6 +21,8 @@ export type BotConfig = { total: bigint; endAt: number; leadS: number; + /** Weekly quote volume per market at which the bot leaves that market (deploy parameter; 0 = seats only). */ + exitWeeklyVolume?: bigint; }; /** A bid of ours still resting in the book: its escrow counts against the cap until we know what it actually paid. */ @@ -112,14 +114,21 @@ export class Bot { this.state.startedAt = now; this.save(); } - const why = exitReason({ now, startedAt: this.state.startedAt, endAt: this.cfg.endAt, marketsSeated: await this.marketsSeated() }); + const markets = await this.marketExits(now); + const exitLine = this.cfg.exitWeeklyVolume ?? 0n; + const why = exitReason({ now, endAt: this.cfg.endAt, markets: [...markets.values()], exitWeeklyVolume: exitLine }); if (why) { this.state.exited = why; this.save(); this.log("bootstrap exit", { reason: why }); return; } - for (const p of this.cfg.pools) await this.quotePool(p.id, p.cls, now); + for (const p of this.cfg.pools) { + const v = await this.poolView(p.id); + const m = markets.get(marketId(v.key.asset, v.key.quote, Number(v.key.kind))); + if (m && marketDone(m, exitLine)) continue; // this market has left bootstrap for good + await this.quotePool(p.id, p.cls, now); + } } /** @@ -167,7 +176,8 @@ export class Bot { for (let lo = from; lo <= head; lo += chunk) { const hi = lo + chunk - 1n < head ? lo + chunk - 1n : head; const logs = await this.io.pub.getLogs({ address: this.core, event: orderFilled, args: { poolId: pid, orderId: id }, fromBlock: lo, toBlock: hi }); - for (const l of logs) paid += l.args.quoteAmount ?? 0n; + // v0.8: a buyer pays the clearing cost plus its 0.015% trade fee; both count against the cap + for (const l of logs) paid += (l.args.quoteAmount ?? 0n) + (l.args.fee ?? 0n); } return paid; } @@ -220,18 +230,35 @@ export class Bot { } /** Seat holder present for each quoted market (blueprint 9: seats replace the bootstrap MM). */ - private async marketsSeated(): Promise { + /** + * Per market: seat holder present, and quote volume over the last week (sum of marketStats(m, e).volume over the + * expiries e in (now - 7 days, next expiry]; one expiry on the weekly grid, seven or eight on the daily grid). The + * volume read is skipped when no exit line is set. + */ + private async marketExits(now: number): Promise> { const reg = this.io.d.seatRegistry; - if (!reg) return []; const markets = new Set(); for (const p of this.cfg.pools) { const v = await this.poolView(p.id); markets.add(marketId(v.key.asset, v.key.quote, Number(v.key.kind))); } - const out: boolean[] = []; + const out = new Map(); + const { gridAnchor: anchor, gridPeriod: period } = this.io.d; for (const m of markets) { - const seat = await this.io.pub.readContract({ address: reg, abi: seatRegistryAbi, functionName: "seats", args: [m] }); - out.push(seat[0] !== ZERO); + let seated = false; + if (reg) { + const seat = await this.io.pub.readContract({ address: reg, abi: seatRegistryAbi, functionName: "seats", args: [m] }); + seated = seat[0] !== ZERO; + } + let weeklyVolume = 0n; + if ((this.cfg.exitWeeklyVolume ?? 0n) > 0n && period > 0) { + const next = anchor + (Math.floor((now - anchor) / period) + 1) * period; + for (let e = next; e > now - 7 * 86_400 && e >= anchor; e -= period) { + const st = await this.io.pub.readContract({ address: this.core, abi: confluereAbi, functionName: "marketStats", args: [m, BigInt(e)] }); + weeklyVolume += BigInt(st.volume); + } + } + out.set(m, { seated, weeklyVolume }); } return out; } @@ -241,9 +268,9 @@ export class Bot { let units = 0n; while (id !== 0n) { const o = await this.io.pub.readContract({ address: this.core, abi: confluereAbi, functionName: "order", args: [pid, id] }); - if (o[3] < limit) break; - if (o[0].toLowerCase() !== this.io.account.address.toLowerCase()) units += o[2]; - id = o[6]; + if (o[4] < limit) break; + if (o[0].toLowerCase() !== this.io.account.address.toLowerCase()) units += o[3]; + id = o[7]; } return units; } diff --git a/services/bootstrap-mm/src/index.ts b/services/bootstrap-mm/src/index.ts index b2f5b3c..b1b5962 100644 --- a/services/bootstrap-mm/src/index.ts +++ b/services/bootstrap-mm/src/index.ts @@ -33,7 +33,7 @@ for (const s of (env.POOLS ?? "").split(",").filter(Boolean)) { pools.push({ id, cls: classOf(v.key.asset, v.key.quote, d.pools) }); } // Cumulative spend per pool, the first start and the exit must survive restarts, or the published caps and the -// 8-week limit would reset on every redeploy. +// exit would reset on every redeploy. const stateFile = env.STATE_FILE ?? `bootstrap-mm-state-${chainId}.json`; const raw = existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, "utf8")) : {}; const saved: Saved = raw.spent ? raw : { spent: raw }; // older files held only the spend map @@ -47,6 +47,7 @@ const bot = new Bot( total: BigInt(env.TOTAL_QUOTE ?? "0"), endAt: Number(env.END_AT ?? 0), leadS: Number(env.LEAD_S ?? 90), + exitWeeklyVolume: BigInt(env.EXIT_WEEKLY_VOLUME ?? "0"), }, saved, save, diff --git a/services/bootstrap-mm/src/rules.ts b/services/bootstrap-mm/src/rules.ts index 2d4f940..1e9a77f 100644 --- a/services/bootstrap-mm/src/rules.ts +++ b/services/bootstrap-mm/src/rules.ts @@ -4,8 +4,10 @@ * - quantity is capped per pool, per batch (PER_BATCH_QUOTE) and in total (TOTAL_QUOTE). The total counts quote * actually paid for fills plus the escrow of bids still resting; escrow refunded by an unfilled bid does not count; * - only fills the gap: if competitive buy depth at or above that price already covers the batch amount, do nothing; - * - exits for good after 8 weeks from its first start, once every market it quotes has a seat holder, or at END_AT - * if the operator set an earlier date. + * - stops quoting a market for good once it has a seat holder or its weekly volume reached EXIT_WEEKLY_VOLUME (a + * deploy parameter, blueprint 13 q3); exits when every market it quotes is done, or at END_AT (v0.8: no fixed + * 8-week run any more); + * - the buyer's trade fee (0.015%) is paid out of the same budget: units are sized so cost + fee fits it. */ export type PoolClass = "pegged" | "main"; @@ -44,7 +46,8 @@ export function quote(i: Inputs): Quote { const limit = (i.lastClearingPrice * (10_000n - SPREAD_BPS[i.cls])) / 10_000n; if (limit === 0n) return null; const budget = i.perBatchQuote < i.remainingQuote ? i.perBatchQuote : i.remainingQuote; - const target = (budget * i.scale) / limit; + // budget covers cost + the buyer's 0.015% trade fee (SDK TRADE_FEE_PPM) + const target = ((budget * 1_000_000n) / 1_000_150n * i.scale) / limit; if (i.competitiveDepthUnits >= target) return null; const units = target - i.competitiveDepthUnits; return units > 0n ? { limit, units } : null; @@ -55,15 +58,20 @@ export function committed(spent: bigint, openEscrows: bigint[]): bigint { return openEscrows.reduce((a, b) => a + b, spent); } -/** Fixed run length from the first start (blueprint 4.2: "run for 8 weeks"). Not configurable on purpose. */ -export const RUN_S = 8 * 7 * 86_400; +/** One market the bot quotes: whether a seat holder took it and its quote volume over the last week. */ +export type MarketExit = { seated: boolean; weeklyVolume: bigint }; -export type ExitInputs = { now: number; startedAt: number; endAt: number; marketsSeated: boolean[] }; +/** A market is done (never quoted again) once seated or once its weekly volume reached the exit line (0 = no line). */ +export const marketDone = (m: MarketExit, exitWeeklyVolume: bigint): boolean => + m.seated || (exitWeeklyVolume > 0n && m.weeklyVolume >= exitWeeklyVolume); + +export type ExitInputs = { now: number; endAt: number; markets: MarketExit[]; exitWeeklyVolume: bigint }; /** Why the bot must stop now, or null to keep running. Exiting is permanent: the caller persists the reason. */ export function exitReason(i: ExitInputs): string | null { - if (i.now >= i.startedAt + RUN_S) return "eight weeks elapsed"; - if (i.marketsSeated.length > 0 && i.marketsSeated.every(Boolean)) return "seats cover every market"; + if (i.markets.length > 0 && i.markets.every((m) => marketDone(m, i.exitWeeklyVolume))) { + return i.markets.every((m) => m.seated) ? "seats cover every market" : "every market seated or past the weekly volume line"; + } if (i.endAt > 0 && i.now >= i.endAt) return "END_AT reached"; return null; } From 933e3b8706fb775c1d6f76a57f65d634dd92b504 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:11:16 -0400 Subject: [PATCH 151/169] feat(bootstrap-mm): per-market exit by seat or weekly volume, buyer fee in spend Part 2/2 of: feat(bootstrap-mm): per-market exit by seat or weekly volume, buyer fee in spend Files: services/bootstrap-mm/test/bot.test.ts, services/bootstrap-mm/test/rules.test.ts Co-Authored-By: Claude Opus 5.5 --- services/bootstrap-mm/test/bot.test.ts | 6 +++-- services/bootstrap-mm/test/rules.test.ts | 29 ++++++++++++++---------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/services/bootstrap-mm/test/bot.test.ts b/services/bootstrap-mm/test/bot.test.ts index aa8ef2d..b0d3622 100644 --- a/services/bootstrap-mm/test/bot.test.ts +++ b/services/bootstrap-mm/test/bot.test.ts @@ -52,7 +52,7 @@ function world() { switch (fn) { case "pool": return { - key: { asset: ETH, quote: USD, expiry: 99_999n, strike: 1n, kind: 0 }, + key: { asset: ETH, quote: USD, expiry: 99_999n, strike: 1n, kind: 0, bandBps: 800, cadence: 0 }, status: s.status[pid] ?? Status.None, assetDecimals: 18, lastClearingPrice: 1_000_000n, @@ -65,7 +65,7 @@ function world() { const o = s.orders.get(args[1] as bigint); const at = blockNumber ?? s.block; const live = o !== undefined && o.block <= at && (o.removedAt === undefined || o.removedAt > at); - return [live ? ME : ZERO, 1, 0n, 0n, 0n, 0, 0n]; + return [live ? ME : ZERO, 1, 0, 0n, 0n, 0n, 0, 0n]; } case "findHint": return 0n; @@ -75,6 +75,8 @@ function world() { return s.pending; case "seats": return [ZERO, 0n, 0n, 0n]; + case "marketStats": + return { volume: 0n, fees: 0n, mainVolume: 0n, mainStrike: 0n, mainIndex: 0, mainBand: 0, mainCadence: 0, bad: false }; case "balanceOf": return s.cBalance[`0x${(args[1] as bigint).toString(16).padStart(64, "0")}` as Hex] ?? 0n; } diff --git a/services/bootstrap-mm/test/rules.test.ts b/services/bootstrap-mm/test/rules.test.ts index dfe874b..3b78943 100644 --- a/services/bootstrap-mm/test/rules.test.ts +++ b/services/bootstrap-mm/test/rules.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { classOf, exitReason, isPoolClass, quote, RUN_S } from "../src/rules.js"; +import { classOf, exitReason, isPoolClass, marketDone, quote } from "../src/rules.js"; const base = { cls: "main" as const, @@ -58,21 +58,26 @@ describe("bootstrap bands by pair", () => { }); }); -describe("bootstrap exit", () => { - const e = { now: 1_000, startedAt: 1_000, endAt: 0, marketsSeated: [true, false] }; - it("keeps running inside eight weeks while a market has no seat holder", () => { +describe("bootstrap exit (v0.8: per market, seat or weekly volume)", () => { + const m = (seated: boolean, weeklyVolume = 0n) => ({ seated, weeklyVolume }); + const e = { now: 1_000, endAt: 0, markets: [m(true), m(false)], exitWeeklyVolume: 0n }; + it("keeps running while a market has no seat holder and no exit line, however long it ran", () => { expect(exitReason(e)).toBeNull(); - expect(exitReason({ ...e, now: 1_000 + RUN_S - 1 })).toBeNull(); - }); - it("exits after eight weeks from the first start, whatever END_AT says", () => { - expect(RUN_S).toBe(56 * 86_400); - expect(exitReason({ ...e, now: 1_000 + RUN_S, endAt: 1_000 + 2 * RUN_S })).toBe("eight weeks elapsed"); + expect(exitReason({ ...e, now: 1_000 + 100 * 86_400 })).toBeNull(); }); it("exits once every market has a seat holder", () => { - expect(exitReason({ ...e, marketsSeated: [true, true] })).toBe("seats cover every market"); + expect(exitReason({ ...e, markets: [m(true), m(true)] })).toBe("seats cover every market"); + }); + it("a market leaves once its weekly volume reaches the exit line", () => { + expect(marketDone(m(false, 99n), 100n)).toBe(false); + expect(marketDone(m(false, 100n), 100n)).toBe(true); + expect(marketDone(m(false, 10n ** 30n), 0n)).toBe(false); // no line: seats only + expect(exitReason({ ...e, markets: [m(true), m(false, 100n)], exitWeeklyVolume: 100n })).toBe( + "every market seated or past the weekly volume line", + ); }); - it("no registry (no markets known) does not count as covered", () => { - expect(exitReason({ ...e, marketsSeated: [] })).toBeNull(); + it("no markets known does not count as covered", () => { + expect(exitReason({ ...e, markets: [] })).toBeNull(); }); it("an earlier END_AT still applies", () => { expect(exitReason({ ...e, endAt: 900 })).toBe("END_AT reached"); From 65995e17b7d896c2193d5b8b973fe83b2a2e7100 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:11:18 -0400 Subject: [PATCH 152/169] fix(web): follow the v0.8 Status enum (Selling = 4, Closed = 5) (web src/lib) The SDK now has Status.Selling between Closing and Closed; the status label table is indexed by status, so it gains the Selling entry, the lifecycle phase names it, and pool state treats it as closing (claims wait for the sale). Part 1/2 of: fix(web): follow the v0.8 Status enum (Selling = 4, Closed = 5) Files: apps/web/src/lib/format.ts, apps/web/src/lib/view.ts, apps/web/src/lib/window.ts Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/format.ts | 2 +- apps/web/src/lib/view.ts | 2 +- apps/web/src/lib/window.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 116f4f3..533aaa0 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -140,7 +140,7 @@ export const fmtLocal = (ts: number) => /** Legacy name kept for older call sites. */ export const dateTime = fmtLocal; -export const STATUS_LABEL = ["—", "Open", "Repayment window", "Closing", "Closed"]; +export const STATUS_LABEL = ["—", "Open", "Repayment window", "Closing", "Selling defaulted collateral", "Closed"]; // indexed by Status (v0.8: Selling = 4, Closed = 5) export const KIND_LABEL = ["Repay any time", "Repay at maturity only"]; export const kindSlug = (k: number) => (k === 0 ? "american" : "european"); diff --git a/apps/web/src/lib/view.ts b/apps/web/src/lib/view.ts index 2e03279..bb157b2 100644 --- a/apps/web/src/lib/view.ts +++ b/apps/web/src/lib/view.ts @@ -42,7 +42,7 @@ export function poolState(p: PoolRow, now: number, block: bigint): PoolState { return windowOpen(p, now, block) ? "window" : "awaiting"; } if (p.status === Status.Window) return windowOpen(p, now, block) ? "window" : "closing"; - if (p.status === Status.Closing) return "closing"; + if (p.status === Status.Closing || p.status === Status.Selling) return "closing"; // Selling: claims wait for the sale if (p.status === Status.Closed) return "closed"; return "none"; } diff --git a/apps/web/src/lib/window.ts b/apps/web/src/lib/window.ts index c4fd1e9..1a41356 100644 --- a/apps/web/src/lib/window.ts +++ b/apps/web/src/lib/window.ts @@ -22,6 +22,7 @@ export function phase(p: PoolRow, now: number): Phase { if (p.status === Status.Open) return now < p.expiry ? "trading" : "awaiting"; if (p.status === Status.Window) return "window"; // v0.8 decision B: an auto-sell pool past its window is selling its defaulted asset in the next maturity + if (p.status === Status.Selling) return "selling"; if (p.status === Status.Closing) return p.autoSell ? "selling" : "closing"; if (p.status === Status.Closed) return "closed"; return "none"; From 42d5f99e7a376c93511b02c51cda020116355214 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:11:18 -0400 Subject: [PATCH 153/169] fix(web): follow the v0.8 Status enum (Selling = 4, Closed = 5) (web test) Part 2/2 of: fix(web): follow the v0.8 Status enum (Selling = 4, Closed = 5) Files: apps/web/test/v08-web.test.tsx Co-Authored-By: Claude Opus 5.5 --- apps/web/test/v08-web.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/test/v08-web.test.tsx b/apps/web/test/v08-web.test.tsx index 21fa620..aadcbd8 100644 --- a/apps/web/test/v08-web.test.tsx +++ b/apps/web/test/v08-web.test.tsx @@ -74,5 +74,6 @@ describe("auto-sell status", () => { expect(phase(p, 0)).toBe("selling"); expect(PHASE_LABEL.selling).toMatch(/claims open later/); expect(phase({ ...p, autoSell: false }, 0)).toBe("closing"); + expect(phase({ ...p, status: Status.Selling, autoSell: false }, 0)).toBe("selling"); }); }); From 25114d9c1bbde1f0a524a68d674c2f3489dd5b33 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:14:42 -0400 Subject: [PATCH 154/169] feat(scripts): testnet strikes on the 1% log grid, fee GRAD rows, fee-inclusive escrow Strikes per chain move to the canonical log-grid indices of spec 2.7 (Monad USD/extra -384, LST -11; HyperEVM USD 434, extra -453; Sepolia LST -7, USD 769, extra 773), exported as *_STRIKE_N plus the matching K in *_STRIKE_LOW/HIGH. GRAD rows' second column is now the per-expiry graduation fee: 4 base units of USDC, 1e12 wei of the native coin. Preflight funds each buy with buyEscrow (cost + 0.015% fee + 2 wei per batch of ttl); the offline test's expected needs follow. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/testnet.sh | 42 ++++++++++++++++++-------------- scripts/test/testnet-lib.test.sh | 29 +++++++++++----------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/scripts/lib/testnet.sh b/scripts/lib/testnet.sh index 8217ec1..77e0f0f 100644 --- a/scripts/lib/testnet.sh +++ b/scripts/lib/testnet.sh @@ -56,14 +56,16 @@ testnet_params() { R=https://testnet-rpc.monad.xyz NAME=monad-testnet export LST=$M_SHMON NATIVE=$M_WMON USDC=$M_USDC + # v0.8 strikes sit on the 1% log grid (Grid.strikeAt(n, quoteDecimals)): *_STRIKE_N is the canonical index the + # core's createPool takes, *_STRIKE_LOW / HIGH the matching K (packages/sdk strikeAt gives the same values). # One buy-back price per pair (equal strikes: CreatePools opens a single strike), about 90% of the collateral's # normal market value on the 2-digit grid (2026-10-13): shMON ≈ 1 MON (the testnet shMON contract's 12.9 MON # redemption rate is a testnet artefact, not a price), MON ≈ 0.0249 USDC (Kuru MON_USDC mid). - export LST_STRIKE_HIGH=900000000000000000 LST_STRIKE_LOW=900000000000000000 # 0.90 WMON per shMON (90%) - export USD_STRIKE_LOW=22000 USD_STRIKE_HIGH=22000 # 0.022 USDC per WMON (88%) + export LST_STRIKE_N=-11 LST_STRIKE_HIGH=896323717517805234 LST_STRIKE_LOW=896323717517805234 # 1.01^-11 ≈ 0.896 WMON per shMON + export USD_STRIKE_N=-384 USD_STRIKE_LOW=21907 USD_STRIKE_HIGH=21907 # 1.01^-384 ≈ 0.0219 USDC per WMON (88%) # Extra pair shMON/USDC (every pair of MON/WMON/shMON/USDC that can be a market), same price as WMON/USDC. export EXTRA_ASSET=$M_SHMON EXTRA_QUOTE=$M_USDC EXTRA_BAND=800 - export EXTRA_STRIKE_LOW=22000 EXTRA_STRIKE_HIGH=22000 + export EXTRA_STRIKE_N=-384 EXTRA_STRIKE_LOW=21907 EXTRA_STRIKE_HIGH=21907 # Seed units: WMON/USDC 0.02 WMON (one seed plus the trade rounds clear the 0.001 USDC graduation row); # shMON/WMON 0.002 shMON and shMON/USDC 0.005 shMON, just above the 0.001 WMON / 0.0001 USDC order minimums # (the wallet holds little shMON; neither pair has to graduate) @@ -78,11 +80,11 @@ testnet_params() { export NATIVE=$H_WHYPE USDC=$H_USDC LST=$ZERO # One buy-back price per pair at about 90% of the normal market value: HYPE ≈ 83.1 USDC on HyperCore mainnet spot # (2026-10-13; the testnet book's ~29 is a testnet artefact): 75 USDC per WHYPE (90%). - export USD_STRIKE_LOW=75000000 USD_STRIKE_HIGH=75000000 + export USD_STRIKE_N=434 USD_STRIKE_LOW=75071695 USD_STRIKE_HIGH=75071695 # 1.01^434 ≈ 75.07 USDC # Extra pair USDC/WHYPE (CreatePools EXTRA_*): lends the native coin against USDC, as shMON/WMON does on Monad. # Strike in WHYPE per USDC: 0.011 (1 / 83.1 = 0.01204, so about 91%). 8% band like the USD pools (not pegged). export EXTRA_ASSET=$H_USDC EXTRA_QUOTE=$H_WHYPE EXTRA_BAND=800 - export EXTRA_STRIKE_LOW=11000000000000000 EXTRA_STRIKE_HIGH=11000000000000000 + export EXTRA_STRIKE_N=-453 EXTRA_STRIKE_LOW=11025992086967947 EXTRA_STRIKE_HIGH=11025992086967947 # 1.01^-453 ≈ 0.01103 WHYPE # Seed units: WHYPE/USDC 0.001 WHYPE (clears the 0.001 USDC graduation row many times over); USDC/WHYPE 0.2 USDC, # above the 0.001 WHYPE order minimum (its 0.1 WHYPE graduation row would need ~2.3 USDC per order: not seeded) export SEED_UNIT_BPS=${SEED_UNIT_BPS:-10} EXTRA_SEED_UNIT_BPS=${EXTRA_SEED_UNIT_BPS:-2000} @@ -101,11 +103,11 @@ testnet_params() { # (2026-10-23). wstETH is worth what it redeems for on this chain, 1.0377 ETH (stEthPerToken: unlike shMON's, # the Sepolia rate is what staking pays here): 0.93 WETH (90%). ETH ≈ 2375 USD (Ethereum mainnet Chainlink # ETH/USD; the Sepolia DEX books are testnet artefacts): 2100 USDC per WETH (88%). - export LST_STRIKE_HIGH=930000000000000000 LST_STRIKE_LOW=930000000000000000 # 0.93 WETH per wstETH - export USD_STRIKE_LOW=2100000000 USD_STRIKE_HIGH=2100000000 # 2100 USDC per WETH + export LST_STRIKE_N=-7 LST_STRIKE_HIGH=932718054707135373 LST_STRIKE_LOW=932718054707135373 # 1.01^-7 ≈ 0.9327 WETH per wstETH + export USD_STRIKE_N=769 USD_STRIKE_LOW=2104439470 USD_STRIKE_HIGH=2104439470 # 1.01^769 ≈ 2104 USDC per WETH # Extra pair wstETH/USDC (as shMON/USDC on Monad): 1.0377 x 2375 = 2465 USD, 2200 USDC per wstETH (89%). export EXTRA_ASSET=$S_WSTETH EXTRA_QUOTE=$S_USDC EXTRA_BAND=800 - export EXTRA_STRIKE_LOW=2200000000 EXTRA_STRIKE_HIGH=2200000000 + export EXTRA_STRIKE_N=773 EXTRA_STRIKE_LOW=2189888152 EXTRA_STRIKE_HIGH=2189888152 # 1.01^773 ≈ 2190 USDC # Seed units, small because faucet Sepolia ETH and USDC are scarce: WETH/USDC and wstETH/USDC 0.0001 (the # smallest unit; ~0.21 USDC per order clears the 0.001 USDC graduation row many times over); wstETH/WETH # 0.0012 wstETH (0.00112 WETH at K, just above the 0.001 WETH order minimum; like shMON/WMON it need not @@ -154,8 +156,9 @@ testnet_env() { } # The graduation / order-minimum rows of the three testnets (Deploy.s.sol GRAD_): -# quote:graduationVolume:minNotional:seatObligation, small testnet values (USDC min order 0.0001, native 0.001). -# Graduation needs this volume in each of four consecutive expiries: 0.001 USDC and 0.1 native, so the small seeds of +# quote:graduationFee:minNotional:seatObligation, small testnet values (USDC min order 0.0001, native 0.001). +# v0.8: graduation needs these treasury fees (trade fees at clearing + spread share at close) in each of four +# consecutive expiries: 4 base units of USDC (0.000004) and 1e12 wei of the native coin, so the small seeds of # scripts/roll-testnet.sh graduate the native/USDC market and let the vaults invest; scripts/test/grad-seed.test.sh # checks one seed clears them. The whole table is part of the core's init code (every chain carries every row, so they # share one core address): changing any value here and redeploying gives a NEW core address on every chain. Adding @@ -167,9 +170,9 @@ testnet_env() { # (and the pegged vault) will in practice not graduate. It is in the init code of the 0xc0f3 core already deployed on # Sepolia (2026-10-23); pick a smaller value (e.g. 0.01 WETH) before the next joint redeploy (IMPLEMENTATION_NOTES §7). testnet_grad() { - export GRAD_10143="$M_WMON:100000000000000000:1000000000000000:100000000000000000,$M_USDC:1000:100:100000" - export GRAD_998="$H_WHYPE:100000000000000000:1000000000000000:100000000000000000,$H_USDC:1000:100:100000" - export GRAD_11155111="$S_WETH:100000000000000000:1000000000000000:100000000000000000,$S_USDC:1000:100:100000" + export GRAD_10143="$M_WMON:1000000000000:1000000000000000:100000000000000000,$M_USDC:4:100:100000" + export GRAD_998="$H_WHYPE:1000000000000:1000000000000000:100000000000000000,$H_USDC:4:100:100000" + export GRAD_11155111="$S_WETH:1000000000000:1000000000000000:100000000000000000,$S_USDC:4:100:100000" } # File mtime in seconds: GNU stat (Linux) first, BSD stat (macOS) second. @@ -356,8 +359,8 @@ trap 'exit 130' INT TERM # Crossing layout (SEED_APR_BPS=0, Seed.s.sol _seedPool): per pool 1+2+3 units sold, and the same units bought at # 97/98/99% of K. A pair whose two strikes are equal is one strike (CreatePools). The loop pair uses # LST_SEED_UNIT_BPS, the extra pair EXTRA_SEED_UNIT_BPS. -# Each buy escrows exactly what the core's placeOrder pulls (OrderLogic, BUY): -# quoteUp(units * limit) + ttl, i.e. the quote rounded up per order plus 1 wei per batch of its ttl (SEED_TTL below). +# Each buy escrows exactly what the core's placeOrder pulls (OrderLogic, BUY, v0.8 fees): g = quoteUp(units * limit), +# g + floor(g * 150 / 1e6) (the buyer's 0.015% trade fee) + 2 wei per batch of its ttl (SEED_TTL below). # FUND=1 wraps the missing wrapped-native amount from the native balance (deposit()) and, where the LST mints for # native coin sent to it (LST_FUND=receive: wstETH on Sepolia), stakes the missing LST the same way; USDC and shMON must # be obtained by hand (Circle faucet; FastLane deposit). GAS_RESERVE (wei, default 0.5 native; testnet_params may set a @@ -401,6 +404,9 @@ preflight() { "${toks[*]}" "${pairs[*]}" "$nexp" "${SEED_APR_BPS:-0}" "${TRADE_ROUNDS:-0}" <<'PY' import sys gas, reserve, ttl, toks, pairs, nexp, apr, rounds = sys.argv[1:] +def esc(units, limit, d, ttl): + g = -(-units * limit // 10 ** d) + return g + g * 150 // 1_000_000 + 2 * ttl # SDK buyEscrow ttl, nexp, book, rounds = int(ttl), int(nexp), int(apr) > 0, int(rounds) tok = {} # lowercase address -> [label, decimals, balance, need] order = [] @@ -416,16 +422,16 @@ for p in pairs.split(): for k in strikes: if book: # Seed._seedBook + trade(), per kind (2) and expiry: 2 + rounds sells and buys of u, each buy escrowing - # quoteUp(u, limit, 10**d) + ttl with limit < K (OrderLogic placeOrder, BUY) + # buyEscrow(u, limit, ttl) valued at K > limit (OrderLogic placeOrder, BUY) n = 2 * nexp * (2 + rounds) tok[asset.lower()][3] += n * u - tok[quote.lower()][3] += n * (-(-u * k // 10 ** d) + ttl) + tok[quote.lower()][3] += n * esc(u, k, d, ttl) continue # Seed._seedPool per kind (2): sells of u*(j+1) (6 units), buys of u*(j+1) at K*(97+j)/100 for j = 0..2 tok[asset.lower()][3] += 2 * nexp * 6 * u for j in range(3): units, limit = u * (j + 1), k * (97 + j) // 100 - tok[quote.lower()][3] += 2 * nexp * (-(-units * limit // 10 ** d) + ttl) + tok[quote.lower()][3] += 2 * nexp * esc(units, limit, d, ttl) labels = {tok[a][0] for a in order} for lbl in ("lst", "native", "usdc"): # the launch labels always print (lst 0 0 0 without an LST) if lbl not in labels: diff --git a/scripts/test/testnet-lib.test.sh b/scripts/test/testnet-lib.test.sh index cd79f66..bb5bdd5 100644 --- a/scripts/test/testnet-lib.test.sh +++ b/scripts/test/testnet-lib.test.sh @@ -252,21 +252,22 @@ grep -q "STOP: balances do not cover one seed" "$T/out" || fail "no stop message grep -q "faucet.circle.com" "$T/out" || fail "no USDC hint" TRADE_ROUNDS=3 lib 'preflight' > "$T/out" 2>&1 || fail "preflight failed when funded" # Book layout (SEED_APR_BPS=1000) at one strike per pair, one expiry, 3 trade rounds: per pool and kind 5 sells and 5 -# buys of one unit, each buy escrowing quoteUp(u x K) + ttl (SEED_TTL=1000). HyperEVM: WHYPE/USDC at 0.001 WHYPE, -# K 75 USDC: 10 x (75000 + 1000) USDC and 10 x 1e15 WHYPE; the extra pair USDC/WHYPE at 0.2 USDC, K 0.011 WHYPE: -# 10 x 200000 USDC and 10 x (2.2e15 + 1000) WHYPE -grep -qE "usdc +need 2760000 " "$T/out" || fail "HyperEVM USDC need misses the extra pair: $(grep usdc "$T/out")" -grep -qE "native +need 32000000000010000 " "$T/out" || fail "HyperEVM WHYPE need misses the extra pair: $(grep native "$T/out")" +# buys of one unit, each buy escrowing buyEscrow = g + floor(g x 150 / 1e6) + 2 x ttl with g = quoteUp(u x K) +# (SEED_TTL=1000). HyperEVM: WHYPE/USDC at 0.001 WHYPE, K 1.01^434 = 75.071695 USDC: 10 x (75072 + 11 + 2000) USDC +# and 10 x 1e15 WHYPE; the extra pair USDC/WHYPE at 0.2 USDC, K 1.01^-453 WHYPE: 10 x 200000 USDC and +# 10 x buyEscrow WHYPE +grep -qE "usdc +need 2770830 " "$T/out" || fail "HyperEVM USDC need misses the extra pair: $(grep usdc "$T/out")" +grep -qE "native +need 32055291971581990 " "$T/out" || fail "HyperEVM WHYPE need misses the extra pair: $(grep native "$T/out")" grep -qE "^ extra" "$T/out" && fail "the extra pair's tokens (USDC, WHYPE) were counted as separate tokens" -if TRADE_ROUNDS=3 FAKE_BAL_USDC=2759999 lib 'preflight' > "$T/out" 2>&1; then fail "preflight passed 1 unit short of the extra pair's USDC"; fi +if TRADE_ROUNDS=3 FAKE_BAL_USDC=2770829 lib 'preflight' > "$T/out" 2>&1; then fail "preflight passed 1 unit short of the extra pair's USDC"; fi grep -q "faucet.circle.com" "$T/out" || fail "no USDC hint when only the extra pair is short" -# Monad, USDC: WMON/USDC at 0.02 WMON, K 0.022: 10 x (440 + 1000); shMON/USDC at 0.005 shMON: 10 x (110 + 1000) = 25500. +# Monad, USDC: WMON/USDC at 0.02 WMON, K 0.021907: 10 x (439 + 0 + 2000); shMON/USDC at 0.005 shMON: 10 x (110 + 2000) = 45490. # Just below that must stop (the seed would revert halfway); exactly that passes. Three expiries triple it. -if TRADE_ROUNDS=3 LIBCHAIN=monad FAKE_BAL_USDC=25499 lib 'preflight' > "$T/out" 2>&1; then fail "preflight passed 1 wei short of the buys' escrow"; fi -grep -qE "usdc +need 25500 " "$T/out" || fail "USDC need is not the escrow sum: $(grep usdc "$T/out")" -TRADE_ROUNDS=3 LIBCHAIN=monad FAKE_BAL_USDC=25500 lib 'preflight' > "$T/out" 2>&1 || fail "preflight failed with exactly the escrow" +if TRADE_ROUNDS=3 LIBCHAIN=monad FAKE_BAL_USDC=45489 lib 'preflight' > "$T/out" 2>&1; then fail "preflight passed 1 wei short of the buys' escrow"; fi +grep -qE "usdc +need 45490 " "$T/out" || fail "USDC need is not the escrow sum: $(grep usdc "$T/out")" +TRADE_ROUNDS=3 LIBCHAIN=monad FAKE_BAL_USDC=45490 lib 'preflight' > "$T/out" 2>&1 || fail "preflight failed with exactly the escrow" TRADE_ROUNDS=3 LIBCHAIN=monad LATER_EXPIRIES=5,6 lib 'preflight' > "$T/out" 2>&1 || true -grep -qE "usdc +need 76500 " "$T/out" || fail "LATER_EXPIRIES not counted: $(grep usdc "$T/out")" +grep -qE "usdc +need 136470 " "$T/out" || fail "LATER_EXPIRIES not counted: $(grep usdc "$T/out")" # the ttl preflight counts is the one Seed.s.sol places (it reads SEED_TTL) grep -q 'vm.envOr("SEED_TTL"' "$ROOT/contracts/script/Seed.s.sol" || fail "Seed.s.sol does not read SEED_TTL" @@ -502,8 +503,8 @@ rm -rf "$DEP/.staging" "$T/state"/* : > "$CALLS" TRADE_ROUNDS=3 LIBCHAIN=sepolia lib 'preflight' > "$T/out" 2>&1 || fail "sepolia preflight failed when funded" grep -qE "lst +need 13000000000000000 " "$T/out" || fail "Sepolia wstETH need: $(grep lst "$T/out")" -grep -qE "native +need 12160000000010000 " "$T/out" || fail "Sepolia WETH need: $(grep native "$T/out")" -grep -qE "usdc +need 4320000 " "$T/out" || fail "Sepolia USDC need: $(grep usdc "$T/out")" +grep -qE "native +need 12194295549004100 " "$T/out" || fail "Sepolia WETH need: $(grep native "$T/out")" +grep -qE "usdc +need 4334960 " "$T/out" || fail "Sepolia USDC need: $(grep usdc "$T/out")" grep -qE "^ extra" "$T/out" && fail "Sepolia: the extra pair's tokens (wstETH, USDC) were counted as separate tokens" grep -q "balanceOf(address)(uint256)" <<<"$(grep "$SW" "$CALLS")" || fail "Sepolia preflight did not read wstETH" # no USDC: a clear stop naming the Circle faucet; FUND=1 stakes the missing wstETH (ETH sent to wstETH, at its rate @@ -514,7 +515,7 @@ if TRADE_ROUNDS=3 LIBCHAIN=sepolia FAKE_BAL=0 FAKE_GAS=2000000000000000000 FUND= fi grep -q "faucet.circle.com" "$T/out" || fail "Sepolia: no USDC faucet hint" grep -q "cast send $SW --value 1037717826760305089 " "$CALLS" || fail "FUND=1 did not stake wstETH: $(grep 'cast send' "$CALLS")" -grep -q "cast send $SE deposit() --value 12160000000010000 " "$CALLS" || fail "FUND=1 did not wrap WETH" +grep -q "cast send $SE deposit() --value 12194295549004100 " "$CALLS" || fail "FUND=1 did not wrap WETH" # enough native coin to stake but not to wrap as well: the wrap would eat into the gas reserve, so it stops there : > "$CALLS" if TRADE_ROUNDS=3 LIBCHAIN=sepolia FAKE_BAL=0 FAKE_GAS=1095000000000000000 FUND=1 lib 'preflight' > "$T/out" 2>&1; then From a4759376742a7ef2ba3a082bfbbef30d3a182aee Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:14:57 -0400 Subject: [PATCH 155/169] fix(scripts): keep Deploy.run within the stack limit Binding the Specials periphery added locals to Deploy.run and the script stopped compiling (stack too deep). The registry precompute moves into _registries and the config inputs into a scoped block; behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- contracts/script/Deploy.s.sol | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol index 997af6c..555fa81 100644 --- a/contracts/script/Deploy.s.sol +++ b/contracts/script/Deploy.s.sol @@ -73,11 +73,19 @@ contract Deploy is Script { (, address deployer,) = vm.readCallers(); Tokens memory t = mocks ? _mockTokens(salt) : Tokens(address(0), address(0), address(0)); DeployConfig.Grad[7] memory grad = _grad(mocks, t); - (address[7] memory registries, SeatRegistry reg) = _registries(salt, deployer, grad); - Guardian g = _deployGuardian(salt, deployer); - address guardian = address(g) != address(0) ? address(g) : vm.envAddress("GUARDIAN"); - Specials sp = _deploySpecials(salt, deployer); - Confluere.Config memory cfg = DeployConfig.build(preset, registries, guardian, grad, address(sp)); + Confluere.Config memory cfg; + SeatRegistry reg; + Guardian g; + address guardian; + Specials sp; + { + address[7] memory registries; + (registries, reg) = _registries(salt, deployer, grad); + g = _deployGuardian(salt, deployer); + guardian = address(g) != address(0) ? address(g) : vm.envAddress("GUARDIAN"); + sp = _deploySpecials(salt, deployer); + cfg = DeployConfig.build(preset, registries, guardian, grad, address(sp)); + } _preflight(cfg, preset); uint256 deployBlock; (core, deployBlock) = _deployCore(salt, cfg, reg, g); From cfd9e755b640a748882e638e0519d25a33668628 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:14:57 -0400 Subject: [PATCH 156/169] feat(core): sell defaulted collateral into the next expiry (autoSell) (Confluere.sol) Decision B (2026-10-24). When a pool created with PoolOptions.autoSell finishes its abandonment auction with defaulted asset left, closeWindow places a competitive SELL for all of it into the same market's next expiry pool of the same tier (strike, band, cadence) at limit = K x AUTOSELL_DISCOUNT_BPS / 10_000 for AUTOSELL_TTL settlements. The order is owned by the core (FLAG_AUTOSELL; nobody can cancel it, purge can clear it when dead) and linked back through linkedPool; its units stay in the old pool's assetBal, marked autoSellUnits. Fills move the asset to the new pool and credit the old pool's quoteBal with the net proceeds (spread share and trade fee charged like any seller); the R minted to the core is never repaid and takes the new pool's normal window. The old pool waits in Status.Selling (claims wait) until the order leaves the book by any path (filled, ttl out, evicted, purged), which closes it; anything unsold is paid in kind as before. closeWindow on a Selling pool ends a dead order (ttl used, or the target stopped trading), so Selling always ends by the target's expiry. No target pool, a target no longer trading, or a full book of better sells: the pool just closes. At a pool's close the core burns any R it holds and moves that R's share of the auction surplus to C holders (choice B2). closeWindow returns true once the pool leaves Closing (Selling or Closed). CoreLib.insert splits out tryInsert (no BookFull revert). Events: AutoSellPlaced(poolId, targetPoolId, orderId, units, limit), AutoSellEnded(poolId, unitsLeft); WindowClosed is emitted when the pool reaches Closed. PoolView gains autoSellPool, autoSellOrder, autoSellUnits. Tests: AutoSell.t.sol (placement, owner is core, fill credits net, claims wait, ttl out in kind, partial then expire, no target, full book, eviction, paused target, closeWindow and purge end a dead order, core R burned with its surplus to lenders, off by default); the invariant suite runs pool 0 with autoSell and adds invariant_noStrandedFunds (3d). Invariants: 1 (autoSell units counted in the source pool, not as sell escrow), 2 (new R minted against the moved asset), 3d (new), 4 (the source pool's asset leaves only against quote at P*), 7 (bookkeeping only, no transfer). Part 1/7 of: feat(core): sell defaulted collateral into the next expiry (autoSell) Files: contracts/src/Confluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/Confluere.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contracts/src/Confluere.sol b/contracts/src/Confluere.sol index 0fdce3e..b8cea44 100644 --- a/contracts/src/Confluere.sol +++ b/contracts/src/Confluere.sol @@ -371,6 +371,9 @@ contract Confluere is IConfluere, IERC6909TokenSupply, IERC3156FlashLender, Reen v.lentMargin = ps.lentMargin; v.shortUnits = ps.shortUnits; v.shortMargin = ps.shortMargin; + v.autoSellPool = ps.autoSellPool; + v.autoSellOrder = ps.autoSellOrder; + v.autoSellUnits = ps.autoSellUnits; v.options = PoolOptions(ps.minFill, ps.firstLo, ps.firstHi, ps.autoSell); v.batchInterval = uint64(ps.interval(GRID_BATCH)); } From e790fd096d3846eb6771e1703448698bd62bf50e Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:14:57 -0400 Subject: [PATCH 157/169] feat(core): sell defaulted collateral into the next expiry (autoSell) (IConfluere.sol) Part 2/7 of: feat(core): sell defaulted collateral into the next expiry (autoSell) Files: contracts/src/interfaces/IConfluere.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/interfaces/IConfluere.sol | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/contracts/src/interfaces/IConfluere.sol b/contracts/src/interfaces/IConfluere.sol index 01f53c9..42927f4 100644 --- a/contracts/src/interfaces/IConfluere.sol +++ b/contracts/src/interfaces/IConfluere.sol @@ -50,6 +50,9 @@ interface IConfluere { uint128 lentMargin; uint128 shortUnits; uint128 shortMargin; + bytes32 autoSellPool; + uint64 autoSellOrder; + uint96 autoSellUnits; PoolOptions options; uint64 batchInterval; } @@ -102,6 +105,13 @@ interface IConfluere { event Lent(bytes32 indexed poolId, address indexed to, uint256 units, uint256 margin, uint256 fee); event LentReturned(bytes32 indexed poolId, uint256 units, uint256 marginOut, address marginTo); event LentToppedUp(bytes32 indexed poolId, uint256 amount); + /// @dev A closed autoSell pool offered its defaulted asset in the next expiry's pool of the same tier; the pool + /// stays Selling (claims wait) until the order leaves the book (AutoSellEnded, then WindowClosed). + event AutoSellPlaced( + bytes32 indexed poolId, bytes32 indexed targetPoolId, uint64 orderId, uint256 units, uint256 limit + ); + /// @dev unitsLeft stay in the pool and are claimed in kind; units sold and their proceeds are in OrderFilled. + event AutoSellEnded(bytes32 indexed poolId, uint256 unitsLeft); event ShortRecorded(bytes32 indexed poolId, uint256 shortUnits, uint256 shortMargin); event FeeEscrowSplit( bytes32 indexed poolId, uint256 toTreasury, uint256 toLenders, uint256 repaidUnits, uint256 filledUnits From b4f1826331e4e7293abfff7b4334ff63b4d8f4da Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:14:57 -0400 Subject: [PATCH 158/169] feat(core): sell defaulted collateral into the next expiry (autoSell) (OrderSet.sol) Part 3/7 of: feat(core): sell defaulted collateral into the next expiry (autoSell) Files: contracts/src/libraries/OrderSet.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/OrderSet.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/src/libraries/OrderSet.sol b/contracts/src/libraries/OrderSet.sol index 46bb97b..0eea1f5 100644 --- a/contracts/src/libraries/OrderSet.sol +++ b/contracts/src/libraries/OrderSet.sol @@ -11,6 +11,7 @@ uint8 constant BID = 4; uint8 constant FLAG_ROLLOVER = 1; uint8 constant FLAG_LOCKED = 2; // cannot be cancelled while it can still trade (commitment orders, e.g. seats) +uint8 constant FLAG_AUTOSELL = 4; // a closed pool's defaulted asset offered in the next expiry's pool; owner is the core /// @notice One resting order. Ids are global and never reused. /// - SELL / NC_SELL: `units` of asset escrowed; `limit` is the minimum price (0 for NC). From 06e3f018168c34499afc5c3b4429fc08db4bd95a Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:14:57 -0400 Subject: [PATCH 159/169] feat(core): sell defaulted collateral into the next expiry (autoSell) (State.sol) Part 4/7 of: feat(core): sell defaulted collateral into the next expiry (autoSell) Files: contracts/src/libraries/State.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/State.sol | 40 ++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/contracts/src/libraries/State.sol b/contracts/src/libraries/State.sol index 1472a9e..cfacc6c 100644 --- a/contracts/src/libraries/State.sol +++ b/contracts/src/libraries/State.sol @@ -7,7 +7,7 @@ import {IERC6909} from "@openzeppelin/contracts/interfaces/IERC6909.sol"; import {IConfluere} from "../interfaces/IConfluere.sol"; import {PoolKey, PoolKeyLib, Status} from "./PoolKey.sol"; -import {Order, Book, OrderSet, SELL, NC_SELL, BID, FLAG_ROLLOVER} from "./OrderSet.sol"; +import {Order, Book, OrderSet, SELL, NC_SELL, BID, FLAG_ROLLOVER, FLAG_AUTOSELL} from "./OrderSet.sol"; /// @notice Per-pool state. All funds stay in the singleton; these are the pool's shares of them. struct PoolState { @@ -41,6 +41,10 @@ struct PoolState { uint128 lentMargin; // their margin (quote), escrowed here from the moment of lending uint128 shortUnits; // lent units not returned by the cutoff, recorded lazily at the first touch after it uint128 shortMargin; // the forfeited margin: pays repayers who paid K for units that are not here + // autoSell (decision B): while Selling, autoSellUnits of assetBal rest as a sell order in the next expiry's pool + bytes32 autoSellPool; + uint64 autoSellOrder; + uint96 autoSellUnits; } /// @notice The whole storage of the singleton. Logic libraries receive it by storage reference. @@ -197,6 +201,19 @@ library CoreLib { function insert(CoreState storage s, Params memory p, bytes32 poolId, PoolState storage ps, uint64 id, uint64 hint) internal { + if (!tryInsert(s, p, poolId, ps, id, hint)) revert IConfluere.BookFull(); + } + + /// @notice Inserts an order whose struct is already written; false (nothing changed) if its book is full and the + /// order cannot displace anything. + function tryInsert( + CoreState storage s, + Params memory p, + bytes32 poolId, + PoolState storage ps, + uint64 id, + uint64 hint + ) internal returns (bool) { mapping(uint64 => Order) storage os = s.orders[poolId]; Order storage o = os[id]; uint8 side = o.side; @@ -209,7 +226,7 @@ library CoreLib { uint64 worst = b.tail; Order storage w = os[worst]; bool beats = w.limit == o.limit ? o.units > w.units : !OrderSet.goesAfter(side, w.limit, o.limit); - if (w.deadline > ps.settleCount && !beats) revert IConfluere.BookFull(); + if (w.deadline > ps.settleCount && !beats) return false; if (hint == worst) hint = w.prev; removeWithRefund(s, poolId, ps, worst, RM_EVICTED); } @@ -218,6 +235,7 @@ library CoreLib { if (b.count >= max) _evictSmallest(s, poolId, ps, b, os, o); b.pushBack(os, id); } + return true; } /// @dev Full non-competitive queue: drop an expired entry if any, else the smallest one if the new order is @@ -265,7 +283,10 @@ library CoreLib { address owner = o.owner; uint8 side = o.side; if (side == SELL || side == NC_SELL) { - if (o.flags & FLAG_ROLLOVER != 0) { + if (o.flags & FLAG_AUTOSELL != 0) { + _endAutoSell(s, s.linkedPool[poolId][id]); + delete s.linkedPool[poolId][id]; + } else if (o.flags & FLAG_ROLLOVER != 0) { s.lockedR[owner][s.linkedPool[poolId][id]] -= o.units; delete s.linkedPool[poolId][id]; if (o.escrow != 0) s.pending[ps.key.quote][owner] += o.escrow; @@ -279,6 +300,19 @@ library CoreLib { emit IConfluere.OrderRemoved(poolId, id, reason); } + /// @dev An autoSell order left its book (filled, expired, evicted or purged): the source pool closes and C holders + /// claim the proceeds and any unsold remainder in kind. + function _endAutoSell(CoreState storage s, bytes32 src) private { + PoolState storage op = s.pools[src]; + uint256 left = op.autoSellUnits; + op.autoSellUnits = 0; + op.autoSellOrder = 0; + op.autoSellPool = bytes32(0); + op.status = Status.Closed; + emit IConfluere.AutoSellEnded(src, left); + emit IConfluere.WindowClosed(src, op.quoteBal, op.assetBal, op.surplusBal); + } + /// @notice Whether an order may trade in the settlement numbered `sc`, whose batch is followed by a boundary at /// `nextBoundary`. A rollover trades while the old pool's window runs, and also in the new pool's last batch /// before the old pool expires (blueprint 3.7 #5): the old pool's K is then paid in full one batch early. From 8e90582db43ff69d414bb1d998db6fc94eb708a2 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:14:57 -0400 Subject: [PATCH 160/169] feat(core): sell defaulted collateral into the next expiry (autoSell) (libraries/logic) Part 5/7 of: feat(core): sell defaulted collateral into the next expiry (autoSell) Files: contracts/src/libraries/logic/ClearingLogic.sol, contracts/src/libraries/logic/WindowLogic.sol Co-Authored-By: Claude Opus 5.5 --- .../src/libraries/logic/ClearingLogic.sol | 9 +- contracts/src/libraries/logic/WindowLogic.sol | 89 ++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/contracts/src/libraries/logic/ClearingLogic.sol b/contracts/src/libraries/logic/ClearingLogic.sol index 3099bba..bbdc203 100644 --- a/contracts/src/libraries/logic/ClearingLogic.sol +++ b/contracts/src/libraries/logic/ClearingLogic.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.37; import {IConfluere} from "../../interfaces/IConfluere.sol"; import {PoolKey, PoolKeyLib} from "../PoolKey.sol"; -import {Order, SELL, BUY, NC_SELL, NC_BUY, FLAG_ROLLOVER} from "../OrderSet.sol"; +import {Order, SELL, BUY, NC_SELL, NC_BUY, FLAG_ROLLOVER, FLAG_AUTOSELL} from "../OrderSet.sol"; import {Clearing} from "../Clearing.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Settlement} from "../Settlement.sol"; @@ -309,6 +309,13 @@ library ClearingLogic { ps.assetBal += f; if (o.flags & FLAG_ROLLOVER != 0) { _settleRollover(s, bt, id, o, f, net); + } else if (o.flags & FLAG_AUTOSELL != 0) { + // the source pool's defaulted asset moves here; its C holders get the net proceeds. The R minted to the + // core is never repaid: these units take this pool's normal window (auction or default). + PoolState storage op = s.pools[s.linkedPool[bt.poolId][id]]; + op.assetBal -= f; + op.autoSellUnits -= uint96(f); + op.quoteBal += net; } else { s.pending[ps.key.quote][owner] += net; } diff --git a/contracts/src/libraries/logic/WindowLogic.sol b/contracts/src/libraries/logic/WindowLogic.sol index dd54891..ef2829c 100644 --- a/contracts/src/libraries/logic/WindowLogic.sol +++ b/contracts/src/libraries/logic/WindowLogic.sol @@ -3,7 +3,8 @@ pragma solidity 0.8.37; import {IConfluere} from "../../interfaces/IConfluere.sol"; import {PoolKeyLib, Kind, Status} from "../PoolKey.sol"; -import {Order, Book, BID} from "../OrderSet.sol"; +import {Order, Book, OrderSet, SELL, BID, FLAG_AUTOSELL} from "../OrderSet.sol"; +import {PoolKey} from "../PoolKey.sol"; import {Clearing} from "../Clearing.sol"; import {Settlement} from "../Settlement.sol"; import {CoreState, PoolState, Params, ParamsLib, CoreLib} from "../State.sol"; @@ -214,6 +215,7 @@ library WindowLogic { ps.shortMargin = 0; } } + if (ps.status == Status.Selling) return _finishSelling(s, ps); if (ps.status != Status.Closing) revert IConfluere.InvalidState(); Book storage bk = s.books[poolId][BID]; @@ -223,13 +225,94 @@ library WindowLogic { s.removeWithRefund(poolId, ps, id, CoreLib.RM_BID_CLOSED); } if (bk.head == 0) { + _burnCoreR(s, ps, poolId); _splitEscrow(s, pr, ps, poolId); - ps.status = Status.Closed; - emit IConfluere.WindowClosed(poolId, ps.quoteBal, ps.assetBal, ps.surplusBal); + if (ps.autoSell && ps.assetBal != 0 && _placeAutoSell(s, pr, ps, poolId)) { + ps.status = Status.Selling; + } else { + ps.status = Status.Closed; + emit IConfluere.WindowClosed(poolId, ps.quoteBal, ps.assetBal, ps.surplusBal); + } return true; } } + /// @dev R held by the core came from autoSell fills of an older pool; it is never repaid. Burn it and move its + /// share of the auction surplus to this pool's C holders (choice B2), so nothing is left unclaimable. + function _burnCoreR(CoreState storage s, PoolState storage ps, bytes32 poolId) private { + uint256 rId = poolId.rId(); + uint256 r = s.balanceOf[address(this)][rId]; + if (r == 0) return; + uint256 x = Settlement.share(r, ps.surplusBal, s.totalSupply[rId]); + ps.surplusBal -= x; + ps.quoteBal += x; + s.burn(address(this), rId, r); + } + + /// @dev Decision B: offer the defaulted asset as a competitive sell at K x discount in the same market's next + /// expiry pool of the same tier, owned by the core and linked back to this pool. False (the pool just closes and + /// pays in kind) when that pool does not exist, no longer trades, or its sell book is full of better orders. + function _placeAutoSell(CoreState storage s, Params memory pr, PoolState storage ps, bytes32 poolId) + private + returns (bool) + { + PoolKey memory k = ps.key; + k.expiry += pr.period(); + bytes32 target = PoolKeyLib.toId(k); + PoolState storage tp = s.pools[target]; + if (tp.status != Status.Open || block.timestamp >= k.expiry) return false; + uint64 id = _writeAutoSell(s, pr, ps, target, tp.settleCount, poolId); + if (!_insertAutoSell(s, pr, target, id)) return false; + Order storage o = s.orders[target][id]; + ps.autoSellPool = target; + ps.autoSellOrder = id; + ps.autoSellUnits = o.units; + emit IConfluere.AutoSellPlaced(poolId, target, id, o.units, o.limit); + return true; + } + + function _insertAutoSell(CoreState storage s, Params memory pr, bytes32 target, uint64 id) private returns (bool) { + uint64 hint = OrderSet.findHint(s.books[target][SELL], s.orders[target], SELL, s.orders[target][id].limit); + if (s.tryInsert(pr, target, s.pools[target], id, hint)) return true; + delete s.orders[target][id]; + delete s.linkedPool[target][id]; + return false; + } + + /// @dev The autoSell order: all of the defaulted asset (capped at uint96), limit K x discount (at least 1). + function _writeAutoSell( + CoreState storage s, + Params memory pr, + PoolState storage ps, + bytes32 target, + uint32 targetSettleCount, + bytes32 poolId + ) private returns (uint64 id) { + uint256 units = ps.assetBal > type(uint96).max ? type(uint96).max : ps.assetBal; + uint256 limit = uint256(ps.key.strike) * pr.autoSellDiscountBps() / Clearing.BPS; + id = s.newOrderId(); + Order storage o = s.orders[target][id]; + o.owner = address(this); + o.side = SELL; + o.flags = FLAG_AUTOSELL; + o.deadline = targetSettleCount + pr.autoSellTtl(); + o.units = uint96(units); + o.limit = uint96(limit == 0 ? 1 : limit); + s.linkedPool[target][id] = poolId; + } + + /// @dev A Selling pool waits for its order; once the order can no longer trade (ttl used up, or the target pool + /// stopped trading) anyone can end it here, so Selling always ends by the target's expiry at the latest. + function _finishSelling(CoreState storage s, PoolState storage ps) private returns (bool) { + bytes32 target = ps.autoSellPool; + PoolState storage tp = s.pools[target]; + Order storage o = s.orders[target][ps.autoSellOrder]; + bool trading = tp.status == Status.Open && block.timestamp < tp.key.expiry; + if (trading && o.deadline > tp.settleCount) revert IConfluere.InvalidState(); + s.removeWithRefund(target, tp, ps.autoSellOrder, CoreLib.RM_EXPIRED); + return true; + } + /// @dev The spread escrow splits once, by the fraction of filled units whose K was paid in full: that part goes /// to the treasury (and counts toward graduation), the defaulted part to C holders. function _splitEscrow(CoreState storage s, Params memory pr, PoolState storage ps, bytes32 poolId) private { From 086a9180f388b014091fe22649a58e10a81baa42 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:14:57 -0400 Subject: [PATCH 161/169] feat(core): sell defaulted collateral into the next expiry (autoSell) (tests invariant) Part 6/7 of: feat(core): sell defaulted collateral into the next expiry (autoSell) Files: contracts/test/invariant/Handler.sol, contracts/test/invariant/Invariants.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/invariant/Handler.sol | 4 +++- contracts/test/invariant/Invariants.t.sol | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/contracts/test/invariant/Handler.sol b/contracts/test/invariant/Handler.sol index c741939..f5db355 100644 --- a/contracts/test/invariant/Handler.sol +++ b/contracts/test/invariant/Handler.sol @@ -58,6 +58,7 @@ contract Handler is Test { uint256 cSupply; uint256 rSupply; uint256 out; // lent + short units: the forfeiture at close swaps them for their margin + bool selling; // autoSell fills trade the pool's asset for quote at the clearing price (invariant 4 allows it) } address public constant SPECIALS = address(0x5bec1a15); // Invariants.setUp makes this the core's Specials @@ -72,7 +73,7 @@ contract Handler is Test { Snap[] memory afterS = _snaps(); for (uint256 i; i < before.length; ++i) { bool burned = afterS[i].cSupply < before[i].cSupply || afterS[i].rSupply < before[i].rSupply - || afterS[i].out < before[i].out; + || afterS[i].out < before[i].out || before[i].selling; if (!burned && afterS[i].value < before[i].value) valueExtracted = true; } } @@ -90,6 +91,7 @@ contract Handler is Test { sn.cSupply = core.totalSupply(pid.cId()); sn.rSupply = core.totalSupply(pid.rId()); sn.out = uint256(v.lentUnits) + v.shortUnits; + sn.selling = v.status == Status.Selling; } constructor(Confluere c, MockERC20 w, MockERC20 u, address t, bytes32[] memory ps, address[] memory as_) { diff --git a/contracts/test/invariant/Invariants.t.sol b/contracts/test/invariant/Invariants.t.sol index dde76d4..6f9cca4 100644 --- a/contracts/test/invariant/Invariants.t.sol +++ b/contracts/test/invariant/Invariants.t.sol @@ -54,7 +54,7 @@ contract InvariantsTest is Test { ps[0] = core.createPool( PoolKey(address(weth), address(usdc), ANCHOR + WEEK, 3_010_966_302, Kind.American, 800, 0), 805, - IConfluere.PoolOptions(0, 0, 0, false) + IConfluere.PoolOptions(0, 0, 0, true) // autoSell into ps[1], the next expiry of the same tier ); ps[1] = core.createPool( PoolKey(address(weth), address(usdc), ANCHOR + 2 * WEEK, 3_010_966_302, Kind.American, 800, 0), @@ -188,6 +188,25 @@ contract InvariantsTest is Test { } } + /// @dev Invariant 3d: no stranded funds. A Selling pool's order rests in its target with exactly the units it + /// marks; a Closed pool has no order, no escrow, no lent or short units, and no R held by the core. + function invariant_noStrandedFunds() public view { + for (uint256 i; i < h.poolsLength(); ++i) { + bytes32 pid = h.pools(i); + IConfluere.PoolView memory v = core.pool(pid); + if (v.status == Status.Selling) { + assertGt(v.autoSellUnits, 0, "selling without units"); + (address owner,,, uint96 units,,,,) = core.order(v.autoSellPool, v.autoSellOrder); + assertEq(owner, address(core), "autoSell order missing"); + assertEq(units, v.autoSellUnits, "order units != marked units"); + } + if (v.status == Status.Closed) { + assertEq(v.autoSellUnits + v.feeEscrow + v.shortUnits + v.shortMargin, 0, "stranded after close"); + assertEq(core.balanceOf(address(core), pid.rId()), 0, "core R after close"); + } + } + } + /// @dev Blueprint 3.5: pools of one expiry share a window however late each is matured. function invariant_windowsAligned() public view { assertFalse(h.windowMisaligned(), "window not aligned per expiry"); From d5152d45ad3603415fc7fd57c03d68338128c7ef Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:14:57 -0400 Subject: [PATCH 162/169] feat(core): sell defaulted collateral into the next expiry (autoSell) (tests unit) Part 7/7 of: feat(core): sell defaulted collateral into the next expiry (autoSell) Files: contracts/test/unit/AutoSell.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/AutoSell.t.sol | 216 +++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 contracts/test/unit/AutoSell.t.sol diff --git a/contracts/test/unit/AutoSell.t.sol b/contracts/test/unit/AutoSell.t.sol new file mode 100644 index 0000000..65d266f --- /dev/null +++ b/contracts/test/unit/AutoSell.t.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.37; + +import {Base} from "../utils/Base.sol"; +import {IConfluere} from "../../src/interfaces/IConfluere.sol"; +import {Kind, Status} from "../../src/libraries/PoolKey.sol"; +import {SELL, BID, FLAG_AUTOSELL} from "../../src/libraries/OrderSet.sol"; +import {Settlement} from "../../src/libraries/Settlement.sol"; +import {Clearing} from "../../src/libraries/Clearing.sol"; + +/// @notice Decision B: a closed autoSell pool offers its defaulted asset in the same market's next expiry pool of the +/// same tier at K x discount (Base: 90%, ttl 4), owned by the core; claims wait while it is Selling. +contract AutoSellTest is Base { + bytes32 internal p1; + bytes32 internal p2; + uint256 internal constant LIMIT = uint256(K) * 9000 / 10_000; + + function setUp() public override { + super.setUp(); + p1 = _createKey(core, _key(K, Kind.American, E1), IConfluere.PoolOptions(0, 0, 0, true)); + p2 = _create(K, Kind.American, E2); + _sell(alice, p1, ONE, 2900e6); + _buy(bob, p1, ONE, 2900e6); + _settle(p1); + } + + /// @dev Default at E1: nobody repays, the window ends, the pool closes into Selling. + function _closeP1() internal returns (uint64 id) { + vm.warp(E1); + core.mature(p1); + vm.roll(block.number + WINDOW); + assertTrue(core.closeWindow(p1, 10)); + id = core.pool(p1).autoSellOrder; + } + + function _status(bytes32 pid) internal view returns (Status) { + return core.pool(pid).status; + } + + function test_autoSell_placesSellInNextExpirySameTier() public { + uint64 id = _closeP1(); + IConfluere.PoolView memory v = core.pool(p1); + assertEq(uint8(v.status), uint8(Status.Selling)); + assertEq(v.autoSellPool, p2); + assertEq(v.autoSellUnits, ONE); + (address owner, uint8 side, uint8 flags, uint96 units, uint96 limit,,,) = core.order(p2, id); + assertEq(owner, address(core)); + assertEq(side, SELL); + assertEq(flags, FLAG_AUTOSELL); + assertEq(units, ONE); + assertEq(limit, LIMIT); + assertEq(core.linkedPool(p2, id), p1); + assertEq(v.assetBal, ONE, "the units stay in the old pool until they sell"); + } + + function test_autoSell_offPool_noOrder() public { + bytes32 plain = _create(K_HI, Kind.American, E1); + _sell(alice, plain, ONE, 2900e6); + _buy(bob, plain, ONE, 2900e6); + _settle(plain); + vm.warp(E1); + core.mature(plain); + vm.roll(block.number + WINDOW); + core.closeWindow(plain, 10); + assertEq(uint8(_status(plain)), uint8(Status.Closed)); + assertEq(core.pool(plain).autoSellOrder, 0); + } + + function test_autoSell_ownerIsCore_cannotCancel() public { + uint64 id = _closeP1(); + vm.prank(alice); + vm.expectRevert(IConfluere.NotOwner.selector); + core.cancelOrder(p2, id); + } + + function test_autoSell_fill_creditsOldPoolNet() public { + _closeP1(); + uint256 q0 = core.pool(p1).quoteBal; + _buy(carol, p2, ONE, 2950e6); + _settle(p2); + uint256 price = Clearing.midpoint(LIMIT, 2950e6); + (,,, uint256 net) = Settlement.sellSplit(ONE, price, K, ONE); + IConfluere.PoolView memory v = core.pool(p1); + assertEq(v.quoteBal, q0 + net, "net proceeds to the old pool's C holders"); + assertEq(v.assetBal, 0); + assertEq(v.autoSellUnits, 0); + assertEq(uint8(v.status), uint8(Status.Closed), "fully sold: closed"); + assertEq(core.pool(p2).assetBal, ONE); + assertEq(core.balanceOf(address(core), _r(p2)), ONE, "the core holds the new R, never repaid"); + assertEq(core.balanceOf(carol, _c(p2)), ONE); + } + + function test_autoSell_claimWaitsForSelling() public { + _closeP1(); + vm.prank(bob); + vm.expectRevert(IConfluere.InvalidState.selector); + core.claim(p1); + _buy(carol, p2, ONE, 2950e6); + _settle(p2); + uint256 q = core.pool(p1).quoteBal; + vm.prank(bob); + (uint256 qo, uint256 ao) = core.claim(p1); + assertEq(qo, q, "lenders get quote only"); + assertEq(ao, 0); + } + + function test_autoSell_ttlOut_fallbackInKind() public { + _closeP1(); + for (uint256 i; i < 5; ++i) { + _settle(p2); + } + assertEq(uint8(_status(p1)), uint8(Status.Closed), "expired order removed at clearing"); + vm.prank(bob); + (, uint256 a) = core.claim(p1); + assertEq(a, ONE, "unsold remainder in kind"); + } + + function test_autoSell_partialThenExpire() public { + _closeP1(); + _buy(carol, p2, 4e17, 2950e6); + _settle(p2); + assertEq(core.pool(p1).autoSellUnits, 6e17); + assertEq(uint8(_status(p1)), uint8(Status.Selling)); + for (uint256 i; i < 4; ++i) { + _settle(p2); + } + assertEq(uint8(_status(p1)), uint8(Status.Closed)); + vm.prank(bob); + (uint256 q, uint256 a) = core.claim(p1); + assertEq(a, 6e17); + assertGt(q, 0); + } + + function test_autoSell_noTargetPool_closesDirectly() public { + bytes32 lone = _createKey(core, _key(K_HI, Kind.American, E1), IConfluere.PoolOptions(0, 0, 0, true)); + _sell(alice, lone, ONE, 2900e6); + _buy(bob, lone, ONE, 2900e6); + _settle(lone); + vm.warp(E1); + core.mature(lone); + vm.roll(block.number + WINDOW); + core.closeWindow(lone, 10); + assertEq(uint8(_status(lone)), uint8(Status.Closed)); + } + + function test_autoSell_targetBookFull_fallback() public { + for (uint256 i; i < 8; ++i) { + _order(dave, p2, SELL, ONE, 2000e6, 0, 100); + } + _closeP1(); + assertEq(uint8(_status(p1)), uint8(Status.Closed), "book full of better sells: no order, in kind"); + assertEq(core.pool(p1).assetBal, ONE); + } + + function test_autoSell_evicted_fallback() public { + for (uint256 i; i < 7; ++i) { + _order(dave, p2, SELL, ONE, 2000e6, 0, 100); + } + _closeP1(); // the 8th order: the book is now full + assertEq(uint8(_status(p1)), uint8(Status.Selling)); + _order(dave, p2, SELL, ONE, 2100e6, 0, 100); // a better sell evicts the worst, the autoSell order + assertEq(uint8(_status(p1)), uint8(Status.Closed)); + assertEq(core.pool(p1).assetBal, ONE); + } + + function test_autoSell_targetPaused_stillPlaced() public { + vm.prank(guardian); + core.pause(p2, 1000); + _closeP1(); + assertEq(uint8(_status(p1)), uint8(Status.Selling)); + } + + function test_autoSell_closeWindowFinalizesDeadOrder() public { + _closeP1(); + vm.expectRevert(IConfluere.InvalidState.selector); + core.closeWindow(p1, 1); // the order can still trade + vm.warp(E2); // the target stopped trading + assertTrue(core.closeWindow(p1, 1)); + assertEq(uint8(_status(p1)), uint8(Status.Closed)); + } + + function test_autoSell_purgeFinalizes() public { + uint64 id = _closeP1(); + vm.warp(E2); + uint64[] memory ids = new uint64[](1); + ids[0] = id; + core.purge(p2, ids); + assertEq(uint8(_status(p1)), uint8(Status.Closed)); + assertEq(core.pool(p1).autoSellUnits, 0); + } + + function test_autoSell_coreR_burnedAndSurplusToLenders() public { + _closeP1(); + _buy(carol, p2, ONE, 2950e6); + _settle(p2); + vm.warp(E2); + core.mature(p2); + uint64 hint = core.findHint(p2, BID, 50e6); + vm.prank(eve); + core.bid(p2, uint96(ONE), 50e6, hint); + vm.roll(block.number + WINDOW); + uint256 q0 = core.pool(p2).quoteBal; + core.closeWindow(p2, 10); + IConfluere.PoolView memory v = core.pool(p2); + assertEq(core.balanceOf(address(core), _r(p2)), 0, "core R burned"); + assertEq(core.totalSupply(_r(p2)), 0); + assertEq(v.surplusBal, 0, "the core's surplus share moved to C holders"); + assertEq(v.quoteBal, q0 + uint256(K) + 50e6 + v.feeEscrow, "K and the surplus of the bid unit, escrow split"); + assertEq(uint8(v.status), uint8(Status.Closed)); + } + + function test_autoSell_notUsedWhileOpen() public view { + assertEq(core.pool(p1).autoSellOrder, 0); + assertTrue(core.pool(p1).options.autoSell); + } +} From 65ff3819265c6351e956a7cbec5fb1ff62377e08 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:16:58 -0400 Subject: [PATCH 163/169] fix(vault): keep VaultFactory under EIP-170 with a hash-checked Vault init code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VaultFactory embedded the Vault init code and grew to 25.7 KB, so it could not be deployed. The constructor now fixes keccak256(type(Vault).creationCode) and create() takes the init code, checks it against that hash and deploys with CREATE2 at the same address as before (salt keccak(sender, salt)). Deploy and CreateVaults pass the code; new test rejects other bytecode. Factory 1.7 KB. No §2.11 invariant touched (periphery only). Part 1/3 of: fix(vault): keep VaultFactory under EIP-170 with a hash-checked Vault init code Files: contracts/script/CreateVaults.s.sol, contracts/script/Deploy.s.sol Co-Authored-By: Claude Opus 5.5 --- contracts/script/CreateVaults.s.sol | 2 +- contracts/script/Deploy.s.sol | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/contracts/script/CreateVaults.s.sol b/contracts/script/CreateVaults.s.sol index 07d9eb5..83e5eac 100644 --- a/contracts/script/CreateVaults.s.sol +++ b/contracts/script/CreateVaults.s.sol @@ -113,6 +113,6 @@ contract CreateVaults is Script { ) internal returns (Vault v) { bytes memory init = abi.encodePacked(type(Vault).creationCode, abi.encode(f.core(), s, name, symbol)); v = Vault(vm.computeCreate2Address(keccak256(abi.encode(me, salt)), keccak256(init), address(f))); - if (address(v).code.length == 0) v = f.create(s, name, symbol, salt); + if (address(v).code.length == 0) v = f.create(type(Vault).creationCode, s, name, symbol, salt); } } diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol index 555fa81..01429e6 100644 --- a/contracts/script/Deploy.s.sol +++ b/contracts/script/Deploy.s.sol @@ -7,6 +7,7 @@ import {IConfluere} from "../src/interfaces/IConfluere.sol"; import {Router} from "../src/periphery/Router.sol"; import {ERC6909WrapperFactory} from "../src/periphery/ERC6909Wrapper.sol"; import {VaultFactory} from "../src/periphery/VaultFactory.sol"; +import {Vault} from "../src/periphery/Vault.sol"; import {KeeperReceiver} from "../src/periphery/KeeperReceiver.sol"; import {SeatRegistry} from "../src/periphery/SeatRegistry.sol"; import {Specials} from "../src/periphery/Specials.sol"; @@ -312,8 +313,9 @@ contract Deploy is Script { if (p.router == address(0)) p.router = address(new Router{salt: salt}(core)); p.wrapperFactory = _existing(salt, abi.encodePacked(type(ERC6909WrapperFactory).creationCode, arg)); if (p.wrapperFactory == address(0)) p.wrapperFactory = address(new ERC6909WrapperFactory{salt: salt}(core)); - p.vaultFactory = _existing(salt, abi.encodePacked(type(VaultFactory).creationCode, arg)); - if (p.vaultFactory == address(0)) p.vaultFactory = address(new VaultFactory{salt: salt}(core)); + bytes32 vaultCode = keccak256(type(Vault).creationCode); + p.vaultFactory = _existing(salt, abi.encodePacked(type(VaultFactory).creationCode, abi.encode(core, vaultCode))); + if (p.vaultFactory == address(0)) p.vaultFactory = address(new VaultFactory{salt: salt}(core, vaultCode)); p.keeperReceiver = _existing(salt, abi.encodePacked(type(KeeperReceiver).creationCode, abi.encode(core, uint256(16)))); if (p.keeperReceiver == address(0)) p.keeperReceiver = address(new KeeperReceiver{salt: salt}(core, 16)); From 41de24ff75e6f5010662cff1d09e8b128b1d6b58 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:16:58 -0400 Subject: [PATCH 164/169] fix(vault): keep VaultFactory under EIP-170 with a hash-checked Vault init code Part 2/3 of: fix(vault): keep VaultFactory under EIP-170 with a hash-checked Vault init code Files: contracts/src/periphery/VaultFactory.sol Co-Authored-By: Claude Opus 5.5 --- contracts/src/periphery/VaultFactory.sol | 35 ++++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/contracts/src/periphery/VaultFactory.sol b/contracts/src/periphery/VaultFactory.sol index 837c9d7..aedf6ab 100644 --- a/contracts/src/periphery/VaultFactory.sol +++ b/contracts/src/periphery/VaultFactory.sol @@ -5,23 +5,42 @@ import {IConfluere} from "../interfaces/IConfluere.sol"; import {Vault} from "./Vault.sol"; /// @notice Deploys vaults with fixed strategies at deterministic addresses and keeps a public list. No admin. +/// @dev The Vault init code is too large to embed (EIP-170), so the caller passes it and the factory checks it against +/// the code hash fixed at construction: only the one audited Vault bytecode can be deployed through it. contract VaultFactory { IConfluere public immutable core; + bytes32 public immutable vaultCodeHash; address[] public vaults; event VaultCreated(address indexed vault, address indexed quote, string symbol); - constructor(IConfluere core_) { + error WrongVaultCode(); + error DeployFailed(); + + constructor(IConfluere core_, bytes32 vaultCodeHash_) { core = core_; + vaultCodeHash = vaultCodeHash_; } - function create(Vault.Strategy calldata s, string calldata name, string calldata symbol, bytes32 salt) - external - returns (Vault v) - { - v = new Vault{salt: keccak256(abi.encode(msg.sender, salt))}(core, s, name, symbol); - vaults.push(address(v)); - emit VaultCreated(address(v), s.quote, symbol); + /// @param initCode `type(Vault).creationCode`; must hash to `vaultCodeHash`. + function create( + bytes calldata initCode, + Vault.Strategy calldata s, + string calldata name, + string calldata symbol, + bytes32 salt + ) external returns (Vault v) { + if (keccak256(initCode) != vaultCodeHash) revert WrongVaultCode(); + bytes memory init = abi.encodePacked(initCode, abi.encode(core, s, name, symbol)); + bytes32 k = keccak256(abi.encode(msg.sender, salt)); + address a; + assembly ("memory-safe") { + a := create2(0, add(init, 0x20), mload(init), k) + } + if (a == address(0)) revert DeployFailed(); + v = Vault(a); + vaults.push(a); + emit VaultCreated(a, s.quote, symbol); } function vaultsLength() external view returns (uint256) { From fc1bef97dbe6f40a9888d6bb8e375d995dc7d664 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:16:58 -0400 Subject: [PATCH 165/169] fix(vault): keep VaultFactory under EIP-170 with a hash-checked Vault init code (tests unit) Part 3/3 of: fix(vault): keep VaultFactory under EIP-170 with a hash-checked Vault init code Files: contracts/test/unit/Periphery.t.sol Co-Authored-By: Claude Opus 5.5 --- contracts/test/unit/Periphery.t.sol | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/contracts/test/unit/Periphery.t.sol b/contracts/test/unit/Periphery.t.sol index ab3c1b9..46df0df 100644 --- a/contracts/test/unit/Periphery.t.sol +++ b/contracts/test/unit/Periphery.t.sol @@ -164,10 +164,11 @@ contract PeripheryTest is Base { } function test_vaultFactory() public { - VaultFactory f = new VaultFactory(core); + VaultFactory f = new VaultFactory(core, keccak256(type(Vault).creationCode)); address[] memory as_ = new address[](1); as_[0] = address(weth); Vault v = f.create( + type(Vault).creationCode, Vault.Strategy(address(usdc), as_, Kind.American, Vault.StrikeRule.HighTier, 2500, 9900), "rvUSDC-ETH-High", "rvUSDC-ETH-High", @@ -177,4 +178,18 @@ contract PeripheryTest is Base { assertEq(v.asset(), address(usdc)); assertEq(uint8(v.strikeRule()), 1); } + + function test_vaultFactory_rejectsOtherCode() public { + VaultFactory f = new VaultFactory(core, keccak256(type(Vault).creationCode)); + address[] memory as_ = new address[](1); + as_[0] = address(weth); + vm.expectRevert(VaultFactory.WrongVaultCode.selector); + f.create( + hex"00", + Vault.Strategy(address(usdc), as_, Kind.American, Vault.StrikeRule.HighTier, 2500, 9900), + "x", + "x", + bytes32(0) + ); + } } From 9c24212bb5127e8a625db9f3fab06132529255b4 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:17:02 -0400 Subject: [PATCH 166/169] test(halmos): symbolic check of the v0.8 NC pricing rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_ncPricingRule compares a reference 3x3 cross with NC demand priced in ahead of competitive buys: the recross price never falls below the competitive-only price, stays between the filling marginal limits, and NC volume stays within half the competitive-only volume (§2.11 NC cap). Left uncommitted in the tree by the core agent; committed in the final pass. Co-Authored-By: Claude Opus 5.5 --- contracts/test/halmos/ClearingHalmos.t.sol | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/contracts/test/halmos/ClearingHalmos.t.sol b/contracts/test/halmos/ClearingHalmos.t.sol index 84af90a..2771443 100644 --- a/contracts/test/halmos/ClearingHalmos.t.sol +++ b/contracts/test/halmos/ClearingHalmos.t.sol @@ -60,4 +60,93 @@ contract ClearingHalmos is Test { uint256 b = Clearing.widenedBand(bandBps, uint256(f) + 1); assert(b >= a && b <= Clearing.BPS); } + + // ------------------------------------------------------------ v0.8: non-competitive buys in the price + + /// Reference of ClearingLogic._cross on a 3-order book per side (sorted limits, positive sizes): `pre` units of + /// any-price demand take the cheapest sells first, then the competitive books cross; P* is the midpoint of the + /// marginal pair. + function _cross(uint256[3] memory su, uint256[3] memory sl, uint256[3] memory bu, uint256[3] memory bl, uint256 pre) + internal + pure + returns (uint256 q, uint256 price, uint256 ms, uint256 mb) + { + uint256 i; + uint256 j; + uint256 sr = su[0]; + uint256 br = bu[0]; + while (pre != 0 && i < 3) { + uint256 t = sr < pre ? sr : pre; + pre -= t; + sr -= t; + if (sr == 0 && ++i < 3) sr = su[i]; + } + if (pre != 0) return (0, 0, 0, 0); + while (i < 3 && j < 3 && sl[i] <= bl[j]) { + uint256 m = sr < br ? sr : br; + q += m; + ms = sl[i]; + mb = bl[j]; + sr -= m; + br -= m; + if (sr == 0 && ++i < 3) sr = su[i]; + if (br == 0 && ++j < 3) br = bu[j]; + } + if (q != 0) price = Clearing.midpoint(ms, mb); + } + + /// The NC pricing rule (blueprint 4.2 as pinned by the simulations): with d = min(NC demand, q0 / 2) priced in + /// ahead of the competitive buys, the recross price never falls below the competitive-only price, stays between + /// the marginal competitive limits that fill, and the NC volume stays within half the competitive-only volume. + function check_ncPricingRule( + uint8[3] memory sUnits, + uint16[3] memory sLim, + uint8[3] memory bUnits, + uint16[3] memory bLim, + uint8 demand + ) public pure { + uint256[3] memory su; + uint256[3] memory sl; + uint256[3] memory bu; + uint256[3] memory bl; + for (uint256 k; k < 3; ++k) { + vm.assume(sUnits[k] != 0 && bUnits[k] != 0); + (su[k], sl[k], bu[k], bl[k]) = (sUnits[k], sLim[k], bUnits[k], bLim[k]); + } + vm.assume(sl[0] <= sl[1] && sl[1] <= sl[2] && bl[0] >= bl[1] && bl[1] >= bl[2]); + (uint256 q0, uint256 p0,,) = _cross(su, sl, bu, bl, 0); + vm.assume(q0 != 0); + uint256 cap = q0 * 5000 / 10_000; + uint256 d = demand < cap ? demand : cap; + vm.assume(d != 0); + (uint256 q1, uint256 p1, uint256 ms, uint256 mb) = _cross(su, sl, bu, bl, d); + vm.assume(q1 != 0); // otherwise the competitive-only result stands + assert(p1 >= p0); + assert(ms <= p1 && p1 <= mb); + assert(2 * d <= q0); + } + + /// Stage-2 sizing keeps the total non-competitive volume within half the competitive-only volume. + function check_ncCapHalf(uint128 qc, uint128 x, uint128 m2raw) public pure { + uint256 cap = uint256(qc) * 5000 / 10_000; + vm.assume(x <= cap); + uint256 m2 = m2raw < cap - x ? m2raw : cap - x; + assert(uint256(x) + m2 <= uint256(qc) / 2); + } + + /// The seller's split never goes negative and adds back to the gross, whatever the raw spread share. + function check_sellSplitNonNegative(uint128 gross, uint128 raw) public pure { + uint256 trade = Settlement.tradeFee(gross); + uint256 spread = raw > gross - trade ? gross - trade : raw; + uint256 net = gross - trade - spread; + assert(trade <= gross && net + spread + trade == gross); + } + + /// Guardian pause budget (invariant 3c): an accepted pause never takes the cumulative total past the cap. + function check_pauseCap(uint64 pausedBlocks, uint64 blocks, uint64 max) public pure { + vm.assume(pausedBlocks <= max); + uint256 total = uint256(pausedBlocks) + blocks; + if (blocks == 0 || total > max) return; // rejected + assert(total <= max && total > pausedBlocks); + } } From 3192a7b6c88573128afbe05e9672eed6d87f7e34 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:21:30 -0400 Subject: [PATCH 167/169] refactor(core): split closeWindow's shared-pot close from the fee-escrow split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The short-margin forfeiture and closeRemaining sizing (decision A) move from the body of closeWindow into _closeSharedPot, so the shared pot and _splitEscrow are two separate internal functions. Behaviour unchanged. Invariants (§2.11): touches the window-close accounting of #1 (pool solvency) and #7 (pull refunds) without changing them; all forge tests pass. Co-Authored-By: Claude Opus 5.5 --- contracts/src/libraries/logic/WindowLogic.sol | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/contracts/src/libraries/logic/WindowLogic.sol b/contracts/src/libraries/logic/WindowLogic.sol index ef2829c..74a2968 100644 --- a/contracts/src/libraries/logic/WindowLogic.sol +++ b/contracts/src/libraries/logic/WindowLogic.sol @@ -203,17 +203,7 @@ library WindowLogic { if (block.number < ps.windowEnd) revert IConfluere.InvalidState(); ps.status = Status.Closing; _syncShort(ps, pr, poolId); - // bids only take units that are here; short units nobody repaid default to C holders as their margin - uint256 su = ps.shortUnits; - uint256 r = s.totalSupply[poolId.rId()]; - uint256 phys = ps.assetBal - su; - ps.closeRemaining = r < phys ? r : phys; - if (su != 0) { - ps.quoteBal += ps.shortMargin; - ps.assetBal -= su; - ps.shortUnits = 0; - ps.shortMargin = 0; - } + _closeSharedPot(s, ps, poolId); } if (ps.status == Status.Selling) return _finishSelling(s, ps); if (ps.status != Status.Closing) revert IConfluere.InvalidState(); @@ -237,6 +227,21 @@ library WindowLogic { } } + /// @dev The shared pot at window end (decision A), kept apart from the fee-escrow split: bids only take units that + /// are physically here, and short units nobody repaid default to C holders as their forfeited margin. + function _closeSharedPot(CoreState storage s, PoolState storage ps, bytes32 poolId) private { + uint256 su = ps.shortUnits; + uint256 r = s.totalSupply[poolId.rId()]; + uint256 phys = ps.assetBal - su; + ps.closeRemaining = r < phys ? r : phys; + if (su != 0) { + ps.quoteBal += ps.shortMargin; + ps.assetBal -= su; + ps.shortUnits = 0; + ps.shortMargin = 0; + } + } + /// @dev R held by the core came from autoSell fills of an older pool; it is never repaid. Burn it and move its /// share of the auction surplus to this pool's C holders (choice B2), so nothing is left unclaimable. function _burnCoreR(CoreState storage s, PoolState storage ps, bytes32 poolId) private { From f8631b9a27647ca0a928459d42f5c19197369c16 Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:34:53 -0400 Subject: [PATCH 168/169] test(halmos): pack the NC pricing harness and mark the two slow checks slow_ The 13-scalar check_ncPricingRule failed to compile in the unoptimized Halmos profile (stack too deep); its inputs are now four packed fields plus demand. It and check_ncCapHalf time out the solver within the default budget, so both move to the slow_ prefix (like the z3-lemma properties) and the check_ suite runs green: 7/7. Co-Authored-By: Claude Opus 5.5 --- contracts/test/halmos/ClearingHalmos.t.sol | 36 +++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/contracts/test/halmos/ClearingHalmos.t.sol b/contracts/test/halmos/ClearingHalmos.t.sol index 2771443..45286e0 100644 --- a/contracts/test/halmos/ClearingHalmos.t.sol +++ b/contracts/test/halmos/ClearingHalmos.t.sol @@ -98,26 +98,18 @@ contract ClearingHalmos is Test { /// The NC pricing rule (blueprint 4.2 as pinned by the simulations): with d = min(NC demand, q0 / 2) priced in /// ahead of the competitive buys, the recross price never falls below the competitive-only price, stays between /// the marginal competitive limits that fill, and the NC volume stays within half the competitive-only volume. - function check_ncPricingRule( - uint8[3] memory sUnits, - uint16[3] memory sLim, - uint8[3] memory bUnits, - uint16[3] memory bLim, - uint8 demand - ) public pure { - uint256[3] memory su; - uint256[3] memory sl; - uint256[3] memory bu; - uint256[3] memory bl; - for (uint256 k; k < 3; ++k) { - vm.assume(sUnits[k] != 0 && bUnits[k] != 0); - (su[k], sl[k], bu[k], bl[k]) = (sUnits[k], sLim[k], bUnits[k], bLim[k]); - } + /// Books of three sells and three buys with symbolic sizes and sorted symbolic limits. + /// @custom:halmos --loop 8 + function slow_ncPricingRule(uint24 sUnits, uint48 sLim, uint24 bUnits, uint48 bLim, uint8 demand) public pure { + uint256[3] memory su = _unpack(sUnits, 8); + uint256[3] memory sl = _unpack(sLim, 16); + uint256[3] memory bu = _unpack(bUnits, 8); + uint256[3] memory bl = _unpack(bLim, 16); + vm.assume(su[0] != 0 && su[1] != 0 && su[2] != 0 && bu[0] != 0 && bu[1] != 0 && bu[2] != 0); vm.assume(sl[0] <= sl[1] && sl[1] <= sl[2] && bl[0] >= bl[1] && bl[1] >= bl[2]); (uint256 q0, uint256 p0,,) = _cross(su, sl, bu, bl, 0); vm.assume(q0 != 0); - uint256 cap = q0 * 5000 / 10_000; - uint256 d = demand < cap ? demand : cap; + uint256 d = demand < q0 / 2 ? demand : q0 / 2; vm.assume(d != 0); (uint256 q1, uint256 p1, uint256 ms, uint256 mb) = _cross(su, sl, bu, bl, d); vm.assume(q1 != 0); // otherwise the competitive-only result stands @@ -126,8 +118,16 @@ contract ClearingHalmos is Test { assert(2 * d <= q0); } + /// Three `w`-bit fields of `x`, low field first (packed so the harness stays within the legacy stack limit). + function _unpack(uint256 x, uint256 w) internal pure returns (uint256[3] memory r) { + uint256 m = (1 << w) - 1; + r[0] = x & m; + r[1] = (x >> w) & m; + r[2] = (x >> (2 * w)) & m; + } + /// Stage-2 sizing keeps the total non-competitive volume within half the competitive-only volume. - function check_ncCapHalf(uint128 qc, uint128 x, uint128 m2raw) public pure { + function slow_ncCapHalf(uint64 qc, uint64 x, uint64 m2raw) public pure { uint256 cap = uint256(qc) * 5000 / 10_000; vm.assume(x <= cap); uint256 m2 = m2raw < cap - x ? m2raw : cap - x; From f12922eb168af79b60b31d86c7aa5fbc31c2155c Mon Sep 17 00:00:00 2001 From: Scout Date: Sat, 24 Oct 2026 03:35:20 -0400 Subject: [PATCH 169/169] fix(scripts): treat a zombie anvil as dead in the e2e wait loop An anvil child that exited at once (port taken) stays a zombie until reaped and still passes kill -0, so the loop waited the full 30 s and printed the timeout message instead of "anvil failed to start". Also check its process state. e2e-guard.test.sh passes again. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e-anvil.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/e2e-anvil.sh b/scripts/e2e-anvil.sh index 240ae31..8a48126 100755 --- a/scripts/e2e-anvil.sh +++ b/scripts/e2e-anvil.sh @@ -50,7 +50,9 @@ trap cleanup EXIT # wait for this run's anvil (30 s at most); stop at once if it exited (e.g. it could not bind the port) for i in $(seq 1 60); do cast chain-id --rpc-url $R >/dev/null 2>&1 && break - kill -0 $ANVIL 2>/dev/null || { echo "FAIL: anvil failed to start"; exit 1; } + # an exited child stays a zombie until reaped and still answers kill -0, so check its state too + { kill -0 $ANVIL 2>/dev/null && [ "$(ps -o stat= -p $ANVIL 2>/dev/null | cut -c1)" != Z ]; } \ + || { echo "FAIL: anvil failed to start"; exit 1; } [ "$i" -lt 60 ] || { echo "FAIL: anvil did not answer on 8545 within 30 s"; exit 1; } sleep 0.5 done