From 99aa167898f9eb56e24adc319d1b111c3ad7dd17 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:24:22 -0600 Subject: [PATCH 01/11] feat(swapper): state-override gas estimation, replace tenderly Pre-approval token sells revert eth_estimateGas, so executable quotes could never carry a real gas limit before the user approved. The estimation seat now reads the seller's actual allowance/balance first and, only when insufficient, re-estimates under a minimal node-level stateOverride (allowance/balance slot patched) on our own RPCs. Storage slots are validated against the known-slot tables via an eth_call under the override, with candidate probing and per-token caching for unknown layouts. Rate paths that simulated through Tenderly (Relay, Portals, NearIntents) now use the same native estimation, which also removes the Monad special-casing Tenderly required. The swapper Tenderly module and its config are gone; the walletConnectToDapps integration is separate and untouched. Co-Authored-By: Claude Fable 5 --- packages/public-api/.env.example | 3 - packages/public-api/src/config.ts | 3 - packages/public-api/src/env.ts | 3 - .../AcrossSwapper/utils/getAcrossStepData.ts | 12 +- .../utils/getAcrossTradeContext.ts | 1 + .../utils/getArbitrumBridgeStepData.ts | 9 +- .../BebopSwapper/utils/getBebopStepData.ts | 8 +- .../utils/getBebopTradeContext.ts | 1 + .../utils/getBobGatewayStepData.ts | 5 + .../ButterSwap/utils/getButterSwapStepData.ts | 5 + .../utils/getChainflipStepData.ts | 3 + .../utils/getDebridgeStepData.ts | 8 + .../utils/getNearIntentsStepData.ts | 62 ++-- .../getPortalsTradeRate.ts | 2 +- .../utils/getPortalsStepData.ts | 52 ++-- .../RelaySwapper/utils/getRelayStepData.ts | 40 +-- packages/swapper/src/types.ts | 3 - .../evm/getEvmNetworkFeeCryptoBaseUnit.ts | 41 ++- packages/swapper/src/utils/evm/index.ts | 2 + .../swapper/src/utils/evm/stateOverride.ts | 288 ++++++++++++++++++ .../utils/{tenderly => evm}/storageSlots.ts | 6 +- packages/swapper/src/utils/tenderly/index.ts | 3 - .../swapper/src/utils/tenderly/simulate.ts | 206 ------------- packages/swapper/src/utils/tenderly/types.ts | 84 ----- .../utils/test-data/cryptoMarketDataById.ts | 4 +- .../src/utils/thorchain/getThorStepData.ts | 15 +- 26 files changed, 447 insertions(+), 422 deletions(-) create mode 100644 packages/swapper/src/utils/evm/stateOverride.ts rename packages/swapper/src/utils/{tenderly => evm}/storageSlots.ts (96%) delete mode 100644 packages/swapper/src/utils/tenderly/index.ts delete mode 100644 packages/swapper/src/utils/tenderly/simulate.ts delete mode 100644 packages/swapper/src/utils/tenderly/types.ts diff --git a/packages/public-api/.env.example b/packages/public-api/.env.example index 864f10a0b06..2a3d2319863 100644 --- a/packages/public-api/.env.example +++ b/packages/public-api/.env.example @@ -75,9 +75,6 @@ ACROSS_INTEGRATOR_ID= BEBOP_API_KEY= CHAINFLIP_API_KEY= NEAR_INTENTS_API_KEY= -TENDERLY_API_KEY= -TENDERLY_ACCOUNT_SLUG= -TENDERLY_PROJECT_SLUG= # Affiliate DEFAULT_AFFILIATE_BPS=60 diff --git a/packages/public-api/src/config.ts b/packages/public-api/src/config.ts index 20d15324e04..7d50d41dfd2 100644 --- a/packages/public-api/src/config.ts +++ b/packages/public-api/src/config.ts @@ -30,9 +30,6 @@ export const getServerConfig = (): SwapperConfig => ({ VITE_RELAY_API_URL: env.RELAY_API_URL, VITE_BEBOP_API_KEY: env.BEBOP_API_KEY, VITE_NEAR_INTENTS_API_KEY: env.NEAR_INTENTS_API_KEY, - VITE_TENDERLY_API_KEY: env.TENDERLY_API_KEY, - VITE_TENDERLY_ACCOUNT_SLUG: env.TENDERLY_ACCOUNT_SLUG, - VITE_TENDERLY_PROJECT_SLUG: env.TENDERLY_PROJECT_SLUG, VITE_TRON_GRID_API_KEY: env.TRON_GRID_API_KEY, VITE_SUI_NODE_URL: env.SUI_NODE_URL, VITE_ACROSS_API_URL: env.ACROSS_API_URL, diff --git a/packages/public-api/src/env.ts b/packages/public-api/src/env.ts index df897d2ab22..b622983033d 100644 --- a/packages/public-api/src/env.ts +++ b/packages/public-api/src/env.ts @@ -82,9 +82,6 @@ const envSchema = z.object({ BOB_GATEWAY_API_KEY: z.string().default(''), CHAINFLIP_API_KEY: z.string().min(1), NEAR_INTENTS_API_KEY: z.string().min(1), - TENDERLY_API_KEY: z.string().min(1), - TENDERLY_ACCOUNT_SLUG: z.string().min(1), - TENDERLY_PROJECT_SLUG: z.string().min(1), TRON_GRID_API_KEY: z.string().default(''), // Feature flags diff --git a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossStepData.ts b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossStepData.ts index 720bdad95e1..3875fff6b06 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossStepData.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossStepData.ts @@ -22,6 +22,7 @@ import type { AcrossSwapTx } from './types' type BaseArgs = { swapTx: AcrossSwapTx + spenderAddress: string | undefined fallbackNetworkFeeCryptoBaseUnit: string } @@ -39,7 +40,8 @@ export function getAcrossStepData( export async function getAcrossStepData( args: GetAcrossStepDataArgs, ): Promise> { - const { swapTx, sellAsset, from, type, input, fallbackNetworkFeeCryptoBaseUnit, deps } = args + const { swapTx, sellAsset, spenderAddress, from, type, input, fallbackNetworkFeeCryptoBaseUnit, deps } = + args const supportsEIP1559 = 'supportsEIP1559' in input ? input.supportsEIP1559 : false @@ -56,6 +58,12 @@ export async function getAcrossStepData( gasLimit: swapTx.gas, } + const stateOverride = { + sellAsset, + sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, + spenderAddress, + } + if (type === 'rate') { const networkFeeCryptoBaseUnit = await (async () => { try { @@ -64,6 +72,7 @@ export async function getAcrossStepData( transactionData, from, supportsEIP1559, + stateOverride, }) } catch { return fallbackNetworkFeeCryptoBaseUnit @@ -81,6 +90,7 @@ export async function getAcrossStepData( transactionData, from, supportsEIP1559, + stateOverride, }) const stepData: AcrossQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } diff --git a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts index ea2b310fd7c..d9b80d24760 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts @@ -240,6 +240,7 @@ export const getAcrossTradeContext = async ({ swapTx: quote.swapTx, sellAsset, from: depositor, + spenderAddress: isEvmChainId(sellAsset.chainId) ? quote.checks.allowance.spender : undefined, fallbackNetworkFeeCryptoBaseUnit: quote.fees.originGas.amount, deps, }, diff --git a/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeStepData.ts b/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeStepData.ts index f248752ef6d..50467956bdc 100644 --- a/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeStepData.ts +++ b/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeStepData.ts @@ -8,7 +8,7 @@ import { makeNetworkFeeEstimationFailedErr, makeTradeStepBuildFailedErr } from ' import { getEvmNetworkFeeCryptoBaseUnit } from '../../../utils/evm' import type { BRIDGE_TYPE } from '../types' import { BRIDGE_TYPE_TO_FALLBACK_GAS_LIMIT } from './constants' -import { buildArbitrumBridgeRequest } from './helpers' +import { buildArbitrumBridgeRequest, getArbitrumBridgeAllowanceContract } from './helpers' type BaseArgs = { bridgeType: BRIDGE_TYPE @@ -80,11 +80,18 @@ export async function getArbitrumBridgeStepData( } try { + const spenderAddress = await getArbitrumBridgeAllowanceContract({ bridgeType, sellAsset }) + const networkFeeCryptoBaseUnit = await getEvmNetworkFeeCryptoBaseUnit({ adapter, transactionData, from: args.from, supportsEIP1559, + stateOverride: { + sellAsset, + sellAmountCryptoBaseUnit, + spenderAddress: spenderAddress || undefined, + }, }) const stepData: ArbitrumBridgeQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } diff --git a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopStepData.ts b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopStepData.ts index b240e787da6..3f0aecc1a39 100644 --- a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopStepData.ts +++ b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopStepData.ts @@ -11,6 +11,7 @@ import type { BebopQuoteResponse } from '../types' type BaseArgs = { tx: BebopQuoteResponse['tx'] + approvalTarget: string } export type GetBebopStepDataArgs = StepDataArgs @@ -27,7 +28,7 @@ export function getBebopStepData( export async function getBebopStepData( args: GetBebopStepDataArgs, ): Promise> { - const { tx, sellAsset, type, input, from, deps } = args + const { tx, approvalTarget, sellAsset, type, input, from, deps } = args const adapter = deps.assertGetEvmChainAdapter(sellAsset.chainId) const supportsEIP1559 = 'supportsEIP1559' in input ? input.supportsEIP1559 : false @@ -65,6 +66,11 @@ export async function getBebopStepData( transactionData, from, supportsEIP1559, + stateOverride: { + sellAsset, + sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, + spenderAddress: approvalTarget || undefined, + }, }) const stepData: BebopQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } diff --git a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts index cbf3a8e063c..c4a61d4e60a 100644 --- a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts +++ b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts @@ -112,6 +112,7 @@ export const getBebopTradeContext = async ({ }, stepDataArgs: { tx: quote.tx, + approvalTarget: quote.approvalTarget, sellAsset, from, deps, diff --git a/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts b/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts index b361164ccde..2a58d24c6c7 100644 --- a/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts +++ b/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts @@ -177,6 +177,11 @@ export async function getBobGatewayStepData( from, supportsEIP1559: input.supportsEIP1559, gasLimitBuffer: 1.2, + stateOverride: { + sellAsset, + sellAmountCryptoBaseUnit, + spenderAddress: tx.to, + }, }) const stepData: BobGatewayQuoteStepData = { diff --git a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts index 3781dd718f5..bf083d18488 100644 --- a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts +++ b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts @@ -113,6 +113,11 @@ export async function getButterSwapStepData( // Butter's gasEstimatedTarget lands short on chain (observed 24% under actual, causing // in-flight OOG/SWAP_FAIL reverts) - estimate ourselves with a buffer gasLimitBuffer: 1.2, + stateOverride: { + sellAsset, + sellAmountCryptoBaseUnit, + spenderAddress: route.contract || undefined, + }, }) const stepData: ButterSwapQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } diff --git a/packages/swapper/src/swappers/ChainflipSwapper/utils/getChainflipStepData.ts b/packages/swapper/src/swappers/ChainflipSwapper/utils/getChainflipStepData.ts index 80b8ce85409..331c6d4b93c 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/utils/getChainflipStepData.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/utils/getChainflipStepData.ts @@ -100,11 +100,14 @@ export async function getChainflipStepData( } try { + // The deposit is a plain (token) transfer with no approval involved, but estimation + // still reverts for an unfunded sender - override the missing balance only const networkFeeCryptoBaseUnit = await getEvmNetworkFeeCryptoBaseUnit({ adapter, transactionData, from, supportsEIP1559, + stateOverride: { sellAsset, sellAmountCryptoBaseUnit }, }) const stepData: ChainflipQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } diff --git a/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts b/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts index 579968fe1ff..fee30e45090 100644 --- a/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts +++ b/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts @@ -43,6 +43,12 @@ export async function getDebridgeStepData( gasLimit, } + const stateOverride = { + sellAsset, + sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, + spenderAddress: tx.to, + } + if (type === 'rate') { const networkFeeCryptoBaseUnit = await (async () => { try { @@ -52,6 +58,7 @@ export async function getDebridgeStepData( from, supportsEIP1559, gasLimitBuffer: 1.2, + stateOverride, }) } catch { return fallbackNetworkFeeCryptoBaseUnit @@ -70,6 +77,7 @@ export async function getDebridgeStepData( from, supportsEIP1559, gasLimitBuffer: 1.2, + stateOverride, }) const stepData: DebridgeQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } diff --git a/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts b/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts index bcb1844517d..5090830f792 100644 --- a/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts +++ b/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts @@ -1,10 +1,8 @@ -import { CHAIN_NAMESPACE, fromAssetId, fromChainId, monadChainId } from '@shapeshiftoss/caip' +import { CHAIN_NAMESPACE, fromAssetId, fromChainId } from '@shapeshiftoss/caip' import { evm } from '@shapeshiftoss/chain-adapters' import { bn, contractAddressOrUndefined } from '@shapeshiftoss/utils' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' -import type { Hex } from 'viem' -import { getAddress } from 'viem' import type { StepDataArgs, SwapErrorRight, TxBuildData } from '../../../types' import { TradeQuoteError } from '../../../types' @@ -18,7 +16,6 @@ import { SOLANA_PLACEHOLDER_ADDRESS, withComputeUnitLimit, } from '../../../utils/solana' -import { RATE_SIM_TIMEOUT_MS, simulateWithStateOverrides } from '../../../utils/tenderly' import { getUtxoNetworkFeeCryptoBaseUnit } from '../../../utils/utxo' // Deposits are plain (token) transfers with constant measured compute consumption (max 15394 CU @@ -72,56 +69,33 @@ export async function getNearIntentsStepData( value: isNativeEvmAsset(sellAsset.assetId) ? sellAmountCryptoBaseUnit : '0', } - // Rates simulate the deposit transfer rather than estimate it, so an unapproved or - // unfunded sender still prices (Monad has no Tenderly support, so it estimates like quotes) - if (type === 'rate' && sellAsset.chainId !== monadChainId) { - const simulationResult = await simulateWithStateOverrides( - { - chainId: sellAsset.chainId, - from: getAddress(from || depositAddress), - to: getAddress(transactionData.to), - data: transactionData.data as Hex, - value: transactionData.value, - sellAsset, - timeoutMs: RATE_SIM_TIMEOUT_MS, - }, - { - apiKey: deps.config.VITE_TENDERLY_API_KEY, - accountSlug: deps.config.VITE_TENDERLY_ACCOUNT_SLUG, - projectSlug: deps.config.VITE_TENDERLY_PROJECT_SLUG, - }, - ) - - if (!simulationResult.success) { - const stepData: NearIntentsRateStepData = { networkFeeCryptoBaseUnit: '0' } - - return Ok(stepData) - } - - const networkFeeCryptoBaseUnit = await getEvmNetworkFeeCryptoBaseUnit({ + // The deposit is a plain (token) transfer with no approval involved - overridden + // estimation still prices an unfunded sender, so rates work walletless + const estimate = () => + getEvmNetworkFeeCryptoBaseUnit({ adapter, + transactionData, + from: from || depositAddress, supportsEIP1559, - gasLimit: simulationResult.gasLimit.toString(), + stateOverride: { sellAsset, sellAmountCryptoBaseUnit }, }) - const stepData: NearIntentsRateStepData = { networkFeeCryptoBaseUnit } - - return Ok(stepData) - } - - const networkFeeCryptoBaseUnit = await getEvmNetworkFeeCryptoBaseUnit({ - adapter, - transactionData, - from: from || depositAddress, - supportsEIP1559, - }) - if (type === 'rate') { + const networkFeeCryptoBaseUnit = await (async () => { + try { + return await estimate() + } catch { + return '0' + } + })() + const stepData: NearIntentsRateStepData = { networkFeeCryptoBaseUnit } return Ok(stepData) } + const networkFeeCryptoBaseUnit = await estimate() + const stepData: NearIntentsQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } return Ok(stepData) diff --git a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.ts b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.ts index da52ebf0052..69a3f59eec8 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.ts @@ -38,7 +38,7 @@ export const getPortalsTradeRate = async ( }) // Walletless rate: full /portal endpoint (validate off) with a realistic stand-in sender so Portals - // prices and Tenderly can simulate gas + // prices and estimation can simulate gas const maybeOrder = await fetchPortalsTradeOrder({ sender: PORTALS_RATE_DEFAULT_ADDRESS, inputToken, diff --git a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts index 8369c3a19cf..76ced58f217 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts @@ -1,13 +1,11 @@ import { fromChainId } from '@shapeshiftoss/caip' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' -import type { Hex } from 'viem' import { getAddress } from 'viem' import type { StepDataArgs, SwapErrorRight, TxBuildData } from '../../../types' import { makeNetworkFeeEstimationFailedErr } from '../../../utils' -import { getEvmNetworkFeeCryptoBaseUnit } from '../../../utils/evm' -import { RATE_SIM_TIMEOUT_MS, simulateWithStateOverrides } from '../../../utils/tenderly' +import { estimateGasWithStateOverride, getEvmNetworkFeeCryptoBaseUnit } from '../../../utils/evm' import type { PortalsTx } from './fetchPortalsTradeOrder' import { fetchPortalsTradeEstimate } from './fetchPortalsTradeOrder' @@ -45,38 +43,30 @@ export async function getPortalsStepData( if (args.type === 'rate') { try { - // No placeholder estimation for provider built routes - Tenderly sim (approval state-overridden) - // with the Portals estimate endpoint as fallback + // No placeholder estimation for provider built routes - overridden estimation (approval + // state need not exist yet) with the Portals estimate endpoint as fallback const gasLimit = await (async () => { - const tenderlySimulation = await simulateWithStateOverrides( - { - chainId: sellAsset.chainId, + try { + return await estimateGasWithStateOverride({ + sellAsset, + sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, from: tx.from, + spenderAddress: getAddress(args.target), to: tx.to, - data: tx.data as Hex, + data: tx.data, value: tx.value, - sellAsset, - spenderAddress: getAddress(args.target), - timeoutMs: RATE_SIM_TIMEOUT_MS, - }, - { - apiKey: deps.config.VITE_TENDERLY_API_KEY, - accountSlug: deps.config.VITE_TENDERLY_ACCOUNT_SLUG, - projectSlug: deps.config.VITE_TENDERLY_PROJECT_SLUG, - }, - ) - - if (tenderlySimulation.success) return tenderlySimulation.gasLimit.toString() - - const quoteEstimateResponse = await fetchPortalsTradeEstimate({ - inputToken: args.inputToken, - outputToken: args.outputToken, - inputAmount: args.inputAmount, - slippageTolerancePercentage: args.slippageTolerancePercentage, - swapperConfig: deps.config, - }) - - return quoteEstimateResponse.context.gasLimit.toString() + }) + } catch { + const quoteEstimateResponse = await fetchPortalsTradeEstimate({ + inputToken: args.inputToken, + outputToken: args.outputToken, + inputAmount: args.inputAmount, + slippageTolerancePercentage: args.slippageTolerancePercentage, + swapperConfig: deps.config, + }) + + return quoteEstimateResponse.context.gasLimit.toString() + } })() const networkFeeCryptoBaseUnit = await getEvmNetworkFeeCryptoBaseUnit({ diff --git a/packages/swapper/src/swappers/RelaySwapper/utils/getRelayStepData.ts b/packages/swapper/src/swappers/RelaySwapper/utils/getRelayStepData.ts index 4c9cb92464e..ba62bec262d 100644 --- a/packages/swapper/src/swappers/RelaySwapper/utils/getRelayStepData.ts +++ b/packages/swapper/src/swappers/RelaySwapper/utils/getRelayStepData.ts @@ -1,9 +1,7 @@ -import { fromChainId, monadChainId } from '@shapeshiftoss/caip' +import { fromChainId } from '@shapeshiftoss/caip' import { bnOrZero, contractAddressOrUndefined, isToken } from '@shapeshiftoss/utils' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' -import type { Hex } from 'viem' -import { getAddress } from 'viem' import type { StepDataArgs, SwapErrorRight, TxBuildData } from '../../../types' import { makeNetworkFeeEstimationFailedErr, makeTradeStepBuildFailedErr } from '../../../utils' @@ -14,7 +12,6 @@ import { omitComputeBudgetInstructions, withComputeUnitLimit, } from '../../../utils/solana' -import { RATE_SIM_TIMEOUT_MS, simulateWithStateOverrides } from '../../../utils/tenderly' import { getUtxoNetworkFeeCryptoBaseUnit } from '../../../utils/utxo' import { getRelayPsbtRelayer } from './getRelayPsbtRelayer' import { convertRelaySolanaInstruction } from './helpers' @@ -91,37 +88,21 @@ export async function getRelayStepData({ const adapter = deps.assertGetEvmChainAdapter(sellAsset.chainId) + const stateOverride = { + sellAsset, + sellAmountCryptoBaseUnit, + spenderAddress: to, + } + if (type === 'rate') { const networkFeeCryptoBaseUnit = await (async () => { try { - const simulation = await simulateWithStateOverrides( - { - chainId: sellAsset.chainId, - from: getAddress(from), - to: getAddress(transactionData.to), - data: transactionData.data as Hex, - value: transactionData.value, - sellAsset, - // Pass Relay's gas limit to Tenderly for Monad as tenderly return crazy gas - gas: - transactionData.gasLimit && sellAsset.chainId === monadChainId - ? Number(transactionData.gasLimit) - : undefined, - timeoutMs: RATE_SIM_TIMEOUT_MS, - }, - { - apiKey: deps.config.VITE_TENDERLY_API_KEY, - accountSlug: deps.config.VITE_TENDERLY_ACCOUNT_SLUG, - projectSlug: deps.config.VITE_TENDERLY_PROJECT_SLUG, - }, - ) - - if (!simulation.success) return fallbackNetworkFeeCryptoBaseUnit - return await getEvmNetworkFeeCryptoBaseUnit({ adapter, + transactionData, + from, supportsEIP1559, - gasLimit: simulation.gasLimit.toString(), + stateOverride, }) } catch { return fallbackNetworkFeeCryptoBaseUnit @@ -140,6 +121,7 @@ export async function getRelayStepData({ transactionData, from, supportsEIP1559, + stateOverride, }) const stepData: RelayQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index 631c4ad61b6..17e0a5a08b7 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -87,9 +87,6 @@ export type SwapperConfig = { VITE_RELAY_API_URL: string VITE_BEBOP_API_KEY: string VITE_NEAR_INTENTS_API_KEY: string - VITE_TENDERLY_API_KEY: string - VITE_TENDERLY_ACCOUNT_SLUG: string - VITE_TENDERLY_PROJECT_SLUG: string VITE_SUI_NODE_URL: string VITE_ACROSS_API_URL: string VITE_ACROSS_INTEGRATOR_ID: string diff --git a/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts b/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts index 32c7ab85e41..21e0bc00fb4 100644 --- a/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts +++ b/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts @@ -1,8 +1,10 @@ import type { EvmChainAdapter } from '@shapeshiftoss/chain-adapters' import { evm } from '@shapeshiftoss/chain-adapters' +import type { Asset } from '@shapeshiftoss/types' import { bnOrZero } from '@shapeshiftoss/utils' import type { TxBuildData } from '../../types' +import { estimateGasWithStateOverride, getMinimalStateOverride, withTimeout } from './stateOverride' type GetEvmNetworkFeeCryptoBaseUnitArgs = { adapter: EvmChainAdapter @@ -14,6 +16,14 @@ type GetEvmNetworkFeeCryptoBaseUnitArgs = { transactionData: Extract from: string gasLimitBuffer?: number + // Pre-approval token sells (and unfunded rate addresses) revert plain estimation - when + // supplied, insufficient allowance/balance is state-overridden at estimation time so the + // trade still carries a real gas limit; sufficient state estimates plainly as before + stateOverride?: { + sellAsset: Asset + sellAmountCryptoBaseUnit: string + spenderAddress?: string + } } ) @@ -39,9 +49,36 @@ export const getEvmNetworkFeeCryptoBaseUnit = async ( return priceProviderGasLimit(providerGasLimit) } - // Executable quote with no provider gas limit: estimate on chain, then set the buffered limit on - // the tx data in place so the executable tx always carries a gas limit const { to, data, value } = transactionData + + if (args.stateOverride) { + const { sellAsset, sellAmountCryptoBaseUnit, spenderAddress } = args.stateOverride + + const overriddenGasLimit = await withTimeout( + (async () => { + const overrideArgs = { + sellAsset, + sellAmountCryptoBaseUnit, + from, + spenderAddress: spenderAddress ?? to, + value, + } + + const stateOverride = await getMinimalStateOverride(overrideArgs) + if (!stateOverride) return undefined + + return estimateGasWithStateOverride({ ...overrideArgs, to, data, stateOverride }) + })(), + ) + + if (overriddenGasLimit) { + transactionData.gasLimit = bnOrZero(overriddenGasLimit).times(gasLimitBuffer).toFixed(0) + return priceProviderGasLimit(overriddenGasLimit) + } + } + + // Estimate on chain, then set the buffered limit on the tx data in place so the executable tx + // always carries a gas limit const estimatedFees = await evm.getFees({ adapter, data, to, value, from, supportsEIP1559 }) transactionData.gasLimit = bnOrZero(estimatedFees.gasLimit).times(gasLimitBuffer).toFixed(0) diff --git a/packages/swapper/src/utils/evm/index.ts b/packages/swapper/src/utils/evm/index.ts index 262e3dfb161..ce126f8fe12 100644 --- a/packages/swapper/src/utils/evm/index.ts +++ b/packages/swapper/src/utils/evm/index.ts @@ -2,3 +2,5 @@ export * from './getEvmExecutionContext' export * from './getEvmNetworkFeeCryptoBaseUnit' export * from './getEvmTransactionFees' export * from './getUnsignedEvmTransaction' +export * from './stateOverride' +export * from './storageSlots' diff --git a/packages/swapper/src/utils/evm/stateOverride.ts b/packages/swapper/src/utils/evm/stateOverride.ts new file mode 100644 index 00000000000..74dfec8920e --- /dev/null +++ b/packages/swapper/src/utils/evm/stateOverride.ts @@ -0,0 +1,288 @@ +import { assertGetViemClient } from '@shapeshiftoss/contracts' +import type { Asset } from '@shapeshiftoss/types' +import { assertUnreachable, contractAddressOrUndefined } from '@shapeshiftoss/utils' +import type { Address, Hex, PublicClient, StateOverride } from 'viem' +import { erc20Abi, getAddress, hexToBigInt, maxUint256, pad, parseEther, toHex } from 'viem' + +import { isNativeEvmAsset } from '../helpers' +import { + getAllowanceStorageSlot, + getBalanceStorageSlot, + getMaxBalanceValue, + getTokenAllowanceSlot, + getTokenBalanceSlot, +} from './storageSlots' + +// Fail fast - rates run inside the UI's 10s bulk budget and anything slower is degraded service +// where retrying beats waiting (discovered slots are cached, so retries skip discovery) +const ESTIMATION_TIMEOUT_MS = 3_000 + +// Large native balance for gas (1B is overkill but guarantees success on all chains) +const NATIVE_BALANCE_OVERRIDE = parseEther('1000000000') + +// Slot numbers seen across known token implementations +const CANDIDATE_BALANCE_SLOTS = [0, 1, 2, 3, 5, 9, 51] +const CANDIDATE_ALLOWANCE_SLOTS = [1, 2, 4, 5, 6, 10, 52] + +const discoveredSlots = new Map() + +export const withTimeout = async (promise: Promise): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('State override estimation timed out')), + ESTIMATION_TIMEOUT_MS, + ) + }), + ]) + } finally { + clearTimeout(timer) + } +} + +type SlotProbeBaseArgs = { + client: PublicClient + tokenAddress: Address + owner: Address +} + +type SlotKindArgs = { kind: 'balance' } | { kind: 'allowance'; spender: Address } + +type ProbeSlotArgs = SlotProbeBaseArgs & SlotKindArgs & { slotNumber: number } + +// A slot guess only counts if a read through the override reflects the written sentinel +const probeSlot = async (args: ProbeSlotArgs): Promise => { + const { client, tokenAddress, owner, slotNumber } = args + + const { storageSlot, sentinel } = (() => { + switch (args.kind) { + case 'allowance': { + const storageSlot = getAllowanceStorageSlot(owner, args.spender, slotNumber) + const sentinel = toHex(maxUint256) + return { storageSlot, sentinel } + } + case 'balance': { + const storageSlot = getBalanceStorageSlot(owner, slotNumber) + const sentinel = getMaxBalanceValue(slotNumber) + return { storageSlot, sentinel } + } + default: + return assertUnreachable(args) + } + })() + + const stateOverride = [ + { address: tokenAddress, stateDiff: [{ slot: storageSlot, value: pad(sentinel) }] }, + ] + + try { + const result = await (() => { + switch (args.kind) { + case 'allowance': { + return client.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'allowance', + args: [owner, args.spender], + stateOverride, + }) + } + case 'balance': { + return client.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'balanceOf', + args: [owner], + stateOverride, + }) + } + default: + return assertUnreachable(args) + } + })() + + return result === hexToBigInt(sentinel) + } catch { + return false + } +} + +// Find the token's balance/allowance mapping slot: try the known-table/pattern guess first, then +// probe the remaining candidates. Discovered slots are cached per chain+token. +const discoverSlot = async ( + args: SlotProbeBaseArgs & SlotKindArgs & { chainId: string }, +): Promise => { + const { chainId, tokenAddress, kind } = args + + const cacheKey = `${chainId}:${tokenAddress.toLowerCase()}:${kind}` + + const cached = discoveredSlots.get(cacheKey) + if (cached !== undefined) return cached + + const guess = + kind === 'balance' ? getTokenBalanceSlot(tokenAddress) : getTokenAllowanceSlot(tokenAddress) + + if (await probeSlot({ ...args, slotNumber: guess })) { + discoveredSlots.set(cacheKey, guess) + return guess + } + + const candidates = ( + kind === 'balance' ? CANDIDATE_BALANCE_SLOTS : CANDIDATE_ALLOWANCE_SLOTS + ).filter(slotNumber => slotNumber !== guess) + + const results = await Promise.all( + candidates.map(slotNumber => probeSlot({ ...args, slotNumber })), + ) + + const found = candidates.find((_, i) => results[i]) + + if (found === undefined) { + throw new Error(`Unable to locate ${kind} storage slot for token ${tokenAddress}`) + } + + discoveredSlots.set(cacheKey, found) + + return found +} + +export type GetMinimalStateOverrideArgs = { + sellAsset: Asset + sellAmountCryptoBaseUnit: string + from: string + // The contract pulling the sell token - omit for trades with no approval involved + spenderAddress?: string + value: string +} + +// Read the seller's actual balance/allowance and override only what's insufficient for the trade, +// so estimation runs against state as close to execution time as possible +export const getMinimalStateOverride = async ({ + sellAsset, + sellAmountCryptoBaseUnit, + from: _from, + spenderAddress, + value, +}: GetMinimalStateOverrideArgs): Promise => { + const client = assertGetViemClient(sellAsset.chainId) + const from = getAddress(_from) + const valueBigInt = BigInt(value || '0') + const sellAmount = BigInt(sellAmountCryptoBaseUnit) + const contractAddress = isNativeEvmAsset(sellAsset.assetId) + ? undefined + : contractAddressOrUndefined(sellAsset.assetId) + + const stateOverride: StateOverride = [] + + const needsNativeBalance = async () => { + if (valueBigInt <= 0n) return false + const balance = await client.getBalance({ address: from }) + return balance < valueBigInt + } + + if (!contractAddress) { + if (await needsNativeBalance()) { + stateOverride.push({ address: from, balance: NATIVE_BALANCE_OVERRIDE }) + } + return stateOverride.length ? stateOverride : undefined + } + + const tokenAddress = getAddress(contractAddress) + const spender = spenderAddress ? getAddress(spenderAddress) : undefined + + const [allowance, balance, nativeBalanceShort] = await Promise.all([ + spender + ? client.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'allowance', + args: [from, spender], + }) + : Promise.resolve(maxUint256), + client.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'balanceOf', + args: [from], + }), + needsNativeBalance(), + ]) + + if (nativeBalanceShort) { + stateOverride.push({ address: from, balance: NATIVE_BALANCE_OVERRIDE }) + } + + const stateDiff: { slot: Hex; value: Hex }[] = [] + + if (balance < sellAmount) { + const slotNumber = await discoverSlot({ + client, + chainId: sellAsset.chainId, + tokenAddress, + kind: 'balance', + owner: from, + }) + + stateDiff.push({ + slot: getBalanceStorageSlot(from, slotNumber), + value: pad(getMaxBalanceValue(slotNumber)), + }) + } + + if (spender && allowance < sellAmount) { + const slotNumber = await discoverSlot({ + client, + chainId: sellAsset.chainId, + tokenAddress, + kind: 'allowance', + owner: from, + spender, + }) + + stateDiff.push({ + slot: getAllowanceStorageSlot(from, spender, slotNumber), + value: pad(toHex(maxUint256)), + }) + } + + if (stateDiff.length) stateOverride.push({ address: tokenAddress, stateDiff }) + + return stateOverride.length ? stateOverride : undefined +} + +export type EstimateGasWithStateOverrideArgs = GetMinimalStateOverrideArgs & { + to: string + data: string + // Skips the minimal-override reads when the caller already built one + stateOverride?: StateOverride +} + +// Estimate gas for a transaction the seller can't yet execute (unapproved/unfunded), overriding +// only the missing state. Estimation runs on our own RPCs - the node applies the same engine over +// the patched pre-state, so the only systematic delta is the unrealized allowance-decrement refund +// (a few k gas high, conservative). +export const estimateGasWithStateOverride = ({ + to, + data, + stateOverride: prebuiltStateOverride, + ...overrideArgs +}: EstimateGasWithStateOverrideArgs): Promise => + withTimeout( + (async () => { + const client = assertGetViemClient(overrideArgs.sellAsset.chainId) + const stateOverride = prebuiltStateOverride ?? (await getMinimalStateOverride(overrideArgs)) + + const gasLimit = await client.estimateGas({ + account: getAddress(overrideArgs.from), + to: getAddress(to), + data: data as Hex, + value: BigInt(overrideArgs.value || '0'), + ...(stateOverride ? { stateOverride } : {}), + }) + + return gasLimit.toString() + })(), + ) diff --git a/packages/swapper/src/utils/tenderly/storageSlots.ts b/packages/swapper/src/utils/evm/storageSlots.ts similarity index 96% rename from packages/swapper/src/utils/tenderly/storageSlots.ts rename to packages/swapper/src/utils/evm/storageSlots.ts index 77c5e579f8b..a51277a32e1 100644 --- a/packages/swapper/src/utils/tenderly/storageSlots.ts +++ b/packages/swapper/src/utils/evm/storageSlots.ts @@ -100,7 +100,5 @@ export const getTokenAllowanceSlot = (tokenAddress: Address): number => { } // USDC slot 9: bit 255 is blacklist flag, must keep cleared -export const getMaxBalanceValue = (tokenAddress: Address): Hex => { - const slot = getTokenBalanceSlot(tokenAddress) - return slot === 9 ? toHex(maxUint256 >> 1n) : toHex(maxUint256) -} +export const getMaxBalanceValue = (balanceSlotNumber: number): Hex => + balanceSlotNumber === 9 ? toHex(maxUint256 >> 1n) : toHex(maxUint256) diff --git a/packages/swapper/src/utils/tenderly/index.ts b/packages/swapper/src/utils/tenderly/index.ts deleted file mode 100644 index 1460b8997b7..00000000000 --- a/packages/swapper/src/utils/tenderly/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './simulate' -export * from './storageSlots' -export * from './types' diff --git a/packages/swapper/src/utils/tenderly/simulate.ts b/packages/swapper/src/utils/tenderly/simulate.ts deleted file mode 100644 index 2c28c3d47fa..00000000000 --- a/packages/swapper/src/utils/tenderly/simulate.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { ChainId } from '@shapeshiftoss/caip' -import { fromChainId } from '@shapeshiftoss/caip' -import type { Asset } from '@shapeshiftoss/types' -import { contractAddressOrUndefined } from '@shapeshiftoss/utils' -import axios from 'axios' -import type { Address, Hex } from 'viem' -import { isAddress, maxUint256, parseEther, toHex } from 'viem' - -import { isNativeEvmAsset } from '../helpers' -import { - getAllowanceStorageSlot, - getBalanceStorageSlot, - getMaxBalanceValue, - getTokenAllowanceSlot, - getTokenBalanceSlot, -} from './storageSlots' -import type { - TenderlyConfig, - TenderlyErrorResponse, - TenderlyInternalTransaction, - TenderlySimulationRequest, - TenderlySimulationResponse, - TenderlyStateOverrides, - TenderlyTransactionResponse, -} from './types' - -export type SimulationResult = { - success: boolean - gasUsed: bigint - gasLimit: bigint - errorMessage?: string -} - -// Rate sims run inside the UI's 10s bulk rate budget - fail fast so the gas fallbacks still fit -export const RATE_SIM_TIMEOUT_MS = 3_000 - -export type SimulateTransactionParams = { - chainId: ChainId - from: Address - to: Address - data: Hex - value?: string | bigint - sellAsset: Asset - spenderAddress?: Address - gas?: number - timeoutMs?: number -} - -export const simulateWithStateOverrides = async ( - params: SimulateTransactionParams, - config: TenderlyConfig, -): Promise => { - const { - chainId, - from, - to, - data, - value, - sellAsset, - spenderAddress, - gas, - timeoutMs = 10_000, - } = params - - try { - if (!isAddress(from)) throw new Error(`Invalid from address: ${from}`) - if (!isAddress(to)) throw new Error(`Invalid to address: ${to}`) - - const evmNetworkId = Number(fromChainId(chainId).chainReference) - const spender = spenderAddress ?? to - - const stateOverrides = buildStateOverrides({ - from, - spender, - sellAsset, - }) - - const request: TenderlySimulationRequest = { - network_id: evmNetworkId.toString(), - from: from.toLowerCase() as Address, - to: to.toLowerCase() as Address, - input: data, - gas, - value: value ? toHex(BigInt(value)) : '0x0', - save: false, - state_objects: stateOverrides, - } - - const url = `https://api.tenderly.co/api/v1/account/${config.accountSlug}/project/${config.projectSlug}/simulate` - - const response = await axios.post( - url, - request, - { - headers: { - 'Content-Type': 'application/json', - 'X-Access-Key': config.apiKey, - }, - timeout: timeoutMs, - }, - ) - - if ('error' in response.data) { - const error = response.data.error - return { - success: false, - gasUsed: 0n, - gasLimit: 0n, - errorMessage: `Tenderly API error: ${error.message}`, - } - } - - const { transaction } = response.data - const gasUsed = BigInt(transaction.gas_used) - - return { - success: transaction.status, - gasUsed, - gasLimit: gasUsed, - errorMessage: transaction.error_message, - } - } catch (error) { - return { - success: false, - gasUsed: 0n, - gasLimit: 0n, - errorMessage: error instanceof Error ? error.message : 'Unknown simulation error', - } - } -} - -export const fetchInternalTransactions = async ( - params: { - chainId: ChainId - txHash: string - }, - config: TenderlyConfig, -): Promise => { - const { chainId, txHash } = params - - try { - const evmNetworkId = Number(fromChainId(chainId).chainReference) - - const url = `https://api.tenderly.co/api/v1/account/${config.accountSlug}/project/${config.projectSlug}/transactions/by-hash/${evmNetworkId}/${txHash}` - - const response = await axios.get(url, { - headers: { - 'Content-Type': 'application/json', - 'X-Access-Key': config.apiKey, - }, - timeout: 10000, - }) - - return response.data.transaction.internal_transactions ?? [] - } catch (error) { - return [] - } -} - -const buildStateOverrides = (params: { - from: Address - spender: Address - sellAsset: Asset -}): TenderlyStateOverrides => { - const { from, spender, sellAsset } = params - // Large native balance for gas (1B is overkill but guarantees success on all chains) - const nativeBalanceOverride = toHex(parseEther('1000000000')) - const isNative = isNativeEvmAsset(sellAsset.assetId) - - return isNative - ? { - [from.toLowerCase() as Address]: { - balance: nativeBalanceOverride, - }, - } - : (() => { - const contractAddress = contractAddressOrUndefined(sellAsset.assetId) - - if (!contractAddress) { - return { - [from.toLowerCase() as Address]: { - balance: nativeBalanceOverride, - }, - } - } - - const balanceSlot = getTokenBalanceSlot(contractAddress as Address) - const balanceStorageSlot = getBalanceStorageSlot(from, balanceSlot) - const maxBalance = getMaxBalanceValue(contractAddress as Address) - - const allowanceSlot = getTokenAllowanceSlot(contractAddress as Address) - const allowanceStorageSlot = getAllowanceStorageSlot(from, spender, allowanceSlot) - - return { - [from.toLowerCase() as Address]: { - balance: nativeBalanceOverride, - }, - [contractAddress.toLowerCase() as Address]: { - storage: { - [balanceStorageSlot]: maxBalance, - [allowanceStorageSlot]: toHex(maxUint256), - }, - }, - } - })() -} diff --git a/packages/swapper/src/utils/tenderly/types.ts b/packages/swapper/src/utils/tenderly/types.ts deleted file mode 100644 index bbf3123806f..00000000000 --- a/packages/swapper/src/utils/tenderly/types.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Address, Hex } from 'viem' - -export type TenderlyStateOverrides = { - [address: Address]: { - balance?: Hex - storage?: { - [slot: Hex]: Hex - } - } -} - -export type TenderlySimulationRequest = { - network_id: string - from: Address - to: Address - input: Hex - value?: Hex - gas?: number - gas_price?: string - max_fee_per_gas?: string - max_priority_fee_per_gas?: string - save?: boolean - state_objects?: TenderlyStateOverrides -} - -export type TenderlySimulationResponse = { - transaction: { - hash: string - status: boolean - error_message?: string - gas_used: number - gas: number - gas_price: string - value: string - call_trace?: unknown[] - asset_changes?: unknown[] - } - simulation: { - id: string - network_id: string - block_number: number - block_hash: string - timestamp: string - gas_used: number - status: boolean - } -} - -export type TenderlyErrorResponse = { - error: { - id: string - slug: string - message: string - } -} - -export type TenderlyConfig = { - apiKey: string - accountSlug: string - projectSlug: string -} - -export type TenderlyInternalTransaction = { - from: Address - to: Address - value: string -} - -export type TenderlyTransactionResponse = { - transaction: { - hash: string - block_number: number - from: Address - to: Address - input: string - value: string - gas: number - gas_used: number - gas_price: string - status: boolean - error_message?: string - internal_transactions?: TenderlyInternalTransaction[] - } -} diff --git a/packages/swapper/src/utils/test-data/cryptoMarketDataById.ts b/packages/swapper/src/utils/test-data/cryptoMarketDataById.ts index a065efeee70..5809188acc9 100644 --- a/packages/swapper/src/utils/test-data/cryptoMarketDataById.ts +++ b/packages/swapper/src/utils/test-data/cryptoMarketDataById.ts @@ -1,3 +1,5 @@ +import type { AssetId } from '@shapeshiftoss/caip' + import { AVAX, BSC, @@ -13,7 +15,7 @@ import { WETH, } from './assets' -export const marketDataByAssetIdUsd = { +export const marketDataByAssetIdUsd: Record = { [FOX_MAINNET.assetId]: { price: '0.04' }, [FOX_GNOSIS.assetId]: { price: '0.04' }, [ETH.assetId]: { price: '1300' }, diff --git a/packages/swapper/src/utils/thorchain/getThorStepData.ts b/packages/swapper/src/utils/thorchain/getThorStepData.ts index d81ae5adc19..02d38c2499f 100644 --- a/packages/swapper/src/utils/thorchain/getThorStepData.ts +++ b/packages/swapper/src/utils/thorchain/getThorStepData.ts @@ -147,14 +147,20 @@ export async function getThorStepData({ const networkFeeCryptoBaseUnit = await (async () => { if (input.sendAddress) { try { + const rateTransactionData = buildTransactionData(rawMemo ?? '') return await getEvmNetworkFeeCryptoBaseUnit({ adapter, - transactionData: buildTransactionData(rawMemo ?? ''), + transactionData: rateTransactionData, from: input.sendAddress, supportsEIP1559, + stateOverride: { + sellAsset, + sellAmountCryptoBaseUnit, + spenderAddress: rateTransactionData.to, + }, }) } catch { - // Token deposit estimations revert before approval - use safe gas limit + // Estimation failed - use safe gas limit } } @@ -178,6 +184,11 @@ export async function getThorStepData({ transactionData, from, supportsEIP1559, + stateOverride: { + sellAsset, + sellAmountCryptoBaseUnit, + spenderAddress: transactionData.to, + }, }) const stepData: ThorQuoteStepData = { From 291955450f590a370b6be36967978d5dbc57d250 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:09:07 -0600 Subject: [PATCH 02/11] feat(swapper): portals pre-approval quotes via validate-false fallback Portals validates orders by simulating with the sender's live state, so an unapproved sender failed the quote outright. On validation failure the order is refetched without validation - the response carries identical calldata and the same minOutputAmount but no gas limit, which the state-override estimation supplies (buffered). Validated orders are unchanged: sim-refined output amount, expected-slippage errors, and Portals' padded gas limit. Co-Authored-By: Claude Fable 5 --- .../getPortalsTradeQuote.ts | 31 ++++++++++++++----- .../utils/fetchPortalsTradeOrder.ts | 3 +- .../utils/getPortalsStepData.ts | 8 ++++- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts index 65d552253ca..b4278703e39 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts @@ -105,13 +105,30 @@ export const getPortalsTradeQuote = async ( }), ) } - return Err( - makeSwapErrorRight({ - message: 'failed to get Portals quote', - cause: err, - code: TradeQuoteError.NetworkFeeEstimationFailed, - }), - ) + + // Validation simulates with the sender's live state, so an unapproved sender fails the + // order outright - refetch without validation and estimate under state override instead + return fetchPortalsTradeOrder({ + sender: sendAddress, + inputToken, + outputToken, + inputAmount: sellAmountIncludingProtocolFeesCryptoBaseUnit, + slippageTolerancePercentage, + partner: getTreasuryAddressFromChainId(sellChainId), + feePercentage: affiliateBpsPercentage, + validate: false, + swapperConfig: deps.config, + }) + .then(res => Ok(res)) + .catch(() => + Err( + makeSwapErrorRight({ + message: 'failed to get Portals quote', + cause: err, + code: TradeQuoteError.NetworkFeeEstimationFailed, + }), + ), + ) }) if (maybePortalsTradeOrderResponse.isErr()) return Err(maybePortalsTradeOrderResponse.unwrapErr()) diff --git a/packages/swapper/src/swappers/PortalsSwapper/utils/fetchPortalsTradeOrder.ts b/packages/swapper/src/swappers/PortalsSwapper/utils/fetchPortalsTradeOrder.ts index 8245edd630f..20f714bd3f7 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/utils/fetchPortalsTradeOrder.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/utils/fetchPortalsTradeOrder.ts @@ -37,7 +37,8 @@ export type PortalsTx = { from: Address data: string value: string - gasLimit: string + // Only present when the order was validated - validate:false skips the simulation that derives it + gasLimit?: string } export type PortalsTradeOrderResponse = { diff --git a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts index 76ced58f217..425da05cab8 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts @@ -89,7 +89,8 @@ export async function getPortalsStepData( to: tx.to, data: tx.data, value: tx.value, - // Portals simulate and pad their gas limit, so no additional buffer + // Portals simulate and pad their gas limit when the order was validated, so no additional + // buffer - an unvalidated order carries no gas limit and estimates below instead gasLimit: tx.gasLimit, } @@ -99,6 +100,11 @@ export async function getPortalsStepData( transactionData, from: args.from, supportsEIP1559, + gasLimitBuffer: 1.2, + stateOverride: { + sellAsset, + sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, + }, }) const stepData: PortalsQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } From e6948285110993173be0f46e5bff7107cf3d53ed Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:56:34 -0600 Subject: [PATCH 03/11] refactor(swapper): single-source spenderAddress from trade contexts The estimation spender was sourced three different ways (context-threaded, re-derived, inline tx.to) plus an implicit tx.to default in the seat that caused needless allowance reads for no-approval swappers. Every context now threads spenderAddress (one name, replacing bebop's approvalTarget) from its allowanceContract derivation, and the seat does nothing implicit. Also fixes the thor longtail spender: swapIn pulls tokens through the aggregator token transfer proxy, not the aggregator itself, so the override patched the wrong allowance slot and longtail pre-approval estimation still reverted. Portals' rate arm had the same latent mismatch (target vs the context's cross-chain-aware derivation). Co-Authored-By: Claude Fable 5 --- .../AcrossSwapper/utils/getAcrossStepData.ts | 14 +++++++++++--- .../utils/getAcrossTradeContext.ts | 6 ++++-- .../utils/getArbitrumBridgeStepData.ts | 18 ++++++++++-------- .../utils/getArbitrumBridgeTradeContext.ts | 1 + .../BebopSwapper/utils/getBebopStepData.ts | 6 +++--- .../BebopSwapper/utils/getBebopTradeContext.ts | 6 ++++-- .../utils/getBobGatewayStepData.ts | 5 +++-- .../utils/getBobGatewayTradeContext.ts | 2 ++ .../swapperApi/getTradeQuote.test.ts | 7 +++++++ .../utils/getButterSwapStepData.test.ts | 18 ++++++++++++++++++ .../ButterSwap/utils/getButterSwapStepData.ts | 6 ++++-- .../utils/getButterSwapTradeContext.ts | 1 + .../utils/getDebridgeStepData.ts | 16 +++++++++++++--- .../utils/getDebridgeTradeContext.ts | 5 ++++- .../getPortalsTradeRate/getPortalsTradeRate.ts | 1 - .../PortalsSwapper/utils/getPortalsStepData.ts | 12 +++++++----- .../utils/getPortalsTradeContext.ts | 4 ++-- .../getTradeQuote/getTradeQuote.ts | 8 +++++++- .../RelaySwapper/getTradeRate/getTradeRate.ts | 8 +++++++- .../RelaySwapper/utils/getRelayStepData.ts | 4 +++- .../RelaySwapper/utils/getRelayTradeContext.ts | 3 ++- .../evm/getEvmNetworkFeeCryptoBaseUnit.ts | 7 +++---- .../src/utils/thorchain/getThorStepData.ts | 17 ++++++++++++----- 23 files changed, 128 insertions(+), 47 deletions(-) diff --git a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossStepData.ts b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossStepData.ts index 3875fff6b06..59a5bf2b488 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossStepData.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossStepData.ts @@ -22,7 +22,7 @@ import type { AcrossSwapTx } from './types' type BaseArgs = { swapTx: AcrossSwapTx - spenderAddress: string | undefined + spenderAddress: string fallbackNetworkFeeCryptoBaseUnit: string } @@ -40,8 +40,16 @@ export function getAcrossStepData( export async function getAcrossStepData( args: GetAcrossStepDataArgs, ): Promise> { - const { swapTx, sellAsset, spenderAddress, from, type, input, fallbackNetworkFeeCryptoBaseUnit, deps } = - args + const { + swapTx, + sellAsset, + spenderAddress, + from, + type, + input, + fallbackNetworkFeeCryptoBaseUnit, + deps, + } = args const supportsEIP1559 = 'supportsEIP1559' in input ? input.supportsEIP1559 : false diff --git a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts index d9b80d24760..6610651f36b 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts @@ -191,6 +191,8 @@ export const getAcrossTradeContext = async ({ return `${bridgeFeeAssetCaipChainId}/${tokenStandard}:${bridgeFeeAsset.address}` })() + const allowanceContract = isEvmChainId(sellAsset.chainId) ? quote.checks.allowance.spender : '' + const protocolFees: QuoteFeeData['protocolFees'] = bridgeFeeAssetId ? { [bridgeFeeAssetId]: { @@ -214,7 +216,7 @@ export const getAcrossTradeContext = async ({ slippageTolerancePercentageDecimal: input.slippageTolerancePercentageDecimal, }, stepCommon: { - allowanceContract: isEvmChainId(sellAsset.chainId) ? quote.checks.allowance.spender : '', + allowanceContract, rate, buyAmountBeforeFeesCryptoBaseUnit, buyAmountAfterFeesCryptoBaseUnit, @@ -240,7 +242,7 @@ export const getAcrossTradeContext = async ({ swapTx: quote.swapTx, sellAsset, from: depositor, - spenderAddress: isEvmChainId(sellAsset.chainId) ? quote.checks.allowance.spender : undefined, + spenderAddress: allowanceContract, fallbackNetworkFeeCryptoBaseUnit: quote.fees.originGas.amount, deps, }, diff --git a/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeStepData.ts b/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeStepData.ts index 50467956bdc..5d32b912ebc 100644 --- a/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeStepData.ts +++ b/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeStepData.ts @@ -8,12 +8,13 @@ import { makeNetworkFeeEstimationFailedErr, makeTradeStepBuildFailedErr } from ' import { getEvmNetworkFeeCryptoBaseUnit } from '../../../utils/evm' import type { BRIDGE_TYPE } from '../types' import { BRIDGE_TYPE_TO_FALLBACK_GAS_LIMIT } from './constants' -import { buildArbitrumBridgeRequest, getArbitrumBridgeAllowanceContract } from './helpers' +import { buildArbitrumBridgeRequest } from './helpers' type BaseArgs = { bridgeType: BRIDGE_TYPE sellAmountCryptoBaseUnit: string buyAsset: Asset + spenderAddress: string } export type GetArbitrumBridgeStepDataArgs = StepDataArgs< @@ -40,7 +41,8 @@ export function getArbitrumBridgeStepData( export async function getArbitrumBridgeStepData( args: GetArbitrumBridgeStepDataArgs, ): Promise> { - const { input, deps, bridgeType, sellAmountCryptoBaseUnit, sellAsset, buyAsset } = args + const { input, deps, bridgeType, sellAmountCryptoBaseUnit, sellAsset, buyAsset, spenderAddress } = + args const adapter = deps.assertGetEvmChainAdapter(sellAsset.chainId) const supportsEIP1559 = 'supportsEIP1559' in input ? input.supportsEIP1559 : false @@ -58,11 +60,13 @@ export async function getArbitrumBridgeStepData( return Ok(stepData) } + const { from, receiveAddress } = args + const request = await buildArbitrumBridgeRequest({ bridgeType, sellAmountCryptoBaseUnit, - from: args.from, - receiveAddress: args.receiveAddress, + from, + receiveAddress, sellAsset, buyAsset, }) @@ -80,17 +84,15 @@ export async function getArbitrumBridgeStepData( } try { - const spenderAddress = await getArbitrumBridgeAllowanceContract({ bridgeType, sellAsset }) - const networkFeeCryptoBaseUnit = await getEvmNetworkFeeCryptoBaseUnit({ adapter, transactionData, - from: args.from, + from, supportsEIP1559, stateOverride: { sellAsset, sellAmountCryptoBaseUnit, - spenderAddress: spenderAddress || undefined, + spenderAddress, }, }) diff --git a/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeTradeContext.ts b/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeTradeContext.ts index 827a356cef3..6c592222a88 100644 --- a/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeTradeContext.ts +++ b/packages/swapper/src/swappers/ArbitrumBridgeSwapper/utils/getArbitrumBridgeTradeContext.ts @@ -75,6 +75,7 @@ export const getArbitrumBridgeTradeContext = async ({ sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, buyAsset, sellAsset, + spenderAddress: allowanceContract, deps, }, }) diff --git a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopStepData.ts b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopStepData.ts index 3f0aecc1a39..4a59a004620 100644 --- a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopStepData.ts +++ b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopStepData.ts @@ -11,7 +11,7 @@ import type { BebopQuoteResponse } from '../types' type BaseArgs = { tx: BebopQuoteResponse['tx'] - approvalTarget: string + spenderAddress: string } export type GetBebopStepDataArgs = StepDataArgs @@ -28,7 +28,7 @@ export function getBebopStepData( export async function getBebopStepData( args: GetBebopStepDataArgs, ): Promise> { - const { tx, approvalTarget, sellAsset, type, input, from, deps } = args + const { tx, spenderAddress, sellAsset, type, input, from, deps } = args const adapter = deps.assertGetEvmChainAdapter(sellAsset.chainId) const supportsEIP1559 = 'supportsEIP1559' in input ? input.supportsEIP1559 : false @@ -69,7 +69,7 @@ export async function getBebopStepData( stateOverride: { sellAsset, sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, - spenderAddress: approvalTarget || undefined, + spenderAddress, }, }) diff --git a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts index c4a61d4e60a..79a10c295bc 100644 --- a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts +++ b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts @@ -82,6 +82,8 @@ export const getBebopTradeContext = async ({ const buyAmountBeforeFeesCryptoBaseUnit = buyTokenData.amountBeforeFee || buyAmount const buyAmountAfterFeesCryptoBaseUnit = buyAmount + const allowanceContract = isNativeEvmAsset(sellAsset.assetId) ? '' : quote.approvalTarget + return Ok({ tradeCommon: { id: uuid(), @@ -91,7 +93,7 @@ export const getBebopTradeContext = async ({ slippageTolerancePercentageDecimal, }, stepCommon: { - allowanceContract: isNativeEvmAsset(sellAsset.assetId) ? '' : quote.approvalTarget, + allowanceContract, rate, buyAmountBeforeFeesCryptoBaseUnit, buyAmountAfterFeesCryptoBaseUnit, @@ -112,7 +114,7 @@ export const getBebopTradeContext = async ({ }, stepDataArgs: { tx: quote.tx, - approvalTarget: quote.approvalTarget, + spenderAddress: allowanceContract, sellAsset, from, deps, diff --git a/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts b/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts index 2a58d24c6c7..8c2271205ae 100644 --- a/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts +++ b/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts @@ -22,6 +22,7 @@ import { createBobGatewayOrder, toTronBase58 } from './helpers' type BaseArgs = { quote: GatewayQuoteV3 sellAmountCryptoBaseUnit: string + spenderAddress: string } export type GetBobGatewayStepDataArgs = StepDataArgs @@ -45,7 +46,7 @@ export function getBobGatewayStepData( export async function getBobGatewayStepData( args: GetBobGatewayStepDataArgs, ): Promise> { - const { input, quote, sellAsset, sellAmountCryptoBaseUnit, deps } = args + const { input, quote, sellAsset, sellAmountCryptoBaseUnit, spenderAddress, deps } = args const { chainNamespace } = fromChainId(sellAsset.chainId) // Rates estimate off the quote shape; quotes resolve the executable order once up front @@ -180,7 +181,7 @@ export async function getBobGatewayStepData( stateOverride: { sellAsset, sellAmountCryptoBaseUnit, - spenderAddress: tx.to, + spenderAddress, }, }) diff --git a/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayTradeContext.ts b/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayTradeContext.ts index fa199c98593..bad1335ae53 100644 --- a/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayTradeContext.ts +++ b/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayTradeContext.ts @@ -1,3 +1,4 @@ +import { isEvmChainId } from '@shapeshiftoss/chain-adapters' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' import { v4 as uuid } from 'uuid' @@ -107,6 +108,7 @@ export const getBobGatewayTradeContext = async ({ quote, sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, sellAsset, + spenderAddress: isEvmChainId(sellAsset.chainId) ? allowanceContract : '', deps, }, }) diff --git a/packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeQuote.test.ts b/packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeQuote.test.ts index b888f9b5a52..645957faa1d 100644 --- a/packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeQuote.test.ts +++ b/packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeQuote.test.ts @@ -30,6 +30,13 @@ vi.mock('../xhr', () => ({ isBuildTxSuccess: () => true, })) +// The override path reads live chain state - resolve to no override so estimation exercises the +// mocked adapter +vi.mock('../../../utils/evm/stateOverride', async importOriginal => ({ + ...(await importOriginal()), + getMinimalStateOverride: vi.fn().mockResolvedValue(undefined), +})) + const mockEvmChainAdapter = { getGasFeeData: () => Promise.resolve({ diff --git a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.test.ts b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.test.ts index e8d687bd9ca..030d42fe621 100644 --- a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.test.ts +++ b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.test.ts @@ -9,6 +9,13 @@ import { ROUTE_QUOTE } from '../test-data/routeQuote' import type { BuildTxSuccessItem, RouteSuccessItem } from '../types' import { getButterSwapStepData } from './getButterSwapStepData' +// The override path reads live chain state - resolve to no override so estimation exercises the +// mocked adapter +vi.mock('../../../utils/evm/stateOverride', async importOriginal => ({ + ...(await importOriginal()), + getMinimalStateOverride: vi.fn().mockResolvedValue(undefined), +})) + const route = (ROUTE_QUOTE.data as RouteSuccessItem[])[0] // The provider gas fee, in human units, that every fallback path prices off of @@ -63,6 +70,7 @@ describe('getButterSwapStepData', () => { sellAsset: WETH, feeAsset: ETH, sellAmountCryptoBaseUnit: '999000000000000000', + spenderAddress: '', }) // gasEstimatedTarget 1159118 * gasPrice 1000000000 @@ -78,6 +86,7 @@ describe('getButterSwapStepData', () => { sellAsset: WETH, feeAsset: ETH, sellAmountCryptoBaseUnit: '999000000000000000', + spenderAddress: '', }) expect(actual.unwrap()).toEqual({ @@ -96,6 +105,7 @@ describe('getButterSwapStepData', () => { sellAsset: WETH, feeAsset: ETH, sellAmountCryptoBaseUnit: '999000000000000000', + spenderAddress: '', }) // gasEstimatedTarget 1159118 is ignored - the tx carries the buffered on chain estimate @@ -123,6 +133,7 @@ describe('getButterSwapStepData', () => { sellAsset: WETH, feeAsset: ETH, sellAmountCryptoBaseUnit: '999000000000000000', + spenderAddress: '', }) const { transactionData, networkFeeCryptoBaseUnit } = actual.unwrap() @@ -143,6 +154,7 @@ describe('getButterSwapStepData', () => { sellAsset: WETH, feeAsset: ETH, sellAmountCryptoBaseUnit: '999000000000000000', + spenderAddress: '', }) expect(actual.isErr()).toBe(true) @@ -167,6 +179,7 @@ describe('getButterSwapStepData', () => { sellAsset: BTC, feeAsset: BTC, sellAmountCryptoBaseUnit: '100000', + spenderAddress: '', }) // 10 sats/byte * 200 vbyte default @@ -184,6 +197,7 @@ describe('getButterSwapStepData', () => { sellAsset: BTC, feeAsset: BTC, sellAmountCryptoBaseUnit: '100000', + spenderAddress: '', }) expect(actual.unwrap()).toEqual({ @@ -208,6 +222,7 @@ describe('getButterSwapStepData', () => { sellAsset: BTC, feeAsset: BTC, sellAmountCryptoBaseUnit: '100000', + spenderAddress: '', }) expect(actual.isErr()).toBe(true) @@ -227,6 +242,7 @@ describe('getButterSwapStepData', () => { sellAsset: SOL, feeAsset: SOL, sellAmountCryptoBaseUnit: '1000000000', + spenderAddress: '', }) expect(actual.unwrap()).toEqual({ @@ -254,6 +270,7 @@ describe('getButterSwapStepData', () => { sellAsset: TRX, feeAsset: TRX, sellAmountCryptoBaseUnit: '1000000', + spenderAddress: '', }) expect(actual.unwrap()).toEqual({ @@ -279,6 +296,7 @@ describe('getButterSwapStepData', () => { sellAsset: RUNE, feeAsset: RUNE, sellAmountCryptoBaseUnit: '100000000', + spenderAddress: '', }) expect(actual.isErr()).toBe(true) diff --git a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts index bf083d18488..b5f80526bb0 100644 --- a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts +++ b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts @@ -39,6 +39,7 @@ type BaseArgs = { route: RouteSuccessItem feeAsset: Asset sellAmountCryptoBaseUnit: string + spenderAddress: string } // Swap transactions are only built for quotes - rates price from the route alone @@ -68,7 +69,8 @@ export function getButterSwapStepData( export async function getButterSwapStepData( args: GetButterSwapStepDataArgs, ): Promise> { - const { input, route, sellAsset, feeAsset, sellAmountCryptoBaseUnit, deps } = args + const { input, route, sellAsset, feeAsset, sellAmountCryptoBaseUnit, spenderAddress, deps } = + args const { chainNamespace, chainReference } = fromChainId(sellAsset.chainId) switch (chainNamespace) { @@ -116,7 +118,7 @@ export async function getButterSwapStepData( stateOverride: { sellAsset, sellAmountCryptoBaseUnit, - spenderAddress: route.contract || undefined, + spenderAddress, }, }) diff --git a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapTradeContext.ts b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapTradeContext.ts index 04de35a57fa..759875555d4 100644 --- a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapTradeContext.ts +++ b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapTradeContext.ts @@ -166,6 +166,7 @@ export const getButterSwapTradeContext = async ({ feeAsset, sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, sellAsset, + spenderAddress: route.contract ?? '', deps, }, }) diff --git a/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts b/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts index fee30e45090..7ab874f58e7 100644 --- a/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts +++ b/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts @@ -10,6 +10,7 @@ import type { DebridgeTx } from './types' type BaseArgs = { tx: DebridgeTx gasLimit: string | undefined + spenderAddress: string fallbackNetworkFeeCryptoBaseUnit: string | undefined } @@ -28,8 +29,17 @@ export function getDebridgeStepData( export async function getDebridgeStepData( args: GetDebridgeStepDataArgs, ): Promise> { - const { tx, gasLimit, fallbackNetworkFeeCryptoBaseUnit, sellAsset, from, type, input, deps } = - args + const { + tx, + gasLimit, + spenderAddress, + fallbackNetworkFeeCryptoBaseUnit, + sellAsset, + from, + type, + input, + deps, + } = args const adapter = deps.assertGetEvmChainAdapter(sellAsset.chainId) const supportsEIP1559 = 'supportsEIP1559' in input ? input.supportsEIP1559 : false @@ -46,7 +56,7 @@ export async function getDebridgeStepData( const stateOverride = { sellAsset, sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, - spenderAddress: tx.to, + spenderAddress, } if (type === 'rate') { diff --git a/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeTradeContext.ts b/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeTradeContext.ts index 87c910d3945..26351cdc5cc 100644 --- a/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeTradeContext.ts +++ b/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeTradeContext.ts @@ -128,6 +128,8 @@ export const getDebridgeTradeContext = async ({ buyAsset, }) + const allowanceContract = isEvmChainId(sellAsset.chainId) ? quote.tx.to : '' + return Ok({ tradeCommon: { id: quote.id, @@ -137,7 +139,7 @@ export const getDebridgeTradeContext = async ({ slippageTolerancePercentageDecimal: quote.slippageTolerancePercentageDecimal, }, stepCommon: { - allowanceContract: isEvmChainId(sellAsset.chainId) ? quote.tx.to : '', + allowanceContract, rate, buyAmountBeforeFeesCryptoBaseUnit: quote.buyAmountBeforeFeesCryptoBaseUnit, buyAmountAfterFeesCryptoBaseUnit: quote.buyAmountAfterFeesCryptoBaseUnit, @@ -157,6 +159,7 @@ export const getDebridgeTradeContext = async ({ gasLimit: quote.gasLimit, fallbackNetworkFeeCryptoBaseUnit: quote.fallbackNetworkFeeCryptoBaseUnit, sellAsset, + spenderAddress: allowanceContract, from: senderAddress, deps, }, diff --git a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.ts b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.ts index 69a3f59eec8..c586ffc05d4 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeRate/getPortalsTradeRate.ts @@ -74,7 +74,6 @@ export const getPortalsTradeRate = async ( ...stepDataArgs, type: 'rate', input, - target: orderContext.target, inputToken, outputToken, inputAmount: sellAmountIncludingProtocolFeesCryptoBaseUnit, diff --git a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts index 425da05cab8..ca01bcd7d4b 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts @@ -1,7 +1,6 @@ import { fromChainId } from '@shapeshiftoss/caip' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' -import { getAddress } from 'viem' import type { StepDataArgs, SwapErrorRight, TxBuildData } from '../../../types' import { makeNetworkFeeEstimationFailedErr } from '../../../utils' @@ -11,12 +10,12 @@ import { fetchPortalsTradeEstimate } from './fetchPortalsTradeOrder' type BaseArgs = { tx: PortalsTx + spenderAddress: string } export type GetPortalsStepDataArgs = StepDataArgs< BaseArgs, { - target: string inputToken: string outputToken: string inputAmount: string @@ -36,7 +35,7 @@ export function getPortalsStepData( export async function getPortalsStepData( args: GetPortalsStepDataArgs, ): Promise> { - const { tx, sellAsset, input, deps } = args + const { tx, sellAsset, spenderAddress, input, deps } = args const adapter = deps.assertGetEvmChainAdapter(sellAsset.chainId) const supportsEIP1559 = 'supportsEIP1559' in input ? input.supportsEIP1559 : false @@ -47,15 +46,17 @@ export async function getPortalsStepData( // state need not exist yet) with the Portals estimate endpoint as fallback const gasLimit = await (async () => { try { - return await estimateGasWithStateOverride({ + const gasLimit = await estimateGasWithStateOverride({ sellAsset, sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, from: tx.from, - spenderAddress: getAddress(args.target), + spenderAddress, to: tx.to, data: tx.data, value: tx.value, }) + + return gasLimit } catch { const quoteEstimateResponse = await fetchPortalsTradeEstimate({ inputToken: args.inputToken, @@ -104,6 +105,7 @@ export async function getPortalsStepData( stateOverride: { sellAsset, sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, + spenderAddress, }, }) diff --git a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsTradeContext.ts b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsTradeContext.ts index ebcd1e03f29..1b1c968a37a 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsTradeContext.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsTradeContext.ts @@ -25,7 +25,7 @@ type PortalsTradeContext = { tradeCommon: TradeCommon stepCommon: Omit protocolFees: QuoteFeeData['protocolFees'] - stepDataArgs: { deps: SwapperDeps; sellAsset: Asset; tx: PortalsTx } + stepDataArgs: { deps: SwapperDeps; sellAsset: Asset; spenderAddress: string; tx: PortalsTx } } export const getPortalsTradeContext = ({ @@ -124,6 +124,6 @@ export const getPortalsTradeContext = ({ }), }, protocolFees, - stepDataArgs: { deps, sellAsset, tx }, + stepDataArgs: { deps, sellAsset, spenderAddress: allowanceContract, tx }, }) } diff --git a/packages/swapper/src/swappers/RelaySwapper/getTradeQuote/getTradeQuote.ts b/packages/swapper/src/swappers/RelaySwapper/getTradeQuote/getTradeQuote.ts index 137cf4cc99f..527358dd62c 100644 --- a/packages/swapper/src/swappers/RelaySwapper/getTradeQuote/getTradeQuote.ts +++ b/packages/swapper/src/swappers/RelaySwapper/getTradeQuote/getTradeQuote.ts @@ -33,7 +33,13 @@ export const getTradeQuote = async ( const stepResults = await Promise.all( relayStepInputs.map(async ({ data, allowanceContract }) => { - const maybeStepData = await getRelayStepData({ ...stepDataArgs, data, type: 'quote', input }) + const maybeStepData = await getRelayStepData({ + ...stepDataArgs, + data, + spenderAddress: allowanceContract, + type: 'quote', + input, + }) return maybeStepData.map( ({ diff --git a/packages/swapper/src/swappers/RelaySwapper/getTradeRate/getTradeRate.ts b/packages/swapper/src/swappers/RelaySwapper/getTradeRate/getTradeRate.ts index 0dde82d74c9..981b77de064 100644 --- a/packages/swapper/src/swappers/RelaySwapper/getTradeRate/getTradeRate.ts +++ b/packages/swapper/src/swappers/RelaySwapper/getTradeRate/getTradeRate.ts @@ -27,7 +27,13 @@ export const getTradeRate = async ( const stepResults = await Promise.all( relayStepInputs.map(async ({ data, allowanceContract }) => { - const maybeStepData = await getRelayStepData({ ...stepDataArgs, data, type: 'rate', input }) + const maybeStepData = await getRelayStepData({ + ...stepDataArgs, + data, + spenderAddress: allowanceContract, + type: 'rate', + input, + }) return maybeStepData.map( ({ networkFeeCryptoBaseUnit }): TradeRateStep => ({ diff --git a/packages/swapper/src/swappers/RelaySwapper/utils/getRelayStepData.ts b/packages/swapper/src/swappers/RelaySwapper/utils/getRelayStepData.ts index ba62bec262d..0239377b610 100644 --- a/packages/swapper/src/swappers/RelaySwapper/utils/getRelayStepData.ts +++ b/packages/swapper/src/swappers/RelaySwapper/utils/getRelayStepData.ts @@ -31,6 +31,7 @@ export const RELAY_SOLANA_COMPUTE_BUDGET: SolanaComputeBudgetOptions = { marginM type BaseArgs = { data: RelayQuoteItem['data'] sellAmountCryptoBaseUnit: string + spenderAddress: string orderId: string | undefined xpub: string | undefined fallbackNetworkFeeCryptoBaseUnit: string @@ -55,6 +56,7 @@ export async function getRelayStepData({ data, sellAsset, sellAmountCryptoBaseUnit, + spenderAddress, orderId, from, xpub, @@ -91,7 +93,7 @@ export async function getRelayStepData({ const stateOverride = { sellAsset, sellAmountCryptoBaseUnit, - spenderAddress: to, + spenderAddress, } if (type === 'rate') { diff --git a/packages/swapper/src/swappers/RelaySwapper/utils/getRelayTradeContext.ts b/packages/swapper/src/swappers/RelaySwapper/utils/getRelayTradeContext.ts index 01ca75707de..227dbf58e74 100644 --- a/packages/swapper/src/swappers/RelaySwapper/utils/getRelayTradeContext.ts +++ b/packages/swapper/src/swappers/RelaySwapper/utils/getRelayTradeContext.ts @@ -40,7 +40,8 @@ type RelayTradeContext = { stepCommon: Omit protocolFees: QuoteFeeData['protocolFees'] relayStepInputs: { data: RelayQuoteItem['data']; allowanceContract: string }[] - stepDataArgs: Omit + // data/spenderAddress are per-step - the callers pair them from relayStepInputs + stepDataArgs: Omit relayId: string } diff --git a/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts b/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts index 21e0bc00fb4..faeeaff5bad 100644 --- a/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts +++ b/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts @@ -16,9 +16,8 @@ type GetEvmNetworkFeeCryptoBaseUnitArgs = { transactionData: Extract from: string gasLimitBuffer?: number - // Pre-approval token sells (and unfunded rate addresses) revert plain estimation - when - // supplied, insufficient allowance/balance is state-overridden at estimation time so the - // trade still carries a real gas limit; sufficient state estimates plainly as before + // Overrides insufficient allowance/balance at estimation time so pre-approval and unfunded + // sells still estimate; spenderAddress is the step's allowanceContract ('' or absent = none) stateOverride?: { sellAsset: Asset sellAmountCryptoBaseUnit: string @@ -60,7 +59,7 @@ export const getEvmNetworkFeeCryptoBaseUnit = async ( sellAsset, sellAmountCryptoBaseUnit, from, - spenderAddress: spenderAddress ?? to, + spenderAddress, value, } diff --git a/packages/swapper/src/utils/thorchain/getThorStepData.ts b/packages/swapper/src/utils/thorchain/getThorStepData.ts index 02d38c2499f..2cc0f79b8fe 100644 --- a/packages/swapper/src/utils/thorchain/getThorStepData.ts +++ b/packages/swapper/src/utils/thorchain/getThorStepData.ts @@ -8,7 +8,10 @@ import { tcyAssetId, thorchainAssetId, } from '@shapeshiftoss/caip' -import { THOR_ROUTER_CONTRACT_MAINNET } from '@shapeshiftoss/contracts' +import { + THOR_ROUTER_CONTRACT_MAINNET, + TS_AGGREGATOR_TOKEN_TRANSFER_PROXY_CONTRACT_MAINNET, +} from '@shapeshiftoss/contracts' import { contractAddressOrUndefined } from '@shapeshiftoss/utils' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' @@ -143,20 +146,24 @@ export async function getThorStepData({ value: !longtail && isNativeEvmAsset(sellAsset.assetId) ? sellAmountCryptoBaseUnit : '0', }) + // Longtail swapIn pulls tokens through the transfer proxy, not the aggregator itself + const spenderAddress = longtail + ? TS_AGGREGATOR_TOKEN_TRANSFER_PROXY_CONTRACT_MAINNET + : router + if (type === 'rate') { const networkFeeCryptoBaseUnit = await (async () => { if (input.sendAddress) { try { - const rateTransactionData = buildTransactionData(rawMemo ?? '') return await getEvmNetworkFeeCryptoBaseUnit({ adapter, - transactionData: rateTransactionData, + transactionData: buildTransactionData(rawMemo ?? ''), from: input.sendAddress, supportsEIP1559, stateOverride: { sellAsset, sellAmountCryptoBaseUnit, - spenderAddress: rateTransactionData.to, + spenderAddress, }, }) } catch { @@ -187,7 +194,7 @@ export async function getThorStepData({ stateOverride: { sellAsset, sellAmountCryptoBaseUnit, - spenderAddress: transactionData.to, + spenderAddress, }, }) From 3cd8281b10eeb9349caa96da3cbd0847d5625764 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:06:53 -0600 Subject: [PATCH 04/11] fix(public-api): supply supportsEIP1559 on evm rate inputs GetEvmTradeRateInput pins supportsEIP1559: false and swappers discriminate evm from solana inputs on its presence - the rates endpoint omitted it, so bebop routed evm pairs to its solana implementation and errored with "Bebop Solana quote not executable: undefined". Co-Authored-By: Claude Fable 5 --- packages/public-api/src/routes/rates/getRates.ts | 2 ++ .../src/swappers/ButterSwap/utils/getButterSwapStepData.ts | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/public-api/src/routes/rates/getRates.ts b/packages/public-api/src/routes/rates/getRates.ts index 162b8776e38..89467fa341b 100644 --- a/packages/public-api/src/routes/rates/getRates.ts +++ b/packages/public-api/src/routes/rates/getRates.ts @@ -1,3 +1,4 @@ +import { isEvmChainId } from '@shapeshiftoss/chain-adapters' import type { GetTradeRateInput } from '@shapeshiftoss/swapper' import { getTradeRates, swappers, TradeQuoteError } from '@shapeshiftoss/swapper' import type { Request, Response } from 'express' @@ -84,6 +85,7 @@ export const getRates = async (req: Request, res: Response): Promise => { accountNumber: undefined, quoteOrRate: 'rate' as const, chainId: sellAsset.chainId, + ...(isEvmChainId(sellAsset.chainId) && { supportsEIP1559: false as const }), } const ratePromises = ENABLED_SWAPPER_NAMES.map(async (swapperName): Promise => { diff --git a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts index b5f80526bb0..6b4af498aaf 100644 --- a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts +++ b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts @@ -69,8 +69,7 @@ export function getButterSwapStepData( export async function getButterSwapStepData( args: GetButterSwapStepDataArgs, ): Promise> { - const { input, route, sellAsset, feeAsset, sellAmountCryptoBaseUnit, spenderAddress, deps } = - args + const { input, route, sellAsset, feeAsset, sellAmountCryptoBaseUnit, spenderAddress, deps } = args const { chainNamespace, chainReference } = fromChainId(sellAsset.chainId) switch (chainNamespace) { From b06cd38030fa50bfcb1ad8f9fc8e39b39ee904a4 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:29:33 -0600 Subject: [PATCH 05/11] feat(swapper): buffer estimated gas limits by default Estimated limits now carry a uniform 1.2 safety margin instead of the previous per-swapper split (butter/debridge/bob/portals buffered, the rest raw). eth_estimateGas returns the bare minimum that succeeds and quotes execute post-approval against moved state - an OOG revert burns the full limit while an oversized one refunds, and the displayed fee still prices the raw estimate, so the margin costs nothing. Provider-supplied limits remain priced and carried as-is. Co-Authored-By: Claude Fable 5 --- .../utils/getBobGatewayStepData.ts | 1 - .../ButterSwap/utils/getButterSwapStepData.ts | 3 +- .../utils/getDebridgeStepData.ts | 2 -- .../utils/getNearIntentsStepData.ts | 30 ++++++++++++------- .../utils/getPortalsStepData.ts | 1 - .../getTradeQuote/getTradeQuote.test.ts | 12 ++++++-- .../evm/getEvmNetworkFeeCryptoBaseUnit.ts | 4 ++- 7 files changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts b/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts index 8c2271205ae..c44cca734d7 100644 --- a/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts +++ b/packages/swapper/src/swappers/BobGatewaySwapper/utils/getBobGatewayStepData.ts @@ -177,7 +177,6 @@ export async function getBobGatewayStepData( transactionData, from, supportsEIP1559: input.supportsEIP1559, - gasLimitBuffer: 1.2, stateOverride: { sellAsset, sellAmountCryptoBaseUnit, diff --git a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts index 6b4af498aaf..44b58932204 100644 --- a/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts +++ b/packages/swapper/src/swappers/ButterSwap/utils/getButterSwapStepData.ts @@ -112,8 +112,7 @@ export async function getButterSwapStepData( transactionData, from, // Butter's gasEstimatedTarget lands short on chain (observed 24% under actual, causing - // in-flight OOG/SWAP_FAIL reverts) - estimate ourselves with a buffer - gasLimitBuffer: 1.2, + // in-flight OOG/SWAP_FAIL reverts) - estimate ourselves instead stateOverride: { sellAsset, sellAmountCryptoBaseUnit, diff --git a/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts b/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts index 7ab874f58e7..090c16a4c99 100644 --- a/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts +++ b/packages/swapper/src/swappers/DebridgeSwapper/utils/getDebridgeStepData.ts @@ -67,7 +67,6 @@ export async function getDebridgeStepData( transactionData, from, supportsEIP1559, - gasLimitBuffer: 1.2, stateOverride, }) } catch { @@ -86,7 +85,6 @@ export async function getDebridgeStepData( transactionData, from, supportsEIP1559, - gasLimitBuffer: 1.2, stateOverride, }) diff --git a/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts b/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts index 5090830f792..e983df6c452 100644 --- a/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts +++ b/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts @@ -69,21 +69,23 @@ export async function getNearIntentsStepData( value: isNativeEvmAsset(sellAsset.assetId) ? sellAmountCryptoBaseUnit : '0', } + const stateOverride = { + sellAsset, + sellAmountCryptoBaseUnit, + } + // The deposit is a plain (token) transfer with no approval involved - overridden // estimation still prices an unfunded sender, so rates work walletless - const estimate = () => - getEvmNetworkFeeCryptoBaseUnit({ - adapter, - transactionData, - from: from || depositAddress, - supportsEIP1559, - stateOverride: { sellAsset, sellAmountCryptoBaseUnit }, - }) - if (type === 'rate') { const networkFeeCryptoBaseUnit = await (async () => { try { - return await estimate() + return await getEvmNetworkFeeCryptoBaseUnit({ + adapter, + transactionData, + from: from || depositAddress, + supportsEIP1559, + stateOverride, + }) } catch { return '0' } @@ -94,7 +96,13 @@ export async function getNearIntentsStepData( return Ok(stepData) } - const networkFeeCryptoBaseUnit = await estimate() + const networkFeeCryptoBaseUnit = await getEvmNetworkFeeCryptoBaseUnit({ + adapter, + transactionData, + from: from || depositAddress, + supportsEIP1559, + stateOverride, + }) const stepData: NearIntentsQuoteStepData = { transactionData, networkFeeCryptoBaseUnit } diff --git a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts index ca01bcd7d4b..eb18464d5b3 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/utils/getPortalsStepData.ts @@ -101,7 +101,6 @@ export async function getPortalsStepData( transactionData, from: args.from, supportsEIP1559, - gasLimitBuffer: 1.2, stateOverride: { sellAsset, sellAmountCryptoBaseUnit: input.sellAmountIncludingProtocolFeesCryptoBaseUnit, diff --git a/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts b/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts index b21a503432f..e912c1c1324 100644 --- a/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts +++ b/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts @@ -36,6 +36,14 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../../../utils/thorchain/getThorTxData') + +// The override path reads live chain state - resolve to no override so estimation exercises the +// mocked adapter +vi.mock('../../../utils/evm/stateOverride', async importOriginal => ({ + ...(await importOriginal()), + getMinimalStateOverride: vi.fn().mockResolvedValue(undefined), +})) + vi.mock('../../../utils/thorchain/service', () => { const mockAxios = { default: { @@ -114,7 +122,7 @@ const expectedQuoteResponse: Omit[] = [ to: '0x3624525075b88B24ecc29CE226b0CEc1fFcB6976', data: expectedDepositData(REGULAR_MEMO), value: '0', - gasLimit: '100000', + gasLimit: '120000', }, }, ], @@ -164,7 +172,7 @@ const expectedQuoteResponse: Omit[] = [ to: '0x3624525075b88B24ecc29CE226b0CEc1fFcB6976', data: expectedDepositData(STREAMING_MEMO), value: '0', - gasLimit: '100000', + gasLimit: '120000', }, }, ], diff --git a/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts b/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts index faeeaff5bad..5a3eae6d6b4 100644 --- a/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts +++ b/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts @@ -15,6 +15,8 @@ type GetEvmNetworkFeeCryptoBaseUnitArgs = { | { transactionData: Extract from: string + // Estimated limits carry a safety margin against state moving before execution (an OOG + // revert burns the full limit, an oversized one refunds); provider limits are never buffered gasLimitBuffer?: number // Overrides insufficient allowance/balance at estimation time so pre-approval and unfunded // sells still estimate; spenderAddress is the step's allowanceContract ('' or absent = none) @@ -40,7 +42,7 @@ export const getEvmNetworkFeeCryptoBaseUnit = async ( // Rate network fee: the caller passed a provider gas limit directly if (!('transactionData' in args)) return priceProviderGasLimit(args.gasLimit) - const { transactionData, from, gasLimitBuffer = 1 } = args + const { transactionData, from, gasLimitBuffer = 1.2 } = args // Executable quote: the provider supplied a gas limit on the tx data, price it as-is const providerGasLimit = transactionData.gasLimit From 2294f871d8afe611d09c80c8e2f189f2623ade46 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:05:42 -0600 Subject: [PATCH 06/11] fix(swapper): review feedback - override resilience, fee sentinel, negative cache - Override-path failures (timeout, undiscoverable slot, rpc without stateOverride support) now fall through to plain estimation instead of rejecting the quote outright - sufficient-state sellers survive transient read failures, insufficient ones fail identically just later - NearIntents evm rate failure returns undefined like the file's other namespaces, not '0' masquerading as a free transaction - Failed slot discoveries are cached with a 60s ttl so unknown layouts and unsupported rpcs don't pay the full candidate sweep on every rate refresh Co-Authored-By: Claude Fable 5 --- .../NearIntentsSwapper/utils/getNearIntentsStepData.ts | 2 +- .../src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts | 4 +++- packages/swapper/src/utils/evm/stateOverride.ts | 10 ++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts b/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts index e983df6c452..a628934e9c5 100644 --- a/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts +++ b/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts @@ -87,7 +87,7 @@ export async function getNearIntentsStepData( stateOverride, }) } catch { - return '0' + return undefined } })() diff --git a/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts b/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts index 5a3eae6d6b4..c20bf93f9fb 100644 --- a/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts +++ b/packages/swapper/src/utils/evm/getEvmNetworkFeeCryptoBaseUnit.ts @@ -55,6 +55,8 @@ export const getEvmNetworkFeeCryptoBaseUnit = async ( if (args.stateOverride) { const { sellAsset, sellAmountCryptoBaseUnit, spenderAddress } = args.stateOverride + // Best effort - a timeout, undiscoverable slot, or an rpc without stateOverride support falls + // through to plain estimation, which succeeds whenever the seller's state was sufficient anyway const overriddenGasLimit = await withTimeout( (async () => { const overrideArgs = { @@ -70,7 +72,7 @@ export const getEvmNetworkFeeCryptoBaseUnit = async ( return estimateGasWithStateOverride({ ...overrideArgs, to, data, stateOverride }) })(), - ) + ).catch(() => undefined) if (overriddenGasLimit) { transactionData.gasLimit = bnOrZero(overriddenGasLimit).times(gasLimitBuffer).toFixed(0) diff --git a/packages/swapper/src/utils/evm/stateOverride.ts b/packages/swapper/src/utils/evm/stateOverride.ts index 74dfec8920e..4f7b8fca4f1 100644 --- a/packages/swapper/src/utils/evm/stateOverride.ts +++ b/packages/swapper/src/utils/evm/stateOverride.ts @@ -25,6 +25,8 @@ const CANDIDATE_BALANCE_SLOTS = [0, 1, 2, 3, 5, 9, 51] const CANDIDATE_ALLOWANCE_SLOTS = [1, 2, 4, 5, 6, 10, 52] const discoveredSlots = new Map() +const failedSlotDiscoveries = new Map() +const FAILED_DISCOVERY_TTL_MS = 60_000 export const withTimeout = async (promise: Promise): Promise => { let timer: ReturnType | undefined @@ -122,6 +124,13 @@ const discoverSlot = async ( const cached = discoveredSlots.get(cacheKey) if (cached !== undefined) return cached + // Failures re-probe only after the ttl - covers unknown layouts and rpcs without stateOverride + // support, which would otherwise pay the full candidate sweep on every rate refresh + const failedAt = failedSlotDiscoveries.get(cacheKey) + if (failedAt !== undefined && Date.now() - failedAt < FAILED_DISCOVERY_TTL_MS) { + throw new Error(`Unable to locate ${kind} storage slot for token ${tokenAddress}`) + } + const guess = kind === 'balance' ? getTokenBalanceSlot(tokenAddress) : getTokenAllowanceSlot(tokenAddress) @@ -141,6 +150,7 @@ const discoverSlot = async ( const found = candidates.find((_, i) => results[i]) if (found === undefined) { + failedSlotDiscoveries.set(cacheKey, Date.now()) throw new Error(`Unable to locate ${kind} storage slot for token ${tokenAddress}`) } From d41b639f18aa4f19a2ed38bc78f953551436baab Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:30:09 -0600 Subject: [PATCH 07/11] feat(swapper): generalize storage slot discovery (access list, vyper, uint96) - probe with a sub-uint96 sentinel so packed-storage tokens (UNI/COMP) read it back intact, and write the trade-sized value instead of maxUint256 to keep flag bits and packed neighbors clear - support the vyper mapping hash order (Curve ecosystem) - discover arbitrary layouts via eth_createAccessList preimage matching (supported on 23/32 EVM chains), with a widened dual-layout candidate sweep as fallback - discover balance and allowance slots in parallel Co-Authored-By: Claude Fable 5 --- .../swapper/src/utils/evm/stateOverride.ts | 219 ++++++++++++------ .../swapper/src/utils/evm/storageSlots.ts | 44 ++-- 2 files changed, 174 insertions(+), 89 deletions(-) diff --git a/packages/swapper/src/utils/evm/stateOverride.ts b/packages/swapper/src/utils/evm/stateOverride.ts index 4f7b8fca4f1..fbdea8a164a 100644 --- a/packages/swapper/src/utils/evm/stateOverride.ts +++ b/packages/swapper/src/utils/evm/stateOverride.ts @@ -2,13 +2,13 @@ import { assertGetViemClient } from '@shapeshiftoss/contracts' import type { Asset } from '@shapeshiftoss/types' import { assertUnreachable, contractAddressOrUndefined } from '@shapeshiftoss/utils' import type { Address, Hex, PublicClient, StateOverride } from 'viem' -import { erc20Abi, getAddress, hexToBigInt, maxUint256, pad, parseEther, toHex } from 'viem' +import { encodeFunctionData, erc20Abi, getAddress, maxUint256, pad, parseEther, toHex } from 'viem' import { isNativeEvmAsset } from '../helpers' +import type { StorageLayout } from './storageSlots' import { getAllowanceStorageSlot, getBalanceStorageSlot, - getMaxBalanceValue, getTokenAllowanceSlot, getTokenBalanceSlot, } from './storageSlots' @@ -20,11 +20,20 @@ const ESTIMATION_TIMEOUT_MS = 3_000 // Large native balance for gas (1B is overkill but guarantees success on all chains) const NATIVE_BALANCE_OVERRIDE = parseEther('1000000000') -// Slot numbers seen across known token implementations -const CANDIDATE_BALANCE_SLOTS = [0, 1, 2, 3, 5, 9, 51] -const CANDIDATE_ALLOWANCE_SLOTS = [1, 2, 4, 5, 6, 10, 52] +// Probe sentinel: distinctive enough that a real balance/allowance can't collide with it, and +// small enough to read back intact through uint96-packed storage (UNI/COMP-style tokens) +const PROBE_SENTINEL = (1n << 88n) - 0xdecafbadn -const discoveredSlots = new Map() +// Slot numbers seen across known token implementations, swept per layout when access-list +// discovery isn't available (51/52 covers the L2 storage-gap pattern) +const CANDIDATE_SLOTS: Record = { + solidity: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 51, 52], + vyper: [0, 1, 2, 3, 4, 5, 6], +} + +type SlotLocator = { slotNumber: number; layout: StorageLayout } + +const discoveredSlots = new Map() const failedSlotDiscoveries = new Map() const FAILED_DISCOVERY_TTL_MS = 60_000 @@ -53,31 +62,28 @@ type SlotProbeBaseArgs = { type SlotKindArgs = { kind: 'balance' } | { kind: 'allowance'; spender: Address } -type ProbeSlotArgs = SlotProbeBaseArgs & SlotKindArgs & { slotNumber: number } +type ProbeSlotArgs = SlotProbeBaseArgs & SlotKindArgs & SlotLocator // A slot guess only counts if a read through the override reflects the written sentinel const probeSlot = async (args: ProbeSlotArgs): Promise => { - const { client, tokenAddress, owner, slotNumber } = args + const { client, tokenAddress, owner, slotNumber, layout } = args - const { storageSlot, sentinel } = (() => { + const storageSlot = (() => { switch (args.kind) { - case 'allowance': { - const storageSlot = getAllowanceStorageSlot(owner, args.spender, slotNumber) - const sentinel = toHex(maxUint256) - return { storageSlot, sentinel } - } - case 'balance': { - const storageSlot = getBalanceStorageSlot(owner, slotNumber) - const sentinel = getMaxBalanceValue(slotNumber) - return { storageSlot, sentinel } - } + case 'allowance': + return getAllowanceStorageSlot(owner, args.spender, slotNumber, layout) + case 'balance': + return getBalanceStorageSlot(owner, slotNumber, layout) default: return assertUnreachable(args) } })() const stateOverride = [ - { address: tokenAddress, stateDiff: [{ slot: storageSlot, value: pad(sentinel) }] }, + { + address: tokenAddress, + stateDiff: [{ slot: storageSlot, value: pad(toHex(PROBE_SENTINEL)) }], + }, ] try { @@ -106,17 +112,78 @@ const probeSlot = async (args: ProbeSlotArgs): Promise => { } })() - return result === hexToBigInt(sentinel) + return result === PROBE_SENTINEL } catch { return false } } -// Find the token's balance/allowance mapping slot: try the known-table/pattern guess first, then -// probe the remaining candidates. Discovered slots are cached per chain+token. +// Caps request gas so nodes that charge the sender up front still accept an unfunded owner +const ACCESS_LIST_GAS = 400_000n + +// Mapping slots are recovered by hashing candidate slot numbers against the touched set - 256 +// covers storage-gap layouts (slot 51/52 style) with plenty of headroom +const MAX_MAPPED_SLOT = 256 + +// Ask the node which storage slots the read touches (eth_createAccessList), then recover the +// mapping slot by matching them against locally computed hashes - handles arbitrary slot numbers +// and both hash orders in a single call on supporting rpcs (23 of our 32 EVM chains) +const findSlotViaAccessList = async ( + args: SlotProbeBaseArgs & SlotKindArgs, +): Promise => { + const { client, tokenAddress, owner } = args + + try { + const { accessList } = await client.createAccessList({ + account: owner, + to: tokenAddress, + data: (() => { + switch (args.kind) { + case 'allowance': + return encodeFunctionData({ + abi: erc20Abi, + functionName: 'allowance', + args: [owner, args.spender], + }) + case 'balance': + return encodeFunctionData({ abi: erc20Abi, functionName: 'balanceOf', args: [owner] }) + default: + return assertUnreachable(args) + } + })(), + gas: ACCESS_LIST_GAS, + }) + + // Delegatecall storage reads are attributed to the proxy, so the token's own entry holds them + const touchedSlots = new Set( + accessList + .filter(entry => entry.address.toLowerCase() === tokenAddress.toLowerCase()) + .flatMap(entry => entry.storageKeys.map(storageKey => storageKey.toLowerCase())), + ) + + for (const layout of ['solidity', 'vyper'] as const) { + for (let slotNumber = 0; slotNumber < MAX_MAPPED_SLOT; slotNumber++) { + const storageSlot = + args.kind === 'allowance' + ? getAllowanceStorageSlot(owner, args.spender, slotNumber, layout) + : getBalanceStorageSlot(owner, slotNumber, layout) + + if (touchedSlots.has(storageSlot)) return { slotNumber, layout } + } + } + } catch { + // Unsupported method or node quirk - the candidate sweep covers it + } + + return undefined +} + +// Find the token's balance/allowance mapping slot: known-table guess first, then access-list +// assisted discovery, then a candidate sweep across both layouts. Discovered slots are cached +// per chain+token. const discoverSlot = async ( args: SlotProbeBaseArgs & SlotKindArgs & { chainId: string }, -): Promise => { +): Promise => { const { chainId, tokenAddress, kind } = args const cacheKey = `${chainId}:${tokenAddress.toLowerCase()}:${kind}` @@ -131,23 +198,35 @@ const discoverSlot = async ( throw new Error(`Unable to locate ${kind} storage slot for token ${tokenAddress}`) } - const guess = - kind === 'balance' ? getTokenBalanceSlot(tokenAddress) : getTokenAllowanceSlot(tokenAddress) + const found = await (async (): Promise => { + const guess: SlotLocator = { + slotNumber: + kind === 'balance' + ? getTokenBalanceSlot(tokenAddress) + : getTokenAllowanceSlot(tokenAddress), + layout: 'solidity', + } - if (await probeSlot({ ...args, slotNumber: guess })) { - discoveredSlots.set(cacheKey, guess) - return guess - } + if (await probeSlot({ ...args, ...guess })) return guess - const candidates = ( - kind === 'balance' ? CANDIDATE_BALANCE_SLOTS : CANDIDATE_ALLOWANCE_SLOTS - ).filter(slotNumber => slotNumber !== guess) + const accessListLocator = await findSlotViaAccessList(args) + if (accessListLocator && (await probeSlot({ ...args, ...accessListLocator }))) { + return accessListLocator + } - const results = await Promise.all( - candidates.map(slotNumber => probeSlot({ ...args, slotNumber })), - ) + const candidates = (['solidity', 'vyper'] as const) + .flatMap(layout => CANDIDATE_SLOTS[layout].map(slotNumber => ({ slotNumber, layout }))) + .filter( + candidate => + !(candidate.layout === guess.layout && candidate.slotNumber === guess.slotNumber), + ) - const found = candidates.find((_, i) => results[i]) + const results = await Promise.all( + candidates.map(candidate => probeSlot({ ...args, ...candidate })), + ) + + return candidates.find((_, i) => results[i]) + })() if (found === undefined) { failedSlotDiscoveries.set(cacheKey, Date.now()) @@ -225,38 +304,40 @@ export const getMinimalStateOverride = async ({ stateOverride.push({ address: from, balance: NATIVE_BALANCE_OVERRIDE }) } - const stateDiff: { slot: Hex; value: Hex }[] = [] - - if (balance < sellAmount) { - const slotNumber = await discoverSlot({ - client, - chainId: sellAsset.chainId, - tokenAddress, - kind: 'balance', - owner: from, - }) - - stateDiff.push({ - slot: getBalanceStorageSlot(from, slotNumber), - value: pad(getMaxBalanceValue(slotNumber)), - }) - } - - if (spender && allowance < sellAmount) { - const slotNumber = await discoverSlot({ - client, - chainId: sellAsset.chainId, - tokenAddress, - kind: 'allowance', - owner: from, - spender, - }) - - stateDiff.push({ - slot: getAllowanceStorageSlot(from, spender, slotNumber), - value: pad(toHex(maxUint256)), - }) - } + // Override to the larger of the sell amount and the probe sentinel - covers the trade with + // headroom while leaving high bits clear (a full maxUint256 write would set flag bits and + // packed-field neighbors, e.g. USDC's slot 9 blacklist bit or UNI's uint96 balances) + const overrideValue = pad(toHex(sellAmount > PROBE_SENTINEL ? sellAmount : PROBE_SENTINEL)) + + const stateDiff = ( + await Promise.all([ + balance < sellAmount + ? discoverSlot({ + client, + chainId: sellAsset.chainId, + tokenAddress, + kind: 'balance', + owner: from, + }).then(({ slotNumber, layout }) => ({ + slot: getBalanceStorageSlot(from, slotNumber, layout), + value: overrideValue, + })) + : undefined, + spender && allowance < sellAmount + ? discoverSlot({ + client, + chainId: sellAsset.chainId, + tokenAddress, + kind: 'allowance', + owner: from, + spender, + }).then(({ slotNumber, layout }) => ({ + slot: getAllowanceStorageSlot(from, spender, slotNumber, layout), + value: overrideValue, + })) + : undefined, + ]) + ).filter((diff): diff is { slot: Hex; value: Hex } => diff !== undefined) if (stateDiff.length) stateOverride.push({ address: tokenAddress, stateDiff }) diff --git a/packages/swapper/src/utils/evm/storageSlots.ts b/packages/swapper/src/utils/evm/storageSlots.ts index a51277a32e1..7504f3db8dd 100644 --- a/packages/swapper/src/utils/evm/storageSlots.ts +++ b/packages/swapper/src/utils/evm/storageSlots.ts @@ -1,26 +1,34 @@ import type { Address, Hex } from 'viem' -import { concat, keccak256, maxUint256, pad, toHex } from 'viem' +import { concat, keccak256, pad, toHex } from 'viem' -// keccak256(concat(pad(address), pad(slot))) -export const getBalanceStorageSlot = (userAddress: Address, balanceSlotNumber: number = 0): Hex => { - const paddedAddress = pad(userAddress.toLowerCase() as Address, { size: 32 }) - const paddedSlot = pad(toHex(balanceSlotNumber), { size: 32 }) - return keccak256(concat([paddedAddress, paddedSlot])) +// Solidity stores mapping values at keccak256(key . slot); Vyper (Curve ecosystem) reverses the +// order to keccak256(slot . key) +export type StorageLayout = 'solidity' | 'vyper' + +const getMappingStorageSlot = (key: Hex, slot: number | Hex, layout: StorageLayout): Hex => { + const paddedKey = pad(key.toLowerCase() as Hex, { size: 32 }) + const paddedSlot = pad(typeof slot === 'number' ? toHex(slot) : slot, { size: 32 }) + return keccak256(concat(layout === 'vyper' ? [paddedSlot, paddedKey] : [paddedKey, paddedSlot])) } -// keccak256(spender, keccak256(owner, slot)) +export const getBalanceStorageSlot = ( + userAddress: Address, + balanceSlotNumber: number, + layout: StorageLayout = 'solidity', +): Hex => getMappingStorageSlot(userAddress, balanceSlotNumber, layout) + +// Nested mapping - the outer (spender) hash chains onto the inner (owner) hash export const getAllowanceStorageSlot = ( ownerAddress: Address, spenderAddress: Address, - allowanceSlotNumber: number = 1, -): Hex => { - const paddedOwner = pad(ownerAddress.toLowerCase() as Address, { size: 32 }) - const paddedAllowanceSlot = pad(toHex(allowanceSlotNumber), { size: 32 }) - const innerSlot = keccak256(concat([paddedOwner, paddedAllowanceSlot])) - - const paddedSpender = pad(spenderAddress.toLowerCase() as Address, { size: 32 }) - return keccak256(concat([paddedSpender, innerSlot])) -} + allowanceSlotNumber: number, + layout: StorageLayout = 'solidity', +): Hex => + getMappingStorageSlot( + spenderAddress, + getMappingStorageSlot(ownerAddress, allowanceSlotNumber, layout), + layout, + ) // Most ERC20s: balance at slot 0, allowance at slot 1 // Slot 51/52 pattern (StandardArbERC20, some L2 implementations) @@ -98,7 +106,3 @@ export const getTokenAllowanceSlot = (tokenAddress: Address): number => { // Standard ERC20: allowance at slot 1 return 1 } - -// USDC slot 9: bit 255 is blacklist flag, must keep cleared -export const getMaxBalanceValue = (balanceSlotNumber: number): Hex => - balanceSlotNumber === 9 ? toHex(maxUint256 >> 1n) : toHex(maxUint256) From 80ab42a55c19341789a593a3f9db36fc5d59ddcf Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:54:53 -0600 Subject: [PATCH 08/11] feat(swapper): harden discovery for long-tail chains and public rpcs - omit the access-list account so gas-charging nodes (plume-style) accept unfunded owners - node defaults to the zero address, which never affects the slots a read touches (fixes a 61s sweep observed on plume) - raw-slot fallback for namespaced storage (ERC-7201) - when no slot number is recoverable, sentinel-probe the touched slots directly and cache per owner - batch the candidate sweep (6 concurrent) - public rpcs on long-tail chains drop requests above ~14 concurrent, and an early hit now skips the remaining batches Co-Authored-By: Claude Fable 5 --- .../swapper/src/utils/evm/stateOverride.ts | 164 +++++++++++------- 1 file changed, 99 insertions(+), 65 deletions(-) diff --git a/packages/swapper/src/utils/evm/stateOverride.ts b/packages/swapper/src/utils/evm/stateOverride.ts index fbdea8a164a..6e5bfc30540 100644 --- a/packages/swapper/src/utils/evm/stateOverride.ts +++ b/packages/swapper/src/utils/evm/stateOverride.ts @@ -31,9 +31,16 @@ const CANDIDATE_SLOTS: Record = { vyper: [0, 1, 2, 3, 4, 5, 6], } +// Public rpcs on long-tail chains rate limit aggressive fan-out, so sweep probes run in small +// batches (which also lets a hit in an early batch skip the rest) +const SWEEP_BATCH_SIZE = 6 + type SlotLocator = { slotNumber: number; layout: StorageLayout } const discoveredSlots = new Map() +// Namespaced-storage tokens (ERC-7201 style) have no recoverable slot number - the touched slot +// itself is cached instead, keyed per owner (and spender) since it embeds them +const rawDiscoveredSlots = new Map() const failedSlotDiscoveries = new Map() const FAILED_DISCOVERY_TTL_MS = 60_000 @@ -62,22 +69,25 @@ type SlotProbeBaseArgs = { type SlotKindArgs = { kind: 'balance' } | { kind: 'allowance'; spender: Address } -type ProbeSlotArgs = SlotProbeBaseArgs & SlotKindArgs & SlotLocator +// The concrete slot for an owner (and spender) at a candidate slot number/layout +const getStorageSlotFromLocator = (args: SlotKindArgs & SlotLocator & { owner: Address }): Hex => { + const { owner, slotNumber, layout } = args + + switch (args.kind) { + case 'allowance': + return getAllowanceStorageSlot(owner, args.spender, slotNumber, layout) + case 'balance': + return getBalanceStorageSlot(owner, slotNumber, layout) + default: + return assertUnreachable(args) + } +} + +type ProbeStorageSlotArgs = SlotProbeBaseArgs & SlotKindArgs & { storageSlot: Hex } // A slot guess only counts if a read through the override reflects the written sentinel -const probeSlot = async (args: ProbeSlotArgs): Promise => { - const { client, tokenAddress, owner, slotNumber, layout } = args - - const storageSlot = (() => { - switch (args.kind) { - case 'allowance': - return getAllowanceStorageSlot(owner, args.spender, slotNumber, layout) - case 'balance': - return getBalanceStorageSlot(owner, slotNumber, layout) - default: - return assertUnreachable(args) - } - })() +const probeStorageSlot = async (args: ProbeStorageSlotArgs): Promise => { + const { client, tokenAddress, owner, storageSlot } = args const stateOverride = [ { @@ -118,24 +128,26 @@ const probeSlot = async (args: ProbeSlotArgs): Promise => { } } -// Caps request gas so nodes that charge the sender up front still accept an unfunded owner +// Caps request gas so nodes that charge the sender up front accept the request. The account is +// omitted entirely - the node's default (zero address) passes funds checks on gas-charging nodes +// where an unfunded owner wouldn't, and the sender never affects which slots the read touches const ACCESS_LIST_GAS = 400_000n // Mapping slots are recovered by hashing candidate slot numbers against the touched set - 256 // covers storage-gap layouts (slot 51/52 style) with plenty of headroom const MAX_MAPPED_SLOT = 256 -// Ask the node which storage slots the read touches (eth_createAccessList), then recover the -// mapping slot by matching them against locally computed hashes - handles arbitrary slot numbers -// and both hash orders in a single call on supporting rpcs (23 of our 32 EVM chains) -const findSlotViaAccessList = async ( - args: SlotProbeBaseArgs & SlotKindArgs, -): Promise => { +// Raw-slot fallback probes are bounded - balanceOf/allowance reads touch a handful of slots at +// most (implementation pointer, pause flags, the mapping value) +const MAX_RAW_SLOT_PROBES = 8 + +// Ask the node which storage slots the read touches (eth_createAccessList) - supported on 23 of +// our 32 EVM chains, everything else falls through to the candidate sweep +const findTouchedSlots = async (args: SlotProbeBaseArgs & SlotKindArgs): Promise => { const { client, tokenAddress, owner } = args try { const { accessList } = await client.createAccessList({ - account: owner, to: tokenAddress, data: (() => { switch (args.kind) { @@ -155,41 +167,52 @@ const findSlotViaAccessList = async ( }) // Delegatecall storage reads are attributed to the proxy, so the token's own entry holds them - const touchedSlots = new Set( - accessList - .filter(entry => entry.address.toLowerCase() === tokenAddress.toLowerCase()) - .flatMap(entry => entry.storageKeys.map(storageKey => storageKey.toLowerCase())), - ) - - for (const layout of ['solidity', 'vyper'] as const) { - for (let slotNumber = 0; slotNumber < MAX_MAPPED_SLOT; slotNumber++) { - const storageSlot = - args.kind === 'allowance' - ? getAllowanceStorageSlot(owner, args.spender, slotNumber, layout) - : getBalanceStorageSlot(owner, slotNumber, layout) - - if (touchedSlots.has(storageSlot)) return { slotNumber, layout } - } - } + return accessList + .filter(entry => entry.address.toLowerCase() === tokenAddress.toLowerCase()) + .flatMap(entry => entry.storageKeys.map(storageKey => storageKey.toLowerCase() as Hex)) } catch { // Unsupported method or node quirk - the candidate sweep covers it + return [] + } +} + +// Recover the mapping slot number by matching the touched slots against locally computed hashes - +// handles arbitrary slot numbers and both hash orders without any further rpc calls +const matchTouchedSlot = ( + args: SlotKindArgs & { owner: Address; touchedSlots: Hex[] }, +): SlotLocator | undefined => { + const touched = new Set(args.touchedSlots) + + for (const layout of ['solidity', 'vyper'] as const) { + for (let slotNumber = 0; slotNumber < MAX_MAPPED_SLOT; slotNumber++) { + if (touched.has(getStorageSlotFromLocator({ ...args, slotNumber, layout }))) { + return { slotNumber, layout } + } + } } return undefined } -// Find the token's balance/allowance mapping slot: known-table guess first, then access-list -// assisted discovery, then a candidate sweep across both layouts. Discovered slots are cached -// per chain+token. -const discoverSlot = async ( +// Find the concrete storage slot for the owner's balance/allowance: known-table guess first, +// then access-list assisted discovery (slot-number recovery, raw-slot fallback for namespaced +// storage), then a batched candidate sweep across both layouts. Results are cached per +// chain+token (raw slots per owner). +const discoverStorageSlot = async ( args: SlotProbeBaseArgs & SlotKindArgs & { chainId: string }, -): Promise => { - const { chainId, tokenAddress, kind } = args +): Promise => { + const { chainId, tokenAddress, owner, kind } = args const cacheKey = `${chainId}:${tokenAddress.toLowerCase()}:${kind}` + const rawCacheKey = `${cacheKey}:${owner.toLowerCase()}${ + args.kind === 'allowance' ? `:${args.spender.toLowerCase()}` : '' + }` const cached = discoveredSlots.get(cacheKey) - if (cached !== undefined) return cached + if (cached !== undefined) return getStorageSlotFromLocator({ ...args, ...cached }) + + const cachedRaw = rawDiscoveredSlots.get(rawCacheKey) + if (cachedRaw !== undefined) return cachedRaw // Failures re-probe only after the ttl - covers unknown layouts and rpcs without stateOverride // support, which would otherwise pay the full candidate sweep on every rate refresh @@ -198,7 +221,10 @@ const discoverSlot = async ( throw new Error(`Unable to locate ${kind} storage slot for token ${tokenAddress}`) } - const found = await (async (): Promise => { + const probeLocator = (locator: SlotLocator) => + probeStorageSlot({ ...args, storageSlot: getStorageSlotFromLocator({ ...args, ...locator }) }) + + const found = await (async (): Promise => { const guess: SlotLocator = { slotNumber: kind === 'balance' @@ -207,11 +233,16 @@ const discoverSlot = async ( layout: 'solidity', } - if (await probeSlot({ ...args, ...guess })) return guess + if (await probeLocator(guess)) return guess + + const touchedSlots = await findTouchedSlots(args) - const accessListLocator = await findSlotViaAccessList(args) - if (accessListLocator && (await probeSlot({ ...args, ...accessListLocator }))) { - return accessListLocator + const matched = matchTouchedSlot({ ...args, touchedSlots }) + if (matched && (await probeLocator(matched))) return matched + + // No recoverable slot number (namespaced storage) - probe the touched slots directly + for (const rawSlot of touchedSlots.slice(0, MAX_RAW_SLOT_PROBES)) { + if (await probeStorageSlot({ ...args, storageSlot: rawSlot })) return { rawSlot } } const candidates = (['solidity', 'vyper'] as const) @@ -221,11 +252,15 @@ const discoverSlot = async ( !(candidate.layout === guess.layout && candidate.slotNumber === guess.slotNumber), ) - const results = await Promise.all( - candidates.map(candidate => probeSlot({ ...args, ...candidate })), - ) + for (let i = 0; i < candidates.length; i += SWEEP_BATCH_SIZE) { + const batch = candidates.slice(i, i + SWEEP_BATCH_SIZE) + const results = await Promise.all(batch.map(probeLocator)) + + const hit = batch.find((_, j) => results[j]) + if (hit) return hit + } - return candidates.find((_, i) => results[i]) + return undefined })() if (found === undefined) { @@ -233,9 +268,14 @@ const discoverSlot = async ( throw new Error(`Unable to locate ${kind} storage slot for token ${tokenAddress}`) } + if ('rawSlot' in found) { + rawDiscoveredSlots.set(rawCacheKey, found.rawSlot) + return found.rawSlot + } + discoveredSlots.set(cacheKey, found) - return found + return getStorageSlotFromLocator({ ...args, ...found }) } export type GetMinimalStateOverrideArgs = { @@ -312,29 +352,23 @@ export const getMinimalStateOverride = async ({ const stateDiff = ( await Promise.all([ balance < sellAmount - ? discoverSlot({ + ? discoverStorageSlot({ client, chainId: sellAsset.chainId, tokenAddress, kind: 'balance', owner: from, - }).then(({ slotNumber, layout }) => ({ - slot: getBalanceStorageSlot(from, slotNumber, layout), - value: overrideValue, - })) + }).then(slot => ({ slot, value: overrideValue })) : undefined, spender && allowance < sellAmount - ? discoverSlot({ + ? discoverStorageSlot({ client, chainId: sellAsset.chainId, tokenAddress, kind: 'allowance', owner: from, spender, - }).then(({ slotNumber, layout }) => ({ - slot: getAllowanceStorageSlot(from, spender, slotNumber, layout), - value: overrideValue, - })) + }).then(slot => ({ slot, value: overrideValue })) : undefined, ]) ).filter((diff): diff is { slot: Hex; value: Hex } => diff !== undefined) From d429fdacde72a95056ac70dbe56ab57b98ae8410 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:00:32 -0600 Subject: [PATCH 09/11] docs: reflect state-override estimation in api guide and swapper skill Co-Authored-By: Claude Fable 5 --- .claude/skills/swapper-integration/examples.md | 2 +- packages/public-api/docs/rest-api-guide.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/swapper-integration/examples.md b/.claude/skills/swapper-integration/examples.md index ddb2ae710b4..03dfe8a6f32 100644 --- a/.claude/skills/swapper-integration/examples.md +++ b/.claude/skills/swapper-integration/examples.md @@ -32,7 +32,7 @@ swapper resembles: - **SwapperMetadata union member + reads**: `ChainflipMetadata` (status polling by swap id), `NearIntentsMetadata` (deposit address for status + un-migrated exec), read via `getSwapMetadata` - **checkTradeStatus with tracker links**: Chainflip (native id -> scan link), Relay (origin-tx link), CowSwapper (order uid link) - return `swapperTxId`/`swapperTxLink` constructed next to the provider response - **HTTP service**: any `utils/[x]Service.ts` (`createCache` + `makeSwapperAxiosServiceMonadic`) -- **Tenderly simulation for rates** (unapproved sender still prices): PortalsSwapper, RelaySwapper, NearIntentsSwapper step data +- **State-override gas estimation** (unapproved/unfunded sender still estimates): pass `stateOverride: { sellAsset, sellAmountCryptoBaseUnit, spenderAddress }` to `getEvmNetworkFeeCryptoBaseUnit` in the quote arm (`spenderAddress` = the step's `allowanceContract`, `''` when no approval is involved) — see ButterSwap/Portals/Relay step data; `utils/evm/stateOverride.ts` handles slot discovery ## Registration example (one line per swapper) diff --git a/packages/public-api/docs/rest-api-guide.md b/packages/public-api/docs/rest-api-guide.md index b49208462b0..391f6c034b8 100644 --- a/packages/public-api/docs/rest-api-guide.md +++ b/packages/public-api/docs/rest-api-guide.md @@ -41,14 +41,14 @@ X-Partner-Code: your-partner-code - `swapperName` comes from the rate you chose in step 2. - `slippageTolerancePercentageDecimal` is optional; `accountNumber` is optional (defaults to `0`) and is needed for chains that derive addresses per account index (e.g. UTXO/Cosmos). -- The response includes a `quoteId` (needed for status tracking), an `approval` object (whether an ERC-20 approval is required and the approval tx to send first), and a `steps` array. Each step may include `transactionData` — a discriminated union on `type` (`evm`, `solana`, `utxo`, `cosmossdk_msg_send`, `cosmossdk_msg_deposit`) — describing exactly what to sign for that chain. +- The response includes a `quoteId` (needed for status tracking), an `approval` object (whether an ERC-20 approval is required and the spender to approve), and a `steps` array. Each step may include `transactionData` — a discriminated union on `type` (`evm`, `solana`, `utxo`, `cosmossdk_msg_send`, `cosmossdk_msg_deposit`) — describing exactly what to sign for that chain. - Quotes expire: honor the `expiresAt` timestamp (≈ 60s after issue). Request a fresh quote rather than submitting an expired one. ## 4. Execute the swap The API does **not** broadcast transactions — your application signs and broadcasts with the user's wallet: -1. If `approval.isRequired` is true and `approval.approvalTx` is present, send the approval transaction first and wait for it to confirm. +1. If `approval.isRequired` is true, send an ERC-20 `approve(approval.spender, amount)` transaction for the sell token first (amount ≥ the step's `sellAmountCryptoBaseUnit`) and wait for it to confirm. Quotes are issued before approval exists — network fees are estimated as if the approval were already in place. 2. For each step with `transactionData`, build, sign, and broadcast the transaction according to its `type` (EVM tx, Solana instructions, UTXO PSBT/deposit, or Cosmos message). 3. Capture the resulting transaction hash for status tracking. From f47a9f18abb7c0f107a41479bbd8df2b39a9d075 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:19:28 -0600 Subject: [PATCH 10/11] fix(swapper): walletless near-intents rates estimate from a placeholder sender A depositAddress fallback priced a self-transfer, which touches one balance slot instead of two and estimates low Co-Authored-By: Claude Fable 5 --- .../NearIntentsSwapper/utils/getNearIntentsStepData.ts | 6 +++--- packages/swapper/src/utils/evm/constants.ts | 4 ++++ packages/swapper/src/utils/evm/index.ts | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 packages/swapper/src/utils/evm/constants.ts diff --git a/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts b/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts index a628934e9c5..b5182ee004b 100644 --- a/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts +++ b/packages/swapper/src/swappers/NearIntentsSwapper/utils/getNearIntentsStepData.ts @@ -7,7 +7,7 @@ import { Err, Ok } from '@sniptt/monads' import type { StepDataArgs, SwapErrorRight, TxBuildData } from '../../../types' import { TradeQuoteError } from '../../../types' import { makeNetworkFeeEstimationFailedErr, makeSwapErrorRight } from '../../../utils' -import { getEvmNetworkFeeCryptoBaseUnit } from '../../../utils/evm' +import { EVM_PLACEHOLDER_ADDRESS, getEvmNetworkFeeCryptoBaseUnit } from '../../../utils/evm' import { isNativeEvmAsset } from '../../../utils/helpers' import type { SolanaComputeBudgetOptions } from '../../../utils/solana' import { @@ -75,14 +75,14 @@ export async function getNearIntentsStepData( } // The deposit is a plain (token) transfer with no approval involved - overridden - // estimation still prices an unfunded sender, so rates work walletless + // estimation still prices an unfunded sender, so rates work walletless via the placeholder if (type === 'rate') { const networkFeeCryptoBaseUnit = await (async () => { try { return await getEvmNetworkFeeCryptoBaseUnit({ adapter, transactionData, - from: from || depositAddress, + from: from || EVM_PLACEHOLDER_ADDRESS, supportsEIP1559, stateOverride, }) diff --git a/packages/swapper/src/utils/evm/constants.ts b/packages/swapper/src/utils/evm/constants.ts new file mode 100644 index 00000000000..410d4d2049e --- /dev/null +++ b/packages/swapper/src/utils/evm/constants.ts @@ -0,0 +1,4 @@ +// Placeholder sender for rate estimation without a connected wallet - the balance override funds +// it as needed, and it never collides with a provider deposit address, keeping the estimated +// transfer two-slot shaped (a self-transfer touches one balance slot and estimates low) +export const EVM_PLACEHOLDER_ADDRESS = '0x000000000000000000000000000000000000dEaD' diff --git a/packages/swapper/src/utils/evm/index.ts b/packages/swapper/src/utils/evm/index.ts index ce126f8fe12..3ab89ef4f61 100644 --- a/packages/swapper/src/utils/evm/index.ts +++ b/packages/swapper/src/utils/evm/index.ts @@ -1,3 +1,4 @@ +export * from './constants' export * from './getEvmExecutionContext' export * from './getEvmNetworkFeeCryptoBaseUnit' export * from './getEvmTransactionFees' From 553fa836ba63dd15d2f4bb812bf4f054851f4e27 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:19:28 -0600 Subject: [PATCH 11/11] chore(swapper): bump to 18.1.0 Co-Authored-By: Claude Fable 5 --- packages/swapper/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swapper/package.json b/packages/swapper/package.json index ba56a623252..ba0390edb5c 100644 --- a/packages/swapper/package.json +++ b/packages/swapper/package.json @@ -1,6 +1,6 @@ { "name": "@shapeshiftoss/swapper", - "version": "18.0.0", + "version": "18.1.0", "repository": "https://github.com/shapeshift/web", "license": "MIT", "type": "module",