From d236719aa59649f41c51d3118cbd5aec0c2fbe6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Lambert?= <44363395+lambertkevin@users.noreply.github.com> Date: Tue, 5 Apr 2022 15:04:36 +0200 Subject: [PATCH] [LIVE-1174] - Feature: Upgrade NFT Architecture (#1805) * Fix wrong NFTResource typing * Update NFT types to ProtoNFT * Update NFT Id to contain currency * Update NFT Helpers for new model * Update Eth API metadata call to include chainId * Move nft metadata resolution to bridge * Update NftMetadaProvider logic to use bridge * Update prepare tx of ERC721/1155 w/ new model * Update CLI for new NFT model * Add getNftCapabilities to nft support * Naming + fixing ERC1155 quantity potentially falsy * Make CLI use the LLC branch hash * Add type to nftMetadataResolver param + use of sync metadata * Remove useless import * Remove useless comment in CLI formatters * Add comment to nftsByCollection helper * Add comments + types to NFT metadata call batchers * Fix sync metadata resolution for Eth family * Add return type to nftMetadataResolver + decodeNftId --- cli/package.json | 2 +- cli/src/commands/sync.ts | 26 ++- cli/yarn.lock | 20 +-- src/account/formatters.ts | 14 +- src/account/serialization.ts | 34 +++- src/api/Ethereum.ts | 13 +- src/bridge/jsHelpers.ts | 9 +- src/families/ethereum/bridge/js.ts | 2 + src/families/ethereum/modules/erc1155.ts | 18 +- src/families/ethereum/modules/erc721.ts | 23 +-- .../ethereum/nft.merging.unit.test.ts | 17 +- src/families/ethereum/nftMetadataResolver.ts | 43 +++++ src/families/ethereum/synchronisation.ts | 9 +- src/nft/NftMetadataProvider/index.tsx | 127 ++++++------- src/nft/NftMetadataProvider/types.ts | 18 +- src/nft/helpers.ts | 168 ++++++++++++++---- src/nft/nftId.ts | 17 +- src/nft/support.ts | 15 +- src/types/account.ts | 7 +- src/types/bridge.ts | 8 + src/types/nft.ts | 29 ++- 21 files changed, 422 insertions(+), 197 deletions(-) create mode 100644 src/families/ethereum/nftMetadataResolver.ts diff --git a/cli/package.json b/cli/package.json index 4cbeebc829..45c005108a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -34,7 +34,7 @@ "@ledgerhq/hw-transport-mocker": "6.24.1", "@ledgerhq/hw-transport-node-hid": "6.24.1", "@ledgerhq/hw-transport-node-speculos": "6.24.1", - "@ledgerhq/live-common": "https://github.com/LedgerHQ/ledger-live-common.git#082c946830a332f764f3c95c0eb9c0572b493825", + "@ledgerhq/live-common": "https://github.com/LedgerHQ/ledger-live-common.git#31177aae3a559e17f8452ed8dc6e010cd1d6f41e", "@ledgerhq/logs": "6.10.0", "@walletconnect/client": "^1.7.1", "asciichart": "^1.5.25", diff --git a/cli/src/commands/sync.ts b/cli/src/commands/sync.ts index 58c60a77b8..308a86abb3 100644 --- a/cli/src/commands/sync.ts +++ b/cli/src/commands/sync.ts @@ -1,6 +1,10 @@ import { map, switchMap } from "rxjs/operators"; -import { accountFormatters } from "@ledgerhq/live-common/lib/account"; -import { metadataCallBatcher } from "@ledgerhq/live-common/lib/nft"; +import { + accountFormatters, + decodeAccountId, +} from "@ledgerhq/live-common/lib/account"; +import { getCryptoCurrencyById } from "@ledgerhq/live-common/lib/currencies"; +import { getCurrencyBridge } from "@ledgerhq/live-common/lib/bridge"; import { scan, scanCommonOpts } from "../scan"; import type { ScanCommonOpts } from "../scan"; export default { @@ -21,23 +25,29 @@ export default { } ) => scan(opts).pipe( - switchMap(async (account) => - account.nfts?.length + switchMap(async (account) => { + const { currencyId } = decodeAccountId(account.id); + const currency = getCryptoCurrencyById(currencyId); + const currencyBridge = getCurrencyBridge(currency); + const { nftMetadataResolver } = currencyBridge; + + return account.nfts?.length && nftMetadataResolver ? { ...account, nfts: await Promise.all( account.nfts.map(async (nft) => { - const { result: metadata } = await metadataCallBatcher.load({ - contract: nft.collection.contract, + const { result: metadata } = await nftMetadataResolver({ + contract: nft.contract, tokenId: nft.tokenId, + currencyId: nft.currencyId, }); return { ...nft, metadata }; }) ).catch(() => account.nfts), } - : account - ), + : account; + }), map((account) => (accountFormatters[opts.format] || accountFormatters.default)(account) ) diff --git a/cli/yarn.lock b/cli/yarn.lock index 91bda3fda5..0bf3a7658c 100644 --- a/cli/yarn.lock +++ b/cli/yarn.lock @@ -1087,18 +1087,14 @@ dependencies: bignumber.js "^9.0.1" json-rpc-2.0 "^0.2.16" - -"@ledgerhq/live-common@https://github.com/LedgerHQ/ledger-live-common.git#082c946830a332f764f3c95c0eb9c0572b493825": - version "21.35.0" - resolved "https://github.com/LedgerHQ/ledger-live-common.git#082c946830a332f764f3c95c0eb9c0572b493825" - dependencies: - "@celo/contractkit" "^1.5.2" - "@celo/wallet-base" "^1.5.2" - "@celo/wallet-ledger" "^1.5.2" - "@cosmjs/crypto" "^0.26.5" - "@cosmjs/ledger-amino" "^0.26.5" - "@cosmjs/proto-signing" "^0.26.5" - "@cosmjs/stargate" "^0.26.5" + +"@ledgerhq/live-common@https://github.com/LedgerHQ/ledger-live-common.git#31177aae3a559e17f8452ed8dc6e010cd1d6f41e": + version "21.32.4" + resolved "https://github.com/LedgerHQ/ledger-live-common.git#31177aae3a559e17f8452ed8dc6e010cd1d6f41e" + dependencies: + "@celo/contractkit" "^1.5.1" + "@celo/wallet-base" "^1.5.1" + "@celo/wallet-ledger" "^1.5.1" "@crypto-com/chain-jslib" "0.0.19" "@ethereumjs/common" "^2.6.2" "@ethereumjs/tx" "^3.5.0" diff --git a/src/account/formatters.ts b/src/account/formatters.ts index 3be46efd8d..5095345b2f 100644 --- a/src/account/formatters.ts +++ b/src/account/formatters.ts @@ -6,7 +6,7 @@ import { getAccountName, getAccountUnit, } from "."; -import type { Account, Operation, Unit } from "../types"; +import type { Account, Operation, ProtoNFT, Unit } from "../types"; import { getOperationAmountNumberWithInternals } from "../operation"; import { formatCurrencyUnit } from "../currencies"; import { getOperationAmountNumber } from "../operation"; @@ -138,13 +138,11 @@ const cliFormat = (account, level?: string) => { const NFTCollections = nftsByCollections(nfts); str += "\n"; - str += `NFT Collections (${NFTCollections.length}) `; + str += `NFT Collections (${Object.keys(NFTCollections).length}) `; str += "\n"; - str += NFTCollections.map( - // nfts are set to any because there not just NFT, we added a metadata prop on the fly - // in the first step of the Rxjs flow to avoid having some async code here - ({ contract, nfts }: { contract: string; nfts: any[] }) => { + str += Object.entries(NFTCollections) + .map(([contract, nfts]: [string, ProtoNFT[]]) => { const tokenName = nfts?.[0]?.metadata?.tokenName; const { bold, magenta, cyan, reverse } = styling; @@ -162,8 +160,8 @@ const cliFormat = (account, level?: string) => { ) .join() ); - } - ).join("\n"); + }) + .join("\n"); } if (level === "basic") return str; diff --git a/src/account/serialization.ts b/src/account/serialization.ts index 53280ca5e3..d5cfe0b8cb 100644 --- a/src/account/serialization.ts +++ b/src/account/serialization.ts @@ -16,8 +16,8 @@ import type { OperationRaw, SubAccount, SubAccountRaw, - NFT, - NFTRaw, + ProtoNFT, + ProtoNFTRaw, } from "../types"; import type { TronResources, TronResourcesRaw } from "../families/tron/types"; import { @@ -949,20 +949,42 @@ export function toAccountRaw({ return res; } -export function toNFTRaw({ id, tokenId, amount, collection }: NFT): NFTRaw { +export function toNFTRaw({ + id, + tokenId, + amount, + contract, + standard, + currencyId, + metadata, +}: ProtoNFT): ProtoNFTRaw { return { id, tokenId, amount: amount.toFixed(), - collection, + contract, + standard, + currencyId, + metadata, }; } -export function fromNFTRaw({ id, tokenId, amount, collection }: NFTRaw): NFT { +export function fromNFTRaw({ + id, + tokenId, + amount, + contract, + standard, + currencyId, + metadata, +}: ProtoNFTRaw): ProtoNFT { return { id, tokenId, amount: new BigNumber(amount), - collection, + contract, + standard, + currencyId, + metadata, }; } diff --git a/src/api/Ethereum.ts b/src/api/Ethereum.ts index 0902836034..23a87add43 100644 --- a/src/api/Ethereum.ts +++ b/src/api/Ethereum.ts @@ -96,7 +96,10 @@ export type API = { getAccountNonce: (address: string) => Promise; broadcastTransaction: (signedTransaction: string) => Promise; getERC20Balances: (input: ERC20BalancesInput) => Promise; - getNFTMetadata: (input: NFTMetadataInput) => Promise; + getNFTMetadata: ( + input: NFTMetadataInput, + chainId: string + ) => Promise; getAccountBalance: (address: string) => Promise; roughlyEstimateGasLimit: (address: string) => Promise; getERC20ApprovalsPerContract: ( @@ -205,12 +208,12 @@ export const apiForCurrency = (currency: CryptoCurrency): API => { return data; }, - async getNFTMetadata(input) { + async getNFTMetadata(input, chainId) { const { data }: { data: NFTMetadataResponse[] } = await network({ method: "POST", - url: - getEnv("NFT_ETH_METADATA_SERVICE") + - "/v1/ethereum/1/contracts/tokens/infos", + url: `${getEnv( + "NFT_ETH_METADATA_SERVICE" + )}/v1/ethereum/${chainId}/contracts/tokens/infos`, data: input, }); diff --git a/src/bridge/jsHelpers.ts b/src/bridge/jsHelpers.ts index 614c47fbb6..e44377a9fb 100644 --- a/src/bridge/jsHelpers.ts +++ b/src/bridge/jsHelpers.ts @@ -34,7 +34,7 @@ import type { SyncConfig, CryptoCurrency, DerivationMode, - NFT, + ProtoNFT, } from "../types"; import type { CurrencyBridge, AccountBridge } from "../types/bridge"; import getAddress from "../hw/getAddress"; @@ -136,9 +136,12 @@ Operation[] { return all; } -export const mergeNfts = (oldNfts: NFT[], newNfts: NFT[]): NFT[] => { +export const mergeNfts = ( + oldNfts: ProtoNFT[], + newNfts: ProtoNFT[] +): ProtoNFT[] => { // Getting a map of id => NFT - const newNftsPerId: Record = {}; + const newNftsPerId: Record = {}; newNfts.forEach((n) => { newNftsPerId[n.id] = n; }); diff --git a/src/families/ethereum/bridge/js.ts b/src/families/ethereum/bridge/js.ts index 6a225e59c3..d5f8cf1ad2 100644 --- a/src/families/ethereum/bridge/js.ts +++ b/src/families/ethereum/bridge/js.ts @@ -33,6 +33,7 @@ import { signOperation } from "../signOperation"; import { modes } from "../modules"; import postSyncPatch from "../postSyncPatch"; import { inferDynamicRange } from "../../../range"; +import nftMetadataResolver from "../nftMetadataResolver"; const receive = makeAccountBridgeReceive(); @@ -210,6 +211,7 @@ const currencyBridge: CurrencyBridge = { preload, hydrate, scanAccounts, + nftMetadataResolver, }; const accountBridge: AccountBridge = { createTransaction, diff --git a/src/families/ethereum/modules/erc1155.ts b/src/families/ethereum/modules/erc1155.ts index e19554e4c8..5591daf22e 100644 --- a/src/families/ethereum/modules/erc1155.ts +++ b/src/families/ethereum/modules/erc1155.ts @@ -11,10 +11,10 @@ import type { ModeModule, Transaction } from "../types"; import type { Account } from "../../../types"; import { prepareTransaction } from "./erc721"; -const notOwnedNft = createCustomErrorClass("NotOwnedNft"); -const notEnoughNftOwned = createCustomErrorClass("NotEnoughNftOwned"); -const notTokenIdsProvided = createCustomErrorClass("NotTokenIdsProvided"); -const quantityNeedsToBePositive = createCustomErrorClass( +const NotOwnedNft = createCustomErrorClass("NotOwnedNft"); +const NotEnoughNftOwned = createCustomErrorClass("NotEnoughNftOwned"); +const NotTokenIdsProvided = createCustomErrorClass("NotTokenIdsProvided"); +const QuantityNeedsToBePositive = createCustomErrorClass( "QuantityNeedsToBePositive" ); @@ -47,8 +47,8 @@ const erc1155Transfer: ModeModule = { } t.quantities?.forEach((quantity) => { - if (quantity.isLessThan(1)) { - result.errors.amount = new quantityNeedsToBePositive(); + if (!quantity || quantity.isLessThan(1)) { + result.errors.amount = new QuantityNeedsToBePositive(); } }); @@ -62,15 +62,15 @@ const erc1155Transfer: ModeModule = { const transferQuantity = Number(t.quantities?.[index]); if (!nft) { - return new notOwnedNft(); + return new NotOwnedNft(); } if (transferQuantity && !nft.amount.gte(transferQuantity)) { - return new notEnoughNftOwned(); + return new NotEnoughNftOwned(); } return true; - }, true as true | Error) || new notTokenIdsProvided(); + }, true as true | Error) || new NotTokenIdsProvided(); if (!enoughTokensOwned || enoughTokensOwned instanceof Error) { result.errors.amount = enoughTokensOwned; diff --git a/src/families/ethereum/modules/erc721.ts b/src/families/ethereum/modules/erc721.ts index c6a2aa31e8..ac5e39dc88 100644 --- a/src/families/ethereum/modules/erc721.ts +++ b/src/families/ethereum/modules/erc721.ts @@ -11,7 +11,7 @@ import type { ModeModule, Transaction } from "../types"; import type { Account } from "../../../types"; import { apiForCurrency } from "../../../api/Ethereum"; -const notOwnedNft = createCustomErrorClass("NotOwnedNft"); +const NotOwnedNft = createCustomErrorClass("NotOwnedNft"); export type Modes = "erc721.transfer"; @@ -23,12 +23,15 @@ 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([ - { - contract: collection, - tokenId: tokenIds[0], - }, - ]); + const [{ status, result }] = await api.getNFTMetadata( + [ + { + contract: collection, + tokenId: tokenIds[0], + }, + ], + account.currency?.ethereumLikeInfo?.chainId?.toString() || "1" + ); let collectionName = ""; // default value fallback if issue if (status === 200) { collectionName = result?.tokenName || ""; @@ -66,12 +69,10 @@ const erc721Transfer: ModeModule = { if ( !a.nfts?.find?.( - (n) => - n.tokenId === t.tokenIds?.[0] && - n.collection.contract === t.collection + (n) => n.tokenId === t.tokenIds?.[0] && n.contract === t.collection ) ) { - result.errors.amount = new notOwnedNft(); + result.errors.amount = new NotOwnedNft(); } } }, diff --git a/src/families/ethereum/nft.merging.unit.test.ts b/src/families/ethereum/nft.merging.unit.test.ts index 0cfe3c114c..50bed022f8 100644 --- a/src/families/ethereum/nft.merging.unit.test.ts +++ b/src/families/ethereum/nft.merging.unit.test.ts @@ -1,19 +1,22 @@ import "../../__tests__/test-helpers/setup"; import BigNumber from "bignumber.js"; import { toNFTRaw } from "../../account"; -import type { NFT } from "../../types"; +import type { ProtoNFT } from "../../types"; import { mergeNfts } from "../../bridge/jsHelpers"; import { encodeNftId } from "../../nft"; describe("nft merging", () => { - const makeNFT = (tokenId: string, contract: string, amount: number): NFT => ({ - id: encodeNftId("test", contract, tokenId), + const makeNFT = ( + tokenId: string, + contract: string, + amount: number + ): ProtoNFT => ({ + id: encodeNftId("test", contract, tokenId, "ethereum"), tokenId, amount: new BigNumber(amount), - collection: { - contract, - standard: "erc721", - }, + contract, + standard: "ERC721", + currencyId: "ethereum", }); const oldNfts = [ makeNFT("1", "contract1", 10), diff --git a/src/families/ethereum/nftMetadataResolver.ts b/src/families/ethereum/nftMetadataResolver.ts new file mode 100644 index 0000000000..136a3fa6d8 --- /dev/null +++ b/src/families/ethereum/nftMetadataResolver.ts @@ -0,0 +1,43 @@ +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/synchronisation.ts b/src/families/ethereum/synchronisation.ts index bd2cf27d2d..4f8bb911d3 100644 --- a/src/families/ethereum/synchronisation.ts +++ b/src/families/ethereum/synchronisation.ts @@ -336,7 +336,7 @@ const txToOps = const receiver = safeEncodeEIP55(event.receiver); const contract = safeEncodeEIP55(event.contract); const tokenId = event.token_id; - const nftId = encodeNftId(id, event.contract, tokenId); + const nftId = encodeNftId(id, event.contract, tokenId, currency.id); const sending = addr === sender; const receiving = addr === receiver; @@ -414,7 +414,12 @@ const txToOps = event.transfers.forEach((transfer, j) => { const tokenId = transfer.id; const value = new BigNumber(transfer.value); - const nftId = encodeNftId(id, event.contract, tokenId); + const nftId = encodeNftId( + id, + event.contract, + tokenId, + currency.id + ); if (sending) { const type = "NFT_OUT"; diff --git a/src/nft/NftMetadataProvider/index.tsx b/src/nft/NftMetadataProvider/index.tsx index f76607c39c..0e95c7f4af 100644 --- a/src/nft/NftMetadataProvider/index.tsx +++ b/src/nft/NftMetadataProvider/index.tsx @@ -5,22 +5,17 @@ import React, { useState, useEffect, } from "react"; -import { Currency, findCryptoCurrencyById } from "@ledgerhq/cryptoassets"; -import { API, apiForCurrency } from "../../api/Ethereum"; -import { NFT, NFTMetadataResponse } from "../../types"; import { getNftKey } from "../helpers"; import { - Batch, - BatchElement, NFTMetadataContextAPI, NFTMetadataContextState, NFTMetadataContextType, NFTResource, } from "./types"; import { isOutdated } from "./logic"; - -const currency: Currency = findCryptoCurrencyById("ethereum")!; -const ethApi: API = apiForCurrency(currency); +import { getCurrencyBridge } from "../../bridge"; +import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets"; +import { NFT, ProtoNFT } from "../../types"; const NftMetadataContext = createContext({ cache: {}, @@ -28,72 +23,24 @@ const NftMetadataContext = createContext({ clearCache: () => {}, }); -export const metadataCallBatcher = (() => { - const batch: BatchElement[] = []; - - let debounce; - const timeoutBatchCall = () => { - // Clear the previous scheduled call if it was existing - clearTimeout(debounce); - - // 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 } = batch.reduce( - (acc, { couple, resolve, reject }) => { - acc.couples.push(couple); - acc.resolvers.push(resolve); - acc.rejecters.push(reject); - - return acc; - }, - { couples: [], resolvers: [], rejecters: [] } as Batch - ); - // Empty the batch - batch.length = 0; - - // Make the call with all the couples of contract and tokenId at once - ethApi - .getNFTMetadata(couples) - .then((res) => { - // Resolve each batch element with its own resolver and only its response - res.forEach((metadata, index) => resolvers[index](metadata)); - }) - .catch((err) => { - // Reject all batch element with the error - rejecters.forEach((reject) => reject(err)); - }); - }); - }; - - return { - // Load the metadata for a given couple contract + tokenId - load({ contract, tokenId }): Promise { - return new Promise((resolve, reject) => { - batch.push({ couple: { contract, tokenId }, resolve, reject }); - timeoutBatchCall(); - }); - }, - }; -})(); - -// DEPRECATED, use useNftResource export function useNftMetadata( contract: string | undefined, - tokenId: string | undefined + tokenId: string | undefined, + currencyId: string | undefined ): NFTResource { const { cache, loadNFTMetadata } = useContext(NftMetadataContext); - - const key = contract && tokenId ? getNftKey(contract, tokenId) : ""; - + const key = + contract && tokenId && currencyId + ? getNftKey(contract, tokenId, currencyId) + : ""; const cachedData = cache[key]; useEffect(() => { - if (!contract || !tokenId) return; + if (!contract || !tokenId || !currencyId) return; if (!cachedData || isOutdated(cachedData)) { - loadNFTMetadata(contract, tokenId); + loadNFTMetadata(contract, tokenId, currencyId); } - }, [contract, tokenId, cachedData, loadNFTMetadata]); + }, [contract, tokenId, cachedData, loadNFTMetadata, currencyId]); if (cachedData) { return cachedData; @@ -104,8 +51,34 @@ export function useNftMetadata( } } -export function useNftResource(nft: NFT | undefined): NFTResource { - return useNftMetadata(nft?.collection.contract, nft?.tokenId); +type UseNFTResponse = + | { status: Exclude } + | { status: "loaded"; nft: NFT }; + +export function useNft(protoNft: ProtoNFT): UseNFTResponse { + const data = useNftMetadata( + protoNft.contract, + protoNft.tokenId, + protoNft.currencyId + ); + + const { status } = data; + const metadata = useMemo( + () => (status === "loaded" ? data.metadata : null), + [data, status] + ); + + const nft: NFT | null = useMemo( + () => (status === "loaded" && metadata ? { ...protoNft, metadata } : null), + [protoNft, metadata] + ); + + return status !== "loaded" + ? { status } + : { + status, + nft: nft!, + }; } export function useNftAPI(): NFTMetadataContextAPI { @@ -130,8 +103,18 @@ export function NftMetadataProvider({ const api = useMemo( () => ({ - loadNFTMetadata: async (contract: string, tokenId: string) => { - const key = getNftKey(contract, tokenId); + loadNFTMetadata: async ( + contract: string, + tokenId: string, + currencyId: string + ) => { + const key = getNftKey(contract, tokenId, currencyId); + const currency = getCryptoCurrencyById(currencyId); + const currencyBridge = getCurrencyBridge(currency); + + if (!currencyBridge.nftMetadataResolver) { + throw new Error("Currency doesn't support NFT"); + } setState((oldState) => ({ ...oldState, @@ -144,9 +127,10 @@ export function NftMetadataProvider({ })); try { - const { status, result } = await metadataCallBatcher.load({ + const { status, result } = await currencyBridge.nftMetadataResolver({ contract, tokenId, + currencyId: currency.id, }); switch (status) { @@ -172,7 +156,7 @@ export function NftMetadataProvider({ ...oldState.cache, [key]: { status: "loaded", - metadata: result || {}, + metadata: result, updatedAt: Date.now(), }, }, @@ -206,6 +190,7 @@ export function NftMetadataProvider({ ); const value = { ...state, ...api }; + return ( {children} diff --git a/src/nft/NftMetadataProvider/types.ts b/src/nft/NftMetadataProvider/types.ts index 935d7e1a97..4a61fae30f 100644 --- a/src/nft/NftMetadataProvider/types.ts +++ b/src/nft/NftMetadataProvider/types.ts @@ -10,7 +10,7 @@ export type NFTResourceLoading = { export type NFTResourceLoaded = { status: "loaded"; - metadata: Pick; + metadata: NFTMetadataResponse["result"]; updatedAt: number; }; @@ -37,13 +37,27 @@ export type NFTMetadataContextState = { }; export type NFTMetadataContextAPI = { - loadNFTMetadata: (contract: string, tokenId: string) => Promise; + loadNFTMetadata: ( + contract: string, + tokenId: string, + currencyId: string + ) => Promise; clearCache: () => void; }; export type NFTMetadataContextType = NFTMetadataContextState & NFTMetadataContextAPI; +export type Batcher = { + load: ({ + contract, + tokenId, + }: { + contract: string; + tokenId: string; + }) => Promise; +}; + export type BatchElement = { couple: { contract: string; diff --git a/src/nft/helpers.ts b/src/nft/helpers.ts index f715523268..2996151b62 100644 --- a/src/nft/helpers.ts +++ b/src/nft/helpers.ts @@ -1,21 +1,23 @@ import eip55 from "eip55"; import BigNumber from "bignumber.js"; -import { NFT, Operation } from "../types"; import { encodeNftId } from "."; - -type Collection = NFT["collection"]; - -type CollectionMap = Record; - -export type CollectionWithNFT = Collection & { - nfts: Array>; -}; - -export const nftsFromOperations = (ops: Operation[]): NFT[] => { +import { decodeAccountId } from "../account"; + +import type { Batch, BatchElement, Batcher } from "./NftMetadataProvider/types"; +import type { + NFTMetadataResponse, + Operation, + ProtoNFT, + NFT, + CryptoCurrency, +} from "../types"; +import { API, apiForCurrency } from "../api/Ethereum"; + +export const nftsFromOperations = (ops: Operation[]): ProtoNFT[] => { const nftsMap = ops // if ops are Operations get the prop nftOperations, else ops are considered nftOperations already .flatMap((op) => (op?.nftOperations?.length ? op.nftOperations : op)) - .reduce((acc: Record, nftOp: Operation) => { + .reduce((acc: Record, nftOp: Operation) => { let { contract } = nftOp; if (!contract) { return acc; @@ -24,15 +26,18 @@ export const nftsFromOperations = (ops: Operation[]): NFT[] => { // Creating a "token for a contract" unique key contract = eip55.encode(contract); const { tokenId, standard, accountId } = nftOp; + const { currencyId } = decodeAccountId(nftOp.accountId); if (!tokenId || !standard) return acc; - const id = encodeNftId(accountId, contract, tokenId || ""); + const id = encodeNftId(accountId, contract, tokenId, currencyId); const nft = (acc[id] || { id, tokenId, amount: new BigNumber(0), - collection: { contract, standard }, - }) as NFT; + contract, + standard, + currencyId, + }) as ProtoNFT; if (nftOp.type === "NFT_IN") { nft.amount = nft.amount.plus(nftOp.value); @@ -48,31 +53,124 @@ export const nftsFromOperations = (ops: Operation[]): NFT[] => { return Object.values(nftsMap); }; +/** + * Helper to group NFTs by their collection/contract. + * + * It will either return an object { [contract address]: NFT[] } + * or if you specify a collectionAddress it will simply filter + * your NFTs for this specific contract and return an NFT[]. + * + * The grouping here can be done with ProtoNFT or NFT. + */ export const nftsByCollections = ( - nfts: NFT[] = [], + nfts: Array = [], collectionAddress?: string -): CollectionWithNFT[] => { - const filteredNfts = collectionAddress - ? nfts.filter((n) => n.collection.contract === collectionAddress) - : nfts; +): Record> | Array => { + return collectionAddress + ? nfts.filter((n) => n.contract === collectionAddress) + : nfts.reduce((acc, nft) => { + const { contract } = nft; - const collectionMap = filteredNfts.reduce( - (acc: CollectionMap, nft: NFT) => { - const { collection, ...nftWithoutCollection } = nft; + if (!acc[contract]) { + acc[contract] = []; + } + acc[contract].push(nft); - if (!acc[collection.contract]) { - acc[collection.contract] = { ...collection, nfts: [] }; - } - acc[collection.contract].nfts.push(nftWithoutCollection); - - return acc; - }, - {} as CollectionMap - ); + return acc; + }, {}); +}; - return Object.values(collectionMap); +export const getNftKey = ( + contract: string, + tokenId: string, + currencyId: string +): string => { + return `${currencyId}-${contract}-${tokenId}`; }; -export const getNftKey = (contract: string, tokenId: string): string => { - return `${contract}-${tokenId}`; +/** + * Factory to make a metadata API call batcher. + * + * It will wait for a complete `tick` to accumulate all the metadata requests + * before sending it as one call to a given API. + * 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 queue: BatchElement[] = []; + + let debounce; + const timeoutBatchCall = () => { + // Clear the previous scheduled call if it was existing + clearTimeout(debounce); + + // 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); + acc.resolvers.push(resolve); + acc.rejecters.push(reject); + + return acc; + }, + { couples: [], 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) => { + // Resolve each batch element with its own resolver and only its response + res.forEach((metadata, index) => resolvers[index](metadata)); + }) + .catch((err) => { + // Reject all batch element with the error + rejecters.forEach((reject) => reject(err)); + }); + }); + }; + + return { + // Load the metadata for a given couple contract + tokenId + load({ + contract, + tokenId, + }: { + contract: string; + tokenId: string; + }): Promise { + return new Promise((resolve, reject) => { + queue.push({ couple: { contract, tokenId }, resolve, reject }); + timeoutBatchCall(); + }); + }, + }; + })(); + +const batchersMap = new Map(); + +/** + * In order to `instanciate`/make only 1 batcher by currency, + * they're `cached` in a Map and retrieved by this method + * 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 => { + const api: API = apiForCurrency(currency); + const chainId = currency?.ethereumLikeInfo?.chainId; + + if (!chainId || !api) { + throw new Error("Ethereum: No API or chainId for this Currency"); + } + + if (!batchersMap.has(currency.id)) { + batchersMap.set(currency.id, makeBatcher(api, chainId)); + } + + return batchersMap.get(currency.id); }; diff --git a/src/nft/nftId.ts b/src/nft/nftId.ts index a82e6cb711..5707bfa914 100644 --- a/src/nft/nftId.ts +++ b/src/nft/nftId.ts @@ -1,17 +1,26 @@ export const encodeNftId = ( accountId: string, contract: string, - tokenId: string + tokenId: string, + currencyId: string ): string => { - return `${accountId}+${contract}+${tokenId.toString()}`; + return `${accountId}+${contract}+${tokenId}+${currencyId}`; }; -export const decodeNftId = (id: string): unknown => { - const [accountId, contract, tokenId] = id.split("+"); +export const decodeNftId = ( + id: string +): { + accountId: string; + contract: string; + tokenId: string; + currencyId: string; +} => { + const [accountId, contract, tokenId, currencyId] = id.split("+"); return { accountId, contract, tokenId, + currencyId, }; }; diff --git a/src/nft/support.ts b/src/nft/support.ts index e57b76d070..254e66ec2b 100644 --- a/src/nft/support.ts +++ b/src/nft/support.ts @@ -1,4 +1,4 @@ -import { Transaction, CryptoCurrency } from "../types"; +import { Transaction, CryptoCurrency, ProtoNFT } from "../types"; import { getEnv } from "../env"; export const isNftTransaction = (transaction: Transaction): boolean => { @@ -12,3 +12,16 @@ export const isNftTransaction = (transaction: Transaction): boolean => { export function isNFTActive(currency: CryptoCurrency): boolean { return getEnv("NFT_CURRENCIES").split(",").includes(currency.id); } + +const nftCapabilities = { + hasQuantity: ["ERC1155"], +}; + +export const getNftCapabilities = (nft: ProtoNFT) => + Object.entries(nftCapabilities).reduce( + (acc, [capability, standards]) => ({ + ...acc, + [capability]: standards.includes(nft.standard), + }), + {} + ); diff --git a/src/types/account.ts b/src/types/account.ts index d75941aeca..70094a8e4f 100644 --- a/src/types/account.ts +++ b/src/types/account.ts @@ -37,7 +37,8 @@ import type { PortfolioRange, } from "./portfolio"; import type { SwapOperation, SwapOperationRaw } from "../exchange/swap/types"; -import type { NFT, NFTRaw } from "./nft"; +import type { ProtoNFT } from "./nft"; +import { ProtoNFTRaw } from "."; // This is the old cache and now DEPRECATED (pre v2 portfoli) export type BalanceHistoryMap = Partial>; export type BalanceHistoryRawMap = Record; @@ -216,7 +217,7 @@ export type Account = { // Hash used to discard tx history on sync syncHash?: string; // Array of NFTs computed by diffing NFTOperations ordered from newest to oldest - nfts?: NFT[]; + nfts?: ProtoNFT[]; }; export type SubAccount = TokenAccount | ChildAccount; export type AccountLike = Account | SubAccount; @@ -302,7 +303,7 @@ export type AccountRaw = { cryptoOrgResources?: CryptoOrgResourcesRaw; swapHistory?: SwapOperationRaw[]; syncHash?: string; - nfts?: NFTRaw[]; + nfts?: ProtoNFTRaw[]; }; export type SubAccountRaw = TokenAccountRaw | ChildAccountRaw; export type AccountRawLike = AccountRaw | SubAccountRaw; diff --git a/src/types/bridge.ts b/src/types/bridge.ts index 0925e5c98e..461b226c9b 100644 --- a/src/types/bridge.ts +++ b/src/types/bridge.ts @@ -18,7 +18,9 @@ import type { DerivationMode, SyncConfig, CryptoCurrencyIds, + NFTMetadataResponse, } from "."; +import { NFTMetadata } from "./nft"; export type ScanAccountEvent = { type: "discovered"; account: Account; @@ -66,6 +68,12 @@ export interface CurrencyBridge { preferredNewAccountScheme?: DerivationMode; }): Observable; getPreloadStrategy?: (currency: CryptoCurrency) => PreloadStrategy; + nftMetadataResolver?: (arg: { + contract: string; + tokenId: string; + currencyId: string; + metadata?: NFTMetadata; + }) => Promise; } // Abstraction related to an account export interface AccountBridge { diff --git a/src/types/nft.ts b/src/types/nft.ts index c782ffacce..4c1ad76504 100644 --- a/src/types/nft.ts +++ b/src/types/nft.ts @@ -1,26 +1,37 @@ import type BigNumber from "bignumber.js"; +import { CryptoCurrencyIds } from "."; export type NFTStandards = "ERC721" | "ERC1155"; -export type NFT = { +export type NFTMetadata = { + tokenName: string | null; + nftName: string | null; + media: string | null; + description: string | null; + properties: Array>; + links: Record; +}; + +export type ProtoNFT = { // id crafted by live id: string; // id on chain tokenId: string; amount: BigNumber; - collection: { - // contract address. Careful 1 contract address != 1 collection as some collections are off-chain - // So 1 contract address from OpenSea for example can reprensent an infinity of collections - contract: string; - // Carefull to non spec compliant NFTs (cryptopunks, cryptokitties, ethrock, and others?) - standard: NFTStandards | string; - }; + contract: string; + standard: NFTStandards; + currencyId: CryptoCurrencyIds; + metadata?: NFTMetadata; }; -export type NFTRaw = Omit & { +export type ProtoNFTRaw = Omit & { amount: string; }; +export type NFT = Omit & { + metadata: NFTMetadata; +}; + export type NFTMetadataLinksProviders = "opensea" | "rarible" | "etherscan"; export type NFTMetadataResponse = {