Sync workspace: config, docs, scripts, CI, operator rules, and submodule pointers.
- Update dbis_core, cross-chain-pmm-lps, explorer-monorepo, metamask-integration, pr-workspace/chains - Omit embedded publish git dirs and empty placeholders from index Made-with: Cursor
This commit is contained in:
12
packages/economics-toolkit/config/curve-rate-provider.json
Normal file
12
packages/economics-toolkit/config/curve-rate-provider.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$comment": "Curve RateProvider.get_quotes (Curve-only AMM liquidity). From AddressProvider id 18 per chain — see https://dev.curve.finance/integration/rate-provider/",
|
||||
"rateProviderByChainId": {
|
||||
"1": "0xa834f3d23749233c9b61ba723588570a1cca0ed7",
|
||||
"10": null,
|
||||
"56": null,
|
||||
"137": null,
|
||||
"42161": null,
|
||||
"8453": null,
|
||||
"138": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$comment": "Copy to executor-allowlist.json (gitignored) for economics-toolkit exec. Restrict to operator LAN.",
|
||||
"chainId": 138,
|
||||
"allowedTo": [
|
||||
"0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895"
|
||||
],
|
||||
"maxValueWei": "0",
|
||||
"maxFeePerGasGwei": 500
|
||||
}
|
||||
18
packages/economics-toolkit/config/gas-networks.json
Normal file
18
packages/economics-toolkit/config/gas-networks.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$comment": "Default public RPC URLs (rotate if rate-limited). Override via ECONOMICS_GAS_RPC_<chainId> env.",
|
||||
"networks": [
|
||||
{ "chainId": 1, "name": "Ethereum Mainnet", "defaultRpc": "https://eth.llamarpc.com", "nativeSymbol": "ETH", "coingeckoId": "ethereum", "nativeDecimals": 18 },
|
||||
{ "chainId": 10, "name": "Optimism", "defaultRpc": "https://mainnet.optimism.io", "nativeSymbol": "ETH", "coingeckoId": "ethereum", "nativeDecimals": 18 },
|
||||
{ "chainId": 25, "name": "Cronos", "defaultRpc": "https://evm.cronos.org", "nativeSymbol": "CRO", "coingeckoId": "crypto-com-chain", "nativeDecimals": 18 },
|
||||
{ "chainId": 56, "name": "BNB Chain", "defaultRpc": "https://bsc-dataseed.binance.org", "nativeSymbol": "BNB", "coingeckoId": "binancecoin", "nativeDecimals": 18 },
|
||||
{ "chainId": 100, "name": "Gnosis", "defaultRpc": "https://rpc.gnosischain.com", "nativeSymbol": "xDAI", "coingeckoId": "xdai", "nativeDecimals": 18 },
|
||||
{ "chainId": 137, "name": "Polygon PoS", "defaultRpc": "https://polygon-rpc.com", "nativeSymbol": "MATIC", "coingeckoId": "matic-network", "nativeDecimals": 18 },
|
||||
{ "chainId": 138, "name": "Chain 138", "defaultRpc": "https://rpc-http-pub.d-bis.org", "nativeSymbol": "ETH", "coingeckoId": null, "nativeDecimals": 18 },
|
||||
{ "chainId": 1111, "name": "WEMIX", "defaultRpc": "https://api.wemix.com", "nativeSymbol": "WEMIX", "coingeckoId": "wemix-token", "nativeDecimals": 18 },
|
||||
{ "chainId": 8453, "name": "Base", "defaultRpc": "https://mainnet.base.org", "nativeSymbol": "ETH", "coingeckoId": "ethereum", "nativeDecimals": 18 },
|
||||
{ "chainId": 42161, "name": "Arbitrum One", "defaultRpc": "https://arb1.arbitrum.io/rpc", "nativeSymbol": "ETH", "coingeckoId": "ethereum", "nativeDecimals": 18 },
|
||||
{ "chainId": 42220, "name": "Celo", "defaultRpc": "https://forno.celo.org", "nativeSymbol": "CELO", "coingeckoId": "celo", "nativeDecimals": 18 },
|
||||
{ "chainId": 43114, "name": "Avalanche C-Chain", "defaultRpc": "https://api.avax.network/ext/bc/C/rpc", "nativeSymbol": "AVAX", "coingeckoId": "avalanche-2", "nativeDecimals": 18 },
|
||||
{ "chainId": 651940, "name": "ALL Mainnet", "defaultRpc": "https://mainnet-rpc.alltra.global", "nativeSymbol": "?", "coingeckoId": null, "nativeDecimals": 18 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"bounds": [
|
||||
{
|
||||
"legId": "a",
|
||||
"param": "grossPct",
|
||||
"min": 0.15,
|
||||
"max": 0.45
|
||||
},
|
||||
{
|
||||
"legId": "a",
|
||||
"param": "flashNotionalMultiple",
|
||||
"min": 1,
|
||||
"max": 3
|
||||
}
|
||||
],
|
||||
"samples": 300,
|
||||
"seed": 42,
|
||||
"rounds": 10,
|
||||
"lineGridSteps": 14
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"dimensions": [
|
||||
{
|
||||
"legId": "a",
|
||||
"param": "grossPct",
|
||||
"min": 0.2,
|
||||
"max": 0.35,
|
||||
"step": 0.05
|
||||
},
|
||||
{
|
||||
"legId": "a",
|
||||
"param": "flashNotionalMultiple",
|
||||
"min": 1,
|
||||
"max": 2,
|
||||
"step": 1
|
||||
}
|
||||
],
|
||||
"maxCombinations": 5000
|
||||
}
|
||||
27
packages/economics-toolkit/config/strategy-smoke.json
Normal file
27
packages/economics-toolkit/config/strategy-smoke.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": 1,
|
||||
"name": "smoke_all_pass",
|
||||
"baseNotionalUsdt": 100,
|
||||
"aggregateMode": "sequential_compound_usd",
|
||||
"legs": [
|
||||
{
|
||||
"id": "a",
|
||||
"kind": "spot_swap",
|
||||
"grossPct": 0.5,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.1,
|
||||
"liquidityPct": 0.01,
|
||||
"minProfitPct": 0
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"kind": "debt_swap",
|
||||
"grossPct": 0.4,
|
||||
"flashFeePct": 0.09,
|
||||
"flashNotionalMultiple": 1,
|
||||
"gasPctOfNotional": 0.05,
|
||||
"liquidityPct": 0.01,
|
||||
"minProfitPct": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
153
packages/economics-toolkit/config/strategy-template.json
Normal file
153
packages/economics-toolkit/config/strategy-template.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"version": 1,
|
||||
"name": "full_strategy_example",
|
||||
"baseNotionalUsdt": 175,
|
||||
"aggregateMode": "sequential_compound_usd",
|
||||
"tags": ["template", "replace_gross_with_live_quotes"],
|
||||
"legs": [
|
||||
{
|
||||
"id": "open_flash_arb",
|
||||
"kind": "flash_arb_path",
|
||||
"grossPct": 0.29,
|
||||
"flashFeePct": 0.09,
|
||||
"flashNotionalMultiple": 1,
|
||||
"gasPctOfNotional": 0.1,
|
||||
"liquidityPct": 0.01,
|
||||
"notes": "Atomic borrow-trade-repay; align gross with path-check impliedGrossPct."
|
||||
},
|
||||
{
|
||||
"id": "collateral_toggle",
|
||||
"kind": "collateral_toggle",
|
||||
"grossPct": 0.02,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.05,
|
||||
"liquidityPct": 0,
|
||||
"fixedCostUsdt": 0,
|
||||
"notes": "Move collateral between venues; bump gas if extra txs."
|
||||
},
|
||||
{
|
||||
"id": "debt_swap",
|
||||
"kind": "debt_swap",
|
||||
"grossPct": 0.03,
|
||||
"flashFeePct": 0.09,
|
||||
"flashNotionalMultiple": 2,
|
||||
"gasPctOfNotional": 0.08,
|
||||
"liquidityPct": 0.01,
|
||||
"notes": "Refinance; flash multiple 2 => effective flash vs base = 0.18%."
|
||||
},
|
||||
{
|
||||
"id": "collateral_swap",
|
||||
"kind": "collateral_swap",
|
||||
"grossPct": 0,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.06,
|
||||
"liquidityPct": 0.02,
|
||||
"fixedCostUsdt": 0.5,
|
||||
"notes": "Slippage as negative gross if you model it."
|
||||
},
|
||||
{
|
||||
"id": "unwind_repay",
|
||||
"kind": "unwind_repay",
|
||||
"grossPct": -0.01,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.04,
|
||||
"liquidityPct": 0,
|
||||
"notes": "Interest drag as small negative gross."
|
||||
},
|
||||
{
|
||||
"id": "unwind_withdraw",
|
||||
"kind": "unwind_withdraw",
|
||||
"grossPct": 0,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.04,
|
||||
"liquidityPct": 0,
|
||||
"notes": "Second unwind tx."
|
||||
},
|
||||
{
|
||||
"id": "bridge_home",
|
||||
"kind": "bridge_transfer",
|
||||
"grossPct": -0.05,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.02,
|
||||
"liquidityPct": 0,
|
||||
"fixedCostUsdt": 2,
|
||||
"notes": "Bridge fee as negative gross or fixed USD."
|
||||
},
|
||||
{
|
||||
"id": "lp_seed",
|
||||
"kind": "lp_add",
|
||||
"grossPct": 0.01,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.07,
|
||||
"liquidityPct": 0.01,
|
||||
"notes": "IL not included."
|
||||
},
|
||||
{
|
||||
"id": "spot_trim",
|
||||
"kind": "spot_swap",
|
||||
"grossPct": 0.1,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.1,
|
||||
"liquidityPct": 0.01,
|
||||
"notes": "No flash; residual uses 0.29-style gross if you replace."
|
||||
},
|
||||
{
|
||||
"id": "liq_opportunity",
|
||||
"kind": "liquidation_harvest",
|
||||
"grossPct": 0.15,
|
||||
"flashFeePct": 0.09,
|
||||
"flashNotionalMultiple": 5,
|
||||
"gasPctOfNotional": 0.15,
|
||||
"liquidityPct": 0,
|
||||
"notes": "Rare; competition not modeled."
|
||||
},
|
||||
{
|
||||
"id": "margin_rebal",
|
||||
"kind": "margin_rebalance",
|
||||
"grossPct": 0,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.05,
|
||||
"liquidityPct": 0,
|
||||
"notes": "LTV maintenance."
|
||||
},
|
||||
{
|
||||
"id": "intent",
|
||||
"kind": "intent_fill",
|
||||
"grossPct": 0.05,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.12,
|
||||
"liquidityPct": 0,
|
||||
"notes": "Solver route."
|
||||
},
|
||||
{
|
||||
"id": "rollup",
|
||||
"kind": "rollup_batch",
|
||||
"grossPct": 0,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.02,
|
||||
"liquidityPct": 0,
|
||||
"fixedCostUsdt": 0.25,
|
||||
"notes": "L2 amortized cost."
|
||||
},
|
||||
{
|
||||
"id": "lp_exit",
|
||||
"kind": "lp_remove",
|
||||
"grossPct": 0,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0.06,
|
||||
"liquidityPct": 0.01,
|
||||
"notes": "Remove LP."
|
||||
},
|
||||
{
|
||||
"id": "custom_tail",
|
||||
"kind": "custom",
|
||||
"grossPct": 0,
|
||||
"flashFeePct": 0,
|
||||
"gasPctOfNotional": 0,
|
||||
"liquidityPct": 0,
|
||||
"slippageBps": 0,
|
||||
"protocolFeeBps": 0,
|
||||
"notes": "Attach your own economics. Optional: slippageBps/protocolFeeBps subtract from gross; derivedFrom / enrich* for automation."
|
||||
}
|
||||
]
|
||||
}
|
||||
102
packages/economics-toolkit/config/strategy.schema.json
Normal file
102
packages/economics-toolkit/config/strategy.schema.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://proxmox.local/economics-toolkit/strategy.schema.json",
|
||||
"title": "EconomicsStrategy",
|
||||
"type": "object",
|
||||
"required": ["version", "name", "baseNotionalUsdt", "aggregateMode", "legs"],
|
||||
"properties": {
|
||||
"version": { "const": 1 },
|
||||
"name": { "type": "string" },
|
||||
"baseNotionalUsdt": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"aggregateMode": { "enum": ["linear_pct_sum", "sequential_compound_usd"] },
|
||||
"tags": { "type": "array", "items": { "type": "string" } },
|
||||
"legs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "kind"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"kind": { "type": "string" },
|
||||
"grossPct": { "type": "number" },
|
||||
"flashFeePct": { "type": "number" },
|
||||
"flashNotionalMultiple": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"gasPctOfNotional": { "type": "number" },
|
||||
"liquidityPct": { "type": "number" },
|
||||
"minProfitPct": { "type": "number" },
|
||||
"fixedCostUsdt": { "type": "number" },
|
||||
"weight": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"notes": { "type": "string" },
|
||||
"slippageBps": { "type": "number", "minimum": 0 },
|
||||
"protocolFeeBps": { "type": "number", "minimum": 0 },
|
||||
"mevDragBps": { "type": "number", "minimum": 0 },
|
||||
"ilDragBps": { "type": "number", "minimum": 0 },
|
||||
"liquidationCompetitionBps": { "type": "number", "minimum": 0 },
|
||||
"derivedFrom": {
|
||||
"type": "object",
|
||||
"required": ["fromLegId", "field"],
|
||||
"properties": {
|
||||
"fromLegId": { "type": "string" },
|
||||
"field": {
|
||||
"enum": ["residualPct", "netAfterFlashPct", "grossEffectivePct", "bucketsTotalPct"]
|
||||
},
|
||||
"scale": { "type": "number" },
|
||||
"offsetPct": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"derivedExpr": { "type": "object", "description": "Safe arithmetic tree; see toolkit docs" },
|
||||
"exec": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["mode", "to", "data"],
|
||||
"properties": {
|
||||
"mode": { "const": "raw" },
|
||||
"to": { "type": "string" },
|
||||
"data": { "type": "string" },
|
||||
"valueWei": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["mode", "integrationTo", "pool", "tokenIn", "amountInWei", "minOutWei"],
|
||||
"properties": {
|
||||
"mode": { "const": "pmm_swap_exact_in" },
|
||||
"integrationTo": { "type": "string" },
|
||||
"pool": { "type": "string" },
|
||||
"tokenIn": { "type": "string" },
|
||||
"amountInWei": { "type": "string" },
|
||||
"minOutWei": { "type": "string" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"enrichPathCheck": {
|
||||
"type": "object",
|
||||
"required": ["poolAddress", "tokenIn", "amountInWei"],
|
||||
"properties": {
|
||||
"poolAddress": { "type": "string" },
|
||||
"tokenIn": { "type": "string" },
|
||||
"amountInWei": { "type": "string" },
|
||||
"traderForView": { "type": "string" },
|
||||
"decimals": { "type": "number" },
|
||||
"flashFeePct": { "type": "number" },
|
||||
"gasPctOfNotional": { "type": "number" },
|
||||
"liquidityPct": { "type": "number" },
|
||||
"minProfitPct": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"enrichGas": {
|
||||
"type": "object",
|
||||
"required": ["chainId", "gasUnits"],
|
||||
"properties": {
|
||||
"chainId": { "type": "integer" },
|
||||
"gasUnits": { "type": ["string", "number"] },
|
||||
"skipUsd": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$comment": "Uniswap V3 quoters: QuoterV2 (struct ABI) where deployed; Quoter V1 (periphery lens) for Ethereum mainnet — see github.com/Uniswap/v3-periphery deploys.md. Override with --quoter on CLI.",
|
||||
"quoterV1ByChainId": {
|
||||
"1": "0xb27308f9f90d607463bb33ea1bebb41c27ce5ab6"
|
||||
},
|
||||
"quoterV2ByChainId": {
|
||||
"1": null,
|
||||
"10": "0x61ffe014ba17989e74306f13b406eefa001af837",
|
||||
"56": "0x78d78e420da98ad378d7799be8f4af69033eb077",
|
||||
"137": "0x61ffe014ba17989e74306f13b406eefa001af837",
|
||||
"42161": "0x61ffe014ba17989e74306f13b406eefa001af837",
|
||||
"8453": "0x3d4e44eb1374240ce5f1b871ab261cd16335b76a",
|
||||
"138": null
|
||||
}
|
||||
}
|
||||
236
packages/economics-toolkit/docs/FLOW_INPUTS_OUTPUTS_TABLE.md
Normal file
236
packages/economics-toolkit/docs/FLOW_INPUTS_OUTPUTS_TABLE.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Economics flows — inputs, outputs, flash, collateral, repayment, profit
|
||||
|
||||
Percent units match the toolkit (`economics-toolkit calc`): **grossPct** / **flashFeePct** are expressed as **percent of notional** (e.g. `0.29` = 0.29%), not decimal fractions.
|
||||
|
||||
## Single-transaction PMM swap (no flash)
|
||||
|
||||
| | **Description** |
|
||||
|--|-----------------|
|
||||
| **Inputs** | `tokenIn` balance + allowance to `DODOPMMIntegration`; `amountIn`; gas (native token). |
|
||||
| **Outputs** | `tokenOut` received from pool (`querySellBase` / `querySellQuote`); any dust stays in wallet. |
|
||||
| **Flash loan** | None. |
|
||||
| **Collateral** | None (spot swap: you own `tokenIn`). |
|
||||
| **Repayments** | None beyond pool fee / slippage inside the swap (no third-party loan). |
|
||||
| **Profit** | `amountOut - amountIn` in value terms (same units or mark-to-market), **minus** gas. Toolkit **implied gross %**: \((\text{amountOut}/\text{amountIn} - 1) \times 100\) before gas. |
|
||||
|
||||
## Flash-assisted path (borrow → trade → repay in one tx)
|
||||
|
||||
| | **Description** |
|
||||
|--|-----------------|
|
||||
| **Inputs** | Flash provider callback; planned `amountIn` of asset to borrow; encoded inner actions; gas. |
|
||||
| **Flash loan** | Principal `B` from provider (e.g. Aave/Balancer/contract-specific); fee `B × flashFeePct / 100` (toolkit: **flashFeePct**). |
|
||||
| **Collateral** | None for the borrowed asset in the same tx (loan is repaid atomically). *Smart contract* may hold temporary balances during the callback. |
|
||||
| **Repayments** | **B + fee** (and any protocol-specific premium) to the flash provider before the outer tx ends; revert if underpaid. |
|
||||
| **Outputs** | Whatever the inner strategy leaves in the executing contract/wallet after repay (often **zero** net token profit if pure arb). |
|
||||
| **Profit** | For end-user: **gross** ≈ value gained on the path; **net** ≈ gross − **flashFeePct** − **gasPctOfNotional** − other buckets (`evaluateEconomics` residual). |
|
||||
|
||||
## Toolkit `calc` (budgeting only)
|
||||
|
||||
| **Field** | **Role** |
|
||||
|-----------|----------|
|
||||
| **Inputs** | `grossPct`, `flashFeePct`, optional `gasPctOfNotional`, `liquidityPct`, `minProfitPct`. |
|
||||
| **Outputs** | `netAfterFlashPct`, `bucketsTotalPct`, `residualPct`, `passesResidual`, `sensitivity[]`. |
|
||||
| **Flash loan** | Modeled only as **flashFeePct** of notional (not principal size). |
|
||||
| **Collateral** | Not modeled (add explicit rows in your own sheet if you use margin elsewhere). |
|
||||
| **Repayments** | Implicit: flash fee is the “repay overhead” in % terms. |
|
||||
| **Profit** | **`residualPct`** after flash and buckets; must be ≥ `minProfitPct` for `passesResidual`. |
|
||||
|
||||
## Toolkit `path-check` (Chain 138 PMM quote + gate)
|
||||
|
||||
| **Field** | **Role** |
|
||||
|-----------|----------|
|
||||
| **Inputs** | `--rpc`, `--pool`, `--token-in`, `--amount-in`, `--trader`, economics flags (`--flash`, `--gas`, `--liquidity`, …). |
|
||||
| **Outputs** | `amountOut`, `impliedGrossPct`, full `economics` object, `passesEconomicsGate`. |
|
||||
| **Flash loan** | Not executed; **flash** is a **budget line** only. |
|
||||
| **Collateral** | N/A (quote-only). |
|
||||
| **Repayments** | N/A. |
|
||||
| **Profit** | **Implied** from quote vs `amountIn`; gate uses same economics as `calc`. |
|
||||
|
||||
## Cross-check: your bucket example
|
||||
|
||||
| Line item | Example % (of notional) |
|
||||
|-----------|-------------------------|
|
||||
| Gross (from path / quote) | 0.29 |
|
||||
| Flash fee | 0.09 |
|
||||
| Net after flash | 0.20 |
|
||||
| Gas bucket | 0.10 |
|
||||
| Liquidity bucket | 0.01 |
|
||||
| **Residual (profit buffer)** | **0.09** |
|
||||
|
||||
Use: `pnpm exec economics-toolkit calc --gross 0.29 --flash 0.09 --gas 0.1 --liquidity 0.01`
|
||||
|
||||
---
|
||||
|
||||
## Numerical tables — wallet liquidity 100, 175, and 250 USDT
|
||||
|
||||
**Assumptions (illustrative budgeting, not live quotes):**
|
||||
|
||||
- **Wallet liquidity** = full **notional** deployed in one swap path (all-in USDT stable leg).
|
||||
- **Gross** = **0.29%** of notional (treat as quoted edge before modeled fees).
|
||||
- **Flash fee** = **0.09%** of **flash principal** \(B\) (when a flash is used; see below).
|
||||
- **Gas bucket** = **0.10%** of notional; **Liquidity bucket** = **0.01%** of notional (same as `calc --gas 0.1 --liquidity 0.01`).
|
||||
- **Residual %** = 0.29 − 0.09 − 0.10 − 0.01 = **0.09%** of notional when flash is sized to **notional** (see flash section for \(B = 10×\) wallet).
|
||||
|
||||
Amounts in **USDT** (6 dp); small rounding may appear in spreadsheets.
|
||||
|
||||
### A. Single swap / single path (notional = wallet)
|
||||
|
||||
| Wallet (USDT) | Notional (USDT) | Gross @ 0.29% (USDT) | Flash fee @ 0.09% of N (USDT) | Net after flash (USDT) | Gas bucket 0.10% (USDT) | Liq bucket 0.01% (USDT) | **Residual profit (USDT)** |
|
||||
|---------------|-----------------|----------------------|--------------------------------|-------------------------|-------------------------|-------------------------|----------------------------|
|
||||
| 100 | 100 | 0.290000 | 0.090000 | 0.200000 | 0.100000 | 0.010000 | **0.090000** |
|
||||
| 175 | 175 | 0.507500 | 0.157500 | 0.350000 | 0.175000 | 0.017500 | **0.157500** |
|
||||
| 250 | 250 | 0.725000 | 0.225000 | 0.500000 | 0.250000 | 0.025000 | **0.225000** |
|
||||
|
||||
**Spot swap (no flash):** drop the **flash** column and use **Gross − gas − liq** only → residual = (0.29% − 0.10% − 0.01%) × N = **0.18% × N** → **0.18 / 0.315 / 0.45** USDT respectively.
|
||||
|
||||
### B. Flash principal \(B = 10×\) wallet (fee on borrowed amount)
|
||||
|
||||
Flash **repayment** (principal + fee) is in **borrowed units**; fee **cash** cost scales with \(B\).
|
||||
|
||||
| Wallet (USDT) | Flash borrow B (USDT) | Flash fee @ 0.09% of B (USDT) |
|
||||
|---------------|------------------------|--------------------------------|
|
||||
| 100 | 1000 | 0.900000 |
|
||||
| 175 | 1750 | 1.575000 |
|
||||
| 250 | 2500 | 2.250000 |
|
||||
|
||||
*Gross path and gas/liquidity buckets are still often modeled on **trade notional** or path-specific rules; align \(B\) with your contract’s actual borrow.*
|
||||
|
||||
### C. Multi-round — **linear** (R separate full-notional passes, same wallet each time)
|
||||
|
||||
Each round uses **notional = wallet** again; **cumulative residual** = R × (0.09% × N) with flash-on-N; or R × (0.18% × N) spot-only.
|
||||
|
||||
| Wallet (USDT) | Residual / round (USDT) | R = 5 (USDT) | R = 25 (USDT) | R = 100 (USDT) | R = 250 (USDT) |
|
||||
|---------------|-------------------------|--------------|---------------|----------------|----------------|
|
||||
| 100 | 0.090000 | 0.450000 | 2.250000 | 9.000000 | 22.500000 |
|
||||
| 175 | 0.157500 | 0.787500 | 3.937500 | 15.750000 | 39.375000 |
|
||||
| 250 | 0.225000 | 1.125000 | 5.625000 | 22.500000 | 56.250000 |
|
||||
|
||||
**Spot-only linear** (0.18% × N per round): multiply the row by **2** (i.e. R × 0.18% × N).
|
||||
|
||||
*Requires R transactions and R× gas in the real world unless batched.*
|
||||
|
||||
### D. Multi-round — **compound** (reinvest residual each round, same % on growing balance)
|
||||
|
||||
Residual rate \(r = 0.09\% = 0.0009\) per round (flash-on-N model). Ending extra vs start: \(N \times ((1+r)^R - 1)\).
|
||||
|
||||
| Wallet N (USDT) | R = 5 (USDT) | R = 25 (USDT) | R = 100 (USDT) | R = 250 (USDT) |
|
||||
|-----------------|--------------|---------------|----------------|----------------|
|
||||
| 100 | 0.450811 | 2.274469 | 9.413000 | 25.219600 |
|
||||
| 175 | 0.788919 | 3.980320 | 16.472749 | 44.134300 |
|
||||
| 250 | 1.127027 | 5.686171 | 23.532499 | 63.049000 |
|
||||
|
||||
*Compound here assumes each round earns **0.09% on the current balance**; real PMM depth and slippage change effective %.*
|
||||
|
||||
### E. One atomic bundle vs many txs (conceptual)
|
||||
|
||||
| Pattern | Flash fees | Gas / other |
|
||||
|---------|------------|-------------|
|
||||
| **One tx** (flash + swaps + repay) | **One** 0.09% on borrow \(B\) (if used) | **One** gas bucket (often one `estimateGas`) |
|
||||
| **R txs** | R × flash (if each uses flash) or 0 | **R ×** gas; **R ×** failure / MEV exposure between rounds |
|
||||
|
||||
### F. Collateral (optional lending loop — not the PMM spot path)
|
||||
|
||||
If you **borrow on margin** with collateral \(C\) in USDT and LTV so borrow = \(k \cdot C\), repayment is **loan + interest**, not the flash row above. This toolkit’s **`calc`** line does not include **interest APR** or **liquidation**; extend your sheet with:
|
||||
|
||||
- **Collateral in** → **borrow** → **swap rounds** → **repay loan + interest** → **release collateral**.
|
||||
|
||||
Use fixed **100 / 175 / 250 USDT** (or your own grid) as \(C\) only in your own risk model; no extra table unless you specify LTV and borrow APR.
|
||||
|
||||
### G. Real-time gas (replace fixed “gas bucket %” with live quotes)
|
||||
|
||||
The toolkit can pull **current** `feeData` from each network’s JSON-RPC and estimate **tx cost** = `gasUnits × effectiveGasPrice` (legacy `gasPrice`, else EIP-1559 `maxFeePerGas` as a conservative bound).
|
||||
|
||||
```bash
|
||||
# All configured networks (see packages/economics-toolkit/config/gas-networks.json)
|
||||
pnpm exec economics-toolkit gas-quote --gas-units 250000
|
||||
|
||||
# Subset + optional notional (USDT) to print gas as % of notional (CoinGecko USD for native token)
|
||||
pnpm exec economics-toolkit gas-quote --chains 1,56,137,138 --gas-units 250000 --notional-usdt 175
|
||||
|
||||
# Native-only (no CoinGecko):
|
||||
ECONOMICS_GAS_SKIP_USD=1 pnpm exec economics-toolkit gas-quote --chains 138 --gas-units 250000
|
||||
```
|
||||
|
||||
- **Networks / RPCs:** defaults are public endpoints; override per chain with `ECONOMICS_GAS_RPC_<chainId>` (e.g. `ECONOMICS_GAS_RPC_1`). Chain **138** uses `RPC_URL_138` when set, else the public FQDN in config.
|
||||
- **USD:** optional via [CoinGecko](https://www.coingecko.com/) public `simple/price` (rate limits apply). Chain 138 has **no** default USD mapping for the native unit; use **`ECONOMICS_GAS_SKIP_USD=1`** or extend config.
|
||||
- **gas-units:** use your own `estimateGas` from a simulation (e.g. `exec` dry-run) for accuracy; **250000** is a generic swap-shaped default.
|
||||
|
||||
**Gas budget vs wallet balance (`gas-budget`):** For a **single chain** (the RPC you pass), the toolkit can combine **`eth_getBalance`** with the same fee heuristic as `gas-quote` and report **how many identical transactions** you could afford if you allocated **25%, 50%, 75%, or 100%** of the account’s **current native balance** to gas (floor division; one cost model per run). This does **not** send transactions. Use an explicit **`--address`**, or omit it and set **`PRIVATE_KEY`** or **`ECONOMICS_GAS_BUDGET_PRIVATE_KEY`** so the deployer EOA is derived (read-only balance fetch).
|
||||
|
||||
```bash
|
||||
pnpm exec economics-toolkit gas-budget --rpc $RPC_URL_138 --address 0xYourDeployer
|
||||
pnpm exec economics-toolkit gas-budget --rpc $RPC_URL_138 --gas-units 300000 # address from PRIVATE_KEY
|
||||
```
|
||||
|
||||
### H. RPC dotenv alignment (operator scripts and economics toolkit)
|
||||
|
||||
| Purpose | Primary variables | Notes |
|
||||
|---------|-------------------|--------|
|
||||
| **Chain 138 core** (deploy, LAN Besu VMID 2101) | `RPC_URL_138`, `CHAIN138_RPC_URL` | Default `http://192.168.11.211:8545` via [`scripts/lib/load-project-env.sh`](../../../scripts/lib/load-project-env.sh) |
|
||||
| **Chain 138 public** (frontends, bridges, MetaMask) | `RPC_URL_138_PUBLIC` | Prefer HTTPS FQDN; public node VMID 2201 / `192.168.11.221` — do not point read-only indexers at core 211 |
|
||||
| **Token-aggregation service** | `CHAIN_138_RPC_URL`, `TOKEN_AGGREGATION_*_RPC_URL`, `RPC_URL_138` | Precedence in `smom-dbis-138/services/token-aggregation/src/config/chain138-rpc.ts` and PMM quote helper |
|
||||
| **Gas quote overrides** | `ECONOMICS_GAS_RPC_<chainId>` | Per-chain RPC for `economics-toolkit gas-quote`; chain **138** also respects `RPC_URL_138` in [`gas-realtime.ts`](../src/gas-realtime.ts) |
|
||||
| **Strip CR/LF** | All of the above + relay / multichain RPC names | `load-project-env.sh` strips line endings on these URLs so `cast`/`curl` do not break |
|
||||
|
||||
Canonical endpoints and FQDNs: [`docs/04-configuration/RPC_ENDPOINTS_MASTER.md`](../../../docs/04-configuration/RPC_ENDPOINTS_MASTER.md). Master template: [`.env.master.example`](../../../.env.master.example).
|
||||
|
||||
### I. Multi-leg strategy building (collateral toggle, debt/collateral swap, unwind, LP, bridge, …)
|
||||
|
||||
Strategies are **JSON** documents (`version: 1`) with `legs[]`. Each leg has a **`kind`** (taxonomy for your runbooks), **`grossPct`**, optional **flash** fields, **gas/liquidity** buckets, **`fixedCostUsdt`**, **`notes`**, and optional:
|
||||
|
||||
| Feature | Purpose |
|
||||
|--------|---------|
|
||||
| **`slippageBps` / `protocolFeeBps`** | Execution / protocol drag (1 bp = 0.01 percentage points subtracted from gross). |
|
||||
| **`mevDragBps` / `ilDragBps` / `liquidationCompetitionBps`** | Budget lines for MEV, impermanent loss, and liquidation competition (same bps units). |
|
||||
| **`derivedFrom`** | Linear function of a **prior** leg’s `residualPct`, `netAfterFlashPct`, `grossEffectivePct`, or `bucketsTotalPct` (earlier in `legs[]` only). |
|
||||
| **`derivedExpr`** | Safe arithmetic tree over prior legs (`const`, `ref`, `add`…`neg`); mutually exclusive with `derivedFrom`. |
|
||||
| **`enrichPathCheck` / `enrichGas`** | Hints for **`strategy enrich`** to pull **implied gross** (Chain 138 PMM) and **gas % of notional** from live RPC. |
|
||||
| **`exec`** | `raw` calldata or **`pmm_swap_exact_in`** for **`strategy exec-plan`** (simulation-only; see section J). |
|
||||
|
||||
**Flash vs base notional:** fee is `%` of borrow \(B\); set **`flashNotionalMultiple`** so \(B = \text{baseNotional} \times M\). Effective flash as % of base is **`M × flashFeePct`**.
|
||||
|
||||
**Aggregate modes:**
|
||||
|
||||
| Mode | Meaning |
|
||||
|------|---------|
|
||||
| `linear_pct_sum` | Sum each leg’s `economics.residualPct` × `weight` (side-by-side budget lines). |
|
||||
| `sequential_compound_usd` | Start at `baseNotionalUsdt`; after each leg multiply by `(1 + residualPct/100)` then subtract `fixedCostUsdt`. |
|
||||
|
||||
**CLI (essentials):**
|
||||
|
||||
```bash
|
||||
pnpm exec economics-toolkit strategy kinds
|
||||
pnpm exec economics-toolkit strategy template --out ./my-strategy.json
|
||||
pnpm exec economics-toolkit strategy validate --file ./my-strategy.json # parse-only (--quiet for batch/CI)
|
||||
pnpm exec economics-toolkit strategy eval --file packages/economics-toolkit/config/strategy-smoke.json
|
||||
pnpm exec economics-toolkit strategy eval --file ./my-strategy.json # exit 1 if any leg fails minProfit
|
||||
|
||||
pnpm exec economics-toolkit strategy optimize --file ./my-strategy.json --leg open_flash_arb \
|
||||
--gross-min 0.15 --gross-max 0.45 --step 0.05 --objective max_linear_residual
|
||||
pnpm exec economics-toolkit strategy optimize-multi --file ./my-strategy.json --dims-file packages/economics-toolkit/config/strategy-optimize-dims.example.json
|
||||
|
||||
pnpm exec economics-toolkit strategy optimize-random --file ./my-strategy.json \
|
||||
--bounds-file packages/economics-toolkit/config/strategy-bounds.example.json --samples 400 --seed 1
|
||||
pnpm exec economics-toolkit strategy optimize-descent --file ./my-strategy.json \
|
||||
--bounds-file packages/economics-toolkit/config/strategy-bounds.example.json --rounds 8 --line-steps 12
|
||||
|
||||
pnpm exec economics-toolkit strategy enrich --file ./my-strategy.json --rpc $RPC_URL_138
|
||||
bash scripts/economics/refresh-strategy-from-live.sh ./my-strategy.json
|
||||
bash scripts/economics/refresh-strategy-from-live.sh --apply ./my-strategy.json
|
||||
|
||||
pnpm exec economics-toolkit strategy runbook --file ./my-strategy.json --format md
|
||||
pnpm exec economics-toolkit strategy exec-plan --file ./my-strategy.json
|
||||
pnpm exec economics-toolkit strategy exec-plan --file ./my-strategy.json --simulate --rpc $RPC_URL_138 \
|
||||
--from <EOA> --allowlist packages/economics-toolkit/config/executor-allowlist.example.json
|
||||
```
|
||||
|
||||
**Validation in repo:** `pnpm run economics:validate` runs [`scripts/validation/validate-economics-strategy-json.sh`](../../../scripts/validation/validate-economics-strategy-json.sh) (also invoked from [`validate-config-files.sh`](../../../scripts/validation/validate-config-files.sh)). Optional JSON Schema check requires `pip install check-jsonschema`.
|
||||
|
||||
**Artifacts:** [strategy-template.json](../config/strategy-template.json), [strategy.schema.json](../config/strategy.schema.json), [strategy-smoke.json](../config/strategy-smoke.json), [strategy-bounds.example.json](../config/strategy-bounds.example.json), [strategy-optimize-dims.example.json](../config/strategy-optimize-dims.example.json). **Source:** [`strategy-types.ts`](../src/strategy-types.ts), [`strategy-engine.ts`](../src/strategy-engine.ts), [`strategy-expr.ts`](../src/strategy-expr.ts).
|
||||
|
||||
**Automation vs manual:** **`enrich`** and **`refresh-strategy-from-live.sh`** fill **`grossPct`** / **`gasPctOfNotional`** when `enrichPathCheck` / `enrichGas` are present. Deep **AMM IL curves**, **MEV equilibrium**, and **liquidation games** are not closed-form in the engine—use **`ilDragBps`**, **`mevDragBps`**, **`liquidationCompetitionBps`**, or external models feeding **`grossPct`**.
|
||||
|
||||
### J. Roadmap and repo-wide TODOs
|
||||
|
||||
- **Toolkit:** Optional future work: closed-form IL from pool state; margin **APR** / **LTV** as first-class legs (today: extend your own model per section F). **`exec-plan`** only **simulates**; production sends remain **`economics-toolkit exec --apply`** with an allowlist. Multi-step EOA flows require **one nonce per tx** and dependency ordering—`exec-plan` JSON includes a **`sequencingNote`** on each step. Large **`optimize-descent`** runs scale with `rounds × bounds × lineGridSteps`.
|
||||
- **Workspace:** Broader deployments and operators: [`docs/00-meta/TODOS_CONSOLIDATED.md`](../../../docs/00-meta/TODOS_CONSOLIDATED.md) and related meta docs.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": "1",
|
||||
"description": "Glue checklist: pool topology and operator runbooks (no automatic Proxmox mutations).",
|
||||
"references": {
|
||||
"pool_matrix": "cross-chain-pmm-lps/config/pool-matrix.json",
|
||||
"deployment_status": "cross-chain-pmm-lps/config/deployment-status.json",
|
||||
"simulation_model": "cross-chain-pmm-lps/docs/08-simulation-model.md",
|
||||
"bot_policy": "cross-chain-pmm-lps/docs/04-bot-policy.md"
|
||||
},
|
||||
"checklist": [
|
||||
{
|
||||
"id": "cw-bootstrap-mainnet",
|
||||
"summary": "cW* PMM USD pools and mesh",
|
||||
"runbooks": [
|
||||
"docs/03-deployment/PMM_POOLS_FUNDING_PLAN.md",
|
||||
"docs/03-deployment/ADD_LIQUIDITY_PMM_CHAIN138_RUNBOOK.md",
|
||||
"docs/11-references/DEPLOYED_TOKENS_BRIDGES_LPS_AND_ROUTING_STATUS.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cross-chain-pmm-sim",
|
||||
"summary": "Run scenario scorecard after deployment changes",
|
||||
"script": "scripts/economics/run-cross-chain-pmm-scenario.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
packages/economics-toolkit/package.json
Normal file
26
packages/economics-toolkit/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@proxmox/economics-toolkit",
|
||||
"version": "0.1.0",
|
||||
"description": "DeFi economics calculator, Chain 138 path gate, dry-run executor, liquidity orchestration helpers",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"economics-toolkit": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --test dist/*.test.js dist/swap-engine/*.test.js",
|
||||
"test:ci": "pnpm run build && node --test dist/*.test.js dist/swap-engine/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethers": "^6.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.33",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
24
packages/economics-toolkit/src/alerts.ts
Normal file
24
packages/economics-toolkit/src/alerts.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Optional webhook when a path clears economics (operator configures URL).
|
||||
*/
|
||||
export async function postAlertIfConfigured(
|
||||
webhookUrl: string | undefined,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<{ sent: boolean; error?: string }> {
|
||||
if (!webhookUrl || webhookUrl.length < 8) {
|
||||
return { sent: false };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'economics-toolkit', ...payload }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { sent: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
return { sent: true };
|
||||
} catch (e) {
|
||||
return { sent: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
40
packages/economics-toolkit/src/chain138-pmm-quote.ts
Normal file
40
packages/economics-toolkit/src/chain138-pmm-quote.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* On-chain DVM quote for Chain 138 PMM pools (aligned with
|
||||
* smom-dbis-138/services/token-aggregation/src/services/pmm-onchain-quote.ts).
|
||||
*/
|
||||
import { Contract, JsonRpcProvider } from 'ethers';
|
||||
|
||||
const POOL_ABI = [
|
||||
'function _BASE_TOKEN_() view returns (address)',
|
||||
'function _QUOTE_TOKEN_() view returns (address)',
|
||||
'function querySellBase(address,uint256) view returns (uint256,uint256)',
|
||||
'function querySellQuote(address,uint256) view returns (uint256,uint256)',
|
||||
];
|
||||
|
||||
export async function pmmQuoteAmountOutFromChain(params: {
|
||||
rpcUrl: string;
|
||||
poolAddress: string;
|
||||
tokenInLookup: string;
|
||||
amountIn: bigint;
|
||||
traderForView: string;
|
||||
}): Promise<bigint | null> {
|
||||
const { rpcUrl, poolAddress, tokenInLookup, amountIn, traderForView } = params;
|
||||
try {
|
||||
const provider = new JsonRpcProvider(rpcUrl);
|
||||
const pool = new Contract(poolAddress, POOL_ABI, provider);
|
||||
const base = (await pool._BASE_TOKEN_()).toString().toLowerCase();
|
||||
const quote = (await pool._QUOTE_TOKEN_()).toString().toLowerCase();
|
||||
const ti = tokenInLookup.toLowerCase();
|
||||
if (ti === base) {
|
||||
const [out] = await pool.querySellBase(traderForView, amountIn);
|
||||
return BigInt(out.toString());
|
||||
}
|
||||
if (ti === quote) {
|
||||
const [out] = await pool.querySellQuote(traderForView, amountIn);
|
||||
return BigInt(out.toString());
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
992
packages/economics-toolkit/src/cli.ts
Normal file
992
packages/economics-toolkit/src/cli.ts
Normal file
@@ -0,0 +1,992 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* economics-toolkit CLI — calc | path-check | swap-quote | exec | gas-quote | gas-budget | strategy | liquidity-print
|
||||
*/
|
||||
import { evaluateEconomics } from './economics-engine.js';
|
||||
import { evaluatePathGate } from './path-gate.js';
|
||||
import {
|
||||
enforceAllowlistAndSimulate,
|
||||
broadcastIfApply,
|
||||
encodeSwapExactInCalldata,
|
||||
} from './executor.js';
|
||||
import { emitMetric } from './metrics.js';
|
||||
import { postAlertIfConfigured } from './alerts.js';
|
||||
import { readFileSync, copyFileSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { quoteAllConfiguredGas } from './gas-realtime.js';
|
||||
import { computeGasBudgetRounds, addressFromPrivateKeyEnv } from './gas-budget-rounds.js';
|
||||
import { STRATEGY_LEG_KINDS, STRATEGY_LEG_DESCRIPTIONS } from './strategy-types.js';
|
||||
import { evaluateStrategy, optimizeGrossForLeg, optimizeStrategyMultiDim } from './strategy-engine.js';
|
||||
import { parseStrategyJson } from './strategy-io.js';
|
||||
import { enrichStrategy } from './strategy-enrich.js';
|
||||
import { buildStrategyRunbook } from './strategy-runbook.js';
|
||||
import type { StrategyOptimizerDimension } from './strategy-types.js';
|
||||
import {
|
||||
optimizeStrategyRandomSearch,
|
||||
optimizeStrategyCoordinateDescent,
|
||||
type OptimizerBound,
|
||||
} from './strategy-optimize-stochastic.js';
|
||||
import { buildExecPlan, simulateExecPlan } from './strategy-exec-plan.js';
|
||||
import { quoteSwap, quoteSwapFromEngines } from './swap-engine/swap-quote-router.js';
|
||||
import { evaluateSwapPathGate } from './swap-engine/swap-path-gate.js';
|
||||
import { DEFAULT_SWAP_QUOTE_ENGINES, type SwapQuoteEngine, type SwapQuoteRequest } from './swap-engine/types.js';
|
||||
|
||||
const SWAP_ENGINES: readonly SwapQuoteEngine[] = [
|
||||
'uniswap-v3',
|
||||
'oneinch',
|
||||
'curve',
|
||||
'balancer',
|
||||
'dodo',
|
||||
'aave',
|
||||
'compound',
|
||||
] as const;
|
||||
|
||||
function parseEnginesCsv(csv: string): SwapQuoteEngine[] {
|
||||
const parts = csv
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const out: SwapQuoteEngine[] = [];
|
||||
for (const e of parts) {
|
||||
if (!SWAP_ENGINES.includes(e as SwapQuoteEngine)) {
|
||||
console.error(`swap-quote: unknown engine "${e}"; expected one of: ${SWAP_ENGINES.join(', ')}`);
|
||||
process.exit(2);
|
||||
}
|
||||
out.push(e as SwapQuoteEngine);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function argVal(args: string[], key: string): string | undefined {
|
||||
const i = args.indexOf(key);
|
||||
if (i >= 0 && args[i + 1]) return args[i + 1];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasFlag(args: string[], key: string): boolean {
|
||||
return args.includes(key);
|
||||
}
|
||||
|
||||
function parseBoundsPayload(body: unknown): {
|
||||
bounds: OptimizerBound[];
|
||||
samples?: number;
|
||||
seed?: number;
|
||||
rounds?: number;
|
||||
lineGridSteps?: number;
|
||||
} {
|
||||
if (!body || typeof body !== 'object') throw new Error('bounds file: invalid JSON');
|
||||
const j = body as Record<string, unknown>;
|
||||
const bounds = j.bounds;
|
||||
if (!Array.isArray(bounds) || bounds.length === 0) {
|
||||
throw new Error('bounds file: need non-empty bounds[]');
|
||||
}
|
||||
for (const b of bounds) {
|
||||
const x = b as Record<string, unknown>;
|
||||
if (typeof x.legId !== 'string' || typeof x.param !== 'string') {
|
||||
throw new Error('each bound needs legId (string) and param (string)');
|
||||
}
|
||||
if (typeof x.min !== 'number' || typeof x.max !== 'number') {
|
||||
throw new Error('each bound needs min and max (numbers)');
|
||||
}
|
||||
}
|
||||
return {
|
||||
bounds: bounds as OptimizerBound[],
|
||||
samples: typeof j.samples === 'number' ? j.samples : undefined,
|
||||
seed: typeof j.seed === 'number' ? j.seed : undefined,
|
||||
rounds: typeof j.rounds === 'number' ? j.rounds : undefined,
|
||||
lineGridSteps: typeof j.lineGridSteps === 'number' ? j.lineGridSteps : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function usage(): void {
|
||||
console.log(`
|
||||
economics-toolkit — DeFi economics & execution helpers
|
||||
|
||||
economics-toolkit calc --gross <pct> --flash <pct> [--gas <pct>] [--liquidity <pct>] [--min-profit <pct>]
|
||||
|
||||
economics-toolkit path-check --rpc <url> --pool <addr> --token-in <addr> --amount-in <wei>
|
||||
--flash <pct> [--gas <pct>] [--liquidity <pct>] [--min-profit <pct>]
|
||||
[--trader <addr>] [--decimals 6]
|
||||
|
||||
economics-toolkit swap-quote --chain-id <n> --rpc <url> --token-in <addr> --token-out <addr> --amount-in <wei>
|
||||
--engine uniswap-v3|oneinch|curve|balancer|dodo|aave|compound
|
||||
[--fee <tier>] | [--fees 500,3000,...] [--path-tokens <a>,<b>,...] [--quoter <addr>]
|
||||
[--oneinch-key <key>] (or ONEINCH_API_KEY; optional ONEINCH_API_URL)
|
||||
[--engines all|eng1,eng2,...] (compare quotes from multiple sources; not with --gate)
|
||||
[--curve-rate-provider <addr>] [--balancer-sor-chain MAINNET|...]
|
||||
[--dodo-key <k>] [--dodo-user <0x>] (or DODO_API_KEY)
|
||||
[--paraswap-key <k>] (optional ParaSwap / Velora)
|
||||
[--zeroex-key <k>] (or ZERO_EX_API_KEY for 0x)
|
||||
[--no-usd-gross] (skip CoinGecko; impliedGrossPct = human-unit ratio only)
|
||||
[--gate --flash <pct> [--gas <pct>] [--liquidity <pct>] [--min-profit <pct>]] (economics gate like path-check)
|
||||
|
||||
economics-toolkit exec --rpc <url> --from <addr> --to <addr> --data <0x...> --allowlist <path>
|
||||
[--apply] (requires PRIVATE_KEY when --apply; default dry-run)
|
||||
|
||||
economics-toolkit prepare-swap --pool <addr> --token-in <addr> --amount-in <wei> --min-out <wei>
|
||||
(prints calldata; integrate with exec --to <DODOPMMIntegration>)
|
||||
|
||||
economics-toolkit liquidity-print [--checklist <path>]
|
||||
|
||||
economics-toolkit gas-quote [--chains 1,56,137,138] [--gas-units 250000]
|
||||
[--notional-usdt 100] (optional: gas as percent of notional; uses CoinGecko USD unless ECONOMICS_GAS_SKIP_USD=1)
|
||||
|
||||
economics-toolkit gas-budget --rpc <url> [--address <0x>] [--gas-units 250000]
|
||||
(address from --address or PRIVATE_KEY / ECONOMICS_GAS_BUDGET_PRIVATE_KEY; rounds at 25/50/75/100% of balance)
|
||||
|
||||
economics-toolkit strategy kinds (all leg kinds + descriptions)
|
||||
economics-toolkit strategy template [--out <path>] (copy config/strategy-template.json)
|
||||
economics-toolkit strategy eval --file <strategy.json>
|
||||
economics-toolkit strategy optimize --file <strategy.json> --leg <id> --gross-min <n> --gross-max <n> --step <n>
|
||||
[--objective max_linear_residual|max_compound_return|all_pass]
|
||||
economics-toolkit strategy optimize-multi --file <strategy.json> --dims-file <dims.json>
|
||||
[--objective ...] [--max-combinations 5000]
|
||||
economics-toolkit strategy enrich --file <strategy.json> [--rpc <url>] [--write] [--out <path>] [--skip-usd]
|
||||
economics-toolkit strategy runbook --file <strategy.json> [--format json|md]
|
||||
economics-toolkit strategy optimize-random --file <s.json> --bounds-file <b.json>
|
||||
[--samples N] [--seed N] [--objective ...]
|
||||
economics-toolkit strategy optimize-descent --file <s.json> --bounds-file <b.json>
|
||||
[--rounds N] [--line-steps N] [--objective ...]
|
||||
economics-toolkit strategy exec-plan --file <s.json> [--simulate --rpc <url> --from <addr> --allowlist <path>]
|
||||
economics-toolkit strategy validate --file <strategy.json> [--quiet] (parse-only; use eval for economics)
|
||||
|
||||
Environment:
|
||||
RPC_URL_138 Default RPC for path-check and Chain 138 gas-quote
|
||||
ECONOMICS_GAS_RPC_<chainId> Override RPC for gas-quote (e.g. ECONOMICS_GAS_RPC_1=)
|
||||
ECONOMICS_GAS_SKIP_USD=1 Skip CoinGecko; native-only costs
|
||||
PRIVATE_KEY Required for exec --apply
|
||||
ECONOMICS_EXEC_APPLY=1 Same as --apply when set
|
||||
ECONOMICS_ALERT_WEBHOOK POST JSON when path-check passes gate (optional)
|
||||
ONEINCH_API_KEY Default API key for swap-quote --engine oneinch
|
||||
DODO_API_KEY DODO SmartTrade (--engine dodo)
|
||||
ZERO_EX_API_KEY 0x Swap API (--engine compound)
|
||||
PARASWAP_API_KEY Optional ParaSwap (--engine aave)
|
||||
ECONOMICS_SWAP_QUOTE_SKIP_USD=1 Skip CoinGecko USD gross (same as --no-usd-gross)
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || hasFlag(args, '-h') || hasFlag(args, '--help')) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const cmd = args[0];
|
||||
|
||||
if (cmd === 'calc') {
|
||||
const gross = parseFloat(argVal(args, '--gross') ?? 'NaN');
|
||||
const flash = parseFloat(argVal(args, '--flash') ?? 'NaN');
|
||||
const gas = parseFloat(argVal(args, '--gas') ?? '0');
|
||||
const liq = parseFloat(argVal(args, '--liquidity') ?? '0');
|
||||
const minP = parseFloat(argVal(args, '--min-profit') ?? '0');
|
||||
if (Number.isNaN(gross) || Number.isNaN(flash)) {
|
||||
console.error('calc requires --gross and --flash');
|
||||
process.exit(2);
|
||||
}
|
||||
const r = evaluateEconomics({ grossPct: gross, flashFeePct: flash, gasPctOfNotional: gas, liquidityPct: liq, minProfitPct: minP });
|
||||
emitMetric('economics_calc', {
|
||||
grossPct: gross,
|
||||
flashFeePct: flash,
|
||||
netAfterFlashPct: r.netAfterFlashPct,
|
||||
residualPct: r.residualPct,
|
||||
passesResidual: r.passesResidual,
|
||||
});
|
||||
console.log(JSON.stringify({ ...r, sensitivity: r.sensitivity }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cmd === 'path-check') {
|
||||
const rpc = argVal(args, '--rpc') ?? process.env.RPC_URL_138 ?? 'https://rpc-http-pub.d-bis.org';
|
||||
const pool = argVal(args, '--pool') ?? '0x9e89bAe009adf128782E19E8341996c596ac40dC';
|
||||
const tokenIn = argVal(args, '--token-in');
|
||||
const amountInStr = argVal(args, '--amount-in');
|
||||
const flash = parseFloat(argVal(args, '--flash') ?? '0.09');
|
||||
const gas = parseFloat(argVal(args, '--gas') ?? '0.1');
|
||||
const liq = parseFloat(argVal(args, '--liquidity') ?? '0.01');
|
||||
const minP = parseFloat(argVal(args, '--min-profit') ?? '0');
|
||||
const trader = argVal(args, '--trader') ?? '0x4A666F96fC8764181194447A7dFdb7d471b301C8';
|
||||
const decimals = parseInt(argVal(args, '--decimals') ?? '6', 10);
|
||||
|
||||
if (!tokenIn || !amountInStr) {
|
||||
console.error('path-check requires --token-in and --amount-in');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const amountIn = BigInt(amountInStr);
|
||||
const result = await evaluatePathGate({
|
||||
rpcUrl: rpc,
|
||||
poolAddress: pool,
|
||||
tokenIn,
|
||||
amountIn,
|
||||
traderForView: trader,
|
||||
decimals,
|
||||
economics: { flashFeePct: flash, gasPctOfNotional: gas, liquidityPct: liq, minProfitPct: minP },
|
||||
});
|
||||
|
||||
emitMetric('path_check', {
|
||||
impliedGrossPct: result.impliedGrossPct,
|
||||
passesEconomicsGate: result.passesEconomicsGate,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(result, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2));
|
||||
|
||||
const hook = process.env.ECONOMICS_ALERT_WEBHOOK;
|
||||
if (result.passesEconomicsGate && hook) {
|
||||
const ar = await postAlertIfConfigured(hook, {
|
||||
impliedGrossPct: result.impliedGrossPct,
|
||||
amountOut: result.amountOut?.toString(),
|
||||
pool,
|
||||
rpc,
|
||||
});
|
||||
emitMetric('alert_webhook', { sent: ar.sent, error: ar.error });
|
||||
}
|
||||
process.exit(result.passesEconomicsGate ? 0 : 1);
|
||||
}
|
||||
|
||||
if (cmd === 'swap-quote') {
|
||||
const chainIdStr = argVal(args, '--chain-id');
|
||||
const rpc = argVal(args, '--rpc')?.trim();
|
||||
const tokenIn = argVal(args, '--token-in');
|
||||
const tokenOut = argVal(args, '--token-out');
|
||||
const amountInStr = argVal(args, '--amount-in');
|
||||
const enginesCsv = argVal(args, '--engines');
|
||||
const engineRaw = (argVal(args, '--engine') ?? 'uniswap-v3').trim() as SwapQuoteEngine;
|
||||
const quoter = argVal(args, '--quoter');
|
||||
const oneInchKey = argVal(args, '--oneinch-key');
|
||||
const feesCsv = argVal(args, '--fees');
|
||||
const feeOne = argVal(args, '--fee');
|
||||
const pathTokCsv = argVal(args, '--path-tokens');
|
||||
const gate = hasFlag(args, '--gate');
|
||||
const flash = parseFloat(argVal(args, '--flash') ?? 'NaN');
|
||||
const gas = parseFloat(argVal(args, '--gas') ?? '0.1');
|
||||
const liq = parseFloat(argVal(args, '--liquidity') ?? '0.01');
|
||||
const minP = parseFloat(argVal(args, '--min-profit') ?? '0');
|
||||
const curveRateProvider = argVal(args, '--curve-rate-provider');
|
||||
const balancerSorChain = argVal(args, '--balancer-sor-chain');
|
||||
const dodoKey = argVal(args, '--dodo-key');
|
||||
const dodoUser = argVal(args, '--dodo-user');
|
||||
const paraswapKey = argVal(args, '--paraswap-key');
|
||||
const zeroexKey = argVal(args, '--zeroex-key');
|
||||
const noUsdGross = hasFlag(args, '--no-usd-gross');
|
||||
|
||||
if (!chainIdStr || !rpc || !tokenIn || !tokenOut || !amountInStr) {
|
||||
console.error('swap-quote requires --chain-id --rpc --token-in --token-out --amount-in');
|
||||
process.exit(2);
|
||||
}
|
||||
const chainId = parseInt(chainIdStr, 10);
|
||||
if (Number.isNaN(chainId)) {
|
||||
console.error('swap-quote: --chain-id must be an integer');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let amountIn: bigint;
|
||||
try {
|
||||
amountIn = BigInt(amountInStr);
|
||||
} catch {
|
||||
console.error('swap-quote: --amount-in must be an integer (wei)');
|
||||
process.exit(2);
|
||||
}
|
||||
if (amountIn <= 0n) {
|
||||
console.error('swap-quote: --amount-in must be positive');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let v3Fees: number[] | undefined;
|
||||
if (feesCsv) {
|
||||
v3Fees = feesCsv
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
if (v3Fees.length === 0) {
|
||||
console.error('swap-quote: --fees must list at least one integer fee tier');
|
||||
process.exit(2);
|
||||
}
|
||||
} else if (feeOne) {
|
||||
const f = parseInt(feeOne, 10);
|
||||
if (Number.isNaN(f)) {
|
||||
console.error('swap-quote: --fee must be an integer (e.g. 3000)');
|
||||
process.exit(2);
|
||||
}
|
||||
v3Fees = [f];
|
||||
}
|
||||
|
||||
const v3PathTokens = pathTokCsv
|
||||
? pathTokCsv
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const baseRequestFields = {
|
||||
chainId,
|
||||
rpcUrl: rpc,
|
||||
tokenIn,
|
||||
tokenOut,
|
||||
amountIn,
|
||||
v3Fees,
|
||||
v3PathTokens,
|
||||
quoterV2Address: quoter,
|
||||
oneInchApiKey: oneInchKey,
|
||||
curveRateProviderAddress: curveRateProvider,
|
||||
balancerSorChain,
|
||||
dodoApiKey: dodoKey,
|
||||
dodoUserAddress: dodoUser,
|
||||
paraswapApiKey: paraswapKey,
|
||||
zeroExApiKey: zeroexKey,
|
||||
usdNormalizeGross: noUsdGross ? false : undefined,
|
||||
};
|
||||
|
||||
if (enginesCsv?.trim()) {
|
||||
if (gate) {
|
||||
console.error('swap-quote: --gate cannot be used with --engines (use a single --engine)');
|
||||
process.exit(2);
|
||||
}
|
||||
const engines =
|
||||
enginesCsv.trim().toLowerCase() === 'all'
|
||||
? [...DEFAULT_SWAP_QUOTE_ENGINES]
|
||||
: parseEnginesCsv(enginesCsv);
|
||||
const rows = await quoteSwapFromEngines(baseRequestFields, engines);
|
||||
const anyOk = rows.some((r) => r.result.ok);
|
||||
emitMetric('swap_quote', {
|
||||
chainId,
|
||||
multi: true,
|
||||
engines: engines.join(','),
|
||||
anyOk,
|
||||
okCount: rows.filter((r) => r.result.ok).length,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ quotes: rows },
|
||||
(_, v) => (typeof v === 'bigint' ? v.toString() : v),
|
||||
2
|
||||
)
|
||||
);
|
||||
process.exit(anyOk ? 0 : 1);
|
||||
}
|
||||
|
||||
if (!SWAP_ENGINES.includes(engineRaw)) {
|
||||
console.error(`swap-quote: --engine must be one of: ${SWAP_ENGINES.join(', ')}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const request: SwapQuoteRequest = {
|
||||
...baseRequestFields,
|
||||
engine: engineRaw,
|
||||
};
|
||||
|
||||
if (gate) {
|
||||
if (Number.isNaN(flash)) {
|
||||
console.error('swap-quote --gate requires --flash (percent units, same as path-check)');
|
||||
process.exit(2);
|
||||
}
|
||||
const result = await evaluateSwapPathGate({
|
||||
request,
|
||||
economics: { flashFeePct: flash, gasPctOfNotional: gas, liquidityPct: liq, minProfitPct: minP },
|
||||
});
|
||||
emitMetric('swap_quote', {
|
||||
chainId,
|
||||
engine: engineRaw,
|
||||
gate: true,
|
||||
passesEconomicsGate: result.passesEconomicsGate,
|
||||
impliedGrossPct: result.impliedGrossPct,
|
||||
quoteOk: result.quote.ok,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
result,
|
||||
(_, v) => (typeof v === 'bigint' ? v.toString() : v),
|
||||
2
|
||||
)
|
||||
);
|
||||
process.exit(result.passesEconomicsGate ? 0 : 1);
|
||||
}
|
||||
|
||||
const result = await quoteSwap(request);
|
||||
emitMetric('swap_quote', {
|
||||
chainId,
|
||||
engine: engineRaw,
|
||||
ok: result.ok,
|
||||
impliedGrossPct: result.ok ? result.impliedGrossPct : null,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
result,
|
||||
(_, v) => (typeof v === 'bigint' ? v.toString() : v),
|
||||
2
|
||||
)
|
||||
);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
if (cmd === 'prepare-swap') {
|
||||
const pool = argVal(args, '--pool');
|
||||
const tokenIn = argVal(args, '--token-in');
|
||||
const amountIn = argVal(args, '--amount-in');
|
||||
const minOut = argVal(args, '--min-out');
|
||||
if (!pool || !tokenIn || !amountIn || !minOut) {
|
||||
console.error('prepare-swap requires --pool --token-in --amount-in --min-out');
|
||||
process.exit(2);
|
||||
}
|
||||
const data = encodeSwapExactInCalldata(pool, tokenIn, BigInt(amountIn), BigInt(minOut));
|
||||
console.log(JSON.stringify({ data }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cmd === 'exec') {
|
||||
const rpc = argVal(args, '--rpc') ?? process.env.RPC_URL_138 ?? '';
|
||||
const from = argVal(args, '--from') ?? '';
|
||||
const to = argVal(args, '--to') ?? '';
|
||||
const data = argVal(args, '--data') ?? '';
|
||||
const allowlist = argVal(args, '--allowlist') ?? '';
|
||||
const apply = hasFlag(args, '--apply') || process.env.ECONOMICS_EXEC_APPLY === '1';
|
||||
|
||||
if (!rpc || !from || !to || !data || !allowlist) {
|
||||
console.error('exec requires --rpc --from --to --data --allowlist');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let dataNorm = data;
|
||||
if (!dataNorm.startsWith('0x')) dataNorm = `0x${dataNorm}`;
|
||||
|
||||
const sim = await enforceAllowlistAndSimulate({
|
||||
rpcUrl: rpc,
|
||||
allowlistPath: allowlist,
|
||||
from,
|
||||
to,
|
||||
data: dataNorm,
|
||||
});
|
||||
|
||||
emitMetric('exec_sim', {
|
||||
ok: sim.ok,
|
||||
allowlistOk: sim.allowlistOk,
|
||||
gasEstimate: sim.gasEstimate?.toString(),
|
||||
error: sim.error ?? sim.allowlistError,
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
simulation: sim,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
if (!sim.ok || !sim.allowlistOk) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log('Dry-run only (omit success broadcast). Pass --apply or ECONOMICS_EXEC_APPLY=1 to send.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const pk = process.env.PRIVATE_KEY ?? process.env.ECONOMICS_EXEC_PRIVATE_KEY;
|
||||
if (!pk) {
|
||||
console.error('PRIVATE_KEY or ECONOMICS_EXEC_PRIVATE_KEY required for --apply');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const br = await broadcastIfApply({
|
||||
rpcUrl: rpc,
|
||||
privateKey: pk,
|
||||
allowlistPath: allowlist,
|
||||
to,
|
||||
data: dataNorm,
|
||||
});
|
||||
emitMetric('exec_broadcast', { hash: br.hash, error: br.error });
|
||||
console.log(JSON.stringify({ broadcast: br }, null, 2));
|
||||
process.exit(br.hash ? 0 : 1);
|
||||
}
|
||||
|
||||
if (cmd === 'gas-quote') {
|
||||
const chainsRaw = argVal(args, '--chains');
|
||||
const gasUnitsStr = argVal(args, '--gas-units') ?? '250000';
|
||||
const notionalStr = argVal(args, '--notional-usdt');
|
||||
const skipUsd = hasFlag(args, '--skip-usd') || process.env.ECONOMICS_GAS_SKIP_USD === '1';
|
||||
|
||||
let chainIds: number[] | undefined;
|
||||
if (chainsRaw) {
|
||||
chainIds = chainsRaw
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
}
|
||||
|
||||
const gasUnits = BigInt(gasUnitsStr);
|
||||
const notionalUsdt = notionalStr != null ? parseFloat(notionalStr) : undefined;
|
||||
if (notionalStr != null && (notionalUsdt === undefined || Number.isNaN(notionalUsdt) || notionalUsdt <= 0)) {
|
||||
console.error('gas-quote: --notional-usdt must be a positive number');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const rows = await quoteAllConfiguredGas({
|
||||
chainIds,
|
||||
gasUnits,
|
||||
notionalUsdt,
|
||||
skipUsd,
|
||||
});
|
||||
|
||||
emitMetric('gas_quote', {
|
||||
networks: rows.length,
|
||||
gasUnits: gasUnitsStr,
|
||||
notionalUsdt: notionalUsdt ?? null,
|
||||
});
|
||||
|
||||
const out = rows.map((r) => ({
|
||||
chainId: r.chainId,
|
||||
name: r.name,
|
||||
rpcUrl: r.rpcUrl,
|
||||
nativeSymbol: r.nativeSymbol,
|
||||
effectiveGasPriceWei: r.effectiveGasPriceWei,
|
||||
gasUnits: r.gasUnits,
|
||||
txCostNative: r.txCostNative,
|
||||
txCostUsd: r.txCostUsd,
|
||||
gasPctOfNotional: r.gasPctOfNotional,
|
||||
usdPerNative: r.usdPerNative,
|
||||
error: r.error,
|
||||
}));
|
||||
|
||||
console.log(JSON.stringify({ gasUnits: gasUnitsStr, notionalUsdt: notionalUsdt ?? null, networks: out }, null, 2));
|
||||
const failed = rows.filter((r) => r.error);
|
||||
process.exit(failed.length === rows.length ? 1 : 0);
|
||||
}
|
||||
|
||||
if (cmd === 'gas-budget') {
|
||||
const rpc = argVal(args, '--rpc') ?? process.env.RPC_URL_138 ?? '';
|
||||
const gasUnitsStr = argVal(args, '--gas-units') ?? '250000';
|
||||
let address = argVal(args, '--address');
|
||||
if (!address) {
|
||||
const pk = process.env.PRIVATE_KEY ?? process.env.ECONOMICS_GAS_BUDGET_PRIVATE_KEY;
|
||||
if (pk) {
|
||||
try {
|
||||
address = addressFromPrivateKeyEnv(pk);
|
||||
} catch {
|
||||
console.error('gas-budget: invalid PRIVATE_KEY / ECONOMICS_GAS_BUDGET_PRIVATE_KEY');
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!rpc.startsWith('http')) {
|
||||
console.error('gas-budget: --rpc or RPC_URL_138 required');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!address) {
|
||||
console.error('gas-budget: --address <0x…> or set PRIVATE_KEY / ECONOMICS_GAS_BUDGET_PRIVATE_KEY');
|
||||
process.exit(2);
|
||||
}
|
||||
let gasUnits: bigint;
|
||||
try {
|
||||
gasUnits = BigInt(gasUnitsStr);
|
||||
} catch {
|
||||
console.error('gas-budget: --gas-units must be an integer');
|
||||
process.exit(2);
|
||||
}
|
||||
if (gasUnits <= 0n) {
|
||||
console.error('gas-budget: --gas-units must be positive');
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
const result = await computeGasBudgetRounds({ rpcUrl: rpc.trim(), address, gasUnits });
|
||||
emitMetric('gas_budget', { chainId: result.chainId, address: result.address });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd === 'strategy') {
|
||||
const sub = args[1];
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const templatePath = join(here, '../config/strategy-template.json');
|
||||
|
||||
if (!sub || sub === '-h' || sub === '--help') {
|
||||
console.log(`
|
||||
strategy subcommands:
|
||||
kinds List leg kinds (for JSON "kind" field)
|
||||
template [--out f] Write starter strategy JSON (default: ./strategy-from-template.json)
|
||||
eval --file f Evaluate multi-leg strategy (rollup + compound metrics)
|
||||
optimize ... Grid-search grossPct on one leg id
|
||||
optimize-multi ... Cartesian grid on multiple leg params (see --dims-file)
|
||||
enrich ... Fill grossPct / gasPct from path-check + gas-quote (optional --write)
|
||||
runbook ... Suggested operator commands per leg (json or markdown)
|
||||
optimize-random ... Joint search via random sampling (bounds-file)
|
||||
optimize-descent ... Joint coordinate descent on a grid (bounds-file)
|
||||
exec-plan ... Materialize leg.exec; optional --simulate (allowlist + eth_call only)
|
||||
validate ... Parse strategy JSON only (no economics eval)
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'kinds') {
|
||||
const rows = STRATEGY_LEG_KINDS.map((k) => ({ kind: k, description: STRATEGY_LEG_DESCRIPTIONS[k] }));
|
||||
console.log(JSON.stringify({ kinds: rows }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'template') {
|
||||
const outPath = argVal(args, '--out') ?? join(process.cwd(), 'strategy-from-template.json');
|
||||
try {
|
||||
copyFileSync(templatePath, outPath);
|
||||
console.log(JSON.stringify({ written: outPath, source: templatePath }, null, 2));
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'validate') {
|
||||
const file = argVal(args, '--file');
|
||||
const quiet = hasFlag(args, '--quiet');
|
||||
if (!file) {
|
||||
console.error('strategy validate requires --file');
|
||||
process.exit(2);
|
||||
}
|
||||
let def;
|
||||
try {
|
||||
def = parseStrategyJson(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!quiet) {
|
||||
emitMetric('strategy_validate', { name: def.name, legs: def.legs.length, file });
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
file,
|
||||
name: def.name,
|
||||
legs: def.legs.length,
|
||||
aggregateMode: def.aggregateMode,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'eval') {
|
||||
const file = argVal(args, '--file');
|
||||
if (!file) {
|
||||
console.error('strategy eval requires --file');
|
||||
process.exit(2);
|
||||
}
|
||||
let def;
|
||||
try {
|
||||
def = parseStrategyJson(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const result = evaluateStrategy(def);
|
||||
emitMetric('strategy_eval', {
|
||||
name: result.name,
|
||||
legs: result.legs.length,
|
||||
allLegsPass: result.allLegsPass,
|
||||
compoundReturnPct: result.impliedTotalReturnPctCompound,
|
||||
});
|
||||
const serial = {
|
||||
...result,
|
||||
legs: result.legs.map((l) => ({
|
||||
...l,
|
||||
grossEffectivePct: l.grossEffectivePct,
|
||||
economics: l.economics,
|
||||
})),
|
||||
};
|
||||
console.log(JSON.stringify(serial, null, 2));
|
||||
process.exit(result.allLegsPass ? 0 : 1);
|
||||
}
|
||||
|
||||
if (sub === 'optimize') {
|
||||
const file = argVal(args, '--file');
|
||||
const legId = argVal(args, '--leg');
|
||||
const gmin = parseFloat(argVal(args, '--gross-min') ?? 'NaN');
|
||||
const gmax = parseFloat(argVal(args, '--gross-max') ?? 'NaN');
|
||||
const step = parseFloat(argVal(args, '--step') ?? 'NaN');
|
||||
const objective =
|
||||
(argVal(args, '--objective') as 'max_linear_residual' | 'max_compound_return' | 'all_pass') ??
|
||||
'max_linear_residual';
|
||||
if (!file || !legId || Number.isNaN(gmin) || Number.isNaN(gmax) || Number.isNaN(step) || step <= 0) {
|
||||
console.error('strategy optimize requires --file --leg --gross-min --gross-max --step (step>0)');
|
||||
process.exit(2);
|
||||
}
|
||||
let def;
|
||||
try {
|
||||
def = parseStrategyJson(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const grid = optimizeGrossForLeg({
|
||||
def,
|
||||
legId,
|
||||
grossMin: gmin,
|
||||
grossMax: gmax,
|
||||
step,
|
||||
objective,
|
||||
});
|
||||
emitMetric('strategy_optimize', { legId, gridPoints: grid.length, objective });
|
||||
const top = grid.slice(0, 15).map((g) => ({
|
||||
grossPct: g.grossPct,
|
||||
allLegsPass: g.result.allLegsPass,
|
||||
totalResidualPctLinear: g.result.totalResidualPctLinear,
|
||||
impliedTotalReturnPctCompound: g.result.impliedTotalReturnPctCompound,
|
||||
finalNotionalUsdtCompound: g.result.finalNotionalUsdtCompound,
|
||||
}));
|
||||
console.log(JSON.stringify({ objective, legId, topCandidates: top, totalGridPoints: grid.length }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'optimize-multi') {
|
||||
const file = argVal(args, '--file');
|
||||
const dimsPath = argVal(args, '--dims-file');
|
||||
const objective =
|
||||
(argVal(args, '--objective') as 'max_linear_residual' | 'max_compound_return' | 'all_pass') ??
|
||||
'max_linear_residual';
|
||||
const maxCombStr = argVal(args, '--max-combinations');
|
||||
const maxCombinations = maxCombStr ? parseInt(maxCombStr, 10) : undefined;
|
||||
if (!file || !dimsPath || (maxCombStr != null && Number.isNaN(maxCombinations))) {
|
||||
console.error('strategy optimize-multi requires --file --dims-file; optional --max-combinations N');
|
||||
process.exit(2);
|
||||
}
|
||||
let def;
|
||||
let dimsBody: { dimensions?: StrategyOptimizerDimension[]; maxCombinations?: number };
|
||||
try {
|
||||
def = parseStrategyJson(readFileSync(file, 'utf8'));
|
||||
dimsBody = JSON.parse(readFileSync(dimsPath, 'utf8')) as typeof dimsBody;
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const dimensions = dimsBody.dimensions;
|
||||
if (!Array.isArray(dimensions) || dimensions.length === 0) {
|
||||
console.error('dims-file must include non-empty "dimensions" array');
|
||||
process.exit(2);
|
||||
}
|
||||
for (const d of dimensions) {
|
||||
if (!d.legId || !d.param || typeof d.min !== 'number' || typeof d.max !== 'number' || typeof d.step !== 'number') {
|
||||
console.error('each dimension needs legId, param, min, max, step');
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
const grid = optimizeStrategyMultiDim({
|
||||
def,
|
||||
dimensions,
|
||||
objective,
|
||||
maxCombinations: maxCombinations ?? dimsBody.maxCombinations,
|
||||
});
|
||||
emitMetric('strategy_optimize_multi', { dimensions: dimensions.length, combos: grid.length, objective });
|
||||
const top = grid.slice(0, 20).map((g) => ({
|
||||
values: g.values,
|
||||
allLegsPass: g.result.allLegsPass,
|
||||
totalResidualPctLinear: g.result.totalResidualPctLinear,
|
||||
impliedTotalReturnPctCompound: g.result.impliedTotalReturnPctCompound,
|
||||
finalNotionalUsdtCompound: g.result.finalNotionalUsdtCompound,
|
||||
}));
|
||||
console.log(JSON.stringify({ objective, topCandidates: top, totalCombinations: grid.length }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'enrich') {
|
||||
const file = argVal(args, '--file');
|
||||
const rpc = argVal(args, '--rpc') ?? process.env.RPC_URL_138 ?? 'https://rpc-http-pub.d-bis.org';
|
||||
const write = hasFlag(args, '--write');
|
||||
const outPath = argVal(args, '--out');
|
||||
const skipUsd = hasFlag(args, '--skip-usd') || process.env.ECONOMICS_GAS_SKIP_USD === '1';
|
||||
if (!file) {
|
||||
console.error('strategy enrich requires --file');
|
||||
process.exit(2);
|
||||
}
|
||||
let def;
|
||||
try {
|
||||
def = parseStrategyJson(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const enriched = await enrichStrategy(def, { rpcUrl138: rpc, skipUsd });
|
||||
emitMetric('strategy_enrich', { legs: enriched.reports.length });
|
||||
const payload = { reports: enriched.reports, definition: enriched.definition };
|
||||
if (write || outPath) {
|
||||
const target = outPath ?? file;
|
||||
writeFileSync(target, JSON.stringify(enriched.definition, null, 2) + '\n', 'utf8');
|
||||
console.log(JSON.stringify({ written: target, reports: enriched.reports }, null, 2));
|
||||
} else {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'runbook') {
|
||||
const file = argVal(args, '--file');
|
||||
const format = argVal(args, '--format') ?? 'json';
|
||||
if (!file) {
|
||||
console.error('strategy runbook requires --file');
|
||||
process.exit(2);
|
||||
}
|
||||
let def;
|
||||
try {
|
||||
def = parseStrategyJson(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const rb = buildStrategyRunbook(def);
|
||||
emitMetric('strategy_runbook', { name: rb.name, steps: rb.steps.length });
|
||||
if (format === 'md' || format === 'markdown') {
|
||||
process.stdout.write(rb.markdown);
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(JSON.stringify(rb, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'optimize-random') {
|
||||
const file = argVal(args, '--file');
|
||||
const boundsPath = argVal(args, '--bounds-file');
|
||||
const samplesStr = argVal(args, '--samples');
|
||||
const seedStr = argVal(args, '--seed');
|
||||
const objective =
|
||||
(argVal(args, '--objective') as 'max_linear_residual' | 'max_compound_return' | 'all_pass') ??
|
||||
'max_linear_residual';
|
||||
if (!file || !boundsPath) {
|
||||
console.error('strategy optimize-random requires --file --bounds-file');
|
||||
process.exit(2);
|
||||
}
|
||||
let def;
|
||||
let meta: ReturnType<typeof parseBoundsPayload>;
|
||||
try {
|
||||
def = parseStrategyJson(readFileSync(file, 'utf8'));
|
||||
meta = parseBoundsPayload(JSON.parse(readFileSync(boundsPath, 'utf8')));
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const samples = samplesStr != null ? parseInt(samplesStr, 10) : meta.samples ?? 400;
|
||||
const seed = seedStr != null ? parseInt(seedStr, 10) : meta.seed;
|
||||
if (Number.isNaN(samples) || samples < 0) {
|
||||
console.error('--samples must be a non-negative integer');
|
||||
process.exit(2);
|
||||
}
|
||||
const out = optimizeStrategyRandomSearch({
|
||||
def,
|
||||
bounds: meta.bounds,
|
||||
samples,
|
||||
objective,
|
||||
seed: seed != null && !Number.isNaN(seed) ? seed : undefined,
|
||||
});
|
||||
emitMetric('strategy_optimize_random', { trials: out.trials, objective });
|
||||
console.log(JSON.stringify({ objective, ...out }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'optimize-descent') {
|
||||
const file = argVal(args, '--file');
|
||||
const boundsPath = argVal(args, '--bounds-file');
|
||||
const roundsStr = argVal(args, '--rounds');
|
||||
const lineStepsStr = argVal(args, '--line-steps');
|
||||
const objective =
|
||||
(argVal(args, '--objective') as 'max_linear_residual' | 'max_compound_return' | 'all_pass') ??
|
||||
'max_linear_residual';
|
||||
if (!file || !boundsPath) {
|
||||
console.error('strategy optimize-descent requires --file --bounds-file');
|
||||
process.exit(2);
|
||||
}
|
||||
let def;
|
||||
let meta: ReturnType<typeof parseBoundsPayload>;
|
||||
try {
|
||||
def = parseStrategyJson(readFileSync(file, 'utf8'));
|
||||
meta = parseBoundsPayload(JSON.parse(readFileSync(boundsPath, 'utf8')));
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const rounds = roundsStr != null ? parseInt(roundsStr, 10) : meta.rounds ?? 8;
|
||||
const lineGridSteps = lineStepsStr != null ? parseInt(lineStepsStr, 10) : meta.lineGridSteps ?? 12;
|
||||
if (Number.isNaN(rounds) || rounds < 1 || Number.isNaN(lineGridSteps) || lineGridSteps < 2) {
|
||||
console.error('--rounds >= 1 and --line-steps >= 2 required');
|
||||
process.exit(2);
|
||||
}
|
||||
const out = optimizeStrategyCoordinateDescent({
|
||||
def,
|
||||
bounds: meta.bounds,
|
||||
objective,
|
||||
rounds,
|
||||
lineGridSteps,
|
||||
});
|
||||
emitMetric('strategy_optimize_descent', { axisEvaluations: out.axisEvaluations, objective });
|
||||
console.log(JSON.stringify({ objective, ...out }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (sub === 'exec-plan') {
|
||||
const file = argVal(args, '--file');
|
||||
const simulate = hasFlag(args, '--simulate');
|
||||
const rpc = argVal(args, '--rpc') ?? process.env.RPC_URL_138 ?? '';
|
||||
const from = argVal(args, '--from') ?? '';
|
||||
const allowlist = argVal(args, '--allowlist') ?? '';
|
||||
if (!file) {
|
||||
console.error('strategy exec-plan requires --file');
|
||||
process.exit(2);
|
||||
}
|
||||
let def;
|
||||
try {
|
||||
def = parseStrategyJson(readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
const steps = buildExecPlan(def);
|
||||
emitMetric('strategy_exec_plan', { steps: steps.length, simulate });
|
||||
if (!simulate) {
|
||||
console.log(JSON.stringify({ steps, note: 'pass --simulate --rpc --from --allowlist for eth_call per step' }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
if (!rpc || !from || !allowlist) {
|
||||
console.error('exec-plan --simulate requires --rpc --from --allowlist');
|
||||
process.exit(2);
|
||||
}
|
||||
const rows = await simulateExecPlan({ def, rpcUrl: rpc, from, allowlistPath: allowlist });
|
||||
console.log(JSON.stringify({ steps: rows }, null, 2));
|
||||
const bad = rows.filter((r) => !r.ok || !r.allowlistOk);
|
||||
process.exit(bad.length ? 1 : 0);
|
||||
}
|
||||
|
||||
console.error(`unknown strategy subcommand: ${sub}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (cmd === 'liquidity-print') {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const defaultChecklist = join(here, '../liquidity/orchestration-checklist.json');
|
||||
const checklist = argVal(args, '--checklist') ?? defaultChecklist;
|
||||
try {
|
||||
const raw = readFileSync(checklist, 'utf8');
|
||||
const j = JSON.parse(raw) as { checklist?: unknown[]; runbooks?: unknown[] };
|
||||
console.log(JSON.stringify(j, null, 2));
|
||||
emitMetric('liquidity_print', { checklistPath: checklist, entries: j.checklist?.length ?? 0 });
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
42
packages/economics-toolkit/src/economics-engine.test.ts
Normal file
42
packages/economics-toolkit/src/economics-engine.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { evaluateEconomics, impliedGrossPct, impliedGrossPctMixedDecimals } from './economics-engine.js';
|
||||
|
||||
test('evaluateEconomics: 0.29 gross - 0.09 flash leaves 0.20 net after flash', () => {
|
||||
const r = evaluateEconomics({
|
||||
grossPct: 0.29,
|
||||
flashFeePct: 0.09,
|
||||
gasPctOfNotional: 0.1,
|
||||
liquidityPct: 0.01,
|
||||
});
|
||||
assert.ok(Math.abs(r.netAfterFlashPct - 0.2) < 1e-9);
|
||||
assert.ok(Math.abs(r.bucketsTotalPct - 0.11) < 1e-9);
|
||||
assert.ok(Math.abs(r.residualPct - 0.09) < 1e-9);
|
||||
assert.equal(r.passesResidual, true);
|
||||
});
|
||||
|
||||
test('evaluateEconomics: negative residual fails minProfit', () => {
|
||||
const r = evaluateEconomics({
|
||||
grossPct: 0.15,
|
||||
flashFeePct: 0.09,
|
||||
gasPctOfNotional: 0.1,
|
||||
liquidityPct: 0.01,
|
||||
minProfitPct: 0,
|
||||
});
|
||||
assert.equal(r.residualPct < 0, true);
|
||||
assert.equal(r.passesResidual, false);
|
||||
});
|
||||
|
||||
test('impliedGrossPct: 6 decimals flat swap', () => {
|
||||
const one = 1_000_000n;
|
||||
const out = 1_002_900n;
|
||||
const g = impliedGrossPct(one, out, 6);
|
||||
assert.ok(Math.abs(g - 0.29) < 1e-9);
|
||||
});
|
||||
|
||||
test('impliedGrossPctMixedDecimals: WETH 18 in, USDC 6 out (flat ~1:1 notional)', () => {
|
||||
const oneEth = 10n ** 18n;
|
||||
const oneUsdc = 1_000_000n;
|
||||
const g = impliedGrossPctMixedDecimals(oneEth, oneUsdc, 18, 6);
|
||||
assert.ok(Math.abs(g - 0) < 1e-9);
|
||||
});
|
||||
90
packages/economics-toolkit/src/economics-engine.ts
Normal file
90
packages/economics-toolkit/src/economics-engine.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Pure budgeting math: percentages are expressed as numbers in *percent units*
|
||||
* (e.g. 0.29 means 0.29%, not 29%).
|
||||
*/
|
||||
import { formatUnits } from 'ethers';
|
||||
|
||||
export interface EconomicsInput {
|
||||
/** Gross return before flash fee, in percent (e.g. 0.29 for 0.29%). */
|
||||
grossPct: number;
|
||||
/** Flash loan fee as percent of notional (e.g. 0.09). */
|
||||
flashFeePct: number;
|
||||
/** Optional: gas as percent of notional (e.g. 0.1). */
|
||||
gasPctOfNotional?: number;
|
||||
/** Optional: carve-out for liquidity building (e.g. 0.01). */
|
||||
liquidityPct?: number;
|
||||
/** Optional: minimum profit required after flash and buckets (e.g. 0). */
|
||||
minProfitPct?: number;
|
||||
}
|
||||
|
||||
export interface EconomicsResult {
|
||||
netAfterFlashPct: number;
|
||||
bucketsTotalPct: number;
|
||||
residualPct: number;
|
||||
/** True if residual >= (minProfitPct ?? 0). */
|
||||
passesResidual: boolean;
|
||||
/** Sensitivity: netAfterFlash at varying gross (flash and buckets fixed). */
|
||||
sensitivity: Array<{ grossPct: number; netAfterFlashPct: number; residualPct: number }>;
|
||||
}
|
||||
|
||||
function num(x: number | undefined, def = 0): number {
|
||||
if (x === undefined || Number.isNaN(x)) return def;
|
||||
return x;
|
||||
}
|
||||
|
||||
export function evaluateEconomics(input: EconomicsInput): EconomicsResult {
|
||||
const gross = input.grossPct;
|
||||
const flash = num(input.flashFeePct);
|
||||
const gas = num(input.gasPctOfNotional);
|
||||
const liq = num(input.liquidityPct);
|
||||
const minP = num(input.minProfitPct);
|
||||
|
||||
const netAfterFlashPct = gross - flash;
|
||||
const bucketsTotalPct = gas + liq;
|
||||
const residualPct = netAfterFlashPct - bucketsTotalPct;
|
||||
const passesResidual = residualPct >= minP;
|
||||
|
||||
const sensitivity: EconomicsResult['sensitivity'] = [];
|
||||
const steps = [-0.1, -0.05, 0, 0.05, 0.1];
|
||||
for (const d of steps) {
|
||||
const g = gross + d;
|
||||
const naf = g - flash;
|
||||
const res = naf - bucketsTotalPct;
|
||||
sensitivity.push({ grossPct: g, netAfterFlashPct: naf, residualPct: res });
|
||||
}
|
||||
|
||||
return {
|
||||
netAfterFlashPct,
|
||||
bucketsTotalPct,
|
||||
residualPct,
|
||||
passesResidual,
|
||||
sensitivity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implied gross return % when swapping amountIn -> amountOut (same nominal unit / decimals).
|
||||
* (amountOut/amountIn - 1) * 100
|
||||
*/
|
||||
export function impliedGrossPct(amountIn: bigint, amountOut: bigint, _decimals: number): number {
|
||||
if (amountIn <= 0n) return 0;
|
||||
const r = Number(amountOut) / Number(amountIn);
|
||||
return (r - 1) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implied gross % when tokenIn and tokenOut use different decimals (e.g. WETH 18 vs USDC 6).
|
||||
* Uses `formatUnits` for ratio; acceptable for typical swap notionals.
|
||||
*/
|
||||
export function impliedGrossPctMixedDecimals(
|
||||
amountIn: bigint,
|
||||
amountOut: bigint,
|
||||
decimalsIn: number,
|
||||
decimalsOut: number
|
||||
): number {
|
||||
if (amountIn <= 0n) return 0;
|
||||
const vIn = parseFloat(formatUnits(amountIn, decimalsIn));
|
||||
const vOut = parseFloat(formatUnits(amountOut, decimalsOut));
|
||||
if (vIn <= 0 || !Number.isFinite(vIn) || !Number.isFinite(vOut)) return 0;
|
||||
return (vOut / vIn - 1) * 100;
|
||||
}
|
||||
180
packages/economics-toolkit/src/executor.ts
Normal file
180
packages/economics-toolkit/src/executor.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Interface, JsonRpcProvider, Wallet, formatEther } from 'ethers';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
const SWAP_ABI = [
|
||||
'function swapExactIn(address pool, address tokenIn, uint256 amountIn, uint256 minAmountOut) external returns (uint256)',
|
||||
];
|
||||
|
||||
export interface ExecutorAllowlist {
|
||||
chainId: number;
|
||||
allowedTo: string[];
|
||||
/** Max msg.value in wei (string). */
|
||||
maxValueWei: string;
|
||||
/** Optional max fee per gas in gwei (omit = no cap). */
|
||||
maxFeePerGasGwei?: number;
|
||||
}
|
||||
|
||||
export function loadAllowlist(path: string): ExecutorAllowlist {
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const j = JSON.parse(raw) as ExecutorAllowlist;
|
||||
if (!j.chainId || !Array.isArray(j.allowedTo)) {
|
||||
throw new Error('Invalid allowlist: need chainId and allowedTo[]');
|
||||
}
|
||||
return {
|
||||
chainId: j.chainId,
|
||||
allowedTo: j.allowedTo.map((a: string) => a.toLowerCase()),
|
||||
maxValueWei: j.maxValueWei ?? '0',
|
||||
maxFeePerGasGwei: j.maxFeePerGasGwei,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAddr(a: string): string {
|
||||
return a.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Calldata for DODOPMMIntegration.swapExactIn; send `to` = integration address. */
|
||||
export function encodeSwapExactInCalldata(
|
||||
pool: string,
|
||||
tokenIn: string,
|
||||
amountIn: bigint,
|
||||
minAmountOut: bigint
|
||||
): string {
|
||||
const iface = new Interface(SWAP_ABI);
|
||||
return iface.encodeFunctionData('swapExactIn', [pool, tokenIn, amountIn, minAmountOut]);
|
||||
}
|
||||
|
||||
export interface SimulationResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
gasEstimate?: bigint;
|
||||
}
|
||||
|
||||
export async function simulateCall(params: {
|
||||
rpcUrl: string;
|
||||
from: string;
|
||||
to: string;
|
||||
data: string;
|
||||
valueWei?: bigint;
|
||||
}): Promise<SimulationResult> {
|
||||
const provider = new JsonRpcProvider(params.rpcUrl);
|
||||
const tx = {
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
data: params.data,
|
||||
value: params.valueWei ?? 0n,
|
||||
};
|
||||
try {
|
||||
await provider.call(tx);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
try {
|
||||
const gasEstimate = await provider.estimateGas(tx);
|
||||
return { ok: true, gasEstimate };
|
||||
} catch {
|
||||
return { ok: true, gasEstimate: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enforceAllowlistAndSimulate(params: {
|
||||
rpcUrl: string;
|
||||
allowlistPath: string;
|
||||
from: string;
|
||||
to: string;
|
||||
data: string;
|
||||
valueWei?: bigint;
|
||||
}): Promise<SimulationResult & { allowlistOk: boolean; allowlistError?: string }> {
|
||||
let list: ExecutorAllowlist;
|
||||
try {
|
||||
list = loadAllowlist(params.allowlistPath);
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
allowlistOk: false,
|
||||
allowlistError: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
const provider = new JsonRpcProvider(params.rpcUrl);
|
||||
const net = await provider.getNetwork();
|
||||
if (Number(net.chainId) !== list.chainId) {
|
||||
return {
|
||||
ok: false,
|
||||
allowlistOk: false,
|
||||
allowlistError: `chainId mismatch: rpc=${net.chainId} allowlist=${list.chainId}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!list.allowedTo.includes(normalizeAddr(params.to))) {
|
||||
return {
|
||||
ok: false,
|
||||
allowlistOk: false,
|
||||
allowlistError: `to not in allowlist: ${params.to}`,
|
||||
};
|
||||
}
|
||||
|
||||
const maxVal = BigInt(list.maxValueWei);
|
||||
const v = params.valueWei ?? 0n;
|
||||
if (v > maxVal) {
|
||||
return {
|
||||
ok: false,
|
||||
allowlistOk: false,
|
||||
allowlistError: `value ${v} exceeds maxValueWei ${maxVal}`,
|
||||
};
|
||||
}
|
||||
|
||||
const sim = await simulateCall(params);
|
||||
return { ...sim, allowlistOk: true };
|
||||
}
|
||||
|
||||
export async function broadcastIfApply(params: {
|
||||
rpcUrl: string;
|
||||
privateKey: string;
|
||||
allowlistPath: string;
|
||||
to: string;
|
||||
data: string;
|
||||
valueWei?: bigint;
|
||||
}): Promise<{ hash?: string; error?: string }> {
|
||||
const list = loadAllowlist(params.allowlistPath);
|
||||
const provider = new JsonRpcProvider(params.rpcUrl);
|
||||
const net = await provider.getNetwork();
|
||||
if (Number(net.chainId) !== list.chainId) {
|
||||
return { error: `chainId mismatch: ${net.chainId}` };
|
||||
}
|
||||
if (!list.allowedTo.includes(normalizeAddr(params.to))) {
|
||||
return { error: 'to not allowlisted' };
|
||||
}
|
||||
const maxVal = BigInt(list.maxValueWei);
|
||||
const v = params.valueWei ?? 0n;
|
||||
if (v > maxVal) {
|
||||
return { error: 'value exceeds allowlist' };
|
||||
}
|
||||
|
||||
const wallet = new Wallet(params.privateKey, provider);
|
||||
const tx: Parameters<Wallet['sendTransaction']>[0] = {
|
||||
to: params.to,
|
||||
data: params.data,
|
||||
value: v,
|
||||
};
|
||||
if (list.maxFeePerGasGwei !== undefined) {
|
||||
tx.maxFeePerGas = BigInt(Math.floor(list.maxFeePerGasGwei * 1e9));
|
||||
}
|
||||
|
||||
try {
|
||||
const sent = await wallet.sendTransaction(tx);
|
||||
return { hash: sent.hash };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Format gas cost in ETH given estimate and effective gas price (wei). */
|
||||
export function formatGasCostEth(gasEstimate: bigint, gasPriceWei: bigint): string {
|
||||
try {
|
||||
const wei = gasEstimate * gasPriceWei;
|
||||
return formatEther(wei);
|
||||
} catch {
|
||||
return 'n/a';
|
||||
}
|
||||
}
|
||||
19
packages/economics-toolkit/src/gas-budget-rounds.test.ts
Normal file
19
packages/economics-toolkit/src/gas-budget-rounds.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { roundsAtBalanceUsage } from './gas-budget-rounds.js';
|
||||
|
||||
test('roundsAtBalanceUsage: 100% with exact division', () => {
|
||||
const balance = 10_000_000_000_000_000n; // 0.01 eth
|
||||
const cost = 2_000_000_000_000_000n; // 5 txs
|
||||
assert.equal(roundsAtBalanceUsage(balance, cost, 100), 5n);
|
||||
assert.equal(roundsAtBalanceUsage(balance, cost, 50), 2n);
|
||||
assert.equal(roundsAtBalanceUsage(balance, cost, 25), 1n);
|
||||
});
|
||||
|
||||
test('roundsAtBalanceUsage: zero balance', () => {
|
||||
assert.equal(roundsAtBalanceUsage(0n, 1n, 100), 0n);
|
||||
});
|
||||
|
||||
test('roundsAtBalanceUsage: floors partial tx', () => {
|
||||
assert.equal(roundsAtBalanceUsage(5n, 2n, 100), 2n);
|
||||
});
|
||||
87
packages/economics-toolkit/src/gas-budget-rounds.ts
Normal file
87
packages/economics-toolkit/src/gas-budget-rounds.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* How many identical txs fit in a fraction of an EOA's native balance (gas budgeting).
|
||||
*/
|
||||
import { JsonRpcProvider, formatEther, getAddress, Wallet } from 'ethers';
|
||||
import { fetchFeeData, effectiveGasPriceWei, txCostWei } from './gas-realtime.js';
|
||||
|
||||
export type BalanceUsagePct = 25 | 50 | 75 | 100;
|
||||
|
||||
/** Floor( (balanceWei × usagePct / 100) / costPerTxWei ). */
|
||||
export function roundsAtBalanceUsage(
|
||||
balanceWei: bigint,
|
||||
costPerTxWei: bigint,
|
||||
usagePct: BalanceUsagePct
|
||||
): bigint {
|
||||
if (costPerTxWei <= 0n || balanceWei <= 0n) return 0n;
|
||||
const budget = (balanceWei * BigInt(usagePct)) / 100n;
|
||||
return budget / costPerTxWei;
|
||||
}
|
||||
|
||||
export interface GasBudgetRoundsResult {
|
||||
chainId: number;
|
||||
address: string;
|
||||
balanceWei: string;
|
||||
balanceNative: string;
|
||||
effectiveGasPriceWei: string;
|
||||
gasUnits: string;
|
||||
costPerTxWei: string;
|
||||
costPerTxNative: string;
|
||||
/** Same-shape txs affordable when spending this % of **current** balance on gas only. */
|
||||
rounds: Record<'pct25' | 'pct50' | 'pct75' | 'pct100', string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch native balance + fee data from RPC; estimate per-tx cost and affordable round counts.
|
||||
* Does not send transactions.
|
||||
*/
|
||||
export async function computeGasBudgetRounds(params: {
|
||||
rpcUrl: string;
|
||||
address: string;
|
||||
gasUnits: bigint;
|
||||
timeoutMs?: number;
|
||||
}): Promise<GasBudgetRoundsResult> {
|
||||
const provider = new JsonRpcProvider(params.rpcUrl);
|
||||
const addr = getAddress(params.address.trim());
|
||||
const t = params.timeoutMs ?? 20000;
|
||||
const net = await provider.getNetwork();
|
||||
const chainId = Number(net.chainId);
|
||||
|
||||
const balanceWei = await provider.getBalance(addr, 'latest');
|
||||
const feeData = await fetchFeeData(params.rpcUrl, t);
|
||||
const eff = effectiveGasPriceWei(feeData);
|
||||
if (eff <= 0n) {
|
||||
throw new Error('gas-budget: fee data unavailable (effectiveGasPriceWei is 0)');
|
||||
}
|
||||
|
||||
const costPerTxWei = txCostWei(params.gasUnits, eff);
|
||||
if (costPerTxWei <= 0n) {
|
||||
throw new Error('gas-budget: cost per tx is 0');
|
||||
}
|
||||
|
||||
const r25 = roundsAtBalanceUsage(balanceWei, costPerTxWei, 25);
|
||||
const r50 = roundsAtBalanceUsage(balanceWei, costPerTxWei, 50);
|
||||
const r75 = roundsAtBalanceUsage(balanceWei, costPerTxWei, 75);
|
||||
const r100 = roundsAtBalanceUsage(balanceWei, costPerTxWei, 100);
|
||||
|
||||
return {
|
||||
chainId,
|
||||
address: addr,
|
||||
balanceWei: balanceWei.toString(),
|
||||
balanceNative: formatEther(balanceWei),
|
||||
effectiveGasPriceWei: eff.toString(),
|
||||
gasUnits: params.gasUnits.toString(),
|
||||
costPerTxWei: costPerTxWei.toString(),
|
||||
costPerTxNative: formatEther(costPerTxWei),
|
||||
rounds: {
|
||||
pct25: r25.toString(),
|
||||
pct50: r50.toString(),
|
||||
pct75: r75.toString(),
|
||||
pct100: r100.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve checksummed address from hex private key (0x…). */
|
||||
export function addressFromPrivateKeyEnv(privateKey: string): string {
|
||||
return getAddress(new Wallet(privateKey.trim()).address);
|
||||
}
|
||||
17
packages/economics-toolkit/src/gas-realtime.test.ts
Normal file
17
packages/economics-toolkit/src/gas-realtime.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { effectiveGasPriceWei, txCostWei, type FeeDataSnapshot } from './gas-realtime.js';
|
||||
|
||||
test('effectiveGasPriceWei prefers legacy gasPrice', () => {
|
||||
const fd: FeeDataSnapshot = { gasPrice: 3n, maxFeePerGas: 100n, maxPriorityFeePerGas: 2n };
|
||||
assert.equal(effectiveGasPriceWei(fd), 3n);
|
||||
});
|
||||
|
||||
test('effectiveGasPriceWei falls back to maxFeePerGas', () => {
|
||||
const fd: FeeDataSnapshot = { gasPrice: null, maxFeePerGas: 50n, maxPriorityFeePerGas: 2n };
|
||||
assert.equal(effectiveGasPriceWei(fd), 50n);
|
||||
});
|
||||
|
||||
test('txCostWei', () => {
|
||||
assert.equal(txCostWei(250000n, 10n ** 9n), 250000n * 10n ** 9n);
|
||||
});
|
||||
193
packages/economics-toolkit/src/gas-realtime.ts
Normal file
193
packages/economics-toolkit/src/gas-realtime.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Live fee data from JSON-RPC (eth_feeData) + optional USD via CoinGecko public API.
|
||||
*/
|
||||
import { JsonRpcProvider, formatEther } from 'ethers';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
export interface GasNetworkRow {
|
||||
chainId: number;
|
||||
name: string;
|
||||
defaultRpc: string;
|
||||
nativeSymbol: string;
|
||||
coingeckoId: string | null;
|
||||
nativeDecimals: number;
|
||||
}
|
||||
|
||||
export interface FeeDataSnapshot {
|
||||
gasPrice: bigint | null;
|
||||
maxFeePerGas: bigint | null;
|
||||
maxPriorityFeePerGas: bigint | null;
|
||||
}
|
||||
|
||||
/** Conservative wei/gas for budgeting: legacy gasPrice, else EIP-1559 maxFeePerGas. */
|
||||
export function effectiveGasPriceWei(fd: FeeDataSnapshot): bigint {
|
||||
if (fd.gasPrice != null && fd.gasPrice > 0n) return fd.gasPrice;
|
||||
if (fd.maxFeePerGas != null && fd.maxFeePerGas > 0n) return fd.maxFeePerGas;
|
||||
return 0n;
|
||||
}
|
||||
|
||||
export function txCostWei(gasUnits: bigint, pricePerGasWei: bigint): bigint {
|
||||
return gasUnits * pricePerGasWei;
|
||||
}
|
||||
|
||||
function configPath(): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
return join(here, '../config/gas-networks.json');
|
||||
}
|
||||
|
||||
export function loadGasNetworks(): GasNetworkRow[] {
|
||||
const raw = readFileSync(configPath(), 'utf8');
|
||||
const j = JSON.parse(raw) as { networks: GasNetworkRow[] };
|
||||
return j.networks;
|
||||
}
|
||||
|
||||
/** Resolve RPC: ECONOMICS_GAS_RPC_<chainId> > RPC_URL_138 for 138 > defaultRpc */
|
||||
export function resolveRpcUrl(net: GasNetworkRow): string {
|
||||
const envKey = `ECONOMICS_GAS_RPC_${net.chainId}`;
|
||||
const fromEnv = process.env[envKey];
|
||||
if (fromEnv && fromEnv.startsWith('http')) return fromEnv.trim();
|
||||
if (net.chainId === 138) {
|
||||
const u = process.env.RPC_URL_138;
|
||||
if (u && u.startsWith('http')) return u.trim();
|
||||
}
|
||||
return net.defaultRpc;
|
||||
}
|
||||
|
||||
export async function fetchFeeData(rpcUrl: string, timeoutMs = 15000): Promise<FeeDataSnapshot> {
|
||||
const provider = new JsonRpcProvider(rpcUrl);
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const fd = await provider.getFeeData();
|
||||
return {
|
||||
gasPrice: fd.gasPrice ?? null,
|
||||
maxFeePerGas: fd.maxFeePerGas ?? null,
|
||||
maxPriorityFeePerGas: fd.maxPriorityFeePerGas ?? null,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
const CG = 'https://api.coingecko.com/api/v3/simple/price';
|
||||
|
||||
/** USD price for one coingecko id (public API; rate-limited). */
|
||||
export async function fetchUsdPrice(coingeckoId: string, timeoutMs = 10000): Promise<number | null> {
|
||||
const u = `${CG}?ids=${encodeURIComponent(coingeckoId)}&vs_currencies=usd`;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(u, { signal: ctrl.signal, headers: { accept: 'application/json' } });
|
||||
if (!res.ok) return null;
|
||||
const j = (await res.json()) as Record<string, { usd?: number }>;
|
||||
const v = j[coingeckoId]?.usd;
|
||||
return typeof v === 'number' && v > 0 ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export interface NetworkGasQuote {
|
||||
chainId: number;
|
||||
name: string;
|
||||
rpcUrl: string;
|
||||
nativeSymbol: string;
|
||||
feeData: FeeDataSnapshot;
|
||||
effectiveGasPriceWei: string;
|
||||
gasUnits: string;
|
||||
txCostWei: string;
|
||||
txCostNative: string;
|
||||
nativeDecimals: number;
|
||||
usdPerNative: number | null;
|
||||
txCostUsd: number | null;
|
||||
/** If notionalUsdt set: txCostUsd / notionalUsdt * 100 */
|
||||
gasPctOfNotional: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function quoteNetworkGas(params: {
|
||||
net: GasNetworkRow;
|
||||
gasUnits: bigint;
|
||||
notionalUsdt?: number;
|
||||
skipUsd?: boolean;
|
||||
}): Promise<NetworkGasQuote> {
|
||||
const rpcUrl = resolveRpcUrl(params.net);
|
||||
let feeData: FeeDataSnapshot;
|
||||
try {
|
||||
feeData = await fetchFeeData(rpcUrl);
|
||||
} catch (e) {
|
||||
return {
|
||||
chainId: params.net.chainId,
|
||||
name: params.net.name,
|
||||
rpcUrl,
|
||||
nativeSymbol: params.net.nativeSymbol,
|
||||
feeData: { gasPrice: null, maxFeePerGas: null, maxPriorityFeePerGas: null },
|
||||
effectiveGasPriceWei: '0',
|
||||
gasUnits: params.gasUnits.toString(),
|
||||
txCostWei: '0',
|
||||
txCostNative: '0',
|
||||
nativeDecimals: params.net.nativeDecimals,
|
||||
usdPerNative: null,
|
||||
txCostUsd: null,
|
||||
gasPctOfNotional: null,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
const eff = effectiveGasPriceWei(feeData);
|
||||
const costWei = txCostWei(params.gasUnits, eff);
|
||||
const costNative = formatEther(costWei);
|
||||
|
||||
let usdPerNative: number | null = null;
|
||||
let txCostUsd: number | null = null;
|
||||
let gasPctOfNotional: number | null = null;
|
||||
|
||||
const skip = params.skipUsd || process.env.ECONOMICS_GAS_SKIP_USD === '1';
|
||||
if (!skip && params.net.coingeckoId) {
|
||||
usdPerNative = await fetchUsdPrice(params.net.coingeckoId);
|
||||
if (usdPerNative != null) {
|
||||
const nativeFloat = parseFloat(costNative);
|
||||
if (!Number.isNaN(nativeFloat)) {
|
||||
txCostUsd = nativeFloat * usdPerNative;
|
||||
if (params.notionalUsdt != null && params.notionalUsdt > 0 && txCostUsd != null) {
|
||||
gasPctOfNotional = (txCostUsd / params.notionalUsdt) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chainId: params.net.chainId,
|
||||
name: params.net.name,
|
||||
rpcUrl,
|
||||
nativeSymbol: params.net.nativeSymbol,
|
||||
feeData,
|
||||
effectiveGasPriceWei: eff.toString(),
|
||||
gasUnits: params.gasUnits.toString(),
|
||||
txCostWei: costWei.toString(),
|
||||
txCostNative: costNative,
|
||||
nativeDecimals: params.net.nativeDecimals,
|
||||
usdPerNative,
|
||||
txCostUsd,
|
||||
gasPctOfNotional,
|
||||
};
|
||||
}
|
||||
|
||||
export async function quoteAllConfiguredGas(params: {
|
||||
chainIds?: number[];
|
||||
gasUnits: bigint;
|
||||
notionalUsdt?: number;
|
||||
skipUsd?: boolean;
|
||||
}): Promise<NetworkGasQuote[]> {
|
||||
const all = loadGasNetworks();
|
||||
const want = params.chainIds?.length
|
||||
? new Set(params.chainIds)
|
||||
: null;
|
||||
const nets = want ? all.filter((n) => want.has(n.chainId)) : all;
|
||||
const results = await Promise.all(nets.map((net) => quoteNetworkGas({ net, gasUnits: params.gasUnits, notionalUsdt: params.notionalUsdt, skipUsd: params.skipUsd })));
|
||||
return results.sort((a, b) => a.chainId - b.chainId);
|
||||
}
|
||||
110
packages/economics-toolkit/src/index.ts
Normal file
110
packages/economics-toolkit/src/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export {
|
||||
evaluateEconomics,
|
||||
impliedGrossPct,
|
||||
impliedGrossPctMixedDecimals,
|
||||
type EconomicsInput,
|
||||
type EconomicsResult,
|
||||
} from './economics-engine.js';
|
||||
export { evaluatePathGate, type PathGateParams, type PathGateResult } from './path-gate.js';
|
||||
export { pmmQuoteAmountOutFromChain } from './chain138-pmm-quote.js';
|
||||
export {
|
||||
loadAllowlist,
|
||||
simulateCall,
|
||||
enforceAllowlistAndSimulate,
|
||||
broadcastIfApply,
|
||||
encodeSwapExactInCalldata,
|
||||
type ExecutorAllowlist,
|
||||
type SimulationResult,
|
||||
} from './executor.js';
|
||||
export { emitMetric } from './metrics.js';
|
||||
export { postAlertIfConfigured } from './alerts.js';
|
||||
export {
|
||||
STRATEGY_LEG_KINDS,
|
||||
STRATEGY_LEG_DESCRIPTIONS,
|
||||
type StrategyLegKind,
|
||||
type StrategyLegInput,
|
||||
type StrategyDefinition,
|
||||
type StrategyAggregateMode,
|
||||
type StrategyOptimizerDimension,
|
||||
type StrategyOptimizerParam,
|
||||
type StrategyRefField,
|
||||
type DerivedExpr,
|
||||
type StrategyLegExec,
|
||||
} from './strategy-types.js';
|
||||
export {
|
||||
effectiveFlashFeePctOfBase,
|
||||
frictionFromBps,
|
||||
applyFrictionToGross,
|
||||
legToEconomicsInput,
|
||||
evaluateStrategy,
|
||||
optimizeGrossForLeg,
|
||||
optimizeStrategyMultiDim,
|
||||
type StrategyLegEvaluated,
|
||||
type StrategyEvaluationResult,
|
||||
} from './strategy-engine.js';
|
||||
export {
|
||||
evaluateDerivedExpr,
|
||||
refValueFromEvaluated,
|
||||
collectDerivedExprRefLegIds,
|
||||
assertValidDerivedExprShape,
|
||||
} from './strategy-expr.js';
|
||||
export { parseStrategyJson } from './strategy-io.js';
|
||||
export { enrichStrategy, type EnrichLegReport, type EnrichStrategyResult } from './strategy-enrich.js';
|
||||
export { buildStrategyRunbook, type RunbookStep, type StrategyRunbook } from './strategy-runbook.js';
|
||||
export {
|
||||
optimizeStrategyRandomSearch,
|
||||
optimizeStrategyCoordinateDescent,
|
||||
getLegOptimizerParam,
|
||||
type OptimizerBound,
|
||||
} from './strategy-optimize-stochastic.js';
|
||||
export {
|
||||
buildExecPlan,
|
||||
simulateExecPlan,
|
||||
type ExecPlanResolvedStep,
|
||||
type ExecPlanSimulationRow,
|
||||
} from './strategy-exec-plan.js';
|
||||
export {
|
||||
loadGasNetworks,
|
||||
resolveRpcUrl,
|
||||
fetchFeeData,
|
||||
fetchUsdPrice,
|
||||
effectiveGasPriceWei,
|
||||
txCostWei,
|
||||
quoteNetworkGas,
|
||||
quoteAllConfiguredGas,
|
||||
type GasNetworkRow,
|
||||
type FeeDataSnapshot,
|
||||
type NetworkGasQuote,
|
||||
} from './gas-realtime.js';
|
||||
export {
|
||||
roundsAtBalanceUsage,
|
||||
computeGasBudgetRounds,
|
||||
addressFromPrivateKeyEnv,
|
||||
type GasBudgetRoundsResult,
|
||||
type BalanceUsagePct,
|
||||
} from './gas-budget-rounds.js';
|
||||
export type {
|
||||
SwapQuoteEngine,
|
||||
SwapQuoteRequest,
|
||||
SwapQuoteResult,
|
||||
SwapQuoteSuccess,
|
||||
SwapQuoteFailure,
|
||||
} from './swap-engine/types.js';
|
||||
export { DEFAULT_SWAP_QUOTE_ENGINES } from './swap-engine/types.js';
|
||||
export { quoteSwap, quoteSwapFromEngines, type SwapQuoteEngineResult } from './swap-engine/swap-quote-router.js';
|
||||
export { evaluateSwapPathGate, type SwapPathGateParams, type SwapPathGateResult } from './swap-engine/swap-path-gate.js';
|
||||
export { encodeV3SwapPath } from './swap-engine/uniswap-v3-path.js';
|
||||
export { quoterV2AddressForChain, resolveQuoter, quoteUniswapV3, type QuoterKind } from './swap-engine/uniswap-v3-quoter.js';
|
||||
export { quoteOneInch } from './swap-engine/oneinch-quote.js';
|
||||
export { quoteCurve } from './swap-engine/curve-rate-quote.js';
|
||||
export { quoteBalancer } from './swap-engine/balancer-sor-quote.js';
|
||||
export { quoteDodo } from './swap-engine/dodo-quote.js';
|
||||
export { quoteAave } from './swap-engine/paraswap-quote.js';
|
||||
export { quoteCompound } from './swap-engine/zeroex-quote.js';
|
||||
export { fetchErc20Decimals } from './swap-engine/erc20-meta.js';
|
||||
export {
|
||||
attachUsdNotionalGross,
|
||||
impliedGrossPctHumanUnitRatio,
|
||||
type GrossPctBasis,
|
||||
type UsdGrossAttachment,
|
||||
} from './swap-engine/swap-usd-gross.js';
|
||||
32
packages/economics-toolkit/src/metrics.ts
Normal file
32
packages/economics-toolkit/src/metrics.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Structured JSON lines on stderr (machine-parseable; does not pollute stdout for scripting).
|
||||
*/
|
||||
export function emitMetric(
|
||||
event:
|
||||
| 'economics_calc'
|
||||
| 'path_check'
|
||||
| 'exec_sim'
|
||||
| 'exec_broadcast'
|
||||
| 'alert_webhook'
|
||||
| 'liquidity_print'
|
||||
| 'gas_quote'
|
||||
| 'gas_budget'
|
||||
| 'strategy_eval'
|
||||
| 'strategy_optimize'
|
||||
| 'strategy_optimize_multi'
|
||||
| 'strategy_enrich'
|
||||
| 'strategy_runbook'
|
||||
| 'strategy_optimize_random'
|
||||
| 'strategy_optimize_descent'
|
||||
| 'strategy_exec_plan'
|
||||
| 'strategy_validate'
|
||||
| 'swap_quote',
|
||||
fields: Record<string, unknown>
|
||||
): void {
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
event,
|
||||
...fields,
|
||||
});
|
||||
console.error(line);
|
||||
}
|
||||
54
packages/economics-toolkit/src/path-gate.ts
Normal file
54
packages/economics-toolkit/src/path-gate.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { evaluateEconomics, impliedGrossPct, type EconomicsInput } from './economics-engine.js';
|
||||
import { pmmQuoteAmountOutFromChain } from './chain138-pmm-quote.js';
|
||||
|
||||
export interface PathGateParams {
|
||||
rpcUrl: string;
|
||||
poolAddress: string;
|
||||
tokenIn: string;
|
||||
amountIn: bigint;
|
||||
traderForView: string;
|
||||
/** Token decimals for both legs (typical: 6 for cUSDT/cUSDC). */
|
||||
decimals: number;
|
||||
economics: Omit<EconomicsInput, 'grossPct'>;
|
||||
}
|
||||
|
||||
export interface PathGateResult {
|
||||
amountOut: bigint | null;
|
||||
impliedGrossPct: number;
|
||||
economics: ReturnType<typeof evaluateEconomics>;
|
||||
passesEconomicsGate: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function evaluatePathGate(params: PathGateParams): Promise<PathGateResult> {
|
||||
const amountOut = await pmmQuoteAmountOutFromChain({
|
||||
rpcUrl: params.rpcUrl,
|
||||
poolAddress: params.poolAddress,
|
||||
tokenInLookup: params.tokenIn,
|
||||
amountIn: params.amountIn,
|
||||
traderForView: params.traderForView,
|
||||
});
|
||||
|
||||
if (amountOut === null) {
|
||||
return {
|
||||
amountOut: null,
|
||||
impliedGrossPct: 0,
|
||||
economics: evaluateEconomics({ grossPct: 0, flashFeePct: params.economics.flashFeePct }),
|
||||
passesEconomicsGate: false,
|
||||
error: 'quote_failed_or_invalid_token_pair',
|
||||
};
|
||||
}
|
||||
|
||||
const gross = impliedGrossPct(params.amountIn, amountOut, params.decimals);
|
||||
const economics = evaluateEconomics({
|
||||
grossPct: gross,
|
||||
...params.economics,
|
||||
});
|
||||
|
||||
return {
|
||||
amountOut,
|
||||
impliedGrossPct: gross,
|
||||
economics,
|
||||
passesEconomicsGate: economics.passesResidual,
|
||||
};
|
||||
}
|
||||
211
packages/economics-toolkit/src/strategy-engine.test.ts
Normal file
211
packages/economics-toolkit/src/strategy-engine.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
effectiveFlashFeePctOfBase,
|
||||
evaluateStrategy,
|
||||
optimizeGrossForLeg,
|
||||
optimizeStrategyMultiDim,
|
||||
applyFrictionToGross,
|
||||
frictionFromBps,
|
||||
} from './strategy-engine.js';
|
||||
import type { StrategyDefinition } from './strategy-types.js';
|
||||
import { parseStrategyJson } from './strategy-io.js';
|
||||
|
||||
test('effectiveFlashFeePctOfBase: M × f', () => {
|
||||
const v = effectiveFlashFeePctOfBase({
|
||||
id: 'a',
|
||||
kind: 'flash_arb_path',
|
||||
flashNotionalMultiple: 10,
|
||||
flashFeePct: 0.09,
|
||||
});
|
||||
assert.ok(Math.abs(v - 0.9) < 1e-9);
|
||||
});
|
||||
|
||||
test('evaluateStrategy linear sums residuals', () => {
|
||||
const def: StrategyDefinition = {
|
||||
version: 1,
|
||||
name: 't',
|
||||
baseNotionalUsdt: 100,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [
|
||||
{ id: '1', kind: 'spot_swap', grossPct: 0.29, flashFeePct: 0, gasPctOfNotional: 0.1, liquidityPct: 0.01 },
|
||||
{ id: '2', kind: 'spot_swap', grossPct: 0.29, flashFeePct: 0, gasPctOfNotional: 0.1, liquidityPct: 0.01 },
|
||||
],
|
||||
};
|
||||
const r = evaluateStrategy(def);
|
||||
const r1 = r.legs[0].economics.residualPct;
|
||||
const r2 = r.legs[1].economics.residualPct;
|
||||
assert.ok(Math.abs(r.totalResidualPctLinear - (r1 + r2)) < 1e-9);
|
||||
});
|
||||
|
||||
test('evaluateStrategy compound moves notional', () => {
|
||||
const def: StrategyDefinition = {
|
||||
version: 1,
|
||||
name: 'c',
|
||||
baseNotionalUsdt: 1000,
|
||||
aggregateMode: 'sequential_compound_usd',
|
||||
legs: [
|
||||
{ id: '1', kind: 'spot_swap', grossPct: 0.1, flashFeePct: 0, gasPctOfNotional: 0, liquidityPct: 0 },
|
||||
{ id: '2', kind: 'spot_swap', grossPct: 0.1, flashFeePct: 0, gasPctOfNotional: 0, liquidityPct: 0, fixedCostUsdt: 5 },
|
||||
],
|
||||
};
|
||||
const r = evaluateStrategy(def);
|
||||
const after1 = 1000 * (1 + 0.1 / 100);
|
||||
const after2 = after1 * (1 + 0.1 / 100) - 5;
|
||||
assert.ok(Math.abs(r.finalNotionalUsdtCompound - after2) < 1e-6);
|
||||
});
|
||||
|
||||
test('optimizeGrossForLeg returns grid', () => {
|
||||
const def: StrategyDefinition = {
|
||||
version: 1,
|
||||
name: 'o',
|
||||
baseNotionalUsdt: 100,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [{ id: 'x', kind: 'flash_arb_path', grossPct: 0.2, flashFeePct: 0.09, gasPctOfNotional: 0.05, liquidityPct: 0.01 }],
|
||||
};
|
||||
const grid = optimizeGrossForLeg({
|
||||
def,
|
||||
legId: 'x',
|
||||
grossMin: 0.2,
|
||||
grossMax: 0.4,
|
||||
step: 0.1,
|
||||
objective: 'max_linear_residual',
|
||||
});
|
||||
assert.equal(grid.length, 3);
|
||||
});
|
||||
|
||||
test('applyFrictionToGross: 10 bps = 0.1 percent points', () => {
|
||||
const leg = { id: 'a', kind: 'spot_swap' as const, slippageBps: 10 };
|
||||
assert.ok(Math.abs(applyFrictionToGross(0.5, leg) - 0.4) < 1e-9);
|
||||
});
|
||||
|
||||
test('frictionFromBps includes MEV and IL bps', () => {
|
||||
const leg = {
|
||||
id: 'a',
|
||||
kind: 'lp_add' as const,
|
||||
slippageBps: 5,
|
||||
mevDragBps: 3,
|
||||
ilDragBps: 10,
|
||||
liquidationCompetitionBps: 2,
|
||||
};
|
||||
assert.ok(Math.abs(frictionFromBps(leg) - 0.2) < 1e-9);
|
||||
});
|
||||
|
||||
test('derivedExpr gross overrides grossPct', () => {
|
||||
const def: StrategyDefinition = {
|
||||
version: 1,
|
||||
name: 'e',
|
||||
baseNotionalUsdt: 100,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [
|
||||
{ id: 'a', kind: 'spot_swap', grossPct: 0.5, flashFeePct: 0, gasPctOfNotional: 0.1, liquidityPct: 0.01 },
|
||||
{
|
||||
id: 'b',
|
||||
kind: 'spot_swap',
|
||||
grossPct: 99,
|
||||
flashFeePct: 0,
|
||||
gasPctOfNotional: 0,
|
||||
liquidityPct: 0,
|
||||
derivedExpr: {
|
||||
op: 'mul',
|
||||
args: [{ ref: { legId: 'a', field: 'residualPct' } }, { const: 2 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const r = evaluateStrategy(def);
|
||||
const ra = r.legs[0].economics.residualPct;
|
||||
assert.ok(Math.abs(r.legs[1].grossEffectivePct - 2 * ra) < 1e-9);
|
||||
});
|
||||
|
||||
test('parseStrategyJson rejects derivedExpr and derivedFrom together', () => {
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
name: 'bad',
|
||||
baseNotionalUsdt: 1,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [
|
||||
{
|
||||
id: 'a',
|
||||
kind: 'spot_swap',
|
||||
derivedFrom: { fromLegId: 'x', field: 'residualPct' },
|
||||
derivedExpr: { const: 1 },
|
||||
},
|
||||
{ id: 'x', kind: 'spot_swap' },
|
||||
],
|
||||
});
|
||||
assert.throws(() => parseStrategyJson(raw), /both derivedExpr and derivedFrom/);
|
||||
});
|
||||
|
||||
test('derivedFrom uses prior leg residual', () => {
|
||||
const def: StrategyDefinition = {
|
||||
version: 1,
|
||||
name: 'd',
|
||||
baseNotionalUsdt: 100,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [
|
||||
{ id: 'first', kind: 'spot_swap', grossPct: 0.5, flashFeePct: 0, gasPctOfNotional: 0.1, liquidityPct: 0.01 },
|
||||
{
|
||||
id: 'second',
|
||||
kind: 'spot_swap',
|
||||
grossPct: 0,
|
||||
flashFeePct: 0,
|
||||
gasPctOfNotional: 0,
|
||||
liquidityPct: 0,
|
||||
derivedFrom: { fromLegId: 'first', field: 'residualPct', scale: 1, offsetPct: 0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const r = evaluateStrategy(def);
|
||||
const r0 = r.legs[0].economics.residualPct;
|
||||
assert.ok(Math.abs(r.legs[1].grossEffectivePct - r0) < 1e-9);
|
||||
assert.ok(Math.abs(r.legs[1].economics.residualPct - (r0 - 0)) < 1e-9);
|
||||
});
|
||||
|
||||
test('parseStrategyJson rejects derivedFrom to later leg', () => {
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
name: 'bad',
|
||||
baseNotionalUsdt: 1,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [
|
||||
{
|
||||
id: 'b',
|
||||
kind: 'spot_swap',
|
||||
derivedFrom: { fromLegId: 'a', field: 'residualPct' },
|
||||
},
|
||||
{ id: 'a', kind: 'spot_swap' },
|
||||
],
|
||||
});
|
||||
assert.throws(() => parseStrategyJson(raw), /earlier leg/);
|
||||
});
|
||||
|
||||
test('optimizeStrategyMultiDim cartesian', () => {
|
||||
const def: StrategyDefinition = {
|
||||
version: 1,
|
||||
name: 'm',
|
||||
baseNotionalUsdt: 100,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [
|
||||
{
|
||||
id: 'a',
|
||||
kind: 'spot_swap',
|
||||
grossPct: 0.3,
|
||||
flashFeePct: 0.09,
|
||||
flashNotionalMultiple: 1,
|
||||
gasPctOfNotional: 0.05,
|
||||
liquidityPct: 0.01,
|
||||
},
|
||||
],
|
||||
};
|
||||
const grid = optimizeStrategyMultiDim({
|
||||
def,
|
||||
dimensions: [
|
||||
{ legId: 'a', param: 'grossPct', min: 0.2, max: 0.3, step: 0.1 },
|
||||
{ legId: 'a', param: 'flashNotionalMultiple', min: 1, max: 2, step: 1 },
|
||||
],
|
||||
objective: 'max_linear_residual',
|
||||
maxCombinations: 100,
|
||||
});
|
||||
assert.equal(grid.length, 4);
|
||||
});
|
||||
247
packages/economics-toolkit/src/strategy-engine.ts
Normal file
247
packages/economics-toolkit/src/strategy-engine.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { evaluateEconomics, type EconomicsInput, type EconomicsResult } from './economics-engine.js';
|
||||
import type {
|
||||
StrategyDefinition,
|
||||
StrategyLegInput,
|
||||
StrategyAggregateMode,
|
||||
StrategyOptimizerDimension,
|
||||
} from './strategy-types.js';
|
||||
import { evaluateDerivedExpr, refValueFromEvaluated } from './strategy-expr.js';
|
||||
|
||||
export function effectiveFlashFeePctOfBase(leg: StrategyLegInput): number {
|
||||
const m = leg.flashNotionalMultiple ?? 1;
|
||||
const f = leg.flashFeePct ?? 0;
|
||||
return m * f;
|
||||
}
|
||||
|
||||
/**
|
||||
* All basis-point drags (1 bp = 0.01 percentage points) subtracted from gross before economics:
|
||||
* slippage, protocol, MEV, IL, liquidation competition.
|
||||
*/
|
||||
export function frictionFromBps(leg: StrategyLegInput): number {
|
||||
const slip = leg.slippageBps ?? 0;
|
||||
const prot = leg.protocolFeeBps ?? 0;
|
||||
const mev = leg.mevDragBps ?? 0;
|
||||
const il = leg.ilDragBps ?? 0;
|
||||
const liq = leg.liquidationCompetitionBps ?? 0;
|
||||
return (slip + prot + mev + il + liq) * 0.01;
|
||||
}
|
||||
|
||||
export function applyFrictionToGross(rawGross: number, leg: StrategyLegInput): number {
|
||||
return rawGross - frictionFromBps(leg);
|
||||
}
|
||||
|
||||
export function legToEconomicsInput(leg: StrategyLegInput, grossEffective: number): EconomicsInput {
|
||||
return {
|
||||
grossPct: grossEffective,
|
||||
flashFeePct: effectiveFlashFeePctOfBase(leg),
|
||||
gasPctOfNotional: leg.gasPctOfNotional,
|
||||
liquidityPct: leg.liquidityPct,
|
||||
minProfitPct: leg.minProfitPct,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StrategyLegEvaluated {
|
||||
id: string;
|
||||
kind: string;
|
||||
economics: EconomicsResult;
|
||||
effectiveFlashFeePctOfBase: number;
|
||||
/** Gross after derivedFrom resolution and slippage/protocol bps; fed into evaluateEconomics. */
|
||||
grossEffectivePct: number;
|
||||
fixedCostUsdt: number;
|
||||
weight: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface StrategyEvaluationResult {
|
||||
name: string;
|
||||
baseNotionalUsdt: number;
|
||||
aggregateMode: StrategyAggregateMode;
|
||||
legs: StrategyLegEvaluated[];
|
||||
/** Sum of leg.residualPct × weight (linear mode interpretation). */
|
||||
totalResidualPctLinear: number;
|
||||
/** Sum of leg economics buckets (gas+liq) × weight. */
|
||||
totalBucketsPctLinear: number;
|
||||
/** Product chain: notional after each leg’s residual% then subtract fixed costs. */
|
||||
finalNotionalUsdtCompound: number;
|
||||
/** (final / base - 1) × 100 */
|
||||
impliedTotalReturnPctCompound: number;
|
||||
allLegsPass: boolean;
|
||||
}
|
||||
|
||||
export function evaluateStrategy(def: StrategyDefinition): StrategyEvaluationResult {
|
||||
const legs: StrategyLegEvaluated[] = [];
|
||||
const idToEval = new Map<string, StrategyLegEvaluated>();
|
||||
let allPass = true;
|
||||
let totalResidualLinear = 0;
|
||||
let totalBucketsLinear = 0;
|
||||
|
||||
let running = def.baseNotionalUsdt;
|
||||
|
||||
for (const leg of def.legs) {
|
||||
let rawGross = leg.grossPct ?? 0;
|
||||
if (leg.derivedExpr) {
|
||||
rawGross = evaluateDerivedExpr(leg.derivedExpr, idToEval);
|
||||
} else if (leg.derivedFrom) {
|
||||
const ref = idToEval.get(leg.derivedFrom.fromLegId);
|
||||
if (!ref) {
|
||||
throw new Error(
|
||||
`strategy: leg ${leg.id} derivedFrom.fromLegId "${leg.derivedFrom.fromLegId}" must refer to an earlier leg in legs[]`
|
||||
);
|
||||
}
|
||||
const scale = leg.derivedFrom.scale ?? 1;
|
||||
const off = leg.derivedFrom.offsetPct ?? 0;
|
||||
rawGross = scale * refValueFromEvaluated(ref, leg.derivedFrom.field) + off;
|
||||
}
|
||||
|
||||
const grossEffective = applyFrictionToGross(rawGross, leg);
|
||||
const input = legToEconomicsInput(leg, grossEffective);
|
||||
const economics = evaluateEconomics(input);
|
||||
const effFlash = effectiveFlashFeePctOfBase(leg);
|
||||
const w = leg.weight ?? 1;
|
||||
const fixed = leg.fixedCostUsdt ?? 0;
|
||||
|
||||
if (!economics.passesResidual) allPass = false;
|
||||
|
||||
const row: StrategyLegEvaluated = {
|
||||
id: leg.id,
|
||||
kind: leg.kind,
|
||||
economics,
|
||||
effectiveFlashFeePctOfBase: effFlash,
|
||||
grossEffectivePct: grossEffective,
|
||||
fixedCostUsdt: fixed,
|
||||
weight: w,
|
||||
notes: leg.notes,
|
||||
};
|
||||
legs.push(row);
|
||||
idToEval.set(leg.id, row);
|
||||
|
||||
totalResidualLinear += economics.residualPct * w;
|
||||
totalBucketsLinear += economics.bucketsTotalPct * w;
|
||||
|
||||
if (def.aggregateMode === 'sequential_compound_usd') {
|
||||
running = running * (1 + economics.residualPct / 100) - fixed;
|
||||
}
|
||||
}
|
||||
|
||||
const impliedCompoundPct =
|
||||
def.baseNotionalUsdt > 0 ? (running / def.baseNotionalUsdt - 1) * 100 : 0;
|
||||
|
||||
return {
|
||||
name: def.name,
|
||||
baseNotionalUsdt: def.baseNotionalUsdt,
|
||||
aggregateMode: def.aggregateMode,
|
||||
legs,
|
||||
totalResidualPctLinear: totalResidualLinear,
|
||||
totalBucketsPctLinear: totalBucketsLinear,
|
||||
finalNotionalUsdtCompound: running,
|
||||
impliedTotalReturnPctCompound: impliedCompoundPct,
|
||||
allLegsPass: allPass,
|
||||
};
|
||||
}
|
||||
|
||||
/** Grid search: vary grossPct on a single leg id, hold others fixed. */
|
||||
export function optimizeGrossForLeg(params: {
|
||||
def: StrategyDefinition;
|
||||
legId: string;
|
||||
grossMin: number;
|
||||
grossMax: number;
|
||||
step: number;
|
||||
objective: 'max_compound_return' | 'max_linear_residual' | 'all_pass';
|
||||
}): Array<{ grossPct: number; result: StrategyEvaluationResult }> {
|
||||
const out: Array<{ grossPct: number; result: StrategyEvaluationResult }> = [];
|
||||
for (let g = params.grossMin; g <= params.grossMax + 1e-9; g += params.step) {
|
||||
const legs = params.def.legs.map((l) =>
|
||||
l.id === params.legId ? { ...l, grossPct: Math.round(g * 1e6) / 1e6 } : l
|
||||
);
|
||||
const def2: StrategyDefinition = { ...params.def, legs };
|
||||
const result = evaluateStrategy(def2);
|
||||
out.push({ grossPct: g, result });
|
||||
}
|
||||
|
||||
if (params.objective === 'max_compound_return') {
|
||||
out.sort((a, b) => b.result.impliedTotalReturnPctCompound - a.result.impliedTotalReturnPctCompound);
|
||||
} else if (params.objective === 'max_linear_residual') {
|
||||
out.sort((a, b) => b.result.totalResidualPctLinear - a.result.totalResidualPctLinear);
|
||||
} else {
|
||||
const passing = out.filter((o) => o.result.allLegsPass);
|
||||
return passing.length ? passing : out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rangeInclusive(min: number, max: number, step: number): number[] {
|
||||
if (step <= 0) throw new Error('optimize: step must be > 0');
|
||||
const out: number[] = [];
|
||||
for (let x = min; x <= max + 1e-9; x += step) {
|
||||
out.push(Math.round(x * 1e9) / 1e9);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function cartesian<T>(arrays: T[][]): T[][] {
|
||||
if (arrays.length === 0) return [[]];
|
||||
const [first, ...rest] = arrays;
|
||||
const sub = cartesian(rest);
|
||||
const out: T[][] = [];
|
||||
for (const x of first) {
|
||||
for (const s of sub) {
|
||||
out.push([x, ...s]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function cloneDefWithLegField(
|
||||
def: StrategyDefinition,
|
||||
legId: string,
|
||||
param: StrategyOptimizerDimension['param'],
|
||||
value: number
|
||||
): StrategyDefinition {
|
||||
const legs = def.legs.map((l) => (l.id === legId ? { ...l, [param]: value } : l));
|
||||
return { ...def, legs };
|
||||
}
|
||||
|
||||
/** Multi-parameter grid on one or more legs; combination count capped. */
|
||||
export function optimizeStrategyMultiDim(params: {
|
||||
def: StrategyDefinition;
|
||||
dimensions: StrategyOptimizerDimension[];
|
||||
objective: 'max_compound_return' | 'max_linear_residual' | 'all_pass';
|
||||
maxCombinations?: number;
|
||||
}): Array<{ values: Record<string, number>; result: StrategyEvaluationResult }> {
|
||||
const dims = params.dimensions;
|
||||
if (!dims.length) throw new Error('optimizeStrategyMultiDim: dimensions[] required');
|
||||
|
||||
const grids = dims.map((d) => rangeInclusive(d.min, d.max, d.step));
|
||||
const combos = cartesian(grids);
|
||||
const maxC = params.maxCombinations ?? 5000;
|
||||
if (combos.length > maxC) {
|
||||
throw new Error(
|
||||
`optimizeStrategyMultiDim: ${combos.length} combinations exceed maxCombinations=${maxC}; widen steps or raise maxCombinations`
|
||||
);
|
||||
}
|
||||
|
||||
const out: Array<{ values: Record<string, number>; result: StrategyEvaluationResult }> = [];
|
||||
|
||||
for (const tuple of combos) {
|
||||
let def2: StrategyDefinition = params.def;
|
||||
const values: Record<string, number> = {};
|
||||
for (let i = 0; i < dims.length; i++) {
|
||||
const dim = dims[i];
|
||||
const v = tuple[i];
|
||||
const key = `${dim.legId}.${dim.param}`;
|
||||
values[key] = v;
|
||||
def2 = cloneDefWithLegField(def2, dim.legId, dim.param, v);
|
||||
}
|
||||
out.push({ values, result: evaluateStrategy(def2) });
|
||||
}
|
||||
|
||||
if (params.objective === 'max_compound_return') {
|
||||
out.sort((a, b) => b.result.impliedTotalReturnPctCompound - a.result.impliedTotalReturnPctCompound);
|
||||
} else if (params.objective === 'max_linear_residual') {
|
||||
out.sort((a, b) => b.result.totalResidualPctLinear - a.result.totalResidualPctLinear);
|
||||
} else {
|
||||
const passing = out.filter((o) => o.result.allLegsPass);
|
||||
return passing.length ? passing : out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
104
packages/economics-toolkit/src/strategy-enrich.ts
Normal file
104
packages/economics-toolkit/src/strategy-enrich.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { evaluatePathGate } from './path-gate.js';
|
||||
import { loadGasNetworks, quoteNetworkGas } from './gas-realtime.js';
|
||||
import type { StrategyDefinition, StrategyLegInput } from './strategy-types.js';
|
||||
|
||||
export interface EnrichLegReport {
|
||||
legId: string;
|
||||
impliedGrossPct?: number;
|
||||
pathError?: string;
|
||||
gasPctOfNotional?: number;
|
||||
gasError?: string;
|
||||
}
|
||||
|
||||
export interface EnrichStrategyResult {
|
||||
definition: StrategyDefinition;
|
||||
reports: EnrichLegReport[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply on-chain path quotes and live gas quotes into a **copy** of the strategy (immutable input safe).
|
||||
*/
|
||||
export async function enrichStrategy(
|
||||
def: StrategyDefinition,
|
||||
opts: {
|
||||
rpcUrl138: string;
|
||||
/** Notional for gas % denominator; defaults to def.baseNotionalUsdt */
|
||||
notionalUsdt?: number;
|
||||
skipUsd?: boolean;
|
||||
}
|
||||
): Promise<EnrichStrategyResult> {
|
||||
const notional = opts.notionalUsdt ?? def.baseNotionalUsdt;
|
||||
const reports: EnrichLegReport[] = [];
|
||||
const legs: StrategyLegInput[] = [];
|
||||
|
||||
const nets = loadGasNetworks();
|
||||
const netById = new Map(nets.map((n) => [n.chainId, n]));
|
||||
|
||||
for (const leg of def.legs) {
|
||||
let next: StrategyLegInput = { ...leg };
|
||||
const rep: EnrichLegReport = { legId: leg.id };
|
||||
|
||||
if (leg.enrichPathCheck) {
|
||||
const e = leg.enrichPathCheck;
|
||||
try {
|
||||
const amountIn = BigInt(e.amountInWei);
|
||||
const r = await evaluatePathGate({
|
||||
rpcUrl: opts.rpcUrl138,
|
||||
poolAddress: e.poolAddress,
|
||||
tokenIn: e.tokenIn,
|
||||
amountIn,
|
||||
traderForView: e.traderForView ?? '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
||||
decimals: e.decimals ?? 6,
|
||||
economics: {
|
||||
flashFeePct: e.flashFeePct ?? leg.flashFeePct ?? 0,
|
||||
gasPctOfNotional: e.gasPctOfNotional ?? leg.gasPctOfNotional,
|
||||
liquidityPct: e.liquidityPct ?? leg.liquidityPct,
|
||||
minProfitPct: e.minProfitPct ?? leg.minProfitPct,
|
||||
},
|
||||
});
|
||||
if (r.error) {
|
||||
rep.pathError = r.error;
|
||||
} else {
|
||||
rep.impliedGrossPct = r.impliedGrossPct;
|
||||
next = { ...next, grossPct: r.impliedGrossPct };
|
||||
}
|
||||
} catch (err) {
|
||||
rep.pathError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (leg.enrichGas) {
|
||||
const eg = leg.enrichGas;
|
||||
const net = netById.get(eg.chainId);
|
||||
if (!net) {
|
||||
rep.gasError = `unknown_chainId_${eg.chainId}`;
|
||||
} else {
|
||||
const gasUnits = typeof eg.gasUnits === 'number' ? BigInt(Math.floor(eg.gasUnits)) : BigInt(String(eg.gasUnits));
|
||||
try {
|
||||
const q = await quoteNetworkGas({
|
||||
net,
|
||||
gasUnits,
|
||||
notionalUsdt: notional,
|
||||
skipUsd: eg.skipUsd ?? opts.skipUsd,
|
||||
});
|
||||
if (q.error) {
|
||||
rep.gasError = q.error;
|
||||
} else if (q.gasPctOfNotional != null) {
|
||||
rep.gasPctOfNotional = q.gasPctOfNotional;
|
||||
next = { ...next, gasPctOfNotional: q.gasPctOfNotional };
|
||||
}
|
||||
} catch (err) {
|
||||
rep.gasError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reports.push(rep);
|
||||
legs.push(next);
|
||||
}
|
||||
|
||||
return {
|
||||
definition: { ...def, legs },
|
||||
reports,
|
||||
};
|
||||
}
|
||||
40
packages/economics-toolkit/src/strategy-exec-plan.test.ts
Normal file
40
packages/economics-toolkit/src/strategy-exec-plan.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildExecPlan } from './strategy-exec-plan.js';
|
||||
import { encodeSwapExactInCalldata } from './executor.js';
|
||||
import type { StrategyDefinition } from './strategy-types.js';
|
||||
|
||||
test('buildExecPlan: pmm_swap_exact_in matches encodeSwapExactInCalldata', () => {
|
||||
const def: StrategyDefinition = {
|
||||
version: 1,
|
||||
name: 'x',
|
||||
baseNotionalUsdt: 100,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [
|
||||
{
|
||||
id: 's',
|
||||
kind: 'spot_swap',
|
||||
grossPct: 0,
|
||||
exec: {
|
||||
mode: 'pmm_swap_exact_in',
|
||||
integrationTo: '0x1111111111111111111111111111111111111111',
|
||||
pool: '0x2222222222222222222222222222222222222222',
|
||||
tokenIn: '0x3333333333333333333333333333333333333333',
|
||||
amountInWei: '1000000',
|
||||
minOutWei: '900000',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const steps = buildExecPlan(def);
|
||||
assert.equal(steps.length, 1);
|
||||
const expected = encodeSwapExactInCalldata(
|
||||
'0x2222222222222222222222222222222222222222',
|
||||
'0x3333333333333333333333333333333333333333',
|
||||
1000000n,
|
||||
900000n
|
||||
);
|
||||
assert.equal(steps[0].data, expected);
|
||||
assert.equal(steps[0].to.toLowerCase(), '0x1111111111111111111111111111111111111111');
|
||||
assert.ok(steps[0].sequencingNote && steps[0].sequencingNote.includes('nonce'));
|
||||
});
|
||||
103
packages/economics-toolkit/src/strategy-exec-plan.ts
Normal file
103
packages/economics-toolkit/src/strategy-exec-plan.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { encodeSwapExactInCalldata, enforceAllowlistAndSimulate } from './executor.js';
|
||||
import type { StrategyDefinition } from './strategy-types.js';
|
||||
|
||||
export interface ExecPlanResolvedStep {
|
||||
legId: string;
|
||||
kind: string;
|
||||
to: string;
|
||||
data: string;
|
||||
valueWei: string;
|
||||
/** Operator hint: multi-step sends need monotonic nonces and ordering; toolkit does not submit txs. */
|
||||
sequencingNote?: string;
|
||||
}
|
||||
|
||||
function normalizeHexData(data: string): string {
|
||||
const d = data.trim();
|
||||
return d.startsWith('0x') ? d : `0x${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize `exec` blocks into ordered calldata. Legs without `exec` are skipped.
|
||||
*/
|
||||
export function buildExecPlan(def: StrategyDefinition): ExecPlanResolvedStep[] {
|
||||
const steps: ExecPlanResolvedStep[] = [];
|
||||
for (const leg of def.legs) {
|
||||
if (!leg.exec) continue;
|
||||
const e = leg.exec;
|
||||
const seq =
|
||||
'Use one EOA nonce per broadcast; execute steps in leg order when dependencies require it. This tool only simulates (eth_call / estimateGas).';
|
||||
if (e.mode === 'raw') {
|
||||
steps.push({
|
||||
legId: leg.id,
|
||||
kind: leg.kind,
|
||||
to: e.to.trim(),
|
||||
data: normalizeHexData(e.data),
|
||||
valueWei: e.valueWei ?? '0',
|
||||
sequencingNote: seq,
|
||||
});
|
||||
} else {
|
||||
const data = encodeSwapExactInCalldata(
|
||||
e.pool.trim(),
|
||||
e.tokenIn.trim(),
|
||||
BigInt(e.amountInWei),
|
||||
BigInt(e.minOutWei)
|
||||
);
|
||||
steps.push({
|
||||
legId: leg.id,
|
||||
kind: leg.kind,
|
||||
to: e.integrationTo.trim(),
|
||||
data,
|
||||
valueWei: '0',
|
||||
sequencingNote: seq,
|
||||
});
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
export type ExecPlanSimulationRow = ExecPlanResolvedStep & {
|
||||
ok: boolean;
|
||||
allowlistOk?: boolean;
|
||||
gasEstimate?: string;
|
||||
error?: string;
|
||||
allowlistError?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `eth_call` + `estimateGas` per step with the same allowlist rules as `exec`.
|
||||
* Does **not** broadcast transactions.
|
||||
*/
|
||||
export async function simulateExecPlan(params: {
|
||||
def: StrategyDefinition;
|
||||
rpcUrl: string;
|
||||
from: string;
|
||||
allowlistPath: string;
|
||||
}): Promise<ExecPlanSimulationRow[]> {
|
||||
const steps = buildExecPlan(params.def);
|
||||
const out: ExecPlanSimulationRow[] = [];
|
||||
for (const s of steps) {
|
||||
let valueWei = 0n;
|
||||
try {
|
||||
valueWei = BigInt(s.valueWei || '0');
|
||||
} catch {
|
||||
valueWei = 0n;
|
||||
}
|
||||
const r = await enforceAllowlistAndSimulate({
|
||||
rpcUrl: params.rpcUrl,
|
||||
allowlistPath: params.allowlistPath,
|
||||
from: params.from,
|
||||
to: s.to,
|
||||
data: s.data,
|
||||
valueWei,
|
||||
});
|
||||
out.push({
|
||||
...s,
|
||||
ok: r.ok,
|
||||
allowlistOk: r.allowlistOk,
|
||||
gasEstimate: r.gasEstimate?.toString(),
|
||||
error: r.error,
|
||||
allowlistError: r.allowlistError,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
38
packages/economics-toolkit/src/strategy-expr.test.ts
Normal file
38
packages/economics-toolkit/src/strategy-expr.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { evaluateDerivedExpr, assertValidDerivedExprShape } from './strategy-expr.js';
|
||||
import type { StrategyLegEvaluated } from './strategy-engine.js';
|
||||
import type { DerivedExpr } from './strategy-types.js';
|
||||
|
||||
function mockEval(id: string, residual: number, grossEff: number): StrategyLegEvaluated {
|
||||
return {
|
||||
id,
|
||||
kind: 'spot_swap',
|
||||
economics: {
|
||||
netAfterFlashPct: residual,
|
||||
bucketsTotalPct: 0.05,
|
||||
residualPct: residual,
|
||||
passesResidual: true,
|
||||
sensitivity: [],
|
||||
},
|
||||
effectiveFlashFeePctOfBase: 0,
|
||||
grossEffectivePct: grossEff,
|
||||
fixedCostUsdt: 0,
|
||||
weight: 1,
|
||||
};
|
||||
}
|
||||
|
||||
test('evaluateDerivedExpr: mul ref by const', () => {
|
||||
const m = new Map<string, StrategyLegEvaluated>();
|
||||
m.set('a', mockEval('a', 0.4, 0.5));
|
||||
const expr: DerivedExpr = {
|
||||
op: 'mul',
|
||||
args: [{ ref: { legId: 'a', field: 'residualPct' } }, { const: 0.5 }],
|
||||
};
|
||||
const v = evaluateDerivedExpr(expr, m);
|
||||
assert.ok(Math.abs(v - 0.2) < 1e-9);
|
||||
});
|
||||
|
||||
test('assertValidDerivedExprShape rejects unknown op', () => {
|
||||
assert.throws(() => assertValidDerivedExprShape({ op: 'evil', args: [] }), /unknown/);
|
||||
});
|
||||
129
packages/economics-toolkit/src/strategy-expr.ts
Normal file
129
packages/economics-toolkit/src/strategy-expr.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { StrategyLegEvaluated } from './strategy-engine.js'; // type-only: no runtime cycle
|
||||
import type { DerivedExpr, StrategyRefField } from './strategy-types.js';
|
||||
|
||||
export function refValueFromEvaluated(ev: StrategyLegEvaluated, field: StrategyRefField): number {
|
||||
switch (field) {
|
||||
case 'residualPct':
|
||||
return ev.economics.residualPct;
|
||||
case 'netAfterFlashPct':
|
||||
return ev.economics.netAfterFlashPct;
|
||||
case 'grossEffectivePct':
|
||||
return ev.grossEffectivePct;
|
||||
case 'bucketsTotalPct':
|
||||
return ev.economics.bucketsTotalPct;
|
||||
default: {
|
||||
const _x: never = field;
|
||||
return _x;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateDerivedExpr(
|
||||
expr: DerivedExpr,
|
||||
idToEval: Map<string, StrategyLegEvaluated>
|
||||
): number {
|
||||
if ('const' in expr && typeof expr.const === 'number') {
|
||||
return expr.const;
|
||||
}
|
||||
if ('ref' in expr && expr.ref?.legId && expr.ref?.field) {
|
||||
const ev = idToEval.get(expr.ref.legId);
|
||||
if (!ev) {
|
||||
throw new Error(`derivedExpr: ref legId "${expr.ref.legId}" not evaluated yet (must be earlier in legs[])`);
|
||||
}
|
||||
return refValueFromEvaluated(ev, expr.ref.field);
|
||||
}
|
||||
if ('op' in expr) {
|
||||
if (expr.op === 'neg') {
|
||||
const a = 'arg' in expr ? expr.arg : undefined;
|
||||
if (!a) throw new Error('derivedExpr: neg requires arg');
|
||||
return -evaluateDerivedExpr(a, idToEval);
|
||||
}
|
||||
const args = expr.args;
|
||||
if (!Array.isArray(args) || args.length === 0) {
|
||||
throw new Error(`derivedExpr: ${expr.op} requires non-empty args`);
|
||||
}
|
||||
const vals = args.map((x) => evaluateDerivedExpr(x, idToEval));
|
||||
switch (expr.op) {
|
||||
case 'add':
|
||||
return vals.reduce((s, v) => s + v, 0);
|
||||
case 'sub':
|
||||
return vals.slice(1).reduce((s, v) => s - v, vals[0]);
|
||||
case 'mul':
|
||||
return vals.reduce((s, v) => s * v, 1);
|
||||
case 'div': {
|
||||
if (vals.length !== 2) throw new Error('derivedExpr: div expects exactly 2 args');
|
||||
const den = vals[1];
|
||||
if (Math.abs(den) < 1e-15) throw new Error('derivedExpr: div by zero');
|
||||
return vals[0] / den;
|
||||
}
|
||||
case 'min':
|
||||
return Math.min(...vals);
|
||||
case 'max':
|
||||
return Math.max(...vals);
|
||||
default: {
|
||||
const _o: never = expr;
|
||||
return _o as never;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('derivedExpr: invalid node');
|
||||
}
|
||||
|
||||
/** Collect leg ids referenced by ref nodes (for validation). */
|
||||
export function collectDerivedExprRefLegIds(expr: DerivedExpr): string[] {
|
||||
if ('const' in expr) return [];
|
||||
if ('ref' in expr) return [expr.ref.legId];
|
||||
if ('op' in expr && expr.op === 'neg') {
|
||||
return 'arg' in expr && expr.arg ? collectDerivedExprRefLegIds(expr.arg) : [];
|
||||
}
|
||||
if ('op' in expr && 'args' in expr && Array.isArray(expr.args)) {
|
||||
return expr.args.flatMap((a) => collectDerivedExprRefLegIds(a));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function assertValidDerivedExprShape(expr: unknown, path = 'derivedExpr'): asserts expr is DerivedExpr {
|
||||
if (expr === null || typeof expr !== 'object') {
|
||||
throw new Error(`strategy: ${path} must be an object`);
|
||||
}
|
||||
const o = expr as Record<string, unknown>;
|
||||
if ('const' in o) {
|
||||
if (typeof o.const !== 'number' || Number.isNaN(o.const)) {
|
||||
throw new Error(`strategy: ${path}.const must be a number`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ('ref' in o) {
|
||||
const r = o.ref;
|
||||
if (!r || typeof r !== 'object') throw new Error(`strategy: ${path}.ref object required`);
|
||||
const rr = r as Record<string, unknown>;
|
||||
if (typeof rr.legId !== 'string' || !rr.legId) throw new Error(`strategy: ${path}.ref.legId required`);
|
||||
const allowed: StrategyRefField[] = ['residualPct', 'netAfterFlashPct', 'grossEffectivePct', 'bucketsTotalPct'];
|
||||
if (typeof rr.field !== 'string' || !allowed.includes(rr.field as StrategyRefField)) {
|
||||
throw new Error(`strategy: ${path}.ref.field must be one of: ${allowed.join(', ')}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ('op' in o) {
|
||||
const op = o.op;
|
||||
if (op === 'neg') {
|
||||
if (!('arg' in o) || o.arg === undefined) throw new Error(`strategy: ${path}.neg requires arg`);
|
||||
assertValidDerivedExprShape(o.arg, `${path}.arg`);
|
||||
return;
|
||||
}
|
||||
if (op !== 'add' && op !== 'sub' && op !== 'mul' && op !== 'div' && op !== 'min' && op !== 'max') {
|
||||
throw new Error(`strategy: ${path}.op unknown`);
|
||||
}
|
||||
if (!Array.isArray(o.args) || o.args.length === 0) {
|
||||
throw new Error(`strategy: ${path}.${String(op)} requires args[]`);
|
||||
}
|
||||
if (op === 'div' && o.args.length !== 2) {
|
||||
throw new Error(`strategy: ${path}.div requires exactly 2 args`);
|
||||
}
|
||||
for (let i = 0; i < o.args.length; i++) {
|
||||
assertValidDerivedExprShape(o.args[i], `${path}.args[${i}]`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`strategy: ${path} must be const, ref, or op node`);
|
||||
}
|
||||
63
packages/economics-toolkit/src/strategy-io.ts
Normal file
63
packages/economics-toolkit/src/strategy-io.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { StrategyDefinition, StrategyLegKind } from './strategy-types.js';
|
||||
import { STRATEGY_LEG_KINDS } from './strategy-types.js';
|
||||
import { assertValidDerivedExprShape, collectDerivedExprRefLegIds } from './strategy-expr.js';
|
||||
|
||||
const KIND_SET = new Set<string>(STRATEGY_LEG_KINDS);
|
||||
|
||||
export function parseStrategyJson(raw: string): StrategyDefinition {
|
||||
const j = JSON.parse(raw) as StrategyDefinition;
|
||||
if (j.version !== 1) {
|
||||
throw new Error(`strategy: unsupported version ${String((j as { version?: unknown }).version)}`);
|
||||
}
|
||||
if (typeof j.name !== 'string' || !j.name.trim()) {
|
||||
throw new Error('strategy: name required');
|
||||
}
|
||||
if (typeof j.baseNotionalUsdt !== 'number' || j.baseNotionalUsdt <= 0) {
|
||||
throw new Error('strategy: baseNotionalUsdt must be > 0');
|
||||
}
|
||||
if (j.aggregateMode !== 'linear_pct_sum' && j.aggregateMode !== 'sequential_compound_usd') {
|
||||
throw new Error('strategy: aggregateMode must be linear_pct_sum | sequential_compound_usd');
|
||||
}
|
||||
if (!Array.isArray(j.legs) || j.legs.length === 0) {
|
||||
throw new Error('strategy: legs[] required');
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < j.legs.length; i++) {
|
||||
const leg = j.legs[i];
|
||||
if (!leg.id || typeof leg.id !== 'string') throw new Error('strategy: each leg needs id');
|
||||
if (ids.has(leg.id)) throw new Error(`strategy: duplicate leg id "${leg.id}"`);
|
||||
ids.add(leg.id);
|
||||
if (!leg.kind || typeof leg.kind !== 'string') throw new Error(`strategy: leg ${leg.id} needs kind`);
|
||||
if (!KIND_SET.has(leg.kind)) {
|
||||
throw new Error(
|
||||
`strategy: leg ${leg.id} unknown kind "${leg.kind}". Use: economics-toolkit strategy kinds`
|
||||
);
|
||||
}
|
||||
const df = (leg as { derivedFrom?: { fromLegId?: string } }).derivedFrom;
|
||||
const dex = (leg as { derivedExpr?: unknown }).derivedExpr;
|
||||
if (dex != null && df?.fromLegId) {
|
||||
throw new Error(`strategy: leg ${leg.id} cannot set both derivedExpr and derivedFrom`);
|
||||
}
|
||||
if (dex != null) {
|
||||
assertValidDerivedExprShape(dex, `leg ${leg.id} derivedExpr`);
|
||||
const refs = collectDerivedExprRefLegIds(dex);
|
||||
for (const rid of refs) {
|
||||
const prior = j.legs.slice(0, i).some((x) => x.id === rid);
|
||||
if (!prior) {
|
||||
throw new Error(
|
||||
`strategy: leg ${leg.id} derivedExpr references "${rid}" which is not an earlier leg in legs[]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (df?.fromLegId) {
|
||||
const prior = j.legs.slice(0, i).some((x) => x.id === df.fromLegId);
|
||||
if (!prior) {
|
||||
throw new Error(
|
||||
`strategy: leg ${leg.id} derivedFrom.fromLegId "${df.fromLegId}" must refer to an earlier leg in legs[]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return j as StrategyDefinition;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { optimizeStrategyRandomSearch, optimizeStrategyCoordinateDescent } from './strategy-optimize-stochastic.js';
|
||||
import type { StrategyDefinition } from './strategy-types.js';
|
||||
|
||||
test('optimizeStrategyRandomSearch improves with deterministic rng', () => {
|
||||
const def: StrategyDefinition = {
|
||||
version: 1,
|
||||
name: 'r',
|
||||
baseNotionalUsdt: 100,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [
|
||||
{
|
||||
id: 'a',
|
||||
kind: 'flash_arb_path',
|
||||
grossPct: 0.15,
|
||||
flashFeePct: 0.09,
|
||||
gasPctOfNotional: 0.05,
|
||||
liquidityPct: 0.01,
|
||||
},
|
||||
],
|
||||
};
|
||||
let x = 0.12345;
|
||||
const rnd = () => {
|
||||
x = (x * 9301 + 49297) % 233280;
|
||||
return x / 233280;
|
||||
};
|
||||
const out = optimizeStrategyRandomSearch({
|
||||
def,
|
||||
bounds: [{ legId: 'a', param: 'grossPct', min: 0.1, max: 0.5 }],
|
||||
samples: 80,
|
||||
objective: 'max_linear_residual',
|
||||
rnd,
|
||||
});
|
||||
assert.equal(out.trials, 81);
|
||||
const g = out.bestValues['a.grossPct'];
|
||||
assert.ok(g >= 0.1 && g <= 0.5);
|
||||
});
|
||||
|
||||
test('optimizeStrategyCoordinateDescent pushes gross toward upper bound', () => {
|
||||
const def: StrategyDefinition = {
|
||||
version: 1,
|
||||
name: 'cd',
|
||||
baseNotionalUsdt: 100,
|
||||
aggregateMode: 'linear_pct_sum',
|
||||
legs: [
|
||||
{
|
||||
id: 'a',
|
||||
kind: 'flash_arb_path',
|
||||
grossPct: 0.12,
|
||||
flashFeePct: 0.09,
|
||||
gasPctOfNotional: 0.05,
|
||||
liquidityPct: 0.01,
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = optimizeStrategyCoordinateDescent({
|
||||
def,
|
||||
bounds: [{ legId: 'a', param: 'grossPct', min: 0.1, max: 0.8 }],
|
||||
objective: 'max_linear_residual',
|
||||
rounds: 4,
|
||||
lineGridSteps: 10,
|
||||
});
|
||||
assert.ok(out.bestValues['a.grossPct'] >= 0.7);
|
||||
});
|
||||
197
packages/economics-toolkit/src/strategy-optimize-stochastic.ts
Normal file
197
packages/economics-toolkit/src/strategy-optimize-stochastic.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { evaluateStrategy, type StrategyEvaluationResult } from './strategy-engine.js';
|
||||
import type { StrategyDefinition, StrategyLegInput, StrategyOptimizerParam } from './strategy-types.js';
|
||||
|
||||
export interface OptimizerBound {
|
||||
legId: string;
|
||||
param: StrategyOptimizerParam;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
return function () {
|
||||
let t = (seed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function getLegOptimizerParam(leg: StrategyLegInput, p: StrategyOptimizerParam): number {
|
||||
switch (p) {
|
||||
case 'grossPct':
|
||||
return leg.grossPct ?? 0;
|
||||
case 'flashNotionalMultiple':
|
||||
return leg.flashNotionalMultiple ?? 1;
|
||||
case 'gasPctOfNotional':
|
||||
return leg.gasPctOfNotional ?? 0;
|
||||
case 'liquidityPct':
|
||||
return leg.liquidityPct ?? 0;
|
||||
case 'flashFeePct':
|
||||
return leg.flashFeePct ?? 0;
|
||||
case 'minProfitPct':
|
||||
return leg.minProfitPct ?? 0;
|
||||
default: {
|
||||
const _e: never = p;
|
||||
return _e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setLegParam(
|
||||
def: StrategyDefinition,
|
||||
legId: string,
|
||||
param: StrategyOptimizerParam,
|
||||
value: number
|
||||
): StrategyDefinition {
|
||||
const legs = def.legs.map((l) => (l.id === legId ? { ...l, [param]: value } : l));
|
||||
return { ...def, legs };
|
||||
}
|
||||
|
||||
function applyBoundsToDef(
|
||||
base: StrategyDefinition,
|
||||
bounds: OptimizerBound[],
|
||||
values: number[]
|
||||
): StrategyDefinition {
|
||||
let d = base;
|
||||
for (let i = 0; i < bounds.length; i++) {
|
||||
const b = bounds[i];
|
||||
d = setLegParam(d, b.legId, b.param, values[i]);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function randomVector(bounds: OptimizerBound[], rnd: () => number): number[] {
|
||||
return bounds.map((b) => {
|
||||
if (b.min > b.max) throw new Error(`optimize: bound ${b.legId}.${b.param} has min > max`);
|
||||
return b.min + rnd() * (b.max - b.min);
|
||||
});
|
||||
}
|
||||
|
||||
function linspace(min: number, max: number, steps: number): number[] {
|
||||
if (min > max) throw new Error('optimize: linspace min > max');
|
||||
if (steps < 2) return [min];
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < steps; i++) {
|
||||
out.push(min + ((max - min) * i) / (steps - 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function better(
|
||||
a: StrategyEvaluationResult,
|
||||
b: StrategyEvaluationResult,
|
||||
objective: 'max_compound_return' | 'max_linear_residual' | 'all_pass'
|
||||
): boolean {
|
||||
if (objective === 'all_pass') {
|
||||
if (a.allLegsPass !== b.allLegsPass) return a.allLegsPass && !b.allLegsPass;
|
||||
return a.impliedTotalReturnPctCompound > b.impliedTotalReturnPctCompound;
|
||||
}
|
||||
if (objective === 'max_compound_return') {
|
||||
return a.impliedTotalReturnPctCompound > b.impliedTotalReturnPctCompound;
|
||||
}
|
||||
return a.totalResidualPctLinear > b.totalResidualPctLinear;
|
||||
}
|
||||
|
||||
function boundKey(bounds: OptimizerBound[], i: number): string {
|
||||
return `${bounds[i].legId}.${bounds[i].param}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joint search via uniform random sampling in the box (min,max) per bound.
|
||||
* Always evaluates the starting `def` first, then `samples` additional draws.
|
||||
*/
|
||||
export function optimizeStrategyRandomSearch(params: {
|
||||
def: StrategyDefinition;
|
||||
bounds: OptimizerBound[];
|
||||
samples: number;
|
||||
objective: 'max_compound_return' | 'max_linear_residual' | 'all_pass';
|
||||
seed?: number;
|
||||
rnd?: () => number;
|
||||
}): {
|
||||
bestValues: Record<string, number>;
|
||||
bestResult: StrategyEvaluationResult;
|
||||
trials: number;
|
||||
} {
|
||||
const bounds = params.bounds;
|
||||
if (!bounds.length) throw new Error('optimizeStrategyRandomSearch: bounds[] required');
|
||||
const rnd = params.rnd ?? mulberry32(params.seed ?? (Date.now() % 2147483647));
|
||||
|
||||
let bestResult = evaluateStrategy(params.def);
|
||||
let vec = bounds.map((b) => {
|
||||
const leg = params.def.legs.find((l) => l.id === b.legId);
|
||||
if (!leg) throw new Error(`optimize: missing leg ${b.legId}`);
|
||||
return getLegOptimizerParam(leg, b.param);
|
||||
});
|
||||
|
||||
for (let t = 0; t < params.samples; t++) {
|
||||
const tryVec = randomVector(bounds, rnd);
|
||||
const cand = applyBoundsToDef(params.def, bounds, tryVec);
|
||||
const r = evaluateStrategy(cand);
|
||||
if (better(r, bestResult, params.objective)) {
|
||||
bestResult = r;
|
||||
vec = tryVec;
|
||||
}
|
||||
}
|
||||
|
||||
const bestValues: Record<string, number> = {};
|
||||
for (let i = 0; i < bounds.length; i++) {
|
||||
bestValues[boundKey(bounds, i)] = vec[i];
|
||||
}
|
||||
return { bestValues, bestResult, trials: 1 + params.samples };
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy coordinate search: each round sweeps every axis on a grid while holding others at the incumbent.
|
||||
*/
|
||||
export function optimizeStrategyCoordinateDescent(params: {
|
||||
def: StrategyDefinition;
|
||||
bounds: OptimizerBound[];
|
||||
objective: 'max_compound_return' | 'max_linear_residual' | 'all_pass';
|
||||
rounds?: number;
|
||||
lineGridSteps?: number;
|
||||
}): {
|
||||
bestValues: Record<string, number>;
|
||||
bestResult: StrategyEvaluationResult;
|
||||
axisEvaluations: number;
|
||||
} {
|
||||
const bounds = params.bounds;
|
||||
if (!bounds.length) throw new Error('optimizeStrategyCoordinateDescent: bounds[] required');
|
||||
const rounds = params.rounds ?? 8;
|
||||
const lineGridSteps = params.lineGridSteps ?? 12;
|
||||
|
||||
let bestResult = evaluateStrategy(params.def);
|
||||
let vec = bounds.map((b) => {
|
||||
const leg = params.def.legs.find((l) => l.id === b.legId);
|
||||
if (!leg) throw new Error(`optimize: missing leg ${b.legId}`);
|
||||
return getLegOptimizerParam(leg, b.param);
|
||||
});
|
||||
let axisEvaluations = 1;
|
||||
|
||||
for (let r = 0; r < rounds; r++) {
|
||||
let improvedRound = false;
|
||||
for (let d = 0; d < bounds.length; d++) {
|
||||
const b = bounds[d];
|
||||
const grid = linspace(b.min, b.max, lineGridSteps);
|
||||
for (const v of grid) {
|
||||
const tryVec = [...vec];
|
||||
tryVec[d] = v;
|
||||
const cand = applyBoundsToDef(params.def, bounds, tryVec);
|
||||
const res = evaluateStrategy(cand);
|
||||
axisEvaluations++;
|
||||
if (better(res, bestResult, params.objective)) {
|
||||
bestResult = res;
|
||||
vec = tryVec;
|
||||
improvedRound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!improvedRound) break;
|
||||
}
|
||||
|
||||
const bestValues: Record<string, number> = {};
|
||||
for (let i = 0; i < bounds.length; i++) {
|
||||
bestValues[boundKey(bounds, i)] = vec[i];
|
||||
}
|
||||
return { bestValues, bestResult, axisEvaluations };
|
||||
}
|
||||
105
packages/economics-toolkit/src/strategy-runbook.ts
Normal file
105
packages/economics-toolkit/src/strategy-runbook.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { StrategyDefinition, StrategyLegKind } from './strategy-types.js';
|
||||
|
||||
export interface RunbookStep {
|
||||
order: number;
|
||||
legId: string;
|
||||
kind: StrategyLegKind;
|
||||
suggestedCommands: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface StrategyRunbook {
|
||||
name: string;
|
||||
baseNotionalUsdt: number;
|
||||
steps: RunbookStep[];
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
function cmdForLeg(leg: StrategyDefinition['legs'][0], baseN: number): string[] {
|
||||
const cmds: string[] = [];
|
||||
const g = leg.grossPct ?? 0;
|
||||
const f = leg.flashFeePct ?? 0;
|
||||
const gas = leg.gasPctOfNotional ?? 0;
|
||||
const liq = leg.liquidityPct ?? 0;
|
||||
const minP = leg.minProfitPct ?? 0;
|
||||
|
||||
cmds.push(
|
||||
`economics-toolkit calc --gross ${g} --flash ${f}` +
|
||||
(gas ? ` --gas ${gas}` : '') +
|
||||
(liq ? ` --liquidity ${liq}` : '') +
|
||||
(minP ? ` --min-profit ${minP}` : '')
|
||||
);
|
||||
|
||||
if (leg.enrichPathCheck) {
|
||||
const e = leg.enrichPathCheck;
|
||||
cmds.push(
|
||||
`economics-toolkit path-check --rpc $RPC_URL_138 --pool ${e.poolAddress} --token-in ${e.tokenIn} --amount-in ${e.amountInWei}` +
|
||||
` --flash ${e.flashFeePct ?? f} --gas ${e.gasPctOfNotional ?? gas} --liquidity ${e.liquidityPct ?? liq}` +
|
||||
(e.minProfitPct != null ? ` --min-profit ${e.minProfitPct}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
if (leg.kind === 'flash_arb_path' || leg.kind === 'spot_swap') {
|
||||
cmds.push(
|
||||
'# Then: economics-toolkit prepare-swap --pool <DODO_POOL> --token-in <addr> --amount-in <wei> --min-out <wei>'
|
||||
);
|
||||
cmds.push(
|
||||
'# Then: economics-toolkit exec --rpc $RPC_URL_138 --from <EOA> --to <DODOPMMIntegration> --data <0x...> --allowlist <path>'
|
||||
);
|
||||
}
|
||||
|
||||
if (leg.enrichGas) {
|
||||
cmds.push(
|
||||
`economics-toolkit gas-quote --chains ${leg.enrichGas.chainId} --gas-units ${leg.enrichGas.gasUnits} --notional-usdt ${baseN}`
|
||||
);
|
||||
}
|
||||
|
||||
if (leg.exec) {
|
||||
cmds.push(
|
||||
'economics-toolkit strategy exec-plan --file <this-strategy.json> --simulate --rpc $RPC_URL_138 --from <EOA> --allowlist <path>'
|
||||
);
|
||||
if (leg.exec.mode === 'raw') {
|
||||
cmds.push(`# Resolved to: --to ${leg.exec.to} (see exec-plan JSON for full calldata)`);
|
||||
} else {
|
||||
cmds.push(
|
||||
`# PMM swap: integration ${leg.exec.integrationTo} pool ${leg.exec.pool} tokenIn ${leg.exec.tokenIn}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return cmds;
|
||||
}
|
||||
|
||||
export function buildStrategyRunbook(def: StrategyDefinition): StrategyRunbook {
|
||||
const steps: RunbookStep[] = def.legs.map((leg, i) => ({
|
||||
order: i + 1,
|
||||
legId: leg.id,
|
||||
kind: leg.kind,
|
||||
suggestedCommands: cmdForLeg(leg, def.baseNotionalUsdt),
|
||||
notes: leg.notes,
|
||||
}));
|
||||
|
||||
const lines: string[] = [
|
||||
`# Runbook: ${def.name}`,
|
||||
'',
|
||||
`- Base notional (USDT): ${def.baseNotionalUsdt}`,
|
||||
`- Aggregate mode: ${def.aggregateMode}`,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const s of steps) {
|
||||
lines.push(`## ${s.order}. ${s.legId} (${s.kind})`);
|
||||
if (s.notes) lines.push('', s.notes, '');
|
||||
for (const c of s.suggestedCommands) {
|
||||
lines.push('- ' + c);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return {
|
||||
name: def.name,
|
||||
baseNotionalUsdt: def.baseNotionalUsdt,
|
||||
steps,
|
||||
markdown: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
173
packages/economics-toolkit/src/strategy-types.ts
Normal file
173
packages/economics-toolkit/src/strategy-types.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Strategy building blocks for multi-leg economics (budgeting; not on-chain execution).
|
||||
*/
|
||||
|
||||
export const STRATEGY_LEG_KINDS = [
|
||||
'spot_swap',
|
||||
'flash_arb_path',
|
||||
'collateral_toggle',
|
||||
'debt_swap',
|
||||
'collateral_swap',
|
||||
'unwind_repay',
|
||||
'unwind_withdraw',
|
||||
'unwind_full',
|
||||
'bridge_transfer',
|
||||
'lp_add',
|
||||
'lp_remove',
|
||||
'liquidation_harvest',
|
||||
'margin_rebalance',
|
||||
'intent_fill',
|
||||
'rollup_batch',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
export type StrategyLegKind = (typeof STRATEGY_LEG_KINDS)[number];
|
||||
|
||||
export const STRATEGY_LEG_DESCRIPTIONS: Record<StrategyLegKind, string> = {
|
||||
spot_swap: 'Single-venue or routed swap; optional mevDragBps / slippageBps for sandwich and path risk.',
|
||||
flash_arb_path:
|
||||
'Borrow → trade → repay in one tx; flashFeePct is % of borrow; use flashNotionalMultiple for B/N; optional mevDragBps.',
|
||||
collateral_toggle: 'Move or swap collateral venue (gas + slippage in buckets; optional gross if yield delta).',
|
||||
debt_swap: 'Refinance debt asset or venue (flash-assisted or sequential; fees in gas/liq).',
|
||||
collateral_swap: 'Change collateral asset while maintaining borrow (slippage + protocol fees).',
|
||||
unwind_repay: 'Repay debt leg of unwind (interest in gross or fixedCostUsdt).',
|
||||
unwind_withdraw: 'Withdraw collateral after repay.',
|
||||
unwind_full: 'Modeled combined repay+withdraw; split into two legs for accuracy.',
|
||||
bridge_transfer: 'Cross-chain move; model bridge fee as gross drag or fixedCostUsdt.',
|
||||
lp_add: 'Add liquidity; optional ilDragBps models expected IL drag (same units as slippageBps).',
|
||||
lp_remove: 'Remove liquidity; optional ilDragBps if exiting into volatile inventory.',
|
||||
liquidation_harvest:
|
||||
'Liquidation bonus as positive grossPct; optional liquidationCompetitionBps haircuts bonus; mevDragBps for ordering risk.',
|
||||
margin_rebalance: 'LTV / health factor adjustment across venues.',
|
||||
intent_fill: 'Solver / intent settlement; gas often higher.',
|
||||
rollup_batch: 'L2 batch cost amortization—use fixedCostUsdt or low gasPct.',
|
||||
custom: 'User-defined; describe in notes.',
|
||||
};
|
||||
|
||||
export type StrategyAggregateMode = 'linear_pct_sum' | 'sequential_compound_usd';
|
||||
|
||||
/** Referencable outputs from an already-evaluated prior leg (used by derivedExpr / derivedFrom). */
|
||||
export type StrategyRefField =
|
||||
| 'residualPct'
|
||||
| 'netAfterFlashPct'
|
||||
| 'grossEffectivePct'
|
||||
| 'bucketsTotalPct';
|
||||
|
||||
/**
|
||||
* Safe arithmetic tree (no string eval). All `ref.legId` must refer to **earlier** legs in `legs[]`.
|
||||
* Example: `{ "op": "mul", "args": [ { "ref": { "legId": "a", "field": "residualPct" } }, { "const": 0.5 } ] }`
|
||||
*/
|
||||
export type DerivedExpr =
|
||||
| { const: number }
|
||||
| { ref: { legId: string; field: StrategyRefField } }
|
||||
| { op: 'neg'; arg: DerivedExpr }
|
||||
| { op: 'add' | 'sub' | 'mul' | 'div' | 'min' | 'max'; args: DerivedExpr[] };
|
||||
|
||||
/** Declarative calldata for `strategy exec-plan` (simulation-only in CLI; never auto-broadcast). */
|
||||
export type StrategyLegExec =
|
||||
| {
|
||||
mode: 'raw';
|
||||
to: string;
|
||||
data: string;
|
||||
valueWei?: string;
|
||||
}
|
||||
| {
|
||||
mode: 'pmm_swap_exact_in';
|
||||
integrationTo: string;
|
||||
pool: string;
|
||||
tokenIn: string;
|
||||
amountInWei: string;
|
||||
minOutWei: string;
|
||||
};
|
||||
|
||||
export interface StrategyLegInput {
|
||||
id: string;
|
||||
kind: StrategyLegKind;
|
||||
/** Expected edge / carry before flash and buckets (% of base notional unless notes say otherwise). */
|
||||
grossPct?: number;
|
||||
/** Flash fee as % of borrowed principal (not of base N unless multiple=1). */
|
||||
flashFeePct?: number;
|
||||
/** Borrow principal B = baseNotional × multiple (fees scale with B). Default 1. */
|
||||
flashNotionalMultiple?: number;
|
||||
gasPctOfNotional?: number;
|
||||
liquidityPct?: number;
|
||||
minProfitPct?: number;
|
||||
/** Absolute USDT cost after this leg (gas in USD, protocol flat fee, etc.). */
|
||||
fixedCostUsdt?: number;
|
||||
/** Optional weight for linear sum (default 1). */
|
||||
weight?: number;
|
||||
notes?: string;
|
||||
/**
|
||||
* Basis points (1 bp = 0.01 percentage points) subtracted from gross before economics.
|
||||
* Models expected execution slippage vs quoted edge.
|
||||
*/
|
||||
slippageBps?: number;
|
||||
/** Protocol fee drag in bps, same units as slippageBps. */
|
||||
protocolFeeBps?: number;
|
||||
/** Expected MEV / ordering / sandwich drag (bps subtracted from gross). */
|
||||
mevDragBps?: number;
|
||||
/** Impermanent-loss or mark-to-market drag for LP legs (bps subtracted from gross). */
|
||||
ilDragBps?: number;
|
||||
/** Haircut on modeled liquidation bonus from competition / gas wars (bps subtracted from gross). */
|
||||
liquidationCompetitionBps?: number;
|
||||
/**
|
||||
* Override gross from a prior leg’s evaluated economics. `fromLegId` must appear **earlier** in `legs[]`.
|
||||
* Formula: `scale * ref[field] + (offsetPct ?? 0)`.
|
||||
* Mutually exclusive with `derivedExpr`.
|
||||
*/
|
||||
derivedFrom?: {
|
||||
fromLegId: string;
|
||||
field: StrategyRefField;
|
||||
scale?: number;
|
||||
offsetPct?: number;
|
||||
};
|
||||
/** Composable gross from prior legs; mutually exclusive with `derivedFrom`. */
|
||||
derivedExpr?: DerivedExpr;
|
||||
/** Optional executable step; see `strategy exec-plan` (allowlist + eth_call only). */
|
||||
exec?: StrategyLegExec;
|
||||
/** When present, `strategy enrich` can fill `grossPct` from on-chain PMM quote (Chain 138). */
|
||||
enrichPathCheck?: {
|
||||
poolAddress: string;
|
||||
tokenIn: string;
|
||||
amountInWei: string;
|
||||
traderForView?: string;
|
||||
decimals?: number;
|
||||
flashFeePct?: number;
|
||||
gasPctOfNotional?: number;
|
||||
liquidityPct?: number;
|
||||
minProfitPct?: number;
|
||||
};
|
||||
/** When present, `strategy enrich` can fill `gasPctOfNotional` from `gas-quote` math for one chain. */
|
||||
enrichGas?: {
|
||||
chainId: number;
|
||||
gasUnits: string | number;
|
||||
skipUsd?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Single dimension for `optimizeStrategyMultiDim` / CLI `optimize-multi`. */
|
||||
export type StrategyOptimizerParam =
|
||||
| 'grossPct'
|
||||
| 'flashNotionalMultiple'
|
||||
| 'gasPctOfNotional'
|
||||
| 'liquidityPct'
|
||||
| 'flashFeePct'
|
||||
| 'minProfitPct';
|
||||
|
||||
export interface StrategyOptimizerDimension {
|
||||
legId: string;
|
||||
param: StrategyOptimizerParam;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}
|
||||
|
||||
export interface StrategyDefinition {
|
||||
version: 1;
|
||||
name: string;
|
||||
baseNotionalUsdt: number;
|
||||
aggregateMode: StrategyAggregateMode;
|
||||
legs: StrategyLegInput[];
|
||||
/** Optional tags for your own CI / reports. */
|
||||
tags?: string[];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { formatUnits, getAddress } from 'ethers';
|
||||
import type { SwapQuoteRequest, SwapQuoteSuccess, SwapQuoteFailure } from './types.js';
|
||||
import { fetchErc20Decimals } from './erc20-meta.js';
|
||||
import { finalizeSwapSuccess } from './swap-quote-common.js';
|
||||
|
||||
const DEFAULT_GQL = 'https://api-v3.balancer.fi/graphql';
|
||||
|
||||
/** Balancer API `GqlChain` enum names for SOR (subset). */
|
||||
const CHAIN_TO_GQL: Record<number, string> = {
|
||||
1: 'MAINNET',
|
||||
5: 'GOERLI',
|
||||
10: 'OPTIMISM',
|
||||
137: 'POLYGON',
|
||||
42161: 'ARBITRUM',
|
||||
100: 'GNOSIS',
|
||||
8453: 'BASE',
|
||||
1101: 'POLYGON_ZKEVM',
|
||||
43114: 'AVALANCHE',
|
||||
56: 'BNB',
|
||||
};
|
||||
|
||||
/**
|
||||
* Balancer Smart Order Router quote via public GraphQL API (Balancer pools).
|
||||
* @see https://docs.balancer.fi/data-and-analytics/data-and-analytics/balancer-api/swap-query-sor.html
|
||||
*/
|
||||
export async function quoteBalancer(req: SwapQuoteRequest): Promise<SwapQuoteSuccess | SwapQuoteFailure> {
|
||||
const engine = 'balancer' as const;
|
||||
try {
|
||||
const gqlChain = req.balancerSorChain?.trim() || CHAIN_TO_GQL[req.chainId];
|
||||
if (!gqlChain) {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: `Balancer SOR: unsupported chainId ${req.chainId}; set balancerSorChain to a GqlChain name (e.g. MAINNET)`,
|
||||
};
|
||||
}
|
||||
|
||||
const decimalsIn = await fetchErc20Decimals(req.rpcUrl, req.tokenIn);
|
||||
const swapAmountHuman = formatUnits(req.amountIn, decimalsIn);
|
||||
const tokenIn = getAddress(req.tokenIn.toLowerCase());
|
||||
const tokenOut = getAddress(req.tokenOut.toLowerCase());
|
||||
|
||||
const query = `
|
||||
query SorQuote($chain: GqlChain!, $swapAmount: String!, $tokenIn: String!, $tokenOut: String!) {
|
||||
sorGetSwapPaths(
|
||||
chain: $chain
|
||||
swapType: EXACT_IN
|
||||
swapAmount: $swapAmount
|
||||
tokenIn: $tokenIn
|
||||
tokenOut: $tokenOut
|
||||
) {
|
||||
returnAmountRaw
|
||||
swapAmountRaw
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const url = (process.env.BALANCER_API_URL?.trim() || DEFAULT_GQL).replace(/\/$/, '');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: {
|
||||
chain: gqlChain,
|
||||
swapAmount: swapAmountHuman,
|
||||
tokenIn,
|
||||
tokenOut,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data?: { sorGetSwapPaths?: { returnAmountRaw?: string } | null };
|
||||
errors?: { message: string }[];
|
||||
};
|
||||
|
||||
if (!res.ok) {
|
||||
return { ok: false, engine, error: `Balancer GraphQL HTTP ${res.status}` };
|
||||
}
|
||||
if (body.errors?.length) {
|
||||
return { ok: false, engine, error: `Balancer: ${body.errors.map((e) => e.message).join('; ')}` };
|
||||
}
|
||||
|
||||
const raw = body.data?.sorGetSwapPaths?.returnAmountRaw;
|
||||
if (raw == null || raw === '') {
|
||||
return { ok: false, engine, error: 'Balancer SOR returned no returnAmountRaw for this pair' };
|
||||
}
|
||||
|
||||
const amountOut = BigInt(raw);
|
||||
const routeDescription = `balancer:sor:${gqlChain}:${tokenIn}->${tokenOut}`;
|
||||
return finalizeSwapSuccess(req, engine, amountOut, routeDescription);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, engine, error: msg };
|
||||
}
|
||||
}
|
||||
109
packages/economics-toolkit/src/swap-engine/curve-rate-quote.ts
Normal file
109
packages/economics-toolkit/src/swap-engine/curve-rate-quote.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Contract, JsonRpcProvider, getAddress } from 'ethers';
|
||||
import type { SwapQuoteRequest, SwapQuoteSuccess, SwapQuoteFailure } from './types.js';
|
||||
import { finalizeSwapSuccess } from './swap-quote-common.js';
|
||||
|
||||
const QUOTE_ABI = [
|
||||
'function get_quotes(address source_token, address destination_token, uint256 amount_in) view returns (tuple(uint256 source_token_index, uint256 dest_token_index, bool is_underlying, uint256 amount_out, address pool, uint256 source_token_pool_balance, uint256 dest_token_pool_balance, uint8 pool_type)[])',
|
||||
];
|
||||
|
||||
let cached: { rateProviderByChainId: Record<string, string | null> } | null = null;
|
||||
|
||||
function loadCurveConfig(): { rateProviderByChainId: Record<string, string | null> } {
|
||||
if (cached) return cached;
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const path = join(here, '../../config/curve-rate-provider.json');
|
||||
cached = JSON.parse(readFileSync(path, 'utf8')) as { rateProviderByChainId: Record<string, string | null> };
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function curveRateProviderForChain(chainId: number, override?: string): string | null {
|
||||
const o = override?.trim();
|
||||
if (o) {
|
||||
try {
|
||||
return getAddress(o.toLowerCase());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const v = loadCurveConfig().rateProviderByChainId[String(chainId)];
|
||||
if (v == null || v === '') return null;
|
||||
try {
|
||||
return getAddress(String(v).trim().toLowerCase());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Curve-only quotes via on-chain RateProvider.get_quotes (Metaregistry pools).
|
||||
*/
|
||||
export async function quoteCurve(req: SwapQuoteRequest): Promise<SwapQuoteSuccess | SwapQuoteFailure> {
|
||||
const engine = 'curve' as const;
|
||||
try {
|
||||
const addr = curveRateProviderForChain(req.chainId, req.curveRateProviderAddress);
|
||||
if (!addr) {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error:
|
||||
'No Curve RateProvider for this chainId in config/curve-rate-provider.json; pass curveRateProviderAddress on the request',
|
||||
};
|
||||
}
|
||||
const provider = new JsonRpcProvider(req.rpcUrl);
|
||||
const c = new Contract(addr, QUOTE_ABI, provider);
|
||||
const quotes = await c.get_quotes.staticCall(req.tokenIn, req.tokenOut, req.amountIn);
|
||||
const arr = quotes as unknown[];
|
||||
if (!Array.isArray(arr) || arr.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: 'Curve get_quotes returned no route for this pair (try another source or pair)',
|
||||
};
|
||||
}
|
||||
const amountOutFromRow = (row: unknown): bigint => {
|
||||
if (row != null && typeof row === 'object' && !Array.isArray(row)) {
|
||||
const o = row as Record<string, unknown>;
|
||||
const v = o.amount_out ?? o.amountOut;
|
||||
if (v !== undefined) return BigInt(String(v));
|
||||
}
|
||||
if (Array.isArray(row) && row.length > 3) return BigInt(String(row[3]));
|
||||
throw new Error('unexpected Curve Quote tuple shape');
|
||||
};
|
||||
const poolFromRow = (row: unknown): string => {
|
||||
if (row != null && typeof row === 'object' && !Array.isArray(row)) {
|
||||
const o = row as Record<string, unknown>;
|
||||
const v = o.pool;
|
||||
if (v !== undefined) return String(v);
|
||||
}
|
||||
if (Array.isArray(row) && row.length > 4) return String(row[4]);
|
||||
return 'unknown';
|
||||
};
|
||||
const poolTypeFromRow = (row: unknown): string => {
|
||||
if (row != null && typeof row === 'object' && !Array.isArray(row)) {
|
||||
const o = row as Record<string, unknown>;
|
||||
const v = o.pool_type ?? o.poolType;
|
||||
if (v !== undefined) return String(v);
|
||||
}
|
||||
if (Array.isArray(row) && row.length > 7) return String(row[7]);
|
||||
return '?';
|
||||
};
|
||||
|
||||
let bestRow: unknown = arr[0];
|
||||
let bestOut = amountOutFromRow(bestRow);
|
||||
for (const q of arr) {
|
||||
const ao = amountOutFromRow(q);
|
||||
if (ao > bestOut) {
|
||||
bestOut = ao;
|
||||
bestRow = q;
|
||||
}
|
||||
}
|
||||
const routeDescription = `curve:rate-provider:pool=${poolFromRow(bestRow)}:pool_type=${poolTypeFromRow(bestRow)}:candidates=${arr.length}`;
|
||||
return finalizeSwapSuccess(req, engine, bestOut, routeDescription);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, engine, error: msg };
|
||||
}
|
||||
}
|
||||
73
packages/economics-toolkit/src/swap-engine/dodo-quote.ts
Normal file
73
packages/economics-toolkit/src/swap-engine/dodo-quote.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { getAddress, parseUnits } from 'ethers';
|
||||
import type { SwapQuoteRequest, SwapQuoteSuccess, SwapQuoteFailure } from './types.js';
|
||||
import { finalizeSwapSuccess } from './swap-quote-common.js';
|
||||
import { fetchErc20Decimals } from './erc20-meta.js';
|
||||
|
||||
const DEFAULT_DODO_BASE = 'https://api.dodoex.io/route-service/developer';
|
||||
|
||||
/**
|
||||
* DODO SmartTrade / route quote (aggregated path; DODO + external liquidity).
|
||||
* Requires `DODO_API_KEY` or `dodoApiKey` (developer portal).
|
||||
* @see https://docs.dodoex.io/en/developer/developers-portal/api/smart-trade/api
|
||||
*/
|
||||
export async function quoteDodo(req: SwapQuoteRequest): Promise<SwapQuoteSuccess | SwapQuoteFailure> {
|
||||
const engine = 'dodo' as const;
|
||||
try {
|
||||
const apiKey = req.dodoApiKey?.trim() || process.env.DODO_API_KEY?.trim();
|
||||
if (!apiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: 'DODO quote requires dodoApiKey or environment DODO_API_KEY',
|
||||
};
|
||||
}
|
||||
|
||||
const base = (process.env.DODO_API_URL?.trim() || DEFAULT_DODO_BASE).replace(/\/$/, '');
|
||||
const userAddr =
|
||||
req.dodoUserAddress?.trim() ||
|
||||
process.env.DODO_USER_ADDRESS?.trim() ||
|
||||
'0x0000000000000000000000000000000000000001';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
chainId: String(req.chainId),
|
||||
fromTokenAddress: getAddress(req.tokenIn.toLowerCase()),
|
||||
toTokenAddress: getAddress(req.tokenOut.toLowerCase()),
|
||||
fromAmount: req.amountIn.toString(),
|
||||
slippage: String(req.dodoSlippage ?? process.env.DODO_SLIPPAGE ?? '0.005'),
|
||||
userAddr,
|
||||
apikey: apiKey,
|
||||
});
|
||||
|
||||
const url = `${base}/swap?${params.toString()}`;
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { ok: false, engine, error: `DODO HTTP ${res.status}: ${text.slice(0, 800)}` };
|
||||
}
|
||||
|
||||
const j = (await res.json()) as Record<string, unknown>;
|
||||
const dataBlock = (j.data as Record<string, unknown> | undefined) ?? j;
|
||||
const raw =
|
||||
(dataBlock.resAmount as string | undefined) ??
|
||||
(dataBlock.toTokenAmount as string | undefined) ??
|
||||
(j.resAmount as string | undefined);
|
||||
if (raw == null || raw === '') {
|
||||
return { ok: false, engine, error: 'DODO response missing resAmount / toTokenAmount' };
|
||||
}
|
||||
|
||||
const decimalsOut = await fetchErc20Decimals(req.rpcUrl, req.tokenOut);
|
||||
const rawStr = String(raw).trim();
|
||||
let amountOut: bigint;
|
||||
try {
|
||||
amountOut = rawStr.includes('.') ? parseUnits(rawStr, decimalsOut) : BigInt(rawStr);
|
||||
} catch {
|
||||
return { ok: false, engine, error: `DODO: could not parse amount out: ${rawStr.slice(0, 80)}` };
|
||||
}
|
||||
const routeDescription = `dodo:smarttrade:${req.chainId}:${req.tokenIn}->${req.tokenOut}`;
|
||||
return finalizeSwapSuccess(req, engine, amountOut, routeDescription);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, engine, error: msg };
|
||||
}
|
||||
}
|
||||
10
packages/economics-toolkit/src/swap-engine/erc20-meta.ts
Normal file
10
packages/economics-toolkit/src/swap-engine/erc20-meta.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Contract, JsonRpcProvider } from 'ethers';
|
||||
|
||||
const ERC20_ABI = ['function decimals() view returns (uint8)'];
|
||||
|
||||
export async function fetchErc20Decimals(rpcUrl: string, tokenAddress: string): Promise<number> {
|
||||
const provider = new JsonRpcProvider(rpcUrl);
|
||||
const c = new Contract(tokenAddress, ERC20_ABI, provider);
|
||||
const d = await c.decimals();
|
||||
return Number(d);
|
||||
}
|
||||
60
packages/economics-toolkit/src/swap-engine/oneinch-quote.ts
Normal file
60
packages/economics-toolkit/src/swap-engine/oneinch-quote.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { SwapQuoteRequest, SwapQuoteSuccess, SwapQuoteFailure } from './types.js';
|
||||
import { finalizeSwapSuccess } from './swap-quote-common.js';
|
||||
|
||||
/** 1inch dev API (quote-only). See https://portal.1inch.dev/ */
|
||||
const DEFAULT_ONEINCH_BASE = 'https://api.1inch.dev/swap/v6.0';
|
||||
|
||||
export async function quoteOneInch(req: SwapQuoteRequest): Promise<SwapQuoteSuccess | SwapQuoteFailure> {
|
||||
const engine = 'oneinch' as const;
|
||||
try {
|
||||
const key = req.oneInchApiKey?.trim() || process.env.ONEINCH_API_KEY?.trim();
|
||||
if (!key) {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: '1inch quote requires oneInchApiKey or environment ONEINCH_API_KEY',
|
||||
};
|
||||
}
|
||||
|
||||
const base = (process.env.ONEINCH_API_URL?.trim() || DEFAULT_ONEINCH_BASE).replace(/\/$/, '');
|
||||
const params = new URLSearchParams({
|
||||
src: req.tokenIn,
|
||||
dst: req.tokenOut,
|
||||
amount: req.amountIn.toString(),
|
||||
});
|
||||
const url = `${base}/${req.chainId}/quote?${params.toString()}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: `1inch HTTP ${res.status}: ${text.slice(0, 800)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const j = (await res.json()) as {
|
||||
dstAmount?: string;
|
||||
toAmount?: string;
|
||||
toTokenAmount?: string;
|
||||
};
|
||||
const dstRaw = j.dstAmount ?? j.toAmount ?? j.toTokenAmount;
|
||||
if (!dstRaw) {
|
||||
return { ok: false, engine, error: '1inch quote response missing dstAmount / toAmount' };
|
||||
}
|
||||
|
||||
const amountOut = BigInt(dstRaw);
|
||||
const routeDescription = `1inch:v6:${req.chainId}:${req.tokenIn}->${req.tokenOut}`;
|
||||
return finalizeSwapSuccess(req, engine, amountOut, routeDescription);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, engine, error: msg };
|
||||
}
|
||||
}
|
||||
63
packages/economics-toolkit/src/swap-engine/paraswap-quote.ts
Normal file
63
packages/economics-toolkit/src/swap-engine/paraswap-quote.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { SwapQuoteRequest, SwapQuoteSuccess, SwapQuoteFailure } from './types.js';
|
||||
import { finalizeSwapSuccess } from './swap-quote-common.js';
|
||||
|
||||
const DEFAULT_PARASWAP = 'https://api.paraswap.io';
|
||||
|
||||
/**
|
||||
* Aave-class swap quote via ParaSwap / Velora pricing API (routes comparable to app.aave.com swap).
|
||||
* @see https://developers.paraswap.network/api/get-rate-for-a-token-pair
|
||||
*/
|
||||
export async function quoteAave(req: SwapQuoteRequest): Promise<SwapQuoteSuccess | SwapQuoteFailure> {
|
||||
return quoteParaswapWithLabel(req, 'aave');
|
||||
}
|
||||
|
||||
async function quoteParaswapWithLabel(
|
||||
req: SwapQuoteRequest,
|
||||
label: 'aave'
|
||||
): Promise<SwapQuoteSuccess | SwapQuoteFailure> {
|
||||
try {
|
||||
const base = (process.env.PARASWAP_API_URL?.trim() || DEFAULT_PARASWAP).replace(/\/$/, '');
|
||||
const apiKey = req.paraswapApiKey?.trim() || process.env.PARASWAP_API_KEY?.trim();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
srcToken: req.tokenIn,
|
||||
destToken: req.tokenOut,
|
||||
amount: req.amountIn.toString(),
|
||||
side: 'SELL',
|
||||
network: String(req.chainId),
|
||||
version: '6.2',
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (apiKey) headers['X-API-KEY'] = apiKey;
|
||||
|
||||
const url = `${base}/prices?${params.toString()}`;
|
||||
const res = await fetch(url, { headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return {
|
||||
ok: false,
|
||||
engine: label,
|
||||
error: `ParaSwap HTTP ${res.status}: ${text.slice(0, 800)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const j = (await res.json()) as {
|
||||
priceRoute?: { destAmount?: string };
|
||||
destAmount?: string;
|
||||
};
|
||||
|
||||
const destRaw = j.priceRoute?.destAmount ?? j.destAmount;
|
||||
if (!destRaw) {
|
||||
return { ok: false, engine: label, error: 'ParaSwap response missing destAmount' };
|
||||
}
|
||||
|
||||
const amountOut = BigInt(destRaw);
|
||||
const routeDescription = `${label}:paraswap:${req.chainId}:${req.tokenIn}->${req.tokenOut}`;
|
||||
return finalizeSwapSuccess(req, label, amountOut, routeDescription);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, engine: label, error: msg };
|
||||
}
|
||||
}
|
||||
52
packages/economics-toolkit/src/swap-engine/swap-path-gate.ts
Normal file
52
packages/economics-toolkit/src/swap-engine/swap-path-gate.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { evaluateEconomics, type EconomicsInput } from '../economics-engine.js';
|
||||
import { quoteSwap } from './swap-quote-router.js';
|
||||
import type { SwapQuoteRequest, SwapQuoteResult } from './types.js';
|
||||
|
||||
export interface SwapPathGateParams {
|
||||
request: SwapQuoteRequest;
|
||||
economics: Omit<EconomicsInput, 'grossPct'>;
|
||||
}
|
||||
|
||||
export interface SwapPathGateResult {
|
||||
quote: SwapQuoteResult;
|
||||
impliedGrossPct: number;
|
||||
economics: ReturnType<typeof evaluateEconomics>;
|
||||
passesEconomicsGate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `evaluatePathGate` for Chain 138 PMM, but for generic mainnet-class routes:
|
||||
* quote via `quoteSwap`, then run `evaluateEconomics` using `quote.impliedGrossPct`
|
||||
* (USD-notional when `impliedGrossPctBasis === 'usd_notional'`, else human-unit ratio).
|
||||
*/
|
||||
export async function evaluateSwapPathGate(params: SwapPathGateParams): Promise<SwapPathGateResult> {
|
||||
const q = await quoteSwap(params.request);
|
||||
if (!q.ok) {
|
||||
return {
|
||||
quote: q,
|
||||
impliedGrossPct: 0,
|
||||
economics: evaluateEconomics({
|
||||
grossPct: 0,
|
||||
flashFeePct: params.economics.flashFeePct,
|
||||
gasPctOfNotional: params.economics.gasPctOfNotional,
|
||||
liquidityPct: params.economics.liquidityPct,
|
||||
minProfitPct: params.economics.minProfitPct,
|
||||
}),
|
||||
passesEconomicsGate: false,
|
||||
};
|
||||
}
|
||||
|
||||
const impliedGrossPct = q.impliedGrossPct;
|
||||
|
||||
const economics = evaluateEconomics({
|
||||
grossPct: impliedGrossPct,
|
||||
...params.economics,
|
||||
});
|
||||
|
||||
return {
|
||||
quote: q,
|
||||
impliedGrossPct,
|
||||
economics,
|
||||
passesEconomicsGate: economics.passesResidual,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { fetchErc20Decimals } from './erc20-meta.js';
|
||||
import type { SwapQuoteRequest, SwapQuoteEngine, SwapQuoteSuccess } from './types.js';
|
||||
import { attachUsdNotionalGross } from './swap-usd-gross.js';
|
||||
|
||||
export async function finalizeSwapSuccess(
|
||||
req: SwapQuoteRequest,
|
||||
engine: SwapQuoteEngine,
|
||||
amountOut: bigint,
|
||||
routeDescription: string
|
||||
): Promise<SwapQuoteSuccess> {
|
||||
const [decimalsIn, decimalsOut] = await Promise.all([
|
||||
fetchErc20Decimals(req.rpcUrl, req.tokenIn),
|
||||
fetchErc20Decimals(req.rpcUrl, req.tokenOut),
|
||||
]);
|
||||
|
||||
const skipUsd =
|
||||
req.usdNormalizeGross === false || process.env.ECONOMICS_SWAP_QUOTE_SKIP_USD === '1';
|
||||
|
||||
const gross = await attachUsdNotionalGross({
|
||||
chainId: req.chainId,
|
||||
tokenIn: req.tokenIn,
|
||||
tokenOut: req.tokenOut,
|
||||
amountIn: req.amountIn,
|
||||
amountOut,
|
||||
decimalsIn,
|
||||
decimalsOut,
|
||||
skipUsd,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
chainId: req.chainId,
|
||||
engine,
|
||||
tokenIn: req.tokenIn,
|
||||
tokenOut: req.tokenOut,
|
||||
amountIn: req.amountIn.toString(),
|
||||
amountOut: amountOut.toString(),
|
||||
decimalsIn,
|
||||
decimalsOut,
|
||||
impliedGrossPct: gross.impliedGrossPctPrimary,
|
||||
impliedGrossPctBasis: gross.basis,
|
||||
impliedGrossPctHumanUnitRatio: gross.impliedGrossPctHumanUnitRatio,
|
||||
impliedGrossPctUsd: gross.impliedGrossPctUsd,
|
||||
notionalApproxUsdIn: gross.notionalApproxUsdIn,
|
||||
notionalApproxUsdOut: gross.notionalApproxUsdOut,
|
||||
usdGrossNote: gross.usdGrossNote,
|
||||
routeDescription,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SwapQuoteRequest, SwapQuoteResult, SwapQuoteEngine } from './types.js';
|
||||
import { DEFAULT_SWAP_QUOTE_ENGINES } from './types.js';
|
||||
import { quoteUniswapV3 } from './uniswap-v3-quoter.js';
|
||||
import { quoteOneInch } from './oneinch-quote.js';
|
||||
import { quoteCurve } from './curve-rate-quote.js';
|
||||
import { quoteBalancer } from './balancer-sor-quote.js';
|
||||
import { quoteDodo } from './dodo-quote.js';
|
||||
import { quoteAave } from './paraswap-quote.js';
|
||||
import { quoteCompound } from './zeroex-quote.js';
|
||||
|
||||
/**
|
||||
* Generic EVM swap quote across Uniswap V3, aggregators, Curve RateProvider, Balancer SOR, DODO, Aave-class (ParaSwap), Compound-class (0x).
|
||||
* Chain 138 PMM remains on `path-check` / `evaluatePathGate`; QuoterV2 is unset in config for 138.
|
||||
*/
|
||||
export async function quoteSwap(req: SwapQuoteRequest): Promise<SwapQuoteResult> {
|
||||
switch (req.engine) {
|
||||
case 'uniswap-v3':
|
||||
return quoteUniswapV3(req);
|
||||
case 'oneinch':
|
||||
return quoteOneInch(req);
|
||||
case 'curve':
|
||||
return quoteCurve(req);
|
||||
case 'balancer':
|
||||
return quoteBalancer(req);
|
||||
case 'dodo':
|
||||
return quoteDodo(req);
|
||||
case 'aave':
|
||||
return quoteAave(req);
|
||||
case 'compound':
|
||||
return quoteCompound(req);
|
||||
default: {
|
||||
const ex: never = req.engine;
|
||||
return { ok: false, engine: ex, error: `unsupported engine: ${String(ex)}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SwapQuoteEngineResult {
|
||||
engine: SwapQuoteEngine;
|
||||
result: SwapQuoteResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the same swap request against multiple engines (e.g. compare liquidity sources).
|
||||
*/
|
||||
export async function quoteSwapFromEngines(
|
||||
base: Omit<SwapQuoteRequest, 'engine'>,
|
||||
engines: readonly SwapQuoteEngine[] = DEFAULT_SWAP_QUOTE_ENGINES
|
||||
): Promise<SwapQuoteEngineResult[]> {
|
||||
const out: SwapQuoteEngineResult[] = [];
|
||||
for (const engine of engines) {
|
||||
out.push({
|
||||
engine,
|
||||
result: await quoteSwap({ ...base, engine }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { impliedGrossPctHumanUnitRatio, attachUsdNotionalGross } from './swap-usd-gross.js';
|
||||
|
||||
test('impliedGrossPctHumanUnitRatio: 1 WETH -> ~2183 USDC human units', () => {
|
||||
const amountIn = 10n ** 18n;
|
||||
const amountOut = 2183645537n;
|
||||
const g = impliedGrossPctHumanUnitRatio(amountIn, amountOut, 18, 6);
|
||||
assert.ok(g > 218000 && g < 219000);
|
||||
});
|
||||
|
||||
test('attachUsdNotionalGross: skipUsd uses human basis', async () => {
|
||||
const r = await attachUsdNotionalGross({
|
||||
chainId: 1,
|
||||
tokenIn: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
tokenOut: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
||||
amountIn: 10n ** 18n,
|
||||
amountOut: 2183645537n,
|
||||
decimalsIn: 18,
|
||||
decimalsOut: 6,
|
||||
skipUsd: true,
|
||||
});
|
||||
assert.equal(r.basis, 'human_unit_ratio');
|
||||
assert.equal(r.impliedGrossPctPrimary, r.impliedGrossPctHumanUnitRatio);
|
||||
assert.equal(r.impliedGrossPctUsd, null);
|
||||
assert.ok(r.usdGrossNote?.includes('skipped'));
|
||||
});
|
||||
202
packages/economics-toolkit/src/swap-engine/swap-usd-gross.ts
Normal file
202
packages/economics-toolkit/src/swap-engine/swap-usd-gross.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { formatUnits } from 'ethers';
|
||||
|
||||
/** CoinGecko `simple/token_price/{platform}` asset platform id. */
|
||||
const COINGECKO_PLATFORM: Record<number, string> = {
|
||||
1: 'ethereum',
|
||||
10: 'optimistic-ethereum',
|
||||
56: 'binance-smart-chain',
|
||||
137: 'polygon-pos',
|
||||
42161: 'arbitrum-one',
|
||||
8453: 'base',
|
||||
43114: 'avalanche',
|
||||
100: 'xdai',
|
||||
};
|
||||
|
||||
const NATIVE_LOWER = new Set([
|
||||
'0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
|
||||
'0x0000000000000000000000000000000000000000',
|
||||
]);
|
||||
|
||||
const CG_TOKEN = 'https://api.coingecko.com/api/v3/simple/token_price';
|
||||
|
||||
const priceCache = new Map<string, { usd: number; at: number }>();
|
||||
const CACHE_MS = 60_000;
|
||||
|
||||
function cacheKey(chainId: number, token: string): string {
|
||||
return `${chainId}:${token.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function isNativePlaceholder(addr: string): boolean {
|
||||
return NATIVE_LOWER.has(addr.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* USD / token via CoinGecko (contract address on supported chains).
|
||||
* Native placeholders use wrapped/native CoinGecko id via `ethereum` etc. for chain 1 only in `fetchNativeUsd`.
|
||||
*/
|
||||
export async function fetchTokenUsdMkt(
|
||||
chainId: number,
|
||||
tokenAddress: string,
|
||||
timeoutMs = 12000
|
||||
): Promise<number | null> {
|
||||
if (process.env.ECONOMICS_SWAP_QUOTE_SKIP_USD === '1') return null;
|
||||
|
||||
const norm = tokenAddress.trim().toLowerCase();
|
||||
if (isNativePlaceholder(norm)) {
|
||||
return fetchNativeUsd(chainId, timeoutMs);
|
||||
}
|
||||
|
||||
const platform = COINGECKO_PLATFORM[chainId];
|
||||
if (!platform) return null;
|
||||
|
||||
const ck = cacheKey(chainId, norm);
|
||||
const hit = priceCache.get(ck);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return hit.usd;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const u = `${CG_TOKEN}/${encodeURIComponent(platform)}?contract_addresses=${encodeURIComponent(norm)}&vs_currencies=usd`;
|
||||
const res = await fetch(u, { signal: ctrl.signal, headers: { accept: 'application/json' } });
|
||||
if (!res.ok) return null;
|
||||
const j = (await res.json()) as Record<string, { usd?: number }>;
|
||||
const row = j[norm] ?? j[tokenAddress] ?? j[tokenAddress.toLowerCase()];
|
||||
const usd = row?.usd;
|
||||
if (typeof usd !== 'number' || !(usd > 0)) return null;
|
||||
priceCache.set(ck, { usd, at: Date.now() });
|
||||
return usd;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
/** Native USD: chain 1 → `ethereum` spot; other chains → null unless we add id map. */
|
||||
async function fetchNativeUsd(chainId: number, timeoutMs: number): Promise<number | null> {
|
||||
if (chainId !== 1) return null;
|
||||
const ck = cacheKey(chainId, '__native_eth__');
|
||||
const hit = priceCache.get(ck);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return hit.usd;
|
||||
|
||||
const CG = 'https://api.coingecko.com/api/v3/simple/price';
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const u = `${CG}?ids=ethereum&vs_currencies=usd`;
|
||||
const res = await fetch(u, { signal: ctrl.signal, headers: { accept: 'application/json' } });
|
||||
if (!res.ok) return null;
|
||||
const j = (await res.json()) as { ethereum?: { usd?: number } };
|
||||
const usd = j.ethereum?.usd;
|
||||
if (typeof usd !== 'number' || !(usd > 0)) return null;
|
||||
priceCache.set(ck, { usd, at: Date.now() });
|
||||
return usd;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export type GrossPctBasis = 'usd_notional' | 'human_unit_ratio';
|
||||
|
||||
export interface UsdGrossAttachment {
|
||||
impliedGrossPctPrimary: number;
|
||||
basis: GrossPctBasis;
|
||||
impliedGrossPctHumanUnitRatio: number;
|
||||
impliedGrossPctUsd: number | null;
|
||||
notionalApproxUsdIn: number | null;
|
||||
notionalApproxUsdOut: number | null;
|
||||
usdGrossNote?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-unit ratio (often meaningless across unlike assets, e.g. WETH vs USDC).
|
||||
*/
|
||||
export function impliedGrossPctHumanUnitRatio(
|
||||
amountIn: bigint,
|
||||
amountOut: bigint,
|
||||
decimalsIn: number,
|
||||
decimalsOut: number
|
||||
): number {
|
||||
const vIn = parseFloat(formatUnits(amountIn, decimalsIn));
|
||||
const vOut = parseFloat(formatUnits(amountOut, decimalsOut));
|
||||
if (vIn <= 0 || !Number.isFinite(vIn) || !Number.isFinite(vOut)) return 0;
|
||||
return (vOut / vIn - 1) * 100;
|
||||
}
|
||||
|
||||
export async function attachUsdNotionalGross(params: {
|
||||
chainId: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountIn: bigint;
|
||||
amountOut: bigint;
|
||||
decimalsIn: number;
|
||||
decimalsOut: number;
|
||||
skipUsd?: boolean;
|
||||
}): Promise<UsdGrossAttachment> {
|
||||
const human = impliedGrossPctHumanUnitRatio(
|
||||
params.amountIn,
|
||||
params.amountOut,
|
||||
params.decimalsIn,
|
||||
params.decimalsOut
|
||||
);
|
||||
|
||||
if (params.skipUsd) {
|
||||
return {
|
||||
impliedGrossPctPrimary: human,
|
||||
basis: 'human_unit_ratio',
|
||||
impliedGrossPctHumanUnitRatio: human,
|
||||
impliedGrossPctUsd: null,
|
||||
notionalApproxUsdIn: null,
|
||||
notionalApproxUsdOut: null,
|
||||
usdGrossNote:
|
||||
'USD gross skipped (ECONOMICS_SWAP_QUOTE_SKIP_USD=1 or request). impliedGrossPct is human-unit ratio — not comparable across unlike assets.',
|
||||
};
|
||||
}
|
||||
|
||||
const [pIn, pOut] = await Promise.all([
|
||||
fetchTokenUsdMkt(params.chainId, params.tokenIn),
|
||||
fetchTokenUsdMkt(params.chainId, params.tokenOut),
|
||||
]);
|
||||
|
||||
if (pIn == null || pOut == null) {
|
||||
return {
|
||||
impliedGrossPctPrimary: human,
|
||||
basis: 'human_unit_ratio',
|
||||
impliedGrossPctHumanUnitRatio: human,
|
||||
impliedGrossPctUsd: null,
|
||||
notionalApproxUsdIn: null,
|
||||
notionalApproxUsdOut: null,
|
||||
usdGrossNote:
|
||||
'CoinGecko USD prices unavailable for this chain or token pair; impliedGrossPct falls back to human-unit ratio (misleading for unlike assets, e.g. WETH/USDC).',
|
||||
};
|
||||
}
|
||||
|
||||
const unitsIn = parseFloat(formatUnits(params.amountIn, params.decimalsIn));
|
||||
const unitsOut = parseFloat(formatUnits(params.amountOut, params.decimalsOut));
|
||||
const usdIn = unitsIn * pIn;
|
||||
const usdOut = unitsOut * pOut;
|
||||
if (!(usdIn > 0) || !Number.isFinite(usdIn) || !Number.isFinite(usdOut)) {
|
||||
return {
|
||||
impliedGrossPctPrimary: human,
|
||||
basis: 'human_unit_ratio',
|
||||
impliedGrossPctHumanUnitRatio: human,
|
||||
impliedGrossPctUsd: null,
|
||||
notionalApproxUsdIn: null,
|
||||
notionalApproxUsdOut: null,
|
||||
usdGrossNote: 'USD notionals invalid; using human-unit ratio.',
|
||||
};
|
||||
}
|
||||
|
||||
const usdGross = (usdOut / usdIn - 1) * 100;
|
||||
return {
|
||||
impliedGrossPctPrimary: usdGross,
|
||||
basis: 'usd_notional',
|
||||
impliedGrossPctHumanUnitRatio: human,
|
||||
impliedGrossPctUsd: usdGross,
|
||||
notionalApproxUsdIn: usdIn,
|
||||
notionalApproxUsdOut: usdOut,
|
||||
usdGrossNote: 'impliedGrossPct is USD-notional (CoinGecko); see impliedGrossPctHumanUnitRatio for raw token-unit ratio.',
|
||||
};
|
||||
}
|
||||
94
packages/economics-toolkit/src/swap-engine/types.ts
Normal file
94
packages/economics-toolkit/src/swap-engine/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Generic EVM swap quote types (mainnet-class routing; not Chain 138 PMM).
|
||||
*/
|
||||
|
||||
export type SwapQuoteEngine =
|
||||
| 'uniswap-v3'
|
||||
| 'oneinch'
|
||||
| 'curve'
|
||||
| 'balancer'
|
||||
| 'dodo'
|
||||
| 'aave'
|
||||
| 'compound';
|
||||
|
||||
export interface SwapQuoteRequest {
|
||||
chainId: number;
|
||||
rpcUrl: string;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountIn: bigint;
|
||||
engine: SwapQuoteEngine;
|
||||
/** Uniswap V3 fee tier per hop (e.g. 500, 3000, 10000). Single-hop uses fees[0]. */
|
||||
v3Fees?: number[];
|
||||
/** Optional multi-hop: tokens.length === v3Fees.length + 1. If omitted, single-hop tokenIn -> tokenOut. */
|
||||
v3PathTokens?: string[];
|
||||
/** Override QuoterV2 (must support quoteExactInput on this chain). */
|
||||
quoterV2Address?: string;
|
||||
/** 1inch dev API key (Authorization: Bearer). */
|
||||
oneInchApiKey?: string;
|
||||
/** Curve RateProvider override (get_quotes). */
|
||||
curveRateProviderAddress?: string;
|
||||
/** Balancer GraphQL `GqlChain` enum (e.g. MAINNET) if chainId not in built-in map. */
|
||||
balancerSorChain?: string;
|
||||
/** DODO developer API key. */
|
||||
dodoApiKey?: string;
|
||||
/** Quote-only user address for DODO route API (placeholder ok). */
|
||||
dodoUserAddress?: string;
|
||||
dodoSlippage?: string;
|
||||
/** ParaSwap / Velora optional API key. */
|
||||
paraswapApiKey?: string;
|
||||
/** 0x Swap API key (0x-api-key header). */
|
||||
zeroExApiKey?: string;
|
||||
zeroExSlippage?: string;
|
||||
/**
|
||||
* When true (default), try CoinGecko USD notionals so `impliedGrossPct` is meaningful across unlike assets.
|
||||
* Set false to skip HTTP and use human-unit ratio only (or set ECONOMICS_SWAP_QUOTE_SKIP_USD=1).
|
||||
*/
|
||||
usdNormalizeGross?: boolean;
|
||||
}
|
||||
|
||||
export interface SwapQuoteSuccess {
|
||||
ok: true;
|
||||
chainId: number;
|
||||
engine: SwapQuoteEngine;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountIn: string;
|
||||
amountOut: string;
|
||||
decimalsIn: number;
|
||||
decimalsOut: number;
|
||||
/**
|
||||
* Primary gross % for economics: USD-notional when `impliedGrossPctBasis === 'usd_notional'`, else human-unit ratio.
|
||||
*/
|
||||
impliedGrossPct: number;
|
||||
impliedGrossPctBasis: 'usd_notional' | 'human_unit_ratio';
|
||||
/** Raw (tokenOut human / tokenIn human - 1)*100 — misleading for unlike assets (e.g. WETH vs USDC). */
|
||||
impliedGrossPctHumanUnitRatio: number;
|
||||
/** Same as impliedGrossPct when basis is usd_notional; otherwise null. */
|
||||
impliedGrossPctUsd: number | null;
|
||||
notionalApproxUsdIn: number | null;
|
||||
notionalApproxUsdOut: number | null;
|
||||
/** Explains basis / CoinGecko fallback. */
|
||||
usdGrossNote?: string;
|
||||
/** Route hint for operators */
|
||||
routeDescription: string;
|
||||
}
|
||||
|
||||
export interface SwapQuoteFailure {
|
||||
ok: false;
|
||||
error: string;
|
||||
engine: SwapQuoteEngine;
|
||||
}
|
||||
|
||||
export type SwapQuoteResult = SwapQuoteSuccess | SwapQuoteFailure;
|
||||
|
||||
/** Default engines used by `quoteSwapFromEngines` when none specified. */
|
||||
export const DEFAULT_SWAP_QUOTE_ENGINES: readonly SwapQuoteEngine[] = [
|
||||
'uniswap-v3',
|
||||
'oneinch',
|
||||
'curve',
|
||||
'balancer',
|
||||
'dodo',
|
||||
'aave',
|
||||
'compound',
|
||||
] as const;
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { encodeV3SwapPath } from './uniswap-v3-path.js';
|
||||
|
||||
test('encodeV3SwapPath: single hop encodes 43 bytes (20+3+20)', () => {
|
||||
const weth = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2';
|
||||
const usdc = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
|
||||
const path = encodeV3SwapPath([weth, usdc], [3000]);
|
||||
assert.equal(path.startsWith('0x'), true);
|
||||
assert.equal((path.length - 2) / 2, 43);
|
||||
});
|
||||
|
||||
test('encodeV3SwapPath: two hops encodes 66 bytes', () => {
|
||||
const a = '0x1111111111111111111111111111111111111111';
|
||||
const b = '0x2222222222222222222222222222222222222222';
|
||||
const c = '0x3333333333333333333333333333333333333333';
|
||||
const path = encodeV3SwapPath([a, b, c], [500, 3000]);
|
||||
assert.equal((path.length - 2) / 2, 66);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { concat, getAddress, getBytes, hexlify, toBeHex } from 'ethers';
|
||||
|
||||
function addressBytes(addr: string): Uint8Array {
|
||||
return getBytes(getAddress(addr));
|
||||
}
|
||||
|
||||
function feeUint24Bytes(fee: number): Uint8Array {
|
||||
if (!Number.isInteger(fee) || fee < 0 || fee > 0xffffff) {
|
||||
throw new Error(`Invalid V3 fee tier (uint24): ${fee}`);
|
||||
}
|
||||
return getBytes(toBeHex(fee, 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode Uniswap V3 `path` bytes: token0 + fee0 + token1 + fee1 + … + tokenN.
|
||||
*/
|
||||
export function encodeV3SwapPath(tokens: string[], fees: number[]): string {
|
||||
if (tokens.length < 2) throw new Error('path needs at least two tokens');
|
||||
if (tokens.length !== fees.length + 1) {
|
||||
throw new Error(`fees length must be tokens.length - 1 (got ${fees.length} fees, ${tokens.length} tokens)`);
|
||||
}
|
||||
const parts: Uint8Array[] = [];
|
||||
for (let i = 0; i < fees.length; i++) {
|
||||
parts.push(addressBytes(tokens[i]!));
|
||||
parts.push(feeUint24Bytes(fees[i]!));
|
||||
}
|
||||
parts.push(addressBytes(tokens[tokens.length - 1]!));
|
||||
return hexlify(concat(parts));
|
||||
}
|
||||
180
packages/economics-toolkit/src/swap-engine/uniswap-v3-quoter.ts
Normal file
180
packages/economics-toolkit/src/swap-engine/uniswap-v3-quoter.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Contract, JsonRpcProvider, getAddress } from 'ethers';
|
||||
import type { SwapQuoteRequest, SwapQuoteSuccess, SwapQuoteFailure } from './types.js';
|
||||
import { encodeV3SwapPath } from './uniswap-v3-path.js';
|
||||
import { finalizeSwapSuccess } from './swap-quote-common.js';
|
||||
|
||||
/** Uniswap V3 QuoterV2 (struct params). */
|
||||
const QUOTER_V2_ABI = [
|
||||
'function quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96)) view returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)',
|
||||
'function quoteExactInput(bytes path, uint256 amountIn) view returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)',
|
||||
];
|
||||
|
||||
/** Original Uniswap V3 Quoter (v3-periphery lens; Ethereum mainnet). */
|
||||
const QUOTER_V1_ABI = [
|
||||
'function quoteExactInputSingle(address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, uint160 sqrtPriceLimitX96) external returns (uint256 amountOut)',
|
||||
'function quoteExactInput(bytes memory path, uint256 amountIn) external returns (uint256 amountOut)',
|
||||
];
|
||||
|
||||
type DeploymentsFile = {
|
||||
quoterV2ByChainId: Record<string, string | null>;
|
||||
quoterV1ByChainId?: Record<string, string | null>;
|
||||
};
|
||||
|
||||
let cachedDeployments: DeploymentsFile | null = null;
|
||||
|
||||
function loadDeployments(): DeploymentsFile {
|
||||
if (cachedDeployments) return cachedDeployments;
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const path = join(here, '../../config/uniswap-v3-deployments.json');
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
cachedDeployments = JSON.parse(raw) as DeploymentsFile;
|
||||
return cachedDeployments;
|
||||
}
|
||||
|
||||
export type QuoterKind = 'v1' | 'v2';
|
||||
|
||||
/** Resolve Quoter contract: explicit override uses V2 ABI; else V2 from config, else V1 from config. */
|
||||
export function resolveQuoter(chainId: number, override?: string): { address: string; kind: QuoterKind } | null {
|
||||
const d = loadDeployments();
|
||||
const o = override?.trim();
|
||||
if (o) {
|
||||
try {
|
||||
return { address: getAddress(o.toLowerCase()), kind: 'v2' };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const v2 = d.quoterV2ByChainId[String(chainId)];
|
||||
if (v2 != null && v2 !== '') {
|
||||
try {
|
||||
return { address: getAddress(String(v2).trim().toLowerCase()), kind: 'v2' };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const v1 = d.quoterV1ByChainId?.[String(chainId)];
|
||||
if (v1 != null && v1 !== '') {
|
||||
try {
|
||||
return { address: getAddress(String(v1).trim().toLowerCase()), kind: 'v1' };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @deprecated Use resolveQuoter; returns address only for backward compatibility. */
|
||||
export function quoterV2AddressForChain(chainId: number, override?: string): string | null {
|
||||
return resolveQuoter(chainId, override)?.address ?? null;
|
||||
}
|
||||
|
||||
function routeDescription(tokens: string[], fees: number[]): string {
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < fees.length; i++) {
|
||||
parts.push(`${tokens[i]}/${fees[i]!}`);
|
||||
}
|
||||
parts.push(tokens[tokens.length - 1]!);
|
||||
return `uniswap-v3:${parts.join('->')}`;
|
||||
}
|
||||
|
||||
export async function quoteUniswapV3(req: SwapQuoteRequest): Promise<SwapQuoteSuccess | SwapQuoteFailure> {
|
||||
const engine = 'uniswap-v3' as const;
|
||||
try {
|
||||
const quoter = resolveQuoter(req.chainId, req.quoterV2Address);
|
||||
if (!quoter) {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: `no Uniswap V3 Quoter in config for chainId ${req.chainId}; set quoterV2Address or extend config/uniswap-v3-deployments.json`,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = new JsonRpcProvider(req.rpcUrl);
|
||||
const abi = quoter.kind === 'v1' ? QUOTER_V1_ABI : QUOTER_V2_ABI;
|
||||
const contract = new Contract(quoter.address, abi, provider);
|
||||
|
||||
const pathTokens = req.v3PathTokens?.length
|
||||
? req.v3PathTokens.map((a) => a.trim())
|
||||
: [req.tokenIn, req.tokenOut];
|
||||
|
||||
let fees: number[];
|
||||
if (req.v3Fees?.length) {
|
||||
fees = req.v3Fees;
|
||||
} else if (!req.v3PathTokens?.length) {
|
||||
fees = [3000];
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: 'multi-hop Uniswap V3 quote requires v3Fees (one per hop, length = path tokens - 1)',
|
||||
};
|
||||
}
|
||||
|
||||
if (fees.length !== pathTokens.length - 1) {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: `v3Fees length must be pathTokens.length - 1 (got ${fees.length} fees, ${pathTokens.length} tokens)`,
|
||||
};
|
||||
}
|
||||
|
||||
let amountOut: bigint;
|
||||
const tin = pathTokens[0]!.toLowerCase();
|
||||
const reqIn = req.tokenIn.toLowerCase();
|
||||
if (tin !== reqIn) {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: `v3PathTokens[0] must match tokenIn (got ${pathTokens[0]} vs ${req.tokenIn})`,
|
||||
};
|
||||
}
|
||||
const tout = pathTokens[pathTokens.length - 1]!.toLowerCase();
|
||||
if (tout !== req.tokenOut.toLowerCase()) {
|
||||
return {
|
||||
ok: false,
|
||||
engine,
|
||||
error: `last path token must match tokenOut (got ${pathTokens[pathTokens.length - 1]} vs ${req.tokenOut})`,
|
||||
};
|
||||
}
|
||||
|
||||
if (pathTokens.length === 2 && fees.length === 1) {
|
||||
const fee = fees[0]!;
|
||||
if (quoter.kind === 'v1') {
|
||||
const out = await contract.quoteExactInputSingle.staticCall(
|
||||
pathTokens[0]!,
|
||||
pathTokens[1]!,
|
||||
fee,
|
||||
req.amountIn,
|
||||
0n
|
||||
);
|
||||
amountOut = out as bigint;
|
||||
} else {
|
||||
const result = await contract.quoteExactInputSingle.staticCall({
|
||||
tokenIn: pathTokens[0]!,
|
||||
tokenOut: pathTokens[1]!,
|
||||
amountIn: req.amountIn,
|
||||
fee,
|
||||
sqrtPriceLimitX96: 0n,
|
||||
});
|
||||
amountOut = result[0] as bigint;
|
||||
}
|
||||
} else {
|
||||
const pathHex = encodeV3SwapPath(pathTokens, fees);
|
||||
if (quoter.kind === 'v1') {
|
||||
const out = await contract.quoteExactInput.staticCall(pathHex, req.amountIn);
|
||||
amountOut = out as bigint;
|
||||
} else {
|
||||
const result = await contract.quoteExactInput.staticCall(pathHex, req.amountIn);
|
||||
amountOut = result[0] as bigint;
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSwapSuccess(req, engine, amountOut, routeDescription(pathTokens, fees));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, engine, error: msg };
|
||||
}
|
||||
}
|
||||
63
packages/economics-toolkit/src/swap-engine/zeroex-quote.ts
Normal file
63
packages/economics-toolkit/src/swap-engine/zeroex-quote.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { SwapQuoteRequest, SwapQuoteSuccess, SwapQuoteFailure } from './types.js';
|
||||
import { finalizeSwapSuccess } from './swap-quote-common.js';
|
||||
|
||||
const DEFAULT_0X = 'https://api.0x.org';
|
||||
|
||||
/**
|
||||
* 0x Swap API quote (v1 `/swap/v1/quote` — used across many Compound-facing integrations).
|
||||
* Optional header `0x-api-key` from `zeroExApiKey` or `ZERO_EX_API_KEY`.
|
||||
* @see https://0x.org/docs/
|
||||
*/
|
||||
export async function quoteCompound(req: SwapQuoteRequest): Promise<SwapQuoteSuccess | SwapQuoteFailure> {
|
||||
return quote0x(req, 'compound');
|
||||
}
|
||||
|
||||
async function quote0x(
|
||||
req: SwapQuoteRequest,
|
||||
label: 'compound'
|
||||
): Promise<SwapQuoteSuccess | SwapQuoteFailure> {
|
||||
try {
|
||||
const base = (process.env.ZERO_EX_API_URL?.trim() || DEFAULT_0X).replace(/\/$/, '');
|
||||
const apiKey = req.zeroExApiKey?.trim() || process.env.ZERO_EX_API_KEY?.trim();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
sellToken: req.tokenIn,
|
||||
buyToken: req.tokenOut,
|
||||
sellAmount: req.amountIn.toString(),
|
||||
chainId: String(req.chainId),
|
||||
slippagePercentage: String(req.zeroExSlippage ?? process.env.ZERO_EX_SLIPPAGE ?? '0.01'),
|
||||
});
|
||||
|
||||
const url = `${base}/swap/v1/quote?${params.toString()}`;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (apiKey) headers['0x-api-key'] = apiKey;
|
||||
|
||||
const res = await fetch(url, { headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return {
|
||||
ok: false,
|
||||
engine: label,
|
||||
error: `0x HTTP ${res.status}: ${text.slice(0, 800)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const j = (await res.json()) as {
|
||||
buyAmount?: string;
|
||||
data?: { buyAmount?: string };
|
||||
};
|
||||
|
||||
const buyRaw = j.buyAmount ?? j.data?.buyAmount;
|
||||
if (!buyRaw) {
|
||||
return { ok: false, engine: label, error: '0x quote response missing buyAmount' };
|
||||
}
|
||||
|
||||
const amountOut = BigInt(buyRaw);
|
||||
const routeDescription = `${label}:0x:v1:${req.chainId}:${req.tokenIn}->${req.tokenOut}`;
|
||||
return finalizeSwapSuccess(req, label, amountOut, routeDescription);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, engine: label, error: msg };
|
||||
}
|
||||
}
|
||||
14
packages/economics-toolkit/tsconfig.json
Normal file
14
packages/economics-toolkit/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user