From 1605d3c1b9f64d8edc1452db8223801fde1487d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Lambert?= <44363395+lambertkevin@users.noreply.github.com> Date: Thu, 7 Apr 2022 19:54:53 +0200 Subject: [PATCH] [LIVE-1911][LIVE-1912] Feature - Add collection name resolver to eth familly (#1863) * Change nfts resolvers from Bridge type * Update eth nft resolvers & add collection resolver * Add loadCollectionMetadata to nft context * Change eth NFT prepareTransaction to use collection call instead of nft metadata call --- src/api/Ethereum.ts | 29 ++++- src/families/ethereum/bridge/js.ts | 7 +- src/families/ethereum/modules/erc721.ts | 3 +- src/families/ethereum/nftMetadataResolver.ts | 43 ------- src/families/ethereum/nftResolvers.ts | 79 ++++++++++++ src/nft/NftMetadataProvider/index.tsx | 127 +++++++++++++++++-- src/nft/NftMetadataProvider/types.ts | 37 ++++-- src/nft/helpers.ts | 58 ++++++--- src/types/bridge.ts | 25 +++- src/types/nft.ts | 12 ++ 10 files changed, 320 insertions(+), 100 deletions(-) delete mode 100644 src/families/ethereum/nftMetadataResolver.ts create mode 100644 src/families/ethereum/nftResolvers.ts diff --git a/src/api/Ethereum.ts b/src/api/Ethereum.ts index 23a87add43..bdcc2fb69f 100644 --- a/src/api/Ethereum.ts +++ b/src/api/Ethereum.ts @@ -3,7 +3,11 @@ import invariant from "invariant"; import { BigNumber } from "bignumber.js"; import { LedgerAPINotAvailable } from "@ledgerhq/errors"; import JSONBigNumber from "@ledgerhq/json-bignumber"; -import type { CryptoCurrency, NFTMetadataResponse } from "../types"; +import type { + CryptoCurrency, + NFTCollectionMetadataResponse, + NFTMetadataResponse, +} from "../types"; import type { EthereumGasLimitRequest } from "../families/ethereum/types"; import network from "../network"; import { blockchainBaseURL } from "./Ledger"; @@ -83,6 +87,11 @@ export type NFTMetadataInput = Readonly< tokenId: string; }> >; +export type NFTCollectionMetadataInput = Readonly< + Array<{ + contract: string; + }> +>; export type API = { getTransactions: ( address: string, @@ -100,6 +109,10 @@ export type API = { input: NFTMetadataInput, chainId: string ) => Promise; + getNFTCollectionMetadata: ( + input: NFTCollectionMetadataInput, + chainId: string + ) => Promise; getAccountBalance: (address: string) => Promise; roughlyEstimateGasLimit: (address: string) => Promise; getERC20ApprovalsPerContract: ( @@ -220,6 +233,20 @@ export const apiForCurrency = (currency: CryptoCurrency): API => { return data; }, + async getNFTCollectionMetadata(input, chainId) { + const { data }: { data: NFTCollectionMetadataResponse[] } = await network( + { + method: "POST", + url: `${getEnv( + "NFT_ETH_METADATA_SERVICE" + )}/v1/ethereum/${chainId}/contracts/infos`, + data: input, + } + ); + + return data; + }, + async getERC20ApprovalsPerContract(owner, contract) { try { const { data } = await network({ diff --git a/src/families/ethereum/bridge/js.ts b/src/families/ethereum/bridge/js.ts index d5f8cf1ad2..789f7467d2 100644 --- a/src/families/ethereum/bridge/js.ts +++ b/src/families/ethereum/bridge/js.ts @@ -33,7 +33,7 @@ import { signOperation } from "../signOperation"; import { modes } from "../modules"; import postSyncPatch from "../postSyncPatch"; import { inferDynamicRange } from "../../../range"; -import nftMetadataResolver from "../nftMetadataResolver"; +import { nftMetadata, collectionMetadata } from "../nftResolvers"; const receive = makeAccountBridgeReceive(); @@ -211,7 +211,10 @@ const currencyBridge: CurrencyBridge = { preload, hydrate, scanAccounts, - nftMetadataResolver, + nftResolvers: { + nftMetadata, + collectionMetadata, + }, }; const accountBridge: AccountBridge = { createTransaction, diff --git a/src/families/ethereum/modules/erc721.ts b/src/families/ethereum/modules/erc721.ts index ac5e39dc88..1083653a5a 100644 --- a/src/families/ethereum/modules/erc721.ts +++ b/src/families/ethereum/modules/erc721.ts @@ -23,11 +23,10 @@ export async function prepareTransaction( const { collection, collectionName, tokenIds } = transaction; if (collection && tokenIds && typeof collectionName === "undefined") { const api = apiForCurrency(account.currency); - const [{ status, result }] = await api.getNFTMetadata( + const [{ status, result }] = await api.getNFTCollectionMetadata( [ { contract: collection, - tokenId: tokenIds[0], }, ], account.currency?.ethereumLikeInfo?.chainId?.toString() || "1" diff --git a/src/families/ethereum/nftMetadataResolver.ts b/src/families/ethereum/nftMetadataResolver.ts deleted file mode 100644 index 136a3fa6d8..0000000000 --- a/src/families/ethereum/nftMetadataResolver.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { CurrencyBridge, NFTMetadataResponse } from "../../types"; -import { getCryptoCurrencyById } from "../../currencies"; -import { metadataCallBatcher } from "../../nft"; - -const SUPPORTED_CHAIN_IDS = new Set([ - 1, // Ethereum - 137, // Polygon -]); - -const nftMetadataResolver: CurrencyBridge["nftMetadataResolver"] = async ({ - contract, - tokenId, - currencyId, - metadata, -}): Promise => { - // This is for test/mock purposes - if (typeof metadata !== "undefined") { - return { - status: 200, - result: { - contract, - tokenId, - ...metadata, - }, - }; - } - - const currency = getCryptoCurrencyById(currencyId); - const chainId = currency?.ethereumLikeInfo?.chainId; - - if (!chainId || !SUPPORTED_CHAIN_IDS.has(chainId)) { - throw new Error("Ethereum Bridge NFT Resolver: Unsupported chainId"); - } - - const response = await metadataCallBatcher(currency).load({ - contract, - tokenId, - }); - - return response; -}; - -export default nftMetadataResolver; diff --git a/src/families/ethereum/nftResolvers.ts b/src/families/ethereum/nftResolvers.ts new file mode 100644 index 0000000000..f731711bf9 --- /dev/null +++ b/src/families/ethereum/nftResolvers.ts @@ -0,0 +1,79 @@ +import { + CurrencyBridge, + NFTCollectionMetadataResponse, + NFTMetadataResponse, +} from "../../types"; +import { getCryptoCurrencyById } from "../../currencies"; +import { metadataCallBatcher } from "../../nft"; + +const SUPPORTED_CHAIN_IDS = new Set([ + 1, // Ethereum + 137, // Polygon +]); + +type NftResolvers = NonNullable; + +export const nftMetadata: NftResolvers["nftMetadata"] = async ({ + contract, + tokenId, + currencyId, + metadata, +}): Promise => { + // This is for test/mock purposes + if (typeof metadata !== "undefined") { + return { + status: 200, + result: { + contract, + tokenId, + ...metadata, + }, + }; + } + + const currency = getCryptoCurrencyById(currencyId); + const chainId = currency?.ethereumLikeInfo?.chainId; + + if (!chainId || !SUPPORTED_CHAIN_IDS.has(chainId)) { + throw new Error("Ethereum Bridge NFT Resolver: Unsupported chainId"); + } + + const response = (await metadataCallBatcher(currency).loadNft({ + contract, + tokenId, + })) as NFTMetadataResponse; + + return response; +}; + +export const collectionMetadata: NftResolvers["collectionMetadata"] = async ({ + contract, + currencyId, + metadata, +}): Promise => { + // This is for test/mock purposes + if (typeof metadata !== "undefined") { + return { + status: 200, + result: { + contract, + ...metadata, + }, + }; + } + + const currency = getCryptoCurrencyById(currencyId); + const chainId = currency?.ethereumLikeInfo?.chainId; + + if (!chainId || !SUPPORTED_CHAIN_IDS.has(chainId)) { + throw new Error("Ethereum Bridge NFT Resolver: Unsupported chainId"); + } + + const response = (await metadataCallBatcher(currency).loadCollection({ + contract, + })) as NFTCollectionMetadataResponse; + + return response; +}; + +export default { nftMetadata, collectionMetadata }; diff --git a/src/nft/NftMetadataProvider/index.tsx b/src/nft/NftMetadataProvider/index.tsx index 0e95c7f4af..6637c4b8a5 100644 --- a/src/nft/NftMetadataProvider/index.tsx +++ b/src/nft/NftMetadataProvider/index.tsx @@ -5,7 +5,7 @@ import React, { useState, useEffect, } from "react"; -import { getNftKey } from "../helpers"; +import { getNftCollectionKey, getNftKey } from "../helpers"; import { NFTMetadataContextAPI, NFTMetadataContextState, @@ -20,6 +20,7 @@ import { NFT, ProtoNFT } from "../../types"; const NftMetadataContext = createContext({ cache: {}, loadNFTMetadata: () => Promise.resolve(), + loadCollectionMetadata: () => Promise.resolve(), clearCache: () => {}, }); @@ -51,6 +52,32 @@ export function useNftMetadata( } } +export function useNftCollectionMetadata( + contract: string | undefined, + currencyId: string | undefined +): NFTResource { + const { cache, loadCollectionMetadata } = useContext(NftMetadataContext); + const key = + contract && currencyId ? getNftCollectionKey(contract, currencyId) : ""; + + const cachedData = cache[key]; + + useEffect(() => { + if (!contract || !currencyId) return; + if (!cachedData || isOutdated(cachedData)) { + loadCollectionMetadata(contract, currencyId); + } + }, [contract, cachedData, currencyId, loadCollectionMetadata]); + + if (cachedData) { + return cachedData; + } else { + return { + status: "queued", + }; + } +} + type UseNFTResponse = | { status: Exclude } | { status: "loaded"; nft: NFT }; @@ -68,10 +95,10 @@ export function useNft(protoNft: ProtoNFT): UseNFTResponse { [data, status] ); - const nft: NFT | null = useMemo( + const nft = useMemo( () => (status === "loaded" && metadata ? { ...protoNft, metadata } : null), [protoNft, metadata] - ); + ) as NFT | null; return status !== "loaded" ? { status } @@ -82,11 +109,13 @@ export function useNft(protoNft: ProtoNFT): UseNFTResponse { } export function useNftAPI(): NFTMetadataContextAPI { - const { clearCache, loadNFTMetadata } = useContext(NftMetadataContext); + const { clearCache, loadNFTMetadata, loadCollectionMetadata } = + useContext(NftMetadataContext); return { clearCache, loadNFTMetadata, + loadCollectionMetadata, }; } @@ -101,7 +130,7 @@ export function NftMetadataProvider({ cache: {}, }); - const api = useMemo( + const api: NFTMetadataContextAPI = useMemo( () => ({ loadNFTMetadata: async ( contract: string, @@ -112,8 +141,82 @@ export function NftMetadataProvider({ const currency = getCryptoCurrencyById(currencyId); const currencyBridge = getCurrencyBridge(currency); - if (!currencyBridge.nftMetadataResolver) { - throw new Error("Currency doesn't support NFT"); + if (!currencyBridge.nftResolvers?.nftMetadata) { + throw new Error("Currency doesn't support NFT metadata"); + } + + setState((oldState) => ({ + ...oldState, + cache: { + ...oldState.cache, + [key]: { + status: "loading", + }, + }, + })); + + try { + const { status, result } = + await currencyBridge.nftResolvers.nftMetadata({ + contract, + tokenId, + currencyId: currency.id, + }); + + switch (status) { + case 500: + throw new Error("NFT Metadata Provider failed"); + case 404: + setState((oldState) => ({ + ...oldState, + cache: { + ...oldState.cache, + [key]: { + status: "nodata", + metadata: null, + updatedAt: Date.now(), + }, + }, + })); + break; + case 200: + setState((oldState) => ({ + ...oldState, + cache: { + ...oldState.cache, + [key]: { + status: "loaded", + metadata: result, + updatedAt: Date.now(), + }, + }, + })); + break; + default: + break; + } + } catch (error) { + setState((oldState) => ({ + ...oldState, + cache: { + ...oldState.cache, + [key]: { + status: "error", + error, + updatedAt: Date.now(), + }, + }, + })); + } + }, + + loadCollectionMetadata: async (contract: string, currencyId: string) => { + const key = getNftCollectionKey(contract, currencyId); + const currency = getCryptoCurrencyById(currencyId); + const currencyBridge = getCurrencyBridge(currency); + + if (!currencyBridge?.nftResolvers?.collectionMetadata) { + throw new Error("Currency doesn't support Collection Metadata"); } setState((oldState) => ({ @@ -127,11 +230,11 @@ export function NftMetadataProvider({ })); try { - const { status, result } = await currencyBridge.nftMetadataResolver({ - contract, - tokenId, - currencyId: currency.id, - }); + const { status, result } = + await currencyBridge.nftResolvers.collectionMetadata({ + contract, + currencyId: currency.id, + }); switch (status) { case 500: diff --git a/src/nft/NftMetadataProvider/types.ts b/src/nft/NftMetadataProvider/types.ts index 4a61fae30f..cfffed8d7d 100644 --- a/src/nft/NftMetadataProvider/types.ts +++ b/src/nft/NftMetadataProvider/types.ts @@ -1,4 +1,7 @@ -import { NFTMetadataResponse } from "../../types"; +import { + NFTCollectionMetadataResponse, + NFTMetadataResponse, +} from "../../types"; export type NFTResourceQueued = { status: "queued"; @@ -10,7 +13,9 @@ export type NFTResourceLoading = { export type NFTResourceLoaded = { status: "loaded"; - metadata: NFTMetadataResponse["result"]; + metadata: + | NFTMetadataResponse["result"] + | NFTCollectionMetadataResponse["result"]; updatedAt: number; }; @@ -42,6 +47,10 @@ export type NFTMetadataContextAPI = { tokenId: string, currencyId: string ) => Promise; + loadCollectionMetadata: ( + contract: string, + currencyId: string + ) => Promise; clearCache: () => void; }; @@ -49,26 +58,26 @@ export type NFTMetadataContextType = NFTMetadataContextState & NFTMetadataContextAPI; export type Batcher = { - load: ({ - contract, - tokenId, - }: { - contract: string; - tokenId: string; - }) => Promise; + load: ( + element: + | { + contract: string; + tokenId: string; + } + | { + contract: string; + } + ) => Promise; }; export type BatchElement = { - couple: { - contract: string; - tokenId: string; - }; + element: any; resolve: (value: NFTMetadataResponse) => void; reject: (reason?: Error) => void; }; export type Batch = { - couples: Array; + elements: Array; resolvers: Array; rejecters: Array; }; diff --git a/src/nft/helpers.ts b/src/nft/helpers.ts index dec479ad9d..020a9d29bb 100644 --- a/src/nft/helpers.ts +++ b/src/nft/helpers.ts @@ -10,6 +10,7 @@ import type { ProtoNFT, NFT, CryptoCurrency, + NFTCollectionMetadataResponse, } from "../types"; import { API, apiForCurrency } from "../api/Ethereum"; @@ -104,6 +105,13 @@ export const getNftKey = ( return `${currencyId}-${contract}-${tokenId}`; }; +export const getNftCollectionKey = ( + contract: string, + currencyId: string +): string => { + return `${currencyId}-${contract}`; +}; + /** * Factory to make a metadata API call batcher. * @@ -112,7 +120,10 @@ export const getNftKey = ( * Once the response is received, it will then spread the metadata to each request Promise, * just like if each request had been made separately. */ -const makeBatcher = (api: API, chainId: number): Batcher => +const makeBatcher = ( + call: API["getNFTMetadata"] | API["getNFTCollectionMetadata"], + chainId: number +): Batcher => (() => { const queue: BatchElement[] = []; @@ -124,23 +135,22 @@ const makeBatcher = (api: API, chainId: number): Batcher => // Schedule a new call with the whole batch debounce = setTimeout(() => { // Seperate each batch element properties into arrays by type and index - const { couples, resolvers, rejecters } = queue.reduce( - (acc, { couple, resolve, reject }) => { - acc.couples.push(couple); + const { elements, resolvers, rejecters } = queue.reduce( + (acc, { element, resolve, reject }) => { + acc.elements.push(element); acc.resolvers.push(resolve); acc.rejecters.push(reject); return acc; }, - { couples: [], resolvers: [], rejecters: [] } as Batch + { elements: [], resolvers: [], rejecters: [] } as Batch ); // Empty the queue queue.length = 0; // Make the call with all the couples of contract and tokenId at once - api - .getNFTMetadata(couples, chainId.toString()) - .then((res) => { + call(elements, chainId.toString()) + .then((res: any) => { // Resolve each batch element with its own resolver and only its response res.forEach((metadata, index) => resolvers[index](metadata)); }) @@ -153,15 +163,16 @@ const makeBatcher = (api: API, chainId: number): Batcher => return { // Load the metadata for a given couple contract + tokenId - load({ - contract, - tokenId, - }: { - contract: string; - tokenId: string; - }): Promise { + load( + element: + | { + contract: string; + tokenId: string; + } + | { contract: string } + ): Promise { return new Promise((resolve, reject) => { - queue.push({ couple: { contract, tokenId }, resolve, reject }); + queue.push({ element, resolve, reject }); timeoutBatchCall(); }); }, @@ -176,7 +187,9 @@ const batchersMap = new Map(); * This method is still EVM based for now but can be improved * to implement an even more generic solution */ -export const metadataCallBatcher = (currency: CryptoCurrency): Batcher => { +export const metadataCallBatcher = ( + currency: CryptoCurrency +): { loadNft: Batcher["load"]; loadCollection: Batcher["load"] } => { const api: API = apiForCurrency(currency); const chainId = currency?.ethereumLikeInfo?.chainId; @@ -185,8 +198,15 @@ export const metadataCallBatcher = (currency: CryptoCurrency): Batcher => { } if (!batchersMap.has(currency.id)) { - batchersMap.set(currency.id, makeBatcher(api, chainId)); + batchersMap.set(currency.id, { + nft: makeBatcher(api.getNFTMetadata, chainId), + collection: makeBatcher(api.getNFTCollectionMetadata, chainId), + }); } - return batchersMap.get(currency.id); + const batchers = batchersMap.get(currency.id); + return { + loadNft: batchers.nft.load, + loadCollection: batchers.collection.load, + }; }; diff --git a/src/types/bridge.ts b/src/types/bridge.ts index 461b226c9b..2da0d11535 100644 --- a/src/types/bridge.ts +++ b/src/types/bridge.ts @@ -20,7 +20,11 @@ import type { CryptoCurrencyIds, NFTMetadataResponse, } from "."; -import { NFTMetadata } from "./nft"; +import { + NFTCollectionMetadata, + NFTCollectionMetadataResponse, + NFTMetadata, +} from "./nft"; export type ScanAccountEvent = { type: "discovered"; account: Account; @@ -68,12 +72,19 @@ export interface CurrencyBridge { preferredNewAccountScheme?: DerivationMode; }): Observable; getPreloadStrategy?: (currency: CryptoCurrency) => PreloadStrategy; - nftMetadataResolver?: (arg: { - contract: string; - tokenId: string; - currencyId: string; - metadata?: NFTMetadata; - }) => Promise; + nftResolvers?: { + nftMetadata: (arg: { + contract: string; + tokenId: string; + currencyId: string; + metadata?: NFTMetadata; + }) => Promise; + collectionMetadata: (arg: { + contract: string; + currencyId: string; + metadata?: NFTCollectionMetadata; + }) => Promise; + }; } // Abstraction related to an account export interface AccountBridge { diff --git a/src/types/nft.ts b/src/types/nft.ts index 4c1ad76504..e4438383a1 100644 --- a/src/types/nft.ts +++ b/src/types/nft.ts @@ -12,6 +12,10 @@ export type NFTMetadata = { links: Record; }; +export type NFTCollectionMetadata = { + tokenName: string | null; +}; + export type ProtoNFT = { // id crafted by live id: string; @@ -47,3 +51,11 @@ export type NFTMetadataResponse = { links: Record; } | null; }; + +export type NFTCollectionMetadataResponse = { + status: 200 | 404 | 500; + result?: { + contract: string; + tokenName: string | null; + } | null; +};