chore: sync submodule state (parent ref update)
Made-with: Cursor
This commit is contained in:
71
scripts/README.md
Normal file
71
scripts/README.md
Normal file
@@ -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_<chainId>` 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.
|
||||
883
scripts/run-scenario.cjs
Normal file
883
scripts/run-scenario.cjs
Normal file
@@ -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();
|
||||
106
scripts/size-inventory.cjs
Normal file
106
scripts/size-inventory.cjs
Normal file
@@ -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();
|
||||
75
scripts/validate-deployment-status.cjs
Normal file
75
scripts/validate-deployment-status.cjs
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user