From a711751d09a9f397b4c550766211ea7b1f7b946d Mon Sep 17 00:00:00 2001 From: Antonio Ventilii Date: Wed, 20 Nov 2024 20:17:26 +0100 Subject: [PATCH] feat(frontend): Grouping util for token list (#3387) # Note This is a continuation/duplicate of PR #3114 , since I changed my account. # Motivation We want to change the way we group tokens, to have more than two elements in the group, and to try and avoid repetitions/glitches when the order of the token list is not the usual one. # Definitions ### Main Token The "original" token of a certain group or of a certain other token; for example `BTC` is the "main token" of `ckBTC`, or `ETH` is the "main token" of `SepoliaETH`. Typically, a token and its "main token" should be 1:1. ### Secondary token A token that has a "main token", even if not present in the list. It is just to indicate that it should always be grouped if the latter is present. # Implementation Following PR #3296 , the function loops through the tokens list and groups them according to a prop key that links a token to its "main token". For example, it could be `twinToken` for ck-tokens. But the logic can be extended to other props, if necessary. The tokens with no "main token" will still be included in a group, but it will be a single-element group, where the the token itself takes the place of the "main token". That, in general, makes sense for: - tokens that are not "secondary tokens" of a "main token"; - tokens that have a "main token" but it is not present in the list. The returned list respects the sorting order of the initial tokens list, meaning that the group is created at each position of the first encountered token of the group. So, independently of being a "main token" or a "secondary token", the group will replace the first token of the group in the list. That is useful if a "secondary token" is before the "main token" in the list; for example, if the list is sorted by balance. # Tests I created a few tests mocking some twin tokens and specific scenarios: different sorting, multiple "secondary tokens", missing "main token". --------- Co-authored-by: Antonio Ventilii <169057656+AntonioVentilii-DFINITY@users.noreply.github.com> Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- src/frontend/src/lib/types/token-group.ts | 2 +- .../src/lib/utils/token-group.utils.ts | 52 +- .../tests/lib/utils/token-group.utils.spec.ts | 461 +++++++++++++++++- 3 files changed, 512 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/lib/types/token-group.ts b/src/frontend/src/lib/types/token-group.ts index 4e9ae7c77d..6efec05923 100644 --- a/src/frontend/src/lib/types/token-group.ts +++ b/src/frontend/src/lib/types/token-group.ts @@ -6,7 +6,7 @@ type GroupId = TokenId; export type TokenUiGroup = { id: GroupId; nativeToken: TokenUi; - tokens: TokenUi[]; + tokens: [TokenUi, ...TokenUi[]]; } & TokenFinancialData; export type TokenUiOrGroupUi = TokenUi | TokenUiGroup; diff --git a/src/frontend/src/lib/utils/token-group.utils.ts b/src/frontend/src/lib/utils/token-group.utils.ts index 7907f50283..2dc020f1e9 100644 --- a/src/frontend/src/lib/utils/token-group.utils.ts +++ b/src/frontend/src/lib/utils/token-group.utils.ts @@ -1,7 +1,7 @@ import type { IcCkToken } from '$icp/types/ic-token'; import { isIcCkToken } from '$icp/validation/ic-token.validation'; import { ZERO } from '$lib/constants/app.constants'; -import type { TokenUi } from '$lib/types/token'; +import type { TokenId, TokenUi } from '$lib/types/token'; import type { TokenUiGroup, TokenUiOrGroupUi } from '$lib/types/token-group'; import { isRequiredTokenWithLinkedData, @@ -164,3 +164,53 @@ export const groupMainToken = ({ token, tokenGroup }: GroupTokenParams): TokenUi */ export const groupSecondaryToken = ({ token, tokenGroup }: GroupTokenParams): TokenUiGroup => nonNullish(tokenGroup) ? updateTokenGroup({ token, tokenGroup }) : mapNewTokenGroup(token); + +/** + * Function to create a list of TokenUiGroup by grouping a provided list of tokens. + * + * The function loops through the tokens list and groups them according to a prop key that links a token to its "main token". + * For example, it could be `twinToken` as per ck token standard. But the logic can be extended to other props, if necessary. + * + * The tokens with no "main token" will still be included in a group, but it will be a single-element group, where the "main token" is the token itself. + * That, in general, makes sense for tokens that are not "secondary tokens" of a "main token". + * + * The returned list respects the sorting order of the initial tokens list, meaning that the group is created at each position of the first encountered token of the group. + * So, independently of being a "main token" or a "secondary token", the group will replace the first token of the group in the list. + * That is useful if a "secondary token" is before the "main token" in the list; for example, if the list is sorted by balance. + * + * NOTE: The function does not sort the groups by any criteria. It only groups the tokens. So, even if a group ends up having a total balance that would put it in a higher position in the list, it will not be moved. + * + * @param {TokenUi[]} tokens - The list of TokenUi objects to group. Each token may or may not have a prop key to identify a "main token". + * @returns {TokenUiGroup[]} A list where tokens are grouped into a TokenUiGroup, even if they are by themselves. + */ +export const groupTokens = (tokens: TokenUi[]): TokenUiGroup[] => { + const tokenGroupsMap = tokens.reduce<{ + [id: TokenId]: TokenUiGroup | undefined; + }>( + (acc, token) => ({ + ...acc, + ...(isIcCkToken(token) && + nonNullish(token.twinToken) && + // TODO: separate the check for decimals from the rest, since it seems important to the logic. + token.decimals === token.twinToken.decimals + ? // If the token has a twinToken, and both have the same decimals, group them together. + { + [token.twinToken.id]: groupSecondaryToken({ + token, + tokenGroup: acc[token.twinToken.id] + }) + } + : { + [token.id]: groupMainToken({ + token, + tokenGroup: acc[token.id] + }) + }) + }), + {} + ); + + return Object.getOwnPropertySymbols(tokenGroupsMap).map( + (id) => tokenGroupsMap[id as TokenId] as TokenUiGroup + ); +}; diff --git a/src/frontend/src/tests/lib/utils/token-group.utils.spec.ts b/src/frontend/src/tests/lib/utils/token-group.utils.spec.ts index def3cf10f0..338f6c4907 100644 --- a/src/frontend/src/tests/lib/utils/token-group.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/token-group.utils.spec.ts @@ -7,11 +7,12 @@ import type { TokenUiGroup } from '$lib/types/token-group'; import { groupMainToken, groupSecondaryToken, + groupTokens, groupTokensByTwin, updateTokenGroup } from '$lib/utils/token-group.utils'; import { bn1, bn2, bn3 } from '$tests/mocks/balances.mock'; -import { mockValidIcCkToken } from '$tests/mocks/ic-tokens.mock'; +import { mockValidIcCkToken, mockValidIcToken } from '$tests/mocks/ic-tokens.mock'; import { BigNumber } from 'alchemy-sdk'; const tokens = [ @@ -454,3 +455,461 @@ describe('groupSecondaryToken', () => { }); }); }); + +describe('updateTokenGroup', () => { + const token = { ...ICP_TOKEN, balance: bn1, usdBalance: 100 }; + const anotherToken = { ...SEPOLIA_TOKEN, balance: bn2, usdBalance: 200 }; + + const tokenGroup: TokenUiGroup = { + id: anotherToken.id, + nativeToken: anotherToken, + tokens: [anotherToken], + balance: anotherToken.balance, + usdBalance: anotherToken.usdBalance + }; + + const expectedGroup: TokenUiGroup = { + ...tokenGroup, + tokens: [anotherToken, token], + balance: anotherToken.balance.add(token.balance), + usdBalance: anotherToken.usdBalance + token.usdBalance + }; + + it('should add a token to a token group successfully', () => { + expect(updateTokenGroup({ token, tokenGroup })).toStrictEqual(expectedGroup); + }); + + it('should add a token to a token group with multiple tokens successfully', () => { + const thirdToken = { ...BTC_TESTNET_TOKEN, balance: bn3, usdBalance: 300 }; + + const initialGroup = updateTokenGroup({ token: thirdToken, tokenGroup }); + + const updatedGroup = updateTokenGroup({ token, tokenGroup: initialGroup }); + + expect(updatedGroup).toStrictEqual({ + ...tokenGroup, + tokens: [anotherToken, thirdToken, token], + balance: anotherToken.balance.add(thirdToken.balance).add(token.balance), + usdBalance: anotherToken.usdBalance + thirdToken.usdBalance + token.usdBalance + }); + }); + + it('should handle nullish balances in tokenGroup correctly', () => { + expect( + updateTokenGroup({ + token, + tokenGroup: { ...tokenGroup, balance: undefined } + }) + ).toStrictEqual({ ...expectedGroup, balance: undefined }); + + expect( + updateTokenGroup({ + token, + tokenGroup: { ...tokenGroup, balance: null } + }) + ).toStrictEqual({ ...expectedGroup, balance: token.balance }); + + expect( + updateTokenGroup({ + token, + tokenGroup: { ...tokenGroup, usdBalance: undefined } + }) + ).toStrictEqual({ ...expectedGroup, usdBalance: token.usdBalance }); + }); + + it('should handle nullish balances in token correctly', () => { + expect( + updateTokenGroup({ + token: { ...token, balance: undefined }, + tokenGroup + }) + ).toStrictEqual({ + ...expectedGroup, + tokens: [anotherToken, { ...token, balance: undefined }], + balance: undefined + }); + + expect( + updateTokenGroup({ + token: { ...token, balance: null }, + tokenGroup + }) + ).toStrictEqual({ + ...expectedGroup, + tokens: [anotherToken, { ...token, balance: null }], + balance: tokenGroup.balance + }); + + expect( + updateTokenGroup({ + token: { ...token, usdBalance: undefined }, + tokenGroup + }) + ).toStrictEqual({ + ...expectedGroup, + tokens: [anotherToken, { ...token, usdBalance: undefined }], + usdBalance: tokenGroup.usdBalance + }); + }); +}); + +describe('groupMainToken', () => { + const token = { ...ICP_TOKEN, balance: bn1, usdBalance: 100 }; + const anotherToken = { ...BTC_REGTEST_TOKEN, balance: bn2, usdBalance: 200 }; + + // We mock the tokens to have the same "main token" + const twinToken = { + ...SEPOLIA_TOKEN, + balance: bn2, + usdBalance: 250, + twinToken: ICP_TOKEN, + decimals: ICP_TOKEN.decimals + }; + + it('should create a new group when no tokenGroup exists', () => { + expect(groupMainToken({ token, tokenGroup: undefined })).toEqual({ + id: token.id, + nativeToken: token, + tokens: [token], + balance: token.balance, + usdBalance: token.usdBalance + }); + }); + + it('should add token to existing group and update balances', () => { + const tokenGroup: TokenUiGroup = { + id: token.id, + nativeToken: token, + tokens: [twinToken], + balance: bn3, + usdBalance: 300 + }; + + expect(groupMainToken({ token, tokenGroup })).toEqual({ + ...tokenGroup, + tokens: [...tokenGroup.tokens, token], + balance: tokenGroup.balance!.add(token.balance), + usdBalance: tokenGroup.usdBalance! + token.usdBalance + }); + }); + + it('should add token to existing group with more than one token already', () => { + const tokenGroup: TokenUiGroup = { + id: token.id, + nativeToken: token, + tokens: [twinToken, anotherToken], + balance: bn3, + usdBalance: 300 + }; + + expect(groupMainToken({ token, tokenGroup })).toEqual({ + ...tokenGroup, + tokens: [...tokenGroup.tokens, token], + balance: tokenGroup.balance!.add(token.balance), + usdBalance: tokenGroup.usdBalance! + token.usdBalance + }); + }); + + it('should override the "main token" props if the group was created by a "secondary token"', () => { + const tokenGroup: TokenUiGroup = { + id: twinToken.id, + nativeToken: twinToken, + tokens: [twinToken], + balance: bn3, + usdBalance: 300 + }; + + expect(groupMainToken({ token, tokenGroup })).toEqual({ + ...tokenGroup, + id: token.id, + nativeToken: token, + tokens: [...tokenGroup.tokens, token], + balance: tokenGroup.balance!.add(token.balance), + usdBalance: tokenGroup.usdBalance! + token.usdBalance + }); + }); +}); + +describe('groupSecondaryToken', () => { + const token = { ...ICP_TOKEN, balance: bn1, usdBalance: 100 }; + const anotherToken = { ...BTC_REGTEST_TOKEN, balance: bn2, usdBalance: 200 }; + + // We mock the tokens to have the same "main token" + const twinToken = { + ...SEPOLIA_TOKEN, + balance: bn2, + usdBalance: 250, + twinToken: ICP_TOKEN, + decimals: ICP_TOKEN.decimals + }; + + it('should create a new group when no tokenGroup exists', () => { + expect(groupSecondaryToken({ token: twinToken, tokenGroup: undefined })).toEqual({ + id: twinToken.id, + nativeToken: twinToken, + tokens: [twinToken], + balance: twinToken.balance, + usdBalance: twinToken.usdBalance + }); + }); + + it('should add token to existing group and update balances', () => { + const tokenGroup: TokenUiGroup = { + id: token.id, + nativeToken: token, + tokens: [token], + balance: bn3, + usdBalance: 300 + }; + + expect(groupSecondaryToken({ token: twinToken, tokenGroup })).toEqual({ + ...tokenGroup, + tokens: [...tokenGroup.tokens, twinToken], + balance: tokenGroup.balance!.add(twinToken.balance), + usdBalance: tokenGroup.usdBalance! + twinToken.usdBalance + }); + }); + + it('should add token to existing group with more than one token already', () => { + const tokenGroup: TokenUiGroup = { + id: token.id, + nativeToken: token, + tokens: [token, anotherToken], + balance: bn3, + usdBalance: 300 + }; + + expect(groupSecondaryToken({ token: twinToken, tokenGroup })).toEqual({ + ...tokenGroup, + tokens: [...tokenGroup.tokens, twinToken], + balance: tokenGroup.balance!.add(twinToken.balance), + usdBalance: tokenGroup.usdBalance! + twinToken.usdBalance + }); + }); +}); + +describe('groupTokens', () => { + const mockToken = { ...SEPOLIA_TOKEN, balance: bn1, usdBalance: 100 }; + const mockSecondToken = { ...BTC_TESTNET_TOKEN, balance: bn3, usdBalance: 300 }; + const mockThirdToken = { ...ICP_TOKEN, balance: bn2, usdBalance: 200 }; + + // We mock the tokens to have the same "main token" + const mockTwinToken1 = { + ...mockValidIcToken, + balance: bn2, + usdBalance: 250, + twinToken: mockToken, + decimals: mockToken.decimals + }; + const mockTwinToken2 = { + ...mockValidIcToken, + balance: bn1, + usdBalance: 450, + twinToken: mockToken, + decimals: mockToken.decimals + }; + + it('should return an empty array if no tokens are provided', () => { + expect(groupTokens([]).length).toBe(0); + }); + + it('should create groups of single-element tokens if none of them have a "main token"', () => { + const tokens = [mockToken, mockSecondToken, mockThirdToken]; + + const result = groupTokens(tokens); + + expect(result.length).toBe(3); + + result.map((group) => { + expect(group.tokens.length).toBe(1); + }); + + expect(result[0].id).toBe(mockToken.id); + expect(result[1].id).toBe(mockSecondToken.id); + expect(result[2].id).toBe(mockThirdToken.id); + + expect(result[0].nativeToken).toBe(mockToken); + expect(result[1].nativeToken).toBe(mockSecondToken); + expect(result[2].nativeToken).toBe(mockThirdToken); + + expect(result[0].balance).toBe(mockToken.balance); + expect(result[1].balance).toBe(mockSecondToken.balance); + expect(result[2].balance).toBe(mockThirdToken.balance); + + expect(result[0].usdBalance).toBe(mockToken.usdBalance); + expect(result[1].usdBalance).toBe(mockSecondToken.usdBalance); + expect(result[2].usdBalance).toBe(mockThirdToken.usdBalance); + + expect(result[0].tokens[0]).toBe(mockToken); + expect(result[1].tokens[0]).toBe(mockSecondToken); + expect(result[2].tokens[0]).toBe(mockThirdToken); + }); + + it('should group tokens with the same "main token" and same decimals', () => { + const tokens = [mockToken, mockTwinToken1, mockSecondToken, mockTwinToken2]; + + const result = groupTokens(tokens); + + expect(result.length).toBe(2); + + expect(result[0].tokens.length).toBe(3); + expect(result[1].tokens.length).toBe(1); + + expect(result[0].id).toBe(mockToken.id); + expect(result[1].id).toBe(mockSecondToken.id); + + expect(result[0].nativeToken).toBe(mockToken); + expect(result[1].nativeToken).toBe(mockSecondToken); + + expect(result[0].balance).toStrictEqual( + mockToken.balance.add(mockTwinToken1.balance).add(mockTwinToken2.balance) + ); + expect(result[1].balance).toBe(mockSecondToken.balance); + + expect(result[0].usdBalance).toBe( + mockToken.usdBalance + mockTwinToken1.usdBalance + mockTwinToken2.usdBalance + ); + expect(result[1].usdBalance).toBe(mockSecondToken.usdBalance); + + expect(result[0].tokens[0]).toBe(mockToken); + expect(result[0].tokens[1]).toBe(mockTwinToken1); + expect(result[0].tokens[2]).toBe(mockTwinToken2); + }); + + it('should group tokens with the same "main token" but not the ones with different decimals', () => { + const mockTwinToken = { + ...mockTwinToken2, + decimals: mockTwinToken2.decimals + 1 + }; + + const tokens = [mockToken, mockTwinToken1, mockSecondToken, mockTwinToken]; + + const result = groupTokens(tokens); + + expect(result.length).toBe(3); + + expect(result[0].tokens.length).toBe(2); + expect(result[1].tokens.length).toBe(1); + expect(result[2].tokens.length).toBe(1); + + expect(result[0].id).toBe(mockToken.id); + expect(result[1].id).toBe(mockSecondToken.id); + expect(result[2].id).toBe(mockTwinToken.id); + + expect(result[0].nativeToken).toBe(mockToken); + expect(result[1].nativeToken).toBe(mockSecondToken); + expect(result[2].nativeToken).toBe(mockTwinToken); + + expect(result[0].balance).toStrictEqual(mockToken.balance.add(mockTwinToken1.balance)); + expect(result[1].balance).toBe(mockSecondToken.balance); + expect(result[2].balance).toBe(mockTwinToken.balance); + + expect(result[0].usdBalance).toBe(mockToken.usdBalance + mockTwinToken1.usdBalance); + expect(result[1].usdBalance).toBe(mockSecondToken.usdBalance); + expect(result[2].usdBalance).toBe(mockTwinToken.usdBalance); + + expect(result[0].tokens[0]).toBe(mockToken); + expect(result[0].tokens[1]).toBe(mockTwinToken1); + }); + + it('should group tokens with the same "main token" respecting the order they arrive in', () => { + const tokens = [mockTwinToken1, mockSecondToken, mockToken, mockTwinToken2]; + + const result = groupTokens(tokens); + + expect(result.length).toBe(2); + + expect(result[0].tokens.length).toBe(3); + expect(result[1].tokens.length).toBe(1); + + expect(result[0].id).toBe(mockToken.id); + expect(result[1].id).toBe(mockSecondToken.id); + + expect(result[0].nativeToken).toBe(mockToken); + expect(result[1].nativeToken).toBe(mockSecondToken); + + expect(result[0].balance).toStrictEqual( + mockTwinToken1.balance.add(mockToken.balance).add(mockTwinToken2.balance) + ); + expect(result[1].balance).toBe(mockSecondToken.balance); + + expect(result[0].usdBalance).toBe( + mockTwinToken1.usdBalance + mockToken.usdBalance + mockTwinToken2.usdBalance + ); + expect(result[1].usdBalance).toBe(mockSecondToken.usdBalance); + + expect(result[0].tokens[0]).toBe(mockTwinToken1); + expect(result[0].tokens[1]).toBe(mockToken); + expect(result[0].tokens[2]).toBe(mockTwinToken2); + }); + + it('should should create single-element group for the token with no "main token" in the list', () => { + const tokens = [mockTwinToken1, mockSecondToken]; + + const result = groupTokens(tokens); + + expect(result.length).toBe(2); + + result.map((group) => { + expect(group.tokens.length).toBe(1); + }); + + expect(result[0].id).toBe(mockTwinToken1.id); + expect(result[1].id).toBe(mockSecondToken.id); + + expect(result[0].nativeToken).toBe(mockTwinToken1); + expect(result[1].nativeToken).toBe(mockSecondToken); + + expect(result[0].balance).toBe(mockTwinToken1.balance); + expect(result[1].balance).toBe(mockSecondToken.balance); + + expect(result[0].usdBalance).toBe(mockTwinToken1.usdBalance); + expect(result[1].usdBalance).toBe(mockSecondToken.usdBalance); + + expect(result[0].tokens[0]).toBe(mockTwinToken1); + expect(result[1].tokens[0]).toBe(mockSecondToken); + }); + + it('should not re-sort the groups even if the total balance of a group would put it in a higher position in the list', () => { + // We mock the tokens to have the same "main token" + const tokens = [mockSecondToken, mockToken, mockTwinToken1, mockTwinToken2]; + + const result = groupTokens(tokens); + + expect(result.length).toBe(2); + + expect(result[0].tokens.length).not.toBe(3); + + expect(result[0].id).not.toBe(mockToken.id); + + expect(result[0].nativeToken).not.toBe(mockToken); + + expect(result[0].balance).not.toStrictEqual( + mockToken.balance.add(mockTwinToken1.balance).add(mockTwinToken2.balance) + ); + }); + + it('should group with balance undefined if any of the tokens has balance undefined', () => { + const mockTwinToken = { + ...mockTwinToken1, + balance: undefined, + usdBalance: undefined + }; + + const tokens = [mockToken, mockTwinToken, mockTwinToken2]; + + const result = groupTokens(tokens); + + expect(result.length).toBe(1); + + expect(result[0].tokens.length).toBe(3); + + expect(result[0].id).toBe(mockToken.id); + + expect(result[0].nativeToken).toBe(mockToken); + + expect(result[0].balance).toBeUndefined(); + + expect(result[0].usdBalance).toBe(mockToken.usdBalance + mockTwinToken2.usdBalance); + }); +});