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:
defiQUG
2026-04-12 06:12:20 -07:00
parent 6fb6bd3993
commit dbd517b279
2935 changed files with 327972 additions and 5533 deletions

View 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
}
}

View File

@@ -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
}

View 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 }
]
}

View File

@@ -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
}

View File

@@ -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
}

View 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
}
]
}

View 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."
}
]
}

View 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" }
}
}
}
}
}
}
}

View File

@@ -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
}
}

View 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 contracts 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 toolkits **`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 networks 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 accounts **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** legs `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 legs `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.

View File

@@ -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"
}
]
}

View 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"
}
}

View 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) };
}
}

View 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;
}
}

View 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);
});

View 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);
});

View 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;
}

View 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';
}
}

View 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);
});

View 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);
}

View 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);
});

View 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);
}

View 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';

View 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);
}

View 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,
};
}

View 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);
});

View 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 legs 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;
}

View 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,
};
}

View 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'));
});

View 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;
}

View 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/);
});

View 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`);
}

View 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;
}

View File

@@ -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);
});

View 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 };
}

View 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'),
};
}

View 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 legs 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[];
}

View File

@@ -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 };
}
}

View 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 };
}
}

View 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 };
}
}

View 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);
}

View 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 };
}
}

View 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 };
}
}

View 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,
};
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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'));
});

View 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.',
};
}

View 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;

View File

@@ -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);
});

View File

@@ -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));
}

View 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 };
}
}

View 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 };
}
}

View 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"]
}