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/constants.ts b/packages/public-api/src/constants.ts index 30f41055a52..e681718aa64 100644 --- a/packages/public-api/src/constants.ts +++ b/packages/public-api/src/constants.ts @@ -54,3 +54,7 @@ export const ENABLED_SWAPPER_NAMES: readonly SwapperName[] = [ SwapperName.Thorchain, SwapperName.Zrx, ] + +// 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/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..d61ba699244 100644 --- a/packages/public-api/src/lib/quoteStore.ts +++ b/packages/public-api/src/lib/quoteStore.ts @@ -25,8 +25,8 @@ export type StoredQuote = { /** * In-memory quote store with dual TTL: - * - 15 minutes for unsubmitted quotes (quote validity window) - * - 60 minutes after txHash is bound (execution tracking window) + * - unsubmitted: swapper deadline + bind grace (a slow first confirmation must still bind) + * - 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). @@ -36,7 +36,7 @@ export class QuoteStore { private txHashIndex = new Map() private cleanupInterval: ReturnType - static readonly QUOTE_TTL_MS = 15 * 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 diff --git a/packages/public-api/src/routes/quote/getQuote.ts b/packages/public-api/src/routes/quote/getQuote.ts index 0f856b6459b..98d239fa2da 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' @@ -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' }, }, }) @@ -171,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, @@ -184,6 +184,28 @@ 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', + } satisfies ErrorResponse) + return + } + + 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 quote deadline exceeds the MAX_QUOTE_DEADLINE_MS sanity ceiling`, + } satisfies ErrorResponse) + return + } + quoteStore.set(quoteId, { ...baseQuote, sellAssetId: sellAsset.assetId, @@ -193,7 +215,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', }) @@ -206,8 +228,8 @@ 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), - expiresAt: now + 60_000, + approval, + 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 930edac9ff1..aeefc1723d3 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..e81b5c4a17f 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,7 @@ type AcrossTradeContext = { stepCommon: Omit protocolFees: QuoteFeeData['protocolFees'] stepDataArgs: Omit + deadline: number } export const getAcrossTradeContext = async ({ @@ -246,5 +247,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..bec02cbb522 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,7 @@ type AvnuTradeContext = { protocolFees: QuoteFeeData['protocolFees'] quoteId: string sellTokenAddress: string + deadline: number | undefined } export const getAvnuTradeContext = async ({ @@ -135,6 +136,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..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' @@ -44,6 +45,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: normalizeEpochToMs(response.expiry), 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..e2cf987c9a4 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' @@ -25,6 +25,7 @@ type BebopTradeContext = { tradeCommon: TradeCommon stepCommon: Omit stepDataArgs: Omit + deadline: number } export const getBebopTradeContext = async ({ @@ -119,5 +120,6 @@ export const getBebopTradeContext = async ({ from, deps, }, + deadline: normalizeEpochToMs(quote.expiry), }) } 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..b45a8b14609 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/constants.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/constants.ts @@ -25,6 +25,9 @@ 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 - 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..2a488b875bc 100644 --- a/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts +++ b/packages/swapper/src/swappers/CowSwapper/getCowSwapTradeQuote/getCowSwapTradeQuote.ts @@ -41,6 +41,7 @@ export const getCowSwapTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + 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..96434100d51 100644 --- a/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/NearIntentsSwapper/swapperApi/getTradeQuote.ts @@ -68,9 +68,17 @@ export const getTradeQuote = async ( if (maybeStepData.isErr()) return Err(maybeStepData.unwrapErr()) const { transactionData, networkFeeCryptoBaseUnit } = maybeStepData.unwrap() + const requestDeadlineMs = Date.parse(quoteRequest.deadline) + const responseDeadlineMs = Date.parse(quote.deadline ?? '') + const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + // 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: [ { diff --git a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts index b4278703e39..b51b558f785 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() + // 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) + 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..24484034071 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' @@ -33,6 +34,7 @@ export const getTradeQuote = async ( const tradeQuote: TradeQuote = { ...tradeCommon, quoteOrRate: 'quote' as const, + deadline: normalizeEpochToMs(stonfiTransactionData.tradeStartDeadline), 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..a7698ae2b01 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -584,6 +584,8 @@ export type TradeQuote = TradeQuoteBase & { } & { quoteOrRate: 'quote' receiveAddress: string + // Epoch ms after which the quote is no longer safe to execute (provider expiry or fallback) + deadline: number } export type MultiHopTradeQuote = TradeQuote & { 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 6dc89ad2b27..3c038f3f799 100644 --- a/packages/swapper/src/utils/helpers.ts +++ b/packages/swapper/src/utils/helpers.ts @@ -34,6 +34,17 @@ import { isTreasuryChainId, } from '@shapeshiftoss/utils' +// Deadline for providers without their own expiry - short enough to keep priced amounts honest +export const FALLBACK_QUOTE_DEADLINE_MS = 60_000 + +// 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) 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..7574caacc73 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' @@ -86,6 +87,8 @@ export const getThorL1TradeQuote = async ( const quote: ThorTradeQuote = { ...tradeCommon, quoteOrRate: 'quote', + // The daemon quote expiry scopes the inbound address - sending later risks a rotated vault + 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 32b01291968..53b3f53a1cb 100644 --- a/src/state/apis/swapper/helpers/testData.ts +++ b/src/state/apis/swapper/helpers/testData.ts @@ -1,9 +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: testQuoteDeadline, rate: '39.23942597524024759752', receiveAddress: '0x31b5c4ab7d020de87901c736535aeb4769806947', affiliateBps: '30', @@ -89,6 +92,7 @@ export const thorQuote: TradeQuote = { export const cowQuote: TradeQuote = { id: '220858750', quoteOrRate: 'quote', + deadline: testQuoteDeadline, rate: '51.86127422365727736757', affiliateBps: '0', receiveAddress: '0x31b5c4ab7d020de87901c736535aeb4769806947', @@ -158,6 +162,7 @@ export const cowQuote: TradeQuote = { export const zrxQuote: TradeQuote = { id: 'dfb5f2e6-9cb9-4865-9ef5-6b54d203affa', quoteOrRate: 'quote', + deadline: testQuoteDeadline, rate: '51.603817692372651273', affiliateBps: '0', receiveAddress: '0x31b5c4ab7d020de87901c736535aeb4769806947',