Skip to content

Commit 297585f

Browse files
committed
feat(bridge): determine intermediate token
1 parent 0336dee commit 297585f

File tree

7 files changed

+451
-21
lines changed

7 files changed

+451
-21
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { NATIVE_CURRENCY_ADDRESS, SupportedChainId, TokenInfo } from '@cowprotocol/sdk-config'
2+
import { determineIntermediateToken } from './determineIntermediateToken'
3+
import { BridgeProviderQuoteError } from '../errors'
4+
5+
describe('determineIntermediateToken', () => {
6+
// Sample tokens for testing
7+
const usdcMainnet: TokenInfo = {
8+
chainId: SupportedChainId.MAINNET,
9+
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Mainnet
10+
decimals: 6,
11+
name: 'USDC',
12+
symbol: 'USDC',
13+
}
14+
15+
const usdtMainnet: TokenInfo = {
16+
chainId: SupportedChainId.MAINNET,
17+
address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', // USDT on Mainnet
18+
decimals: 6,
19+
name: 'USDT',
20+
symbol: 'USDT',
21+
}
22+
23+
const wethMainnet: TokenInfo = {
24+
chainId: SupportedChainId.MAINNET,
25+
address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', // WETH on Mainnet
26+
decimals: 18,
27+
name: 'WETH',
28+
symbol: 'WETH',
29+
}
30+
31+
const nativeEth: TokenInfo = {
32+
chainId: SupportedChainId.MAINNET,
33+
address: NATIVE_CURRENCY_ADDRESS,
34+
decimals: 18,
35+
name: 'Ether',
36+
symbol: 'ETH',
37+
}
38+
39+
const randomToken: TokenInfo = {
40+
chainId: SupportedChainId.MAINNET,
41+
address: '0x1111111111111111111111111111111111111111',
42+
decimals: 18,
43+
name: 'Random',
44+
symbol: 'RND',
45+
}
46+
47+
const usdcArbitrum: TokenInfo = {
48+
chainId: SupportedChainId.ARBITRUM_ONE,
49+
address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC on Arbitrum
50+
decimals: 6,
51+
name: 'USDC',
52+
symbol: 'USDC',
53+
}
54+
55+
describe('error handling', () => {
56+
it('should throw error when no intermediate tokens provided', async () => {
57+
await expect(determineIntermediateToken(SupportedChainId.MAINNET, [])).rejects.toThrow(BridgeProviderQuoteError)
58+
})
59+
60+
it('should throw error when intermediateTokens is empty array', async () => {
61+
await expect(determineIntermediateToken(SupportedChainId.MAINNET, [])).rejects.toThrow(BridgeProviderQuoteError)
62+
})
63+
})
64+
65+
describe('priority level: HIGHEST (stablecoins)', () => {
66+
it('should prioritize USDC over other tokens', async () => {
67+
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [randomToken, wethMainnet, usdcMainnet])
68+
69+
expect(result).toBe(usdcMainnet)
70+
})
71+
72+
it('should prioritize USDT over other tokens', async () => {
73+
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [randomToken, wethMainnet, usdtMainnet])
74+
75+
expect(result).toBe(usdtMainnet)
76+
})
77+
78+
it('should prioritize first stablecoin when both USDC and USDT present', async () => {
79+
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [usdcMainnet, usdtMainnet])
80+
81+
expect(result).toBe(usdcMainnet)
82+
83+
// Order matters - maintain stable sort
84+
const result2 = await determineIntermediateToken(SupportedChainId.MAINNET, [usdtMainnet, usdcMainnet])
85+
86+
expect(result2).toBe(usdtMainnet)
87+
})
88+
89+
it('should prioritize stablecoins on different chains', async () => {
90+
const result = await determineIntermediateToken(SupportedChainId.ARBITRUM_ONE, [randomToken, usdcArbitrum])
91+
92+
expect(result).toBe(usdcArbitrum)
93+
})
94+
})
95+
96+
describe('priority level: HIGH (correlated tokens)', () => {
97+
it('should prioritize correlated tokens over non-correlated', async () => {
98+
const getCorrelatedTokens = async () => [wethMainnet]
99+
100+
const result = await determineIntermediateToken(
101+
SupportedChainId.MAINNET,
102+
[randomToken, wethMainnet],
103+
getCorrelatedTokens,
104+
)
105+
106+
expect(result).toBe(wethMainnet)
107+
})
108+
109+
it('should prioritize stablecoins over correlated tokens', async () => {
110+
const getCorrelatedTokens = async () => [wethMainnet]
111+
112+
const result = await determineIntermediateToken(
113+
SupportedChainId.MAINNET,
114+
[wethMainnet, usdcMainnet],
115+
getCorrelatedTokens,
116+
)
117+
118+
expect(result).toBe(usdcMainnet)
119+
})
120+
121+
it('should handle multiple correlated tokens with stable sort', async () => {
122+
const daiToken: TokenInfo = {
123+
chainId: SupportedChainId.MAINNET,
124+
address: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
125+
decimals: 18,
126+
name: 'DAI',
127+
symbol: 'DAI',
128+
}
129+
130+
const getCorrelatedTokens = async () => [wethMainnet, daiToken]
131+
132+
const result = await determineIntermediateToken(
133+
SupportedChainId.MAINNET,
134+
[randomToken, wethMainnet, daiToken],
135+
getCorrelatedTokens,
136+
)
137+
138+
// Should return first correlated token in original order
139+
expect(result).toBe(wethMainnet)
140+
})
141+
142+
it('should gracefully handle getCorrelatedTokens failure', async () => {
143+
const getCorrelatedTokens = async () => {
144+
throw new Error('API error')
145+
}
146+
147+
const result = await determineIntermediateToken(
148+
SupportedChainId.MAINNET,
149+
[randomToken, wethMainnet],
150+
getCorrelatedTokens,
151+
)
152+
153+
// Should fallback and still work
154+
expect(result).toBe(randomToken)
155+
})
156+
})
157+
158+
describe('priority level: MEDIUM (native tokens)', () => {
159+
it('should prioritize native token over random tokens', async () => {
160+
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [randomToken, nativeEth])
161+
162+
expect(result).toBe(nativeEth)
163+
})
164+
165+
it('should prioritize stablecoins over native token', async () => {
166+
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [nativeEth, usdcMainnet])
167+
168+
expect(result).toBe(usdcMainnet)
169+
})
170+
171+
it('should prioritize correlated tokens over native token', async () => {
172+
const getCorrelatedTokens = async () => [wethMainnet]
173+
174+
const result = await determineIntermediateToken(
175+
SupportedChainId.MAINNET,
176+
[nativeEth, wethMainnet],
177+
getCorrelatedTokens,
178+
)
179+
180+
expect(result).toBe(wethMainnet)
181+
})
182+
})
183+
184+
describe('priority level: LOW (other tokens)', () => {
185+
it('should return first token when all have low priority', async () => {
186+
const token1: TokenInfo = {
187+
chainId: SupportedChainId.MAINNET,
188+
address: '0x1111111111111111111111111111111111111111',
189+
decimals: 18,
190+
name: 'Token1',
191+
}
192+
193+
const token2: TokenInfo = {
194+
chainId: SupportedChainId.MAINNET,
195+
address: '0x2222222222222222222222222222222222222222',
196+
decimals: 18,
197+
name: 'Token2',
198+
}
199+
200+
const result = await determineIntermediateToken(SupportedChainId.MAINNET, [token1, token2])
201+
202+
expect(result).toBe(token1)
203+
})
204+
})
205+
})
Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,96 @@
1-
import { TokenInfo } from '@cowprotocol/sdk-config'
1+
import { SupportedChainId, TokenInfo } from '@cowprotocol/sdk-config'
22
import { BridgeProviderQuoteError, BridgeQuoteErrors } from '../errors'
3+
import { isHighPriorityToken, isCorrelatedToken, isNativeToken } from './tokenPriority'
34

4-
export function determineIntermediateToken(intermediateTokens: TokenInfo[]): TokenInfo {
5-
// We just pick the first intermediate token for now
6-
const intermediateToken = intermediateTokens[0]
5+
/**
6+
* Priority levels for intermediate token selection
7+
*/
8+
enum TokenPriority {
9+
HIGHEST = 4, // USDC/USDT from hardcoded registry
10+
HIGH = 3, // Tokens in CMS correlated tokens list
11+
MEDIUM = 2, // Blockchain native token
12+
LOW = 1, // Other tokens
13+
}
14+
15+
/**
16+
* Determines the best intermediate token from a list of candidates using a priority-based algorithm.
17+
*
18+
* @param sourceChainId - The chain ID where the swap originates
19+
* @param intermediateTokens - Array of candidate intermediate tokens to evaluate
20+
* @param getCorrelatedTokens - Optional callback to fetch tokens with known high liquidity/correlation.
21+
* Called with `sourceChainId` and should return a list of correlated tokens.
22+
* If not provided or fails, correlated token priority is skipped.
23+
*
24+
* @returns The best intermediate token based on the priority algorithm
25+
*
26+
* @throws {BridgeProviderQuoteError} If `intermediateTokens` is empty or undefined
27+
*/
28+
export async function determineIntermediateToken(
29+
sourceChainId: SupportedChainId,
30+
intermediateTokens: TokenInfo[],
31+
getCorrelatedTokens?: (chainId: SupportedChainId) => Promise<TokenInfo[]>,
32+
): Promise<TokenInfo> {
33+
const firstToken = intermediateTokens[0]
34+
35+
if (intermediateTokens.length === 0 || !firstToken) {
36+
throw new BridgeProviderQuoteError(BridgeQuoteErrors.NO_INTERMEDIATE_TOKENS, { intermediateTokens })
37+
}
38+
39+
// If only one token, return it immediately
40+
if (intermediateTokens.length === 1) {
41+
return firstToken
42+
}
43+
44+
const correlatedTokens = await resolveCorrelatedTokens(sourceChainId, getCorrelatedTokens)
745

8-
if (!intermediateToken) {
46+
// Calculate priority for each token
47+
const tokensWithPriority = intermediateTokens.map((token) => {
48+
if (isHighPriorityToken(token.chainId, token.address)) {
49+
return { token, priority: TokenPriority.HIGHEST }
50+
}
51+
if (isCorrelatedToken(token.address, correlatedTokens)) {
52+
return { token, priority: TokenPriority.HIGH }
53+
}
54+
if (isNativeToken(token.address)) {
55+
return { token, priority: TokenPriority.MEDIUM }
56+
}
57+
58+
return { token, priority: TokenPriority.LOW }
59+
})
60+
61+
// Sort by priority (highest first), then by original order for stability
62+
tokensWithPriority.sort((a, b) => {
63+
if (a.priority !== b.priority) {
64+
return b.priority - a.priority // Higher priority first
65+
}
66+
// Maintain original order for tokens with same priority
67+
return intermediateTokens.indexOf(a.token) - intermediateTokens.indexOf(b.token)
68+
})
69+
70+
const result = tokensWithPriority[0]?.token
71+
72+
if (!result) {
973
throw new BridgeProviderQuoteError(BridgeQuoteErrors.NO_INTERMEDIATE_TOKENS, { intermediateTokens })
1074
}
1175

12-
return intermediateToken
76+
return result
77+
}
78+
79+
async function resolveCorrelatedTokens(
80+
sourceChainId: SupportedChainId,
81+
getCorrelatedTokens: ((chainId: SupportedChainId) => Promise<TokenInfo[]>) | undefined,
82+
): Promise<Set<string>> {
83+
if (getCorrelatedTokens) {
84+
try {
85+
const tokens = await getCorrelatedTokens(sourceChainId)
86+
return new Set<string>(tokens.map((t) => t.address.toLowerCase()))
87+
} catch (error) {
88+
console.warn(
89+
'[determineIntermediateToken] Failed to fetch correlated tokens, falling back to basic priority',
90+
error,
91+
)
92+
}
93+
}
94+
95+
return new Set<string>()
1396
}

packages/bridging/src/BridgingSdk/getIntermediateSwapResult.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,12 @@ export async function getIntermediateSwapResult<T extends BridgeQuoteResult>({
6363
intermediateTokensCache: params.intermediateTokensCache,
6464
})
6565

66-
// We just pick the first intermediate token for now
67-
const intermediateToken = determineIntermediateToken(intermediateTokens)
66+
// Determine the best intermediate token based on priority (USDC/USDT > CMS correlated > others)
67+
const intermediateToken = await determineIntermediateToken(
68+
sellTokenChainId,
69+
intermediateTokens,
70+
params.advancedSettings?.getCorrelatedTokens,
71+
)
6872

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { NATIVE_CURRENCY_ADDRESS, SupportedChainId } from '@cowprotocol/sdk-config'
2+
3+
/**
4+
* High-priority stablecoins registry (USDC and USDT)
5+
* These tokens get the highest priority when selecting intermediate tokens
6+
*/
7+
export const HIGH_PRIORITY_TOKENS: Partial<Record<SupportedChainId, Set<string>>> = {
8+
[SupportedChainId.MAINNET]: new Set([
9+
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC
10+
'0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT
11+
]),
12+
[SupportedChainId.BNB]: new Set([
13+
'0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', // USDC
14+
'0x55d398326f99059ff775485246999027b3197955', // USDT
15+
]),
16+
[SupportedChainId.GNOSIS_CHAIN]: new Set([
17+
'0xddafbb505ad214d7b80b1f830fccc89b60fb7a83', // USDC
18+
'0x4ecaba5870353805a9f068101a40e0f32ed605c6', // USDT
19+
]),
20+
[SupportedChainId.POLYGON]: new Set([
21+
'0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', // USDC
22+
'0xc2132d05d31c914a87c6611c10748aeb04b58e8f', // USDT
23+
]),
24+
[SupportedChainId.BASE]: new Set([
25+
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', // USDC
26+
]),
27+
[SupportedChainId.ARBITRUM_ONE]: new Set([
28+
'0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC
29+
'0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', // USDT
30+
]),
31+
[SupportedChainId.AVALANCHE]: new Set([
32+
'0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', // USDC
33+
'0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7', // USDT
34+
]),
35+
[SupportedChainId.LINEA]: new Set([
36+
'0x176211869ca2b568f2a7d4ee941e073a821ee1ff', // USDC
37+
]),
38+
[SupportedChainId.SEPOLIA]: new Set([
39+
'0x1c7d4b196cb0c7b01d743fbc6116a902379c7238', // USDC
40+
]),
41+
}
42+
43+
/**
44+
* Checks if a token is in the high-priority registry (USDC/USDT)
45+
*/
46+
export function isHighPriorityToken(chainId: SupportedChainId, tokenAddress: string): boolean {
47+
const chainTokens = HIGH_PRIORITY_TOKENS[chainId]
48+
if (!chainTokens) return false
49+
50+
return chainTokens.has(tokenAddress.toLowerCase())
51+
}
52+
53+
/**
54+
* Checks if a token is in the CMS correlated tokens list
55+
*/
56+
export function isCorrelatedToken(tokenAddress: string, correlatedTokens: Set<string>): boolean {
57+
return correlatedTokens.has(tokenAddress.toLowerCase())
58+
}
59+
60+
/**
61+
* Checks if a token is the native blockchain currency (ETH, MATIC, AVAX, etc.)
62+
*/
63+
export function isNativeToken(tokenAddress: string): boolean {
64+
return tokenAddress.toLowerCase() === NATIVE_CURRENCY_ADDRESS.toLowerCase()
65+
}

0 commit comments

Comments
 (0)