Skip to content

Commit

Permalink
feat: useCheckNftAccount in [LIB] (#7891)
Browse files Browse the repository at this point in the history
* feat: useCheckNftAccount in [LIB]

* ✨(lld): add suggestion

* ✨(lld): fix what rebase broke

---------

Co-authored-by: Lucas Werey <lucas.werey@ledger.fr>
  • Loading branch information
mcayuelas-ledger and LucasWerey authored Oct 8, 2024
1 parent 67ed92a commit 00cab1d
Show file tree
Hide file tree
Showing 14 changed files with 259 additions and 50 deletions.
8 changes: 8 additions & 0 deletions .changeset/nervous-pumpkins-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@ledgerhq/types-live": patch
"ledger-live-desktop": patch
"@ledgerhq/live-common": patch
"@ledgerhq/live-nft-react": patch
---

Add useCheckNftAccount Hook
Original file line number Diff line number Diff line change
@@ -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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion libs/live-nft-react/.unimportedrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"entry": ["src/index.ts", "src/tools/*", "src/hooks/*"],
"ignoreUnused": []
"ignoreUnused": ["@ledgerhq/coin-framework/nft/nftId"]
}
4 changes: 2 additions & 2 deletions libs/live-nft-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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)];
Expand Down
7 changes: 7 additions & 0 deletions libs/live-nft-react/src/hooks/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions libs/live-nft-react/src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type HookProps = {
nftsOwned: ProtoNFT[];
chains: string[];
threshold: number;
action?: (collection: string) => void;
};

export type PartialProtoNFT = Partial<ProtoNFT>;
Expand All @@ -29,6 +30,13 @@ export type NftGalleryFilterResult = UseInfiniteQueryResult<
nfts: ProtoNFT[];
};

export type NftsFilterResult = UseInfiniteQueryResult<
InfiniteData<SimpleHashResponse, unknown>,
Error
> & {
nfts: ProtoNFT[];
};

// SpamReportNft
export type SpamReportNftResult = UseMutationResult<
SimpleHashSpamReportResponse,
Expand Down
78 changes: 78 additions & 0 deletions libs/live-nft-react/src/hooks/useCheckNftAccount.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 1 addition & 8 deletions libs/live-nft-react/src/hooks/useNftGalleryFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions libs/live-nft-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
33 changes: 33 additions & 0 deletions libs/live-nft-react/src/tools/helperTests.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

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;
};
Loading

0 comments on commit 00cab1d

Please sign in to comment.