Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
4 changes: 4 additions & 0 deletions scripts/generateAssetData/generateAssetData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ import * as sui from './sui'
import * as tronModule from './tron'
import { filterOutBlacklistedAssets, getSortedAssetIds } from './utils'

// To regenerate all relatedAssetKey values, run: REGEN_ALL=true yarn generate:asset-data
const REGEN_ALL = process.env.REGEN_ALL === 'true'

const generateAssetData = async () => {
const ethAssets = await ethereum.getAssets()
const avalancheAssets = await avalanche.getAssets()
Expand Down Expand Up @@ -107,6 +110,7 @@ const generateAssetData = async () => {
// Only preserve actual AssetId values, not null (null means "checked but no related assets found")
// By not preserving null, we allow re-checking when upstream providers add new platforms
if (
!REGEN_ALL &&
currentGeneratedAssetId?.relatedAssetKey &&
currentGeneratedAssetId.relatedAssetKey !== null
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { zerionFungiblesSchema } from './validators/fungible'

import type { CoingeckoAssetDetails } from '@/lib/coingecko/types'
import type { CoinGeckoMarketCap } from '@/lib/market-service/coingecko/coingecko-types'
import type { PartialFields } from '@/lib/types'

// NOTE: this must call the zerion api directly rather than our proxy because of rate limiting requirements
Expand All @@ -48,11 +49,25 @@ axiosRetry(axiosInstance, { retries: 5, retryDelay: axiosRetry.exponentialDelay
const ZERION_API_KEY = process.env.ZERION_API_KEY
if (!ZERION_API_KEY) throw new Error('Missing Zerion API key - see readme for instructions')

const REGEN_ALL = process.env.REGEN_ALL === 'true'

const manualRelatedAssetIndex: Record<AssetId, AssetId[]> = {
[ethAssetId]: [optimismAssetId, arbitrumAssetId, arbitrumNovaAssetId, baseAssetId],
[foxAssetId]: [foxOnArbitrumOneAssetId],
}

// Category → Canonical Asset mapping for bridged tokens
// Maps CoinGecko bridged categories to their Ethereum canonical tokens
// Note: bridged-usdt includes USDT0 variants - they will be grouped together with ETH USDT as primary
const BRIDGED_CATEGORY_MAPPINGS: Record<string, AssetId> = {
'bridged-usdc': 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // ETH USDC
'bridged-usdt': 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', // ETH USDT (includes USDT0)
'bridged-weth': 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', // ETH WETH
'bridged-wbtc': 'eip155:1/erc20:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', // ETH WBTC
'bridged-dai': 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', // ETH DAI
'bridged-wsteth': 'eip155:1/erc20:0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0', // ETH wstETH
}

export const getManualRelatedAssetIds = (
assetId: AssetId,
): { relatedAssetIds: AssetId[]; relatedAssetKey: AssetId } | undefined => {
Expand Down Expand Up @@ -82,6 +97,30 @@ export const getManualRelatedAssetIds = (
const isSome = <T>(option: T | null | undefined): option is T =>
!isUndefined(option) && !isNull(option)

// Pre-fetch bridged category mappings
// Returns mapping of category → array of coin IDs in that category
const fetchBridgedCategoryMappings = async (): Promise<Record<string, string[]>> => {
const categoryToCoinIds: Record<string, string[]> = {}

for (const category of Object.keys(BRIDGED_CATEGORY_MAPPINGS)) {
const { data } = await axiosInstance.get<CoinGeckoMarketCap[]>(
`${coingeckoBaseUrl}/coins/markets`,
{
params: {
category,
vs_currency: 'usd',
per_page: 250,
page: 1,
},
},
)

categoryToCoinIds[category] = data.map(coin => coin.id)
}

return categoryToCoinIds
}

const chunkArray = <T>(array: T[], chunkSize: number) => {
const result = []
for (let i = 0; i < array.length; i += chunkSize) {
Expand All @@ -91,8 +130,6 @@ const chunkArray = <T>(array: T[], chunkSize: number) => {
return result
}

const PLASMA_USDT0_ASSET_ID = 'eip155:9745/erc20:0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb'

const getZerionRelatedAssetIds = async (
assetId: AssetId,
assetData: Record<AssetId, PartialFields<Asset, 'relatedAssetKey'>>,
Expand Down Expand Up @@ -132,14 +169,13 @@ const getZerionRelatedAssetIds = async (

const implementations = firstEntry.attributes.implementations

// Use all assetIds actually present in the dataset, excluding Plasma USDT0 (corrupt CoinGecko data)
// Use all assetIds actually present in the dataset
const allRelatedAssetIds = implementations
?.map(zerionImplementationToMaybeAssetId)
.filter(isSome)
.filter(relatedAssetId => {
return assetData[relatedAssetId] !== undefined
})
.filter(relatedAssetId => relatedAssetId !== PLASMA_USDT0_ASSET_ID)

if (!allRelatedAssetIds || allRelatedAssetIds.length <= 1) {
return
Expand All @@ -154,6 +190,7 @@ const getZerionRelatedAssetIds = async (
const getCoingeckoRelatedAssetIds = async (
assetId: AssetId,
assetData: Record<AssetId, PartialFields<Asset, 'relatedAssetKey'>>,
categoryToCoinIds: Record<string, string[]>,
): Promise<{ relatedAssetIds: AssetId[]; relatedAssetKey: AssetId } | undefined> => {
if (!isToken(assetId)) return
// Yes, this means effectively the same but double wrap never hurts
Expand All @@ -164,15 +201,57 @@ const getCoingeckoRelatedAssetIds = async (
const { data } = await axios.get<CoingeckoAssetDetails>(`${coingeckoBaseUrl}/coins/${coinUri}`)

const platforms = data.platforms
const coinId = data.id

// Use all assetIds actually present in the dataset, excluding Plasma USDT0 (corrupt CoinGecko data)
const allRelatedAssetIds = Object.entries(platforms)
// Use all assetIds actually present in the dataset
let allRelatedAssetIds = Object.entries(platforms)
?.map(coingeckoPlatformDetailsToMaybeAssetId)
.filter(isSome)
.filter(relatedAssetId => assetData[relatedAssetId] !== undefined)
.filter(relatedAssetId => relatedAssetId !== PLASMA_USDT0_ASSET_ID)

// Determine canonical asset in THREE ways:
let bridgedCanonical: AssetId | undefined

// 1. Check if THIS asset is an Ethereum canonical (e.g., processing ETH USDT itself)
const ethereumCanonicals = Object.values(BRIDGED_CATEGORY_MAPPINGS)
if (ethereumCanonicals.includes(assetId)) {
bridgedCanonical = assetId
}

// 2. Check if this coin is in a bridged category (catches bridged variants with unique coin IDs)
if (!bridgedCanonical) {
for (const [category, coinIds] of Object.entries(categoryToCoinIds)) {
if (coinIds.includes(coinId)) {
bridgedCanonical = BRIDGED_CATEGORY_MAPPINGS[category]
break
}
}
}

// 3. Check if platforms list contains an Ethereum canonical (catches shared coin IDs like USDC/USDT)
// CoinGecko uses the same coin ID for native USDC/USDT across multiple chains
if (!bridgedCanonical) {
for (const canonical of ethereumCanonicals) {
if (allRelatedAssetIds.includes(canonical)) {
bridgedCanonical = canonical
break
}
}
}

// Add canonical FIRST to ensure it becomes the primary (relatedAssetKey)
// This fixes the first-come-first-served issue where non-canonical assets became primaries
if (bridgedCanonical && assetData[bridgedCanonical]) {
allRelatedAssetIds.unshift(bridgedCanonical)
// Remove duplicates while preserving order
allRelatedAssetIds = Array.from(new Set(allRelatedAssetIds))
}

if (allRelatedAssetIds.length <= 1) {
// Still return canonical even if no other assets yet (fixes Zerion override for WBTC/WETH/WSTETH)
if (bridgedCanonical) {
return { relatedAssetIds: [], relatedAssetKey: bridgedCanonical }
}
return
}

Expand All @@ -190,38 +269,37 @@ const processRelatedAssetIds = async (
assetId: AssetId,
assetData: Record<AssetId, PartialFields<Asset, 'relatedAssetKey'>>,
relatedAssetIndex: Record<AssetId, AssetId[]>,
categoryToCoinIds: Record<string, string[]>,
throttle: () => Promise<void>,
): Promise<void> => {
// Skip related asset generation for Plasma usdt0 - Coingecko has corrupt data claiming
// it shares the same Arbitrum/Polygon contracts as real USDT, which corrupts groupings
if (assetId === PLASMA_USDT0_ASSET_ID) {
assetData[assetId].relatedAssetKey = null
await throttle()
return
}

const existingRelatedAssetKey = assetData[assetId].relatedAssetKey

if (existingRelatedAssetKey) {
if (!REGEN_ALL && existingRelatedAssetKey) {
return
}

console.log(`Processing related assetIds for ${assetId}`)

// Check if this asset is already in the relatedAssetIndex
for (const [key, relatedAssets] of Object.entries(relatedAssetIndex)) {
if (relatedAssets.includes(assetId)) {
if (existingRelatedAssetKey !== key) {
console.log(
`Updating relatedAssetKey for ${assetId} from ${existingRelatedAssetKey} to ${key}`,
)
assetData[assetId].relatedAssetKey = key
if (!REGEN_ALL) {
for (const [key, relatedAssets] of Object.entries(relatedAssetIndex)) {
if (relatedAssets.includes(assetId)) {
if (existingRelatedAssetKey !== key) {
console.log(
`Updating relatedAssetKey for ${assetId} from ${existingRelatedAssetKey} to ${key}`,
)
assetData[assetId].relatedAssetKey = key
}
return // Early return - asset already processed and grouped
}
return // Early return - asset already processed and grouped
}
}

const coingeckoRelatedAssetsResult = await getCoingeckoRelatedAssetIds(assetId, assetData)
const coingeckoRelatedAssetsResult = await getCoingeckoRelatedAssetIds(
assetId,
assetData,
categoryToCoinIds,
)
.then(result => {
happyCount++
return result
Expand Down Expand Up @@ -256,10 +334,19 @@ const processRelatedAssetIds = async (
relatedAssetIds: [],
}

// Prioritize CoinGecko if it detected an Ethereum canonical (via our three-way check)
// This prevents Zerion from overriding our canonical detection
const ethereumCanonicals = Object.values(BRIDGED_CATEGORY_MAPPINGS)
const coingeckoDetectedCanonical =
coingeckoRelatedAssetsResult?.relatedAssetKey &&
ethereumCanonicals.includes(coingeckoRelatedAssetsResult.relatedAssetKey)

let relatedAssetKey =
manualRelatedAssetsResult?.relatedAssetKey ||
zerionRelatedAssetsResult?.relatedAssetKey ||
coingeckoRelatedAssetsResult?.relatedAssetKey ||
(coingeckoDetectedCanonical
? coingeckoRelatedAssetsResult?.relatedAssetKey
: zerionRelatedAssetsResult?.relatedAssetKey ||
coingeckoRelatedAssetsResult?.relatedAssetKey) ||
assetId

// If the relatedAssetKey itself points to another key, follow the chain to find the actual key
Expand All @@ -269,13 +356,6 @@ const processRelatedAssetIds = async (
relatedAssetKey = relatedAssetKeyData
}

// If the relatedAssetKey is Plasma USDT0, reject this entire grouping
if (relatedAssetKey === PLASMA_USDT0_ASSET_ID) {
assetData[assetId].relatedAssetKey = null
await throttle()
return
}

const zerionRelatedAssetIds = zerionRelatedAssetsResult?.relatedAssetIds ?? []
const coingeckoRelatedAssetIds = coingeckoRelatedAssetsResult?.relatedAssetIds ?? []

Expand All @@ -286,10 +366,30 @@ const processRelatedAssetIds = async (
...coingeckoRelatedAssetIds,
assetId,
]),
).filter(id => id !== PLASMA_USDT0_ASSET_ID) // Filter out Plasma USDT0 from final merged array
)

// First-come-first-served conflict detection
// Filters out assets already claimed by a different group to prevent cross-contamination
const cleanedRelatedAssetIds = mergedRelatedAssetIds.filter(candidateAssetId => {
const existingKey = assetData[candidateAssetId]?.relatedAssetKey

// Asset has no group yet, or is already in the current group - OK to include
if (!existingKey || existingKey === relatedAssetKey) {
return true
}

// Asset already belongs to a different group - reject to prevent stealing
console.warn(
`[Related Asset Conflict] Asset ${candidateAssetId} already belongs to group ${existingKey}, ` +
`refusing to add to ${relatedAssetKey}. ` +
`This asset was claimed by a higher market cap token that processed first. ` +
`Upstream data provider (CoinGecko/Zerion) may have data quality issues.`,
)
return false
})

// Has zerion-provided related assets, or manually added ones
const hasRelatedAssets = mergedRelatedAssetIds.length > 1
const hasRelatedAssets = cleanedRelatedAssetIds.length > 1

if (hasRelatedAssets) {
// Check if this exact group already exists in the index (can happen with parallel processing)
Expand All @@ -300,7 +400,7 @@ const processRelatedAssetIds = async (
// Merge with existing group instead of replacing it
const currentGroup = relatedAssetIndex[relatedAssetKey] || []
relatedAssetIndex[relatedAssetKey] = Array.from(
new Set([...currentGroup, ...mergedRelatedAssetIds]),
new Set([...currentGroup, ...cleanedRelatedAssetIds]),
)
}

Expand Down Expand Up @@ -330,24 +430,20 @@ export const generateRelatedAssetIndex = async () => {
)

const { assetData: generatedAssetData, sortedAssetIds } = decodeAssetData(encodedAssetData)
const relatedAssetIndex = decodeRelatedAssetIndex(encodedRelatedAssetIndex, sortedAssetIds)
const relatedAssetIndex = REGEN_ALL
? {}
: decodeRelatedAssetIndex(encodedRelatedAssetIndex, sortedAssetIds)

// Remove stale related asset data from the assetData where:
// a) the primary related asset no longer exists in the dataset
// b) the related asset key is Plasma usdt0 (corrupt Coingecko data)
// Remove stale related asset data from the assetData where the primary related asset no longer exists
Object.values(generatedAssetData).forEach(asset => {
const relatedAssetKey = asset.relatedAssetKey

if (!relatedAssetKey) return

const primaryRelatedAsset = generatedAssetData[relatedAssetKey]

// Clear Plasma usdt0 related asset key - Coingecko has corrupt data for this token
const isPlasmaUsdt0 =
relatedAssetKey === 'eip155:9745/erc20:0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb'

// remove relatedAssetKey from the existing data to ensure the related assets get updated
if (primaryRelatedAsset === undefined || isPlasmaUsdt0) {
if (primaryRelatedAsset === undefined) {
delete relatedAssetIndex[relatedAssetKey]
delete asset.relatedAssetKey
}
Expand All @@ -361,6 +457,8 @@ export const generateRelatedAssetIndex = async () => {
)
})

const categoryToCoinIds = await fetchBridgedCategoryMappings()

const { throttle, clear: clearThrottleInterval } = createThrottle({
capacity: 50, // Reduced initial capacity to allow for a burst but not too high
costPerReq: 1, // Keeping the cost per request as 1 for simplicity
Expand All @@ -373,7 +471,13 @@ export const generateRelatedAssetIndex = async () => {
console.log(`Processing chunk: ${i} of ${chunks.length}`)
await Promise.all(
batch.map(async assetId => {
await processRelatedAssetIds(assetId, generatedAssetData, relatedAssetIndex, throttle)
await processRelatedAssetIds(
assetId,
generatedAssetData,
relatedAssetIndex,
categoryToCoinIds,
throttle,
)
return
}),
)
Expand Down
13 changes: 9 additions & 4 deletions src/components/AssetSearch/components/GroupedAssetRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,16 @@ export const GroupedAssetRow: FC<GroupedAssetRowProps> = ({
)

const networksIcons = useMemo(() => {
return relatedAssetIds.map((assetId, index) => {
const feeAsset = selectFeeAssetByChainId(store.getState(), fromAssetId(assetId).chainId)
// Deduplicate by chainId to show each chain only once
const uniqueChainIds = Array.from(
new Set(relatedAssetIds.map(assetId => fromAssetId(assetId).chainId)),
)

return uniqueChainIds.map((chainId, index) => {
const feeAsset = selectFeeAssetByChainId(store.getState(), chainId)
return (
<Box
key={feeAsset?.chainId}
key={chainId}
borderRadius='full'
display='flex'
alignItems='center'
Expand All @@ -101,7 +106,7 @@ export const GroupedAssetRow: FC<GroupedAssetRowProps> = ({
boxSize='16px'
color='white'
fontWeight='bold'
zIndex={relatedAssetIds.length - index} // Higher z-index for earlier items
zIndex={uniqueChainIds.length - index} // Higher z-index for earlier items
ml={index > 0 ? -1.5 : 0}
border='1px solid'
borderColor='background.surface.overlay.base'
Expand Down
2 changes: 1 addition & 1 deletion src/lib/asset-service/service/encodedAssetData.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Loading