-
Notifications
You must be signed in to change notification settings - Fork 32
feat(bridge): determine intermediate token #738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0336dee
refactor: extract determineIntermediateToken as it is
shoom3301 297585f
feat(bridge): determine intermediate token
shoom3301 b321ddf
chore: simplify getCorrelatedTokens return type
shoom3301 3cd991b
chore: add lowercase
shoom3301 4b54ea4
chore: fix PRIORITY_STABLECOIN_TOKENS
shoom3301 e563f65
chore: fix priority nums
shoom3301 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
205 changes: 205 additions & 0 deletions
205
packages/bridging/src/BridgingSdk/determineIntermediateToken.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
96
packages/bridging/src/BridgingSdk/determineIntermediateToken.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>() | ||
| } | ||
shoom3301 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
shoom3301 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| '0x4ecaba5870353805a9f068101a40e0f32ed605c6', // USDT | ||
| ]), | ||
| [SupportedChainId.POLYGON]: new Set([ | ||
| '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', // USDC | ||
| '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', // USDT | ||
| ]), | ||
| [SupportedChainId.BASE]: new Set([ | ||
| '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', // USDC | ||
shoom3301 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ]), | ||
| [SupportedChainId.ARBITRUM_ONE]: new Set([ | ||
| '0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC | ||
| '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', // USDT | ||
| ]), | ||
| [SupportedChainId.AVALANCHE]: new Set([ | ||
| '0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', // USDC | ||
| '0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7', // USDT | ||
| ]), | ||
| [SupportedChainId.LINEA]: new Set([ | ||
| '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', // USDC | ||
shoom3301 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ]), | ||
| [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() | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.