-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: NWNT-681: Add useTronStakeApy hook #23743
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 4 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a67e53f
feat: bumped @metamask/stake-sdk to v^3.4.0
Matt561 a74643a
feat: added truncateNumber util
Matt561 cd681a0
feat: added useTronStakeApy hook
Matt561 3fae5ca
fix: set apy properties to null if consensys witness isn't found for …
Matt561 ccaacae
feat: clear error on new witness fetch
Matt561 1d8376e
Merge branch 'main' into feat/nwnt-681-add-tron-staking-apy-to-client
Matt561 a42d1d4
feat: simplify truncateNumber util
Matt561 2fed490
Merge branch 'main' into feat/nwnt-681-add-tron-staking-apy-to-client
Matt561 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,306 @@ | ||
| import { renderHook, act } from '@testing-library/react-hooks'; | ||
| import { ChainId } from '@metamask/stake-sdk'; | ||
| import useTronStakeApy from './useTronStakeApy'; | ||
| import { tronStakingApiService } from '../../Stake/sdk/stakeSdkProvider'; | ||
|
|
||
| jest.mock('../../Stake/sdk/stakeSdkProvider', () => ({ | ||
| tronStakingApiService: { | ||
| getWitnesses: jest.fn(), | ||
| }, | ||
| })); | ||
|
|
||
| const mockGetWitnesses = | ||
| tronStakingApiService.getWitnesses as jest.MockedFunction< | ||
| typeof tronStakingApiService.getWitnesses | ||
| >; | ||
|
|
||
| const CONSENSYS_MAINNET_ADDRESS = 'TVMwGfdDz58VvM7yTzGMWWSHsmofSxa9jH'; | ||
| const CONSENSYS_NILE_ADDRESS = 'TBSX9dpxbNrsLgTADXtkC2ASmxW4Q2mTgY'; | ||
|
|
||
| const createMockWitnessData = (overrides = {}) => ({ | ||
| address: CONSENSYS_MAINNET_ADDRESS, | ||
| annualizedRate: '4.56', | ||
| name: 'Consensys', | ||
| url: 'https://consensys.io', | ||
| producer: true, | ||
| latestBlockNumber: 12345678, | ||
| latestSlotNumber: 87654321, | ||
| missedTotal: 0, | ||
| producedTotal: 1000, | ||
| producedTrx: 5000, | ||
| votes: 100000000, | ||
| votesPercentage: 0.5, | ||
| changeVotes: 1000, | ||
| lastCycleVotes: 99000000, | ||
| realTimeVotes: 100500000, | ||
| brokerage: 20, | ||
| voterBrokerage: 80, | ||
| producePercentage: 100, | ||
| version: 1, | ||
| witnessType: 1, | ||
| index: 0, | ||
| totalOutOfTimeTrans: 0, | ||
| lastWeekOutOfTimeTrans: 0, | ||
| changedBrokerage: false, | ||
| lowestBrokerage: 20, | ||
| ranking: 1, | ||
| ...overrides, | ||
| }); | ||
|
|
||
| const createMockWitnessesResponse = ( | ||
| witnesses = [createMockWitnessData()], | ||
| ) => ({ | ||
| total: witnesses.length, | ||
| data: witnesses, | ||
| }); | ||
|
|
||
| describe('useTronStakeApy', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('initialization and fetching', () => { | ||
| it('fetches Consensys witness data on mount by default', async () => { | ||
| mockGetWitnesses.mockResolvedValue(createMockWitnessesResponse()); | ||
|
|
||
| const { waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(mockGetWitnesses).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('does not fetch on mount when fetchOnMount is false', () => { | ||
| mockGetWitnesses.mockResolvedValue(createMockWitnessesResponse()); | ||
|
|
||
| renderHook(() => useTronStakeApy({ fetchOnMount: false })); | ||
|
|
||
| expect(mockGetWitnesses).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('uses TRON_MAINNET chainId by default', async () => { | ||
| mockGetWitnesses.mockResolvedValue(createMockWitnessesResponse()); | ||
|
|
||
| const { waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(mockGetWitnesses).toHaveBeenCalledWith(ChainId.TRON_MAINNET); | ||
| }); | ||
|
|
||
| it('uses specified chainId when provided', async () => { | ||
| const nileWitness = createMockWitnessData({ | ||
| address: CONSENSYS_NILE_ADDRESS, | ||
| }); | ||
| mockGetWitnesses.mockResolvedValue( | ||
| createMockWitnessesResponse([nileWitness]), | ||
| ); | ||
|
|
||
| const { waitForNextUpdate } = renderHook(() => | ||
| useTronStakeApy({ chainId: ChainId.TRON_NILE }), | ||
| ); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(mockGetWitnesses).toHaveBeenCalledWith(ChainId.TRON_NILE); | ||
| }); | ||
| }); | ||
|
|
||
| describe('APY data extraction', () => { | ||
| it('sets apyDecimal from witness annualizedRate', async () => { | ||
| const witness = createMockWitnessData({ annualizedRate: '5.25' }); | ||
| mockGetWitnesses.mockResolvedValue( | ||
| createMockWitnessesResponse([witness]), | ||
| ); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.apyDecimal).toBe('5.25'); | ||
| }); | ||
|
|
||
| it('sets apyPercent with truncated rate and percent symbol', async () => { | ||
| const witness = createMockWitnessData({ annualizedRate: '4.56789' }); | ||
| mockGetWitnesses.mockResolvedValue( | ||
| createMockWitnessesResponse([witness]), | ||
| ); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.apyPercent).toBe('4.56%'); | ||
| }); | ||
|
|
||
| it('returns null APY values when Consensys witness not found', async () => { | ||
| const otherWitness = createMockWitnessData({ | ||
| address: 'TDifferentAddress12345', | ||
| }); | ||
| mockGetWitnesses.mockResolvedValue( | ||
| createMockWitnessesResponse([otherWitness]), | ||
| ); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.apyDecimal).toBeNull(); | ||
| expect(result.current.apyPercent).toBeNull(); | ||
| }); | ||
|
|
||
| it('returns null APY values when witnesses data is empty', async () => { | ||
| mockGetWitnesses.mockResolvedValue(createMockWitnessesResponse([])); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.apyDecimal).toBeNull(); | ||
| expect(result.current.apyPercent).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('error handling', () => { | ||
| it('sets errorMessage from Error.message when API throws Error', async () => { | ||
| const errorMessage = 'Network request failed'; | ||
| mockGetWitnesses.mockRejectedValue(new Error(errorMessage)); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.errorMessage).toBe(errorMessage); | ||
| }); | ||
|
|
||
| it('sets errorMessage to default when non-Error is thrown', async () => { | ||
| mockGetWitnesses.mockRejectedValue('string error'); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.errorMessage).toBe('Unknown error occurred'); | ||
| }); | ||
|
|
||
| it('clears APY values when error occurs', async () => { | ||
| mockGetWitnesses.mockRejectedValue(new Error('API Error')); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.apyDecimal).toBeNull(); | ||
| expect(result.current.apyPercent).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('loading state', () => { | ||
| it('sets isLoading true during fetch', async () => { | ||
| let resolvePromise: ( | ||
| value: ReturnType<typeof createMockWitnessesResponse>, | ||
| ) => void = () => undefined; | ||
| const pendingPromise = new Promise< | ||
| ReturnType<typeof createMockWitnessesResponse> | ||
| >((resolve) => { | ||
| resolvePromise = resolve; | ||
| }); | ||
| mockGetWitnesses.mockReturnValue(pendingPromise); | ||
|
|
||
| const { result } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| expect(result.current.isLoading).toBe(true); | ||
|
|
||
| await act(async () => { | ||
| resolvePromise(createMockWitnessesResponse()); | ||
| }); | ||
| }); | ||
|
|
||
| it('sets isLoading false after successful fetch', async () => { | ||
| mockGetWitnesses.mockResolvedValue(createMockWitnessesResponse()); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.isLoading).toBe(false); | ||
| }); | ||
|
|
||
| it('sets isLoading false after failed fetch', async () => { | ||
| mockGetWitnesses.mockRejectedValue(new Error('API Error')); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.isLoading).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('refetch', () => { | ||
| it('clears APY values before refetching', async () => { | ||
| mockGetWitnesses.mockResolvedValue(createMockWitnessesResponse()); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.apyDecimal).toBe('4.56'); | ||
|
|
||
| let resolveSecondCall: ( | ||
| value: ReturnType<typeof createMockWitnessesResponse>, | ||
| ) => void = () => undefined; | ||
| const pendingPromise = new Promise< | ||
| ReturnType<typeof createMockWitnessesResponse> | ||
| >((resolve) => { | ||
| resolveSecondCall = resolve; | ||
| }); | ||
| mockGetWitnesses.mockReturnValue(pendingPromise); | ||
|
|
||
| act(() => { | ||
| result.current.refetch(); | ||
| }); | ||
|
|
||
| expect(result.current.apyDecimal).toBeNull(); | ||
| expect(result.current.apyPercent).toBeNull(); | ||
|
|
||
| await act(async () => { | ||
| resolveSecondCall(createMockWitnessesResponse()); | ||
| }); | ||
| }); | ||
|
|
||
| it('clears errorMessage before refetching', async () => { | ||
| mockGetWitnesses.mockRejectedValue(new Error('Initial error')); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(result.current.errorMessage).toBe('Initial error'); | ||
|
|
||
| mockGetWitnesses.mockResolvedValue(createMockWitnessesResponse()); | ||
|
|
||
| await act(async () => { | ||
| await result.current.refetch(); | ||
| }); | ||
|
|
||
| expect(result.current.errorMessage).toBeNull(); | ||
| }); | ||
|
|
||
| it('triggers new API call', async () => { | ||
| mockGetWitnesses.mockResolvedValue(createMockWitnessesResponse()); | ||
|
|
||
| const { result, waitForNextUpdate } = renderHook(() => useTronStakeApy()); | ||
|
|
||
| await waitForNextUpdate(); | ||
|
|
||
| expect(mockGetWitnesses).toHaveBeenCalledTimes(1); | ||
|
|
||
| await act(async () => { | ||
| await result.current.refetch(); | ||
| }); | ||
|
|
||
| expect(mockGetWitnesses).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); | ||
| }); |
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,79 @@ | ||
| import { useCallback, useEffect, useState } from 'react'; | ||
| import { tronStakingApiService } from '../../Stake/sdk/stakeSdkProvider'; | ||
| import { ChainId } from '@metamask/stake-sdk'; | ||
| import { truncateNumber } from '../utils'; | ||
|
|
||
| type TronChainId = ChainId.TRON_MAINNET | ChainId.TRON_NILE; | ||
|
|
||
| // Consensys has witnesses for the following chains: | ||
| const CONSENSYS_WITNESS_ADDRESS_BY_CHAIN_ID: Record<TronChainId, string> = { | ||
| [ChainId.TRON_MAINNET]: 'TVMwGfdDz58VvM7yTzGMWWSHsmofSxa9jH', | ||
| [ChainId.TRON_NILE]: 'TBSX9dpxbNrsLgTADXtkC2ASmxW4Q2mTgY', | ||
| }; | ||
|
|
||
| interface UseTronStakeApyOptions { | ||
| fetchOnMount?: boolean; | ||
| chainId?: TronChainId; | ||
| } | ||
|
|
||
| const useTronStakeApy = ({ | ||
| fetchOnMount = true, | ||
| chainId = ChainId.TRON_MAINNET, | ||
| }: UseTronStakeApyOptions = {}) => { | ||
| const [isLoading, setIsLoading] = useState(false); | ||
| const [errorMessage, setErrorMessage] = useState<string | null>(null); | ||
| const [apyDecimal, setApyDecimal] = useState<string | null>(null); | ||
| const [apyPercent, setApyPercent] = useState<string | null>(null); | ||
|
|
||
| const fetchConsensysWitness = useCallback(async () => { | ||
| try { | ||
| setIsLoading(true); | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we also clear the error on a new fetch? with |
||
| const witnesses = await tronStakingApiService.getWitnesses(chainId); | ||
|
|
||
| const consensysWitness = witnesses?.data.find( | ||
| (witness) => | ||
| witness.address === CONSENSYS_WITNESS_ADDRESS_BY_CHAIN_ID[chainId], | ||
| ); | ||
Matt561 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (consensysWitness) { | ||
| setApyDecimal(consensysWitness.annualizedRate); | ||
| setApyPercent(`${truncateNumber(consensysWitness.annualizedRate)}%`); | ||
Matt561 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } else { | ||
| setApyDecimal(null); | ||
| setApyPercent(null); | ||
| } | ||
Matt561 marked this conversation as resolved.
Show resolved
Hide resolved
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } catch (error: unknown) { | ||
| setErrorMessage( | ||
| error instanceof Error ? error.message : 'Unknown error occurred', | ||
| ); | ||
| setApyDecimal(null); | ||
| setApyPercent(null); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }, [chainId]); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| useEffect(() => { | ||
| if (fetchOnMount) { | ||
| fetchConsensysWitness(); | ||
| } | ||
| }, [fetchConsensysWitness, fetchOnMount]); | ||
Matt561 marked this conversation as resolved.
Show resolved
Hide resolved
Matt561 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const refetch = useCallback(() => { | ||
| setApyDecimal(null); | ||
| setApyPercent(null); | ||
| setErrorMessage(null); | ||
| return fetchConsensysWitness(); | ||
| }, [fetchConsensysWitness]); | ||
|
|
||
| return { | ||
| isLoading, | ||
| errorMessage, | ||
| apyDecimal, | ||
| apyPercent, | ||
| refetch, | ||
| }; | ||
| }; | ||
|
|
||
| export default useTronStakeApy; | ||
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.