-
Notifications
You must be signed in to change notification settings - Fork 177
feat: add balancec-watcher updater [pr 2 - connection logic] #7640
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
Merged
Merged
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
9efdcc6
feat: add balances-watcher interfaces
limitofzero 53e049f
fix(balances): terminate SSE on malformed balance_update
limitofzero f98d907
Merge branch 'develop' into feat/add-balances-watcher-interfaces
limitofzero ebeb8d0
Merge branch 'develop' into feat/add-balances-watcher-interfaces
limitofzero 08eb84c
Merge branch 'develop' into feat/add-balances-watcher-interfaces
limitofzero d9c3c1a
chore: update balances-watcher default URL to staging endpoint
limitofzero b914309
feat: add bw updater
limitofzero ef2de4a
fix: address review comments
limitofzero 171c27c
Merge branch 'feat/add-balances-watcher-interfaces' into feat/balance…
limitofzero 12aec76
chore: comments
limitofzero e9785cf
refactor: replace epoch by cancel flag
limitofzero 10344db
Merge branch 'develop' into feat/balance-watcher-updater-2
limitofzero ffc4819
feat: remove custom tokens comparator
limitofzero f339215
refactor: useMemo
limitofzero 1febce9
feat: add eth tracking
limitofzero ac9903d
fix: change ff name
limitofzero 73520a7
Merge branch 'develop' into feat/balance-watcher-updater-2
limitofzero 2a532c6
fix: skip non-evm networks
limitofzero f1d0cd4
fix: remove toLowerCase
limitofzero ab8147b
Merge branch 'feat/balance-watcher-updater-2' of github.com:cowprotoc…
limitofzero 67445bd
Merge branch 'develop' into feat/balance-watcher-updater-2
limitofzero b86e1c8
fix: add error handling when first initial sse request is failed
limitofzero 09eb541
Merge branch 'develop' into feat/balance-watcher-updater-2
fairlighteth 7bc8961
Merge branch 'develop' into feat/balance-watcher-updater-2
limitofzero dc9b354
fix: add edge case handling when there is no tokens to request
limitofzero 4dda8df
Merge branch 'feat/balance-watcher-updater-2' of github.com:cowprotoc…
limitofzero 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
300 changes: 300 additions & 0 deletions
300
libs/balances-and-allowances/src/hooks/useBalancesWatcherSession.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,300 @@ | ||
| import { Provider, useAtomValue } from 'jotai' | ||
| import { useHydrateAtoms } from 'jotai/utils' | ||
| import React, { ReactNode } from 'react' | ||
|
|
||
| import { NATIVE_CURRENCY_ADDRESS } from '@cowprotocol/common-const' | ||
| import { getAddressKey, SupportedChainId } from '@cowprotocol/cow-sdk' | ||
|
|
||
| import { act, renderHook } from '@testing-library/react' | ||
|
|
||
| import { useBalancesWatcherSession, UseBalancesWatcherSessionParams } from './useBalancesWatcherSession' | ||
|
|
||
| import { BalancesSubscription, BalancesWatcherApiError, SubscribeToBalancesEventsParams } from '../balancesWatcher' | ||
| import { balancesAtom, BalancesState, DEFAULT_BALANCES_STATE } from '../state/balancesAtom' | ||
|
|
||
| jest.mock('../balancesWatcher', () => { | ||
| const actual = jest.requireActual('../balancesWatcher') | ||
| return { | ||
| ...actual, | ||
| createBalancesWatcherSession: jest.fn(), | ||
| subscribeToBalancesEvents: jest.fn(), | ||
| } | ||
| }) | ||
|
|
||
| const balancesWatcherModule = jest.requireMock('../balancesWatcher') as { | ||
| createBalancesWatcherSession: jest.Mock | ||
| subscribeToBalancesEvents: jest.Mock | ||
| } | ||
| const mockCreateSession = balancesWatcherModule.createBalancesWatcherSession | ||
| const mockSubscribe = balancesWatcherModule.subscribeToBalancesEvents | ||
|
|
||
| interface Deferred<T> { | ||
| promise: Promise<T> | ||
| resolve: (value: T) => void | ||
| reject: (reason?: unknown) => void | ||
| } | ||
|
|
||
| function deferred<T>(): Deferred<T> { | ||
| let resolve!: (value: T) => void | ||
| let reject!: (reason?: unknown) => void | ||
| const promise = new Promise<T>((res, rej) => { | ||
| resolve = res | ||
| reject = rej | ||
| }) | ||
| return { promise, resolve, reject } | ||
| } | ||
|
|
||
| const ACCOUNT = '0x1234567890123456789012345678901234567890' | ||
| const TOKEN_A = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' | ||
| const TOKEN_B = '0xdAC17F958D2ee523a2206206994597C13D831ec7' | ||
|
|
||
| function makeParams(overrides: Partial<UseBalancesWatcherSessionParams> = {}): UseBalancesWatcherSessionParams { | ||
| return { | ||
| account: ACCOUNT, | ||
| chainId: SupportedChainId.MAINNET, | ||
| tokensListsUrls: ['https://example.com/tokens.json'], | ||
| customTokens: [], | ||
| ...overrides, | ||
| } | ||
| } | ||
|
|
||
| let currentInitialBalances: BalancesState = DEFAULT_BALANCES_STATE | ||
|
|
||
| function HydrateAtoms({ children }: { children: ReactNode }): ReactNode { | ||
| useHydrateAtoms([[balancesAtom, currentInitialBalances]]) | ||
| return <>{children}</> | ||
| } | ||
|
|
||
| function Wrapper({ children }: { children: ReactNode }): ReactNode { | ||
| return ( | ||
| <Provider> | ||
| <HydrateAtoms>{children}</HydrateAtoms> | ||
| </Provider> | ||
| ) | ||
| } | ||
|
|
||
| function renderSession( | ||
| initialParams: UseBalancesWatcherSessionParams = makeParams(), | ||
| initialBalances: BalancesState = DEFAULT_BALANCES_STATE, | ||
| ): ReturnType<typeof renderHook<BalancesState, { params: UseBalancesWatcherSessionParams }>> { | ||
| currentInitialBalances = initialBalances | ||
| return renderHook( | ||
| ({ params }: { params: UseBalancesWatcherSessionParams }) => { | ||
| useBalancesWatcherSession(params) | ||
| return useAtomValue(balancesAtom) | ||
| }, | ||
| { wrapper: Wrapper, initialProps: { params: initialParams } }, | ||
| ) | ||
| } | ||
|
|
||
| function capturedSubscribeParams(): SubscribeToBalancesEventsParams { | ||
| const calls = mockSubscribe.mock.calls | ||
| expect(calls.length).toBeGreaterThan(0) | ||
| return calls[calls.length - 1][0] as SubscribeToBalancesEventsParams | ||
| } | ||
|
|
||
| describe('useBalancesWatcherSession', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks() | ||
| mockCreateSession.mockReturnValue(Promise.resolve()) | ||
| mockSubscribe.mockReturnValue({ close: jest.fn() } satisfies BalancesSubscription) | ||
| }) | ||
|
|
||
| it('does not create a session when account is undefined', () => { | ||
| renderSession(makeParams({ account: undefined })) | ||
|
|
||
| expect(mockCreateSession).not.toHaveBeenCalled() | ||
| expect(mockSubscribe).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('does not create a session when both lists and customTokens are empty', () => { | ||
| renderSession(makeParams({ tokensListsUrls: [], customTokens: [] })) | ||
|
|
||
| expect(mockCreateSession).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('does not create a session for a non-EVM chain (Solana)', () => { | ||
| renderSession(makeParams({ chainId: SupportedChainId.SOLANA })) | ||
|
|
||
| expect(mockCreateSession).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('creates a session with the expected body and subscribes after it resolves', async () => { | ||
| const session = deferred<void>() | ||
| mockCreateSession.mockReturnValueOnce(session.promise) | ||
|
|
||
| renderSession(makeParams({ customTokens: [TOKEN_A.toLowerCase()] })) | ||
|
|
||
| expect(mockCreateSession).toHaveBeenCalledTimes(1) | ||
| expect(mockCreateSession).toHaveBeenCalledWith({ | ||
| chainId: SupportedChainId.MAINNET, | ||
| owner: ACCOUNT, | ||
| body: { | ||
| tokensListsUrls: ['https://example.com/tokens.json'], | ||
| customTokens: [TOKEN_A.toLowerCase()], | ||
| }, | ||
| }) | ||
| expect(mockSubscribe).not.toHaveBeenCalled() | ||
|
|
||
| await act(async () => { | ||
| session.resolve() | ||
| }) | ||
|
|
||
| expect(mockSubscribe).toHaveBeenCalledTimes(1) | ||
| expect(capturedSubscribeParams()).toMatchObject({ | ||
| chainId: SupportedChainId.MAINNET, | ||
| owner: ACCOUNT, | ||
| }) | ||
| }) | ||
|
|
||
| it('writes the snapshot into balancesAtom (bigint values, normalized address keys, first-load flags)', async () => { | ||
| const session = deferred<void>() | ||
| mockCreateSession.mockReturnValueOnce(session.promise) | ||
|
|
||
| const { result } = renderSession() | ||
|
|
||
| await act(async () => { | ||
| session.resolve() | ||
| }) | ||
|
|
||
| await act(async () => { | ||
| capturedSubscribeParams().onBalances({ | ||
| [NATIVE_CURRENCY_ADDRESS]: '1000000000000000000', | ||
| [TOKEN_A]: '500', | ||
| }) | ||
| }) | ||
|
|
||
| expect(result.current.values[getAddressKey(NATIVE_CURRENCY_ADDRESS)]).toBe(1000000000000000000n) | ||
| expect(result.current.values[getAddressKey(TOKEN_A)]).toBe(500n) | ||
| expect(result.current.hasFirstLoad).toBe(true) | ||
| expect(result.current.isLoading).toBe(false) | ||
| expect(result.current.fromCache).toBe(false) | ||
| expect(result.current.error).toBeNull() | ||
| expect(result.current.chainId).toBe(SupportedChainId.MAINNET) | ||
| }) | ||
|
|
||
| it('merges a diff into balancesAtom without clearing prior keys', async () => { | ||
| const session = deferred<void>() | ||
| mockCreateSession.mockReturnValueOnce(session.promise) | ||
|
|
||
| const { result } = renderSession() | ||
|
|
||
| await act(async () => { | ||
| session.resolve() | ||
| }) | ||
| const sub = capturedSubscribeParams() | ||
|
|
||
| await act(async () => { | ||
| sub.onBalances({ [TOKEN_A]: '100', [TOKEN_B]: '200' }) | ||
| }) | ||
| await act(async () => { | ||
| sub.onBalances({ [TOKEN_B]: '999' }) | ||
| }) | ||
|
|
||
| expect(result.current.values[getAddressKey(TOKEN_A)]).toBe(100n) | ||
| expect(result.current.values[getAddressKey(TOKEN_B)]).toBe(999n) | ||
| }) | ||
|
|
||
| it('writes the atom error and clears isLoading on a terminal SSE error', async () => { | ||
| const session = deferred<void>() | ||
| mockCreateSession.mockReturnValueOnce(session.promise) | ||
|
|
||
| const { result } = renderSession() | ||
|
|
||
| await act(async () => { | ||
| session.resolve() | ||
| }) | ||
| const sub = capturedSubscribeParams() | ||
|
|
||
| await act(async () => { | ||
| sub.onError(new Error('stream closed by server'), true) | ||
| }) | ||
|
|
||
| expect(result.current.error).toBe('stream closed by server') | ||
| expect(result.current.isLoading).toBe(false) | ||
| }) | ||
|
|
||
| it('ignores non-terminal SSE errors (transport is reconnecting)', async () => { | ||
| const session = deferred<void>() | ||
| mockCreateSession.mockReturnValueOnce(session.promise) | ||
|
|
||
| const { result } = renderSession() | ||
|
|
||
| await act(async () => { | ||
| session.resolve() | ||
| }) | ||
| const sub = capturedSubscribeParams() | ||
|
|
||
| await act(async () => { | ||
| sub.onError(new Error('transient'), false) | ||
| }) | ||
|
|
||
| expect(result.current.error).toBeNull() | ||
| }) | ||
|
|
||
| it('writes the atom error and clears isLoading when createSession rejects', async () => { | ||
| const session = deferred<void>() | ||
| mockCreateSession.mockReturnValueOnce(session.promise) | ||
|
|
||
| const { result } = renderSession() | ||
|
|
||
| await act(async () => { | ||
| session.reject(new BalancesWatcherApiError(503, { code: 1, message: 'service unavailable' })) | ||
| }) | ||
|
|
||
| expect(result.current.error).toBe('service unavailable') | ||
| expect(result.current.isLoading).toBe(false) | ||
| expect(mockSubscribe).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('closes the subscription on unmount and ignores late events', async () => { | ||
| const session = deferred<void>() | ||
| mockCreateSession.mockReturnValueOnce(session.promise) | ||
| const close = jest.fn() | ||
| mockSubscribe.mockReturnValueOnce({ close }) | ||
|
|
||
| const { result, unmount } = renderSession() | ||
|
|
||
| await act(async () => { | ||
| session.resolve() | ||
| }) | ||
| const sub = capturedSubscribeParams() | ||
|
|
||
| unmount() | ||
| expect(close).toHaveBeenCalledTimes(1) | ||
|
|
||
| await act(async () => { | ||
| sub.onBalances({ [TOKEN_A]: '777' }) | ||
| }) | ||
| expect(result.current.values[getAddressKey(TOKEN_A)]).toBeUndefined() | ||
| }) | ||
|
|
||
| it('discards a session whose POST resolves after a chainId change (race-guard)', async () => { | ||
| const stale = deferred<void>() | ||
| const fresh = deferred<void>() | ||
| mockCreateSession.mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise) | ||
|
|
||
| const { result, rerender } = renderSession(makeParams({ chainId: SupportedChainId.MAINNET })) | ||
|
|
||
| rerender({ params: makeParams({ chainId: SupportedChainId.ARBITRUM_ONE }) }) | ||
|
|
||
| // Stale chain=1 session resolves first; it must NOT open a subscription. | ||
| await act(async () => { | ||
| stale.resolve() | ||
| }) | ||
| expect(mockSubscribe).not.toHaveBeenCalled() | ||
|
|
||
| // Fresh chain=42161 session resolves; subscription opens for that chain. | ||
| await act(async () => { | ||
| fresh.resolve() | ||
| }) | ||
| expect(mockSubscribe).toHaveBeenCalledTimes(1) | ||
| expect(capturedSubscribeParams().chainId).toBe(SupportedChainId.ARBITRUM_ONE) | ||
|
|
||
| await act(async () => { | ||
| capturedSubscribeParams().onBalances({ [TOKEN_A]: '42' }) | ||
| }) | ||
| expect(result.current.chainId).toBe(SupportedChainId.ARBITRUM_ONE) | ||
| expect(result.current.values[getAddressKey(TOKEN_A)]).toBe(42n) | ||
| }) | ||
| }) | ||
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.