diff --git a/.changeset/nervous-pumpkins-remain.md b/.changeset/nervous-pumpkins-remain.md new file mode 100644 index 000000000000..91ee80c43882 --- /dev/null +++ b/.changeset/nervous-pumpkins-remain.md @@ -0,0 +1,8 @@ +--- +"@ledgerhq/types-live": patch +"ledger-live-desktop": patch +"@ledgerhq/live-common": patch +"@ledgerhq/live-nft-react": patch +--- + +Add useCheckNftAccount Hook diff --git a/apps/ledger-live-desktop/src/renderer/hooks/useHideSpamCollection.ts b/apps/ledger-live-desktop/src/renderer/hooks/useHideSpamCollection.ts new file mode 100644 index 000000000000..e4ac73c9cc1a --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/hooks/useHideSpamCollection.ts @@ -0,0 +1,24 @@ +import { useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { hideNftCollection } from "~/renderer/actions/settings"; +import { hiddenNftCollectionsSelector } from "../reducers/settings"; + +export function useHideSpamCollection() { + const spamFilteringTxFeature = useFeature("spamFilteringTx"); + const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); + const dispatch = useDispatch(); + const hideSpamCollection = useCallback( + (collection: string) => { + if (!hiddenNftCollections.includes(collection)) { + dispatch(hideNftCollection(collection)); + } + }, + [dispatch, hiddenNftCollections], + ); + + return { + hideSpamCollection, + enabled: spamFilteringTxFeature?.enabled, + }; +} diff --git a/apps/ledger-live-desktop/src/renderer/screens/nft/Collections/Collections.tsx b/apps/ledger-live-desktop/src/renderer/screens/nft/Collections/Collections.tsx index f1e6af2de780..345a47071e1d 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/nft/Collections/Collections.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/nft/Collections/Collections.tsx @@ -19,8 +19,9 @@ import Text from "~/renderer/components/Text"; import { openURL } from "~/renderer/linking"; import Box from "~/renderer/components/Box"; import Row from "./Row"; -import { isThresholdValid, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; +import { isThresholdValid, useCheckNftAccount } from "@ledgerhq/live-nft-react"; import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { useHideSpamCollection } from "~/renderer/hooks/useHideSpamCollection"; const INCREMENT = 5; const EmptyState = styled.div` @@ -71,11 +72,14 @@ const Collections = ({ account }: Props) => { [account.id, history], ); - const { nfts, fetchNextPage, hasNextPage } = useNftGalleryFilter({ + const { enabled, hideSpamCollection } = useHideSpamCollection(); + + const { nfts, fetchNextPage, hasNextPage } = useCheckNftAccount({ nftsOwned: account.nfts || [], addresses: account.freshAddress, chains: [account.currency.id], threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + ...(enabled && { action: hideSpamCollection }), }); const collections = useMemo( diff --git a/libs/live-nft-react/.unimportedrc.json b/libs/live-nft-react/.unimportedrc.json index f14e07def2f7..187a263de59b 100644 --- a/libs/live-nft-react/.unimportedrc.json +++ b/libs/live-nft-react/.unimportedrc.json @@ -1,4 +1,4 @@ { "entry": ["src/index.ts", "src/tools/*", "src/hooks/*"], - "ignoreUnused": [] + "ignoreUnused": ["@ledgerhq/coin-framework/nft/nftId"] } diff --git a/libs/live-nft-react/package.json b/libs/live-nft-react/package.json index 4bd5eb574644..5217d53dac14 100644 --- a/libs/live-nft-react/package.json +++ b/libs/live-nft-react/package.json @@ -28,7 +28,8 @@ "@ledgerhq/cryptoassets": "workspace:*", "@ledgerhq/live-nft": "workspace:*", "@ledgerhq/types-cryptoassets": "workspace:*", - "@ledgerhq/types-live": "workspace:*" + "@ledgerhq/types-live": "workspace:*", + "@ledgerhq/coin-framework": "workspace:*" }, "devDependencies": { "@testing-library/react": "14", @@ -38,7 +39,6 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "ts-jest": "^29.1.1", - "@ledgerhq/coin-framework": "workspace:*", "@tanstack/react-query": "^5.28.9", "bignumber.js": "9" }, diff --git a/libs/live-nft-react/src/hooks/__tests__/useCheckNftAccount.test.tsx b/libs/live-nft-react/src/hooks/__tests__/useCheckNftAccount.test.tsx new file mode 100644 index 000000000000..fd09fe0ea9c1 --- /dev/null +++ b/libs/live-nft-react/src/hooks/__tests__/useCheckNftAccount.test.tsx @@ -0,0 +1,82 @@ +import { waitFor, renderHook } from "@testing-library/react"; +import { SimpleHashResponse } from "@ledgerhq/live-nft/api/types"; +import { notifyManager } from "@tanstack/react-query"; + +import { wrapper, generateNftsOwned } from "../../tools/helperTests"; +import { useCheckNftAccount } from "../useCheckNftAccount"; + +jest.setTimeout(30000); + +// invoke callback instantly +notifyManager.setScheduler(cb => cb()); + +const pagedBy = 5; + +const nftsOwned = generateNftsOwned(); +const expected = [...new Set(nftsOwned)]; +expected.sort(() => Math.random() - 0.5); + +const apiResults: SimpleHashResponse[] = []; +for (let i = 0; i < expected.length; i += pagedBy) { + const slice = expected.slice(i, i + pagedBy); + const apiResult = { + next_cursor: i + pagedBy < expected.length ? String(i + pagedBy) : null, + nfts: slice.map(nft => ({ + nft_id: nft.id, + chain: "ethereum", + contract_address: nft.contract, + token_id: nft.tokenId, + image_url: "", + name: "", + description: "", + token_count: 1, + collection: { name: "", spam_score: 0 }, + contract: { type: "ERC721" }, + extra_metadata: { + image_original_url: "", + animation_original_url: "", + }, + })), + }; + apiResults.push(apiResult); +} + +let callCount = 0; + +jest.mock("@ledgerhq/live-nft/api/simplehash", () => ({ + fetchNftsFromSimpleHash: jest.fn().mockImplementation(opts => { + const { cursor } = opts; + const index = cursor ? Number(cursor) : 0; + + const pageIndex = Math.floor(index / pagedBy); + if (!apiResults[pageIndex]) throw new Error("no such page"); + + callCount++; + return Promise.resolve(apiResults[pageIndex]); + }), +})); + +describe("useCheckNftAccount", () => { + test("fetches all pages", async () => { + const addresses = "0x34"; + const chains = ["ethereum"]; + + const { result } = renderHook( + () => + useCheckNftAccount({ + addresses, + nftsOwned, + chains, + threshold: 80, + }), + { + wrapper, + }, + ); + + await waitFor(() => !result.current.hasNextPage); + + expect(callCount).toBe(nftsOwned.length / pagedBy); + expect(result.current.nfts.length).toEqual(nftsOwned.length); + }); +}); diff --git a/libs/live-nft-react/src/hooks/__tests__/useNftGalleryFilter.test.tsx b/libs/live-nft-react/src/hooks/__tests__/useNftGalleryFilter.test.tsx index 88a1434d8236..7d57d334dc66 100644 --- a/libs/live-nft-react/src/hooks/__tests__/useNftGalleryFilter.test.tsx +++ b/libs/live-nft-react/src/hooks/__tests__/useNftGalleryFilter.test.tsx @@ -1,47 +1,17 @@ -import { BigNumber } from "bignumber.js"; import { waitFor, act, renderHook } from "@testing-library/react"; -import { isThresholdValid, useNftGalleryFilter } from "../useNftGalleryFilter"; -import { NFTs } from "@ledgerhq/coin-framework/mocks/fixtures/nfts"; -import { encodeNftId } from "@ledgerhq/coin-framework/nft/nftId"; +import { useNftGalleryFilter } from "../useNftGalleryFilter"; + import { SimpleHashResponse } from "@ledgerhq/live-nft/api/types"; import { notifyManager } from "@tanstack/react-query"; -import { wrapper } from "../../tools/helperTests"; +import { generateNftsOwned, wrapper } from "../../tools/helperTests"; +import { isThresholdValid } from "../helpers"; jest.setTimeout(30000); // invoke callback instantly notifyManager.setScheduler(cb => cb()); -type FakeNFTRaw = { - id: string; - tokenId: string; - amount: BigNumber; - contract: string; - standard: "ERC721"; - currencyId: string; - metadata: undefined; -}; -const generateNftsOwned = () => { - const nfts: FakeNFTRaw[] = []; - - NFTs.forEach(nft => { - for (let i = 1; i <= 20; i++) { - nfts.push({ - id: encodeNftId("foo", nft.collection.contract, String(i), "ethereum"), - tokenId: String(i), - amount: new BigNumber(0), - contract: nft.collection.contract, - standard: "ERC721" as const, - currencyId: "ethereum", - metadata: undefined, - }); - } - }); - - return nfts; -}; - // TODO better way to make ProtoNFT[] collection const nftsOwned = generateNftsOwned(); let expected = [...new Set(nftsOwned)]; diff --git a/libs/live-nft-react/src/hooks/helpers/index.ts b/libs/live-nft-react/src/hooks/helpers/index.ts new file mode 100644 index 000000000000..55657dcfe669 --- /dev/null +++ b/libs/live-nft-react/src/hooks/helpers/index.ts @@ -0,0 +1,7 @@ +export function hashProtoNFT(contract: string, tokenId: string, currencyId: string): string { + return `${contract}|${tokenId}|${currencyId}`; +} + +export function isThresholdValid(threshold?: string | number): boolean { + return Number(threshold) >= 0 && Number(threshold) <= 100; +} diff --git a/libs/live-nft-react/src/hooks/types.ts b/libs/live-nft-react/src/hooks/types.ts index 7321a50548be..8a33820aa55c 100644 --- a/libs/live-nft-react/src/hooks/types.ts +++ b/libs/live-nft-react/src/hooks/types.ts @@ -18,6 +18,7 @@ export type HookProps = { nftsOwned: ProtoNFT[]; chains: string[]; threshold: number; + action?: (collection: string) => void; }; export type PartialProtoNFT = Partial; @@ -29,6 +30,13 @@ export type NftGalleryFilterResult = UseInfiniteQueryResult< nfts: ProtoNFT[]; }; +export type NftsFilterResult = UseInfiniteQueryResult< + InfiniteData, + Error +> & { + nfts: ProtoNFT[]; +}; + // SpamReportNft export type SpamReportNftResult = UseMutationResult< SimpleHashSpamReportResponse, diff --git a/libs/live-nft-react/src/hooks/useCheckNftAccount.ts b/libs/live-nft-react/src/hooks/useCheckNftAccount.ts new file mode 100644 index 000000000000..4eaad5874cbd --- /dev/null +++ b/libs/live-nft-react/src/hooks/useCheckNftAccount.ts @@ -0,0 +1,78 @@ +import { useEffect, useMemo } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { fetchNftsFromSimpleHash } from "@ledgerhq/live-nft/api/simplehash"; +import { ProtoNFT } from "@ledgerhq/types-live"; +import { NFTS_QUERY_KEY } from "../queryKeys"; +import { HookProps, NftsFilterResult } from "./types"; +import { decodeNftId } from "@ledgerhq/coin-framework/nft/nftId"; +import { nftsByCollections } from "@ledgerhq/live-nft/index"; +import { hashProtoNFT } from "./helpers"; + +/** + * useCheckNftAccount() will apply a spam filtering on top of existing NFT data. + * - addresses: a list of wallet addresses separated by a "," + * - nftOwned: the array of all nfts as found by all user's account on Ledger Live + * - chains: a list of selected network to search for NFTs + * - action: custom action to handle collections + * NB: for performance, make sure that addresses, nftOwned and chains are memoized + */ +export function useCheckNftAccount({ + addresses, + nftsOwned, + chains, + threshold, + action, +}: HookProps): NftsFilterResult { + // for performance, we hashmap the list of nfts by hash. + const nftsWithProperties = useMemo( + () => + new Map(nftsOwned.map(obj => [hashProtoNFT(obj.contract, obj.tokenId, obj.currencyId), obj])), + [nftsOwned], + ); + + const queryResult = useInfiniteQuery({ + queryKey: [NFTS_QUERY_KEY.SpamFilter, addresses, chains], + queryFn: ({ pageParam }: { pageParam: string | undefined }) => + fetchNftsFromSimpleHash({ addresses, chains, cursor: pageParam, threshold }), + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.next_cursor, + enabled: addresses.length > 0, + }); + + useEffect(() => { + if (queryResult.hasNextPage && !queryResult.isFetchingNextPage) { + queryResult.fetchNextPage(); + } + }, [queryResult, queryResult.hasNextPage, queryResult.isFetchingNextPage]); + + const out = useMemo(() => { + const nfts: ProtoNFT[] = []; + + const processingNFTs = queryResult.data?.pages.flatMap(page => page.nfts); + + if (!queryResult.hasNextPage && processingNFTs) { + for (const nft of processingNFTs) { + const hash = hashProtoNFT(nft.contract_address, nft.token_id, nft.chain); + const existing = nftsWithProperties.get(hash); + if (existing) { + nfts.push(existing); + } + } + + if (action) { + const spams = nftsOwned.filter(nft => !nfts.some(ownedNft => ownedNft.id === nft.id)); + + const collections = nftsByCollections(spams); + + Object.entries(collections).map(([contract, nfts]: [string, ProtoNFT[]]) => { + const { accountId } = decodeNftId(nfts[0].id); + const collection = `${accountId}|${contract}`; + action(collection); + }); + } + } + return { ...queryResult, nfts }; + }, [queryResult, action, nftsWithProperties, nftsOwned]); + + return out; +} diff --git a/libs/live-nft-react/src/hooks/useNftGalleryFilter.ts b/libs/live-nft-react/src/hooks/useNftGalleryFilter.ts index e03cc4724879..cedbaf10c41f 100644 --- a/libs/live-nft-react/src/hooks/useNftGalleryFilter.ts +++ b/libs/live-nft-react/src/hooks/useNftGalleryFilter.ts @@ -4,6 +4,7 @@ import { fetchNftsFromSimpleHash } from "@ledgerhq/live-nft/api/simplehash"; import { ProtoNFT } from "@ledgerhq/types-live"; import { NFTS_QUERY_KEY } from "../queryKeys"; import { NftGalleryFilterResult, HookProps } from "./types"; +import { hashProtoNFT } from "./helpers"; /** * useNftGalleryFilter() will apply a spam filtering on top of existing NFT data. @@ -52,11 +53,3 @@ export function useNftGalleryFilter({ return out; } - -function hashProtoNFT(contract: string, tokenId: string, currencyId: string): string { - return `${contract}|${tokenId}|${currencyId}`; -} - -export function isThresholdValid(threshold?: string | number): boolean { - return Number(threshold) >= 0 && Number(threshold) <= 100; -} diff --git a/libs/live-nft-react/src/index.ts b/libs/live-nft-react/src/index.ts index 0fe3e2c0d1f5..50e4700d566a 100644 --- a/libs/live-nft-react/src/index.ts +++ b/libs/live-nft-react/src/index.ts @@ -7,3 +7,5 @@ export * from "./hooks/useCheckSpamScore"; export * from "./hooks/useFetchOrdinals"; export * from "./hooks/useFetchOrdinalByTokenId"; export * from "./hooks/helpers/ordinals"; +export * from "./hooks/useCheckNftAccount"; +export * from "./hooks/helpers/index"; diff --git a/libs/live-nft-react/src/tools/helperTests.tsx b/libs/live-nft-react/src/tools/helperTests.tsx index 167bed0dde05..0697b401507a 100644 --- a/libs/live-nft-react/src/tools/helperTests.tsx +++ b/libs/live-nft-react/src/tools/helperTests.tsx @@ -1,8 +1,41 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React, { ReactNode } from "react"; +import { BigNumber } from "bignumber.js"; +import { NFTs } from "@ledgerhq/coin-framework/mocks/fixtures/nfts"; +import { encodeNftId } from "@ledgerhq/coin-framework/nft/nftId"; const queryClient = new QueryClient(); export const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); + +export type FakeNFTRaw = { + id: string; + tokenId: string; + amount: BigNumber; + contract: string; + standard: "ERC721"; + currencyId: string; + metadata: undefined; +}; + +export const generateNftsOwned = () => { + const nfts: FakeNFTRaw[] = []; + + NFTs.forEach(nft => { + for (let i = 1; i <= 20; i++) { + nfts.push({ + id: encodeNftId("foo", nft.collection.contract, String(i), "ethereum"), + tokenId: String(i), + amount: new BigNumber(0), + contract: nft.collection.contract, + standard: "ERC721" as const, + currencyId: "ethereum", + metadata: undefined, + }); + } + }); + + return nfts; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 885912da0c94..ed4772257fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6056,6 +6056,9 @@ importers: libs/live-nft-react: dependencies: + '@ledgerhq/coin-framework': + specifier: workspace:* + version: link:../coin-framework '@ledgerhq/cryptoassets': specifier: workspace:* version: link:../ledgerjs/packages/cryptoassets @@ -6069,9 +6072,6 @@ importers: specifier: workspace:* version: link:../ledgerjs/packages/types-live devDependencies: - '@ledgerhq/coin-framework': - specifier: workspace:* - version: link:../coin-framework '@tanstack/react-query': specifier: ^5.28.9 version: 5.28.9(react@18.2.0)