Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
29 changes: 27 additions & 2 deletions src/app/trade/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ import type { QuoteResult } from '@/lib/hooks/use-best-quote/types'

const eth2x = getTokenByChainAndSymbol(1, 'ETH2X')

/**
* Re-matches a token object by symbol against the connected chain's token lists.
* useQueryParams only re-resolves tokens when the URL changes and, for a bare URL,
* hands back the hard-coded defaults (mainnet ETH2X). Without this, that mainnet
* object would be used on Arbitrum/Base until the user touches a selector.
*/
function resolveTokenForChain<T extends Token>(token: T, chainId: number): T {
const match = [
...getLeverageTokens(chainId),
...getCurrencyTokens(chainId),
].find(
(candidate) =>
candidate.symbol.toLowerCase() === token.symbol.toLowerCase(),
)

return (match as T | undefined) ?? token
}

interface TokenContext {
inputValue: string
isMinting: boolean
Expand Down Expand Up @@ -131,14 +149,21 @@ export function LeverageProvider(props: { children: any }) {
const [inputValue, setInputValue] = useState('')

const isMinting = queryIsMinting
const inputToken = queryInputToken
const outputToken = queryOutputToken
const baseToken = queryBaseToken

const chainId = useMemo(() => {
return chainIdRaw ?? ARBITRUM.chainId
}, [chainIdRaw])

const inputToken = useMemo(
() => resolveTokenForChain(queryInputToken, chainId),
[queryInputToken, chainId],
)
const outputToken = useMemo(
() => resolveTokenForChain(queryOutputToken, chainId),
[queryOutputToken, chainId],
)

const indexToken = useMemo(() => {
return isMinting ? outputToken : inputToken
}, [inputToken, isMinting, outputToken])
Expand Down
97 changes: 97 additions & 0 deletions src/app/trade/utils/get-underlying-asset-symbol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {
getTokenByChainAndAddress,
getTokenByChainAndSymbol,
getUnderlyingToken,
isLeverageToken,
tokenlist,
} from '@indexcoop/tokenlists'
import { arbitrum, base, mainnet } from 'viem/chains'

import { USDC } from '@/constants/tokens'

import { getUnderlyingAssetSymbol } from './get-underlying-asset-symbol'

/**
* Verbatim copy of the derivation that lived in src/lib/utils/api/database.ts before the fix.
* Kept here so the parity test documents exactly which behaviour was preserved.
*/
const legacyGetUnderlyingAssetSymbol = (
chainId: number,
address: string | undefined,
) => {
const possible = [
'ETH',
'BTC',
'SUI',
'SOL',
'XRP',
'AAVE',
'ARB',
'LINK',
'XAUt',
'MATIC',
]

const token = getTokenByChainAndAddress(chainId, address)

if (isLeverageToken(token)) {
const { symbol } = getUnderlyingToken(token)

return possible.find((p) => symbol.includes(p)) ?? ''
}

return possible.find((p) => token?.symbol.includes(p)) ?? ''
}

describe('getUnderlyingAssetSymbol', () => {
const leverageTokens = tokenlist.tokens.filter(isLeverageToken)

beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {})
})

afterEach(() => {
jest.restoreAllMocks()
})

it('has leverage tokens to check against', () => {
expect(leverageTokens.length).toBeGreaterThan(0)
})

it.each(leverageTokens.map((token) => [token.chainId, token.symbol, token]))(
'keeps the previous result for chain %s %s',
(_chainId, _symbol, token) => {
const expected = legacyGetUnderlyingAssetSymbol(
token.chainId,
token.address,
).toUpperCase()

expect(expected).not.toBe('')
expect(getUnderlyingAssetSymbol(token.chainId, token)).toBe(expected)
},
)

it('resolves a token object built for another chain by its symbol (the "/ USD" bug)', () => {
const mainnetEth2x = getTokenByChainAndSymbol(mainnet.id, 'ETH2X')

// The old derivation returned '' for exactly this input …
expect(
legacyGetUnderlyingAssetSymbol(arbitrum.id, mainnetEth2x.address),
).toBe('')

// … the new one resolves ETH2X on the requested chain instead.
expect(getUnderlyingAssetSymbol(arbitrum.id, mainnetEth2x)).toBe('ETH')
expect(getUnderlyingAssetSymbol(base.id, mainnetEth2x)).toBe('ETH')
expect(console.warn).toHaveBeenCalled()
})

it('still returns an empty string for non-leverage tokens', () => {
expect(getUnderlyingAssetSymbol(arbitrum.id, USDC)).toBe('')
expect(
getUnderlyingAssetSymbol(arbitrum.id, {
symbol: 'NOPE',
address: '0x0000000000000000000000000000000000000001',
}),
).toBe('')
})
})
92 changes: 92 additions & 0 deletions src/app/trade/utils/get-underlying-asset-symbol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
getTokenByChainAndAddress,
getTokenByChainAndSymbol,
getUnderlyingToken,
isLeverageToken,
type ListedToken,
} from '@indexcoop/tokenlists'

/**
* Market symbols we price and display by. Matched as substrings of the
* underlying token's symbol so that e.g. WETH / cbBTC / uSOL map to ETH / BTC / SOL.
*/
const KNOWN_UNDERLYING_SYMBOLS = [
'ETH',
'BTC',
'SUI',
'SOL',
'XRP',
'AAVE',
'ARB',
'LINK',
'XAUt',
'MATIC',
] as const

type TokenLike = {
address?: string | null
symbol: string
}

const matchKnownSymbol = (symbol: string) =>
KNOWN_UNDERLYING_SYMBOLS.find((known) => symbol.includes(known))

/**
* Resolves a token against the tokenlist for the given chain.
* Tries the address first; if that misses, falls back to the symbol so that a token
* object built for a different chain (e.g. the mainnet ETH2X default used while the
* wallet is on Arbitrum) still resolves to the right chain's token.
*/
function resolveListedToken(
chainId: number,
token: TokenLike,
): ListedToken | null {
const byAddress = token.address
? getTokenByChainAndAddress(chainId, token.address)
: null

if (byAddress) return byAddress

const bySymbol = getTokenByChainAndSymbol(chainId, token.symbol)

if (bySymbol && token.address) {
console.warn(
'[getUnderlyingAssetSymbol] token address not found on chain, resolved by symbol instead',
{ chainId, address: token.address, symbol: token.symbol },
)
}

return bySymbol
}

/**
* Derives the market symbol ("ETH", "BTC", "SOL", …) that is persisted as a trade's
* `underlyingAssetSymbol` and rendered as "<symbol> / USD".
*
* Always upper-cased. Returns an empty string when the symbol is not in
* KNOWN_UNDERLYING_SYMBOLS (same as before) — consumers such as the leverage history
* route rely on '' to skip price lookups, so an unknown value must never leak through.
* When a new market is listed, add its base asset to KNOWN_UNDERLYING_SYMBOLS.
*/
export function getUnderlyingAssetSymbol(
chainId: number,
token: TokenLike,
): string {
const listed = resolveListedToken(chainId, token)

if (isLeverageToken(listed)) {
const underlyingSymbol = getUnderlyingToken(listed)?.symbol ?? ''
const known = matchKnownSymbol(underlyingSymbol)

if (!known) {
console.warn(
'[getUnderlyingAssetSymbol] underlying symbol is not in KNOWN_UNDERLYING_SYMBOLS',
{ chainId, symbol: listed.symbol, underlyingSymbol },
)
}

return (known ?? '').toUpperCase()
}

return (matchKnownSymbol(listed?.symbol ?? token.symbol) ?? '').toUpperCase()
}
41 changes: 6 additions & 35 deletions src/lib/utils/api/database.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import {
getTokenByChainAndAddress,
getUnderlyingToken,
isLeverageToken,
} from '@indexcoop/tokenlists'
import { formatUnits } from 'viem'

import { getUnderlyingAssetSymbol } from '@/app/trade/utils/get-underlying-asset-symbol'

import type { PostApiV2TradeMutationRequest } from '@/gen'
import type { Quote } from '@/lib/hooks/use-best-quote/types'
import type { UtmParam } from '@/lib/store/utm-atoms'
Expand Down Expand Up @@ -56,34 +53,8 @@ export const mapQuoteToTrade = (
? (utm as PostApiV2TradeMutationRequest['utm'])
: undefined,
createdAt: new Date(),
underlyingAssetSymbol: getUnderlyingAssetSymbol(quote).toUpperCase(),
underlyingAssetSymbol: getUnderlyingAssetSymbol(
Number(quote.chainId),
quote.isMinting ? quote.outputToken : quote.inputToken,
),
})

const getUnderlyingAssetSymbol = (quote: Quote) => {
const possible = [
'ETH',
'BTC',
'SUI',
'SOL',
'XRP',
'AAVE',
'ARB',
'LINK',
'XAUt',
'MATIC',
]

const address = quote.isMinting
? quote.outputToken.address
: quote.inputToken.address

const token = getTokenByChainAndAddress(quote.chainId, address)

if (isLeverageToken(token)) {
const { symbol } = getUnderlyingToken(token)

return possible.find((p) => symbol.includes(p)) ?? ''
}

return possible.find((p) => token?.symbol.includes(p)) ?? ''
}
Loading