Skip to content

Commit c11ff3f

Browse files
feat: implement bridged asset canonical detection and UI improvements
**Bridged Asset Canonical Detection:** - Add BRIDGED_CATEGORY_MAPPINGS for 6 Ethereum canonicals (WBTC, WETH, WSTETH, USDC, USDT, DAI) - Implement three-way canonical detection via CoinGecko: 1. Check if asset IS canonical (processes ETH WBTC/USDC/USDT directly) 2. Check if coinId in bridged category (catches Arbitrum WBTC, Polygon WBTC.E variants) 3. Check if platforms contains canonical (catches shared coin IDs like USDC/USDT) - Prioritize CoinGecko over Zerion when Ethereum canonical detected - Fix length <= 1 edge case to return canonical instead of undefined - Merge USDT0 into USDT group via bridged-usdt category - Pre-fetch bridged category mappings (+6 CoinGecko API calls, one-time) **UI Improvements:** - Deduplicate chain icons in grouped asset rows - Each chain now appears only once regardless of asset variant count **Results:** - ✅ ETH WBTC/WETH/WSTETH/USDC/USDT are primaries (clean "Wrapped Bitcoin" titles) - ✅ All L2 bridged variants point to Ethereum canonicals - ✅ USDT0 (Plasma) merged into USDT group as intended - ✅ No more duplicate chain icons in search results - ✅ No more ugly "Arbitrum Bridged WBTC" titles 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) <[email protected]>
1 parent 76574c4 commit c11ff3f

File tree

6 files changed

+226
-34
lines changed

6 files changed

+226
-34
lines changed

scripts/generateAssetData/generateAssetData.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import * as sui from './sui'
4545
import * as tronModule from './tron'
4646
import { filterOutBlacklistedAssets, getSortedAssetIds } from './utils'
4747

48+
const REGEN_ALL = process.env.REGEN_ALL === 'true'
49+
4850
const generateAssetData = async () => {
4951
const ethAssets = await ethereum.getAssets()
5052
const avalancheAssets = await avalanche.getAssets()
@@ -107,6 +109,7 @@ const generateAssetData = async () => {
107109
// Only preserve actual AssetId values, not null (null means "checked but no related assets found")
108110
// By not preserving null, we allow re-checking when upstream providers add new platforms
109111
if (
112+
!REGEN_ALL &&
110113
currentGeneratedAssetId?.relatedAssetKey &&
111114
currentGeneratedAssetId.relatedAssetKey !== null
112115
) {

scripts/generateAssetData/generateRelatedAssetIndex/generateRelatedAssetIndex.ts

Lines changed: 204 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import { zerionFungiblesSchema } from './validators/fungible'
3535

3636
import type { CoingeckoAssetDetails } from '@/lib/coingecko/types'
37+
import type { CoinGeckoMarketCap } from '@/lib/market-service/coingecko/coingecko-types'
3738
import 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
4849
const ZERION_API_KEY = process.env.ZERION_API_KEY
4950
if (!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+
5154
const 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+
5671
export const getManualRelatedAssetIds = (
5772
assetId: AssetId,
5873
): { relatedAssetIds: AssetId[]; relatedAssetKey: AssetId } | undefined => {
@@ -82,6 +97,31 @@ export const getManualRelatedAssetIds = (
8297
const 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+
85125
const 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 (
151191
const 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

Comments
 (0)