@@ -34,6 +34,7 @@ import {
3434import { zerionFungiblesSchema } from './validators/fungible'
3535
3636import type { CoingeckoAssetDetails } from '@/lib/coingecko/types'
37+ import type { CoinGeckoMarketCap } from '@/lib/market-service/coingecko/coingecko-types'
3738import type { PartialFields } from '@/lib/types'
3839
3940// NOTE: this must call the zerion api directly rather than our proxy because of rate limiting requirements
@@ -48,11 +49,25 @@ axiosRetry(axiosInstance, { retries: 5, retryDelay: axiosRetry.exponentialDelay
4849const ZERION_API_KEY = process . env . ZERION_API_KEY
4950if ( ! ZERION_API_KEY ) throw new Error ( 'Missing Zerion API key - see readme for instructions' )
5051
52+ const REGEN_ALL = process . env . REGEN_ALL === 'true'
53+
5154const manualRelatedAssetIndex : Record < AssetId , AssetId [ ] > = {
5255 [ ethAssetId ] : [ optimismAssetId , arbitrumAssetId , arbitrumNovaAssetId , baseAssetId ] ,
5356 [ foxAssetId ] : [ foxOnArbitrumOneAssetId ] ,
5457}
5558
59+ // Category → Canonical Asset mapping for bridged tokens
60+ // Maps CoinGecko bridged categories to their Ethereum canonical tokens
61+ // Note: bridged-usdt includes USDT0 variants - they will be grouped together with ETH USDT as primary
62+ const BRIDGED_CATEGORY_MAPPINGS : Record < string , AssetId > = {
63+ 'bridged-usdc' : 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' , // ETH USDC
64+ 'bridged-usdt' : 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7' , // ETH USDT (includes USDT0)
65+ 'bridged-weth' : 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' , // ETH WETH
66+ 'bridged-wbtc' : 'eip155:1/erc20:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599' , // ETH WBTC
67+ 'bridged-dai' : 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f' , // ETH DAI
68+ 'bridged-wsteth' : 'eip155:1/erc20:0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0' , // ETH wstETH
69+ }
70+
5671export const getManualRelatedAssetIds = (
5772 assetId : AssetId ,
5873) : { relatedAssetIds : AssetId [ ] ; relatedAssetKey : AssetId } | undefined => {
@@ -82,6 +97,31 @@ export const getManualRelatedAssetIds = (
8297const isSome = < T > ( option : T | null | undefined ) : option is T =>
8398 ! isUndefined ( option ) && ! isNull ( option )
8499
100+ // Pre-fetch bridged category mappings (6 API calls total)
101+ // Returns mapping of category → array of coin IDs in that category
102+ const fetchBridgedCategoryMappings = async ( ) : Promise < Record < string , string [ ] > > => {
103+ const categoryToCoinIds : Record < string , string [ ] > = { }
104+
105+ for ( const category of Object . keys ( BRIDGED_CATEGORY_MAPPINGS ) ) {
106+ const { data } = await axiosInstance . get < CoinGeckoMarketCap [ ] > (
107+ `${ coingeckoBaseUrl } /coins/markets` ,
108+ {
109+ params : {
110+ category,
111+ vs_currency : 'usd' ,
112+ per_page : 250 ,
113+ page : 1 ,
114+ } ,
115+ } ,
116+ )
117+
118+ // No filtering - include all coins (USDT0 will be grouped with USDT)
119+ categoryToCoinIds [ category ] = data . map ( coin => coin . id )
120+ }
121+
122+ return categoryToCoinIds
123+ }
124+
85125const chunkArray = < T > ( array : T [ ] , chunkSize : number ) => {
86126 const result = [ ]
87127 for ( let i = 0 ; i < array . length ; i += chunkSize ) {
@@ -151,6 +191,7 @@ const getZerionRelatedAssetIds = async (
151191const getCoingeckoRelatedAssetIds = async (
152192 assetId : AssetId ,
153193 assetData : Record < AssetId , PartialFields < Asset , 'relatedAssetKey' > > ,
194+ categoryToCoinIds : Record < string , string [ ] > ,
154195) : Promise < { relatedAssetIds : AssetId [ ] ; relatedAssetKey : AssetId } | undefined > => {
155196 if ( ! isToken ( assetId ) ) return
156197 // Yes, this means effectively the same but double wrap never hurts
@@ -161,20 +202,106 @@ const getCoingeckoRelatedAssetIds = async (
161202 const { data } = await axios . get < CoingeckoAssetDetails > ( `${ coingeckoBaseUrl } /coins/${ coinUri } ` )
162203
163204 const platforms = data . platforms
205+ const coinId = data . id
164206
165207 // Use all assetIds actually present in the dataset
166- const allRelatedAssetIds = Object . entries ( platforms )
208+ let allRelatedAssetIds = Object . entries ( platforms )
167209 ?. map ( coingeckoPlatformDetailsToMaybeAssetId )
168210 . filter ( isSome )
169211 . filter ( relatedAssetId => assetData [ relatedAssetId ] !== undefined )
170212
213+ // Determine canonical asset in THREE ways:
214+ let bridgedCanonical : AssetId | undefined
215+
216+ // Debug logging for specific assets
217+ const isDebugAsset = [
218+ 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7' , // ETH USDT
219+ 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' , // ETH USDC
220+ 'eip155:1/erc20:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599' , // ETH WBTC
221+ 'eip155:43114/erc20:0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7' , // Avalanche USDT
222+ 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831' , // Arbitrum USDC
223+ 'eip155:42161/erc20:0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f' , // Arbitrum WBTC
224+ 'eip155:9745/erc20:0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb' , // Plasma USDT0
225+ ] . includes ( assetId )
226+
227+ // 1. Check if THIS asset is an Ethereum canonical (e.g., processing ETH USDT itself)
228+ const ethereumCanonicals = Object . values ( BRIDGED_CATEGORY_MAPPINGS )
229+ if ( ethereumCanonicals . includes ( assetId ) ) {
230+ bridgedCanonical = assetId
231+ if ( isDebugAsset ) {
232+ console . log (
233+ `[DEBUG CG] ${ assetId . slice ( 0 , 50 ) } ... CHECK 1: IS canonical → ${ bridgedCanonical . slice ( 0 , 40 ) } ...` ,
234+ )
235+ }
236+ }
237+
238+ // 2. Check if this coin is in a bridged category (catches bridged variants with unique coin IDs)
239+ if ( ! bridgedCanonical ) {
240+ for ( const [ category , coinIds ] of Object . entries ( categoryToCoinIds ) ) {
241+ if ( coinIds . includes ( coinId ) ) {
242+ bridgedCanonical = BRIDGED_CATEGORY_MAPPINGS [ category ]
243+ if ( isDebugAsset ) {
244+ console . log (
245+ `[DEBUG CG] ${ assetId . slice ( 0 , 50 ) } ... CHECK 2: coinId "${ coinId } " in ${ category } → ${ bridgedCanonical . slice ( 0 , 40 ) } ...` ,
246+ )
247+ }
248+ break
249+ }
250+ }
251+ }
252+
253+ // 3. Check if platforms list contains an Ethereum canonical (catches shared coin IDs like USDC/USDT)
254+ // CoinGecko uses the same coin ID for native USDC/USDT across multiple chains
255+ if ( ! bridgedCanonical ) {
256+ for ( const canonical of ethereumCanonicals ) {
257+ if ( allRelatedAssetIds . includes ( canonical ) ) {
258+ bridgedCanonical = canonical
259+ if ( isDebugAsset ) {
260+ console . log (
261+ `[DEBUG CG] ${ assetId . slice ( 0 , 50 ) } ... CHECK 3: platforms contains ${ canonical . slice ( 0 , 40 ) } ... → canonical` ,
262+ )
263+ }
264+ break
265+ }
266+ }
267+ }
268+
269+ if ( isDebugAsset && ! bridgedCanonical ) {
270+ console . log (
271+ `[DEBUG CG] ${ assetId . slice ( 0 , 50 ) } ... NO MATCH: coinId="${ coinId } ", ${ allRelatedAssetIds . length } platforms` ,
272+ )
273+ }
274+
275+ // Add canonical FIRST to ensure it becomes the primary (relatedAssetKey)
276+ // This fixes the first-come-first-served issue where non-canonical assets became primaries
277+ if ( bridgedCanonical && assetData [ bridgedCanonical ] ) {
278+ allRelatedAssetIds . unshift ( bridgedCanonical )
279+ // Remove duplicates while preserving order
280+ allRelatedAssetIds = Array . from ( new Set ( allRelatedAssetIds ) )
281+ if ( isDebugAsset ) {
282+ console . log (
283+ `[DEBUG CG] ${ assetId . slice ( 0 , 50 ) } ... UNSHIFT canonical → first=${ allRelatedAssetIds [ 0 ] ?. slice ( 0 , 40 ) } ...` ,
284+ )
285+ }
286+ }
287+
171288 if ( allRelatedAssetIds . length <= 1 ) {
289+ // Still return canonical even if no other assets yet (fixes Zerion override for WBTC/WETH/WSTETH)
290+ if ( bridgedCanonical ) {
291+ return { relatedAssetIds : [ ] , relatedAssetKey : bridgedCanonical }
292+ }
172293 return
173294 }
174295
175296 const relatedAssetKey = allRelatedAssetIds [ 0 ]
176297 const relatedAssetIds = allRelatedAssetIds . filter ( assetId => assetId !== relatedAssetKey )
177298
299+ if ( isDebugAsset ) {
300+ console . log (
301+ `[DEBUG CG] ${ assetId . slice ( 0 , 50 ) } ... RETURN relatedAssetKey=${ relatedAssetKey . slice ( 0 , 40 ) } ... (${ allRelatedAssetIds . length } total)` ,
302+ )
303+ }
304+
178305 return { relatedAssetIds, relatedAssetKey }
179306}
180307
@@ -186,30 +313,37 @@ const processRelatedAssetIds = async (
186313 assetId : AssetId ,
187314 assetData : Record < AssetId , PartialFields < Asset , 'relatedAssetKey' > > ,
188315 relatedAssetIndex : Record < AssetId , AssetId [ ] > ,
316+ categoryToCoinIds : Record < string , string [ ] > ,
189317 throttle : ( ) => Promise < void > ,
190318) : Promise < void > => {
191319 const existingRelatedAssetKey = assetData [ assetId ] . relatedAssetKey
192320
193- if ( existingRelatedAssetKey ) {
321+ if ( ! REGEN_ALL && existingRelatedAssetKey ) {
194322 return
195323 }
196324
197325 console . log ( `Processing related assetIds for ${ assetId } ` )
198326
199327 // Check if this asset is already in the relatedAssetIndex
200- for ( const [ key , relatedAssets ] of Object . entries ( relatedAssetIndex ) ) {
201- if ( relatedAssets . includes ( assetId ) ) {
202- if ( existingRelatedAssetKey !== key ) {
203- console . log (
204- `Updating relatedAssetKey for ${ assetId } from ${ existingRelatedAssetKey } to ${ key } ` ,
205- )
206- assetData [ assetId ] . relatedAssetKey = key
328+ if ( ! REGEN_ALL ) {
329+ for ( const [ key , relatedAssets ] of Object . entries ( relatedAssetIndex ) ) {
330+ if ( relatedAssets . includes ( assetId ) ) {
331+ if ( existingRelatedAssetKey !== key ) {
332+ console . log (
333+ `Updating relatedAssetKey for ${ assetId } from ${ existingRelatedAssetKey } to ${ key } ` ,
334+ )
335+ assetData [ assetId ] . relatedAssetKey = key
336+ }
337+ return // Early return - asset already processed and grouped
207338 }
208- return // Early return - asset already processed and grouped
209339 }
210340 }
211341
212- const coingeckoRelatedAssetsResult = await getCoingeckoRelatedAssetIds ( assetId , assetData )
342+ const coingeckoRelatedAssetsResult = await getCoingeckoRelatedAssetIds (
343+ assetId ,
344+ assetData ,
345+ categoryToCoinIds ,
346+ )
213347 . then ( result => {
214348 happyCount ++
215349 return result
@@ -244,16 +378,47 @@ const processRelatedAssetIds = async (
244378 relatedAssetIds : [ ] ,
245379 }
246380
381+ // Prioritize CoinGecko if it detected an Ethereum canonical (via our three-way check)
382+ // This prevents Zerion from overriding our canonical detection
383+ const ethereumCanonicals = Object . values ( BRIDGED_CATEGORY_MAPPINGS )
384+ const coingeckoDetectedCanonical =
385+ coingeckoRelatedAssetsResult ?. relatedAssetKey &&
386+ ethereumCanonicals . includes ( coingeckoRelatedAssetsResult . relatedAssetKey )
387+
247388 let relatedAssetKey =
248389 manualRelatedAssetsResult ?. relatedAssetKey ||
249- zerionRelatedAssetsResult ?. relatedAssetKey ||
250- coingeckoRelatedAssetsResult ?. relatedAssetKey ||
390+ ( coingeckoDetectedCanonical
391+ ? coingeckoRelatedAssetsResult ?. relatedAssetKey
392+ : zerionRelatedAssetsResult ?. relatedAssetKey ||
393+ coingeckoRelatedAssetsResult ?. relatedAssetKey ) ||
251394 assetId
252395
396+ // Debug logging for specific assets
397+ const isDebugAsset = [
398+ 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7' , // ETH USDT
399+ 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' , // ETH USDC
400+ 'eip155:1/erc20:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599' , // ETH WBTC
401+ 'eip155:43114/erc20:0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7' , // Avalanche USDT
402+ 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831' , // Arbitrum USDC
403+ 'eip155:42161/erc20:0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f' , // Arbitrum WBTC
404+ 'eip155:9745/erc20:0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb' , // Plasma USDT0
405+ ] . includes ( assetId )
406+
407+ if ( isDebugAsset ) {
408+ console . log (
409+ `[DEBUG PR] ${ assetId . slice ( 0 , 50 ) } ... INITIAL relatedAssetKey=${ relatedAssetKey . slice ( 0 , 40 ) } ...` ,
410+ )
411+ }
412+
253413 // If the relatedAssetKey itself points to another key, follow the chain to find the actual key
254414 // This handles the case where Tron WETH -> ETH WETH, but ETH WETH -> Arbitrum WETH
255415 const relatedAssetKeyData = assetData [ relatedAssetKey ] ?. relatedAssetKey
256416 if ( relatedAssetKeyData ) {
417+ if ( isDebugAsset ) {
418+ console . log (
419+ `[DEBUG PR] ${ assetId . slice ( 0 , 50 ) } ... CHAIN FOLLOW ${ relatedAssetKey . slice ( 0 , 40 ) } ... → ${ relatedAssetKeyData . slice ( 0 , 40 ) } ...` ,
420+ )
421+ }
257422 relatedAssetKey = relatedAssetKeyData
258423 }
259424
@@ -268,19 +433,6 @@ const processRelatedAssetIds = async (
268433 assetId ,
269434 ] ) ,
270435 )
271- // Filter to prevent USDT <-> USDT0 cross-contamination
272- // Allows bridged variants (BSC-USD, USDTE, AXLUSDT) while preventing USDT from claiming USDT0
273- . filter ( candidateAssetId => {
274- const candidate = assetData [ candidateAssetId ]
275- const current = assetData [ assetId ]
276-
277- // Detect USDT0 by symbol or name
278- const currentIsUsdt0 = current ?. symbol ?. includes ( 'USDT0' ) || current ?. name === 'USDT0'
279- const candidateIsUsdt0 = candidate ?. symbol ?. includes ( 'USDT0' ) || candidate ?. name === 'USDT0'
280-
281- // Must both be USDT0 or both NOT be USDT0
282- return currentIsUsdt0 === candidateIsUsdt0
283- } )
284436
285437 // First-come-first-served conflict detection
286438 // Filters out assets already claimed by a different group to prevent cross-contamination
@@ -316,6 +468,11 @@ const processRelatedAssetIds = async (
316468 relatedAssetIndex [ relatedAssetKey ] = Array . from (
317469 new Set ( [ ...currentGroup , ...cleanedRelatedAssetIds ] ) ,
318470 )
471+ if ( isDebugAsset ) {
472+ console . log (
473+ `[DEBUG PR] ${ assetId . slice ( 0 , 50 ) } ... MERGE into relatedAssetIndex[${ relatedAssetKey . slice ( 0 , 40 ) } ...] → ${ relatedAssetIndex [ relatedAssetKey ] . length } assets` ,
474+ )
475+ }
319476 }
320477
321478 // Always ensure all assets in the group have the correct relatedAssetKey
@@ -326,6 +483,12 @@ const processRelatedAssetIds = async (
326483 assetData [ relatedAssetId ] . relatedAssetKey = relatedAssetKey
327484 }
328485 }
486+
487+ if ( isDebugAsset ) {
488+ console . log (
489+ `[DEBUG PR] ${ assetId . slice ( 0 , 50 ) } ... FINAL assetData[${ assetId . slice ( 0 , 40 ) } ...].relatedAssetKey=${ relatedAssetKey . slice ( 0 , 40 ) } ...` ,
490+ )
491+ }
329492 } else {
330493 // If there are no related assets, set relatedAssetKey to null
331494 assetData [ assetId ] . relatedAssetKey = null
@@ -344,7 +507,9 @@ export const generateRelatedAssetIndex = async () => {
344507 )
345508
346509 const { assetData : generatedAssetData , sortedAssetIds } = decodeAssetData ( encodedAssetData )
347- const relatedAssetIndex = decodeRelatedAssetIndex ( encodedRelatedAssetIndex , sortedAssetIds )
510+ const relatedAssetIndex = REGEN_ALL
511+ ? { }
512+ : decodeRelatedAssetIndex ( encodedRelatedAssetIndex , sortedAssetIds )
348513
349514 // Remove stale related asset data from the assetData where the primary related asset no longer exists
350515 Object . values ( generatedAssetData ) . forEach ( asset => {
@@ -369,6 +534,11 @@ export const generateRelatedAssetIndex = async () => {
369534 )
370535 } )
371536
537+ // Pre-fetch bridged category mappings (6 API calls)
538+ console . log ( 'Pre-fetching bridged category mappings...' )
539+ const categoryToCoinIds = await fetchBridgedCategoryMappings ( )
540+ console . log ( '✓ Category mappings fetched\n' )
541+
372542 const { throttle, clear : clearThrottleInterval } = createThrottle ( {
373543 capacity : 50 , // Reduced initial capacity to allow for a burst but not too high
374544 costPerReq : 1 , // Keeping the cost per request as 1 for simplicity
@@ -381,7 +551,13 @@ export const generateRelatedAssetIndex = async () => {
381551 console . log ( `Processing chunk: ${ i } of ${ chunks . length } ` )
382552 await Promise . all (
383553 batch . map ( async assetId => {
384- await processRelatedAssetIds ( assetId , generatedAssetData , relatedAssetIndex , throttle )
554+ await processRelatedAssetIds (
555+ assetId ,
556+ generatedAssetData ,
557+ relatedAssetIndex ,
558+ categoryToCoinIds ,
559+ throttle ,
560+ )
385561 return
386562 } ) ,
387563 )
0 commit comments