Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4b424b2
feat(swapper): per-swapper quote deadlines, honored by the api quote …
kaladinlight Aug 3, 2026
6aea1b5
fix(swapper): harden quote deadlines per review
kaladinlight Aug 5, 2026
af8b025
lint fix
kaladinlight Aug 5, 2026
9c8b656
Merge branch 'develop' into feat/swapper-quote-deadlines
kaladinlight Aug 5, 2026
608eab7
feat(public-api): widen quote bind grace to 60min for slow-chain binds
kaladinlight Aug 5, 2026
5d6a149
chore(swapper): drop boilerplate deadline comments
kaladinlight Aug 5, 2026
946be67
chore: condense deadline comments to one-liners
kaladinlight Aug 5, 2026
786d593
chore(public-api): parallel phrasing for the dual TTL comment
kaladinlight Aug 5, 2026
2102709
chore(swapper): correct portals expiry comment to observed api behavior
kaladinlight Aug 5, 2026
c03c9b1
refactor(swapper): make nearintents deadline derivation readable
kaladinlight Aug 5, 2026
f94e15b
feat(public-api): reject implausibly distant quote deadlines
kaladinlight Aug 5, 2026
0173377
feat(swapper): normalize micro/nanosecond epochs too
kaladinlight Aug 5, 2026
723f09a
chore(public-api): make the deadline ceiling self-diagnosing
kaladinlight Aug 5, 2026
e5533d6
docs(public-api): document the 502 deadline rejections in openapi
kaladinlight Aug 5, 2026
e07760e
fix(public-api): validate quote deadline after the allowance rpc reads
kaladinlight Aug 6, 2026
c81631e
Merge branch 'develop' into feat/swapper-quote-deadlines
kaladinlight Aug 6, 2026
7f8fcc9
Merge branch 'develop' into feat/swapper-quote-deadlines
kaladinlight Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/public-api/docs/rest-api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions packages/public-api/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 9 additions & 6 deletions packages/public-api/src/lib/quoteStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): StoredQuote => ({
quoteId: 'quote-1',
swapperName: '0x',
Expand All @@ -18,7 +21,7 @@ const makeQuote = (overrides: Partial<StoredQuote> = {}): 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',
Expand Down Expand Up @@ -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()
})
})
Expand All @@ -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()
})

Expand Down Expand Up @@ -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)
})
Expand Down
6 changes: 3 additions & 3 deletions packages/public-api/src/lib/quoteStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -36,7 +36,7 @@ export class QuoteStore {
private txHashIndex = new Map<string, string>()
private cleanupInterval: ReturnType<typeof setInterval>

static readonly QUOTE_TTL_MS = 15 * 60 * 1000
static readonly BIND_GRACE_MS = 60 * 60 * 1000
Comment thread
coderabbitai[bot] marked this conversation as resolved.
static readonly EXECUTION_TTL_MS = 60 * 60 * 1000
static readonly CLEANUP_INTERVAL_MS = 60 * 1000
static readonly MAX_QUOTES = 10000
Expand Down
32 changes: 27 additions & 5 deletions packages/public-api/src/routes/quote/getQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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' },
},
})

Expand Down Expand Up @@ -171,7 +172,6 @@ export const getQuote = async (req: Request, res: Response): Promise<void> => {
const lastStep = quote.steps[quote.steps.length - 1]

const quoteId = uuidv4()
const now = Date.now()

const baseQuote = {
quoteId,
Expand All @@ -184,6 +184,28 @@ export const getQuote = async (req: Request, res: Response): Promise<void> => {
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
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
quoteStore.set(quoteId, {
...baseQuote,
sellAssetId: sellAsset.assetId,
Expand All @@ -193,7 +215,7 @@ export const getQuote = async (req: Request, res: Response): Promise<void> => {
partnerAddress: req.affiliateInfo?.partnerAddress,
partnerCode: req.affiliateInfo?.partnerCode,
createdAt: now,
expiresAt: now + QuoteStore.QUOTE_TTL_MS,
expiresAt: quote.deadline + QuoteStore.BIND_GRACE_MS,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
metadata: buildSwapMetadata(step, { stepIndex: 0, quoteId }),
status: 'pending',
})
Expand All @@ -206,8 +228,8 @@ export const getQuote = async (req: Request, res: Response): Promise<void> => {
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)
Expand Down
6 changes: 5 additions & 1 deletion packages/public-api/src/routes/quote/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
}),
}),
)

Expand Down
2 changes: 1 addition & 1 deletion packages/swapper/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand All @@ -35,6 +35,7 @@ export const getTradeQuote = async (
const tradeQuote: TradeQuote = {
...tradeCommon,
quoteOrRate: 'quote' as const,
deadline,
receiveAddress,
steps: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -35,6 +35,7 @@ type AcrossTradeContext = {
stepCommon: Omit<TradeStepCommon, 'feeData'>
protocolFees: QuoteFeeData['protocolFees']
stepDataArgs: Omit<GetAcrossStepDataArgs, 'type' | 'input'>
deadline: number
}

export const getAcrossTradeContext = async ({
Expand Down Expand Up @@ -246,5 +247,6 @@ export const getAcrossTradeContext = async ({
fallbackNetworkFeeCryptoBaseUnit: quote.fees.originGas.amount,
deps,
},
deadline: normalizeEpochToMs(quote.quoteExpiryTimestamp),
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -53,6 +54,7 @@ export const getTradeQuote = async (
const tradeQuote: TradeQuote = {
...tradeCommon,
quoteOrRate: 'quote',
deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS,
receiveAddress,
steps: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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)
Expand All @@ -46,6 +47,7 @@ export const getTradeQuote = async (
...tradeCommon,
receiveAddress: normalizedReceiveAddress,
quoteOrRate: 'quote',
deadline: deadline ?? Date.now() + FALLBACK_QUOTE_DEADLINE_MS,
steps: [
{
...stepCommon,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -26,6 +26,7 @@ type AvnuTradeContext = {
protocolFees: QuoteFeeData['protocolFees']
quoteId: string
sellTokenAddress: string
deadline: number | undefined
}

export const getAvnuTradeContext = async ({
Expand Down Expand Up @@ -135,6 +136,7 @@ export const getAvnuTradeContext = async ({
protocolFees,
quoteId: bestQuote.quoteId,
sellTokenAddress,
deadline: bestQuote.expiry ? normalizeEpochToMs(bestQuote.expiry) : undefined,
})
} catch (error) {
return Err(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand All @@ -35,6 +35,7 @@ export const getBebopTradeQuote = async (
const tradeQuote: TradeQuote = {
...tradeCommon,
quoteOrRate: 'quote',
deadline,
receiveAddress,
steps: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +25,7 @@ type BebopTradeContext = {
tradeCommon: TradeCommon
stepCommon: Omit<TradeStepCommon, 'feeData'>
stepDataArgs: Omit<GetBebopStepDataArgs, 'type' | 'input'>
deadline: number
}

export const getBebopTradeContext = async ({
Expand Down Expand Up @@ -119,5 +120,6 @@ export const getBebopTradeContext = async ({
from,
deps,
},
deadline: normalizeEpochToMs(quote.expiry),
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -46,6 +47,7 @@ export const getBobGatewayTradeQuote = async (
const tradeQuote: TradeQuote = {
...tradeCommon,
quoteOrRate: 'quote',
deadline: Date.now() + FALLBACK_QUOTE_DEADLINE_MS,
receiveAddress,
steps: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: [
{
Expand Down
Loading