Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
205 changes: 205 additions & 0 deletions packages/bridging/src/BridgingSdk/determineIntermediateToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { NATIVE_CURRENCY_ADDRESS, SupportedChainId, TokenInfo } from '@cowprotocol/sdk-config'
import { determineIntermediateToken } from './determineIntermediateToken'
import { BridgeProviderQuoteError } from '../errors'

describe('determineIntermediateToken', () => {
// Sample tokens for testing
const usdcMainnet: TokenInfo = {
chainId: SupportedChainId.MAINNET,
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Mainnet
decimals: 6,
name: 'USDC',
symbol: 'USDC',
}

const usdtMainnet: TokenInfo = {
chainId: SupportedChainId.MAINNET,
address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', // USDT on Mainnet
decimals: 6,
name: 'USDT',
symbol: 'USDT',
}

const wethMainnet: TokenInfo = {
chainId: SupportedChainId.MAINNET,
address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', // WETH on Mainnet
decimals: 18,
name: 'WETH',
symbol: 'WETH',
}

const nativeEth: TokenInfo = {
chainId: SupportedChainId.MAINNET,
address: NATIVE_CURRENCY_ADDRESS,
decimals: 18,
name: 'Ether',
symbol: 'ETH',
}

const randomToken: TokenInfo = {
chainId: SupportedChainId.MAINNET,
address: '0x1111111111111111111111111111111111111111',
decimals: 18,
name: 'Random',
symbol: 'RND',
}

const usdcArbitrum: TokenInfo = {
chainId: SupportedChainId.ARBITRUM_ONE,
address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC on Arbitrum
decimals: 6,
name: 'USDC',
symbol: 'USDC',
}

describe('error handling', () => {
it('should throw error when no intermediate tokens provided', async () => {
await expect(determineIntermediateToken(SupportedChainId.MAINNET, [])).rejects.toThrow(BridgeProviderQuoteError)
})

it('should throw error when intermediateTokens is empty array', async () => {
await expect(determineIntermediateToken(SupportedChainId.MAINNET, [])).rejects.toThrow(BridgeProviderQuoteError)
})
})

describe('priority level: HIGHEST (stablecoins)', () => {
it('should prioritize USDC over other tokens', async () => {
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [randomToken, wethMainnet, usdcMainnet])

expect(result).toBe(usdcMainnet)
})

it('should prioritize USDT over other tokens', async () => {
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [randomToken, wethMainnet, usdtMainnet])

expect(result).toBe(usdtMainnet)
})

it('should prioritize first stablecoin when both USDC and USDT present', async () => {
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [usdcMainnet, usdtMainnet])

expect(result).toBe(usdcMainnet)

// Order matters - maintain stable sort
const result2 = await determineIntermediateToken(SupportedChainId.MAINNET, [usdtMainnet, usdcMainnet])

expect(result2).toBe(usdtMainnet)
})

it('should prioritize stablecoins on different chains', async () => {
const result = await determineIntermediateToken(SupportedChainId.ARBITRUM_ONE, [randomToken, usdcArbitrum])

expect(result).toBe(usdcArbitrum)
})
})

describe('priority level: HIGH (correlated tokens)', () => {
it('should prioritize correlated tokens over non-correlated', async () => {
const getCorrelatedTokens = async () => [wethMainnet.address]

const result = await determineIntermediateToken(
SupportedChainId.MAINNET,
[randomToken, wethMainnet],
getCorrelatedTokens,
)

expect(result).toBe(wethMainnet)
})

it('should prioritize stablecoins over correlated tokens', async () => {
const getCorrelatedTokens = async () => [wethMainnet.address]

const result = await determineIntermediateToken(
SupportedChainId.MAINNET,
[wethMainnet, usdcMainnet],
getCorrelatedTokens,
)

expect(result).toBe(usdcMainnet)
})

it('should handle multiple correlated tokens with stable sort', async () => {
const daiToken: TokenInfo = {
chainId: SupportedChainId.MAINNET,
address: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
decimals: 18,
name: 'DAI',
symbol: 'DAI',
}

const getCorrelatedTokens = async () => [wethMainnet.address, daiToken.address]

const result = await determineIntermediateToken(
SupportedChainId.MAINNET,
[randomToken, wethMainnet, daiToken],
getCorrelatedTokens,
)

// Should return first correlated token in original order
expect(result).toBe(wethMainnet)
})

it('should gracefully handle getCorrelatedTokens failure', async () => {
const getCorrelatedTokens = async () => {
throw new Error('API error')
}

const result = await determineIntermediateToken(
SupportedChainId.MAINNET,
[randomToken, wethMainnet],
getCorrelatedTokens,
)

// Should fallback and still work
expect(result).toBe(randomToken)
})
})

describe('priority level: MEDIUM (native tokens)', () => {
it('should prioritize native token over random tokens', async () => {
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [randomToken, nativeEth])

expect(result).toBe(nativeEth)
})

it('should prioritize stablecoins over native token', async () => {
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [nativeEth, usdcMainnet])

expect(result).toBe(usdcMainnet)
})

it('should prioritize correlated tokens over native token', async () => {
const getCorrelatedTokens = async () => [wethMainnet.address]

const result = await determineIntermediateToken(
SupportedChainId.MAINNET,
[nativeEth, wethMainnet],
getCorrelatedTokens,
)

expect(result).toBe(wethMainnet)
})
})

describe('priority level: LOW (other tokens)', () => {
it('should return first token when all have low priority', async () => {
const token1: TokenInfo = {
chainId: SupportedChainId.MAINNET,
address: '0x1111111111111111111111111111111111111111',
decimals: 18,
name: 'Token1',
}

const token2: TokenInfo = {
chainId: SupportedChainId.MAINNET,
address: '0x2222222222222222222222222222222222222222',
decimals: 18,
name: 'Token2',
}

const result = await determineIntermediateToken(SupportedChainId.MAINNET, [token1, token2])

expect(result).toBe(token1)
})
})
})
96 changes: 96 additions & 0 deletions packages/bridging/src/BridgingSdk/determineIntermediateToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { SupportedChainId, TokenInfo } from '@cowprotocol/sdk-config'
import { BridgeProviderQuoteError, BridgeQuoteErrors } from '../errors'
import { isHighPriorityToken, isCorrelatedToken, isNativeToken } from './tokenPriority'

/**
* Priority levels for intermediate token selection
*/
enum TokenPriority {
HIGHEST = 4, // USDC/USDT from hardcoded registry
HIGH = 3, // Tokens in CMS correlated tokens list
MEDIUM = 2, // Blockchain native token
LOW = 1, // Other tokens
}

/**
* Determines the best intermediate token from a list of candidates using a priority-based algorithm.
*
* @param sourceChainId - The chain ID where the swap originates
* @param intermediateTokens - Array of candidate intermediate tokens to evaluate
* @param getCorrelatedTokens - Optional callback to fetch tokens with known high liquidity/correlation.
* Called with `sourceChainId` and should return a list of correlated tokens.
* If not provided or fails, correlated token priority is skipped.
*
* @returns The best intermediate token based on the priority algorithm
*
* @throws {BridgeProviderQuoteError} If `intermediateTokens` is empty or undefined
*/
export async function determineIntermediateToken(
sourceChainId: SupportedChainId,
intermediateTokens: TokenInfo[],
getCorrelatedTokens?: (chainId: SupportedChainId) => Promise<string[]>,
): Promise<TokenInfo> {
const firstToken = intermediateTokens[0]

if (intermediateTokens.length === 0 || !firstToken) {
throw new BridgeProviderQuoteError(BridgeQuoteErrors.NO_INTERMEDIATE_TOKENS, { intermediateTokens })
}

// If only one token, return it immediately
if (intermediateTokens.length === 1) {
return firstToken
}

const correlatedTokens = await resolveCorrelatedTokens(sourceChainId, getCorrelatedTokens)

// Calculate priority for each token
const tokensWithPriority = intermediateTokens.map((token) => {
if (isHighPriorityToken(token.chainId, token.address)) {
return { token, priority: TokenPriority.HIGHEST }
}
if (isCorrelatedToken(token.address, correlatedTokens)) {
return { token, priority: TokenPriority.HIGH }
}
if (isNativeToken(token.address)) {
return { token, priority: TokenPriority.MEDIUM }
}

return { token, priority: TokenPriority.LOW }
})

// Sort by priority (highest first), then by original order for stability
tokensWithPriority.sort((a, b) => {
if (a.priority !== b.priority) {
return b.priority - a.priority // Higher priority first
}
// Maintain original order for tokens with same priority
return intermediateTokens.indexOf(a.token) - intermediateTokens.indexOf(b.token)
})

const result = tokensWithPriority[0]?.token

if (!result) {
throw new BridgeProviderQuoteError(BridgeQuoteErrors.NO_INTERMEDIATE_TOKENS, { intermediateTokens })
}

return result
}

async function resolveCorrelatedTokens(
sourceChainId: SupportedChainId,
getCorrelatedTokens: ((chainId: SupportedChainId) => Promise<string[]>) | undefined,
): Promise<Set<string>> {
if (getCorrelatedTokens) {
try {
const tokens = await getCorrelatedTokens(sourceChainId)
return new Set<string>(tokens)
} catch (error) {
console.warn(
'[determineIntermediateToken] Failed to fetch correlated tokens, falling back to basic priority',
error,
)
}
}

return new Set<string>()
}
13 changes: 7 additions & 6 deletions packages/bridging/src/BridgingSdk/getIntermediateSwapResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { BridgeProviderQuoteError, BridgeQuoteErrors } from '../errors'
import { GetQuoteWithBridgeParams } from './types'
import { getCacheKey } from './helpers'
import { OrderBookApi } from '@cowprotocol/sdk-order-book'
import { determineIntermediateToken } from './determineIntermediateToken'

export interface GetIntermediateSwapResultParams<T extends BridgeQuoteResult> {
provider: BridgeProvider<T>
Expand Down Expand Up @@ -62,15 +63,15 @@ export async function getIntermediateSwapResult<T extends BridgeQuoteResult>({
intermediateTokensCache: params.intermediateTokensCache,
})

// We just pick the first intermediate token for now
const intermediateToken = intermediateTokens[0]
// Determine the best intermediate token based on priority (USDC/USDT > CMS correlated > others)
const intermediateToken = await determineIntermediateToken(
sellTokenChainId,
intermediateTokens,
params.advancedSettings?.getCorrelatedTokens,
)

log(`Using ${intermediateToken?.name ?? intermediateToken?.address} as intermediate tokens`)

if (!intermediateToken) {
throw new BridgeProviderQuoteError(BridgeQuoteErrors.NO_INTERMEDIATE_TOKENS, { intermediateTokens })
}

const bridgeRequestWithoutAmount: QuoteBridgeRequestWithoutAmount = {
...swapAndBridgeRequest,
sellTokenAddress: intermediateToken.address,
Expand Down
65 changes: 65 additions & 0 deletions packages/bridging/src/BridgingSdk/tokenPriority.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NATIVE_CURRENCY_ADDRESS, SupportedChainId } from '@cowprotocol/sdk-config'

/**
* High-priority stablecoins registry (USDC and USDT)
* These tokens get the highest priority when selecting intermediate tokens
*/
export const HIGH_PRIORITY_TOKENS: Partial<Record<SupportedChainId, Set<string>>> = {
[SupportedChainId.MAINNET]: new Set([
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC
'0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT
]),
[SupportedChainId.BNB]: new Set([
'0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', // USDC
'0x55d398326f99059ff775485246999027b3197955', // USDT
]),
[SupportedChainId.GNOSIS_CHAIN]: new Set([
'0xddafbb505ad214d7b80b1f830fccc89b60fb7a83', // USDC
'0x4ecaba5870353805a9f068101a40e0f32ed605c6', // USDT
]),
[SupportedChainId.POLYGON]: new Set([
'0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', // USDC
'0xc2132d05d31c914a87c6611c10748aeb04b58e8f', // USDT
]),
[SupportedChainId.BASE]: new Set([
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', // USDC
]),
[SupportedChainId.ARBITRUM_ONE]: new Set([
'0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC
'0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', // USDT
]),
[SupportedChainId.AVALANCHE]: new Set([
'0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', // USDC
'0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7', // USDT
]),
[SupportedChainId.LINEA]: new Set([
'0x176211869ca2b568f2a7d4ee941e073a821ee1ff', // USDC
]),
[SupportedChainId.SEPOLIA]: new Set([
'0x1c7d4b196cb0c7b01d743fbc6116a902379c7238', // USDC
]),
}

/**
* Checks if a token is in the high-priority registry (USDC/USDT)
*/
export function isHighPriorityToken(chainId: SupportedChainId, tokenAddress: string): boolean {
const chainTokens = HIGH_PRIORITY_TOKENS[chainId]
if (!chainTokens) return false

return chainTokens.has(tokenAddress.toLowerCase())
}

/**
* Checks if a token is in the CMS correlated tokens list
*/
export function isCorrelatedToken(tokenAddress: string, correlatedTokens: Set<string>): boolean {
return correlatedTokens.has(tokenAddress.toLowerCase())
}

/**
* Checks if a token is the native blockchain currency (ETH, MATIC, AVAX, etc.)
*/
export function isNativeToken(tokenAddress: string): boolean {
return tokenAddress.toLowerCase() === NATIVE_CURRENCY_ADDRESS.toLowerCase()
}
Loading
Loading