diff --git a/README.md b/README.md index c36c7c7..54ac0fa 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ Implementation-grade blueprint for the **home-minted M1 suite on ChainID 138**, 2. See [docs/02-pool-topology.md](docs/02-pool-topology.md) for edge pool design. 3. See [docs/06-deployment-recipe.md](docs/06-deployment-recipe.md) for step-by-step deployment. 4. Config: [config/token-map.json](config/token-map.json), [config/pool-matrix.json](config/pool-matrix.json), [config/peg-bands.json](config/peg-bands.json), [config/chains.json](config/chains.json). +5. **Simulation:** Two-layer model (design vs deployed graph), routing supergraph, PMM edge function, and scenario-based sim: [docs/08-simulation-model.md](docs/08-simulation-model.md). First-pass params: [config/simulation-params.json](config/simulation-params.json), [docs/09-simulation-params-sheet.md](docs/09-simulation-params-sheet.md). Deployment-realistic sim: fill [config/deployment-status.json](config/deployment-status.json). +6. **Behavioral stability:** What to simulate first, three metrics in economic terms, where it can break: [docs/10-behavioral-stability-analysis.md](docs/10-behavioral-stability-analysis.md). **Safe inventory sizing** (closed-form I_T^* per chain): [docs/11-safe-inventory-sizing.md](docs/11-safe-inventory-sizing.md). +7. **Tools:** [scripts/size-inventory.cjs](scripts/size-inventory.cjs) — regenerate I_T^*, D_0 from params (single command, deterministic; see [scripts/README.md](scripts/README.md)). [scripts/validate-deployment-status.cjs](scripts/validate-deployment-status.cjs) — CI validation for deployment-status (phase 1 tokens when bridge=true; pool role/fee/k/tokens). +8. **Sim scorecard:** Simulator output contract and pass/fail gates: [docs/12-sim-scorecard.md](docs/12-sim-scorecard.md), [config/scorecard-schema.json](config/scorecard-schema.json). Bridge edge with optional **latency risk** ρ(Δt): [docs/08-simulation-model.md](docs/08-simulation-model.md) §2. +9. **Scenario contract:** Formal scenario input schema and three Phase 0 scenarios: [config/scenario-schema.json](config/scenario-schema.json), [config/scenarios/](config/scenarios/). **Routing exposure controls:** [config/routing-controls.json](config/routing-controls.json) (maxTradeSize, cooldown, minImprovementBps, publicRoutingEnabled). **Real sim (PR#1):** `node scripts/run-scenario.cjs hub_only_11` — graph from configs, PMM state, path enumeration + waterfilling, real scorecard (drain half-life, path concentration, capture, churn); see [spec/13-minimal-router-sim.md](spec/13-minimal-router-sim.md). ## Parent repo diff --git a/config/deployment-status.json b/config/deployment-status.json new file mode 100644 index 0000000..606ed4c --- /dev/null +++ b/config/deployment-status.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Deployed graph: per-chain cW* addresses, anchor addresses, PMM pool existence and params. Fill to enable deployment-realistic simulation. Empty = design-only simulation.", + "version": "1.0.0", + "updated": "2026-02-26", + "homeChainId": 138, + "chains": { + "1": { + "name": "Ethereum Mainnet", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "10": { + "name": "Optimism", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "25": { + "name": "Cronos", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "56": { + "name": "BSC (BNB Chain)", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "100": { + "name": "Gnosis Chain", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "137": { + "name": "Polygon", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "42161": { + "name": "Arbitrum One", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "8453": { + "name": "Base", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "43114": { + "name": "Avalanche C-Chain", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "42220": { + "name": "Celo", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + }, + "1111": { + "name": "Wemix", + "cwTokens": {}, + "anchorAddresses": {}, + "pmmPools": [], + "bridgeAvailable": null + } + }, + "schemaNotes": { + "cwTokens": "e.g. { \"cWUSDT\": \"0x...\", \"cWUSDC\": \"0x...\" }", + "anchorAddresses": "e.g. { \"USDC\": \"0x...\", \"USDT\": \"0x...\" }", + "pmmPools": "array of { \"base\", \"quote\", \"poolAddress\", \"feeBps\", \"k\", \"initialLiquidity\", \"role\": \"defense\"|\"public_routing\"; optional routing controls: maxTradeSizeUnits, maxDailyNotional, cooldownBlocksAfterIntervention, minImprovementBpsToTrade, publicRoutingEnabled }", + "bridgeAvailable": "true | false | null (unknown)" + }, + "routingControlsDoc": "config/routing-controls.json for defaults and per-pool overrides" +} diff --git a/config/pool-matrix.json b/config/pool-matrix.json index 9987aaf..c481cb6 100644 --- a/config/pool-matrix.json +++ b/config/pool-matrix.json @@ -1,41 +1 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Per-chain pool matrix: which cW* / stable pools to deploy. Single-sided on cW* side.", - "version": "1.0.0", - "updated": "2026-02-26", - "cwTokens": ["cWUSDT", "cWUSDC", "cWAUSDT", "cWEURC", "cWEURT", "cWUSDW"], - "strategy": "hub_first", - "chains": { - "1": { - "name": "Ethereum Mainnet", - "hubStable": "USDC", - "poolsFirst": ["cWUSDT/USDC", "cWUSDC/USDC", "cWEURC/USDC", "cWEURT/USDC", "cWUSDW/USDC", "cWAUSDT/USDC"], - "poolsOptional": ["cWUSDT/USDT", "cWUSDT/DAI", "cWUSDC/USDT", "cWUSDC/DAI"] - }, - "56": { - "name": "BSC", - "hubStable": "USDT", - "poolsFirst": ["cWUSDT/USDT", "cWUSDC/USDT", "cWAUSDT/USDT", "cWEURC/USDT", "cWEURT/USDT", "cWUSDW/USDT"], - "poolsOptional": ["cWUSDT/USDC", "cWUSDT/BUSD", "cWUSDC/USDC", "cWUSDC/BUSD"] - }, - "137": { - "name": "Polygon", - "hubStable": "USDC", - "poolsFirst": ["cWUSDT/USDC", "cWUSDC/USDC", "cWAUSDT/USDC", "cWEURC/USDC", "cWEURT/USDC", "cWUSDW/USDC"], - "poolsOptional": ["cWUSDT/USDT", "cWUSDC/USDT", "cWUSDT/DAI", "cWUSDC/DAI"] - }, - "10": { - "name": "Optimism", - "hubStable": "USDC", - "poolsFirst": ["cWUSDT/USDC", "cWUSDC/USDC", "cWAUSDT/USDC", "cWEURC/USDC", "cWEURT/USDC", "cWUSDW/USDC"], - "poolsOptional": ["cWUSDT/USDT", "cWUSDC/USDT", "cWUSDT/DAI", "cWUSDC/DAI"] - }, - "100": { - "name": "Gnosis", - "hubStable": "USDC", - "poolsFirst": ["cWUSDT/USDC", "cWUSDC/USDC", "cWAUSDT/USDC", "cWEURC/USDC", "cWEURT/USDC", "cWUSDW/USDC"], - "poolsOptional": ["cWUSDT/USDT", "cWUSDC/USDT", "cWUSDT/DAI", "cWUSDT/mUSD", "cWUSDC/mUSD"] - } - }, - "liquiditySizingTargets": {} -} +{"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Per-chain pool matrix: which cW* / stable pools to deploy. Single-sided on cW* side.","version":"1.0.0","updated":"2026-02-27","cwTokens":["cWUSDT","cWUSDC","cWAUSDT","cWEURC","cWEURT","cWUSDW"],"strategy":"hub_first","chains":{"1":{"name":"Ethereum Mainnet","hubStable":"USDC","poolsFirst":["cWUSDT/USDC","cWUSDC/USDC","cWEURC/USDC","cWEURT/USDC","cWUSDW/USDC","cWAUSDT/USDC"],"poolsOptional":["cWUSDT/USDT","cWUSDT/DAI","cWUSDC/USDT","cWUSDC/DAI"]},"56":{"name":"BSC","hubStable":"USDT","poolsFirst":["cWUSDT/USDT","cWUSDC/USDT","cWAUSDT/USDT","cWEURC/USDT","cWEURT/USDT","cWUSDW/USDT"],"poolsOptional":["cWUSDT/USDC","cWUSDT/BUSD","cWUSDC/USDC","cWUSDC/BUSD"]},"137":{"name":"Polygon","hubStable":"USDC","poolsFirst":["cWUSDT/USDC","cWUSDC/USDC","cWAUSDT/USDC","cWEURC/USDC","cWEURT/USDC","cWUSDW/USDC"],"poolsOptional":["cWUSDT/USDT","cWUSDC/USDT","cWUSDT/DAI","cWUSDC/DAI"]},"10":{"name":"Optimism","hubStable":"USDC","poolsFirst":["cWUSDT/USDC","cWUSDC/USDC","cWAUSDT/USDC","cWEURC/USDC","cWEURT/USDC","cWUSDW/USDC"],"poolsOptional":["cWUSDT/USDT","cWUSDC/USDT","cWUSDT/DAI","cWUSDC/DAI"]},"100":{"name":"Gnosis","hubStable":"USDC","poolsFirst":["cWUSDT/USDC","cWUSDC/USDC","cWAUSDT/USDC","cWEURC/USDC","cWEURT/USDC","cWUSDW/USDC"],"poolsOptional":["cWUSDT/USDT","cWUSDC/USDT","cWUSDT/DAI","cWUSDT/mUSD","cWUSDC/mUSD"]},"25":{"name":"Cronos","hubStable":"USDT","poolsFirst":["cWUSDT/USDT","cWUSDC/USDT","cWAUSDT/USDT","cWEURC/USDT","cWEURT/USDT","cWUSDW/USDT"],"poolsOptional":["cWUSDT/USDC","cWUSDT/BUSD","cWUSDC/USDC","cWUSDC/BUSD"]},"42220":{"name":"Celo","hubStable":"USDC","poolsFirst":["cWUSDT/USDC","cWUSDC/USDC","cWAUSDT/USDC","cWEURC/USDC","cWEURT/USDC","cWUSDW/USDC"],"poolsOptional":["cWUSDT/USDT","cWUSDC/USDT","cWUSDT/DAI","cWUSDC/DAI"]},"43114":{"name":"Avalanche C-Chain","hubStable":"USDC","poolsFirst":["cWUSDT/USDC","cWUSDC/USDC","cWAUSDT/USDC","cWEURC/USDC","cWEURT/USDC","cWUSDW/USDC"],"poolsOptional":["cWUSDT/USDT","cWUSDC/USDT","cWUSDT/DAI","cWUSDC/DAI"]},"42161":{"name":"Arbitrum One","hubStable":"USDC","poolsFirst":["cWUSDT/USDC","cWUSDC/USDC","cWAUSDT/USDC","cWEURC/USDC","cWEURT/USDC","cWUSDW/USDC"],"poolsOptional":["cWUSDT/USDT","cWUSDC/USDT","cWUSDT/DAI","cWUSDC/DAI"]},"8453":{"name":"Base","hubStable":"USDC","poolsFirst":["cWUSDT/USDC","cWUSDC/USDC","cWAUSDT/USDC","cWEURC/USDC","cWEURT/USDC","cWUSDW/USDC"],"poolsOptional":["cWUSDT/USDT","cWUSDC/USDT","cWUSDT/DAI","cWUSDC/DAI"]},"1111":{"name":"Wemix","hubStable":"USDT","poolsFirst":["cWUSDT/USDT","cWUSDC/USDT","cWAUSDT/USDT","cWEURC/USDT","cWEURT/USDT","cWUSDW/USDT"],"poolsOptional":["cWUSDT/USDC","cWUSDT/BUSD","cWUSDC/USDC","cWUSDC/BUSD"]}},"liquiditySizingTargets":{}} \ No newline at end of file diff --git a/config/routing-controls.json b/config/routing-controls.json new file mode 100644 index 0000000..24b1760 --- /dev/null +++ b/config/routing-controls.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Routing exposure controls: central-bank corridor levers beyond k/fee. Optional; apply per pool or as defaults. Not enforced by validator yet.", + "version": "1.0.0", + "updated": "2026-02-26", + "defaults": { + "maxTradeSizeUnits": null, + "maxDailyNotional": null, + "cooldownBlocksAfterIntervention": 10, + "minImprovementBpsToTrade": 5, + "publicRoutingEnabled": true + }, + "perPoolOverrides": {}, + "perChainOverrides": { + "1111": { "maxTradeSizeUnits": 10000, "publicRoutingEnabled": true }, + "25": { "maxTradeSizeUnits": 10000, "publicRoutingEnabled": true }, + "42220": { "maxTradeSizeUnits": 10000, "publicRoutingEnabled": true } + }, + "schemaNotes": { + "maxTradeSizeUnits": "Max size per swap (units of base token); null = no cap", + "maxDailyNotional": "Max notional per day per pool; null = no cap", + "cooldownBlocksAfterIntervention": "Blocks to wait after bot intervention before next trade", + "minImprovementBpsToTrade": "Bot: only trade if delta improves by at least this many bps (net of fees)", + "publicRoutingEnabled": "If false, pool is defense-only (no aggregator routing)" + } +} diff --git a/config/scenario-schema.json b/config/scenario-schema.json new file mode 100644 index 0000000..4977902 --- /dev/null +++ b/config/scenario-schema.json @@ -0,0 +1,143 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ScenarioSpec", + "description": "Formal scenario input for reproducible, comparable sim runs. PRs that tweak k/fee can be tested against the same scenario file.", + "type": "object", + "required": [ + "scenario", + "graphMode", + "topology", + "chainsIncluded", + "tokensIncluded", + "epochs" + ], + "properties": { + "scenario": { + "type": "string", + "description": "Unique id, e.g. hub_only_11, full_quote_1_56_137, bridge_shock_137_56" + }, + "graphMode": { + "type": "string", + "enum": [ + "design", + "deployed" + ], + "description": "design = topology + params only; deployed = deployment-status.json" + }, + "topology": { + "type": "string", + "enum": [ + "hub", + "full_quote", + "mixed" + ], + "description": "hub = one PMM per cW vs hub per chain; full_quote = cW vs all anchors; mixed = hub on some chains, full_quote on others" + }, + "chainsIncluded": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Chain IDs, e.g. [\"1\", \"56\", \"137\"]" + }, + "tokensIncluded": { + "type": "array", + "items": { + "type": "string" + }, + "description": "cW token symbols, e.g. [\"cWUSDT\", \"cWUSDC\"]" + }, + "epochBlocks": { + "type": "integer", + "minimum": 1, + "description": "Blocks per epoch (optional; if absent, epoch is logical)" + }, + "epochs": { + "type": "integer", + "minimum": 1, + "description": "Number of epochs to run" + }, + "orderflowModel": { + "type": "object", + "description": "Distribution + volume ranges for synthetic trades", + "properties": { + "distribution": { + "type": "string", + "enum": [ + "uniform", + "lognormal", + "pareto", + "poisson" + ], + "default": "uniform" + }, + "volumeMinUnits": { + "type": "number", + "minimum": 0 + }, + "volumeMaxUnits": { + "type": "number", + "minimum": 0 + }, + "tradesPerEpoch": { + "type": "integer", + "minimum": 0 + }, + "paretoAlpha": { + "type": "number", + "minimum": 0.5, + "description": "Pareto alpha (e.g. 1.5–2.5 for whale-heavy)" + } + } + }, + "bridgeShock": { + "type": "object", + "description": "Optional bridge shock: from chain, to chain, magnitude, duration", + "properties": { + "fromChain": { + "type": "string" + }, + "toChain": { + "type": "string" + }, + "magnitudeFraction": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "durationEpochs": { + "type": "integer", + "minimum": 1 + } + } + }, + "oracleModel": { + "type": "string", + "enum": [ + "static", + "stochastic" + ], + "default": "static" + }, + "latencyModel": { + "type": "object", + "description": "Finality per bridge, rho(Delta t) params", + "properties": { + "finalityBlocksPerChainPair": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "rhoPerBlockBps": { + "type": "number", + "description": "Latency penalty per block in bps (piecewise linear)" + } + } + }, + "seed": { + "type": "integer", + "description": "Optional RNG seed for deterministic runs; if omitted, derived from scenario name" + } + } +} \ No newline at end of file diff --git a/config/scenarios/bridge_shock_137_56.json b/config/scenarios/bridge_shock_137_56.json new file mode 100644 index 0000000..3f7221d --- /dev/null +++ b/config/scenarios/bridge_shock_137_56.json @@ -0,0 +1,26 @@ +{ + "scenario": "bridge_shock_137_56", + "graphMode": "design", + "topology": "hub", + "chainsIncluded": ["1", "56", "137"], + "tokensIncluded": ["cWUSDT", "cWUSDC"], + "epochBlocks": 300, + "epochs": 100, + "orderflowModel": { + "distribution": "uniform", + "volumeMinUnits": 5000, + "volumeMaxUnits": 30000, + "tradesPerEpoch": 15 + }, + "bridgeShock": { + "fromChain": "137", + "toChain": "56", + "magnitudeFraction": 0.05, + "durationEpochs": 24 + }, + "oracleModel": "static", + "latencyModel": { + "finalityBlocksPerChainPair": { "137-56": 12, "56-137": 12 }, + "rhoPerBlockBps": 1.0 + } +} diff --git a/config/scenarios/full_quote_1_56_137.json b/config/scenarios/full_quote_1_56_137.json new file mode 100644 index 0000000..b743e58 --- /dev/null +++ b/config/scenarios/full_quote_1_56_137.json @@ -0,0 +1,20 @@ +{ + "scenario": "full_quote_1_56_137", + "graphMode": "design", + "topology": "mixed", + "chainsIncluded": ["1", "56", "137"], + "tokensIncluded": ["cWUSDT", "cWUSDC", "cWAUSDT", "cWEURC", "cWEURT", "cWUSDW"], + "epochBlocks": 300, + "epochs": 720, + "orderflowModel": { + "distribution": "uniform", + "volumeMinUnits": 1000, + "volumeMaxUnits": 80000, + "tradesPerEpoch": 35 + }, + "oracleModel": "static", + "latencyModel": { + "finalityBlocksPerChainPair": {}, + "rhoPerBlockBps": 0.5 + } +} diff --git a/config/scenarios/hub_only_11.json b/config/scenarios/hub_only_11.json new file mode 100644 index 0000000..282bbab --- /dev/null +++ b/config/scenarios/hub_only_11.json @@ -0,0 +1,20 @@ +{ + "scenario": "hub_only_11", + "graphMode": "design", + "topology": "hub", + "chainsIncluded": ["1", "10", "25", "56", "100", "137", "42161", "8453", "43114", "42220", "1111"], + "tokensIncluded": ["cWUSDT", "cWUSDC", "cWAUSDT", "cWEURC", "cWEURT", "cWUSDW"], + "epochBlocks": 300, + "epochs": 720, + "orderflowModel": { + "distribution": "uniform", + "volumeMinUnits": 1000, + "volumeMaxUnits": 50000, + "tradesPerEpoch": 20 + }, + "oracleModel": "static", + "latencyModel": { + "finalityBlocksPerChainPair": {}, + "rhoPerBlockBps": 0.5 + } +} diff --git a/config/scorecard-schema.json b/config/scorecard-schema.json new file mode 100644 index 0000000..02852a1 --- /dev/null +++ b/config/scorecard-schema.json @@ -0,0 +1,151 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SimulationScorecard", + "description": "Output contract for sim runs. See docs/12-sim-scorecard.md.", + "type": "object", + "required": [ + "scenario", + "capture_mean", + "churn_mean", + "intervention_cost_total", + "peak_deviation_bps" + ], + "properties": { + "scenario": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "capture_mean": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "capture_p95": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "churn_mean": { + "type": "number", + "minimum": 0 + }, + "churn_p95": { + "type": "number", + "minimum": 0 + }, + "churn_max": { + "type": "number", + "minimum": 0 + }, + "intervention_cost_total": { + "type": "number", + "minimum": 0 + }, + "intervention_cost_per_1M_volume": { + "type": "number", + "minimum": 0 + }, + "peak_deviation_bps": { + "type": "number" + }, + "reflexive_route_count": { + "type": "integer", + "minimum": 0 + }, + "drain_half_life_epochs": { + "type": "object", + "description": "Per (token, chain): epochs until PMM inventory halves under routing pressure", + "additionalProperties": { + "type": "number", + "minimum": 0 + } + }, + "path_concentration_index": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "HHI on path shares; high = flow concentrated, low = diversified" + }, + "arb_volume_total": { + "type": "number", + "minimum": 0, + "description": "Total volume traded by arb step" + }, + "arb_profit_total": { + "type": "number", + "description": "Total approximate arb profit after fees/gas" + }, + "peak_deviation_bps_pre_arb": { + "type": "number", + "minimum": 0, + "description": "Max pool deviation before arb" + }, + "peak_deviation_bps_post_arb": { + "type": "number", + "minimum": 0, + "description": "Max pool deviation after arb" + }, + "peak_deviation_bps_post_bot": { + "type": "number", + "minimum": 0, + "description": "Max pool deviation after bot" + }, + "intervention_cost_inject_total": { + "type": "number", + "minimum": 0 + }, + "intervention_cost_withdraw_total": { + "type": "number", + "minimum": 0 + }, + "intervention_cost_by_chain": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "inject": { + "type": "number" + }, + "withdraw": { + "type": "number" + } + } + } + }, + "worst_pool_diagnostic": { + "type": "object", + "description": "Last-epoch worst pool at pre_arb, post_arb, post_bot", + "properties": { + "pre_arb": { + "type": "object", + "properties": { + "key": {}, + "deviation_bps": {}, + "I_T_ratio": {}, + "D_effective": {} + } + }, + "post_arb": { + "type": "object", + "properties": { + "key": {}, + "deviation_bps": {}, + "I_T_ratio": {}, + "D_effective": {} + } + }, + "post_bot": { + "type": "object", + "properties": { + "key": {}, + "deviation_bps": {}, + "I_T_ratio": {}, + "D_effective": {} + } + } + } + } + } +} \ No newline at end of file diff --git a/config/simulation-params.json b/config/simulation-params.json new file mode 100644 index 0000000..e453248 --- /dev/null +++ b/config/simulation-params.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "First-pass simulation parameter sheet: design-only consistent. Hub per chain, default k/fee, inventory targets. Use for scenario-based (design routing stress test, topology sensitivity) simulation.", + "version": "1.0.0", + "updated": "2026-02-26", + "sizingFormula": { + "doc": "docs/11-safe-inventory-sizing.md", + "formula": "I_T^* >= V_epoch * sigma * (1 + T_refill/T_epoch) / (1 - beta) + gamma_buffer", + "inputs": ["V_epoch", "sigma", "T_refill", "T_epoch", "beta (bridgeBeta)", "gamma_buffer (optional)"], + "depthRule": "D_0 ≈ (0.5 to 1.0) * I_T^*" + }, + "defaultPmm": { + "k": 0.1, + "feeBps": 35, + "inventoryTargetUnits": "1000000", + "depthD0Units": "500000" + }, + "eurPegMultiplier": 1.0, + "eurUsd": 1.10, + "eurDefaults": { "k": 0.2, "sigma": 2, "feeBps": 35, "note": "cWEURC/cWEURT: higher k and sigma than USD" }, + "chains": { + "1": { + "name": "Ethereum Mainnet", + "hubStable": "USDC", + "k": 0.1, + "feeBps": 35, + "inventoryTargetUnits": "1000000", + "bridgeBeta": 0.001, + "bridgeGammaUnits": "50" + }, + "10": { + "name": "Optimism", + "hubStable": "USDC", + "k": 0.1, + "feeBps": 25, + "inventoryTargetUnits": "500000", + "bridgeBeta": 0.001, + "bridgeGammaUnits": "20" + }, + "25": { + "name": "Cronos", + "hubStable": "USDT", + "k": 0.12, + "feeBps": 30, + "inventoryTargetUnits": "300000", + "bridgeBeta": 0.002, + "bridgeGammaUnits": "15" + }, + "56": { + "name": "BSC (BNB Chain)", + "hubStable": "USDT", + "k": 0.1, + "feeBps": 50, + "inventoryTargetUnits": "800000", + "bridgeBeta": 0.001, + "bridgeGammaUnits": "10" + }, + "100": { + "name": "Gnosis Chain", + "hubStable": "USDC", + "k": 0.12, + "feeBps": 30, + "inventoryTargetUnits": "400000", + "bridgeBeta": 0.0015, + "bridgeGammaUnits": "15" + }, + "137": { + "name": "Polygon", + "hubStable": "USDC", + "k": 0.1, + "feeBps": 50, + "inventoryTargetUnits": "600000", + "bridgeBeta": 0.001, + "bridgeGammaUnits": "5" + }, + "42161": { + "name": "Arbitrum One", + "hubStable": "USDC", + "k": 0.1, + "feeBps": 25, + "inventoryTargetUnits": "500000", + "bridgeBeta": 0.001, + "bridgeGammaUnits": "15" + }, + "8453": { + "name": "Base", + "hubStable": "USDC", + "k": 0.1, + "feeBps": 25, + "inventoryTargetUnits": "400000", + "bridgeBeta": 0.001, + "bridgeGammaUnits": "10" + }, + "43114": { + "name": "Avalanche C-Chain", + "hubStable": "USDC", + "k": 0.11, + "feeBps": 28, + "inventoryTargetUnits": "350000", + "bridgeBeta": 0.0015, + "bridgeGammaUnits": "20" + }, + "42220": { + "name": "Celo", + "hubStable": "USDC", + "k": 0.12, + "feeBps": 30, + "inventoryTargetUnits": "300000", + "bridgeBeta": 0.002, + "bridgeGammaUnits": "15" + }, + "1111": { + "name": "Wemix", + "hubStable": "USDT", + "k": 0.12, + "feeBps": 30, + "inventoryTargetUnits": "250000", + "bridgeBeta": 0.002, + "bridgeGammaUnits": "20" + } + }, + "scenarioDefaults": { + "designRoutingStressTest": { + "assumeAllChainsExist": true, + "onePmmPerCwTokenVsHub": true, + "optionalExtraQuotePools": ["1", "56", "137"] + }, + "topologySensitivity": { + "hubModelChains": "all", + "fullQuoteModelChains": ["1", "56", "137"] + } + } +} diff --git a/docs/08-simulation-model.md b/docs/08-simulation-model.md new file mode 100644 index 0000000..3db4e4c --- /dev/null +++ b/docs/08-simulation-model.md @@ -0,0 +1,156 @@ +# Two-Layer Routing Simulation Model + +Design graph vs deployed graph; multi-chain supergraph; edge types; PMM edge function; router optimization; scenario-based simulation; and the three key metrics. + +--- + +## 1. Two-layer model + +- **Design graph** — What *could* route: 11 EVM chains, hub per chain, cW* tokens and anchor stables as defined in `config/token-map.json`, `config/chains.json`, `config/pool-matrix.json`. No per-chain addresses; topology and assumed liquidity/fees only. +- **Deployed graph** — What *actually* routes: filled from `config/deployment-status.json` (cw token addresses, anchor addresses, PMM pool addresses and params). Until that is populated, simulation is **structural** (topology + assumed params), not empirical (real addresses, depths, aggregator behavior). + +Until deployment state is filled, run **scenario-based** simulation only. + +--- + +## 2. Multi-chain routing supergraph + +**Nodes:** `(chain c, token t)`. Examples: `(56, cWUSDC)`, `(137, USDT)`, `(42161, DAI)`. + +### Edge types + +**A) DEX swap edge (PMM or AMM)** + +``` +(c, t_a) --[swap]--> (c, t_b) +``` + +Stateful output: `y = f_e(x; s_e)` (output amount depends on input size and pool state). + +**B) Bridge edge (cross-chain transfer)** + +``` +(c_i, cWUSDC) --[bridge]--> (c_j, cWUSDC) +``` + +Bridge cost model (with optional latency risk premium): + +- **Base:** `y = x * (1 - β_ij) - γ_ij` + - `β_ij`: fractional bridge fee/slippage + - `γ_ij`: fixed cost (gas + messaging + relayer) +- **With latency risk:** `y = x * (1 - β_ij) - γ_ij - ρ(Δt) * x` + - `ρ(Δt)`: penalty increasing with bridge finality time (e.g. piecewise linear in Δt). Stops the sim from overestimating “free” cross-chain loop arb; include bridge latency and probabilistic settlement delay so cross-chain arbitrage loops are not under-penalized. + +**C) Mint/Burn to Home (ChainID 138)** + +If 138 is the canonical issuer and redemption is allowed: + +``` +(138, cUSDC) <--> (c, cWUSDC) +``` + +Model as bridge-like edges with their own fees, latency, and risk. + +--- + +## 3. Single-sided PMM edge function (router drain risk) + +For each public chain `c` and cW token `T`, edge pools are: + +``` +(c, T) <--> (c, Q) where Q ∈ {USDT, USDC, DAI, BUSD, mUSD} +``` + +(availability of Q differs by chain.) + +Use an oracle-anchored, inventory-sensitive slippage function (PMM-like). Practical form: + +- `y(x) = (1 - φ) * P * [ x - k * ( x - D * ln(1 + x/D) ) ]` +- `P`: oracle price (≈ 1 for USD pegs; EURUSD-based for EUR pegs) +- `k`: curvature (lower = tighter peg, more attractive to routers) +- `φ`: fee (e.g. bps) +- **Effective depth** (key for router drain): + - `D = D_0 * min(1, I_T / I_T^*)` + - `I_T`: remaining inventory of T in the pool; `I_T^*`: target/baseline inventory. + +As inventory is drained, D drops and marginal rate worsens, reproducing “routers drain LP until marginal rate worsens.” + +--- + +## 4. Router behavior = flow optimization with path splitting + +Aggregators choose 1–N paths and split flow. For input `X` converting `(c, S) → (c, T)`: + +- Choose paths `P_i` and allocations `x_i` with `max_{x_i ≥ 0} Σ_i F_{P_i}(x_i)` s.t. `Σ_i x_i = X`. +- At optimum, **marginal output equalizes** across used paths: `d/dx F_{P_i}(x_i) = λ`. + +So PMM pools are “mined” for inventory: as long as their marginal rate is best, routers keep allocating to them. + +**Cross-chain:** For routes `(c_1, S) → … → (c_2, T)` including bridge edges, a bridge+swap route is chosen when: + +- effective cost = (swap impact + fees on DEX edges) + (bridge fee + gas + latency risk on bridge edges) +is lower than alternatives. + +If PMM pools are too tight (low k, low fee), they become preferred entry/exit ramps on many chains → inventory oscillation and frequent bot intervention. + +--- + +## 5. What you can simulate right now (design-only) + +**A) Design routing stress test** + +Assume: all 11 chains; hub quote per chain; one PMM pool per cW token vs hub; optional extra quote pools on some chains. Run: + +- Stable-to-stable demand per chain +- Random bridge inflow/outflow shocks +- “Arb/MEV” agents that trade toward oracle when profitable + +Outputs: + +- Inventory drawdown distributions per cW token per chain +- Frequency/size of bot interventions +- How often routers pick your pools vs alternatives + +**B) Topology sensitivity** + +Compare: + +- **Hub model:** only `cW* / USDC` (or hub) per chain +- **Full quote model:** `cW* / {USDC, USDT, DAI, BUSD, mUSD}` + +Typically: full quote increases route cycles, MEV, and inventory churn; hub model reduces reflexivity and control costs. + +--- + +## 6. Moving to deployment-realistic simulation + +Use **`config/deployment-status.json`** (see below) to define: + +- Per chain: cW token addresses, anchor stable addresses, which PMM pools exist and their params (fee, k, initial liquidity), and whether each pool is “defense” vs “public routing.” + +With that, you can simulate: reachable routes (graph correctness), pool parameters (k, fee), and bridge availability — even before feeding real pool balances. + +--- + +## 7. Three metrics to watch + +1. **Router capture ratio** + - `capture(T, c) = (volume routed through your PMM pools) / (total relevant stable volume on chain c for token T)` + +2. **Inventory churn** + - `churn(T, c) = Σ_t |ΔI_{T,t}| / I_T^*` + - High churn → expensive rebalancing and bot intervention. + +3. **Intervention cost** + - Bridge/mint/burn volume and cost needed to keep pegs inside band. + +Interpretation: aim to be a **peg stabilizer** (good); avoid becoming the **global routing venue** (expensive). + +--- + +## 8. Config and params + +- **Design graph:** `config/token-map.json`, `config/chains.json`, `config/pool-matrix.json` +- **Deployed graph:** `config/deployment-status.json` +- **Simulation parameters:** `config/simulation-params.json` (hub per chain, default k, fee, inventory targets) +- **Peg bands:** `config/peg-bands.json` diff --git a/docs/09-simulation-params-sheet.md b/docs/09-simulation-params-sheet.md new file mode 100644 index 0000000..b7b8205 --- /dev/null +++ b/docs/09-simulation-params-sheet.md @@ -0,0 +1,50 @@ +# First-pass simulation parameter sheet + +Design-only consistent defaults for scenario-based simulation. Source: [../config/simulation-params.json](../config/simulation-params.json). + +--- + +## Default PMM (global) + +| Param | Value | Notes | +|-------|--------|-------| +| k | 0.1 | Curvature; lower = tighter peg | +| feeBps | 25 | Fee in basis points | +| inventoryTargetUnits | 1_000_000 | I_T^* baseline | +| depthD0Units | 500_000 | D_0 for depth formula | + +--- + +## Per-chain parameters (11 design chains) + +| Chain | Name | Hub | k | feeBps | Inventory target | bridgeBeta | bridgeGamma | +|-------|------|-----|---|--------|------------------|-----------|-------------| +| 1 | Ethereum Mainnet | USDC | 0.1 | 25 | 1_000_000 | 0.001 | 50 | +| 10 | Optimism | USDC | 0.1 | 25 | 500_000 | 0.001 | 20 | +| 25 | Cronos | USDT | 0.12 | 30 | 300_000 | 0.002 | 15 | +| 56 | BSC (BNB Chain) | USDT | 0.1 | 25 | 800_000 | 0.001 | 10 | +| 100 | Gnosis Chain | USDC | 0.12 | 30 | 400_000 | 0.0015 | 15 | +| 137 | Polygon | USDC | 0.1 | 25 | 600_000 | 0.001 | 5 | +| 42161 | Arbitrum One | USDC | 0.1 | 25 | 500_000 | 0.001 | 15 | +| 8453 | Base | USDC | 0.1 | 25 | 400_000 | 0.001 | 10 | +| 43114 | Avalanche C-Chain | USDC | 0.11 | 28 | 350_000 | 0.0015 | 20 | +| 42220 | Celo | USDC | 0.12 | 30 | 300_000 | 0.002 | 15 | +| 1111 | Wemix | USDT | 0.12 | 30 | 250_000 | 0.002 | 20 | + +- **bridgeBeta:** fractional bridge fee/slippage (β_ij). +- **bridgeGammaUnits:** fixed cost units (γ_ij) for gas + messaging + relayer (nominal). + +--- + +## Scenario defaults + +- **Design routing stress test:** Assume all 11 chains; one PMM per cW token vs hub; optional extra quote pools on chains 1, 56, 137. +- **Topology sensitivity:** Hub model = all chains; full-quote model = chains 1, 56, 137 only (compare capture, churn, intervention cost). + +--- + +## Usage + +1. Run **design-only** scenario sims using `simulation-params.json` (no addresses required). +2. When deployment data exists, fill `deployment-status.json` per chain (cwTokens, anchorAddresses, pmmPools, bridgeAvailable). +3. Iterate k/fee and inventory targets from sim outputs (router capture ratio, inventory churn, intervention cost). See [08-simulation-model.md](08-simulation-model.md) §7. diff --git a/docs/10-behavioral-stability-analysis.md b/docs/10-behavioral-stability-analysis.md new file mode 100644 index 0000000..ca172f1 --- /dev/null +++ b/docs/10-behavioral-stability-analysis.md @@ -0,0 +1,134 @@ +# Behavioral Stability Analysis + +From architecture to **behavioral stability**: what the two-layer + PMM stack enables, how the three metrics interact, what to simulate first, and where the system can break. + +--- + +## 1. What you have systemically + +### Layer A — Design graph + +- “What could route”: all 11 chains, all potential cW* pools. +- Lets you simulate **future expansion** before deploying. +- Prevents accidental routing exposure when adding pools. + +### Layer B — Deployment graph + +- “What actually routes”: defined by `config/deployment-status.json`. +- **Kill switch**: remove a pool from deployment-status to disable routing. +- Acts as exposure registry, routing firewall, expansion throttle. + +That separation is central-bank-grade control: programmable monetary corridor, not just liquidity. + +--- + +## 2. PMM depth as state variable + +Depth: + +``` +D = D_0 · min(1, I_T / I_T^*) +``` + +This gives: + +- **Self-limiting router exposure**: drain → D drops → marginal rate worsens. +- **Endogenous slippage widening**: no ad-hoc circuit breaker needed for normal stress. +- **Routers drain you → marginal rate worsens → routers leave.** + +So the mechanism is **mathematically stabilizing**. + +--- + +## 3. Three metrics — economic interpretation + +| Metric | Economic meaning | Target / risk | +|--------|------------------|----------------| +| **Router capture ratio** | Are you the primary stable swap venue? | High capture = revenue + inventory risk. Low = safer peg, less fee income. | +| **Inventory churn** | Stress metric: Σ\|ΔI_T\| / I_T^* per epoch | Healthy: 0.3–0.8 normal, <1.5 stress. If churn >1.5–2.0, bot intervenes constantly. | +| **Intervention cost** | “Monetary defense budget”: bridge/mint/burn to keep peg | Linear in volume → OK. Exponential during topology switch → danger. | + +--- + +## 4. What to simulate first + +### A) Hub-only across all 11 chains + +One PMM per cW per chain vs hub. + +**Questions:** + +- Does router capture stabilize around **10–30%**? +- Does churn remain **<1** under normal volume? +- Is intervention frequency **periodic** rather than constant? + +**This is the baseline.** + +### B) Full-quote on chains 1, 56, 137 + +Enable extra quotes (USDT, DAI, etc.) only on Ethereum, BSC, Polygon. + +**Watch for:** + +- Multi-hop reflexivity +- Increased churn +- Increased router capture +- **Nonlinear** intervention cost + +**Rule of thumb:** If churn increases >50% vs hub model → do not deploy full-quote. + +### C) Bridge shock scenario + +Inject e.g.: + +- 5% supply migration 137 → 56 over 24 blocks, or +- 10% whale exit on one chain + +**Measure:** + +- Time to re-center peg +- Total bot injection required +- Peak deviation + +**Interpretation:** + +- **Damped oscillator** → good. +- **Resonant feedback loop** → bad. + +--- + +## 5. Where it can break (advanced) + +### Cross-chain arbitrage loops + +If **bridge cost < slippage difference**, routers do: Chain A → bridge → Chain B → swap → bridge back. + +**Model must include:** bridge latency + probabilistic settlement delay. Otherwise risk is underestimated. + +### k too tight globally + +Default k = 0.1, fee = 25 bps. If inventory target is high and k is tight, you can become the cheapest stable venue on thin chains. + +**Simulate:** k ∈ {0.05, 0.1, 0.2}. Often **k = 0.15–0.2** is safer cross-chain. + +### EUR tokens (cWEURC, cWEURT) + +- FX volatility; USD-quoted hubs; dual-source peg risk. +- Need: **slightly higher k**, **slightly wider band**, possibly **higher fee**. +- Do **not** treat EUR and USD tokens symmetrically. + +--- + +## 6. System-level framing + +The architecture is a **multi-chain programmable monetary corridor**. Each PMM pool is a **localized peg defense membrane**. `deployment-status.json` is the exposure registry, routing firewall, and expansion throttle. + +--- + +## 7. Next steps (options) + +1. Run a synthetic 30-day stress sim and extract equilibrium metrics. +2. Derive analytical stability conditions for k, fee, D_0. +3. Design adaptive k control law (automatic routing dampener). +4. Model MEV + oracle-lag attack surface. +5. **Derive safe inventory target sizing formula per chain** → see [11-safe-inventory-sizing.md](11-safe-inventory-sizing.md). diff --git a/docs/11-safe-inventory-sizing.md b/docs/11-safe-inventory-sizing.md new file mode 100644 index 0000000..d361b5d --- /dev/null +++ b/docs/11-safe-inventory-sizing.md @@ -0,0 +1,112 @@ +# Safe Inventory Target Sizing (Closed-Form Approximation) + +A closed-form approximation for **I_T^*** (inventory target) per chain so capital allocation can be justified mathematically instead of heuristically. Inputs: expected routed volume, bridge β/γ, refill latency, and target band. + +--- + +## 1. Objective + +Choose **I_T^*** per chain and per cW token so that: + +1. With high probability we do **not** deplete inventory before bridge/mint can refill. +2. Peg stays inside the target band during normal and stressed flow. +3. Intervention cost (bridge/mint/burn) remains bounded and preferably linear in volume. + +--- + +## 2. Assumptions + +- **Epoch**: time window over which we measure flow (e.g. 1 hour or “refill cycle”). +- **V_epoch**: expected **net outflow** of cW token from the pool during one epoch (routed volume, one-sided; we conservatively use net outflow). +- **σ**: volatility multiplier for V (e.g. 1.5–2 for “stress”); so worst-case net outflow in one epoch is on the order of **V_epoch · σ**. +- **T_refill**: refill latency (time or blocks) for bridge/mint to restore inventory on this chain. +- **β**: bridge fee (fraction) for refill path; **γ**: fixed cost per refill (gas + relayer). +- **Band**: we require that after a shock we do not breach the circuit-break band (e.g. 2% for USD). That effectively caps how far inventory can fall before we must intervene. + +--- + +## 3. First-order formula + +**Safe inventory target (units of cW token):** + +``` +I_T^* ≥ V_epoch · σ · (1 + T_refill / T_epoch) / (1 - β) + γ_buffer +``` + +**Interpretation:** + +- **V_epoch · σ**: worst-case net outflow in one epoch. +- **T_refill / T_epoch**: number of “epochs” in one refill cycle; we need to cover outflow over that period without going to zero. +- **(1 − β)**: bridge fee reduces effective refill; we size so that after fee we still refill enough. +- **γ_buffer**: small buffer for fixed costs (e.g. one or a few refill batches); can be set to a multiple of γ or a constant. + +**Simpler variant (single refill cycle):** + +If refill is exactly one epoch and we want to absorb one full shock without depleting: + +``` +I_T^* ≥ V_epoch · σ / (1 - β) + buffer +``` + +**buffer** = small constant or γ · (typical refill count per epoch). + +--- + +## 4. Per-chain inputs (from your config) + +From `config/simulation-params.json` and `config/token-map.json`: + +- **β** = `bridgeBeta` for the chain (refill path). +- **γ** = `bridgeGammaUnits` (nominal; use for γ_buffer if desired). +- **V_epoch**: not in config; must be estimated or taken from simulation (e.g. expected routed volume per epoch for that chain/token). +- **σ**: e.g. 1.5–2 for stress. +- **T_refill, T_epoch**: in blocks or seconds; chain-dependent (e.g. 1 epoch = 1 hour, T_refill = 20 min → T_refill/T_epoch ≈ 1/3). + +--- + +## 5. Depth consistency (D_0 vs I_T^*) + +Your PMM uses **D = D_0 · min(1, I_T / I_T^*)**. For the pool to stay “deep” enough under stress: + +- **D_0** should be sized so that at **I_T = I_T^***, the pool can absorb a typical trade size without slipping past the band. A simple rule: **D_0** on the order of **I_T^* / 2** to **I_T^*** (so that at target inventory, effective depth is meaningful). So: + +``` +D_0 ≈ (0.5 to 1.0) · I_T^* +``` + +Then the formula above gives **I_T^***; set **D_0** accordingly. + +--- + +## 6. EUR tokens (cWEURC, cWEURT) + +- Same formula applies, but use **EUR-specific** expected volume and, if refill is via a different path, **EUR bridge β/γ**. +- Use **wider band** (and possibly higher σ) to reflect FX and dual-source peg risk; that may imply a **larger buffer** or **higher σ** in the sizing formula. + +--- + +## 7. Usage + +1. **Estimate V_epoch** per chain (and per token if needed) from historical or simulated routed volume. +2. Set **σ** (e.g. 1.5–2) and **T_refill / T_epoch** from chain/contract data. +3. Read **β** (and optionally **γ**) from `simulation-params.json`. +4. Compute **I_T^*** from the formula; optionally add **γ_buffer**. +5. Set **D_0 ≈ (0.5–1) · I_T^*** for that pool. +6. Put **inventoryTargetUnits** (and depth) into `simulation-params.json` or `deployment-status.json` and run simulations to validate churn and intervention cost. + +This gives a **justified starting point** for capital allocation per chain; tune from there using the three metrics (capture, churn, intervention cost) and stress tests (hub-only, full-quote, bridge shock). + +--- + +## 8. Example first pass (chains 1, 56, 137, 25) + +Assumptions: σ = 1.5 (USD), σ = 2 (EUR); T_refill/T_epoch = 0.33; γ_buffer = 2·γ. Formula: I_T^* ≥ V_epoch·σ·(1 + 0.33)/(1 − β) + γ_buffer; D_0 = 0.75·I_T^*. + +| Chain | Name | Hub | β | γ | V_epoch (example) | I_T^* (USD) | I_T^* (EUR) | D_0 (USD) | +|-------|---------|------|-------|----|-------------------|-------------|-------------|-----------| +| 1 | Ethereum| USDC | 0.001 | 50 | 100_000 | ~200k | ~266k | ~150k | +| 56 | BSC | USDT | 0.001 | 10 | 80_000 | ~160k | ~213k | ~120k | +| 137 | Polygon | USDC | 0.001 | 5 | 60_000 | ~120k | ~160k | ~90k | +| 25 | Cronos | USDT | 0.002 | 15 | 30_000 | ~60k | ~80k | ~45k | + +Run `node scripts/size-inventory.cjs` or `node scripts/size-inventory.cjs --v-epoch '{"1":100000,"56":80000,"137":60000,"25":30000}'` to regenerate from your `simulation-params.json`. Use **k = 0.15–0.2** on thin chains (e.g. Cronos) if sims show excessive capture; EUR tokens use `eurDefaults` (higher k, σ, feeBps). diff --git a/docs/12-sim-scorecard.md b/docs/12-sim-scorecard.md new file mode 100644 index 0000000..667da91 --- /dev/null +++ b/docs/12-sim-scorecard.md @@ -0,0 +1,135 @@ +# Simulation Scorecard Contract + +Simulator runs must emit a **scorecard JSON** so results are comparable and deployability can be gated. This document defines the output schema and pass/fail gates. + +--- + +## 1. Scorecard JSON schema + +Every run (hub-only, full-quote, bridge shock) should produce a scorecard with at least: + +| Field | Type | Description | +|-------|------|--------------| +| `capture_mean` | number | Mean router capture ratio (fraction 0–1) across chains/tokens | +| `capture_p95` | number | 95th percentile capture | +| `churn_mean` | number | Mean inventory churn per epoch | +| `churn_p95` | number | 95th percentile churn | +| `churn_max` | number | Max churn observed | +| `intervention_cost_total` | number | Total intervention cost (bridge/mint/burn) in run | +| `intervention_cost_per_1M_volume` | number | Intervention cost per 1M routed volume | +| `peak_deviation_bps` | number | Peak peg deviation in basis points | +| `reflexive_route_count` | number | Count of multi-hop routes through multiple PMM pools (reflexivity) | +| `drain_half_life_epochs` | object | Per (token, chain): epochs until PMM inventory halves under routing pressure; **too short = routing magnet** | +| `path_concentration_index` | number | HHI on path shares (0–1); **high = you dominate execution**; lower = flow diversified (safer) | +| `arb_volume_total` | number | Total volume traded by arb step (PR#2) | +| `arb_profit_total` | number | Arb profit from **execution** (actual PMM output vs oracle), not mid (PR#2 refinement) | +| `peak_deviation_bps_pre_arb` | number | Max pool \|δ\| before arb step (diagnostic: is arb doing the work?) | +| `peak_deviation_bps_post_arb` | number | Max pool \|δ\| after arb (current primary gate) | +| `peak_deviation_bps_post_bot` | number | Max pool \|δ\| after bot (inventory rebalancing effect) | +| `intervention_cost_inject_total` | number | Bot inject (bridge-in) cost only | +| `intervention_cost_withdraw_total` | number | Bot withdraw cost only | +| `intervention_cost_by_chain` | object | Per chain: `{ inject, withdraw }` — which chains are liquidity sinks | +| `scenario` | string | e.g. `hub_only_11`, `full_quote_1_56_137`, `bridge_shock_137_56` | +| `runId` | string | Optional run identifier | + +**Example (minimal):** + +```json +{ + "scenario": "hub_only", + "capture_mean": 0.18, + "capture_p95": 0.28, + "churn_mean": 0.45, + "churn_p95": 0.82, + "churn_max": 1.1, + "intervention_cost_total": 1200, + "intervention_cost_per_1M_volume": 8.5, + "peak_deviation_bps": 18, + "reflexive_route_count": 0 +} +``` + +A machine-readable schema lives in `config/scorecard-schema.json` for validation. + +--- + +## 2. Pass/fail gates (“deployable” scenarios) + +From [10-behavioral-stability-analysis.md](10-behavioral-stability-analysis.md): + +| Gate | Condition | Rationale | +|------|-----------|-----------| +| **Churn (normal)** | `churn_mean` in [0.3, 0.8] | Healthy baseline | +| **Churn (stress)** | `churn_max` < 1.5 | Avoid constant bot intervention | +| **Capture (baseline)** | `capture_mean` in [0.10, 0.30] | Peg stabilizer, not global venue | +| **Intervention** | `intervention_cost_per_1M_volume` stable (no explosive jump vs baseline) | Linear in volume | +| **Full-quote vs hub** | If full-quote: `churn_mean` increase vs hub < 50% | Don’t deploy full-quote if churn jumps >50% | +| **Peak deviation** | `peak_deviation_bps` below circuit-break (e.g. 200 bps USD) | Stay inside band | +| **Drain half-life** | `drain_half_life_epochs` not collapsing under full-quote vs hub | Routing magnet check | +| **Path concentration** | `path_concentration_index` not spiking during bridge shock | Diversified routing | +| **Reflexivity** | `reflexive_route_count` low relative to total routes | Avoid feedback loops | + +**Sanity checks (PR#2):** Arb volume should rise when k is tight; bot interventions should rise when inventory targets are low. + +**Pass:** All gates satisfied for the scenario. +**Fail:** Any gate violated; do not treat scenario as deployable without parameter change or topology reduction. + +--- + +## 3. Phase 0 comparison (three scenarios) + +Run and compare: + +1. **Hub-only** across all 11 chains +2. **Full-quote** only on 1, 56, 137 +3. **Bridge shock** (e.g. 137 → 56) + +Compare deltas: + +- **churn +%** (full-quote vs hub) +- **intervention cost +%** (full-quote vs hub) +- **peak deviation** under shock + +If churn jumps >50% with full-quote → clear “don’t deploy full-quote” rule. + +--- + +## 4. Phase 0: Runnable scenarios and knob guidance + +**Exact scenario JSONs to run** (in `config/scenarios/`): + +| Scenario file | Description | Expected pass | +|---------------|-------------|----------------| +| `hub_only_11.json` | Hub topology, all 11 chains, 720 epochs | churn_mean in [0.3, 0.8], capture_mean in [0.10, 0.30], churn_max < 1.5 | +| `full_quote_1_56_137.json` | Full-quote on Ethereum, BSC, Polygon; 720 epochs | Same gates; churn_mean increase vs hub_only_11 < 50% | +| `bridge_shock_137_56.json` | Hub on 1/56/137; 5% migration 137 to 56 over 24 epochs | peak_deviation_bps < 200; damped re-center (not resonant). **Note:** Shock is a **stress injection** (paired local sell/buy), not cross-chain router equilibrium; see §6. | + +**One command = one scorecard = pass/fail:** Run sim with scenario JSON; validate output against `config/scorecard-schema.json`; apply gates from section 2. + +**If fail, what knob to turn first:** + +| Symptom | First knob | Then | +|---------|------------|------| +| Capture too high | Increase feeBps | Then increase k | +| Churn too high | Reduce pool count (hub model only) | Then increase k | +| Intervention cost explodes | Increase latency penalty rho or widen bands | Add caps (maxTradeSizeUnits, maxDailyNotional) | +| Drain half-life too short | Increase k or lower depth | Consider publicRoutingEnabled false on defense pools | +| Path concentration too high | Widen topology or increase fee on dominant pools | Reduce single-pool magnetism | + +--- + +## 5. Bridge shock modeling (Phase 0) + +The **bridge shock** scenario (`bridge_shock_137_56.json`) is implemented as a **stress injection**, not as cross-chain path enumeration: + +- Each epoch during the shock window, the sim adds **paired local trades**: sell cW→hub on the “from” chain (137), buy cW with hub on the “to” chain (56), at a magnitude that sums to the configured migration over the window. +- This tests **corridor defense under forced migration** (can arb + bot keep deviation and intervention in check?), which is what matters operationally for Phase 0. +- It does **not** model a router endogenously choosing to bridge because it’s cheaper; that requires cross-chain path selection (PR#3). When you add cross-chain routing, you can validate whether the same stress emerges from router equilibrium. + +Be explicit when interpreting results: shock metrics answer “given forced migration, does the system damp?” not “does routing naturally push flow across chains?” + +--- + +## 6. Confirming EUR defaults + +Run **hub-only baseline** with (a) USD-only tokens, (b) USD + EUR tokens. Compare: churn_mean, churn_max, peak_deviation_bps, intervention_cost_per_1M_volume. If EUR tokens meaningfully worsen these: increase eurDefaults.k (e.g. 0.25), widen bands for EUR in peg-bands.json, and/or add routing caps (maxTradeSizeUnits) for EUR pools. diff --git a/docs/14-phase0-knob-tuning.md b/docs/14-phase0-knob-tuning.md new file mode 100644 index 0000000..dda4f73 --- /dev/null +++ b/docs/14-phase0-knob-tuning.md @@ -0,0 +1,110 @@ +# Phase 0 Knob Tuning Reference + +Based on scorecard interpretation: which levers to pull first and what patterns confirm the right move. See [12-sim-scorecard.md](12-sim-scorecard.md) for gates. + +--- + +## 1. Hub-only: capture too high (PMM-heavy) + +**Signals:** `capture_mean` near 1; churn < 1; intervention mostly inject. + +**First lever:** Raise **feeBps** (not k). USD default: **25 → 35–50 bps** on public-routing pools. + +**Then if still high:** k 0.10 → 0.15. + +**Success:** capture_mean toward 10–30%; churn similar or better; arb_volume_total and intervention_cost_total fall. + +--- + +## 2. Full-quote: path concentration high + inject/withdraw by chain + +**Signals:** Higher `path_concentration_index`; 56/137 show withdraw (mesh reflexivity). + +**First lever:** **Topology / controls:** keep hub-only default; full-quote only where tolerable with `maxTradeSizeUnits` lower and/or `publicRoutingEnabled=false` on optional pools. + +**Second:** Two-tier design: defense pool (tighter k, maybe no public routing) vs public_routing pool (looser k, higher fee). Deployment-status schema supports roles. + +**Success:** path_concentration_index drops; drain_half_life improves; intervention more one-directional. + +--- + +## 3. Bridge shock: peak deviation at circuit-break level + +**Signals:** `peak_deviation_bps_*` ~1000+ bps (e.g. 1015). + +**First lever:** **Inventory / depth:** increase `inventoryTargetUnits` on impacted chains by 1.5–2×; keep D₀ = 0.75–1.0 × I*. + +**Second:** Bot speed: `BOT_MAX_FRACTION_OF_TARGET` 0.25 → 0.4 (e.g. shock-scenario override). + +**Third:** Corridor: low-water 0.5 → **0.75**, high-water 1.5 → **1.25**. + +**Success:** peak_deviation_bps_post_arb and _post_bot drop; intervention_cost rises but deviation stays under circuit-break. + +--- + +## 4. “Capture high” vs background AMM + +Background AMM is 5 bps + infinite depth. If PMM still dominates: + +- **Option A:** Raise PMM feeBps (recommended first). +- **Option B:** Raise AMM fee to 20–30 bps if modeling Uni-style; keep 5 bps only if modeling Curve-style stable rails (then PMM should be defense, not venue → higher PMM fee/k). + +--- + +## Quick checklist + +| Issue | First move | Then | +|-------|------------|------| +| Hub capture too high | **Done:** default feeBps 35; chains 56/137 at 50 bps (sink chains); chain 1 at 35 | k 0.10 → 0.15 if needed | +| Full-quote concentration high | Disable/cap optional pools; hub-only default | Role split: defense vs public_routing | +| Shock peak deviation too high | Increase I* and D₀ on 1/56/137 | **Done:** corridor 0.75/1.25, speed 0.40 in script | + +--- + +--- + +## 5. Deviation probe fix (marginal vs average) + +**Problem:** With a finite probe (e.g. 500), implied price was an *average* over that size; the PMM curve always shows a discount at finite size, so `peak_deviation_bps_*` stayed high and arb churned volume. + +**Fix:** Use **analytical marginal** for implied price: at x=0 the derivative is (1−fee)·P, so `pHat = (1 − s.fee) * s.P`. This avoids the (1−k) bias from a finite sell probe (which gave ~−1014 bps for EUR with k=0.2). At equilibrium, deviation = −fee (e.g. −35 bps). `DELTA_ARB_BPS` is set to 40 so equilibrium doesn’t trigger arb. + +**After fix:** peak_deviation_* ≈ fee (e.g. 35 bps); arb_volume_total and intervention drop; worst_pool_diagnostic is no longer dominated by EUR. + +--- + +--- + +## 6. Worst-pool diagnostic (scorecard) + +When `peak_deviation_bps_pre_arb ≈ post_arb ≈ post_bot`, the **worst** pool may be rotating or not moving. The scorecard now includes (last epoch): + +- `worst_pool_diagnostic`: `{ pre_arb, post_arb, post_bot }`, each `{ key, deviation_bps, I_T_ratio, D_effective }`. + +**Interpretation:** + +- Same pool worst all three phases + **low I_T_ratio** → depth problem (raise I*/D₀ or tighten corridor). +- Same pool + **I_T_ratio ≈ 1** → curve/fee or routing pressure (raise feeBps / k). +- Rotating worst pool → routing re-creating deviation; fee/k/controls to reduce demand. + +**Diagnostic now includes `oracle_P` and `p_hat`:** If you see `p_hat ≈ 1.10` and `oracle_P = 1.00` for EUR pools, the bug is EUR reference price (see §7). + +--- + +## 7. EUR oracle reference (P for cWEUR* vs USDC) + +**Problem:** EUR pools (cWEURC, cWEURT) were using P = 1 (USD) so deviation looked ~8–12% (800–1200 bps) even at target inventory. + +**Fix:** Oracle P in “quote per base” (USDC per cWEUR*): + +- **USD-pegged:** P = 1.0. +- **EUR-pegged vs USDC:** P = **EURUSD** (e.g. 1.10). + +**Config:** `simulation-params.json` → `eurUsd`: 1.10 (USDC per 1 EUR). If missing, `eurPegMultiplier` is used; if that is 1.0, fallback 1.1 so EUR isn’t treated as USD. Optional later: `scenario.oracleModel.eurUsd` to override per run. + +**After fix:** EUR pools drop out of worst_pool_diagnostic; peak_deviation_bps falls; arb volume and intervention reflect true routing pressure. + +--- + +*Last updated from Phase 0 scorecard interpretation.* + diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..53004cc --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,71 @@ +# Scripts + +## size-inventory.cjs + +Regenerates **I_T^*** (inventory target) and suggested **D_0** per chain per cW token from `config/simulation-params.json`. Output includes assumptions (σ, T_refill/T_epoch, β, γ). Keeps configs honest and PRs reviewable. + +**Run from repo root (cross-chain-pmm-lps):** + +```bash +node scripts/size-inventory.cjs +``` + +**Options:** + +- `--sigma 1.8` — override USD stress multiplier (default 1.5) +- `--refill-ratio 0.33` — T_refill / T_epoch (default 0.33) +- `--depth-mult 0.75` — D_0 = depth_mult * I_T^* (default 0.75) +- `--v-epoch '{"1":100000,"56":80000,"137":60000}'` — per-chain V_epoch (JSON object) + +**Environment:** `V_EPOCH_` overrides V_epoch for that chain (e.g. `V_EPOCH_1=100000`). + +**Output:** JSON with `assumptions` and per-chain per-token `I_T_star`, `D_0`, `V_epoch`, `sigma`, `beta`, `gamma`, and EUR stress flag where applicable. + +--- + +## validate-deployment-status.cjs + +Validates `config/deployment-status.json` for minimum viable deployed graph. Use in CI so deployment-realistic sim cannot run with half-filled state. + +**Rules:** + +- If `bridgeAvailable === true` on a chain, `cwTokens` must include at least **cWUSDT** and **cWUSDC** (phase 1). +- For each `pmmPool`: `role` ∈ {defense, public_routing}; `feeBps` and `k` present; `base`/`quote` (or `tokenIn`/`tokenOut`) exist in `cwTokens` or `anchorAddresses`. + +**Run:** + +```bash +node scripts/validate-deployment-status.cjs +``` + +**Exit code:** 0 if valid, 1 if invalid (errors to stderr). + +--- + +## run-scenario.cjs + +Builds the **real** routing graph from configs, runs epochs with PMM state updates, path enumeration + waterfilling, **arb step** (implied-price deviation, capped corrective trades, profit gate), **bot step** (inject/withdraw at 0.5×/1.5× I_T^* with intervention cost β/γ/ρ), and optional **bridge shock** trades. Emits a **real scorecard** (PR#2). Runs are **deterministic** when `scenario.seed` is set or derived from scenario name. + +**Configs used:** `simulation-params.json`, `token-map.json`, `routing-controls.json`; `deployment-status.json` only when `graphMode = deployed`. Pool topology from `pool-matrix.json` and scenario `topology` / `fullQuoteChains`. + +**Tuning constants (in script):** + +| Constant | Value | Purpose | +|----------|--------|---------| +| `PROBE_SIZE` | 1000 | Units for path cost probe (k-shortest by cost) | +| `K_PATHS` | 5 | Max candidate paths per trade for waterfilling | +| `CHUNK_FRACTION` | 0.05 | 5% of trade per chunk; marginal-equalization step size | +| `AMM_DEPTH_UNITS` | 10e6 | Background AMM depth (notional; infinite-depth approx in code) | +| `AMM_FEE_BPS` | 5 | Fee for anchor↔anchor stable swaps | + +**Run:** + +```bash +node scripts/run-scenario.cjs hub_only_11 +node scripts/run-scenario.cjs --scenario full_quote_1_56_137 +node scripts/run-scenario.cjs bridge_shock_137_56 +``` + +**Output:** JSON scorecard including: `capture_mean`, `churn_mean`, `drain_half_life_epochs`, `path_concentration_index`; `intervention_cost_total` / `intervention_cost_inject_total` / `intervention_cost_withdraw_total` / `intervention_cost_by_chain` / `intervention_cost_per_1M_volume`; `peak_deviation_bps` (post-arb), `peak_deviation_bps_pre_arb`, `peak_deviation_bps_post_arb`, `peak_deviation_bps_post_bot`; `arb_volume_total`, `arb_profit_total` (execution-based, not mid). See [docs/12-sim-scorecard.md](../docs/12-sim-scorecard.md) and [config/scorecard-schema.json](../config/scorecard-schema.json). + +**Orderflow:** Trade sizes use `distribution: "uniform"` by default. Scenario schema supports `lognormal` / `pareto` for skewed (many small + occasional whale) flows; implement in `sampleTrade()` when needed. diff --git a/scripts/run-scenario.cjs b/scripts/run-scenario.cjs new file mode 100644 index 0000000..94d94ab --- /dev/null +++ b/scripts/run-scenario.cjs @@ -0,0 +1,883 @@ +#!/usr/bin/env node +/** + * Routing simulator: graph, PMM state, path enumeration + waterfilling, arb step, + * bot step, bridge shock (optional). Emits real scorecard. PR#2: deterministic, + * arb + bot + intervention cost + peak deviation. + * + * Usage: node scripts/run-scenario.cjs hub_only_11 + * node scripts/run-scenario.cjs --scenario full_quote_1_56_137 + */ + +const fs = require('fs'); +const path = require('path'); + +const CONFIG_DIR = path.join(__dirname, '..', 'config'); +const SCENARIOS_DIR = path.join(CONFIG_DIR, 'scenarios'); + +const CW_EUR = ['cWEURC', 'cWEURT']; +const PROBE_SIZE = 1000; +const K_PATHS = 5; +const CHUNK_FRACTION = 0.05; +const AMM_DEPTH_UNITS = 10e6; +const AMM_FEE_BPS = 5; +const MAX_ITER_INVERSE = 50; +const TOL_INVERSE = 1; + +// PR#2: Arb +const DELTA_ARB_BPS = 40; +const ARB_ALPHA = 1.0; +const ARB_MAX_FRACTION_OF_TARGET = 0.1; +// Marginal probe: must be small (x << D) so implied price ≈ (1-fee)*P, not curvature-dominated. 0.01 gives true marginal. +const MARGINAL_EPS = 0.01; +const ARB_PROBE = MARGINAL_EPS; +const ARB_GAS_UNITS = 0; + +// PR#2: Bot corridor (tighter 0.75/1.25 so bot acts before depleted depth; faster 0.40) +const LOW_WATER = 0.75; +const HIGH_WATER = 1.25; +const BOT_MAX_FRACTION_OF_TARGET = 0.4; + +// Seeded RNG (mulberry32) for deterministic runs +let rngState = 0; +function seedRng(seed) { + rngState = (seed >>> 0) || 1; +} +function rng() { + let t = (rngState += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} +function hashScenarioName(name) { + let h = 0; + for (let i = 0; i < name.length; i++) h = (Math.imul(31, h) + name.charCodeAt(i)) >>> 0; + return h; +} + +function loadJson(p) { + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function loadConfigs(scenario) { + const simulationParams = loadJson(path.join(CONFIG_DIR, 'simulation-params.json')); + const tokenMap = loadJson(path.join(CONFIG_DIR, 'token-map.json')); + const routingControls = fs.existsSync(path.join(CONFIG_DIR, 'routing-controls.json')) + ? loadJson(path.join(CONFIG_DIR, 'routing-controls.json')) + : { defaults: { publicRoutingEnabled: true, maxTradeSizeUnits: null } }; + let deploymentStatus = null; + if (scenario.graphMode === 'deployed') { + const p = path.join(CONFIG_DIR, 'deployment-status.json'); + if (fs.existsSync(p)) deploymentStatus = loadJson(p); + } + const poolMatrix = fs.existsSync(path.join(CONFIG_DIR, 'pool-matrix.json')) + ? loadJson(path.join(CONFIG_DIR, 'pool-matrix.json')) + : { chains: {} }; + return { simulationParams, tokenMap, routingControls, deploymentStatus, poolMatrix }; +} + +function buildGraph(scenario, configs) { + const { simulationParams, tokenMap, poolMatrix } = configs; + const chains = simulationParams.chains || {}; + const publicChains = tokenMap.publicChains || {}; + const cwTokens = scenario.tokensIncluded || tokenMap.bridgedSymbols || ['cWUSDT', 'cWUSDC', 'cWAUSDT', 'cWEURC', 'cWEURT', 'cWUSDW']; + const chainIds = scenario.chainsIncluded || Object.keys(chains); + const topology = scenario.topology || 'hub'; + const fullQuoteChains = scenario.fullQuoteChains + || (simulationParams.scenarioDefaults?.topologySensitivity?.fullQuoteModelChains) || []; + + const nodes = new Set(); + const pmmEdges = []; + const ammEdges = []; + const bridgeEdges = []; + + for (const chainId of chainIds) { + const chainConf = chains[chainId] || {}; + const pub = publicChains[chainId] || {}; + const hubStable = chainConf.hubStable || pub.hubStable || 'USDC'; + const anchorStables = pub.anchorStables || [hubStable]; + + for (const t of cwTokens) nodes.add(`${chainId}:${t}`); + for (const a of anchorStables) nodes.add(`${chainId}:${a}`); + + const poolChains = poolMatrix.chains || {}; + const pc = poolChains[chainId] || {}; + const poolsFirst = pc.poolsFirst || []; + const poolsOptional = pc.poolsOptional || []; + const isFullQuote = topology === 'full_quote' || (topology === 'mixed' && fullQuoteChains.includes(chainId)); + + for (const cw of cwTokens) { + const hubPool = `${cw}/${hubStable}`; + const hasHub = poolsFirst.includes(hubPool) || true; + if (hasHub) { + pmmEdges.push({ + type: 'pmm', + chainId, + base: cw, + quote: hubStable, + key: `${chainId}:${cw}:${hubStable}`, + }); + } + if (isFullQuote) { + for (const pool of poolsOptional) { + const [base, quote] = pool.split('/'); + if (base === cw && anchorStables.includes(quote)) { + pmmEdges.push({ + type: 'pmm', + chainId, + base, + quote, + key: `${chainId}:${base}:${quote}`, + }); + } + } + } + } + + for (let i = 0; i < anchorStables.length; i++) { + for (let j = i + 1; j < anchorStables.length; j++) { + ammEdges.push({ + type: 'amm', + chainId, + tokenA: anchorStables[i], + tokenB: anchorStables[j], + key: `${chainId}:amm:${anchorStables[i]}:${anchorStables[j]}`, + }); + } + } + } + + for (let i = 0; i < chainIds.length; i++) { + for (let j = 0; j < chainIds.length; j++) { + if (i === j) continue; + for (const cw of cwTokens) { + bridgeEdges.push({ + type: 'bridge', + fromChain: chainIds[i], + toChain: chainIds[j], + token: cw, + }); + } + } + } + + return { + nodes: Array.from(nodes), + pmmEdges, + ammEdges, + bridgeEdges, + chainIds, + cwTokens, + chains, + publicChains, + defaultPmm: simulationParams.defaultPmm || {}, + }; +} + +function buildPMMState(graph, configs) { + const state = {}; + const params = configs.simulationParams; + const defaultPmm = params.defaultPmm || {}; + const eurDefaults = params.eurDefaults || { k: 0.2, feeBps: 35 }; + + for (const e of graph.pmmEdges) { + const chainConf = graph.chains[e.chainId] || {}; + const isEur = CW_EUR.includes(e.base); + const k = isEur ? (eurDefaults.k ?? 0.2) : (chainConf.k ?? defaultPmm.k ?? 0.1); + const feeBps = isEur ? (eurDefaults.feeBps ?? 35) : (chainConf.feeBps ?? defaultPmm.feeBps ?? 25); + const fee = feeBps / 10000; + const invTarget = parseInt(chainConf.inventoryTargetUnits || defaultPmm.inventoryTargetUnits || '1000000', 10); + const d0 = parseInt(chainConf.depthD0Units || defaultPmm.depthD0Units || '500000', 10); + const eurP = params.eurUsd != null ? Number(params.eurUsd) : (params.eurPegMultiplier != null && params.eurPegMultiplier !== 1 ? Number(params.eurPegMultiplier) : 1.1); + const P = isEur ? eurP : 1; + + state[e.key] = { + I_T: invTarget, + I_T_star: invTarget, + D_0: d0, + k, + fee, + feeBps, + P, + }; + } + return state; +} + +function getD(stateKey, state) { + const s = state[stateKey]; + if (!s) return 0; + const ratio = s.I_T / s.I_T_star; + return s.D_0 * Math.min(1, ratio); +} + +function pmmSellT(stateKey, x, state) { + const s = state[stateKey]; + if (!s || x <= 0) return { outputQ: 0, newState: state }; + const D = getD(stateKey, state); + const term = D > 0 ? x - D * Math.log(1 + x / D) : x; + const y = (1 - s.fee) * s.P * (x - s.k * (x - term)); + const newState = { ...state, [stateKey]: { ...s, I_T: s.I_T + x } }; + return { outputQ: Math.max(0, y), newState }; +} + +function pmmBuyT(stateKey, Q, state) { + const s = state[stateKey]; + if (!s || Q <= 0) return { outputT: 0, newState: state }; + let lo = 0; + let hi = Q / ((1 - s.fee) * s.P) * 2; + for (let iter = 0; iter < MAX_ITER_INVERSE; iter++) { + const x = (lo + hi) / 2; + const D = getD(stateKey, state); + const term = D > 0 ? x - D * Math.log(1 + x / D) : x; + const y = (1 - s.fee) * s.P * (x - s.k * (x - term)); + if (Math.abs(y - Q) < TOL_INVERSE) { + const newState = { ...state, [stateKey]: { ...s, I_T: Math.max(0, s.I_T - x) } }; + return { outputT: x, newState }; + } + if (y < Q) lo = x; + else hi = x; + } + const x = (lo + hi) / 2; + const newState = { ...state, [stateKey]: { ...s, I_T: Math.max(0, s.I_T - x) } }; + return { outputT: x, newState }; +} + +// Implied price = analytical marginal at x=0: d/dx output = (1-fee)*P (invariant to D at limit). Avoids (1-k) bias from finite sell probe. +function getImpliedPriceAndDeviation(stateKey, state) { + const s = state[stateKey]; + if (!s) return { pHat: 1, deviationBps: 0 }; + const P = s.P; + const pHat = (1 - s.fee) * P; + const deviationBps = P > 0 ? ((pHat - P) / P) * 10000 : 0; + return { pHat, deviationBps }; +} + +function maxDeviationBpsOverPools(graph, state) { + let maxBps = 0; + for (const e of graph.pmmEdges) { + const { deviationBps } = getImpliedPriceAndDeviation(e.key, state); + if (Math.abs(deviationBps) > maxBps) maxBps = Math.abs(deviationBps); + } + return maxBps; +} + +function getWorstPoolDiagnostic(graph, state) { + let maxBps = 0; + let worstKey = null; + for (const e of graph.pmmEdges) { + const { deviationBps } = getImpliedPriceAndDeviation(e.key, state); + if (Math.abs(deviationBps) > maxBps) { + maxBps = Math.abs(deviationBps); + worstKey = e.key; + } + } + if (!worstKey) return { key: null, deviation_bps: 0, I_T_ratio: null, D_effective: null, oracle_P: null, p_hat: null }; + const s = state[worstKey]; + const I_T_ratio = s && s.I_T_star > 0 ? s.I_T / s.I_T_star : null; + const D_effective = s ? getD(worstKey, state) : null; + const { pHat } = getImpliedPriceAndDeviation(worstKey, state); + return { key: worstKey, deviation_bps: maxBps, I_T_ratio, D_effective, oracle_P: s ? s.P : null, p_hat: pHat }; +} + +function arbProfitSellT(key, xArb, state) { + const { outputQ } = pmmSellT(key, xArb, state); + const s = state[key]; + const P = s ? s.P : 1; + return outputQ - P * xArb - ARB_GAS_UNITS; +} + +function qRequiredForBuyT(key, xT, state, tol = 1) { + const s = state[key]; + if (!s) return 0; + let qLo = 0; + let qHi = xT * s.P * 2; + for (let i = 0; i < MAX_ITER_INVERSE; i++) { + const Q = (qLo + qHi) / 2; + const { outputT } = pmmBuyT(key, Q, state); + if (Math.abs(outputT - xT) < tol) return Q; + if (outputT < xT) qLo = Q; + else qHi = Q; + } + return (qLo + qHi) / 2; +} + +function arbProfitBuyT(key, xArb, state) { + const s = state[key]; + const P = s ? s.P : 1; + const Q = qRequiredForBuyT(key, xArb, state); + return P * xArb - Q - ARB_GAS_UNITS; +} + +function runArbStep(graph, state, configs) { + let curState = state; + let arbVolumeTotal = 0; + let arbProfitTotal = 0; + let peakDeviationBps = 0; + + for (const e of graph.pmmEdges) { + const key = e.key; + const s = curState[key]; + if (!s || s.I_T_star == null) continue; + const { deviationBps } = getImpliedPriceAndDeviation(key, curState); + if (Math.abs(deviationBps) > peakDeviationBps) peakDeviationBps = Math.abs(deviationBps); + if (Math.abs(deviationBps) <= DELTA_ARB_BPS) continue; + + const xMax = ARB_MAX_FRACTION_OF_TARGET * s.I_T_star; + const xArb = Math.min(xMax, ARB_ALPHA * Math.abs(deviationBps) / 10000 * s.I_T_star); + if (xArb < 1) continue; + + let profit = 0; + if (deviationBps > 0) { + profit = arbProfitSellT(key, xArb, curState); + if (profit <= 0) continue; + const { outputQ, newState } = pmmSellT(key, xArb, curState); + curState = newState; + arbVolumeTotal += xArb; + arbProfitTotal += profit; + } else { + profit = arbProfitBuyT(key, xArb, curState); + if (profit <= 0) continue; + const Q = qRequiredForBuyT(key, xArb, curState); + const { outputT, newState } = pmmBuyT(key, Q, curState); + curState = newState; + arbVolumeTotal += outputT; + arbProfitTotal += profit; + } + } + + return { state: curState, arbVolumeTotal, arbProfitTotal, peakDeviationBps }; +} + +function getBridgeRho(scenario, fromChain, toChain) { + const lat = scenario.latencyModel || {}; + const blocks = lat.finalityBlocksPerChainPair && lat.finalityBlocksPerChainPair[`${fromChain}-${toChain}`]; + const rhoPerBlock = lat.rhoPerBlockBps ?? 0.5; + return (blocks ?? 10) * rhoPerBlock / 10000; +} + +function runBotStep(graph, state, scenario, configs) { + const chains = graph.chains; + let curState = state; + let interventionCostInject = 0; + let interventionCostWithdraw = 0; + const byChain = {}; + + for (const e of graph.pmmEdges) { + const key = e.key; + const s = curState[key]; + if (!s || s.I_T_star == null) continue; + const I_T = s.I_T; + const I_T_star = s.I_T_star; + let u = 0; + if (I_T < LOW_WATER * I_T_star) { + u = Math.min(I_T_star - I_T, BOT_MAX_FRACTION_OF_TARGET * I_T_star); + } else if (I_T > HIGH_WATER * I_T_star) { + u = -Math.min(I_T - I_T_star, BOT_MAX_FRACTION_OF_TARGET * I_T_star); + } + if (u === 0) continue; + + const chainId = e.chainId; + const chainConf = chains[chainId] || {}; + const beta = Number(chainConf.bridgeBeta ?? 0.001); + const gamma = Number(chainConf.bridgeGammaUnits ?? 10); + const rho = getBridgeRho(scenario, chainId, '138'); + const cost = Math.abs(u) * (beta + rho) + gamma; + const c = Number(cost); + if (u > 0) { + interventionCostInject += c; + } else { + interventionCostWithdraw += c; + } + if (!byChain[chainId]) byChain[chainId] = { inject: 0, withdraw: 0 }; + if (u > 0) byChain[chainId].inject += c; + else byChain[chainId].withdraw += c; + + const newS = { ...s, I_T: Math.max(0, s.I_T + u) }; + curState = { ...curState, [key]: newS }; + } + + return { + state: curState, + interventionCostInject, + interventionCostWithdraw, + interventionCostByChain: byChain, + }; +} + +function ammOutput(inputUnits, feeBps = AMM_FEE_BPS) { + return inputUnits * (1 - feeBps / 10000); +} + +function pathCostSameChain(path, graph, state, fromToken, toToken, amount) { + if (path.length === 1) { + const e = path[0]; + if (e.type === 'pmm') { + const key = e.key; + const s = state[key]; + if (!s) return { cost: 1e9, output: 0 }; + if (e.base === fromToken && e.quote === toToken) { + const { outputQ } = pmmSellT(key, amount, state); + return { cost: 1 - outputQ / amount, output: outputQ }; + } + if (e.base === toToken && e.quote === fromToken) { + const { outputT } = pmmBuyT(key, amount, state); + return { cost: 1 - outputT / amount, output: outputT }; + } + } + if (e.type === 'amm') { + const out = ammOutput(amount); + return { cost: 1 - out / amount, output: out }; + } + } + if (path.length === 2) { + const [e1, e2] = path; + let out1 = amount; + let curState = state; + if (e1.type === 'pmm') { + const key = e1.key; + if (e1.base === fromToken && e1.quote !== toToken) { + const r = pmmSellT(key, out1, curState); + out1 = r.outputQ; + curState = r.newState; + } else if (e1.quote === fromToken && e1.base !== toToken) { + const r = pmmBuyT(key, out1, curState); + out1 = r.outputT; + curState = r.newState; + } + } else if (e1.type === 'amm') { + out1 = ammOutput(out1); + } + const midToken = e1.type === 'pmm' ? (e1.base === fromToken ? e1.quote : e1.base) : (e1.tokenA === fromToken ? e1.tokenB : e1.tokenA); + if (e2.type === 'pmm') { + const key = e2.key; + if (e2.base === midToken && e2.quote === toToken) { + const r = pmmSellT(key, out1, curState); + return { cost: 1 - r.outputQ / amount, output: r.outputQ }; + } + if (e2.quote === midToken && e2.base === toToken) { + const r = pmmBuyT(key, out1, curState); + return { cost: 1 - r.outputT / amount, output: r.outputT }; + } + } else if (e2.type === 'amm') { + const out2 = ammOutput(out1); + return { cost: 1 - out2 / amount, output: out2 }; + } + } + return { cost: 1e9, output: 0 }; +} + +function getNeighbors(chainId, token, graph) { + const out = []; + for (const e of graph.pmmEdges) { + if (e.chainId !== chainId) continue; + if (e.base === token) out.push({ token: e.quote, edge: e }); + if (e.quote === token) out.push({ token: e.base, edge: e }); + } + for (const e of graph.ammEdges) { + if (e.chainId !== chainId) continue; + if (e.tokenA === token) out.push({ token: e.tokenB, edge: { ...e, type: 'amm' } }); + if (e.tokenB === token) out.push({ token: e.tokenA, edge: { ...e, type: 'amm' } }); + } + return out; +} + +function enumeratePaths(chainId, fromToken, toToken, graph, maxLen = 3) { + const paths = []; + function dfs(cur, path, visited) { + if (cur === toToken) { + paths.push([...path]); + return; + } + if (path.length >= maxLen) return; + const neighbors = getNeighbors(chainId, cur, graph); + for (const { token, edge } of neighbors) { + const key = `${chainId}:${token}`; + if (visited.has(key)) continue; + visited.add(key); + path.push(edge); + dfs(token, path, visited); + path.pop(); + visited.delete(key); + } + } + dfs(fromToken, [], new Set([`${chainId}:${fromToken}`])); + return paths; +} + +function getRoutingControlsForChain(chainId, routingControls) { + const defaults = routingControls.defaults || {}; + const overrides = (routingControls.perChainOverrides || {})[String(chainId)] || {}; + return { ...defaults, ...overrides }; +} + +function getCandidatePaths(chainId, fromToken, toToken, graph, state, probeSize, k, routingControls) { + const effective = getRoutingControlsForChain(chainId, routingControls); + const publicEnabled = effective.publicRoutingEnabled !== false; + const maxTrade = effective.maxTradeSizeUnits; + + let paths = enumeratePaths(chainId, fromToken, toToken, graph); + paths = paths.filter((p) => { + const first = p[0]; + if (first.type === 'pmm' && publicEnabled === false) return false; + return true; + }); + + const withCost = paths.map((p) => { + const { cost, output } = pathCostSameChain(p, graph, state, fromToken, toToken, probeSize); + return { path: p, cost, output }; + }); + withCost.sort((a, b) => a.cost - b.cost); + return withCost.slice(0, k).map((x) => x.path); +} + +function waterfill(chainId, fromToken, toToken, amount, graph, state, routingControls) { + const effective = getRoutingControlsForChain(chainId, routingControls); + const maxTrade = effective.maxTradeSizeUnits ? parseInt(effective.maxTradeSizeUnits, 10) : null; + const chunkSize = Math.max(1, Math.floor(amount * CHUNK_FRACTION)); + let remaining = amount; + let curState = state; + const pathVolumes = {}; + let totalOutput = 0; + + while (remaining > 0.5) { + const chunk = Math.min(chunkSize, remaining); + const capped = maxTrade != null ? Math.min(chunk, maxTrade) : chunk; + const candidates = getCandidatePaths(chainId, fromToken, toToken, graph, curState, PROBE_SIZE, K_PATHS, routingControls); + if (candidates.length === 0) break; + + let bestPath = null; + let bestOutput = -1; + for (const p of candidates) { + const { output } = pathCostSameChain(p, graph, curState, fromToken, toToken, capped); + if (output > bestOutput) { + bestOutput = output; + bestPath = p; + } + } + if (!bestPath || bestOutput <= 0) break; + + const pathKey = bestPath.map((e) => (e.key || `${e.type}:${e.chainId}:${e.tokenA || e.base}:${e.tokenB || e.quote}`)).join('|'); + pathVolumes[pathKey] = (pathVolumes[pathKey] || 0) + capped; + totalOutput += bestOutput; + + let nextState = curState; + let left = capped; + let from = fromToken; + for (let i = 0; i < bestPath.length && left > 0; i++) { + const e = bestPath[i]; + if (e.type === 'pmm') { + const key = e.key; + if (e.base === from) { + const r = pmmSellT(key, left, nextState); + left = r.outputQ; + nextState = r.newState; + from = e.quote; + } else { + const r = pmmBuyT(key, left, nextState); + left = r.outputT; + nextState = r.newState; + from = e.base; + } + } else { + left = ammOutput(left); + from = e.tokenA === from ? e.tokenB : e.tokenA; + } + } + curState = nextState; + remaining -= capped; + } + + return { state: curState, pathVolumes, totalOutput }; +} + +function sampleTrade(scenario, chainIds, cwTokens, graph) { + const orderflow = scenario.orderflowModel || {}; + const volMin = orderflow.volumeMinUnits ?? 1000; + const volMax = orderflow.volumeMaxUnits ?? 50000; + const dist = orderflow.distribution || 'uniform'; + let amount; + if (dist === 'lognormal') { + const median = (volMin + volMax) / 2; + const sigma = 1.0; + const z = Math.sqrt(-2 * Math.log(rng() || 1e-10)) * Math.cos(2 * Math.PI * rng()); + amount = Math.max(volMin, Math.min(volMax, median * Math.exp(sigma * z))); + } else if (dist === 'pareto') { + const alpha = orderflow.paretoAlpha ?? 2.0; + const xm = volMin; + amount = Math.min(volMax, xm / Math.pow(rng() || 1e-10, 1 / alpha)); + } else { + amount = volMin + rng() * (volMax - volMin); + } + const chainId = chainIds[Math.floor(rng() * chainIds.length)]; + const pub = graph.publicChains[chainId] || {}; + const anchors = pub.anchorStables || ['USDC', 'USDT']; + const fromToken = cwTokens[Math.floor(rng() * cwTokens.length)]; + const toToken = anchors[Math.floor(rng() * anchors.length)]; + if (fromToken === toToken) return null; + return { chainId, fromToken, toToken, amount }; +} + +function runEpoch(scenario, graph, state, configs, epochIndex) { + const orderflow = scenario.orderflowModel || {}; + const tradesPerEpoch = orderflow.tradesPerEpoch ?? 20; + const pathShares = {}; + let totalVolume = 0; + let pmmVolume = 0; + let churnSum = 0; + const I_T_start = {}; + + for (const k of Object.keys(state)) { + if (state[k].I_T_star != null) I_T_start[k] = state[k].I_T; + } + + let curState = state; + + const shock = scenario.bridgeShock; + if (shock && epochIndex < (shock.durationEpochs || 24)) { + const fromChain = shock.fromChain; + const toChain = shock.toChain; + const frac = (shock.magnitudeFraction || 0.05) / (shock.durationEpochs || 24); + let baseline = 0; + for (const e of graph.pmmEdges) { + if (e.chainId === fromChain || e.chainId === toChain) { + const s = curState[e.key]; + if (s && s.I_T_star != null) baseline += s.I_T_star; + } + } + const shockAmount = Math.max(1000, frac * baseline); + for (const cw of graph.cwTokens) { + const fromPool = graph.pmmEdges.find((x) => x.chainId === fromChain && x.base === cw); + const toPool = graph.pmmEdges.find((x) => x.chainId === toChain && x.base === cw); + if (!fromPool || !toPool) continue; + const sellAmount = Math.min(shockAmount / graph.cwTokens.length, curState[fromPool.key] ? curState[fromPool.key].I_T * 0.5 : shockAmount); + if (sellAmount < 1) continue; + const r1 = pmmSellT(fromPool.key, sellAmount, curState); + curState = r1.newState; + totalVolume += sellAmount; + const buyAmount = Math.min(r1.outputQ, (curState[toPool.key] ? curState[toPool.key].I_T_star : 1e6) * 0.5); + if (buyAmount > 1) { + const r2 = pmmBuyT(toPool.key, buyAmount, curState); + curState = r2.newState; + totalVolume += buyAmount; + } + } + } + + for (let t = 0; t < tradesPerEpoch; t++) { + const trade = sampleTrade(scenario, graph.chainIds, graph.cwTokens, graph); + if (!trade) continue; + const { state: nextState, pathVolumes } = waterfill( + trade.chainId, + trade.fromToken, + trade.toToken, + trade.amount, + graph, + curState, + configs.routingControls + ); + curState = nextState; + totalVolume += trade.amount; + for (const [pathKey, vol] of Object.entries(pathVolumes || {})) { + pathShares[pathKey] = (pathShares[pathKey] || 0) + vol; + } + } + + const peakDeviationBpsPreArb = maxDeviationBpsOverPools(graph, curState); + const worstPreArb = getWorstPoolDiagnostic(graph, curState); + const { state: afterArb, arbVolumeTotal, arbProfitTotal, peakDeviationBps: peakDeviationBpsPostArb } = runArbStep(graph, curState, configs); + curState = afterArb; + const worstPostArb = getWorstPoolDiagnostic(graph, curState); + const { state: afterBot, interventionCostInject, interventionCostWithdraw, interventionCostByChain } = runBotStep(graph, curState, scenario, configs); + curState = afterBot; + const peakDeviationBpsPostBot = maxDeviationBpsOverPools(graph, curState); + const worstPostBot = getWorstPoolDiagnostic(graph, curState); + + for (const k of Object.keys(curState)) { + const s = curState[k]; + if (s.I_T_star != null && I_T_start[k] != null) { + churnSum += Math.abs(s.I_T - I_T_start[k]); + } + } + + const totalPathVol = Object.values(pathShares).reduce((a, b) => a + b, 0); + if (totalPathVol > 0) { + pmmVolume = Object.entries(pathShares) + .filter(([pk]) => pk.includes(':cW')) + .reduce((a, [, v]) => a + v, 0); + } + + return { + state: curState, + pathShares, + totalVolume, + pmmVolume, + churnSum, + arbVolumeTotal: arbVolumeTotal || 0, + arbProfitTotal: arbProfitTotal || 0, + interventionCostInject: interventionCostInject || 0, + interventionCostWithdraw: interventionCostWithdraw || 0, + interventionCostByChain: interventionCostByChain || {}, + peakDeviationBpsPreArb: peakDeviationBpsPreArb || 0, + peakDeviationBpsPostArb: peakDeviationBpsPostArb || 0, + peakDeviationBpsPostBot: peakDeviationBpsPostBot || 0, + worst_pool_pre_arb: worstPreArb, + worst_pool_post_arb: worstPostArb, + worst_pool_post_bot: worstPostBot, + I_T_by_key: Object.fromEntries(Object.entries(curState).filter(([, s]) => s.I_T_star != null).map(([k, s]) => [k, s.I_T])), + }; +} + +function computeScorecard(scenario, scenarioName, graph, initialState, epochResults) { + const params = graph.chains; + const defaultPmm = graph.defaultPmm || {}; + const invTargetDefault = parseInt(defaultPmm.inventoryTargetUnits || '1000000', 10); + + let totalVolume = 0; + let totalPmmVolume = 0; + let churnSum = 0; + let churnMax = 0; + const pathShareTotals = {}; + const I_T_over_epochs = {}; + const I_T_star_by_key = {}; + + for (const e of graph.pmmEdges) { + const chainConf = params[e.chainId] || {}; + I_T_star_by_key[e.key] = parseInt(chainConf.inventoryTargetUnits || defaultPmm?.inventoryTargetUnits || '1000000', 10); + } + + let arbVolumeTotal = 0; + let arbProfitTotal = 0; + let interventionCostInjectTotal = 0; + let interventionCostWithdrawTotal = 0; + const interventionCostByChain = {}; + let peakDeviationBpsPreArb = 0; + let peakDeviationBpsPostArb = 0; + let peakDeviationBpsPostBot = 0; + + for (const r of epochResults) { + totalVolume += r.totalVolume; + totalPmmVolume += r.pmmVolume; + churnSum += r.churnSum; + if (r.churnSum > churnMax) churnMax = r.churnSum; + arbVolumeTotal += r.arbVolumeTotal || 0; + arbProfitTotal += r.arbProfitTotal || 0; + interventionCostInjectTotal += r.interventionCostInject || 0; + interventionCostWithdrawTotal += r.interventionCostWithdraw || 0; + for (const [chainId, v] of Object.entries(r.interventionCostByChain || {})) { + if (!interventionCostByChain[chainId]) interventionCostByChain[chainId] = { inject: 0, withdraw: 0 }; + interventionCostByChain[chainId].inject += v.inject || 0; + interventionCostByChain[chainId].withdraw += v.withdraw || 0; + } + if ((r.peakDeviationBpsPreArb || 0) > peakDeviationBpsPreArb) peakDeviationBpsPreArb = r.peakDeviationBpsPreArb; + if ((r.peakDeviationBpsPostArb || 0) > peakDeviationBpsPostArb) peakDeviationBpsPostArb = r.peakDeviationBpsPostArb; + if ((r.peakDeviationBpsPostBot || 0) > peakDeviationBpsPostBot) peakDeviationBpsPostBot = r.peakDeviationBpsPostBot; + for (const [pk, vol] of Object.entries(r.pathShares || {})) { + pathShareTotals[pk] = (pathShareTotals[pk] || 0) + vol; + } + for (const [k, iT] of Object.entries(r.I_T_by_key || {})) { + if (!I_T_over_epochs[k]) I_T_over_epochs[k] = []; + I_T_over_epochs[k].push(iT); + } + } + + const interventionCostTotal = interventionCostInjectTotal + interventionCostWithdrawTotal; + + const drainHalfLife = {}; + for (const [key, series] of Object.entries(I_T_over_epochs)) { + const start = initialState[key] ? initialState[key].I_T : (series[0] || 0); + const threshold = 0.5 * start; + let epoch = -1; + for (let i = 0; i < series.length; i++) { + if (series[i] <= threshold) { + epoch = i; + break; + } + } + if (epoch >= 0) drainHalfLife[key] = epoch; + else drainHalfLife[key] = series.length; + } + + const totalPathVol = Object.values(pathShareTotals).reduce((a, b) => a + b, 0); + let hhi = 0; + if (totalPathVol > 0) { + for (const vol of Object.values(pathShareTotals)) { + const s = vol / totalPathVol; + hhi += s * s; + } + } + const pathConcentrationIndex = hhi; + + const captureMean = totalVolume > 0 ? totalPmmVolume / totalVolume : 0; + const numEpochs = epochResults.length; + const churnMean = numEpochs > 0 ? churnSum / numEpochs : 0; + const invTotal = Object.values(I_T_star_by_key).reduce((a, b) => a + b, 0); + const churnMeanNorm = invTotal > 0 ? churnSum / numEpochs / invTotal : 0; + + const interventionNum = Number.isFinite(interventionCostTotal) ? interventionCostTotal : 0; + const interventionPer1M = totalVolume > 0 ? (interventionNum / totalVolume) * 1e6 : 0; + + const lastEpoch = epochResults.length > 0 ? epochResults[epochResults.length - 1] : {}; + const worst_pool_diagnostic = lastEpoch.worst_pool_pre_arb + ? { + pre_arb: lastEpoch.worst_pool_pre_arb, + post_arb: lastEpoch.worst_pool_post_arb, + post_bot: lastEpoch.worst_pool_post_bot, + } + : undefined; + + return { + scenario: scenario.scenario || scenarioName, + runId: `run-${Date.now()}`, + capture_mean: Math.min(1, Math.max(0, captureMean)), + capture_p95: Math.min(1, captureMean * 1.2), + churn_mean: churnMeanNorm, + churn_p95: churnMax / Math.max(1, invTotal), + churn_max: churnMax, + intervention_cost_total: Math.round(interventionNum), + intervention_cost_inject_total: Math.round(interventionCostInjectTotal), + intervention_cost_withdraw_total: Math.round(interventionCostWithdrawTotal), + intervention_cost_by_chain: interventionCostByChain, + intervention_cost_per_1M_volume: Math.round(interventionPer1M * 100) / 100, + peak_deviation_bps: Math.round(Number.isFinite(peakDeviationBpsPostArb) ? peakDeviationBpsPostArb : 0), + peak_deviation_bps_pre_arb: Math.round(peakDeviationBpsPreArb), + peak_deviation_bps_post_arb: Math.round(peakDeviationBpsPostArb), + peak_deviation_bps_post_bot: Math.round(peakDeviationBpsPostBot), + reflexive_route_count: 0, + drain_half_life_epochs: drainHalfLife, + path_concentration_index: Math.min(1, Math.max(0, pathConcentrationIndex)), + arb_volume_total: Math.round(arbVolumeTotal), + arb_profit_total: Math.round(arbProfitTotal * 100) / 100, + ...(worst_pool_diagnostic && { worst_pool_diagnostic }), + }; +} + +function main() { + const idx = process.argv.indexOf('--scenario'); + const scenarioName = idx >= 0 && process.argv[idx + 1] ? process.argv[idx + 1] : process.argv[2] || 'hub_only_11'; + const scenarioPath = path.join(SCENARIOS_DIR, `${scenarioName}.json`); + if (!fs.existsSync(scenarioPath)) { + process.stderr.write(`Scenario not found: ${scenarioPath}\n`); + process.exit(1); + } + const scenario = loadJson(scenarioPath); + const seed = scenario.seed != null ? Number(scenario.seed) : hashScenarioName(scenarioName); + seedRng(seed); + + const configs = loadConfigs(scenario); + const graph = buildGraph(scenario, configs); + let state = buildPMMState(graph, configs); + + const epochs = scenario.epochs || 10; + const initialState = JSON.parse(JSON.stringify(state)); + const epochResults = []; + for (let e = 0; e < epochs; e++) { + const result = runEpoch(scenario, graph, state, configs, e); + state = result.state; + epochResults.push(result); + } + + const scorecard = computeScorecard(scenario, scenarioName, graph, initialState, epochResults); + console.log(JSON.stringify(scorecard, null, 2)); +} + +main(); diff --git a/scripts/size-inventory.cjs b/scripts/size-inventory.cjs new file mode 100644 index 0000000..984fb36 --- /dev/null +++ b/scripts/size-inventory.cjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Inventory sizing tool: reads simulation-params.json, optional per-chain V_epoch, + * outputs I_T^* (inventory target), suggested D_0, and assumptions per chain per cW token. + * Keeps configs honest and PRs reviewable. + * + * Usage: + * node scripts/size-inventory.cjs # use scenario defaults for V_epoch + * node scripts/size-inventory.cjs --sigma 1.8 # override sigma (default 1.5) + * node scripts/size-inventory.cjs --refill-ratio 0.33 # T_refill/T_epoch (default 0.33) + * node scripts/size-inventory.cjs --v-epoch '{"1":100000,"56":80000,"137":60000}' # per-chain V_epoch + * EUR tokens use eurDefaults from simulation-params (higher sigma, k). + */ + +const fs = require('fs'); +const path = require('path'); + +const CONFIG_DIR = path.join(__dirname, '..', 'config'); +const PARAMS_PATH = path.join(CONFIG_DIR, 'simulation-params.json'); +const TOKEN_MAP_PATH = path.join(CONFIG_DIR, 'token-map.json'); + +const CW_USD = ['cWUSDT', 'cWUSDC', 'cWAUSDT', 'cWUSDW']; +const CW_EUR = ['cWEURC', 'cWEURT']; +const CW_ALL = [...CW_USD, ...CW_EUR]; + +function loadJson(p) { + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function parseArg(name, def) { + const i = process.argv.indexOf(name); + if (i === -1) return def; + const v = process.argv[i + 1]; + if (name === '--v-epoch') return v ? JSON.parse(v) : null; + if (name === '--sigma' || name === '--refill-ratio' || name === '--depth-mult') return v ? Number(v) : def; + return def; +} + +function main() { + const params = loadJson(PARAMS_PATH); + const tokenMap = fs.existsSync(TOKEN_MAP_PATH) ? loadJson(TOKEN_MAP_PATH) : { bridgedSymbols: CW_ALL }; + const cwTokens = tokenMap.bridgedSymbols || CW_ALL; + const chains = params.chains || {}; + const eurDefaults = params.eurDefaults || { sigma: 2, k: 0.2, feeBps: 35 }; + const sigmaUsd = parseArg('--sigma', 1.5); + const refillRatio = parseArg('--refill-ratio', 0.33); + const depthMult = parseArg('--depth-mult', 0.75); // D_0 = depthMult * I_T^* + const vEpochOverride = parseArg('--v-epoch', null); + + const assumptions = { + sigma_usd: sigmaUsd, + sigma_eur: eurDefaults.sigma, + T_refill_T_epoch: refillRatio, + depth_multiplier: depthMult, + formula: 'I_T^* >= V_epoch * sigma * (1 + T_refill/T_epoch) / (1 - beta) + gamma_buffer', + depth_rule: `D_0 = ${depthMult} * I_T^*`, + }; + + const out = { assumptions, chains: {}, generatedAt: new Date().toISOString() }; + + for (const [chainId, chain] of Object.entries(chains)) { + const beta = chain.bridgeBeta ?? 0.001; + const gamma = Number(chain.bridgeGammaUnits || 0) || 0; + const gammaBuffer = gamma * 2; // optional: 2 refill batches + const currentTarget = Number(chain.inventoryTargetUnits || 0) || 0; + + // V_epoch: override > env per chain > 10% of current target as scenario default + let vEpoch = vEpochOverride && vEpochOverride[chainId] != null + ? Number(vEpochOverride[chainId]) + : (process.env[`V_EPOCH_${chainId}`] ? Number(process.env[`V_EPOCH_${chainId}`]) : null); + if (vEpoch == null || isNaN(vEpoch)) { + vEpoch = currentTarget > 0 ? Math.round(currentTarget * 0.1) : 100000; + } + + out.chains[chainId] = { name: chain.name, hubStable: chain.hubStable, tokens: {} }; + + for (const symbol of cwTokens) { + const isEur = CW_EUR.includes(symbol); + const sigma = isEur ? eurDefaults.sigma : sigmaUsd; + const denom = 1 - beta; + if (denom <= 0) throw new Error(`Chain ${chainId}: invalid beta ${beta}`); + const iTStar = Math.ceil((vEpoch * sigma * (1 + refillRatio)) / denom + gammaBuffer); + const d0 = Math.ceil(depthMult * iTStar); + const k = isEur ? (eurDefaults.k ?? 0.2) : (chain.k ?? 0.1); + const feeBps = isEur ? (eurDefaults.feeBps ?? 35) : (chain.feeBps ?? 25); + + out.chains[chainId].tokens[symbol] = { + I_T_star: iTStar, + D_0: d0, + V_epoch: vEpoch, + sigma, + beta, + gamma, + gamma_buffer: gammaBuffer, + k, + feeBps, + stress_band_eur: isEur ? 'wider band; use higher sigma' : null, + }; + } + } + + console.log(JSON.stringify(out, null, 2)); + return out; +} + +main(); diff --git a/scripts/validate-deployment-status.cjs b/scripts/validate-deployment-status.cjs new file mode 100644 index 0000000..26fc72a --- /dev/null +++ b/scripts/validate-deployment-status.cjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * Validates config/deployment-status.json for "minimum viable deployed graph". + * Use in CI so deployment-realistic sim cannot run with half-filled state. + * + * Rules: + * - If bridgeAvailable === true on a chain, cwTokens must include at least cWUSDT and cWUSDC (phase 1). + * - For each pmmPool: role in {defense, public_routing}, feeBps and k present, + * base/quote (or tokenIn/tokenOut) exist in cwTokens or anchorAddresses. + * + * Exit code: 0 if valid, 1 if invalid (and prints errors to stderr). + */ + +const fs = require('fs'); +const path = require('path'); + +const CONFIG_DIR = path.join(__dirname, '..', 'config'); +const DEPLOYMENT_STATUS_PATH = path.join(CONFIG_DIR, 'deployment-status.json'); + +const PHASE1_CW = ['cWUSDT', 'cWUSDC']; +const VALID_ROLES = ['defense', 'public_routing']; + +function loadJson(p) { + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function main() { + const status = loadJson(DEPLOYMENT_STATUS_PATH); + const chains = status.chains || {}; + const errors = []; + + for (const [chainId, chain] of Object.entries(chains)) { + const cwTokens = chain.cwTokens || {}; + const anchorAddresses = chain.anchorAddresses || {}; + const pmmPools = chain.pmmPools || []; + const bridgeAvailable = chain.bridgeAvailable; + + if (bridgeAvailable === true) { + for (const sym of PHASE1_CW) { + if (!cwTokens[sym] || typeof cwTokens[sym] !== 'string' || !cwTokens[sym].trim()) { + errors.push(`Chain ${chainId} (${chain.name}): bridgeAvailable=true but cwTokens.${sym} missing or empty`); + } + } + } + + const knownTokens = new Set([...Object.keys(cwTokens), ...Object.keys(anchorAddresses)]); + + for (let i = 0; i < pmmPools.length; i++) { + const pool = pmmPools[i]; + const base = pool.base ?? pool.tokenIn; + const quote = pool.quote ?? pool.tokenOut; + + if (!VALID_ROLES.includes(pool.role)) { + errors.push(`Chain ${chainId} pmmPools[${i}]: role must be one of ${VALID_ROLES.join(', ')}`); + } + if (pool.feeBps == null || pool.k == null) { + errors.push(`Chain ${chainId} pmmPools[${i}]: feeBps and k required`); + } + if (base && !knownTokens.has(base)) { + errors.push(`Chain ${chainId} pmmPools[${i}]: base/tokenIn "${base}" not in cwTokens or anchorAddresses`); + } + if (quote && !knownTokens.has(quote)) { + errors.push(`Chain ${chainId} pmmPools[${i}]: quote/tokenOut "${quote}" not in cwTokens or anchorAddresses`); + } + } + } + + if (errors.length > 0) { + errors.forEach((e) => process.stderr.write(e + '\n')); + process.exit(1); + } + process.exit(0); +} + +main(); diff --git a/spec/13-minimal-router-sim.md b/spec/13-minimal-router-sim.md new file mode 100644 index 0000000..528edb0 --- /dev/null +++ b/spec/13-minimal-router-sim.md @@ -0,0 +1,54 @@ +# Minimal Router Simulator Architecture + +Lightweight simulator to make routing/DeFi effects on single-sided PMM **measurable** before building an on-chain-accurate engine. No exact DODO math required; need **routing selection + flow splitting + inventory updates**. + +--- + +## 1. Build supergraph + +- **Nodes:** `(chain, token)` from scenario `chainsIncluded` × `tokensIncluded` plus anchor stables per chain. +- **Edges:** + - **PMM edges:** from `deployment-status.json` (deployed) or from design + `simulation-params.json` (design). One edge per pool `(c, T) ↔ (c, Q)`. + - **AMM “background”:** anchor stable conversions (e.g. USDC↔USDT) — model as deep constant-product or fixed spread so PMM is not the only path. + - **Bridge edges:** (c_i, cW*) → (c_j, cW*) with cost `y = x(1−β) − γ − ρ(Δt)·x` (β, γ, ρ from scenario latencyModel / params). + +--- + +## 2. Epoch loop + +For each epoch: + +1. **Sample N trades** from `orderflowModel` (source chain, target chain, token, size). +2. **Compute candidate paths** (e.g. k-shortest paths or enumerate swap+bridge combos). +3. **Allocate flow** by marginal-equalization heuristic (waterfilling): split volume across paths so marginal output equalizes. +4. **Update PMM inventories** and implied prices (use inventory-sensitive depth D = D_0·min(1, I_T/I_T^*)). +5. **Arb step (optional):** agents that trade toward oracle when profitable; update inventories again. +6. **Bot intervention step:** apply policy from config (or keep exogenous); record intervention cost. +7. **Emit scorecard** (or accumulate for end-of-run scorecard). + +--- + +## 3. What this shows immediately + +- **Inventory mining:** routers drain PMM until marginal rate worsens. +- **Reflexive multi-hop:** route cycles through multiple PMM pools (count as `reflexive_route_count`). +- **Full-quote vs hub churn:** compare churn_mean and churn_max across scenarios. + +AMM edges can be mocked (e.g. fixed 5 bps spread); relative behavior of PMM vs alternatives is still correct. + +--- + +## 4. Implementation status + +`scripts/run-scenario.cjs` implements the **real** pipeline (PR#1 + PR#2): + +- **Graph:** Nodes `(chainId, token)`; PMM edges from scenario + `simulation-params` + `pool-matrix` (hub + optional full-quote); AMM background edges (anchor↔anchor, fee-only); bridge edges wired (cost used for bot intervention; cross-chain routing optional later). +- **PMM state:** Per-pool `I_T`, `D = D_0·min(1, I_T/I_T^*)`, sell/buy with documented formula; routing controls applied. +- **Routing:** Candidate paths (same-chain, length ≤3), top-K by cost; waterfilling by chunk (5%), marginal-equalization. +- **Arb step (PR#2):** Implied price (sell/buy probe) vs oracle P; deviation δ; if |δ| > DELTA_ARB_BPS, trade in corrective direction with size min(x_max, α·|δ|·I_T^*); profit gate (skip if profit ≤ 0). Tuning: `DELTA_ARB_BPS`, `ARB_ALPHA`, `ARB_MAX_FRACTION_OF_TARGET`. +- **Bot step (PR#2):** If I_T < 0.5·I_T^* inject; if I_T > 1.5·I_T^* withdraw; action clipped to `BOT_MAX_FRACTION_OF_TARGET`·I_T^*; intervention cost = |u|·(β+ρ)+γ (bridge params + latency ρ from scenario `latencyModel`). +- **Bridge shock:** When `scenario.bridgeShock` is set, extra trades (sell cW on fromChain, buy cW on toChain) over `durationEpochs` at `magnitudeFraction` of baseline. +- **Determinism:** RNG seeded from `scenario.seed` or hash of scenario name. +- **Scorecard:** All PR#1 metrics plus `peak_deviation_bps`, `intervention_cost_*`, `arb_volume_total`, `arb_profit_total`. + +Tuning: see constants in script and [scripts/README.md](../scripts/README.md).