diff --git a/src/app/trade/provider.tsx b/src/app/trade/provider.tsx index 65808b170..5768a2a6d 100644 --- a/src/app/trade/provider.tsx +++ b/src/app/trade/provider.tsx @@ -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(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 @@ -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]) diff --git a/src/app/trade/utils/get-underlying-asset-symbol.test.ts b/src/app/trade/utils/get-underlying-asset-symbol.test.ts new file mode 100644 index 000000000..2b294278a --- /dev/null +++ b/src/app/trade/utils/get-underlying-asset-symbol.test.ts @@ -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('') + }) +}) diff --git a/src/app/trade/utils/get-underlying-asset-symbol.ts b/src/app/trade/utils/get-underlying-asset-symbol.ts new file mode 100644 index 000000000..fb3b8510d --- /dev/null +++ b/src/app/trade/utils/get-underlying-asset-symbol.ts @@ -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 " / 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() +} diff --git a/src/lib/utils/api/database.ts b/src/lib/utils/api/database.ts index a5a32b937..e1421ad02 100644 --- a/src/lib/utils/api/database.ts +++ b/src/lib/utils/api/database.ts @@ -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' @@ -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)) ?? '' -}