-
Notifications
You must be signed in to change notification settings - Fork 176
feat(solana): load token balances #7850
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
shoom3301
wants to merge
43
commits into
develop
Choose a base branch
from
solana/web-2
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 40 commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
ef14f70
fix: switch network through reown
shoom3301 4789ba4
docs: design for EVM/non-EVM network switching flow
shoom3301 a9ad910
feat(solana): switch between EVM/non-EVM chains
shoom3301 fdbc00b
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] dde49f1
fix: support non-EVM network switching from URL
shoom3301 c97af95
fix: handle wallet disconnect error
shoom3301 11e5ce3
chore: rename test
shoom3301 2a8d3ee
feat(solana): load token balances
shoom3301 f249cde
Merge branch 'develop' of https://github.com/cowprotocol/cowswap into…
shoom3301 e9e29e2
fix: confirm cross-chain switch on currency select
shoom3301 1db89fb
chore: update text
shoom3301 755d8d3
Merge branch 'develop' into solana/web-1
fairlighteth b1d4de1
chore: rename text
shoom3301 2306273
chore: ff IS_SOLANA_ENABLED in URL
shoom3301 0a6d43d
chore: ff IS_SOLANA_ENABLED in URL
shoom3301 6dd1ff6
Merge branch 'develop' of https://github.com/cowprotocol/cowswap into…
shoom3301 bb85f30
Merge remote-tracking branch 'origin/solana/web-1' into solana/web-1
shoom3301 855a751
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] 079e0f0
chore: update confirm text
shoom3301 0cdca9a
fix: disable bridge from Solana
shoom3301 e0fd749
fix: update text
shoom3301 1fce32b
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] 72254aa
fix: fix network switching
shoom3301 f1d582d
Merge remote-tracking branch 'origin/solana/web-1' into solana/web-1
shoom3301 f17495f
fix: fix network switching
shoom3301 5c916cd
Merge branch 'solana/web-1' of https://github.com/cowprotocol/cowswap…
shoom3301 a3e8e29
feat(solana): support token2022
shoom3301 8144ef2
fix: capture network switch error
shoom3301 e7c453a
fix: fix account.toLowerCase
shoom3301 a4c85c9
fix: isolates malformed mint
shoom3301 68c3211
Merge branch 'solana/web-1' of https://github.com/cowprotocol/cowswap…
shoom3301 e624ae6
chore: add solana default RPC
shoom3301 8d440d7
fix(solana): fix recent wallet reconnect
shoom3301 27bff7d
fix: fix solana dedupe
shoom3301 979400f
fix: fix build
shoom3301 72dd7d8
chore: remove DISABLED_SOURCE_CHAINS
shoom3301 cbd9c2e
chore: fix solana bridge
shoom3301 eaf2e51
Merge branch 'solana/web-1' of https://github.com/cowprotocol/cowswap…
shoom3301 d0e58b0
Merge remote-tracking branch 'origin/solana/web-2' into solana/web-2
shoom3301 49150e3
Merge branch 'develop' into solana/web-2
shoom3301 65b0b61
chore: update comment
shoom3301 4c9078d
chore: add refetchOnMount
shoom3301 a228233
chore: add try catch
shoom3301 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
6 changes: 3 additions & 3 deletions
6
apps/cowswap-frontend/src/modules/combinedBalances/hooks/useTokensBalancesCombined.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,9 @@ | ||
| import { useAtomValue } from 'jotai' | ||
|
|
||
| import type { BalancesState } from '@cowprotocol/balances-and-allowances' | ||
|
|
||
| import { balancesCombinedAtom } from '../state/balanceCombinedAtom' | ||
|
|
||
| // TODO: Add proper return type annotation | ||
| // eslint-disable-next-line @typescript-eslint/explicit-function-return-type | ||
| export function useTokensBalancesCombined() { | ||
| export function useTokensBalancesCombined(): BalancesState { | ||
| return useAtomValue(balancesCombinedAtom) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
232 changes: 232 additions & 0 deletions
232
libs/balances-and-allowances/src/hooks/usePersistSolanaBalancesViaWebCalls.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| import { Provider, useAtomValue } from 'jotai' | ||
| import { useHydrateAtoms } from 'jotai/utils' | ||
| import React, { ReactNode } from 'react' | ||
|
|
||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | ||
|
|
||
| import { TOKEN_2022_TAG } from '@cowprotocol/common-const' | ||
| import { getAddressKey, SupportedChainId, mapSupportedNetworks, solana } from '@cowprotocol/cow-sdk' | ||
| import { PersistentStateByChain } from '@cowprotocol/types' | ||
|
|
||
| import { PublicKey } from '@solana/web3.js' | ||
| import { renderHook, waitFor } from '@testing-library/react' | ||
|
|
||
| import { PersistBalancesAndAllowancesParams } from './usePersistBalancesViaWebCalls' | ||
| import { usePersistSolanaBalancesViaWebCalls } from './usePersistSolanaBalancesViaWebCalls' | ||
|
|
||
| import { balancesAtom, BalancesState, balancesUpdateAtom } from '../state/balancesAtom' | ||
|
|
||
| // Valid base58 addresses so `new PublicKey(...)` inside the hook does not throw. | ||
| const ACCOUNT = 'So11111111111111111111111111111111111111112' | ||
| const MINT_A = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' | ||
| const MINT_B = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' | ||
| const NATIVE_MINT = solana.nativeCurrency.address | ||
|
|
||
| const CLASSIC_PROGRAM = 'TOKEN_PROGRAM_ID' | ||
| const TOKEN_2022_PROGRAM = 'TOKEN_2022_PROGRAM_ID' | ||
|
|
||
| // The ATA key encodes the selected program, so a test can assert which token program a mint was read under. | ||
| function ataKey(mint: string, program: string = CLASSIC_PROGRAM): string { | ||
| return `ata:${program}:${mint}` | ||
| } | ||
|
|
||
| let mockConnection: MockConnection | undefined | ||
| // getAddressKey(address) -> token metadata, mirroring the token list; its tags drive program selection. | ||
| let mockTokensByAddress: Record<string, { tags: string[] } | undefined> | ||
|
|
||
| jest.mock('@reown/appkit-adapter-solana/react', () => ({ | ||
| useAppKitConnection: () => ({ connection: mockConnection }), | ||
| })) | ||
|
|
||
| jest.mock('@cowprotocol/tokens', () => ({ | ||
| useTokensByAddressMapForChain: () => mockTokensByAddress, | ||
| })) | ||
|
|
||
| // The ATA-derivation math is not what we are testing; make it deterministic and echo the mint plus the | ||
| // selected program back so the mocked RPC/`unpackAccount` can look accounts up by their ATA key. We only | ||
| // assert what lands in the atom and which program each ATA was derived with. | ||
| jest.mock('@solana/spl-token', () => ({ | ||
| TOKEN_PROGRAM_ID: 'TOKEN_PROGRAM_ID', | ||
| TOKEN_2022_PROGRAM_ID: 'TOKEN_2022_PROGRAM_ID', | ||
| ASSOCIATED_TOKEN_PROGRAM_ID: 'ASSOCIATED_TOKEN_PROGRAM_ID', | ||
| getAssociatedTokenAddressSync: ( | ||
| mint: { toBase58(): string }, | ||
| _owner: unknown, | ||
| _allowOwnerOffCurve: boolean, | ||
| programId: string, | ||
| ) => ({ toBase58: () => `ata:${programId}:${mint.toBase58()}` }), | ||
| unpackAccount: (ata: { toBase58(): string }) => ({ amount: mockAmountByAta[ata.toBase58()] }), | ||
| })) | ||
|
|
||
| interface MockConnection { | ||
| rpcEndpoint: string | ||
| getMultipleAccountsInfo: jest.Mock | ||
| } | ||
|
|
||
| // ATA base58 -> token amount, read by the mocked `unpackAccount`. | ||
| let mockAmountByAta: Record<string, bigint> | ||
| // ATA base58 -> account info; an absent entry means "no account exists" (a zero balance). | ||
| let mockInfoByAta: Record<string, { present: true } | undefined> | ||
|
|
||
| function createConnection(): MockConnection { | ||
| return { | ||
| rpcEndpoint: 'https://solana.example/rpc', | ||
| getMultipleAccountsInfo: jest.fn((batch: Array<{ toBase58(): string }>) => | ||
| Promise.resolve(batch.map((ata) => mockInfoByAta[ata.toBase58()] ?? null)), | ||
| ), | ||
| } | ||
| } | ||
|
|
||
| const mockBalancesUpdate: PersistentStateByChain<Record<string, number | undefined>> = mapSupportedNetworks({}) | ||
|
|
||
| function makeParams(overrides: Partial<PersistBalancesAndAllowancesParams> = {}): PersistBalancesAndAllowancesParams { | ||
| return { | ||
| account: ACCOUNT, | ||
| chainId: SupportedChainId.SOLANA, | ||
| tokenAddresses: [MINT_A, MINT_B], | ||
| setLoadingState: true, | ||
| ...overrides, | ||
| } | ||
| } | ||
|
|
||
| function renderWithBalances(params: PersistBalancesAndAllowancesParams): { result: { current: BalancesState } } { | ||
| return renderHook( | ||
| () => { | ||
| usePersistSolanaBalancesViaWebCalls(params) | ||
| return useAtomValue(balancesAtom) | ||
| }, | ||
| { wrapper }, | ||
| ) | ||
| } | ||
|
|
||
| function wrapper({ children }: { children: ReactNode }): ReactNode { | ||
| const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) | ||
|
|
||
| const HydrateAtoms = ({ children }: { children: ReactNode }): ReactNode => { | ||
| useHydrateAtoms([ | ||
| [ | ||
| balancesAtom, | ||
| { | ||
| isLoading: false, | ||
| chainId: SupportedChainId.SOLANA, | ||
| values: {}, | ||
| fromCache: false, | ||
| hasFirstLoad: false, | ||
| error: null, | ||
| } as BalancesState, | ||
| ], | ||
| [balancesUpdateAtom, mockBalancesUpdate], | ||
| ]) | ||
| return <>{children}</> | ||
| } | ||
|
|
||
| return ( | ||
| <QueryClientProvider client={queryClient}> | ||
| <Provider> | ||
| <HydrateAtoms>{children}</HydrateAtoms> | ||
| </Provider> | ||
| </QueryClientProvider> | ||
| ) | ||
| } | ||
|
|
||
| describe('usePersistSolanaBalancesViaWebCalls', () => { | ||
| beforeEach(() => { | ||
| mockTokensByAddress = {} | ||
| mockAmountByAta = { [ataKey(MINT_A)]: 100n, [ataKey(MINT_B)]: 250n } | ||
| mockInfoByAta = { [ataKey(MINT_A)]: { present: true }, [ataKey(MINT_B)]: { present: true } } | ||
| mockConnection = createConnection() | ||
| }) | ||
|
|
||
| it('reads SPL balances via a single batched RPC call and persists them to the atom', async () => { | ||
| const { result } = renderWithBalances(makeParams()) | ||
|
|
||
| await waitFor(() => expect(result.current.hasFirstLoad).toBe(true)) | ||
|
|
||
| expect(mockConnection?.getMultipleAccountsInfo).toHaveBeenCalledTimes(1) | ||
| expect(result.current.values[getAddressKey(MINT_A)]).toBe(100n) | ||
| expect(result.current.values[getAddressKey(MINT_B)]).toBe(250n) | ||
| }) | ||
|
|
||
| it('clears the loading state once balances are loaded', async () => { | ||
| const { result } = renderWithBalances(makeParams()) | ||
|
|
||
| await waitFor(() => expect(result.current.hasFirstLoad).toBe(true)) | ||
|
|
||
| expect(result.current.isLoading).toBe(false) | ||
| }) | ||
|
|
||
| it('treats a missing token account as a zero balance rather than an error', async () => { | ||
| delete mockInfoByAta[ataKey(MINT_B)] | ||
|
|
||
| const { result } = renderWithBalances(makeParams()) | ||
|
|
||
| await waitFor(() => expect(result.current.hasFirstLoad).toBe(true)) | ||
|
|
||
| expect(result.current.values[getAddressKey(MINT_A)]).toBe(100n) | ||
| expect(result.current.values[getAddressKey(MINT_B)]).toBe(0n) | ||
| expect(result.current.error).toBeNull() | ||
| }) | ||
|
|
||
| it('batches ATAs into chunks of 100 so large token lists do not exceed the RPC limit', async () => { | ||
| // 250 mints -> ceil(250 / 100) = 3 `getMultipleAccountsInfo` calls, none over the 100-account limit. | ||
| const mints = Array.from({ length: 250 }, (_, i) => | ||
| new PublicKey(Uint8Array.from({ length: 32 }, (_, j) => (i + j + 1) % 256)).toBase58(), | ||
| ) | ||
| mints.forEach((mint) => { | ||
| mockAmountByAta[ataKey(mint)] = 7n | ||
| mockInfoByAta[ataKey(mint)] = { present: true } | ||
| }) | ||
|
|
||
| const { result } = renderWithBalances(makeParams({ tokenAddresses: mints })) | ||
|
|
||
| await waitFor(() => expect(result.current.hasFirstLoad).toBe(true)) | ||
|
|
||
| expect(mockConnection?.getMultipleAccountsInfo).toHaveBeenCalledTimes(3) | ||
| mockConnection?.getMultipleAccountsInfo.mock.calls.forEach(([batch]) => { | ||
| expect(batch.length).toBeLessThanOrEqual(100) | ||
| }) | ||
| expect(Object.keys(result.current.values)).toHaveLength(250) | ||
| expect(result.current.values[getAddressKey(mints[0])]).toBe(7n) | ||
| expect(result.current.values[getAddressKey(mints[249])]).toBe(7n) | ||
| }) | ||
|
|
||
| it('excludes the native SOL address from the SPL batch (it has no ATA)', async () => { | ||
| renderWithBalances(makeParams({ tokenAddresses: [NATIVE_MINT, MINT_A] })) | ||
|
|
||
| await waitFor(() => expect(mockConnection?.getMultipleAccountsInfo).toHaveBeenCalled()) | ||
|
|
||
| const [atas] = mockConnection!.getMultipleAccountsInfo.mock.calls[0] | ||
| expect(atas).toHaveLength(1) | ||
| expect(atas[0].toBase58()).toBe(ataKey(MINT_A)) | ||
| }) | ||
|
|
||
| it('keys the update timestamp by the case-sensitive Solana account, not a lowercased alias', async () => { | ||
| const { result } = renderHook( | ||
| () => { | ||
| usePersistSolanaBalancesViaWebCalls(makeParams()) | ||
| return useAtomValue(balancesUpdateAtom) | ||
| }, | ||
| { wrapper }, | ||
| ) | ||
|
|
||
| await waitFor(() => expect(result.current[SupportedChainId.SOLANA]?.[getAddressKey(ACCOUNT)]).toBeDefined()) | ||
|
|
||
| // getAddressKey preserves case for Solana pubkeys; a lowercased key would alias distinct owners. | ||
| expect(ACCOUNT).not.toBe(ACCOUNT.toLowerCase()) | ||
| expect(result.current[SupportedChainId.SOLANA]?.[ACCOUNT.toLowerCase()]).toBeUndefined() | ||
| }) | ||
|
|
||
| it('derives a Token-2022 ATA for mints tagged as Token-2022 in the token list', async () => { | ||
| mockTokensByAddress = { [getAddressKey(MINT_A)]: { tags: [TOKEN_2022_TAG] } } | ||
| mockAmountByAta = { [ataKey(MINT_A, TOKEN_2022_PROGRAM)]: 999n } | ||
| mockInfoByAta = { [ataKey(MINT_A, TOKEN_2022_PROGRAM)]: { present: true } } | ||
|
|
||
| const { result } = renderWithBalances(makeParams({ tokenAddresses: [MINT_A] })) | ||
|
|
||
| await waitFor(() => expect(result.current.hasFirstLoad).toBe(true)) | ||
|
|
||
| const [atas] = mockConnection!.getMultipleAccountsInfo.mock.calls[0] | ||
| expect(atas[0].toBase58()).toBe(ataKey(MINT_A, TOKEN_2022_PROGRAM)) | ||
| expect(result.current.values[getAddressKey(MINT_A)]).toBe(999n) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.