From 3cb9bf7026208680a856f6d3caf45adb4ef467c0 Mon Sep 17 00:00:00 2001 From: Prazwal Ratti Date: Wed, 10 Jun 2026 03:55:12 +0530 Subject: [PATCH] fix: Uniswap V4 Quoter ABI, MEV unit correctness, Permit2 two-leg approval + cleanup --- .gitignore | 1 + README.md | 78 +++++-- .../swap-kit-engine/src/mev/bot_scanner.rs | 6 +- .../swap-kit-engine/src/mev/liquidity.rs | 211 +++++++++++------- .../swap-kit-engine/src/mev/simulator.rs | 38 ++++ .../swap-kit-engine/src/mining/hook_miner.rs | 2 +- swap-kit/crates/swap-kit-types/src/lib.rs | 26 --- swap-kit/packages/cli/package.json | 4 +- swap-kit/packages/cli/src/index.ts | 8 +- swap-kit/packages/core/package.json | 2 +- swap-kit/packages/core/scripts/fork-edge.ts | 84 +++++++ .../core/scripts/fork-verify-permit2.ts | 67 ++++++ swap-kit/packages/core/scripts/fork-verify.ts | 81 +++++++ swap-kit/packages/core/scripts/logic-edge.ts | 57 +++++ .../packages/core/scripts/oneinch-exec.ts | 30 +++ swap-kit/packages/core/src/abis/index.ts | 102 +-------- .../packages/core/src/adapters/one-inch.ts | 4 +- .../packages/core/src/adapters/paraswap.ts | 4 +- .../packages/core/src/adapters/uniswap-v4.ts | 88 ++------ .../packages/core/src/execution/engine.ts | 68 +++++- .../core/src/test/approval-strategy.test.ts | 41 +++- .../packages/core/src/test/gasless.test.ts | 27 ++- swap-kit/packages/core/src/types.ts | 2 + swap-kit/pnpm-lock.yaml | 2 +- 24 files changed, 722 insertions(+), 311 deletions(-) create mode 100644 swap-kit/packages/core/scripts/fork-edge.ts create mode 100644 swap-kit/packages/core/scripts/fork-verify-permit2.ts create mode 100644 swap-kit/packages/core/scripts/fork-verify.ts create mode 100644 swap-kit/packages/core/scripts/logic-edge.ts create mode 100644 swap-kit/packages/core/scripts/oneinch-exec.ts diff --git a/.gitignore b/.gitignore index a5b764a..6cbe7da 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ *.env *.log test-harness/ +.DS_Store diff --git a/README.md b/README.md index e6807cf..dbd31cd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Write 4 lines of code and let SwapKit find the best route, simulate MEV risk, an [![npm version](https://img.shields.io/npm/v/@swap-kit/core.svg)](https://www.npmjs.com/package/@swap-kit/core) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Tests](https://img.shields.io/badge/tests-111%2B%20passed-brightgreen)]() +[![Tests](https://img.shields.io/badge/tests-220%2B%20passed-brightgreen)]() [![GitHub Repo](https://img.shields.io/badge/GitHub-Repository-black?logo=github)](https://github.com/PrazwalR/SwapKit) [Getting Started](#-getting-started) • @@ -305,14 +305,21 @@ Best Quote (Paraswap, 2,022.15 USDC) ▼ Rust Engine POST /simulate │ - ├─ Checks trade size against pool liquidity - ├─ Estimates sandwich attack profitability + ├─ Values the trade in the chain's NATIVE token (ETH/MATIC/…) wei + ├─ Compares extractable value vs the attacker's 2-tx gas cost (same unit) + ├─ Scans recent blocks for known sandwich-bot activity ├─ Calculates optimal slippage tolerance │ ▼ Returns: { sandwichRisk: "low", recommendedSlippage: 30bps } ``` +> **Why native-token valuation matters:** profitability is only meaningful when the +> value a bot can extract and the gas it must pay are in the **same unit**. The engine +> denominates both in native-token wei, so a large USDC-output trade is no longer +> mis-scored as "no risk" just because USDC has 6 decimals. Token→token swaps with no +> native leg are honestly reported as `unknown` rather than guessed. + If the Rust engine detects **high MEV risk**, the SDK can automatically: - Lower the slippage tolerance to make sandwich attacks unprofitable - Route the transaction through **Flashbots Protect** (a private submission channel that hides your transaction from bots) @@ -562,19 +569,22 @@ SwapKit resolves the highest priority RPC url logic automatically: ### Rust Engine API Endpoints -The Rust engine has 4 endpoints. +The Rust engine has 3 endpoints. (Quoting lives entirely in the TypeScript SDK, +which calls the real Paraswap/1inch APIs and the on-chain Uniswap V4 Quoter — the +engine does not expose a `/quote` route.) 1. **GET /health** — Returns `"ok"`. Used for monitoring. -2. **POST /quote** — Returns heuristic estimates (**NOT real market data**). Uses percentage-based formulas (`from_amount × 98.5%` for 1inch, `98%` for Uniswap V4). This is a scaffolding for testing HTTP infra. Real quotes come from the TypeScript SDK. - -3. **POST /simulate** — MEV sandwich attack risk assessment: +2. **POST /simulate** — MEV sandwich attack risk assessment: - Classifies risk as `none`, `low`, `medium`, `high`, or `unknown` - - Uses integer-based risk classification (no floating-point precision loss) + - Decides profitability in the chain's native token (wei), so the risk is correct + regardless of the output token's decimals; token→token swaps with no native + leg are reported as `unknown` rather than guessed + - Uses integer-based math (no floating-point precision loss) - Recommends optimal slippage - Request body: `{ from_token, to_token, from_amount, chain_id, protocol, amount_out, slippage_bps }` -4. **POST /mine** — CREATE2 vanity address mining for Uniswap V4 hooks: +3. **POST /mine** — CREATE2 vanity address mining for Uniswap V4 hooks: In Uniswap V4, the starting characters of a Hook's contract address dictate what permissions the Hook has (e.g., an address starting with `0x40...` vs `0x00...`). If you want "BeforeSwap" permissions, you must deploy to a specific prefix. This endpoint brute-forces that deployment salt for you. **How it works:** @@ -621,6 +631,33 @@ The SDK has these modules: --- +## 📝 Changelog (v0.2.0) + +A second deep audit fixed three correctness bugs that made headline features silently +misbehave, plus a round of cleanups. All fixes were verified against a mainnet fork +(anvil) with real transactions and an exhaustive edge-case matrix. + +- 🔴 **Uniswap V4 quotes now work.** The Quoter ABI was wrong (extra `sqrtPriceLimitX96` + input, array outputs), so every quote reverted on-chain and Uniswap was silently + dropped from routing. Replaced with the real `IV4Quoter` ABI + (`(uint256 amountOut, uint256 gasEstimate)`), verified live on mainnet. Fee tiers are + now quoted concurrently. +- 🔴 **MEV risk is no longer mis-scored.** The simulator compared output-token units + (e.g. 6-decimal USDC) against gas-in-wei, so large stablecoin-output trades always read + as "no risk". Profitability is now decided in native-token wei; token→token swaps with + no native leg return `unknown` instead of a false "none". +- 🔴 **Uniswap V4 ERC-20 swaps now execute.** Added the missing second Permit2 approval + (`Permit2.approve(token, UniversalRouter)`); without it the router could not pull the + token and every ERC-20-input v4 swap reverted. +- 🟠 Fixed the red `pnpm typecheck` (closure-narrowing `never` in the gasless tests). +- 🟠 CLI now reads the `1INCH_API_KEY` env var spelling shipped in `.env`. +- 🟡 Type-safe `slippageBps` on route data (removed `as any`); removed dead ABIs, dead + Rust quote types, and the unused `ACTIONS`/`CHAINS` constants; fixed compiler warnings. +- ℹ️ Test suite expanded to 220+ checks: 34 Rust, 123 TS regression suites, 28 logic + edge cases, 38 live integration tests, plus fork-based end-to-end execution. + +--- + ## 📝 Changelog (v0.1.9) We recently underwent an intense audit leading to 15 key fixes in v0.1.9. @@ -703,20 +740,33 @@ const sdk = createSwapKit({ ## 📊 Test Results -Our comprehensive test suite validates every component against live mainnet data: +Every component is validated by deterministic suites, live integration tests, and +end-to-end execution against a mainnet fork (anvil): ``` ╔══════════════════════════════════════════════════════════════╗ ║ SwapKit — Full Integration & Edge Case Test Suite ║ ╚══════════════════════════════════════════════════════════════╝ - 🦀 Rust Unit Tests ................... 14/14 ✅ - 🛡️ Rust Security / DoS tests .......... 25/25 ✅ - 📦 TypeScript E2E .................... 72/78 ✅ (6 are normal API behaviors) + 🦀 Rust unit + security/DoS tests ...... 34/34 ✅ + 📦 TS regression suites ................ 123/123 ✅ + (approvals · gasless · flashbots · recipient · MEV fail-open · slippage) + 🧪 TS logic edge-case suite ............ 28/28 ✅ + 🌐 Live integration (Alchemy + 1inch) .. 38/38 ✅ + ⛓️ Fork E2E execution + HTTP edge matrix . verified ✅ + (Uniswap V4 ETH→USDC & USDC→WETH, 1inch ETH→USDC, /simulate · /mine · DoS) - TOTAL: 111+ tests PASSED — Ready for publication! 🎉 + TOTAL: 220+ checks PASSED — Ready for publication! 🎉 ``` +> Reproduce the fork tests: +> ```bash +> anvil --fork-url $RPC_ETHEREUM --port 8545 --silent & +> cd swap-kit/packages/core +> RPC_ETHEREUM=http://127.0.0.1:8545 npx tsx scripts/fork-verify.ts +> RPC_ETHEREUM=http://127.0.0.1:8545 npx tsx scripts/fork-edge.ts +> ``` + --- ## 📄 License diff --git a/swap-kit/crates/swap-kit-engine/src/mev/bot_scanner.rs b/swap-kit/crates/swap-kit-engine/src/mev/bot_scanner.rs index bc7114f..2a88089 100644 --- a/swap-kit/crates/swap-kit-engine/src/mev/bot_scanner.rs +++ b/swap-kit/crates/swap-kit-engine/src/mev/bot_scanner.rs @@ -33,9 +33,11 @@ const KNOWN_BOTS: &[&str] = &[ pub struct BotScanResult { /// Bot addresses found active in recent blocks pub detected_bots: Vec, - /// Number of blocks scanned + /// Number of blocks scanned (diagnostic; surfaced via logs and tests) + #[allow(dead_code)] pub blocks_scanned: u64, - /// Total transactions scanned + /// Total transactions scanned (diagnostic; surfaced via logs and tests) + #[allow(dead_code)] pub txs_scanned: u64, } diff --git a/swap-kit/crates/swap-kit-engine/src/mev/liquidity.rs b/swap-kit/crates/swap-kit-engine/src/mev/liquidity.rs index dfc237d..9e7ef6e 100644 --- a/swap-kit/crates/swap-kit-engine/src/mev/liquidity.rs +++ b/swap-kit/crates/swap-kit-engine/src/mev/liquidity.rs @@ -44,67 +44,81 @@ pub struct ProfitabilityResult { /// Calculate whether a sandwich attack is profitable given real gas prices. /// +/// # Unit correctness +/// +/// A profitability decision must compare two amounts in the SAME unit. The +/// attacker's gas cost is denominated in the chain's **native-token wei** (ETH on +/// mainnet, MATIC on Polygon, …). So the value the attacker can extract must also +/// be expressed in native-token wei — `eth_notional_wei`, the trade's notional in +/// the native token. Comparing an output-token amount (e.g. 6-decimal USDC) to +/// gas-in-wei is meaningless and previously made every stablecoin-output swap look +/// like "no risk" regardless of size. +/// +/// `amount_out` (output-token units) is used ONLY to report `extractable_value_wei` +/// — the user's worst-case slippage loss in the token they receive — which the +/// TypeScript layer subtracts from `netAmountOut` (also output-token units). +/// +/// If the trade cannot be priced in the native token (`eth_notional_wei` is `None`, +/// e.g. a token→token swap with no oracle), the risk is honestly reported as +/// `"unknown"` rather than guessed. +/// /// # Arguments -/// * `amount_out` — Expected output of the user's swap (in token wei) +/// * `amount_out` — Expected output of the user's swap (output-token units) +/// * `eth_notional_wei` — Trade value in native-token wei, or `None` if unpriceable /// * `slippage_bps` — User's slippage tolerance in basis points /// * `gas_price_wei` — Current gas price from `eth_gasPrice` (in wei) /// * `is_mainnet` — Whether this is Ethereum mainnet (higher bot activity) -/// -/// # Returns -/// A `ProfitabilityResult` with the full breakdown. pub fn calculate_sandwich_profitability( amount_out: u128, + eth_notional_wei: Option, slippage_bps: u64, gas_price_wei: Option, is_mainnet: bool, ) -> ProfitabilityResult { - // Step 1: Calculate extractable value + // Reported extractable value, in OUTPUT-TOKEN units (for netAmountOut). // = amount_out × (slippage_bps / 10000) × extraction_efficiency - let slippage_value = amount_out - .checked_mul(slippage_bps as u128) - .map(|v| v / BPS) - .unwrap_or(amount_out); - - let extractable_value = slippage_value - .checked_mul(EXTRACTION_EFFICIENCY_BPS) - .map(|v| v / BPS) - .unwrap_or(slippage_value); + let token_extractable = apply_slippage_and_efficiency(amount_out, slippage_bps); - // Step 2: Calculate attacker's gas cost (2 transactions) + // Attacker's gas cost for the two sandwich transactions, in native-token wei. let gas_cost = match gas_price_wei { - Some(gp) => SANDWICH_GAS_UNITS - .checked_mul(gp) - .unwrap_or(u128::MAX), + Some(gp) => SANDWICH_GAS_UNITS.checked_mul(gp).unwrap_or(u128::MAX), None => { - // No real gas price — fall back to a conservative estimate - // Assume ~30 gwei (moderate mainnet conditions) + // No real gas price — fall back to a conservative estimate. let default_gas = if is_mainnet { 30_000_000_000u128 } else { 1_000_000_000u128 }; SANDWICH_GAS_UNITS.checked_mul(default_gas).unwrap_or(u128::MAX) } }; - // Step 3: Is it profitable? - let is_profitable = extractable_value > gas_cost; - let attacker_profit = if is_profitable { - extractable_value - gas_cost - } else { - 0 + // Without a native-token valuation we cannot honestly decide profitability. + let eth_notional = match eth_notional_wei { + Some(v) if v > 0 => v, + _ => { + return ProfitabilityResult { + is_profitable: false, + attacker_profit_wei: 0, + extractable_value_wei: token_extractable, + attacker_gas_cost_wei: gas_cost, + risk_level: "unknown", + // Conservative: never recommend looser than the user asked, cap at 50 bps. + recommended_slippage_bps: (slippage_bps.min(50)).max(1) as u32, + }; + } }; - // Step 4: Classify risk based on profit margin + // Value the attacker can extract, in native-token wei — same unit as gas_cost. + let eth_extractable = apply_slippage_and_efficiency(eth_notional, slippage_bps); + + let is_profitable = eth_extractable > gas_cost; + let attacker_profit = if is_profitable { eth_extractable - gas_cost } else { 0 }; + + // Classify risk by how far profit exceeds the gas the attacker risks. + // high = profit ≥ 5× gas cost (easy money) + // medium = profit 1–4× gas cost + // low = profit > 0 but < 1× gas cost let risk_level = if !is_profitable { "none" } else { - // Calculate profit as a percentage of gas cost to gauge severity - // High = profit > 5× gas cost (easy money for bots) - // Medium = profit > 1× gas cost (viable but risky) - // Low = profit > 0 but < 1× gas cost (barely worth it) - let profit_to_gas_ratio = if gas_cost > 0 { - attacker_profit / gas_cost - } else { - u128::MAX // Division by zero guard — infinite profit - }; - + let profit_to_gas_ratio = if gas_cost > 0 { attacker_profit / gas_cost } else { u128::MAX }; match profit_to_gas_ratio { 5.. => "high", 1..=4 => "medium", @@ -112,7 +126,7 @@ pub fn calculate_sandwich_profitability( } }; - // Mainnet has more bot competition, so bump risk level up one notch + // Mainnet has more bot competition, so bump non-"none" risk up one notch. let risk_level = if is_mainnet && risk_level == "low" { "medium" } else if is_mainnet && risk_level == "medium" { @@ -121,46 +135,54 @@ pub fn calculate_sandwich_profitability( risk_level }; - // Step 5: Recommend slippage that makes the attack unprofitable - // We need: extractable_value ≤ gas_cost - // slippage_value × 0.7 ≤ gas_cost - // amount_out × (slippage / 10000) × 0.7 ≤ gas_cost - // slippage ≤ (gas_cost × 10000) / (amount_out × 0.7) - let recommended_slippage_bps = if amount_out > 0 && slippage_bps > 0 { - let numerator = gas_cost - .checked_mul(BPS * BPS) - .unwrap_or(u128::MAX); - let denominator = amount_out - .checked_mul(EXTRACTION_EFFICIENCY_BPS) - .unwrap_or(1); - - let optimal = (numerator / denominator) as u32; - // Clamp between 1 bps (0.01%) and the user's original slippage + // Recommend a slippage that makes the attack unprofitable, in native-token terms: + // eth_notional × (slip/BPS) × eff ≤ gas_cost + // slip ≤ gas_cost × BPS × BPS / (eth_notional × eff) + let recommended_slippage_bps = if slippage_bps > 0 { + let numerator = gas_cost.checked_mul(BPS * BPS).unwrap_or(u128::MAX); + let denominator = eth_notional.checked_mul(EXTRACTION_EFFICIENCY_BPS).unwrap_or(1); + let optimal = (numerator / denominator).min(u32::MAX as u128) as u32; + // Clamp between 1 bps (0.01%) and the user's original slippage. optimal.clamp(1, slippage_bps as u32) } else { - slippage_bps as u32 // 0 slippage = user accepts no slippage, nothing to recommend + 0 }; ProfitabilityResult { is_profitable, attacker_profit_wei: attacker_profit, - extractable_value_wei: extractable_value, + extractable_value_wei: token_extractable, attacker_gas_cost_wei: gas_cost, risk_level, recommended_slippage_bps, } } +/// `value × (slippage_bps / 10000) × (EXTRACTION_EFFICIENCY_BPS / 10000)`, saturating. +fn apply_slippage_and_efficiency(value: u128, slippage_bps: u64) -> u128 { + let after_slippage = value + .checked_mul(slippage_bps as u128) + .map(|v| v / BPS) + .unwrap_or(value); + after_slippage + .checked_mul(EXTRACTION_EFFICIENCY_BPS) + .map(|v| v / BPS) + .unwrap_or(after_slippage) +} + #[cfg(test)] mod tests { use super::*; + // For ETH-output swaps the output token IS the native token, so amount_out and + // eth_notional coincide. We pass both explicitly to exercise the new signature. #[test] fn test_large_trade_high_slippage_low_gas_is_profitable() { - // 100 ETH swap, 200 bps slippage, 5 gwei gas - let amount_out = 100_000_000_000_000_000_000u128; // 100 ETH + // 100 ETH notional, 200 bps slippage, 5 gwei gas + let eth = 100_000_000_000_000_000_000u128; // 100 ETH let result = calculate_sandwich_profitability( - amount_out, + eth, + Some(eth), 200, // 2% slippage Some(5_000_000_000), // 5 gwei true, // mainnet @@ -172,10 +194,11 @@ mod tests { #[test] fn test_small_trade_tight_slippage_high_gas_not_profitable() { - // 0.01 ETH swap, 10 bps slippage, 200 gwei gas - let amount_out = 10_000_000_000_000_000u128; // 0.01 ETH + // 0.01 ETH notional, 10 bps slippage, 200 gwei gas + let eth = 10_000_000_000_000_000u128; // 0.01 ETH let result = calculate_sandwich_profitability( - amount_out, + eth, + Some(eth), 10, // 0.1% slippage Some(200_000_000_000), // 200 gwei true, @@ -185,11 +208,47 @@ mod tests { assert_eq!(result.attacker_profit_wei, 0); } + // THE BUG THIS FIX TARGETS: a huge stablecoin-output trade is a real sandwich + // target, but its 6-decimal amount_out is tiny next to gas-in-wei. The risk must + // come from the ETH notional, not from comparing USDC units to wei. + #[test] + fn test_large_stablecoin_output_trade_is_still_high_risk() { + // 1000 ETH → ~1.6M USDC. amount_out is 1_600_000_000_000 (6 decimals). + let amount_out_usdc = 1_600_000_000_000u128; + let eth_notional = 1_000_000_000_000_000_000_000u128; // 1000 ETH + let result = calculate_sandwich_profitability( + amount_out_usdc, + Some(eth_notional), + 200, // 2% slippage + Some(20_000_000_000), // 20 gwei + true, + ); + assert!(result.is_profitable, "A $1.6M trade at 2% slippage must read as profitable"); + assert_eq!(result.risk_level, "high"); + // extractable is reported in USDC units (for netAmountOut), not wei. + assert!(result.extractable_value_wei > 0); + } + + // Token→token with no native valuation must be "unknown", never a confident "none". + #[test] + fn test_unpriceable_trade_is_unknown() { + let result = calculate_sandwich_profitability( + 1_000_000_000u128, // some DAI out + None, // cannot price in native token + 200, + Some(20_000_000_000), + true, + ); + assert_eq!(result.risk_level, "unknown"); + assert!(!result.is_profitable); + } + #[test] fn test_no_gas_price_uses_fallback() { - let amount_out = 50_000_000_000_000_000_000u128; // 50 ETH + let eth = 50_000_000_000_000_000_000u128; // 50 ETH let result = calculate_sandwich_profitability( - amount_out, + eth, + Some(eth), 100, // 1% slippage None, // No RPC → fallback gas estimate true, @@ -200,24 +259,25 @@ mod tests { #[test] fn test_zero_amount_out_is_safe() { - let result = calculate_sandwich_profitability(0, 200, Some(10_000_000_000), true); + let result = calculate_sandwich_profitability(0, Some(0), 200, Some(10_000_000_000), true); assert!(!result.is_profitable); - assert_eq!(result.risk_level, "none"); + // Zero notional cannot be valued → unknown rather than a false "none". + assert_eq!(result.risk_level, "unknown"); } #[test] fn test_zero_slippage_is_safe() { - let amount_out = 100_000_000_000_000_000_000u128; - let result = calculate_sandwich_profitability(amount_out, 0, Some(10_000_000_000), true); + let eth = 100_000_000_000_000_000_000u128; + let result = calculate_sandwich_profitability(eth, Some(eth), 0, Some(10_000_000_000), true); assert!(!result.is_profitable); assert_eq!(result.risk_level, "none"); } #[test] fn test_l2_has_lower_risk_than_mainnet() { - let amount_out = 10_000_000_000_000_000_000u128; // 10 ETH - let mainnet = calculate_sandwich_profitability(amount_out, 100, Some(10_000_000_000), true); - let l2 = calculate_sandwich_profitability(amount_out, 100, Some(10_000_000_000), false); + let eth = 10_000_000_000_000_000_000u128; // 10 ETH + let mainnet = calculate_sandwich_profitability(eth, Some(eth), 100, Some(10_000_000_000), true); + let l2 = calculate_sandwich_profitability(eth, Some(eth), 100, Some(10_000_000_000), false); // L2 risk should be same or lower than mainnet for identical parameters let risk_order = |r: &str| -> u8 { @@ -229,14 +289,15 @@ mod tests { #[test] fn test_recommended_slippage_makes_attack_unprofitable() { - let amount_out = 50_000_000_000_000_000_000u128; // 50 ETH + let eth = 50_000_000_000_000_000_000u128; // 50 ETH let gas_price = 20_000_000_000u128; // 20 gwei - let result = calculate_sandwich_profitability(amount_out, 200, Some(gas_price), true); + let result = calculate_sandwich_profitability(eth, Some(eth), 200, Some(gas_price), true); if result.is_profitable { // Re-run with the recommended slippage — should be unprofitable let safer = calculate_sandwich_profitability( - amount_out, + eth, + Some(eth), result.recommended_slippage_bps as u64, Some(gas_price), true, @@ -248,8 +309,8 @@ mod tests { #[test] fn test_overflow_protection() { - // u128::MAX amount — should not panic - let result = calculate_sandwich_profitability(u128::MAX, 10000, Some(u128::MAX), true); + // u128::MAX amounts — should not panic + let result = calculate_sandwich_profitability(u128::MAX, Some(u128::MAX), 10000, Some(u128::MAX), true); // Just verify it doesn't panic — any result is fine let _ = result.risk_level; } diff --git a/swap-kit/crates/swap-kit-engine/src/mev/simulator.rs b/swap-kit/crates/swap-kit-engine/src/mev/simulator.rs index 3770597..d49b7fb 100644 --- a/swap-kit/crates/swap-kit-engine/src/mev/simulator.rs +++ b/swap-kit/crates/swap-kit-engine/src/mev/simulator.rs @@ -82,8 +82,12 @@ pub async fn simulate(req: &SimulateRequest) -> Result { let bot_scan = scan_recent_blocks(rpc.as_ref(), BLOCKS_TO_SCAN).await; // ─── Stage 2: Calculate sandwich profitability ───────────────────── + // Value the trade in the chain's native token so the profitability decision + // compares like units (extractable value vs gas, both in native wei). + let eth_notional = native_notional_wei(req, from_amount, amount_out); let profitability = calculate_sandwich_profitability( amount_out, + eth_notional, slippage_bps, gas_price, is_mainnet, @@ -128,6 +132,40 @@ pub async fn simulate(req: &SimulateRequest) -> Result { }) } +/// True if `addr` is the chain's native gas token — either the standard native +/// sentinel (`0xEeee…`/zero address) or the chain's wrapped-native ERC-20. +fn is_native_token(addr: &str, chain_id: u64) -> bool { + let a = addr.to_lowercase(); + if a == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + || a == "0x0000000000000000000000000000000000000000" + { + return true; + } + let wrapped = match chain_id { + 1 => "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // WETH + 8453 => "0x4200000000000000000000000000000000000006", // WETH (Base) + 42161 => "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", // WETH (Arbitrum) + 10 => "0x4200000000000000000000000000000000000006", // WETH (Optimism) + 137 => "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270", // WMATIC + 56 => "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c", // WBNB + _ => return false, + }; + a == wrapped +} + +/// Value the trade in the chain's native token (wei), if either side is native. +/// `from_amount` is native wei when the input is native; `amount_out` is native wei +/// when the output is native. A token→token swap has no native leg → `None`. +fn native_notional_wei(req: &SimulateRequest, from_amount: u128, amount_out: u128) -> Option { + if is_native_token(&req.from_token, req.chain_id) { + Some(from_amount) + } else if is_native_token(&req.to_token, req.chain_id) { + Some(amount_out) + } else { + None + } +} + /// Returns a safe default response when simulation fails. /// Returns "unknown" risk to honestly signal the simulation could not complete, /// rather than misleadingly reporting "low" risk. diff --git a/swap-kit/crates/swap-kit-engine/src/mining/hook_miner.rs b/swap-kit/crates/swap-kit-engine/src/mining/hook_miner.rs index dc4345c..24b0953 100644 --- a/swap-kit/crates/swap-kit-engine/src/mining/hook_miner.rs +++ b/swap-kit/crates/swap-kit-engine/src/mining/hook_miner.rs @@ -163,7 +163,7 @@ mod tests { fn test_compute_create2_address() { // Known CREATE2 test vector let deployer = hex_decode("0000000000000000000000000000000000000000").unwrap(); - let mut salt = [0u8; 32]; + let salt = [0u8; 32]; let init_code_hash = hex_decode("0000000000000000000000000000000000000000000000000000000000000000").unwrap(); diff --git a/swap-kit/crates/swap-kit-types/src/lib.rs b/swap-kit/crates/swap-kit-types/src/lib.rs index 24ae4bd..8f19595 100644 --- a/swap-kit/crates/swap-kit-types/src/lib.rs +++ b/swap-kit/crates/swap-kit-types/src/lib.rs @@ -28,32 +28,6 @@ pub struct SimulateResponse { pub detected_bots: Vec, } -// ─── Quoting ──────────────────────────────────────────────────────────────── - -/// Request for a multi-source quote. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QuoteRequest { - pub from_token: String, - pub to_token: String, - pub from_amount: String, - pub chain_id: u64, -} - -/// A single quote from one protocol. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SingleQuote { - pub protocol: String, - pub amount_out: String, - pub gas_cost_wei: String, - pub price_impact_bps: u32, -} - -/// Aggregated quote response. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct QuoteResponse { - pub quotes: Vec, -} - // ─── Hook Mining ──────────────────────────────────────────────────────────── /// Request to mine a CREATE2 vanity address for a Uniswap V4 hook. diff --git a/swap-kit/packages/cli/package.json b/swap-kit/packages/cli/package.json index a52da65..0a7ab90 100644 --- a/swap-kit/packages/cli/package.json +++ b/swap-kit/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@swap-kit/cli", - "version": "0.1.11", + "version": "0.2.0", "description": "CLI for SwapKit — get DeFi quotes and simulate MEV from your terminal", "main": "./dist/index.js", "bin": { @@ -36,7 +36,7 @@ }, "dependencies": { "@clack/prompts": "^1.4.0", - "@swap-kit/core": "0.1.9", + "@swap-kit/core": "workspace:*", "chalk": "^5.3.0", "commander": "^12.1.0", "dotenv": "^16.4.5", diff --git a/swap-kit/packages/cli/src/index.ts b/swap-kit/packages/cli/src/index.ts index 31e8e45..6145718 100644 --- a/swap-kit/packages/cli/src/index.ts +++ b/swap-kit/packages/cli/src/index.ts @@ -16,7 +16,13 @@ dotenv.config(); const program = new Command(); const kit = new SwapKit({ - oneInchApiKey: process.env.ONE_INCH_API_KEY || process.env.ONEINCH_API_KEY || "", + // Accept all common spellings — note `.env` ships the 1inch key as `1INCH_API_KEY`, + // which is not a valid JS identifier so it must be read via bracket access. + oneInchApiKey: + process.env.ONE_INCH_API_KEY || + process.env.ONEINCH_API_KEY || + process.env["1INCH_API_KEY"] || + "", }); // Standard Chain mapping diff --git a/swap-kit/packages/core/package.json b/swap-kit/packages/core/package.json index 84fd934..099fa69 100644 --- a/swap-kit/packages/core/package.json +++ b/swap-kit/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@swap-kit/core", - "version": "0.1.12", + "version": "0.2.0", "type": "module", "description": "The unified intent-based liquidity layer for Web3", "main": "./dist/index.cjs", diff --git a/swap-kit/packages/core/scripts/fork-edge.ts b/swap-kit/packages/core/scripts/fork-edge.ts new file mode 100644 index 0000000..f6d74e6 --- /dev/null +++ b/swap-kit/packages/core/scripts/fork-edge.ts @@ -0,0 +1,84 @@ +/** + * Fork E2E edge matrix. GOOD cases must execute; WRONG cases must throw (never + * silently succeed). Run against an anvil mainnet fork: + * + * anvil --fork-url $RPC_ETHEREUM --port 8545 --silent & + * RPC_ETHEREUM=http://127.0.0.1:8545 npx tsx scripts/fork-edge.ts + */ +import { createWalletClient, createPublicClient, http, formatUnits } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { mainnet } from "viem/chains"; +import { createSwapKit, UniswapV4Adapter, ExecutionEngine, normalizeIntent } from "../src/index.js"; +import { getTokenBalance } from "../src/utils/token.js"; + +const ANVIL = "http://127.0.0.1:8545"; +const PK = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; +const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const DEAD = "0x000000000000000000000000000000000000dEaD"; +const OTHER = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; // anvil acct #1 + +const account = privateKeyToAccount(PK); +const walletClient = createWalletClient({ account, chain: mainnet, transport: http(ANVIL) }); +const publicClient = createPublicClient({ chain: mainnet, transport: http(ANVIL) }); +const sdk = createSwapKit({ oneInchApiKey: "", flashbotsEnabled: false }); +const adapter = new UniswapV4Adapter(); +const engine = new ExecutionEngine([adapter], { flashbotsEnabled: false }); + +let pass = 0, fail = 0; +const ok = (c: boolean, m: string) => { c ? (pass++, console.log("✅ " + m)) : (fail++, console.log("❌ " + m)); }; +async function expectOk(fn: () => Promise, m: string) { + try { ok(await fn(), m); } catch (e: any) { ok(false, m + " (unexpected throw: " + (e.shortMessage || e.message) + ")"); } +} +async function expectThrow(fn: () => Promise, m: string) { + try { await fn(); ok(false, m + " (did NOT throw!)"); } catch (e: any) { ok(true, m + " (threw: " + String(e.shortMessage || e.message).slice(0, 70) + ")"); } +} + +async function swapUni(fromToken: string, toToken: string, amount: bigint) { + const intent = normalizeIntent({ fromToken, toToken, fromAmount: amount, fromChainId: 1, protocols: ["uniswap-v4"], skipMEVCheck: true }); + const quote = await adapter.quote(intent); + return engine.execute(intent, quote, walletClient as any, publicClient as any); +} + +async function main() { + console.log("── GOOD: must execute ──"); + await expectOk(async () => { + const before = await getTokenBalance(USDC, account.address, publicClient as any); + await swapUni(ETH, USDC, 50_000000000000000000n); // 50 ETH + const after = await getTokenBalance(USDC, account.address, publicClient as any); + console.log(` 50 ETH→USDC Δ=${formatUnits(after - before, 6)} USDC`); + return after > before; + }, "LARGE 50 ETH → USDC executes"); + + await expectOk(async () => { + const before = await getTokenBalance(USDC, account.address, publicClient as any); + await swapUni(ETH, USDC, 100000000000000n); // 0.0001 ETH + const after = await getTokenBalance(USDC, account.address, publicClient as any); + console.log(` 0.0001 ETH→USDC Δ=${formatUnits(after - before, 6)} USDC`); + return after > before; + }, "TINY 0.0001 ETH → USDC executes"); + + await expectOk(async () => { + const quotes = await sdk.quote({ fromToken: ETH, toToken: USDC, fromAmount: 1_000000000000000000n, fromChainId: 1, skipMEVCheck: true }); + console.log(` multi-protocol returned ${quotes.length} quote(s): ${quotes.map(q => q.protocol).join(", ")}`); + return quotes.length >= 1 && quotes[0].amountOut > 0n; + }, "multi-protocol quote returns sorted quotes"); + + console.log("\n── WRONG: must throw ──"); + await expectThrow(() => sdk.quote({ fromToken: ETH, toToken: USDC, fromAmount: 1n, fromChainId: 999, skipMEVCheck: true }), "unsupported chain 999 rejected"); + await expectThrow(() => sdk.quote({ fromToken: ETH, toToken: USDC, fromAmount: 1n, fromChainId: 1, toChainId: 8453, skipMEVCheck: true }), "cross-chain (no adapter) rejected"); + await expectThrow(() => sdk.quote({ fromToken: DEAD, toToken: USDC, fromAmount: 1_000000000000000000n, fromChainId: 1, skipMEVCheck: true }), "invalid/no-liquidity token rejected"); + await expectThrow(() => sdk.quote({ fromToken: ETH, toToken: USDC, fromAmount: 1n, fromChainId: 1, maxSlippageBps: 2001, skipMEVCheck: true }), "slippage 2001 rejected"); + await expectThrow(() => swapUni(USDC, DEAD, 1_000000n), "no-pool pair (USDC→DEAD) rejected at quote"); + await expectThrow(async () => { + // recipient != signer must be blocked by the execution guard + const intent = normalizeIntent({ fromToken: ETH, toToken: USDC, fromAmount: 1_000000000000000000n, fromChainId: 1, protocols: ["uniswap-v4"], skipMEVCheck: true, recipient: OTHER as `0x${string}` }); + const quote = await adapter.quote(intent); + return engine.execute(intent, quote, walletClient as any, publicClient as any); + }, "recipient != signer blocked"); + await expectThrow(() => swapUni(ETH, USDC, 1_000_000_000000000000000000n), "insufficient balance (1,000,000 ETH) reverts"); + + console.log(`\n──── ${pass} passed, ${fail} failed ────`); + process.exit(fail > 0 ? 1 : 0); +} +main().catch((e) => { console.error("FATAL:", e?.shortMessage || e?.message || e); process.exit(1); }); diff --git a/swap-kit/packages/core/scripts/fork-verify-permit2.ts b/swap-kit/packages/core/scripts/fork-verify-permit2.ts new file mode 100644 index 0000000..16670d1 --- /dev/null +++ b/swap-kit/packages/core/scripts/fork-verify-permit2.ts @@ -0,0 +1,67 @@ +/** + * Isolated proof of the Permit2 two-leg approval fix: a USDC→WETH swap via + * Uniswap V4 (ERC-20 input). Uses the adapter + ExecutionEngine directly so the + * QuoteEngine's 15s race wrapper can't time out on cold fork eth_calls. + * + * Requires the account to already hold USDC on the fork (run fork-verify.ts first). + * + * RPC_ETHEREUM=http://127.0.0.1:8545 npx tsx scripts/fork-verify-permit2.ts + */ +import { createWalletClient, createPublicClient, http, formatUnits } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { mainnet } from "viem/chains"; +import { UniswapV4Adapter, ExecutionEngine, normalizeIntent } from "../src/index.js"; +import { getTokenBalance } from "../src/utils/token.js"; + +const ANVIL = "http://127.0.0.1:8545"; +const PK = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; +const UR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; + +const account = privateKeyToAccount(PK); +const walletClient = createWalletClient({ account, chain: mainnet, transport: http(ANVIL) }); +const publicClient = createPublicClient({ chain: mainnet, transport: http(ANVIL) }); + +async function allowanceERC20(token: string, spender: string) { + return publicClient.readContract({ + address: token as `0x${string}`, + abi: [{ type: "function", name: "allowance", stateMutability: "view", + inputs: [{ name: "o", type: "address" }, { name: "s", type: "address" }], + outputs: [{ type: "uint256" }] }], + functionName: "allowance", args: [account.address, spender as `0x${string}`], + }) as Promise; +} + +async function main() { + const adapter = new UniswapV4Adapter(); + const engine = new ExecutionEngine([adapter], { flashbotsEnabled: false }); + + const usdc = await getTokenBalance(USDC, account.address, publicClient as any); + const spend = usdc / 2n; + console.log(`Spending ${formatUnits(spend, 6)} of ${formatUnits(usdc, 6)} USDC → WETH\n`); + + const intent = normalizeIntent({ + fromToken: USDC, toToken: WETH, fromAmount: spend, fromChainId: 1, + protocols: ["uniswap-v4"], skipMEVCheck: true, + }); + + console.log("Quoting (direct adapter, no race timeout)…"); + const quote = await adapter.quote(intent); + console.log(` quote amountOut = ${formatUnits(quote.amountOut, 18)} WETH`); + + const wethBefore = await getTokenBalance(WETH, account.address, publicClient as any); + const res = await engine.execute(intent, quote, walletClient as any, publicClient as any); + const wethAfter = await getTokenBalance(WETH, account.address, publicClient as any); + + console.log(`\n txHash = ${res.txHash}`); + console.log(` ERC20→Permit2 allowance now: ${await allowanceERC20(USDC, PERMIT2)}`); + console.log(` Permit2→Router allowance (uint160) confirmed via successful pull`); + console.log(` WETH: ${formatUnits(wethBefore, 18)} → ${formatUnits(wethAfter, 18)} (Δ ${formatUnits(wethAfter - wethBefore, 18)})`); + + const ok = wethAfter > wethBefore; + console.log(`\n──── ${ok ? "✅ PASS" : "❌ FAIL"}: Permit2 two-leg approval + ERC20-input v4 swap ────`); + process.exit(ok ? 0 : 1); +} +main().catch((e) => { console.error("FATAL:", e?.shortMessage || e?.message || e); process.exit(1); }); diff --git a/swap-kit/packages/core/scripts/fork-verify.ts b/swap-kit/packages/core/scripts/fork-verify.ts new file mode 100644 index 0000000..f48ea09 --- /dev/null +++ b/swap-kit/packages/core/scripts/fork-verify.ts @@ -0,0 +1,81 @@ +/** + * Fork verification — proves the critical fixes work end-to-end against a + * mainnet fork (anvil), with ZERO real funds at risk. + * + * Run: + * anvil --fork-url $RPC_ETHEREUM --port 8545 --silent & + * RPC_ETHEREUM=http://127.0.0.1:8545 npx tsx scripts/fork-verify.ts + * + * What it checks: + * A. Uniswap V4 quote now returns a real on-chain amount (the Quoter ABI fix). + * B. An ETH→USDC swap via Uniswap V4 actually executes; USDC balance rises. + * C. A USDC→WETH swap via Uniswap V4 executes — exercises BOTH Permit2 approval + * legs (ERC20→Permit2 and Permit2→UniversalRouter). + */ +import { createWalletClient, createPublicClient, http, formatUnits } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { mainnet } from "viem/chains"; +import { createSwapKit } from "../src/index.js"; +import { getTokenBalance } from "../src/utils/token.js"; + +const ANVIL = "http://127.0.0.1:8545"; +// anvil default account #0 — well-known dev key, prefunded with 10000 ETH on the fork. +const PK = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; + +const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; + +const account = privateKeyToAccount(PK); +const walletClient = createWalletClient({ account, chain: mainnet, transport: http(ANVIL) }); +const publicClient = createPublicClient({ chain: mainnet, transport: http(ANVIL) }); + +// flashbots OFF + skip MEV so nothing reroutes off the fork to a real relay. +const sdk = createSwapKit({ oneInchApiKey: "", flashbotsEnabled: false }); + +let pass = 0, fail = 0; +const ok = (c: boolean, m: string) => { c ? (pass++, console.log("✅ " + m)) : (fail++, console.log("❌ " + m)); }; + +async function main() { + console.log("\n=== A. Uniswap V4 quote (ABI fix) ==="); + const quotes = await sdk.quote({ + fromToken: ETH, toToken: USDC, fromAmount: 50000000000000000n /* 0.05 ETH */, + fromChainId: 1, skipMEVCheck: true, + }); + for (const q of quotes) { + console.log(` ${q.protocol.padEnd(13)} amountOut=${formatUnits(q.amountOut, 6)} USDC gas=${q.gasCostWei}`); + } + const uni = quotes.find(q => q.protocol === "uniswap-v4"); + ok(!!uni && uni.amountOut > 0n, "Uniswap V4 returns a real quote (was always throwing before)"); + + console.log("\n=== B. ETH→USDC swap via Uniswap V4 (native input, real tx) ==="); + const usdcBefore = await getTokenBalance(USDC, account.address, publicClient as any); + const resB = await sdk.swap( + { fromToken: ETH, toToken: USDC, fromAmount: 50000000000000000n, fromChainId: 1, + protocols: ["uniswap-v4"], skipMEVCheck: true }, + walletClient as any, publicClient as any, + ); + const usdcAfter = await getTokenBalance(USDC, account.address, publicClient as any); + console.log(` txHash=${resB.txHash}`); + console.log(` USDC: ${formatUnits(usdcBefore, 6)} → ${formatUnits(usdcAfter, 6)} (Δ ${formatUnits(usdcAfter - usdcBefore, 6)})`); + console.log(` gasPaidWei=${resB.gasPaidWei} actualAmountOut=${formatUnits(resB.actualAmountOut, 6)}`); + ok(usdcAfter > usdcBefore, "ETH→USDC executed and USDC balance increased"); + + console.log("\n=== C. USDC→WETH swap via Uniswap V4 (ERC20 input → Permit2 two-leg) ==="); + const spend = usdcAfter / 2n; // spend half the USDC we just got + const wethBefore = await getTokenBalance(WETH, account.address, publicClient as any); + const resC = await sdk.swap( + { fromToken: USDC, toToken: WETH, fromAmount: spend, fromChainId: 1, + protocols: ["uniswap-v4"], skipMEVCheck: true }, + walletClient as any, publicClient as any, + ); + const wethAfter = await getTokenBalance(WETH, account.address, publicClient as any); + console.log(` txHash=${resC.txHash}`); + console.log(` WETH: ${formatUnits(wethBefore, 18)} → ${formatUnits(wethAfter, 18)} (Δ ${formatUnits(wethAfter - wethBefore, 18)})`); + ok(wethAfter > wethBefore, "USDC→WETH executed (both Permit2 approvals worked) and WETH increased"); + + console.log(`\n──── RESULT: ${pass} passed, ${fail} failed ────`); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch((e) => { console.error("FATAL:", e?.shortMessage || e?.message || e); process.exit(1); }); diff --git a/swap-kit/packages/core/scripts/logic-edge.ts b/swap-kit/packages/core/scripts/logic-edge.ts new file mode 100644 index 0000000..39913d6 --- /dev/null +++ b/swap-kit/packages/core/scripts/logic-edge.ts @@ -0,0 +1,57 @@ +/** + * Pure-logic edge-case suite (no network). Boundary values, high numbers, and + * malformed inputs that MUST be rejected. + * + * npx tsx scripts/logic-edge.ts + */ +import { + normalizeIntent, assertValidSlippageBps, MIN_SLIPPAGE_BPS, MAX_SLIPPAGE_BPS, + calculateMinOutput, estimateOptimalSlippage, calculatePriceImpact, +} from "../src/index.js"; + +let pass = 0, fail = 0; +const ok = (c: boolean, m: string) => { c ? (pass++, console.log("✅ " + m)) : (fail++, console.log("❌ " + m)); }; +const throws = (fn: () => unknown, m: string) => { try { fn(); ok(false, m + " (did NOT throw)"); } catch { ok(true, m + " (threw as expected)"); } }; +const nothrow = (fn: () => unknown, m: string) => { try { fn(); ok(true, m); } catch (e: any) { ok(false, m + " (threw: " + e.message + ")"); } }; + +console.log("── slippage bounds [" + MIN_SLIPPAGE_BPS + ", " + MAX_SLIPPAGE_BPS + "] ──"); +nothrow(() => assertValidSlippageBps(1), "1 bps accepted (min)"); +nothrow(() => assertValidSlippageBps(2000), "2000 bps accepted (max)"); +nothrow(() => assertValidSlippageBps(50), "50 bps accepted"); +throws(() => assertValidSlippageBps(0), "0 bps rejected (unfillable / no protection)"); +throws(() => assertValidSlippageBps(2001), "2001 bps rejected (over ceiling)"); +throws(() => assertValidSlippageBps(10000), "10000 bps rejected (zero minOut)"); +throws(() => assertValidSlippageBps(-1), "-1 bps rejected"); +throws(() => assertValidSlippageBps(1.5), "1.5 bps rejected (non-integer)"); +throws(() => assertValidSlippageBps(NaN), "NaN rejected"); +throws(() => assertValidSlippageBps("50" as any), "string '50' rejected (non-number)"); + +console.log("\n── normalizeIntent defaults + rejections ──"); +const norm = normalizeIntent({ fromToken: "ETH", toToken: "USDC", fromAmount: 1n, fromChainId: 1 }); +ok(norm.maxSlippageBps === 50, "default slippage 50"); +ok(norm.recipient === "0x0000000000000000000000000000000000000000", "default recipient zero"); +ok(norm.protocols.length === 3, "defaults to all 3 protocols"); +ok(norm.toChainId === 1, "toChainId defaults to fromChainId"); +ok(norm.fromToken.startsWith("0x") && norm.fromToken.length === 42, "ETH symbol resolved to address"); +throws(() => normalizeIntent({ fromToken: "NOTATOKEN", toToken: "USDC", fromAmount: 1n, fromChainId: 1 }), "unknown token symbol rejected"); +throws(() => normalizeIntent({ fromToken: "ETH", toToken: "USDC", fromAmount: 1n, fromChainId: 1, maxSlippageBps: 2001 }), "intent slippage 2001 rejected"); +throws(() => normalizeIntent({ fromToken: "ETH", toToken: "USDC", fromAmount: 1n, fromChainId: 1, maxSlippageBps: 0 }), "intent slippage 0 rejected"); + +console.log("\n── calculateMinOutput math + high numbers ──"); +ok(calculateMinOutput(1_000_000n, 50) === 995_000n, "1e6 @ 50bps = 995000"); +ok(calculateMinOutput(1_000_000n, 0) === 1_000_000n, "0 slippage = full amount"); +ok(calculateMinOutput(1_000_000n, 10000) === 0n, "10000 bps = 0 minOut"); +throws(() => calculateMinOutput(1n, 10001), "slippage > 10000 rejected"); +throws(() => calculateMinOutput(1n, -1), "negative slippage rejected"); +// High number: 1e30 wei, exact bigint math, no float precision loss +const huge = 10n ** 30n; +ok(calculateMinOutput(huge, 50) === huge * 9950n / 10000n, "1e30 @ 50bps exact (no precision loss)"); + +console.log("\n── estimateOptimalSlippage / priceImpact edges ──"); +ok(estimateOptimalSlippage(1n, 0n) === 200, "zero liquidity → 2% default"); +ok(estimateOptimalSlippage(1n, 10n ** 24n) === 10, "tiny trade vs deep pool → 0.1%"); +ok(calculatePriceImpact(0n, 100n, 5n) === 0, "zero input → 0 impact"); +ok(calculatePriceImpact(100n, 100n, 0n) === 0, "zero spot price → 0 impact"); + +console.log(`\n──── ${pass} passed, ${fail} failed ────`); +process.exit(fail > 0 ? 1 : 0); diff --git a/swap-kit/packages/core/scripts/oneinch-exec.ts b/swap-kit/packages/core/scripts/oneinch-exec.ts new file mode 100644 index 0000000..cd7624b --- /dev/null +++ b/swap-kit/packages/core/scripts/oneinch-exec.ts @@ -0,0 +1,30 @@ +import { createWalletClient, createPublicClient, http, formatUnits } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { mainnet } from "viem/chains"; +import { OneInchFusionAdapter, ExecutionEngine, normalizeIntent } from "../src/index.js"; +import { getTokenBalance } from "../src/utils/token.js"; + +const ANVIL = "http://127.0.0.1:8545"; +const PK = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; +const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + +const account = privateKeyToAccount(PK); +const walletClient = createWalletClient({ account, chain: mainnet, transport: http(ANVIL) }); +const publicClient = createPublicClient({ chain: mainnet, transport: http(ANVIL) }); +const key = process.env["1INCH_API_KEY"] || ""; + +async function main() { + const adapter = new OneInchFusionAdapter(key); + const engine = new ExecutionEngine([adapter], { flashbotsEnabled: false }); + const intent = normalizeIntent({ fromToken: ETH, toToken: USDC, fromAmount: 500000000000000000n, fromChainId: 1, protocols: ["1inch-fusion"], skipMEVCheck: true }); + const quote = await adapter.quote(intent); + console.log(`1inch quote: ${formatUnits(quote.amountOut, 6)} USDC for 0.5 ETH`); + const before = await getTokenBalance(USDC, account.address, publicClient as any); + const res = await engine.execute(intent, quote, walletClient as any, publicClient as any); + const after = await getTokenBalance(USDC, account.address, publicClient as any); + console.log(`tx=${res.txHash} USDC Δ=${formatUnits(after - before, 6)}`); + console.log(after > before ? "✅ 1inch ETH→USDC executed on fork" : "❌ no USDC received"); + process.exit(after > before ? 0 : 1); +} +main().catch((e) => { console.error("1inch exec note:", e?.shortMessage || e?.message || e); process.exit(2); }); diff --git a/swap-kit/packages/core/src/abis/index.ts b/swap-kit/packages/core/src/abis/index.ts index e73bf9b..4608685 100644 --- a/swap-kit/packages/core/src/abis/index.ts +++ b/swap-kit/packages/core/src/abis/index.ts @@ -30,63 +30,14 @@ export const UniversalRouterABI = [ }, ] as const; -// ─── PoolManager ────────────────────────────────────────────────────────────── -// https://docs.uniswap.org/contracts/v4/reference/core/PoolManager +// ─── V4 Quoter ──────────────────────────────────────────────────────────────── +// Matches the DEPLOYED Uniswap V4 `Quoter` (IV4Quoter). Verified on mainnet +// (0x52f0…1203): selector 0xaa9d21cb. The QuoteExactSingleParams struct has NO +// `sqrtPriceLimitX96`, and the function returns two scalars +// `(uint256 amountOut, uint256 gasEstimate)` — NOT arrays. Encoding the older +// (sqrtPriceLimitX96 + array-output) shape reverts on-chain. -export const PoolManagerABI = [ - { - type: "function", - name: "swap", - inputs: [ - { - name: "key", - type: "tuple", - internalType: "struct PoolKey", - components: [ - { name: "currency0", type: "address", internalType: "Currency" }, - { name: "currency1", type: "address", internalType: "Currency" }, - { name: "fee", type: "uint24", internalType: "uint24" }, - { name: "tickSpacing", type: "int24", internalType: "int24" }, - { name: "hooks", type: "address", internalType: "contract IHooks" }, - ], - }, - { - name: "params", - type: "tuple", - internalType: "struct IPoolManager.SwapParams", - components: [ - { name: "zeroForOne", type: "bool", internalType: "bool" }, - { name: "amountSpecified", type: "int256", internalType: "int256" }, - { name: "sqrtPriceLimitX96", type: "uint160", internalType: "uint160" }, - ], - }, - { name: "hookData", type: "bytes", internalType: "bytes" }, - ], - outputs: [ - { name: "swapDelta", type: "int256", internalType: "BalanceDelta" }, - ], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "getSlot0", - inputs: [ - { name: "id", type: "bytes32", internalType: "PoolId" }, - ], - outputs: [ - { name: "sqrtPriceX96", type: "uint160", internalType: "uint160" }, - { name: "tick", type: "int24", internalType: "int24" }, - { name: "protocolFee", type: "uint24", internalType: "uint24" }, - { name: "lpFee", type: "uint24", internalType: "uint24" }, - ], - stateMutability: "view", - }, -] as const; - -// ─── QuoterV2 ───────────────────────────────────────────────────────────────── -// Used for off-chain swap simulation (reverts with result) - -export const QuoterV2ABI = [ +export const V4QuoterABI = [ { type: "function", name: "quoteExactInputSingle", @@ -94,7 +45,7 @@ export const QuoterV2ABI = [ { name: "params", type: "tuple", - internalType: "struct IQuoter.QuoteExactSingleParams", + internalType: "struct IV4Quoter.QuoteExactSingleParams", components: [ { name: "poolKey", @@ -110,51 +61,18 @@ export const QuoterV2ABI = [ }, { name: "zeroForOne", type: "bool", internalType: "bool" }, { name: "exactAmount", type: "uint128", internalType: "uint128" }, - { name: "sqrtPriceLimitX96", type: "uint160", internalType: "uint160" }, { name: "hookData", type: "bytes", internalType: "bytes" }, ], }, ], outputs: [ - { name: "deltaAmounts", type: "int128[]", internalType: "int128[]" }, - { name: "sqrtPriceX96After", type: "uint160", internalType: "uint160" }, - { name: "initializedTicksCrossed", type: "uint32", internalType: "uint32" }, + { name: "amountOut", type: "uint256", internalType: "uint256" }, + { name: "gasEstimate", type: "uint256", internalType: "uint256" }, ], stateMutability: "nonpayable", }, ] as const; -// ─── StateView ──────────────────────────────────────────────────────────────── -// Read-only view of pool state - -export const StateViewABI = [ - { - type: "function", - name: "getSlot0", - inputs: [ - { name: "poolId", type: "bytes32", internalType: "PoolId" }, - ], - outputs: [ - { name: "sqrtPriceX96", type: "uint160", internalType: "uint160" }, - { name: "tick", type: "int24", internalType: "int24" }, - { name: "protocolFee", type: "uint24", internalType: "uint24" }, - { name: "lpFee", type: "uint24", internalType: "uint24" }, - ], - stateMutability: "view", - }, - { - type: "function", - name: "getLiquidity", - inputs: [ - { name: "poolId", type: "bytes32", internalType: "PoolId" }, - ], - outputs: [ - { name: "liquidity", type: "uint128", internalType: "uint128" }, - ], - stateMutability: "view", - }, -] as const; - // ─── Permit2 ────────────────────────────────────────────────────────────────── export const Permit2ABI = [ diff --git a/swap-kit/packages/core/src/adapters/one-inch.ts b/swap-kit/packages/core/src/adapters/one-inch.ts index 09bd660..30c36aa 100644 --- a/swap-kit/packages/core/src/adapters/one-inch.ts +++ b/swap-kit/packages/core/src/adapters/one-inch.ts @@ -93,7 +93,7 @@ export class OneInchFusionAdapter implements ISwapAdapter { fromAmount: intent.fromAmount.toString(), secrets: data.secrets || [], slippageBps: intent.maxSlippageBps, - } as any, + }, validUntil: Math.floor(Date.now() / 1000) + 120, }; } @@ -113,7 +113,7 @@ export class OneInchFusionAdapter implements ISwapAdapter { throw new Error("Cross-chain Fusion+ execution is not supported in this version. Requires 1inch Fusion SDK signature."); } - const slippagePct = ((routeData as any).slippageBps || 50) / 100; + const slippagePct = (routeData.slippageBps || 50) / 100; const swapUrl = `https://api.1inch.dev/swap/v6.0/${chainId}/swap?src=${routeData.srcToken}&dst=${routeData.dstToken}&amount=${routeData.fromAmount}&from=${userAddress}&slippage=${slippagePct}&disableEstimate=true`; const res = await fetch(swapUrl, { diff --git a/swap-kit/packages/core/src/adapters/paraswap.ts b/swap-kit/packages/core/src/adapters/paraswap.ts index ba3fde6..bbd82c5 100644 --- a/swap-kit/packages/core/src/adapters/paraswap.ts +++ b/swap-kit/packages/core/src/adapters/paraswap.ts @@ -104,7 +104,7 @@ export class ParaswapAdapter implements ISwapAdapter { priceRoute: priceData.priceRoute, calldata: "0x" as Hex, slippageBps: intent.maxSlippageBps, - } as any, + }, validUntil: Math.floor(Date.now() / 1000) + 60, }; } catch (error: any) { @@ -129,7 +129,7 @@ export class ParaswapAdapter implements ISwapAdapter { routeData.priceRoute, userAddress, quote.amountOut, - (routeData as any).slippageBps || 50 + routeData.slippageBps || 50 ); // Get balance before diff --git a/swap-kit/packages/core/src/adapters/uniswap-v4.ts b/swap-kit/packages/core/src/adapters/uniswap-v4.ts index 393b19f..5a0352f 100644 --- a/swap-kit/packages/core/src/adapters/uniswap-v4.ts +++ b/swap-kit/packages/core/src/adapters/uniswap-v4.ts @@ -1,6 +1,4 @@ import { - createPublicClient, - http, encodeFunctionData, encodeAbiParameters, type Address, @@ -8,10 +6,7 @@ import { type WalletClient, type PublicClient, } from "viem"; -import { mainnet, base, arbitrum } from "viem/chains"; -import { UniversalRouterABI } from "../abis/index.js"; -import { isNativeToken } from "../utils/token.js"; -import { ERC20ABI } from "../abis/index.js"; +import { UniversalRouterABI, ERC20ABI, V4QuoterABI } from "../abis/index.js"; import type { ISwapAdapter } from "./base.js"; import type { SwapIntent, @@ -24,7 +19,7 @@ import { getPublicClient } from "../utils/chain.js"; import { assertValidSlippageBps } from "../intent/parser.js"; // Chain-specific addresses (Uniswap v4 deployments) -const UNISWAP_V4_ADDRESSES: Record = { - 1: mainnet, - 8453: base, - 42161: arbitrum, -}; - -// Quoter ABI (only the function we need) -const QUOTER_ABI = [ - { - name: "quoteExactInputSingle", - type: "function", - stateMutability: "nonpayable", - inputs: [ - { - name: "params", - type: "tuple", - components: [ - { name: "poolKey", type: "tuple", components: [ - { name: "currency0", type: "address" }, - { name: "currency1", type: "address" }, - { name: "fee", type: "uint24" }, - { name: "tickSpacing", type: "int24" }, - { name: "hooks", type: "address" }, - ]}, - { name: "zeroForOne", type: "bool" }, - { name: "exactAmount", type: "uint128" }, - { name: "sqrtPriceLimitX96", type: "uint160" }, - { name: "hookData", type: "bytes" }, - ], - }, - ], - outputs: [ - { name: "amountOut", type: "int128[]" }, - { name: "sqrtPriceX96After", type: "uint160[]" }, - { name: "initializedTicksCrossed", type: "uint32" }, - ], - }, -] as const; - -// V4 Router Actions -const ACTIONS = { - SWAP_EXACT_IN_SINGLE: 0x06, - SETTLE_ALL: 0x0c, - TAKE_ALL: 0x0f, -}; - export class UniswapV4Adapter implements ISwapAdapter { readonly protocol = "uniswap-v4" as const; @@ -122,17 +71,21 @@ export class UniswapV4Adapter implements ISwapAdapter { let bestAmountOut = 0n; let bestPoolKey: PoolKey | null = null; - // Test multiple fee tiers to find the pool with the best liquidity (CRITICAL-3) - for (const fee of fees) { - const poolKey = this.buildPoolKey(intent.fromToken as Address, intent.toToken as Address, fee); - try { + // Quote every fee tier CONCURRENTLY and keep the best. Doing this sequentially + // makes a single quote up to 4× slower and can trip the quote-engine timeout on + // slower RPCs. Failed tiers (no pool / no liquidity) are simply ignored. + const tierQuotes = await Promise.allSettled( + fees.map(async (fee) => { + const poolKey = this.buildPoolKey(intent.fromToken as Address, intent.toToken as Address, fee); const amountOut = await this.getQuoteExact(client, poolKey, intent.fromAmount, addrs.quoter, intent.fromToken as Address); - if (amountOut > bestAmountOut) { - bestAmountOut = amountOut; - bestPoolKey = poolKey; - } - } catch { - // Pool doesn't exist or not enough liquidity, try next fee tier + return { poolKey, amountOut }; + }) + ); + + for (const tier of tierQuotes) { + if (tier.status === "fulfilled" && tier.value.amountOut > bestAmountOut) { + bestAmountOut = tier.value.amountOut; + bestPoolKey = tier.value.poolKey; } } @@ -274,7 +227,7 @@ export class UniswapV4Adapter implements ISwapAdapter { const result = await client.simulateContract({ address: quoterAddr, - abi: QUOTER_ABI, + abi: V4QuoterABI, functionName: "quoteExactInputSingle", args: [{ poolKey: { @@ -286,14 +239,13 @@ export class UniswapV4Adapter implements ISwapAdapter { }, zeroForOne, exactAmount: amountIn, - sqrtPriceLimitX96: 0n, hookData: "0x", }], }); - const amountOutArray = result.result[0] as bigint[]; - const rawOut = amountOutArray[0]; - return rawOut < 0n ? -rawOut : rawOut; + // IV4Quoter returns (uint256 amountOut, uint256 gasEstimate) — two scalars. + const amountOut = result.result[0] as bigint; + return amountOut; } private async getPriceImpact( diff --git a/swap-kit/packages/core/src/execution/engine.ts b/swap-kit/packages/core/src/execution/engine.ts index d3d145a..b654e18 100644 --- a/swap-kit/packages/core/src/execution/engine.ts +++ b/swap-kit/packages/core/src/execution/engine.ts @@ -4,12 +4,16 @@ import type { ISwapAdapter } from "../adapters/base.js"; import { ERC20ABI, Permit2ABI } from "../abis/index.js"; import { isNativeToken } from "../utils/token.js"; import { checkGasAffordability, type GasCheck } from "../gasless/detector.js"; +import { UNISWAP_V4_ADDRESSES } from "../adapters/uniswap-v4.js"; // Permit2 is deployed at the same address on all chains const PERMIT2_ADDRESS: Address = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; -// Max uint256 for unlimited approval +// Max uint256 for unlimited ERC-20 approval; Permit2 amounts are uint160. const MAX_UINT256 = 2n ** 256n - 1n; +const MAX_UINT160 = 2n ** 160n - 1n; +// Permit2 allowance expiration is a uint48 unix timestamp. 30 days ahead. +const PERMIT2_EXPIRATION_SECONDS = 30 * 24 * 60 * 60; // Default Flashbots Protect RPC endpoint (Ethereum Mainnet) const DEFAULT_FLASHBOTS_RPC = "https://rpc.flashbots.net"; @@ -241,8 +245,10 @@ export class ExecutionEngine { const owner = walletClient.account!.address; if (this.config.usePermit2 && quote.protocol === "uniswap-v4") { - // Uniswap v4 uses Permit2 — approve token to Permit2 first, - // then Permit2 will authorize the UniversalRouter + // Uniswap v4 uses Permit2 — this requires TWO approvals: + // 1. ERC-20 approve(token → Permit2) — lets Permit2 pull the token + // 2. Permit2 approve(token, UniversalRouter) — lets the router pull via Permit2 + // Without step 2 the UniversalRouter cannot move the token and the swap reverts. await this.ensureERC20Approval( tokenAddress, PERMIT2_ADDRESS, @@ -251,6 +257,18 @@ export class ExecutionEngine { walletClient, publicClient ); + + const universalRouter = UNISWAP_V4_ADDRESSES[walletClient.chain!.id]?.universalRouter; + if (universalRouter) { + await this.ensurePermit2Allowance( + tokenAddress, + universalRouter, + amount, + owner, + walletClient, + publicClient + ); + } } else { // Standard ERC-20 approval to the protocol's router const spender = this.getSpenderForProtocol(quote, walletClient.chain!.id); @@ -305,6 +323,50 @@ export class ExecutionEngine { await walletClient.writeContract(request); } + /** + * Grants a Permit2 allowance so `spenderAddress` (the UniversalRouter) can pull + * `tokenAddress` from the owner via Permit2. This is the SECOND leg of the + * Uniswap v4 approval flow — the ERC-20 → Permit2 approval alone is insufficient. + * + * Permit2 amounts are uint160 and allowances carry a uint48 expiration; the + * "infinite" strategy grants the uint160 max, "exact" grants just the trade amount. + */ + private async ensurePermit2Allowance( + tokenAddress: Address, + spenderAddress: Address, + amount: bigint, + ownerAddress: Address, + walletClient: WalletClient, + publicClient: PublicClient + ): Promise { + // Permit2.allowance(owner, token, spender) → (amount, expiration, nonce) + const [currentAmount, currentExpiration] = await publicClient.readContract({ + address: PERMIT2_ADDRESS, + abi: Permit2ABI, + functionName: "allowance", + args: [ownerAddress, tokenAddress, spenderAddress], + }) as [bigint, number, number]; + + const now = Math.floor(Date.now() / 1000); + // Sufficient if the allowance covers the amount AND has not expired. + if (currentAmount >= amount && Number(currentExpiration) > now) return; + + const approvalAmount = this.config.approvalStrategy === "infinite" + ? MAX_UINT160 + : amount; + const expiration = now + PERMIT2_EXPIRATION_SECONDS; + + const { request } = await publicClient.simulateContract({ + address: PERMIT2_ADDRESS, + abi: Permit2ABI, + functionName: "approve", + args: [tokenAddress, spenderAddress, approvalAmount, expiration], + account: ownerAddress, + }); + + await walletClient.writeContract(request); + } + /** * Returns the contract address that needs token approval for each protocol. */ diff --git a/swap-kit/packages/core/src/test/approval-strategy.test.ts b/swap-kit/packages/core/src/test/approval-strategy.test.ts index 5fd3c1d..f10ea04 100644 --- a/swap-kit/packages/core/src/test/approval-strategy.test.ts +++ b/swap-kit/packages/core/src/test/approval-strategy.test.ts @@ -28,7 +28,10 @@ import type { WalletClient, PublicClient } from "viem"; // ─── Constants under test ────────────────────────────────────────────────────── const MAX_UINT256 = 2n ** 256n - 1n; +const MAX_UINT160 = 2n ** 160n - 1n; const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; +// Uniswap v4 UniversalRouter on Ethereum mainnet (chain id 1). +const UNIVERSAL_ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const PARASWAP_PROXY = "0x216B4B4Ba9F3e719726886d34a177484278Bfcae"; const SIGNER = "0x1234567890abcdef1234567890abcdef12345678"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; @@ -106,15 +109,26 @@ function mockWalletClient(): WalletClient { * Returns the captured { spender, amount } via the closure. */ function makeCapturingPublicClient(allowance: bigint) { - const captured: { spender: string; amount: bigint }[] = []; + const captured: { spender: string; amount: bigint; via: "erc20" | "permit2" }[] = []; const client = { readContract: async (params: any) => { - if (params.functionName === "allowance") return allowance; + if (params.functionName === "allowance") { + // Permit2.allowance(owner, token, spender) returns a (amount, expiration, nonce) + // tuple; ERC-20 allowance(owner, spender) returns a scalar. + if (params.args.length === 3) return [allowance, 0, 0]; + return allowance; + } return 0n; }, simulateContract: async (params: any) => { if (params.functionName === "approve") { - captured.push({ spender: params.args[0], amount: params.args[1] }); + if (params.args.length === 4) { + // Permit2.approve(token, spender, amount, expiration) + captured.push({ spender: params.args[1], amount: params.args[2], via: "permit2" }); + } else { + // ERC-20 approve(spender, amount) + captured.push({ spender: params.args[0], amount: params.args[1], via: "erc20" }); + } } return { request: { __captured: params.args } }; }, @@ -203,18 +217,24 @@ async function main() { // ─── SECTION 4: Permit2 (Uniswap v4) path ────────────────────────────── console.log("\n──── SECTION 4: Permit2 path honors the strategy ────"); - await test("uniswap-v4 default approves EXACT amount to Permit2", async () => { + await test("uniswap-v4 default approves EXACT amount: ERC20→Permit2 AND Permit2→UniversalRouter", async () => { const engine = new ExecutionEngine([new SpyAdapter("uniswap-v4")], { flashbotsEnabled: false }); const { client, captured } = makeCapturingPublicClient(0n); const intent = { ...erc20Intent(), protocols: ["uniswap-v4"] as SwapProtocol[] }; await engine.execute(intent, mockQuote("uniswap-v4"), mockWalletClient(), client); - assert.strictEqual(captured.length, 1); - assert.strictEqual(captured[0].spender.toLowerCase(), PERMIT2_ADDRESS.toLowerCase(), "v4 should approve to Permit2"); - assert.strictEqual(captured[0].amount, SWAP_AMOUNT, "v4 should approve exact amount by default"); + assert.strictEqual(captured.length, 2, "v4 needs BOTH the ERC20→Permit2 and Permit2→UniversalRouter approvals"); + // Leg 1: ERC-20 approve(Permit2) + assert.strictEqual(captured[0].via, "erc20"); + assert.strictEqual(captured[0].spender.toLowerCase(), PERMIT2_ADDRESS.toLowerCase(), "leg 1 spender should be Permit2"); + assert.strictEqual(captured[0].amount, SWAP_AMOUNT, "leg 1 should approve exact amount by default"); + // Leg 2: Permit2 approve(UniversalRouter) + assert.strictEqual(captured[1].via, "permit2"); + assert.strictEqual(captured[1].spender.toLowerCase(), UNIVERSAL_ROUTER.toLowerCase(), "leg 2 spender should be the UniversalRouter"); + assert.strictEqual(captured[1].amount, SWAP_AMOUNT, "leg 2 should approve exact amount by default"); }); - await test("uniswap-v4 infinite approves MAX_UINT256 to Permit2", async () => { + await test("uniswap-v4 infinite approves MAX_UINT256 (ERC20) and MAX_UINT160 (Permit2)", async () => { const engine = new ExecutionEngine([new SpyAdapter("uniswap-v4")], { flashbotsEnabled: false, approvalStrategy: "infinite", @@ -222,8 +242,11 @@ async function main() { const { client, captured } = makeCapturingPublicClient(0n); const intent = { ...erc20Intent(), protocols: ["uniswap-v4"] as SwapProtocol[] }; await engine.execute(intent, mockQuote("uniswap-v4"), mockWalletClient(), client); - assert.strictEqual(captured[0].amount, MAX_UINT256); + assert.strictEqual(captured.length, 2); + assert.strictEqual(captured[0].amount, MAX_UINT256, "ERC20→Permit2 infinite is uint256 max"); assert.strictEqual(captured[0].spender.toLowerCase(), PERMIT2_ADDRESS.toLowerCase()); + assert.strictEqual(captured[1].amount, MAX_UINT160, "Permit2→router infinite is uint160 max"); + assert.strictEqual(captured[1].spender.toLowerCase(), UNIVERSAL_ROUTER.toLowerCase()); }); // ─── RESULTS ─────────────────────────────────────────────────────────── diff --git a/swap-kit/packages/core/src/test/gasless.test.ts b/swap-kit/packages/core/src/test/gasless.test.ts index b757a9a..ff9f476 100644 --- a/swap-kit/packages/core/src/test/gasless.test.ts +++ b/swap-kit/packages/core/src/test/gasless.test.ts @@ -368,12 +368,15 @@ console.log("══════════════════════ await test("Callback fires when user cannot afford gas", async () => { let callCount = 0; - let receivedCheck: GasCheck | null = null; + // Holder object: a `let` assigned only inside a closure gets narrowed by CFA to + // its `null` initializer, which `assert.ok` then collapses to `never`. A property + // on an object is not narrowed that way, so this keeps its declared type. + const received: { check: GasCheck | null } = { check: null }; const engine = new ExecutionEngine([new SpyAdapter()], { gasless: { enabled: true, - onGaslessSwap: (check) => { callCount++; receivedCheck = check; }, + onGaslessSwap: (check) => { callCount++; received.check = check; }, }, }); const quote = mockQuote({ gasCostWei: 50000000000000n }); @@ -383,10 +386,10 @@ await test("Callback fires when user cannot afford gas", async () => { } catch { /* Expected */ } assert.strictEqual(callCount, 1, "Callback should fire exactly once"); - assert.ok(receivedCheck, "Callback should receive a GasCheck object"); - assert.strictEqual(receivedCheck!.canAffordGas, false); - assert.strictEqual(receivedCheck!.userBalanceWei, 0n); - assert.ok(receivedCheck!.shortfallWei > 0n); + assert.ok(received.check, "Callback should receive a GasCheck object"); + assert.strictEqual(received.check.canAffordGas, false); + assert.strictEqual(received.check.userBalanceWei, 0n); + assert.ok(received.check.shortfallWei > 0n); }); await test("Callback does NOT fire when user can afford gas", async () => { @@ -508,12 +511,12 @@ await test("Gasless check with zero gas cost: always passes (free transaction)", }); await test("GasCheck shortfallWei is mathematically correct", async () => { - let receivedCheck: GasCheck | null = null; + const received: { check: GasCheck | null } = { check: null }; const engine = new ExecutionEngine([new SpyAdapter()], { gasless: { enabled: true, - onGaslessSwap: (check) => { receivedCheck = check; }, + onGaslessSwap: (check) => { received.check = check; }, }, }); // Gas cost 1000, margin = 1200, user has 500 → shortfall = 700 @@ -523,10 +526,10 @@ await test("GasCheck shortfallWei is mathematically correct", async () => { await engine.execute(mockIntent(), quote, mockWalletClient(), mockPublicClient(500n)); } catch { /* Expected */ } - assert.ok(receivedCheck); - assert.strictEqual(receivedCheck!.userBalanceWei, 500n); - assert.strictEqual(receivedCheck!.estimatedGasCostWei, 1200n, "Should include 20% margin"); - assert.strictEqual(receivedCheck!.shortfallWei, 700n, "1200 - 500 = 700"); + assert.ok(received.check); + assert.strictEqual(received.check.userBalanceWei, 500n); + assert.strictEqual(received.check.estimatedGasCostWei, 1200n, "Should include 20% margin"); + assert.strictEqual(received.check.shortfallWei, 700n, "1200 - 500 = 700"); }); // ═══════════════════════════════════════════════════════════════════════ diff --git a/swap-kit/packages/core/src/types.ts b/swap-kit/packages/core/src/types.ts index 9f33124..11f68c1 100644 --- a/swap-kit/packages/core/src/types.ts +++ b/swap-kit/packages/core/src/types.ts @@ -68,12 +68,14 @@ export interface OneInchRouteData { dstToken: string; // Destination token address (for execution) fromAmount: string; // Input amount as string (for execution) secrets: Hex[]; // HTLC secrets for cross-chain + slippageBps?: number; // Slippage tolerance carried from quote → execution } export interface ParaswapRouteData { type: "paraswap"; priceRoute: unknown; // Paraswap's opaque priceRoute object calldata: Hex; + slippageBps?: number; // Slippage tolerance carried from quote → execution } // ─── Uniswap V4 primitives ──────────────────────────────────────────────────── diff --git a/swap-kit/pnpm-lock.yaml b/swap-kit/pnpm-lock.yaml index 26e8176..f2ba2a0 100644 --- a/swap-kit/pnpm-lock.yaml +++ b/swap-kit/pnpm-lock.yaml @@ -116,7 +116,7 @@ importers: specifier: ^8.0.1 version: 8.2.0 viem: - specifier: ^2.23.2 + specifier: ^2.9.15 version: 2.50.4(bufferutil@4.1.0)(typescript@5.9.2)(utf-8-validate@6.0.6)(zod@4.4.3) devDependencies: tsup: