Skip to content

Commit 1b9598b

Browse files
authored
Upgrade Jupiter API to V2/V3 and optimize token handling (#30)
1 parent ad14566 commit 1b9598b

8 files changed

Lines changed: 300 additions & 181 deletions

File tree

components/treasuryV2/WalletList/WalletListItem/AssetList/TokenList.tsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, { useMemo } from 'react'
22
import cx from 'classnames'
33

44
import { AssetType, Token, Sol } from '@models/treasury/Asset'
@@ -18,23 +18,31 @@ interface Props {
1818
}
1919

2020
export default function TokenList(props: Props) {
21-
const tokens = props.tokens.sort((a, b) => {
22-
const aTotal = a.count.multipliedBy(a.value)
23-
const bTotal = b.count.multipliedBy(b.value)
21+
const tokens = useMemo(() => {
22+
return props.tokens.sort((a, b) => {
23+
const aHasValue = a.value.isGreaterThan(0)
24+
const bHasValue = b.value.isGreaterThan(0)
2425

25-
if (aTotal.eq(bTotal)) {
26-
return b.count.comparedTo(a.count)
27-
}
26+
// Tokens with value come first
27+
if (aHasValue && !bHasValue) return -1
28+
if (!aHasValue && bHasValue) return 1
2829

29-
return bTotal.comparedTo(aTotal)
30-
})
30+
// Both have value: sort by highest value
31+
if (aHasValue && bHasValue) {
32+
return b.value.comparedTo(a.value)
33+
}
3134

32-
const expandCutoff = Math.max(
33-
tokens.findIndex((token) =>
34-
token.value.multipliedBy(token.count).isEqualTo(0),
35-
),
36-
3,
37-
)
35+
// Neither has value: sort by count
36+
return b.count.comparedTo(a.count)
37+
})
38+
}, [props?.tokens])
39+
40+
const expandCutoff = useMemo(() => {
41+
return Math.max(
42+
tokens.findIndex((token) => token.value.isEqualTo(0)),
43+
3,
44+
)
45+
}, [tokens])
3846

3947
return (
4048
<Collapsible

components/treasuryV2/WalletList/WalletListItem/AssetList/TokenListItem.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, { useMemo } from 'react'
22
import type { BigNumber } from 'bignumber.js'
33
import cx from 'classnames'
44

@@ -17,14 +17,21 @@ interface Props {
1717
onSelect?(): void
1818
}
1919

20+
// eslint-disable-next-line no-control-regex
21+
const CONTROL_CHARS_REGEX = /[\u0000-\u001F\u007F-\u009F]/g
22+
2023
export default function TokenListItem(props: Props) {
24+
const sanitizedSymbol = useMemo(() => {
25+
return props.symbol?.replace(CONTROL_CHARS_REGEX, '').trim() || ''
26+
}, [props.symbol])
27+
2128
return (
2229
<ListItem
2330
className={props.className}
2431
name={props.name}
2532
rhs={
2633
<div className="flex items-end flex-col">
27-
<div className="flex items-center space-x-1">
34+
<div className="flex items-center space-x-1 text-right">
2835
<div className="text-xs text-fgd-1 font-bold">
2936
{props.amount.isLessThan(0)
3037
? formatNumber(props.amount, undefined, {})
@@ -34,9 +41,11 @@ export default function TokenListItem(props: Props) {
3441
})
3542
: formatNumber(props.amount)}
3643
</div>
37-
<div className="text-xs text-fgd-1">{props.symbol}</div>
44+
<div className="text-xs text-fgd-1 text-right">
45+
{sanitizedSymbol}
46+
</div>
3847
</div>
39-
{props.price && (
48+
{!!props.price && (
4049
<div className="text-xs text-white/50">
4150
${formatNumber(props.amount.multipliedBy(props.price))}
4251
</div>

components/treasuryV2/WalletList/WalletListItem/AssetList/index.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,17 @@ interface Props {
8181

8282
export default function AssetList(props: Props) {
8383
const { indicatorTokens } = useDefi()
84-
const assets = props.assets.filter(
84+
const assets = useMemo(() => props.assets.filter(
8585
(a) =>
8686
a.type !== AssetType.Token ||
8787
!indicatorTokens.includes(a.mintAddress ?? '')
88-
)
88+
), [props.assets, indicatorTokens])
8989
const tokensFromProps = useMemo(() => {
9090
return assets
9191
.filter(isTokenLike)
92-
.sort((a, b) => b.value.comparedTo(a.value))
93-
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree
92+
// .sort((a, b) => b.value.comparedTo(a.value))
93+
.sort((a, b) => b.value?.toNumber() - a.value?.toNumber())
94+
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree
9495
}, [])
9596
const tokensFromPropsFiltered = tokensFromProps.filter(
9697
(token) =>

components/treasuryV2/WalletList/WalletListItem/AssetsPreviewIconList.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ interface Props {
9595
export default function AssetsPreviewIconList(props: Props) {
9696
const { indicatorTokens } = useDefi()
9797
const nfts = useGovernanceNfts(props.governance) ?? []
98-
const tokens = (props.assets.filter(
98+
const tokens = props.assets.filter(
9999
(t) => isToken(t) && !indicatorTokens.includes(t.mintAddress ?? '')
100-
) as Token[]).sort((a, b) => b.value.comparedTo(a.value))
100+
) as Token[]
101101
const sol = props.assets.filter(isSol)
102102
const councilMint: Mint | undefined = props.assets.filter(isCouncilMint)[0]
103103
const communityMint: Mint | undefined =
@@ -158,14 +158,20 @@ export default function AssetsPreviewIconList(props: Props) {
158158
// Display the tokens next
159159
if (tokens.length) {
160160
const list = tokens.sort((a, b) => {
161-
const aTotal = a.count.multipliedBy(a.value)
162-
const bTotal = b.count.multipliedBy(b.value)
161+
const aHasValue = a.value.isGreaterThan(0)
162+
const bHasValue = b.value.isGreaterThan(0)
163163

164-
if (aTotal.eq(bTotal)) {
165-
return b.count.comparedTo(a.count)
164+
// Tokens with value come first
165+
if (aHasValue && !bHasValue) return -1
166+
if (!aHasValue && bHasValue) return 1
167+
168+
// Both have value: sort by highest value
169+
if (aHasValue && bHasValue) {
170+
return b.value.comparedTo(a.value)
166171
}
167172

168-
return bTotal.comparedTo(aTotal)
173+
// Neither has value: sort by count
174+
return b.count.comparedTo(a.count)
169175
})
170176
// Show atleast one token
171177
previewList.push(list[0].icon)

hooks/queries/jupiterPrice.ts

Lines changed: 87 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,57 +2,46 @@ import { PublicKey } from '@solana/web3.js'
22
import { useQuery } from '@tanstack/react-query'
33
import queryClient from './queryClient'
44

5-
const URL = 'https://lite-api.jup.ag/price/v2'
5+
const PRICE_URL = 'https://lite-api.jup.ag/price/v3'
66

7-
/* example query
7+
/* example query - Price API V3
88
# Unit price of 1 JUP & 1 SOL based on the Derived Price in USDC
9-
https://api.jup.ag/price/v2?ids=JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN,So11111111111111111111111111111111111111112
9+
https://lite-api.jup.ag/price/v3?ids=JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN,So11111111111111111111111111111111111111112
1010
1111
{
12-
"data": {
13-
"So11111111111111111111111111111111111111112": {
14-
"id": "So11111111111111111111111111111111111111112",
15-
"type": "derivedPrice",
16-
"price": "133.890945000"
17-
},
18-
"JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN": {
19-
"id": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
20-
"type": "derivedPrice",
21-
"price": "0.751467"
22-
}
23-
},
24-
"timeTaken": 0.00395219
12+
"JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN": {
13+
"usdPrice": 0.4056018512541055,
14+
"blockId": 348004026,
15+
"decimals": 6,
16+
"priceChange24h": 0.5292887924920519
17+
},
18+
"So11111111111111111111111111111111111111112": {
19+
"usdPrice": 147.4789340738336,
20+
"blockId": 348004023,
21+
"decimals": 9,
22+
"priceChange24h": 1.2907622140620008
23+
}
2524
}
2625
*/
27-
/* example intentionally broken query
28-
curl -X 'GET' 'https://api.jup.ag/price/v2?ids=So11111111111111111111111111111111111111112&showExtraInfo=true'
29-
{
30-
"data": {
31-
"So11111111111111111111111111111111111111112": {
32-
"id": "So11111111111111111111111111111111111111112",
33-
"type": "derivedPrice",
34-
"price": "134.170633378"
35-
},
36-
"8agCopCHWdpj7mHk3JUWrzt8pHAxMiPX5hLVDJh9TXWv": null
37-
},
38-
"timeTaken": 0.003186833
26+
27+
// Price API V3 Response
28+
type PriceV3Response = {
29+
usdPrice: number
30+
blockId: number
31+
decimals: number
32+
priceChange24h?: number
3933
}
40-
*/
4134

35+
// Internal Price format (for backwards compatibility)
4236
type Price = {
4337
id: string // pubkey,
4438
// price is in USD
4539
price: number
46-
// removed in v2 API
47-
// mintSymbol: string
48-
// vsToken: string // pubkey,
49-
// vsTokenSymbol: string
50-
}
51-
type Response = {
52-
data: Record<string, Price> //uses whatever you input (so, pubkey OR symbol). no entry if data not found
53-
timeTaken: number
5440
}
5541

42+
// V3 API returns direct object mapping, not wrapped in "data"
43+
type Response = Record<string, PriceV3Response | null>
44+
5645
function* chunks<T>(arr: T[], n: number): Generator<T[], void> {
5746
for (let i = 0; i < arr.length; i += n) {
5847
yield arr.slice(i, i + n)
@@ -69,12 +58,20 @@ export const jupiterPriceQueryKeys = {
6958
}
7059

7160
const jupQueryFn = async (mint: PublicKey) => {
72-
const x = await fetch(`${URL}?ids=${mint?.toString()}`)
61+
const x = await fetch(`${PRICE_URL}?ids=${mint?.toString()}`)
7362
const response = (await x.json()) as Response
74-
const result = response.data[mint.toString()]
75-
return result !== undefined
76-
? ({ found: true, result } as const)
77-
: ({ found: false, result: undefined } as const)
63+
const priceData = response[mint.toString()]
64+
65+
// Convert V3 response to internal format
66+
if (priceData && priceData.usdPrice !== undefined) {
67+
const result: Price = {
68+
id: mint.toString(),
69+
price: priceData.usdPrice,
70+
}
71+
return { found: true, result } as const
72+
}
73+
74+
return { found: false, result: undefined } as const
7875
}
7976

8077
export const useJupiterPriceByMintQuery = (mint: PublicKey | undefined) => {
@@ -111,19 +108,32 @@ export const useJupiterPricesByMintsQuery = (mints: PublicKey[]) => {
111108
enabled,
112109
queryKey: jupiterPriceQueryKeys.byMints(dedupedMints),
113110
queryFn: async () => {
114-
const batches = [...chunks(dedupedMints, 100)]
111+
const batches = [...chunks(dedupedMints, 50)] // V3 limits to 50 ids per request
115112
const responses = await Promise.all(
116113
batches.map(async (batch) => {
117-
const x = await fetch(`${URL}?ids=${batch.join(',')}`)
114+
const x = await fetch(`${PRICE_URL}?ids=${batch.join(',')}`)
118115
const response = (await x.json()) as Response
119116
return response
120117
}),
121118
)
122-
const data = responses.reduce(
123-
(acc, next) => ({ ...acc, ...next.data }),
124-
{} as Response['data'],
119+
120+
// Merge all batch responses
121+
const mergedResponse = responses.reduce(
122+
(acc, next) => ({ ...acc, ...next }),
123+
{} as Response,
125124
)
126125

126+
// Convert V3 response to internal Price format
127+
const data: Record<string, Price> = {}
128+
Object.entries(mergedResponse).forEach(([mintAddress, priceData]) => {
129+
if (priceData && priceData.usdPrice !== undefined) {
130+
data[mintAddress] = {
131+
id: mintAddress,
132+
price: priceData.usdPrice,
133+
}
134+
}
135+
})
136+
127137
//override chai price if its broken
128138
const chaiMint = '3jsFX1tx2Z8ewmamiwSU851GzyzM2DJMq7KWW5DM8Py3'
129139
const chaiData = data[chaiMint]
@@ -155,9 +165,35 @@ export const getJupiterPricesByMintStrings = async (mints: string[]) => {
155165
const deduped = new Set(mints)
156166
const dedupedMints = Array.from(deduped)
157167
try {
158-
const x = await fetch(`${URL}?ids=${dedupedMints.join(',')}`)
159-
const response = (await x.json()) as Response
160-
const data = response.data
168+
// V3 limits to 50 ids per request
169+
const batches: string[][] = []
170+
for (let i = 0; i < dedupedMints.length; i += 50) {
171+
batches.push(dedupedMints.slice(i, i + 50))
172+
}
173+
174+
const responses = await Promise.all(
175+
batches.map(async (batch) => {
176+
const x = await fetch(`${PRICE_URL}?ids=${batch.join(',')}`)
177+
return (await x.json()) as Response
178+
}),
179+
)
180+
181+
// Merge all batch responses
182+
const mergedResponse = responses.reduce(
183+
(acc, next) => ({ ...acc, ...next }),
184+
{} as Response,
185+
)
186+
187+
// Convert V3 response to internal Price format
188+
const data: Record<string, Price> = {}
189+
Object.entries(mergedResponse).forEach(([mintAddress, priceData]) => {
190+
if (priceData && priceData.usdPrice !== undefined) {
191+
data[mintAddress] = {
192+
id: mintAddress,
193+
price: priceData.usdPrice,
194+
}
195+
}
196+
})
161197

162198
//override chai price if its broken
163199
const chaiMint = '3jsFX1tx2Z8ewmamiwSU851GzyzM2DJMq7KWW5DM8Py3'
@@ -175,3 +211,4 @@ export const getJupiterPricesByMintStrings = async (mints: string[]) => {
175211
throw error
176212
}
177213
}
214+

0 commit comments

Comments
 (0)