From 4b424b2f10d493adf748991b88d4fdb1e0578f83 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:56:17 -0600 Subject: [PATCH 01/14] feat(swapper): per-swapper quote deadlines, honored by the api quote store Add a required deadline (epoch ms) to TradeQuote - the provider's own expiry where supplied, a conservative 60s default otherwise: - THORChain/MAYAChain: daemon quote expiry scoping the inbound address (longtail additionally bounded by the aggregator swapIn deadline) - NearIntents: requested refund deadline (deposits must be mined before it; the response deadline is the later address-inactive bound) - Chainflip: 6h against the ~24h deposit channel life - CoW validTo, Bebop expiry, Across quoteExpiryTimestamp, Stonfi tradeStartDeadline, Avnu expiry, Portals validated-order expiry - Relay/Debridge/ButterSwap/ArbitrumBridge/BobGateway/0x/Cetus/Sunio: 60s fallback public-api propagates it as the quote expiresAt (previously a fixed now+60s) and the quote store keeps unsubmitted quotes until deadline plus a 5min binding grace instead of a flat 15min TTL, so status binding tracks real quote validity. Swapper bumped to 19.0.0 (required field on TradeQuote). Co-Authored-By: Claude Fable 5 --- packages/public-api/docs/rest-api-guide.md | 2 +- packages/public-api/src/lib/quoteStore.test.ts | 15 +++++++++------ packages/public-api/src/lib/quoteStore.ts | 5 +++-- packages/public-api/src/routes/quote/getQuote.ts | 4 ++-- packages/public-api/src/routes/quote/types.ts | 6 +++++- packages/swapper/package.json | 2 +- .../AcrossSwapper/getTradeQuote/getTradeQuote.ts | 3 ++- .../AcrossSwapper/utils/getAcrossTradeContext.ts | 5 ++++- .../getTradeQuote/getTradeQuote.ts | 2 ++ .../AvnuSwapper/swapperApi/getTradeQuote.ts | 4 +++- .../AvnuSwapper/utils/getAvnuTradeContext.ts | 5 ++++- .../getBebopSolanaTradeQuote.ts | 2 ++ .../getBebopTradeQuote/getBebopTradeQuote.ts | 3 ++- .../BebopSwapper/utils/getBebopTradeContext.ts | 3 +++ .../swapperApi/getTradeQuote.ts | 2 ++ .../ButterSwap/swapperApi/getTradeQuote.ts | 2 ++ .../CetusSwapper/swapperApi/getTradeQuote.ts | 2 ++ .../src/swappers/ChainflipSwapper/constants.ts | 4 ++++ .../ChainflipSwapper/swapperApi/getTradeQuote.ts | 2 ++ .../getCowSwapTradeQuote/getCowSwapTradeQuote.ts | 2 ++ .../getTradeQuote/getTradeQuote.ts | 2 ++ .../swapperApi/getTradeQuote.ts | 6 ++++++ .../getPortalsTradeQuote/getPortalsTradeQuote.ts | 16 +++++++++++++++- .../RelaySwapper/getTradeQuote/getTradeQuote.ts | 2 ++ .../StonfiSwapper/swapperApi/getTradeQuote.ts | 2 ++ .../getSunioTradeQuote/getSunioTradeQuote.ts | 2 ++ .../getTradeQuote/getTradeQuote.test.ts | 2 ++ .../ThorchainSwapper/utils/getLongtailQuote.ts | 2 ++ .../getZrxTradeQuote/getZrxTradeQuote.ts | 3 ++- packages/swapper/src/types.ts | 4 ++++ packages/swapper/src/utils/helpers.ts | 9 +++++++++ .../src/utils/test-data/setupSwapQuote.ts | 1 + .../src/utils/thorchain/getThorL1TradeQuote.ts | 3 +++ src/state/apis/swapper/helpers/testData.ts | 3 +++ 34 files changed, 112 insertions(+), 20 deletions(-) diff --git a/packages/public-api/docs/rest-api-guide.md b/packages/public-api/docs/rest-api-guide.md index 33e442c6913..ba198410d91 100644 --- a/packages/public-api/docs/rest-api-guide.md +++ b/packages/public-api/docs/rest-api-guide.md @@ -44,7 +44,7 @@ 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, the spender, and ready-to-sign `approvalTxs` when it is), 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. +- Quotes expire: honor the `expiresAt` timestamp — it reflects the swapper's own quote deadline (e.g. THORChain inbound addresses rotate, deposit-address swappers deactivate their channels; deadline-less providers get a conservative 60s). **Never sign or broadcast after `expiresAt`** — for deposit-style swappers funds sent late can be lost. Request a fresh quote instead. ## 4. Execute the swap diff --git a/packages/public-api/src/lib/quoteStore.test.ts b/packages/public-api/src/lib/quoteStore.test.ts index ae8f5dc28ad..9dd5c5606ff 100644 --- a/packages/public-api/src/lib/quoteStore.test.ts +++ b/packages/public-api/src/lib/quoteStore.test.ts @@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StoredQuote } from './quoteStore' import { QuoteStore } from './quoteStore' +// The store treats expiresAt as caller-provided data (swapper deadline + bind grace in prod) +const QUOTE_TTL_MS = 15 * 60 * 1000 + const makeQuote = (overrides: Partial = {}): StoredQuote => ({ quoteId: 'quote-1', swapperName: '0x', @@ -18,7 +21,7 @@ const makeQuote = (overrides: Partial = {}): StoredQuote => ({ sendAddress: '0xsender', rate: '1800', createdAt: Date.now(), - expiresAt: Date.now() + QuoteStore.QUOTE_TTL_MS, + expiresAt: Date.now() + QUOTE_TTL_MS, metadata: { stepIndex: 0, quoteId: 'quote-1', @@ -56,14 +59,14 @@ describe('QuoteStore', () => { it('returns quote within QUOTE_TTL_MS', () => { const quote = makeQuote() store.set(quote.quoteId, quote) - vi.advanceTimersByTime(QuoteStore.QUOTE_TTL_MS - 1) + vi.advanceTimersByTime(QUOTE_TTL_MS - 1) expect(store.get(quote.quoteId)).toBeDefined() }) it('expires quote after QUOTE_TTL_MS', () => { const quote = makeQuote() store.set(quote.quoteId, quote) - vi.advanceTimersByTime(QuoteStore.QUOTE_TTL_MS + 1) + vi.advanceTimersByTime(QUOTE_TTL_MS + 1) expect(store.get(quote.quoteId)).toBeUndefined() }) }) @@ -74,13 +77,13 @@ describe('QuoteStore', () => { const quote = makeQuote({ txHash: '0xabc', registeredAt: now, - expiresAt: now + QuoteStore.QUOTE_TTL_MS, + expiresAt: now + QUOTE_TTL_MS, status: 'submitted', }) store.set(quote.quoteId, quote) // past QUOTE_TTL but within EXECUTION_TTL - vi.advanceTimersByTime(QuoteStore.QUOTE_TTL_MS + 1) + vi.advanceTimersByTime(QUOTE_TTL_MS + 1) expect(store.get(quote.quoteId)).toBeDefined() }) @@ -158,7 +161,7 @@ describe('QuoteStore', () => { store.set(quote.quoteId, quote) expect(store.size()).toBe(1) - vi.advanceTimersByTime(QuoteStore.QUOTE_TTL_MS + QuoteStore.CLEANUP_INTERVAL_MS + 1) + vi.advanceTimersByTime(QUOTE_TTL_MS + QuoteStore.CLEANUP_INTERVAL_MS + 1) expect(store.size()).toBe(0) }) diff --git a/packages/public-api/src/lib/quoteStore.ts b/packages/public-api/src/lib/quoteStore.ts index ac399ae1dbf..42634498a2c 100644 --- a/packages/public-api/src/lib/quoteStore.ts +++ b/packages/public-api/src/lib/quoteStore.ts @@ -25,7 +25,8 @@ export type StoredQuote = { /** * In-memory quote store with dual TTL: - * - 15 minutes for unsubmitted quotes (quote validity window) + * - unsubmitted quotes live until the swapper quote deadline plus a binding grace window + * (a tx broadcast just before the deadline must still be able to bind for status tracking) * - 60 minutes after txHash is bound (execution tracking window) * * Automatic sweep of expired entries every 60 seconds. @@ -36,7 +37,7 @@ export class QuoteStore { private txHashIndex = new Map() private cleanupInterval: ReturnType - static readonly QUOTE_TTL_MS = 15 * 60 * 1000 + static readonly BIND_GRACE_MS = 5 * 60 * 1000 static readonly EXECUTION_TTL_MS = 60 * 60 * 1000 static readonly CLEANUP_INTERVAL_MS = 60 * 1000 static readonly MAX_QUOTES = 10000 diff --git a/packages/public-api/src/routes/quote/getQuote.ts b/packages/public-api/src/routes/quote/getQuote.ts index 0f856b6459b..dfce56f3ea6 100644 --- a/packages/public-api/src/routes/quote/getQuote.ts +++ b/packages/public-api/src/routes/quote/getQuote.ts @@ -193,7 +193,7 @@ export const getQuote = async (req: Request, res: Response): Promise => { partnerAddress: req.affiliateInfo?.partnerAddress, partnerCode: req.affiliateInfo?.partnerCode, createdAt: now, - expiresAt: now + QuoteStore.QUOTE_TTL_MS, + expiresAt: quote.deadline + QuoteStore.BIND_GRACE_MS, metadata: buildSwapMetadata(step, { stepIndex: 0, quoteId }), status: 'pending', }) @@ -207,7 +207,7 @@ export const getQuote = async (req: Request, res: Response): Promise => { networkFeeCryptoBaseUnit: step.feeData.networkFeeCryptoBaseUnit, steps: quote.steps.map(transformQuoteStep), approval: await buildApprovalInfo(step, sendAddress), - expiresAt: now + 60_000, + expiresAt: quote.deadline, } res.json(response) diff --git a/packages/public-api/src/routes/quote/types.ts b/packages/public-api/src/routes/quote/types.ts index 34a879bb01e..042007a4deb 100644 --- a/packages/public-api/src/routes/quote/types.ts +++ b/packages/public-api/src/routes/quote/types.ts @@ -154,7 +154,11 @@ export const QuoteResponseSchema = registry.register( networkFeeCryptoBaseUnit: z.string().optional().openapi({ example: '23000' }), approval: ApprovalInfoSchema, steps: z.array(QuoteStepSchema), - expiresAt: z.number(), + expiresAt: z.number().openapi({ + example: 1754265600000, + description: + "Epoch ms after which the quote must not be executed - the swapper's own deadline (inbound address rotation, deposit channel expiry, order validity). Broadcasting after it risks failed swaps or, for deposit-style swappers, lost funds. Request a fresh quote instead.", + }), }), ) diff --git a/packages/swapper/package.json b/packages/swapper/package.json index af0e9695416..ea8dc74b2d8 100644 --- a/packages/swapper/package.json +++ b/packages/swapper/package.json @@ -1,6 +1,6 @@ { "name": "@shapeshiftoss/swapper", - "version": "18.1.1", + "version": "19.0.0", "repository": "https://github.com/shapeshift/web", "license": "MIT", "type": "module", diff --git a/packages/swapper/src/swappers/AcrossSwapper/getTradeQuote/getTradeQuote.ts b/packages/swapper/src/swappers/AcrossSwapper/getTradeQuote/getTradeQuote.ts index 3ac1a042e38..0f56082de2e 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/getTradeQuote/getTradeQuote.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/getTradeQuote/getTradeQuote.ts @@ -25,7 +25,7 @@ export const getTradeQuote = async ( }) if (maybeContext.isErr()) return Err(maybeContext.unwrapErr()) - const { tradeCommon, stepCommon, protocolFees, stepDataArgs } = maybeContext.unwrap() + const { tradeCommon, stepCommon, protocolFees, stepDataArgs, deadline } = maybeContext.unwrap() const maybeStepData = await getAcrossStepData({ ...stepDataArgs, type: 'quote', input }) @@ -35,6 +35,7 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + deadline, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts index 6610651f36b..32250709a54 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts @@ -22,7 +22,7 @@ import type { import { SwapperName, TradeQuoteError } from '../../../types' import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' import { buildAffiliateFee } from '../../../utils/affiliateFee' -import { getTreasuryAddressFromChainId } from '../../../utils/helpers' +import { getTreasuryAddressFromChainId, normalizeEpochToMs } from '../../../utils/helpers' import { acrossChainIdToChainId, acrossErrorCodeToTradeQuoteError } from '../constant' import { fetchAcrossTrade } from './fetchAcrossTrade' import type { GetAcrossStepDataArgs } from './getAcrossStepData' @@ -35,6 +35,8 @@ type AcrossTradeContext = { stepCommon: Omit protocolFees: QuoteFeeData['protocolFees'] stepDataArgs: Omit + // Provider quote expiry (epoch ms), consumed by the quote arm only + deadline: number } export const getAcrossTradeContext = async ({ @@ -246,5 +248,6 @@ export const getAcrossTradeContext = async ({ fallbackNetworkFeeCryptoBaseUnit: quote.fees.originGas.amount, deps, }, + deadline: normalizeEpochToMs(quote.quoteExpiryTimestamp), }) } diff --git a/packages/swapper/src/swappers/ArbitrumBridgeSwapper/getTradeQuote/getTradeQuote.ts b/packages/swapper/src/swappers/ArbitrumBridgeSwapper/getTradeQuote/getTradeQuote.ts index cce7cadd7bc..d7ebcde68dd 100644 --- a/packages/swapper/src/swappers/ArbitrumBridgeSwapper/getTradeQuote/getTradeQuote.ts +++ b/packages/swapper/src/swappers/ArbitrumBridgeSwapper/getTradeQuote/getTradeQuote.ts @@ -9,6 +9,7 @@ import type { TradeQuote, } from '../../../types' import { assertQuoteAddresses } from '../../../utils' +import { FALLBACK_QUOTE_DEADLINE_MS } from '../../../utils/helpers' import type { ArbitrumBridgeTradeQuoteInput } from '../types' import { getArbitrumBridgeStepData } from '../utils/getArbitrumBridgeStepData' import { getArbitrumBridgeTradeContext } from '../utils/getArbitrumBridgeTradeContext' @@ -53,6 +54,7 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote', + deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeQuote.ts index df7059156c8..9a862c3ed91 100644 --- a/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/AvnuSwapper/swapperApi/getTradeQuote.ts @@ -5,6 +5,7 @@ import { validateAndParseAddress } from 'starknet' import type { SwapErrorRight, SwapperDeps, TradeQuote } from '../../../types' import { TradeQuoteError } from '../../../types' import { assertQuoteAddresses, makeSwapErrorRight } from '../../../utils' +import { FALLBACK_QUOTE_DEADLINE_MS } from '../../../utils/helpers' import type { AvnuTradeQuoteInput } from '../types' import { getAvnuTradeContext } from '../utils/getAvnuTradeContext' @@ -27,7 +28,7 @@ export const getTradeQuote = async ( const maybeContext = await getAvnuTradeContext({ input, takerAddress: normalizedSendAddress }) if (maybeContext.isErr()) return Err(maybeContext.unwrapErr()) - const { tradeCommon, stepCommon, protocolFees, quoteId, sellTokenAddress } = + const { tradeCommon, stepCommon, protocolFees, quoteId, sellTokenAddress, deadline } = maybeContext.unwrap() const adapter = deps.assertGetStarknetChainAdapter(sellAsset.chainId) @@ -46,6 +47,7 @@ export const getTradeQuote = async ( ...tradeCommon, receiveAddress: normalizedReceiveAddress, quoteOrRate: 'quote', + deadline: deadline ?? Date.now() + FALLBACK_QUOTE_DEADLINE_MS, steps: [ { ...stepCommon, diff --git a/packages/swapper/src/swappers/AvnuSwapper/utils/getAvnuTradeContext.ts b/packages/swapper/src/swappers/AvnuSwapper/utils/getAvnuTradeContext.ts index c72a436d3c4..683af3d45c6 100644 --- a/packages/swapper/src/swappers/AvnuSwapper/utils/getAvnuTradeContext.ts +++ b/packages/swapper/src/swappers/AvnuSwapper/utils/getAvnuTradeContext.ts @@ -17,7 +17,7 @@ import type { import { SwapperName, TradeQuoteError } from '../../../types' import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' import { buildAffiliateFee } from '../../../utils/affiliateFee' -import { getTreasuryAddressFromChainId } from '../../../utils/helpers' +import { getTreasuryAddressFromChainId, normalizeEpochToMs } from '../../../utils/helpers' import { assertValidTrade, getTokenAddress } from './helpers' type AvnuTradeContext = { @@ -26,6 +26,8 @@ type AvnuTradeContext = { protocolFees: QuoteFeeData['protocolFees'] quoteId: string sellTokenAddress: string + // Provider quote expiry (epoch ms) when supplied, consumed by the quote arm only + deadline: number | undefined } export const getAvnuTradeContext = async ({ @@ -135,6 +137,7 @@ export const getAvnuTradeContext = async ({ protocolFees, quoteId: bestQuote.quoteId, sellTokenAddress, + deadline: bestQuote.expiry ? normalizeEpochToMs(bestQuote.expiry) : undefined, }) } catch (error) { return Err( diff --git a/packages/swapper/src/swappers/BebopSwapper/getBebopSolanaTradeQuote/getBebopSolanaTradeQuote.ts b/packages/swapper/src/swappers/BebopSwapper/getBebopSolanaTradeQuote/getBebopSolanaTradeQuote.ts index fb69d216c16..11781016af8 100644 --- a/packages/swapper/src/swappers/BebopSwapper/getBebopSolanaTradeQuote/getBebopSolanaTradeQuote.ts +++ b/packages/swapper/src/swappers/BebopSwapper/getBebopSolanaTradeQuote/getBebopSolanaTradeQuote.ts @@ -44,6 +44,8 @@ export const getBebopSolanaTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote', + // The sealed multi-signer tx is blockhash-pinned, so the provider expiry governs + deadline: response.expiry * 1000, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/BebopSwapper/getBebopTradeQuote/getBebopTradeQuote.ts b/packages/swapper/src/swappers/BebopSwapper/getBebopTradeQuote/getBebopTradeQuote.ts index 015a0e8ddd5..be8dc125b32 100644 --- a/packages/swapper/src/swappers/BebopSwapper/getBebopTradeQuote/getBebopTradeQuote.ts +++ b/packages/swapper/src/swappers/BebopSwapper/getBebopTradeQuote/getBebopTradeQuote.ts @@ -25,7 +25,7 @@ export const getBebopTradeQuote = async ( }) if (maybeContext.isErr()) return Err(maybeContext.unwrapErr()) - const { tradeCommon, stepCommon, stepDataArgs } = maybeContext.unwrap() + const { tradeCommon, stepCommon, stepDataArgs, deadline } = maybeContext.unwrap() const maybeStepData = await getBebopStepData({ ...stepDataArgs, type: 'quote', input }) @@ -35,6 +35,7 @@ export const getBebopTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote', + deadline, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts index 79a10c295bc..2de30ad2387 100644 --- a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts +++ b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts @@ -25,6 +25,8 @@ type BebopTradeContext = { tradeCommon: TradeCommon stepCommon: Omit stepDataArgs: Omit + // Provider quote expiry (epoch ms), consumed by the quote arm only + deadline: number } export const getBebopTradeContext = async ({ @@ -119,5 +121,6 @@ export const getBebopTradeContext = async ({ from, deps, }, + deadline: quote.expiry * 1000, }) } diff --git a/packages/swapper/src/swappers/BobGatewaySwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/BobGatewaySwapper/swapperApi/getTradeQuote.ts index 6ab875aa181..8040097a780 100644 --- a/packages/swapper/src/swappers/BobGatewaySwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/BobGatewaySwapper/swapperApi/getTradeQuote.ts @@ -4,6 +4,7 @@ import { Err, Ok } from '@sniptt/monads' import type { SwapErrorRight, SwapperDeps, TradeQuote } from '../../../types' import { assertQuoteAddresses } from '../../../utils' +import { FALLBACK_QUOTE_DEADLINE_MS } from '../../../utils/helpers' import type { BobGatewayTradeQuoteInput } from '../types' import { getBobGatewayStepData } from '../utils/getBobGatewayStepData' import { getBobGatewayTradeContext } from '../utils/getBobGatewayTradeContext' @@ -46,6 +47,7 @@ export const getBobGatewayTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote', + deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeQuote.ts index a27d834db25..4598a448b89 100644 --- a/packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeQuote.ts @@ -7,6 +7,7 @@ import { getDefaultSlippageDecimalPercentageForSwapper } from '../../../constant import type { SwapErrorRight, SwapperDeps, TradeQuote } from '../../../types' import { SwapperName, TradeQuoteError } from '../../../types' import { assertQuoteAddresses, makeSwapErrorRight } from '../../../utils' +import { FALLBACK_QUOTE_DEADLINE_MS } from '../../../utils/helpers' import type { ButterSwapTradeQuoteInput } from '../types' import { getButterSwapStepData } from '../utils/getButterSwapStepData' import { getButterSwapTradeContext } from '../utils/getButterSwapTradeContext' @@ -82,6 +83,7 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeQuote.ts index 11f1e05d1bb..3ff148fa5f0 100644 --- a/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/CetusSwapper/swapperApi/getTradeQuote.ts @@ -3,6 +3,7 @@ import { Err, Ok } from '@sniptt/monads' import type { SwapErrorRight, SwapperDeps, TradeQuote } from '../../../types' import { assertQuoteAddresses } from '../../../utils' +import { FALLBACK_QUOTE_DEADLINE_MS } from '../../../utils/helpers' import type { CetusTradeQuoteInput } from '../types' import { getCetusStepData } from './getCetusStepData' import { getCetusTradeContext } from './getCetusTradeContext' @@ -36,6 +37,7 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/ChainflipSwapper/constants.ts b/packages/swapper/src/swappers/ChainflipSwapper/constants.ts index cd8f52f1b8b..531b7c7792d 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/constants.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/constants.ts @@ -25,6 +25,10 @@ import { ChainflipNetwork } from './types' export const CHAINFLIP_REGULAR_QUOTE = 'regular' as const export const CHAINFLIP_DCA_QUOTE = 'dca' as const +// Deposit channels stay open ~24h from issuance (sourceExpiryBlock is a source-chain height we +// can't cheaply convert to time) - 6h keeps a 4x margin against depositing to an expired channel +export const CHAINFLIP_CHANNEL_DEADLINE_MS = 6 * 60 * 60 * 1000 + export const ChainflipSupportedChainIds = [ KnownChainIds.EthereumMainnet, KnownChainIds.ArbitrumMainnet, diff --git a/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts index b0b9601ef8d..6e4f2525806 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts @@ -5,6 +5,7 @@ import type { AxiosError } from 'axios' import type { SwapErrorRight, SwapperDeps, TradeQuote } from '../../../types' import { TradeQuoteError } from '../../../types' import { assertQuoteAddresses, makeSwapErrorRight } from '../../../utils' +import { CHAINFLIP_CHANNEL_DEADLINE_MS } from '../constants' import type { ChainflipMetadata, ChainflipTradeQuoteInput } from '../types' import { getChainflipStepData } from '../utils/getChainflipStepData' import { getChainflipTradeContexts } from '../utils/getChainflipTradeContexts' @@ -97,6 +98,7 @@ export const getTradeQuote = async ( tradeQuotes.push({ ...tradeCommon, quoteOrRate: 'quote' as const, + deadline: Date.now() + CHAINFLIP_CHANNEL_DEADLINE_MS, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts b/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts index feb66c993ad..b07176ea0b4 100644 --- a/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts +++ b/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts @@ -41,6 +41,8 @@ export const getCowSwapTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + // The signed order is submittable until its validTo + deadline: stepDataArgs.cowswapQuoteResponse.quote.validTo * 1000, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/DebridgeSwapper/getTradeQuote/getTradeQuote.ts b/packages/swapper/src/swappers/DebridgeSwapper/getTradeQuote/getTradeQuote.ts index 051801121e9..45e32dfd34d 100644 --- a/packages/swapper/src/swappers/DebridgeSwapper/getTradeQuote/getTradeQuote.ts +++ b/packages/swapper/src/swappers/DebridgeSwapper/getTradeQuote/getTradeQuote.ts @@ -3,6 +3,7 @@ import { Err, Ok } from '@sniptt/monads' import type { SwapErrorRight, SwapperDeps, TradeQuote } from '../../../types' import { assertQuoteAddresses } from '../../../utils' +import { FALLBACK_QUOTE_DEADLINE_MS } from '../../../utils/helpers' import { getDebridgeStepData } from '../utils/getDebridgeStepData' import { getDebridgeTradeContext } from '../utils/getDebridgeTradeContext' import type { DebridgeTradeQuoteInput } from '../utils/types' @@ -34,6 +35,7 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts index f2f6d07a805..0730311017f 100644 --- a/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts @@ -71,6 +71,12 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + // Deposits must be mined before the requested refund deadline to execute as quoted; the + // response deadline is the later bound where the deposit address goes inactive entirely + deadline: Math.min( + Date.parse(quoteRequest.deadline), + quote.deadline ? Date.parse(quote.deadline) : Infinity, + ), receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts index b4278703e39..c4412d0f9f6 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts @@ -9,7 +9,11 @@ import { makeSwapErrorRight, makeTradeStepBuildFailedErr, } from '../../../utils' -import { getTreasuryAddressFromChainId } from '../../../utils/helpers' +import { + FALLBACK_QUOTE_DEADLINE_MS, + getTreasuryAddressFromChainId, + normalizeEpochToMs, +} from '../../../utils/helpers' import type { PortalsTradeQuoteInput } from '../types' import { fetchPortalsTradeOrder, PortalsError } from '../utils/fetchPortalsTradeOrder' import { getPortalsStepData } from '../utils/getPortalsStepData' @@ -158,9 +162,19 @@ export const getPortalsTradeQuote = async ( if (maybeStepData.isErr()) return Err(maybeStepData.unwrapErr()) const { transactionData, networkFeeCryptoBaseUnit } = maybeStepData.unwrap() + // Validated orders carry an expiry; the validate:false fallback does not + const expiryMs = (() => { + if (!orderContext.expiry) return undefined + const numeric = Number(orderContext.expiry) + if (Number.isFinite(numeric)) return normalizeEpochToMs(numeric) + const parsed = Date.parse(orderContext.expiry) + return Number.isNaN(parsed) ? undefined : parsed + })() + const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + deadline: expiryMs ?? Date.now() + FALLBACK_QUOTE_DEADLINE_MS, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/RelaySwapper/getTradeQuote/getTradeQuote.ts b/packages/swapper/src/swappers/RelaySwapper/getTradeQuote/getTradeQuote.ts index 527358dd62c..2632b291c52 100644 --- a/packages/swapper/src/swappers/RelaySwapper/getTradeQuote/getTradeQuote.ts +++ b/packages/swapper/src/swappers/RelaySwapper/getTradeQuote/getTradeQuote.ts @@ -10,6 +10,7 @@ import type { TradeQuoteStep, } from '../../../types' import { assertQuoteAddresses } from '../../../utils' +import { FALLBACK_QUOTE_DEADLINE_MS } from '../../../utils/helpers' import type { chainIdToRelayChainId as relayChainMapImplementation } from '../constant' import { getRelayStepData } from '../utils/getRelayStepData' import { getRelayTradeContext } from '../utils/getRelayTradeContext' @@ -74,6 +75,7 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS, receiveAddress, steps, } diff --git a/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts index 5c44da40b80..4d465134cad 100644 --- a/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts @@ -33,6 +33,8 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + // The provider caps when the trade may start + deadline: stonfiTransactionData.tradeStartDeadline * 1000, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/SunioSwapper/getSunioTradeQuote/getSunioTradeQuote.ts b/packages/swapper/src/swappers/SunioSwapper/getSunioTradeQuote/getSunioTradeQuote.ts index 8cc24518bc3..1e98be401a6 100644 --- a/packages/swapper/src/swappers/SunioSwapper/getSunioTradeQuote/getSunioTradeQuote.ts +++ b/packages/swapper/src/swappers/SunioSwapper/getSunioTradeQuote/getSunioTradeQuote.ts @@ -3,6 +3,7 @@ import { Err, Ok } from '@sniptt/monads' import type { SwapErrorRight, SwapperDeps, TradeQuote } from '../../../types' import { assertQuoteAddresses } from '../../../utils' +import { FALLBACK_QUOTE_DEADLINE_MS } from '../../../utils/helpers' import type { SunioTradeQuoteInput } from '../types' import { getSunioStepData } from '../utils/getSunioStepData' import { getSunioTradeContext } from '../utils/getSunioTradeContext' @@ -34,6 +35,7 @@ export const getSunioTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS, receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts b/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts index e912c1c1324..2f52564cf1a 100644 --- a/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts +++ b/packages/swapper/src/swappers/ThorchainSwapper/getTradeQuote/getTradeQuote.test.ts @@ -89,6 +89,7 @@ const expectedQuoteResponse: Omit[] = [ tradeType: TradeType.L1ToL1, slippageTolerancePercentageDecimal: '0.04357', expiry: 1713710808, + deadline: 1713710808000, swapperName: SwapperName.Thorchain, steps: [ { @@ -139,6 +140,7 @@ const expectedQuoteResponse: Omit[] = [ tradeType: TradeType.L1ToL1, slippageTolerancePercentageDecimal: undefined, expiry: 1713710808, + deadline: 1713710808000, swapperName: SwapperName.Thorchain, steps: [ { diff --git a/packages/swapper/src/swappers/ThorchainSwapper/utils/getLongtailQuote.ts b/packages/swapper/src/swappers/ThorchainSwapper/utils/getLongtailQuote.ts index 66609e70299..3e77c4d4e27 100644 --- a/packages/swapper/src/swappers/ThorchainSwapper/utils/getLongtailQuote.ts +++ b/packages/swapper/src/swappers/ThorchainSwapper/utils/getLongtailQuote.ts @@ -121,6 +121,8 @@ export const getLongtailToL1Quote = async ( return Ok({ ...quote, + // The aggregator swapIn deadline is tighter than the daemon quote expiry + deadline: Math.min(quote.deadline, Number(deadline) * 1000), data: data ?? quote.data, // This logic will need to be updated to support multi-hop, if that's ever implemented for THORChain steps: quote.steps.map(s => ({ diff --git a/packages/swapper/src/swappers/ZrxSwapper/getZrxTradeQuote/getZrxTradeQuote.ts b/packages/swapper/src/swappers/ZrxSwapper/getZrxTradeQuote/getZrxTradeQuote.ts index 9e0f6239d7a..d28c01dfcfd 100644 --- a/packages/swapper/src/swappers/ZrxSwapper/getZrxTradeQuote/getZrxTradeQuote.ts +++ b/packages/swapper/src/swappers/ZrxSwapper/getZrxTradeQuote/getZrxTradeQuote.ts @@ -10,7 +10,7 @@ import type { } from '../../../types' import { SwapperName, TradeQuoteError } from '../../../types' import { assertQuoteAddresses, makeSwapErrorRight } from '../../../utils' -import { isNativeEvmAsset } from '../../../utils/helpers' +import { FALLBACK_QUOTE_DEADLINE_MS, isNativeEvmAsset } from '../../../utils/helpers' import type { ZrxTradeQuoteInput } from '../types' import { fetchZrxQuote } from '../utils/fetchFromZrx' import { getZrxStepData } from '../utils/getZrxStepData' @@ -112,6 +112,7 @@ export const getZrxTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS, receiveAddress, steps: [ { diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index 4c4c2f1a870..e4d5e7d115b 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -584,6 +584,10 @@ export type TradeQuote = TradeQuoteBase & { } & { quoteOrRate: 'quote' receiveAddress: string + // Epoch ms after which the quote is no longer safe to execute - the provider's own expiry + // where supplied (e.g. thornode quote expiry scoping the inbound address), a conservative + // default otherwise + deadline: number } export type MultiHopTradeQuote = TradeQuote & { diff --git a/packages/swapper/src/utils/helpers.ts b/packages/swapper/src/utils/helpers.ts index 6dc89ad2b27..a2724f0c878 100644 --- a/packages/swapper/src/utils/helpers.ts +++ b/packages/swapper/src/utils/helpers.ts @@ -34,6 +34,15 @@ import { isTreasuryChainId, } from '@shapeshiftoss/utils' +// Quote deadline for providers that don't supply their own expiry - short enough that priced +// amounts stay honest, long enough to review and sign +export const FALLBACK_QUOTE_DEADLINE_MS = 60_000 + +// For provider timestamps of undocumented resolution - unix seconds land ~2e9, ms ~2e12, so the +// boundary is unambiguous for any realistic date +export const normalizeEpochToMs = (value: number): number => + value < 1e12 ? value * 1000 : value + export const isNativeEvmAsset = (assetId: AssetId): boolean => { const { chainId } = fromAssetId(assetId) switch (chainId) { diff --git a/packages/swapper/src/utils/test-data/setupSwapQuote.ts b/packages/swapper/src/utils/test-data/setupSwapQuote.ts index b7371518d77..88eb0582345 100644 --- a/packages/swapper/src/utils/test-data/setupSwapQuote.ts +++ b/packages/swapper/src/utils/test-data/setupSwapQuote.ts @@ -12,6 +12,7 @@ export const setupQuote = () => { const tradeQuote: TradeQuote = { quoteOrRate: 'quote', id: 'foobar', + deadline: Date.now() + 60_000, receiveAddress: '0x1234', affiliateBps: '0', slippageTolerancePercentageDecimal: '0', diff --git a/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts b/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts index 09acee9a669..30981d85f50 100644 --- a/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts +++ b/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts @@ -86,6 +86,9 @@ export const getThorL1TradeQuote = async ( const quote: ThorTradeQuote = { ...tradeCommon, quoteOrRate: 'quote', + // The daemon quote expiry scopes the inbound address - sending funds after it risks a + // rotated vault + deadline: tradeCommon.expiry * 1000, memo, receiveAddress, data, diff --git a/src/state/apis/swapper/helpers/testData.ts b/src/state/apis/swapper/helpers/testData.ts index 32b01291968..21cff17e7ba 100644 --- a/src/state/apis/swapper/helpers/testData.ts +++ b/src/state/apis/swapper/helpers/testData.ts @@ -4,6 +4,7 @@ import { SwapperName } from '@shapeshiftoss/swapper' export const thorQuote: TradeQuote = { id: 'f4636745-bf07-4799-9efb-c056691b652f', quoteOrRate: 'quote', + deadline: 1800000000000, rate: '39.23942597524024759752', receiveAddress: '0x31b5c4ab7d020de87901c736535aeb4769806947', affiliateBps: '30', @@ -89,6 +90,7 @@ export const thorQuote: TradeQuote = { export const cowQuote: TradeQuote = { id: '220858750', quoteOrRate: 'quote', + deadline: 1800000000000, rate: '51.86127422365727736757', affiliateBps: '0', receiveAddress: '0x31b5c4ab7d020de87901c736535aeb4769806947', @@ -158,6 +160,7 @@ export const cowQuote: TradeQuote = { export const zrxQuote: TradeQuote = { id: 'dfb5f2e6-9cb9-4865-9ef5-6b54d203affa', quoteOrRate: 'quote', + deadline: 1800000000000, rate: '51.603817692372651273', affiliateBps: '0', receiveAddress: '0x31b5c4ab7d020de87901c736535aeb4769806947', From 6aea1b51dad313028f85294984ce88d15bbedaee Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:29:22 -0600 Subject: [PATCH 02/14] fix(swapper): harden quote deadlines per review - 502 instead of storing/returning a quote whose deadline already passed - NearIntents: treat an unparsable response deadline as absent instead of poisoning Math.min with NaN - normalize provider-sourced expiries (thor daemon, bebop evm/solana, stonfi) through normalizeEpochToMs so a provider switching seconds to ms can never inflate a deadline; self-constructed and protocol-typed u32 values keep the explicit *1000 - derive test fixture deadlines from the clock instead of a fixed epoch that expires Jan 2027 Co-Authored-By: Claude Fable 5 --- packages/public-api/src/routes/quote/getQuote.ts | 7 +++++++ .../getBebopSolanaTradeQuote/getBebopSolanaTradeQuote.ts | 3 ++- .../swappers/BebopSwapper/utils/getBebopTradeContext.ts | 4 ++-- .../NearIntentsSwapper/swapperApi/getTradeQuote.ts | 8 +++++--- .../swappers/StonfiSwapper/swapperApi/getTradeQuote.ts | 3 ++- .../swapper/src/utils/thorchain/getThorL1TradeQuote.ts | 3 ++- src/state/apis/swapper/helpers/testData.ts | 8 +++++--- 7 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/public-api/src/routes/quote/getQuote.ts b/packages/public-api/src/routes/quote/getQuote.ts index dfce56f3ea6..2d94b09cf88 100644 --- a/packages/public-api/src/routes/quote/getQuote.ts +++ b/packages/public-api/src/routes/quote/getQuote.ts @@ -184,6 +184,13 @@ export const getQuote = async (req: Request, res: Response): Promise => { rate: quote.rate, } + if (!Number.isFinite(quote.deadline) || quote.deadline <= now) { + res.status(502).json({ + error: 'Swapper quote expired before it could be returned; request a new quote', + } satisfies ErrorResponse) + return + } + quoteStore.set(quoteId, { ...baseQuote, sellAssetId: sellAsset.assetId, diff --git a/packages/swapper/src/swappers/BebopSwapper/getBebopSolanaTradeQuote/getBebopSolanaTradeQuote.ts b/packages/swapper/src/swappers/BebopSwapper/getBebopSolanaTradeQuote/getBebopSolanaTradeQuote.ts index 11781016af8..b27fdbd7384 100644 --- a/packages/swapper/src/swappers/BebopSwapper/getBebopSolanaTradeQuote/getBebopSolanaTradeQuote.ts +++ b/packages/swapper/src/swappers/BebopSwapper/getBebopSolanaTradeQuote/getBebopSolanaTradeQuote.ts @@ -9,6 +9,7 @@ import type { } from '../../../types' import { TradeQuoteError } from '../../../types' import { assertQuoteAddresses, makeSwapErrorRight } from '../../../utils' +import { normalizeEpochToMs } from '../../../utils/helpers' import { getBebopSolanaTradeContext } from '../utils/getBebopSolanaTradeContext' import { isBebopSolanaTxSafe } from '../utils/helpers' @@ -45,7 +46,7 @@ export const getBebopSolanaTradeQuote = async ( ...tradeCommon, quoteOrRate: 'quote', // The sealed multi-signer tx is blockhash-pinned, so the provider expiry governs - deadline: response.expiry * 1000, + deadline: normalizeEpochToMs(response.expiry), receiveAddress, steps: [ { diff --git a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts index 2de30ad2387..287ccb0dfe7 100644 --- a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts +++ b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts @@ -16,7 +16,7 @@ import type { import { SwapperName, TradeQuoteError } from '../../../types' import { makeSwapErrorRight } from '../../../utils' import { buildAffiliateFee } from '../../../utils/affiliateFee' -import { isNativeEvmAsset } from '../../../utils/helpers' +import { isNativeEvmAsset, normalizeEpochToMs } from '../../../utils/helpers' import { fetchBebopQuote } from './fetchFromBebop' import type { GetBebopStepDataArgs } from './getBebopStepData' import { assertValidTrade, calculateRate } from './helpers' @@ -121,6 +121,6 @@ export const getBebopTradeContext = async ({ from, deps, }, - deadline: quote.expiry * 1000, + deadline: normalizeEpochToMs(quote.expiry), }) } diff --git a/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts index 0730311017f..8c64ddc6985 100644 --- a/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts @@ -68,14 +68,16 @@ export const getTradeQuote = async ( if (maybeStepData.isErr()) return Err(maybeStepData.unwrapErr()) const { transactionData, networkFeeCryptoBaseUnit } = maybeStepData.unwrap() + // The deposit address goes inactive at the response deadline, ignored when absent or unparsable + const responseDeadlineMs = quote.deadline ? Date.parse(quote.deadline) : NaN + const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, - // Deposits must be mined before the requested refund deadline to execute as quoted; the - // response deadline is the later bound where the deposit address goes inactive entirely + // Deposits must be mined before the requested refund deadline to execute as quoted deadline: Math.min( Date.parse(quoteRequest.deadline), - quote.deadline ? Date.parse(quote.deadline) : Infinity, + Number.isFinite(responseDeadlineMs) ? responseDeadlineMs : Infinity, ), receiveAddress, steps: [ diff --git a/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts index 4d465134cad..a1aabe17bce 100644 --- a/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts @@ -2,6 +2,7 @@ import { Err, Ok } from '@sniptt/monads' import type { SwapperDeps, TradeQuote, TradeQuoteResult } from '../../../types' import { assertQuoteAddresses } from '../../../utils' +import { normalizeEpochToMs } from '../../../utils/helpers' import type { StonfiTradeQuoteInput } from '../types' import { getStonfiStepData } from '../utils/getStonfiStepData' import { getStonfiTradeContext } from '../utils/getStonfiTradeContext' @@ -34,7 +35,7 @@ export const getTradeQuote = async ( ...tradeCommon, quoteOrRate: 'quote' as const, // The provider caps when the trade may start - deadline: stonfiTransactionData.tradeStartDeadline * 1000, + deadline: normalizeEpochToMs(stonfiTransactionData.tradeStartDeadline), receiveAddress, steps: [ { diff --git a/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts b/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts index 30981d85f50..ee1a7a95c2b 100644 --- a/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts +++ b/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts @@ -3,6 +3,7 @@ import { Err, Ok } from '@sniptt/monads' import type { SwapErrorRight, SwapperDeps, SwapperName } from '../../types' import { assertQuoteAddresses, makeTradeStepBuildFailedErr } from '../../utils' +import { normalizeEpochToMs } from '../helpers' import { getLimitWithManualSlippage } from './getLimitWithManualSlippage/getLimitWithManualSlippage' import { getSuccessfulTrades, getThorL1TradeContexts } from './getThorL1TradeContexts' import { getThorStepData } from './getThorStepData' @@ -88,7 +89,7 @@ export const getThorL1TradeQuote = async ( quoteOrRate: 'quote', // The daemon quote expiry scopes the inbound address - sending funds after it risks a // rotated vault - deadline: tradeCommon.expiry * 1000, + deadline: normalizeEpochToMs(tradeCommon.expiry), memo, receiveAddress, data, diff --git a/src/state/apis/swapper/helpers/testData.ts b/src/state/apis/swapper/helpers/testData.ts index 21cff17e7ba..53b3f53a1cb 100644 --- a/src/state/apis/swapper/helpers/testData.ts +++ b/src/state/apis/swapper/helpers/testData.ts @@ -1,10 +1,12 @@ import type { TradeQuote } from '@shapeshiftoss/swapper' import { SwapperName } from '@shapeshiftoss/swapper' +const testQuoteDeadline = Date.now() + 60 * 60 * 1000 + export const thorQuote: TradeQuote = { id: 'f4636745-bf07-4799-9efb-c056691b652f', quoteOrRate: 'quote', - deadline: 1800000000000, + deadline: testQuoteDeadline, rate: '39.23942597524024759752', receiveAddress: '0x31b5c4ab7d020de87901c736535aeb4769806947', affiliateBps: '30', @@ -90,7 +92,7 @@ export const thorQuote: TradeQuote = { export const cowQuote: TradeQuote = { id: '220858750', quoteOrRate: 'quote', - deadline: 1800000000000, + deadline: testQuoteDeadline, rate: '51.86127422365727736757', affiliateBps: '0', receiveAddress: '0x31b5c4ab7d020de87901c736535aeb4769806947', @@ -160,7 +162,7 @@ export const cowQuote: TradeQuote = { export const zrxQuote: TradeQuote = { id: 'dfb5f2e6-9cb9-4865-9ef5-6b54d203affa', quoteOrRate: 'quote', - deadline: 1800000000000, + deadline: testQuoteDeadline, rate: '51.603817692372651273', affiliateBps: '0', receiveAddress: '0x31b5c4ab7d020de87901c736535aeb4769806947', From af8b02530ebddff47e267a9849bc29fcc65a0209 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:47:47 -0600 Subject: [PATCH 03/14] lint fix --- packages/swapper/src/utils/helpers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/swapper/src/utils/helpers.ts b/packages/swapper/src/utils/helpers.ts index a2724f0c878..df4cbfffb9b 100644 --- a/packages/swapper/src/utils/helpers.ts +++ b/packages/swapper/src/utils/helpers.ts @@ -40,8 +40,7 @@ export const FALLBACK_QUOTE_DEADLINE_MS = 60_000 // For provider timestamps of undocumented resolution - unix seconds land ~2e9, ms ~2e12, so the // boundary is unambiguous for any realistic date -export const normalizeEpochToMs = (value: number): number => - value < 1e12 ? value * 1000 : value +export const normalizeEpochToMs = (value: number): number => (value < 1e12 ? value * 1000 : value) export const isNativeEvmAsset = (assetId: AssetId): boolean => { const { chainId } = fromAssetId(assetId) From 608eab7fa1a9f6ac5d7e3254556cfca4651b8699 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:57:50 -0600 Subject: [PATCH 04/14] feat(public-api): widen quote bind grace to 60min for slow-chain binds Clients that only bind a txHash after first confirmation can exceed a 5min window on chains with 10min mean block times (P(block > 30min) is ~5% on bitcoin). Retention stays bounded by MAX_QUOTES either way. Co-Authored-By: Claude Fable 5 --- packages/public-api/src/lib/quoteStore.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/public-api/src/lib/quoteStore.ts b/packages/public-api/src/lib/quoteStore.ts index 42634498a2c..03aab1d57a2 100644 --- a/packages/public-api/src/lib/quoteStore.ts +++ b/packages/public-api/src/lib/quoteStore.ts @@ -26,7 +26,8 @@ export type StoredQuote = { /** * In-memory quote store with dual TTL: * - unsubmitted quotes live until the swapper quote deadline plus a binding grace window - * (a tx broadcast just before the deadline must still be able to bind for status tracking) + * (a tx broadcast just before the deadline must still be able to bind for status tracking, + * including clients that only bind after a first confirmation on slow chains like bitcoin) * - 60 minutes after txHash is bound (execution tracking window) * * Automatic sweep of expired entries every 60 seconds. @@ -37,7 +38,7 @@ export class QuoteStore { private txHashIndex = new Map() private cleanupInterval: ReturnType - static readonly BIND_GRACE_MS = 5 * 60 * 1000 + static readonly BIND_GRACE_MS = 60 * 60 * 1000 static readonly EXECUTION_TTL_MS = 60 * 60 * 1000 static readonly CLEANUP_INTERVAL_MS = 60 * 1000 static readonly MAX_QUOTES = 10000 From 5d6a1498ae255149fdcfda969a29eb2675d286ef Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:31:59 -0600 Subject: [PATCH 05/14] chore(swapper): drop boilerplate deadline comments Keep only the non-obvious ones: inbound address rotation, channel margins, NearIntents request-vs-response semantics, the Portals validate:false gap, and the longtail min. Co-Authored-By: Claude Fable 5 --- .../src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts | 1 - .../src/swappers/AvnuSwapper/utils/getAvnuTradeContext.ts | 1 - .../src/swappers/BebopSwapper/utils/getBebopTradeContext.ts | 1 - .../CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts | 1 - .../src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts | 1 - 5 files changed, 5 deletions(-) diff --git a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts index 32250709a54..e81b5c4a17f 100644 --- a/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts +++ b/packages/swapper/src/swappers/AcrossSwapper/utils/getAcrossTradeContext.ts @@ -35,7 +35,6 @@ type AcrossTradeContext = { stepCommon: Omit protocolFees: QuoteFeeData['protocolFees'] stepDataArgs: Omit - // Provider quote expiry (epoch ms), consumed by the quote arm only deadline: number } diff --git a/packages/swapper/src/swappers/AvnuSwapper/utils/getAvnuTradeContext.ts b/packages/swapper/src/swappers/AvnuSwapper/utils/getAvnuTradeContext.ts index 683af3d45c6..bec02cbb522 100644 --- a/packages/swapper/src/swappers/AvnuSwapper/utils/getAvnuTradeContext.ts +++ b/packages/swapper/src/swappers/AvnuSwapper/utils/getAvnuTradeContext.ts @@ -26,7 +26,6 @@ type AvnuTradeContext = { protocolFees: QuoteFeeData['protocolFees'] quoteId: string sellTokenAddress: string - // Provider quote expiry (epoch ms) when supplied, consumed by the quote arm only deadline: number | undefined } diff --git a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts index 287ccb0dfe7..e2cf987c9a4 100644 --- a/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts +++ b/packages/swapper/src/swappers/BebopSwapper/utils/getBebopTradeContext.ts @@ -25,7 +25,6 @@ type BebopTradeContext = { tradeCommon: TradeCommon stepCommon: Omit stepDataArgs: Omit - // Provider quote expiry (epoch ms), consumed by the quote arm only deadline: number } diff --git a/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts b/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts index b07176ea0b4..2a488b875bc 100644 --- a/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts +++ b/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts @@ -41,7 +41,6 @@ export const getCowSwapTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, - // The signed order is submittable until its validTo deadline: stepDataArgs.cowswapQuoteResponse.quote.validTo * 1000, receiveAddress, steps: [ diff --git a/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts index a1aabe17bce..24484034071 100644 --- a/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/StonfiSwapper/swapperApi/getTradeQuote.ts @@ -34,7 +34,6 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, - // The provider caps when the trade may start deadline: normalizeEpochToMs(stonfiTransactionData.tradeStartDeadline), receiveAddress, steps: [ From 946be67729c770576ec06b145c26c688fc2ff200 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:33:13 -0600 Subject: [PATCH 06/14] chore: condense deadline comments to one-liners Co-Authored-By: Claude Fable 5 --- packages/public-api/src/lib/quoteStore.ts | 4 +--- packages/swapper/src/swappers/ChainflipSwapper/constants.ts | 3 +-- packages/swapper/src/types.ts | 4 +--- packages/swapper/src/utils/helpers.ts | 6 ++---- packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts | 3 +-- 5 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/public-api/src/lib/quoteStore.ts b/packages/public-api/src/lib/quoteStore.ts index 03aab1d57a2..7a2e31ddcd3 100644 --- a/packages/public-api/src/lib/quoteStore.ts +++ b/packages/public-api/src/lib/quoteStore.ts @@ -25,9 +25,7 @@ export type StoredQuote = { /** * In-memory quote store with dual TTL: - * - unsubmitted quotes live until the swapper quote deadline plus a binding grace window - * (a tx broadcast just before the deadline must still be able to bind for status tracking, - * including clients that only bind after a first confirmation on slow chains like bitcoin) + * - unsubmitted: swapper deadline + bind grace (a slow first confirmation must still bind) * - 60 minutes after txHash is bound (execution tracking window) * * Automatic sweep of expired entries every 60 seconds. diff --git a/packages/swapper/src/swappers/ChainflipSwapper/constants.ts b/packages/swapper/src/swappers/ChainflipSwapper/constants.ts index 531b7c7792d..b45a8b14609 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/constants.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/constants.ts @@ -25,8 +25,7 @@ import { ChainflipNetwork } from './types' export const CHAINFLIP_REGULAR_QUOTE = 'regular' as const export const CHAINFLIP_DCA_QUOTE = 'dca' as const -// Deposit channels stay open ~24h from issuance (sourceExpiryBlock is a source-chain height we -// can't cheaply convert to time) - 6h keeps a 4x margin against depositing to an expired channel +// Deposit channels stay open ~24h - 6h keeps a 4x margin against depositing to an expired channel export const CHAINFLIP_CHANNEL_DEADLINE_MS = 6 * 60 * 60 * 1000 export const ChainflipSupportedChainIds = [ diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index e4d5e7d115b..a7698ae2b01 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -584,9 +584,7 @@ export type TradeQuote = TradeQuoteBase & { } & { quoteOrRate: 'quote' receiveAddress: string - // Epoch ms after which the quote is no longer safe to execute - the provider's own expiry - // where supplied (e.g. thornode quote expiry scoping the inbound address), a conservative - // default otherwise + // Epoch ms after which the quote is no longer safe to execute (provider expiry or fallback) deadline: number } diff --git a/packages/swapper/src/utils/helpers.ts b/packages/swapper/src/utils/helpers.ts index df4cbfffb9b..83400dfca25 100644 --- a/packages/swapper/src/utils/helpers.ts +++ b/packages/swapper/src/utils/helpers.ts @@ -34,12 +34,10 @@ import { isTreasuryChainId, } from '@shapeshiftoss/utils' -// Quote deadline for providers that don't supply their own expiry - short enough that priced -// amounts stay honest, long enough to review and sign +// Deadline for providers without their own expiry - short enough to keep priced amounts honest export const FALLBACK_QUOTE_DEADLINE_MS = 60_000 -// For provider timestamps of undocumented resolution - unix seconds land ~2e9, ms ~2e12, so the -// boundary is unambiguous for any realistic date +// Unix seconds land ~2e9 and ms ~2e12, so the boundary is unambiguous for any realistic date export const normalizeEpochToMs = (value: number): number => (value < 1e12 ? value * 1000 : value) export const isNativeEvmAsset = (assetId: AssetId): boolean => { diff --git a/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts b/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts index ee1a7a95c2b..7574caacc73 100644 --- a/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts +++ b/packages/swapper/src/utils/thorchain/getThorL1TradeQuote.ts @@ -87,8 +87,7 @@ export const getThorL1TradeQuote = async ( const quote: ThorTradeQuote = { ...tradeCommon, quoteOrRate: 'quote', - // The daemon quote expiry scopes the inbound address - sending funds after it risks a - // rotated vault + // The daemon quote expiry scopes the inbound address - sending later risks a rotated vault deadline: normalizeEpochToMs(tradeCommon.expiry), memo, receiveAddress, From 786d593d4026df4e04bb2984ce79498be9461fe4 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:41:55 -0600 Subject: [PATCH 07/14] chore(public-api): parallel phrasing for the dual TTL comment Co-Authored-By: Claude Fable 5 --- packages/public-api/src/lib/quoteStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/public-api/src/lib/quoteStore.ts b/packages/public-api/src/lib/quoteStore.ts index 7a2e31ddcd3..d61ba699244 100644 --- a/packages/public-api/src/lib/quoteStore.ts +++ b/packages/public-api/src/lib/quoteStore.ts @@ -26,7 +26,7 @@ export type StoredQuote = { /** * In-memory quote store with dual TTL: * - unsubmitted: swapper deadline + bind grace (a slow first confirmation must still bind) - * - 60 minutes after txHash is bound (execution tracking window) + * - submitted: txHash bind time + execution TTL (destination-chain settlement tracking) * * Automatic sweep of expired entries every 60 seconds. * Migration path: swap to Redis with zero code changes (same get/set/delete interface). From 2102709edbcafb53e8587608ea0af970ff520a98 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:44:05 -0600 Subject: [PATCH 08/14] chore(swapper): correct portals expiry comment to observed api behavior Live validated orders return expiry: null, so the parse currently always falls through to the 60s fallback; keep the defensive parse for when the typed optional field is populated. Co-Authored-By: Claude Fable 5 --- .../PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts index c4412d0f9f6..b51b558f785 100644 --- a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts +++ b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts @@ -162,7 +162,7 @@ export const getPortalsTradeQuote = async ( if (maybeStepData.isErr()) return Err(maybeStepData.unwrapErr()) const { transactionData, networkFeeCryptoBaseUnit } = maybeStepData.unwrap() - // Validated orders carry an expiry; the validate:false fallback does not + // Nullable in practice (live validated orders return null) - numeric or ISO when supplied const expiryMs = (() => { if (!orderContext.expiry) return undefined const numeric = Number(orderContext.expiry) From c03c9b15525ff24e980f9b76b3f3f7a9595a4e8a Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:46:52 -0600 Subject: [PATCH 09/14] refactor(swapper): make nearintents deadline derivation readable Conditional min instead of the Infinity identity trick, with one comment carrying both deadline semantics. Co-Authored-By: Claude Fable 5 --- .../NearIntentsSwapper/swapperApi/getTradeQuote.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts index 8c64ddc6985..96434100d51 100644 --- a/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts @@ -68,17 +68,17 @@ export const getTradeQuote = async ( if (maybeStepData.isErr()) return Err(maybeStepData.unwrapErr()) const { transactionData, networkFeeCryptoBaseUnit } = maybeStepData.unwrap() - // The deposit address goes inactive at the response deadline, ignored when absent or unparsable - const responseDeadlineMs = quote.deadline ? Date.parse(quote.deadline) : NaN + const requestDeadlineMs = Date.parse(quoteRequest.deadline) + const responseDeadlineMs = Date.parse(quote.deadline ?? '') const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, - // Deposits must be mined before the requested refund deadline to execute as quoted - deadline: Math.min( - Date.parse(quoteRequest.deadline), - Number.isFinite(responseDeadlineMs) ? responseDeadlineMs : Infinity, - ), + // Deposits mined after our requested refund deadline refund instead of executing as quoted; + // the response deadline (when the deposit address goes inactive, ~72h) only binds if tighter + deadline: Number.isFinite(responseDeadlineMs) + ? Math.min(requestDeadlineMs, responseDeadlineMs) + : requestDeadlineMs, receiveAddress, steps: [ { From f94e15b26da2969b4d43b9cf4493f2703c92ca93 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:48:38 -0600 Subject: [PATCH 10/14] feat(public-api): reject implausibly distant quote deadlines normalizeEpochToMs disambiguates seconds vs ms airtightly for future timestamps, but a provider emitting micro/nanosecond epochs would pass through as far-future - the one silent inflation vector. A 7d ceiling at the enforcement point catches any unit bug regardless of cause; max legitimate deadline today is chainflip's 6h. Co-Authored-By: Claude Fable 5 --- packages/public-api/src/constants.ts | 3 +++ packages/public-api/src/routes/quote/getQuote.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/public-api/src/constants.ts b/packages/public-api/src/constants.ts index 30f41055a52..1940b19ed62 100644 --- a/packages/public-api/src/constants.ts +++ b/packages/public-api/src/constants.ts @@ -54,3 +54,6 @@ export const ENABLED_SWAPPER_NAMES: readonly SwapperName[] = [ SwapperName.Thorchain, SwapperName.Zrx, ] + +// Ceiling for plausible swapper deadlines - catches provider unit bugs (e.g. µs epochs) upstream +export const MAX_QUOTE_DEADLINE_MS = 7 * 24 * 60 * 60 * 1000 diff --git a/packages/public-api/src/routes/quote/getQuote.ts b/packages/public-api/src/routes/quote/getQuote.ts index 2d94b09cf88..046cc1ae7c8 100644 --- a/packages/public-api/src/routes/quote/getQuote.ts +++ b/packages/public-api/src/routes/quote/getQuote.ts @@ -12,7 +12,7 @@ import type { Request, Response } from 'express' import { v4 as uuidv4 } from 'uuid' import { getAsset } from '../../assets' -import { ENABLED_SWAPPER_NAMES } from '../../constants' +import { ENABLED_SWAPPER_NAMES, MAX_QUOTE_DEADLINE_MS } from '../../constants' import { env } from '../../env' import { QuoteStore, quoteStore } from '../../lib/quoteStore' import { registry } from '../../registry' @@ -191,6 +191,14 @@ export const getQuote = async (req: Request, res: Response): Promise => { return } + // No swapper legitimately quotes past 6h (chainflip) - anything further is a units bug + if (quote.deadline > now + MAX_QUOTE_DEADLINE_MS) { + res.status(502).json({ + error: 'Swapper returned an implausible quote deadline', + } satisfies ErrorResponse) + return + } + quoteStore.set(quoteId, { ...baseQuote, sellAssetId: sellAsset.assetId, From 0173377298f5fedc429e1ac0dcca1c276c6d8fba Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:49:31 -0600 Subject: [PATCH 11/14] feat(swapper): normalize micro/nanosecond epochs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same log-1000 banding - each unit occupies its own unambiguous window for any realistic date, so the µs and ns folds carry the same certainty as the original s/ms boundary. Co-Authored-By: Claude Fable 5 --- packages/swapper/src/utils/helpers.test.ts | 24 +++++++++++++++++++++- packages/swapper/src/utils/helpers.ts | 9 ++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/swapper/src/utils/helpers.test.ts b/packages/swapper/src/utils/helpers.test.ts index fa4deeee8a6..8ede38f6142 100644 --- a/packages/swapper/src/utils/helpers.test.ts +++ b/packages/swapper/src/utils/helpers.test.ts @@ -4,7 +4,7 @@ import { KnownChainIds } from '@shapeshiftoss/types' import { treasuryChainIds } from '@shapeshiftoss/utils' import { describe, expect, it } from 'vitest' -import { getTreasuryAddressFromChainId } from './helpers' +import { getTreasuryAddressFromChainId, normalizeEpochToMs } from './helpers' describe('getTreasuryAddressFromChainId', () => { // Affiliate and fee recipient addresses for every swapper flow through here, so pin the values @@ -39,3 +39,25 @@ describe('getTreasuryAddressFromChainId', () => { ) }) }) + +describe('normalizeEpochToMs', () => { + // 2026-08-05T00:00:00Z expressed in each unit + const epochS = 1785888000 + const epochMs = epochS * 1000 + + it('converts unix seconds to ms', () => { + expect(normalizeEpochToMs(epochS)).toBe(epochMs) + }) + + it('passes milliseconds through', () => { + expect(normalizeEpochToMs(epochMs)).toBe(epochMs) + }) + + it('converts microseconds to ms', () => { + expect(normalizeEpochToMs(epochMs * 1000)).toBe(epochMs) + }) + + it('converts nanoseconds to ms', () => { + expect(normalizeEpochToMs(epochMs * 1e6)).toBe(epochMs) + }) +}) diff --git a/packages/swapper/src/utils/helpers.ts b/packages/swapper/src/utils/helpers.ts index 83400dfca25..3c038f3f799 100644 --- a/packages/swapper/src/utils/helpers.ts +++ b/packages/swapper/src/utils/helpers.ts @@ -37,8 +37,13 @@ import { // Deadline for providers without their own expiry - short enough to keep priced amounts honest export const FALLBACK_QUOTE_DEADLINE_MS = 60_000 -// Unix seconds land ~2e9 and ms ~2e12, so the boundary is unambiguous for any realistic date -export const normalizeEpochToMs = (value: number): number => (value < 1e12 ? value * 1000 : value) +// Bands are unambiguous for any realistic date: unix s land ~2e9, ms ~2e12, µs ~2e15, ns ~2e18 +export const normalizeEpochToMs = (value: number): number => { + if (value < 1e12) return value * 1000 + if (value < 1e15) return value + if (value < 1e18) return Math.floor(value / 1000) + return Math.floor(value / 1e6) +} export const isNativeEvmAsset = (assetId: AssetId): boolean => { const { chainId } = fromAssetId(assetId) From 723f09a4a8c7d1918872be96c96dd0de1796db29 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:50:48 -0600 Subject: [PATCH 12/14] chore(public-api): make the deadline ceiling self-diagnosing A future swapper legitimately quoting past 7d should hit a clear checkpoint naming the constant to raise, not an opaque 502. Co-Authored-By: Claude Fable 5 --- packages/public-api/src/constants.ts | 3 ++- packages/public-api/src/routes/quote/getQuote.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/public-api/src/constants.ts b/packages/public-api/src/constants.ts index 1940b19ed62..e681718aa64 100644 --- a/packages/public-api/src/constants.ts +++ b/packages/public-api/src/constants.ts @@ -55,5 +55,6 @@ export const ENABLED_SWAPPER_NAMES: readonly SwapperName[] = [ SwapperName.Zrx, ] -// Ceiling for plausible swapper deadlines - catches provider unit bugs (e.g. µs epochs) upstream +// Sanity ceiling catching provider deadline bugs (unit inflation, sentinel far-future dates). +// Widest legitimate deadline today is chainflip's 6h - raise this if a swapper ever quotes longer. export const MAX_QUOTE_DEADLINE_MS = 7 * 24 * 60 * 60 * 1000 diff --git a/packages/public-api/src/routes/quote/getQuote.ts b/packages/public-api/src/routes/quote/getQuote.ts index 046cc1ae7c8..77a6478fee2 100644 --- a/packages/public-api/src/routes/quote/getQuote.ts +++ b/packages/public-api/src/routes/quote/getQuote.ts @@ -191,10 +191,12 @@ export const getQuote = async (req: Request, res: Response): Promise => { return } - // No swapper legitimately quotes past 6h (chainflip) - anything further is a units bug if (quote.deadline > now + MAX_QUOTE_DEADLINE_MS) { + console.error( + `[getQuote] ${validSwapperName} deadline ${quote.deadline} exceeds MAX_QUOTE_DEADLINE_MS sanity ceiling - provider bug, or raise the ceiling if this swapper legitimately quotes longer`, + ) res.status(502).json({ - error: 'Swapper returned an implausible quote deadline', + error: `Swapper quote deadline exceeds the MAX_QUOTE_DEADLINE_MS sanity ceiling`, } satisfies ErrorResponse) return } From e5533d66f5aa99b879ff8e40148182e249942393 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:56:32 -0600 Subject: [PATCH 13/14] docs(public-api): document the 502 deadline rejections in openapi Co-Authored-By: Claude Fable 5 --- packages/public-api/src/routes/quote/getQuote.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/public-api/src/routes/quote/getQuote.ts b/packages/public-api/src/routes/quote/getQuote.ts index 77a6478fee2..5844ead4135 100644 --- a/packages/public-api/src/routes/quote/getQuote.ts +++ b/packages/public-api/src/routes/quote/getQuote.ts @@ -48,6 +48,7 @@ registry.registerPath({ 404: { description: 'No quote available' }, 429: rateLimitResponse, 500: { description: 'Internal server error' }, + 502: { description: 'Swapper returned an expired or implausible quote deadline' }, }, }) From e07760e08b65ee1f92ee4bc481398b79b1ec6a15 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:17:35 -0600 Subject: [PATCH 14/14] fix(public-api): validate quote deadline after the allowance rpc reads A slow buildApprovalInfo allowance check could otherwise store and return a quote whose deadline had already passed. Co-Authored-By: Claude Fable 5 --- packages/public-api/src/routes/quote/getQuote.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/public-api/src/routes/quote/getQuote.ts b/packages/public-api/src/routes/quote/getQuote.ts index 5844ead4135..98d239fa2da 100644 --- a/packages/public-api/src/routes/quote/getQuote.ts +++ b/packages/public-api/src/routes/quote/getQuote.ts @@ -172,7 +172,6 @@ export const getQuote = async (req: Request, res: Response): Promise => { const lastStep = quote.steps[quote.steps.length - 1] const quoteId = uuidv4() - const now = Date.now() const baseQuote = { quoteId, @@ -185,6 +184,11 @@ export const getQuote = async (req: Request, res: Response): Promise => { rate: quote.rate, } + const approval = await buildApprovalInfo(step, sendAddress) + + // taken after the allowance rpc reads so a slow check can't sneak an expired quote through + const now = Date.now() + if (!Number.isFinite(quote.deadline) || quote.deadline <= now) { res.status(502).json({ error: 'Swapper quote expired before it could be returned; request a new quote', @@ -224,7 +228,7 @@ export const getQuote = async (req: Request, res: Response): Promise => { slippageTolerancePercentageDecimal: quote.slippageTolerancePercentageDecimal, networkFeeCryptoBaseUnit: step.feeData.networkFeeCryptoBaseUnit, steps: quote.steps.map(transformQuoteStep), - approval: await buildApprovalInfo(step, sendAddress), + approval, expiresAt: quote.deadline, }