diff --git a/.env.default b/.env.default index f5037546..249fa5ec 100644 --- a/.env.default +++ b/.env.default @@ -21,3 +21,5 @@ SUBGRAPH_COMPONENT_QUERY_TIMEOUT=60000 SUBGRAPH_COMPONENT_RETRIES=1 DISABLE_THIRD_PARTY_PROVIDERS_RESOLVER_SERVICE_USAGE=false + +#NFT_WORKER_BASE_URL= diff --git a/.gitignore b/.gitignore index 44ba5f3a..bafe444b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ **/.DS_Store coverage .env +third-party-contracts-*.json diff --git a/README.md b/README.md index 73423288..faaa745f 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,38 @@ The layer that communicates with the outside world, such as http, kafka, and the We use the components abstraction to organize our ports (e.g. HTTP client, database client, redis client) and any other logic that needs to track mutable state or encode dependencies between stateful components. For every environment (e.g. test, e2e, prod, staging...) we have a different version of our component systems, enabling us to easily inject mocks or different implementations for different contexts. We make components available to incoming http and kafka handlers. For instance, the http-server handlers have access to things like the database or HTTP components, and pass them down to the controller level for general use. + +### Sequence diagram of for backpack building +```mermaid +sequenceDiagram + actor User + participant Catalyst + participant Graph as Third Party Resolvers + participant Alchemy as Alchemy API + User ->> Catalyst: Get all wearables I own + activate Catalyst + Catalyst ->> Graph: Get all active Third Party Providers + Graph -->> Catalyst: Ok (HTTP 200) with the list of TPAs + Catalyst ->> Catalyst: Build list of contracts to query + Catalyst ->> Alchemy: Get owned wearables for 0x123 in those contracts + Alchemy -->> Catalyst: Ok (HTTP 200) with the wearables + Catalyst ->> Catalyst: For each NFT owned returned from Alchemy
assign the corresponding wearable (based on mappings) + deactivate Catalyst + Catalyst ->> User: Ok (HTTP 200) with the wearables +``` + + +### Sequence diagram for ownership checking during profile validation +```mermaid +sequenceDiagram + actor User + participant Catalyst + participant Blockchain + User ->> Catalyst: Get my profile + activate Catalyst + Catalyst ->> Blockchain: Does 0x123 own this red shirt, this blue hat and the yellow shoes? + Blockchain->> Catalyst: Ok: yes, no, yes + Catalyst ->> Catalyst: Remove all non-owned wearables + deactivate Catalyst + Catalyst ->> User: Ok (HTTP 200) with the profile +``` diff --git a/src/adapters/alchemy-nft-fetcher.ts b/src/adapters/alchemy-nft-fetcher.ts new file mode 100644 index 00000000..73e8b1b1 --- /dev/null +++ b/src/adapters/alchemy-nft-fetcher.ts @@ -0,0 +1,58 @@ +import { AppComponents } from '../types' +import { ContractNetwork } from '@dcl/schemas' + +export type AlchemyNftFetcher = { + getNFTsForOwner(owner: string, contractsByNetwork: Record>): Promise +} + +export async function createAlchemyNftFetcher({ + config, + logs, + fetch +}: Pick): Promise { + const logger = logs.getLogger('alchemy-nft-fetcher') + const nftWorkerBaseUrl = (await config.getString('NFT_WORKER_BASE_URL')) || 'https://nfts.decentraland.org' + + async function getNFTsForOwnerForNetwork( + owner: string, + network: string, + contractAddresses: Set + ): Promise { + if (!Object.values(ContractNetwork).includes(network as ContractNetwork)) { + logger.warn(`Network ${network} not supported for LinkedWearables`) + return [] + } + + const response = await fetch.fetch(`${nftWorkerBaseUrl}/wallets/${owner}/networks/${network}/nfts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(Array.from(contractAddresses)) + }) + + if (!response.ok) { + logger.error(`Error fetching NFTs from Alchemy: ${response.status} - ${response.statusText}`) + return [] + } + + const nfts = await response.json() + + return nfts.data as string[] + } + + async function getNFTsForOwner(owner: string, contractsByNetwork: Record>): Promise { + const all = await Promise.all( + Object.entries(contractsByNetwork).map(([network, contractAddresses]) => + getNFTsForOwnerForNetwork(owner, network, contractAddresses) + ) + ) + + return all.flat(1) + } + + return { + getNFTsForOwner + } +} diff --git a/src/adapters/profiles.ts b/src/adapters/profiles.ts index fcbef9ae..c0e82b72 100644 --- a/src/adapters/profiles.ts +++ b/src/adapters/profiles.ts @@ -56,12 +56,17 @@ export type IProfilesComponent = { export async function createProfilesComponent( components: Pick< AppComponents, + | 'alchemyNftFetcher' | 'metrics' | 'content' + | 'contentServerUrl' + | 'entitiesFetcher' | 'theGraph' | 'config' | 'fetch' | 'ownershipCaches' + | 'l1ThirdPartyItemChecker' + | 'l2ThirdPartyItemChecker' | 'thirdPartyProvidersStorage' | 'logs' | 'wearablesFetcher' diff --git a/src/adapters/third-party-providers-graph-fetcher.ts b/src/adapters/third-party-providers-graph-fetcher.ts index d477a48e..404fb80e 100644 --- a/src/adapters/third-party-providers-graph-fetcher.ts +++ b/src/adapters/third-party-providers-graph-fetcher.ts @@ -1,4 +1,5 @@ import { AppComponents, ThirdPartyProvider } from '../types' +import { sanitizeContractList } from '../logic/utils' export type ThirdPartyProvidersGraphFetcher = { get(): Promise @@ -17,6 +18,10 @@ const QUERY_ALL_THIRD_PARTY_RESOLVERS = ` thirdParty { name description + contracts { + network + address + } } } } @@ -28,12 +33,18 @@ export function createThirdPartyProvidersGraphFetcherComponent({ }: Pick): ThirdPartyProvidersGraphFetcher { return { async get(): Promise { - return ( + const thirdPartyProviders = ( await theGraph.thirdPartyRegistrySubgraph.query( QUERY_ALL_THIRD_PARTY_RESOLVERS, {} ) - ).thirdParties.filter((thirdParty) => thirdParty.id.includes('collections-thirdparty')) + ).thirdParties + + if (thirdPartyProviders) { + sanitizeContractList(thirdPartyProviders) + } + + return thirdPartyProviders } } } diff --git a/src/adapters/third-party-providers-service-fetcher.ts b/src/adapters/third-party-providers-service-fetcher.ts index 05142d4f..afb9b24a 100644 --- a/src/adapters/third-party-providers-service-fetcher.ts +++ b/src/adapters/third-party-providers-service-fetcher.ts @@ -1,5 +1,6 @@ import { L2Network } from '@dcl/catalyst-contracts' import { AppComponents, ThirdPartyProvider } from '../types' +import { sanitizeContractList } from '../logic/utils' export type ThirdPartyProvidersServiceFetcher = { get(): Promise @@ -23,15 +24,22 @@ export async function createThirdPartyProvidersServiceFetcherComponent( const isThirdPartyProvidersResolverServiceDisabled: boolean = (await config.getString('DISABLE_THIRD_PARTY_PROVIDERS_RESOLVER_SERVICE_USAGE')) === 'true' - return { - async get(): Promise { - if (isThirdPartyProvidersResolverServiceDisabled) { - throw new Error( - 'Third Party Providers resolver service will not be used since DISABLE_THIRD_PARTY_PROVIDERS_RESOLVER_SERVICE_USAGE is set' - ) - } - const response: ThirdPartyProvidersServiceResponse = await (await fetch.fetch(`${serviceUrl}/providers`)).json() - return response.thirdPartyProviders + async function get(): Promise { + if (isThirdPartyProvidersResolverServiceDisabled) { + throw new Error( + 'Third Party Providers resolver service will not be used since DISABLE_THIRD_PARTY_PROVIDERS_RESOLVER_SERVICE_USAGE is set' + ) + } + const response: ThirdPartyProvidersServiceResponse = await (await fetch.fetch(`${serviceUrl}/providers`)).json() + + if (response.thirdPartyProviders) { + sanitizeContractList(response.thirdPartyProviders) } + + return response.thirdPartyProviders + } + + return { + get } } diff --git a/src/components.ts b/src/components.ts index 11fc0a5c..2df2e67b 100644 --- a/src/components.ts +++ b/src/components.ts @@ -36,6 +36,9 @@ import { createThirdPartyProvidersServiceFetcherComponent } from './adapters/thi import { createThirdPartyProvidersStorage } from './logic/third-party-providers-storage' import { createProfilesComponent } from './adapters/profiles' import { IFetchComponent } from '@well-known-components/interfaces' +import { createAlchemyNftFetcher } from './adapters/alchemy-nft-fetcher' +import { createThirdPartyContractRegistry } from './ports/ownership-checker/third-party-contract-registry' +import { createThirdPartyItemChecker } from './ports/ownership-checker/third-party-item-checker' // Initialize all the components of the app export async function initComponents( @@ -112,6 +115,19 @@ export async function initComponents( const poisFetcher = await createPOIsFetcher({ l2Provider }, l2Network) const nameDenylistFetcher = await createNameDenylistFetcher({ l1Provider }, l1Network) + const l1ThirdPartyContractRegistry = await createThirdPartyContractRegistry(logs, l1Provider, l1Network as any, '.') + const l2ThirdPartyContractRegistry = await createThirdPartyContractRegistry(logs, l2Provider, l2Network as any, '.') + const l1ThirdPartyItemChecker = await createThirdPartyItemChecker( + { entitiesFetcher, logs }, + l1Provider, + l1ThirdPartyContractRegistry + ) + const l2ThirdPartyItemChecker = await createThirdPartyItemChecker( + { entitiesFetcher, logs }, + l2Provider, + l2ThirdPartyContractRegistry + ) + const thirdPartyProvidersGraphFetcher = createThirdPartyProvidersGraphFetcherComponent({ theGraph }) const thirdPartyProvidersServiceFetcher = await createThirdPartyProvidersServiceFetcherComponent( { config, fetch }, @@ -123,12 +139,20 @@ export async function initComponents( thirdPartyProvidersServiceFetcher }) const thirdPartyWearablesFetcher = createElementsFetcherComponent({ logs }, async (address) => - fetchAllThirdPartyWearables({ thirdPartyProvidersStorage, fetch, logs, entitiesFetcher, metrics }, address) + fetchAllThirdPartyWearables( + { alchemyNftFetcher, contentServerUrl, thirdPartyProvidersStorage, fetch, logs, entitiesFetcher, metrics }, + address + ) ) + const alchemyNftFetcher = await createAlchemyNftFetcher({ config, logs, fetch }) + const profiles = await createProfilesComponent({ + alchemyNftFetcher, metrics, content, + contentServerUrl, + entitiesFetcher, theGraph, config, fetch, @@ -137,7 +161,9 @@ export async function initComponents( logs, wearablesFetcher, emotesFetcher, - namesFetcher + namesFetcher, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker }) return { @@ -170,6 +196,9 @@ export async function initComponents( catalystsFetcher, poisFetcher, nameDenylistFetcher, - profiles + profiles, + alchemyNftFetcher, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker } } diff --git a/src/controllers/handlers/outfits-handler.ts b/src/controllers/handlers/outfits-handler.ts index 05a8b08e..dd7ef7e6 100644 --- a/src/controllers/handlers/outfits-handler.ts +++ b/src/controllers/handlers/outfits-handler.ts @@ -4,14 +4,19 @@ import { Entity } from '@dcl/schemas' export async function outfitsHandler( context: HandlerContextWithPath< + | 'alchemyNftFetcher' | 'metrics' | 'content' + | 'contentServerUrl' + | 'entitiesFetcher' | 'theGraph' | 'config' | 'fetch' | 'ownershipCaches' | 'wearablesFetcher' | 'namesFetcher' + | 'l1ThirdPartyItemChecker' + | 'l2ThirdPartyItemChecker' | 'thirdPartyProvidersStorage' | 'logs', '/outfits/:id' diff --git a/src/logic/fetch-elements/fetch-third-party-wearables.ts b/src/logic/fetch-elements/fetch-third-party-wearables.ts index bbe53169..ef29429c 100644 --- a/src/logic/fetch-elements/fetch-third-party-wearables.ts +++ b/src/logic/fetch-elements/fetch-third-party-wearables.ts @@ -1,7 +1,7 @@ -import { Entity, Wearable } from '@dcl/schemas' +import { ContractNetwork, createMappingsHelper, Entity, Wearable } from '@dcl/schemas' import { BlockchainCollectionThirdPartyName, parseUrn } from '@dcl/urn-resolver' import { FetcherError } from '../../adapters/elements-fetcher' -import { AppComponents, ThirdPartyProvider, ThirdPartyAsset, ThirdPartyWearable } from '../../types' +import { AppComponents, ThirdPartyAsset, ThirdPartyProvider, ThirdPartyWearable } from '../../types' const URN_THIRD_PARTY_NAME_TYPE = 'blockchain-collection-third-party-name' const URN_THIRD_PARTY_ASSET_TYPE = 'blockchain-collection-third-party' @@ -14,6 +14,11 @@ export type ThirdPartyAssets = { next?: string } +type LinkedWearableAssetEntities = { + total: number + entities: Entity[] +} + async function fetchAssets( { logs, fetch, metrics }: Pick, owner: string, @@ -24,6 +29,10 @@ async function fetchAssets( if (!urn || urn.type !== URN_THIRD_PARTY_NAME_TYPE) { throw new Error(`Couldn't parse third party id: ${thirdParty.id}`) } + if (!thirdParty.resolver) { + logger.warn(`Third party ${thirdParty.id} doesn't have a resolver`) + return [] + } const baseUrl = new URL(thirdParty.resolver).href.replace(/\/$/, '') let url: string | undefined = `${baseUrl}/registry/${urn.thirdPartyName}/address/${owner}/assets` @@ -57,23 +66,35 @@ async function fetchAssets( return allAssets } +async function fetchAssetsV2( + { contentServerUrl, fetch }: Pick, + linkedWearableProvider: ThirdPartyProvider +): Promise { + const urn = await parseUrn(linkedWearableProvider.id) + if (!urn || urn.type !== URN_THIRD_PARTY_NAME_TYPE) { + throw new Error(`Couldn't parse linked wearable provider id: ${linkedWearableProvider.id}`) + } + + const response = await fetch.fetch(`${contentServerUrl}/entities/active/collections/${linkedWearableProvider.id}`) + const assetsByOwner: LinkedWearableAssetEntities = await response.json() + return assetsByOwner.entities || [] +} + function groupThirdPartyWearablesByURN(assets: (ThirdPartyAsset & { entity: Entity })[]): ThirdPartyWearable[] { const wearablesByURN = new Map() for (const asset of assets) { const metadata: Wearable = asset.entity.metadata + const individualData = { id: asset.urn.decentraland } + if (wearablesByURN.has(asset.urn.decentraland)) { const wearableFromMap = wearablesByURN.get(asset.urn.decentraland)! - wearableFromMap.individualData.push({ id: asset.urn.decentraland }) + wearableFromMap.individualData.push(individualData) wearableFromMap.amount = wearableFromMap.amount + 1 } else { wearablesByURN.set(asset.urn.decentraland, { urn: asset.urn.decentraland, - individualData: [ - { - id: asset.urn.decentraland - } - ], + individualData: [individualData], amount: 1, name: metadata.name, category: metadata.data.category, @@ -85,8 +106,44 @@ function groupThirdPartyWearablesByURN(assets: (ThirdPartyAsset & { entity: Enti return Array.from(wearablesByURN.values()) } +function groupLinkedWearablesByURN( + assets: Record< + string, + { + individualData: string[] + entity: Entity + } + > +): ThirdPartyWearable[] { + const wearablesByURN = new Map() + + for (const [assetId, data] of Object.entries(assets)) { + wearablesByURN.set(assetId, { + urn: assetId, + individualData: data.individualData.map((indi) => ({ + id: `${assetId}:${indi}`, + tokenId: indi + })), + amount: data.individualData.length, + name: data.entity.metadata.name, + category: data.entity.metadata.data.category, + entity: data.entity + }) + } + return Array.from(wearablesByURN.values()) +} + export async function fetchUserThirdPartyAssets( - components: Pick, + components: Pick< + AppComponents, + | 'alchemyNftFetcher' + | 'contentServerUrl' + | 'thirdPartyProvidersStorage' + | 'fetch' + | 'logs' + | 'entitiesFetcher' + | 'metrics' + >, owner: string, collectionId: string ): Promise { @@ -101,52 +158,164 @@ export async function fetchUserThirdPartyAssets( const thirdPartyId = parts.slice(0, 5).join(':') - let thirdPartyProvider: ThirdPartyProvider | undefined = undefined - const thirdPartyProviders = await components.thirdPartyProvidersStorage.getAll() - for (const provider of thirdPartyProviders) { - if (provider.id === thirdPartyId) { - thirdPartyProvider = provider - break - } - } + const thirdPartyProvider: ThirdPartyProvider | undefined = thirdPartyProviders.find( + (provider) => provider.id === thirdPartyId + ) if (!thirdPartyProvider) { return [] } - const assetsByOwner = await fetchAssets(components, owner, thirdPartyProvider) - if (!assetsByOwner) { - throw new Error(`Could not fetch assets for owner: ${owner}`) - } + const thirdPartyWearables = await _fetchThirdPartyWearables(components, owner, [thirdPartyProvider]) - return assetsByOwner.filter((asset) => asset.urn.decentraland.startsWith(thirdPartyId)) ?? [] + return thirdPartyWearables.map((tpw) => ({ + id: tpw.urn, // TODO check this, not sure id refers to full urn, it might be provider + collection id + item id + amount: tpw.amount, + urn: { + decentraland: tpw.urn + } + })) } export async function fetchAllThirdPartyWearables( - components: Pick, + components: Pick< + AppComponents, + | 'alchemyNftFetcher' + | 'contentServerUrl' + | 'thirdPartyProvidersStorage' + | 'fetch' + | 'logs' + | 'entitiesFetcher' + | 'metrics' + >, owner: string ): Promise { const thirdParties = await components.thirdPartyProvidersStorage.getAll() - // TODO: test if stateValue is kept in case of an exception - const thirdPartyAssets = ( - await Promise.all(thirdParties.map((thirdParty: ThirdPartyProvider) => fetchAssets(components, owner, thirdParty))) - ).flat() - - const entities = await components.entitiesFetcher.fetchEntities(thirdPartyAssets.map((tpa) => tpa.urn.decentraland)) - const results: (ThirdPartyAsset & { entity: Entity })[] = [] - for (let i = 0; i < thirdPartyAssets.length; ++i) { - const entity = entities[i] - if (entity) { - results.push({ - ...thirdPartyAssets[i], - entity - }) + return await _fetchThirdPartyWearables(components, owner, thirdParties) +} + +async function _fetchThirdPartyWearables( + components: Pick< + AppComponents, + | 'alchemyNftFetcher' + | 'contentServerUrl' + | 'thirdPartyProvidersStorage' + | 'fetch' + | 'logs' + | 'entitiesFetcher' + | 'metrics' + >, + owner: string, + thirdParties: ThirdPartyProvider[] +): Promise { + async function fetchThirdPartyV1(thirdParties: ThirdPartyProvider[]) { + if (thirdParties.length === 0) { + return [] } + + // TODO: test if stateValue is kept in case of an exception + const thirdPartyAssets = ( + await Promise.all( + thirdParties.map((thirdParty: ThirdPartyProvider) => fetchAssets(components, owner, thirdParty)) + ) + ).flat() + + const entities = await components.entitiesFetcher.fetchEntities(thirdPartyAssets.map((tpa) => tpa.urn.decentraland)) + const results: (ThirdPartyAsset & { entity: Entity })[] = [] + for (let i = 0; i < thirdPartyAssets.length; ++i) { + const entity = entities[i] + if (entity) { + results.push({ + ...thirdPartyAssets[i], + entity + }) + } + } + + return groupThirdPartyWearablesByURN(results) } - return groupThirdPartyWearablesByURN(results) + async function fetchThirdPartyV2(linkedWearableProviders: ThirdPartyProvider[]) { + if (linkedWearableProviders.length === 0) { + return [] + } + + const contractAddresses = linkedWearableProviders.reduce( + (carry, provider) => { + ;(provider.metadata.thirdParty.contracts || []).forEach((contract) => { + carry[contract.network] = carry[contract.network] || new Set() + carry[contract.network].add(contract.address) + }) + return carry + }, + {} as Record> + ) + + const nfts = await components.alchemyNftFetcher.getNFTsForOwner(owner, contractAddresses) + + const providersThatReturnedNfts = new Set() + for (const nft of nfts) { + const [network, contractAddress] = nft.split(':') + // TODO Performance could be improved here by having indexed the providers by their network and contract addresses + const provider = linkedWearableProviders.find((provider) => + (provider.metadata.thirdParty.contracts || []).find( + (contract) => contract.network === network && contract.address === contractAddress + ) + ) + if (provider) { + providersThatReturnedNfts.add(`${provider.id}`) + } + } + + const providersToCheck = linkedWearableProviders.filter((provider) => providersThatReturnedNfts.has(provider.id)) + + const linkedWearableEntities = ( + await Promise.all(providersToCheck.map((provider: ThirdPartyProvider) => fetchAssetsV2(components, provider))) + ).flat() + + const assignedLinkedWearables: Record = {} + for (const entity of linkedWearableEntities) { + const urn = entity.metadata.id + if (!entity.metadata.mappings) { + continue + } + + const mappingsHelper = createMappingsHelper(entity.metadata.mappings) + for (const nft of nfts) { + const [network, contract, tokenId] = nft.split(':') + if (mappingsHelper.includesNft(network as ContractNetwork, contract, tokenId)) { + if (assignedLinkedWearables[urn]) { + assignedLinkedWearables[urn].individualData.push(nft) + } else { + assignedLinkedWearables[urn] = { individualData: [nft], entity } + } + } + } + } + + return groupLinkedWearablesByURN(assignedLinkedWearables) + } + + const [providersV1, providersV2] = thirdParties.reduce( + (acc, provider) => { + if ((provider.metadata.thirdParty.contracts?.length || 0) <= 0) { + acc[0].push(provider) + } else { + acc[1].push(provider) + } + return acc + }, + [[], []] as ThirdPartyProvider[][] + ) + + const [thirdPartyV1, thirdPartyV2] = await Promise.all([ + fetchThirdPartyV1(providersV1), + fetchThirdPartyV2(providersV2) + ]) + + return [...thirdPartyV1, ...thirdPartyV2] } export async function fetchThirdPartyWearablesFromThirdPartyName( diff --git a/src/logic/maps.ts b/src/logic/maps.ts index cfa4d6ad..83066c31 100644 --- a/src/logic/maps.ts +++ b/src/logic/maps.ts @@ -1,4 +1,4 @@ -/* +/** * Merge map1 into map2. They must be { string -> [string] } maps */ export function mergeMapIntoMap(map1: Map, map2: Map) { diff --git a/src/logic/outfits.ts b/src/logic/outfits.ts index 2153a417..6a913f97 100644 --- a/src/logic/outfits.ts +++ b/src/logic/outfits.ts @@ -6,14 +6,19 @@ import { createTPWOwnershipChecker } from '../ports/ownership-checker/tpw-owners export async function getOutfits( components: Pick< AppComponents, + | 'alchemyNftFetcher' | 'metrics' | 'content' + | 'contentServerUrl' + | 'entitiesFetcher' | 'theGraph' | 'config' | 'fetch' | 'ownershipCaches' | 'wearablesFetcher' | 'namesFetcher' + | 'l1ThirdPartyItemChecker' + | 'l2ThirdPartyItemChecker' | 'thirdPartyProvidersStorage' | 'logs' >, diff --git a/src/logic/third-party-providers-storage.ts b/src/logic/third-party-providers-storage.ts index 5a4dc488..f9738513 100644 --- a/src/logic/third-party-providers-storage.ts +++ b/src/logic/third-party-providers-storage.ts @@ -42,7 +42,6 @@ export async function createThirdPartyProvidersStorage({ let response = await wrapCall(async (): Promise => { return thirdPartyProvidersServiceFetcher.get() }) - if (!response.ok) { logger.info('Retry fetching Third Party Providers from TheGraph') response = await wrapCall(async (): Promise => { @@ -62,23 +61,24 @@ export async function createThirdPartyProvidersStorage({ throw new FetcherError(`Cannot fetch third party providers`) } + async function get(thirdPartyProviderNameUrn: BlockchainCollectionThirdPartyName) { + return await findAsync(await getAll(), async (thirdParty: ThirdPartyProvider): Promise => { + const urn = await parseUrn(thirdParty.id) + return ( + !!urn && + urn.type === 'blockchain-collection-third-party-name' && + urn.thirdPartyName === thirdPartyProviderNameUrn.thirdPartyName + ) + }) + } + + async function start() { + await getAll() + } + return { + get, getAll, - async start() { - await getAll() - }, - async get(thirdPartyProviderNameUrn: BlockchainCollectionThirdPartyName) { - const URN_THIRD_PARTY_NAME_TYPE = 'blockchain-collection-third-party-name' - const thirdParty = await findAsync(await getAll(), async (thirdParty: ThirdPartyProvider): Promise => { - const urn = await parseUrn(thirdParty.id) - return ( - !!urn && - urn.type === URN_THIRD_PARTY_NAME_TYPE && - urn.thirdPartyName === thirdPartyProviderNameUrn.thirdPartyName - ) - }) - - return thirdParty - } + start } } diff --git a/src/logic/utils.ts b/src/logic/utils.ts index 0b8f1a6a..a44b9ac7 100644 --- a/src/logic/utils.ts +++ b/src/logic/utils.ts @@ -1,4 +1,5 @@ import { parseUrn as resolverParseUrn } from '@dcl/urn-resolver' +import { ThirdPartyProvider } from '../types' export const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary', 'mythic', 'unique'] @@ -31,3 +32,14 @@ export async function findAsync(elements: T[], f: (e: T) => Promise) return undefined } + +export function sanitizeContractList(thirdPartyProviders: ThirdPartyProvider[]) { + for (const thirdParty of thirdPartyProviders) { + if (thirdParty.metadata.thirdParty?.contracts) { + thirdParty.metadata.thirdParty.contracts = thirdParty.metadata.thirdParty.contracts.map((c) => ({ + network: c.network.toLowerCase(), + address: c.address.toLowerCase() + })) + } + } +} diff --git a/src/ports/ownership-checker/contract-helpers.ts b/src/ports/ownership-checker/contract-helpers.ts new file mode 100644 index 00000000..b0a645fc --- /dev/null +++ b/src/ports/ownership-checker/contract-helpers.ts @@ -0,0 +1,92 @@ +import { HTTPProvider, RPCSendableMessage, toBatchPayload } from 'eth-connect' +import { parseUrn } from '@dcl/urn-resolver' + +export const erc721Abi = [ + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'ownerOf', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function' + } +] + +export const erc1155Abi = [ + { + inputs: [ + { internalType: 'address', name: 'account', type: 'address' }, + { internalType: 'uint256', name: 'id', type: 'uint256' } + ], + name: 'balanceOf', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function' + } +] + +export function sendBatch(provider: HTTPProvider, batch: RPCSendableMessage[]) { + const payload = toBatchPayload(batch) + return new Promise((resolve, reject) => { + provider.sendAsync(payload as any, (err: any, result: any) => { + if (err) { + reject(err) + return + } + + resolve(result) + }) + }) +} + +export async function sendSingle(provider: HTTPProvider, message: RPCSendableMessage) { + const res = await sendBatch(provider, [message]) + return res[0] +} + +const ITEM_TYPES_TO_SPLIT = ['blockchain-collection-third-party', 'blockchain-collection-third-party-item'] + +type URNsByNetwork = { + v1: { urn: string; type: string }[] + l1ThirdParty: { urn: string; type: string }[] + l2ThirdParty: { urn: string; type: string }[] +} + +export const L1_NETWORKS = ['mainnet', 'sepolia'] +export const L2_NETWORKS = ['matic', 'amoy'] + +export async function splitItemsURNsByTypeAndNetwork(urnsToSplit: string[]): Promise { + const v1: { urn: string; type: string }[] = [] + const l1ThirdParty: { urn: string; type: string }[] = [] + const l2ThirdParty: { urn: string; type: string }[] = [] + + for (const urn of urnsToSplit) { + const asset = await parseUrn(urn) + if (!asset || !('network' in asset) || !ITEM_TYPES_TO_SPLIT.includes(asset.type)) { + continue + } + + // check if it is a L1 or L2 asset + // 'ethereum' is included since L1 Mainnet assets include it instead of 'mainnet' + if (![...L1_NETWORKS, 'ethereum'].includes(asset.network) && !L2_NETWORKS.includes(asset.network)) { + continue + } + + if (L2_NETWORKS.includes(asset.network)) { + if (asset.type === 'blockchain-collection-third-party-item') { + if (L1_NETWORKS.includes(asset.nftChain)) { + l1ThirdParty.push({ urn: asset.uri.toString(), type: asset.type }) + } else if (L2_NETWORKS.includes(asset.nftChain)) { + l2ThirdParty.push({ urn: asset.uri.toString(), type: asset.type }) + } + } else if (asset.type === 'blockchain-collection-third-party') { + v1.push({ urn: asset.uri.toString(), type: asset.type }) + } + } + } + + return { + v1, + l1ThirdParty, + l2ThirdParty + } +} diff --git a/src/ports/ownership-checker/third-party-contract-registry.ts b/src/ports/ownership-checker/third-party-contract-registry.ts new file mode 100644 index 00000000..963c4dff --- /dev/null +++ b/src/ports/ownership-checker/third-party-contract-registry.ts @@ -0,0 +1,124 @@ +import fs from 'fs' +import path from 'path' + +import RequestManager, { ContractFactory, HTTPProvider, toData } from 'eth-connect' +import { ILoggerComponent } from '@well-known-components/interfaces' +import { ContractAddress } from '@dcl/schemas' +import { erc1155Abi, erc721Abi, sendSingle } from './contract-helpers' + +export enum ContractType { + ERC721 = 'erc721', + ERC1155 = 'erc1155', + UNKNOWN = 'unknown' +} + +export function loadCacheFile(file: string): Record { + try { + if (!fs.existsSync(file)) { + saveCacheFile(file, {}) + } + const fileContent = fs.readFileSync(file, 'utf-8') + return JSON.parse(fileContent) + } catch (_) { + return {} + } +} + +export function saveCacheFile(file: string, data: any): void { + const jsonData = JSON.stringify(data, null, 2) + fs.writeFileSync(file, jsonData, 'utf-8') +} + +export type ThirdPartyContractRegistry = { + isErc721(contractAddress: ContractAddress): boolean + isErc1155(contractAddress: ContractAddress): boolean + isUnknown(contractAddress: ContractAddress): boolean + ensureContractsKnown(contractAddresses: ContractAddress[]): Promise +} + +export async function createThirdPartyContractRegistry( + logs: ILoggerComponent, + provider: HTTPProvider, + network: 'mainnet' | 'sepolia' | 'polygon' | 'amoy', + storageRoot: string +): Promise { + const logger = logs.getLogger('contract-registry') + + const requestManager = new RequestManager(provider) + const erc721ContractFactory = new ContractFactory(requestManager, erc721Abi) + const erc1155ContractFactory = new ContractFactory(requestManager, erc1155Abi) + + const file = path.join(storageRoot, `third-party-contracts-${network}.json`) + const data: Record = loadCacheFile(file) + + function isErc721(contractAddress: ContractAddress): boolean { + return data[contractAddress.toLowerCase()] === ContractType.ERC721 + } + + function isErc1155(contractAddress: ContractAddress): boolean { + return data[contractAddress.toLowerCase()] === ContractType.ERC1155 + } + + function isUnknown(contractAddress: ContractAddress): boolean { + return data[contractAddress.toLowerCase()] === ContractType.UNKNOWN + } + + async function checkIfErc721(contractAddress: ContractAddress): Promise { + const contract: any = await erc721ContractFactory.at(contractAddress) + try { + const r = await sendSingle(provider, await contract.ownerOf.toRPCMessage(0)) + if (r.error?.code === 3) { + // NFT id doesn't exist, but it is an ERC-721 + return true + } + if (!r.result) { + return false + } + return !!contract.ownerOf.unpackOutput(toData(r.result)) + } catch (_) { + return false + } + } + + async function checkIfErc1155(contractAddress: ContractAddress): Promise { + const contract: any = await erc1155ContractFactory.at(contractAddress) + + try { + const r = await sendSingle(provider, await contract.balanceOf.toRPCMessage(contract.address, 0)) + if (!r.result) { + return false + } + return !!contract.balanceOf.unpackOutput(toData(r.result)) + } catch (_) { + return false + } + } + + async function ensureContractsKnown(contractAddresses: ContractAddress[]) { + const needToFigureOut = contractAddresses + .map((contractAddress) => contractAddress.toLowerCase()) + .filter((contractAddress) => !data[contractAddress]) + + if (needToFigureOut.length > 0) { + for (const contract of needToFigureOut) { + if (await checkIfErc1155(contract)) { + data[contract] = ContractType.ERC1155 + } else if (await checkIfErc721(contract)) { + data[contract] = ContractType.ERC721 + } else { + data[contract] = ContractType.UNKNOWN + } + } + + logger.debug('Updating contract cache', { file, newContracts: needToFigureOut.join(', ') }) + saveCacheFile(file, data) + } + } + + return { + isErc721, + isErc1155, + isUnknown, + ensureContractsKnown + } +} diff --git a/src/ports/ownership-checker/third-party-item-checker.ts b/src/ports/ownership-checker/third-party-item-checker.ts new file mode 100644 index 00000000..90868cee --- /dev/null +++ b/src/ports/ownership-checker/third-party-item-checker.ts @@ -0,0 +1,158 @@ +import RequestManager, { ContractFactory, HTTPProvider, RPCSendableMessage, toData } from 'eth-connect' +import { BlockchainCollectionThirdPartyItem, parseUrn } from '@dcl/urn-resolver' +import { ContractType, ThirdPartyContractRegistry } from './third-party-contract-registry' +import { erc1155Abi, erc721Abi, sendBatch } from './contract-helpers' +import { AppComponents } from '../../types' +import { ContractNetwork, createMappingsHelper, Entity } from '@dcl/schemas' + +type TempData = { + urn: string + assetUrn?: string + network?: string + contract?: string + nftId?: string + type?: ContractType + result?: boolean +} +export type ThirdPartyItemChecker = { + checkThirdPartyItems(ethAddress: string, itemUrns: string[]): Promise +} + +const EMPTY_MESSAGE = '0x' + +export async function createThirdPartyItemChecker( + { entitiesFetcher, logs }: Pick, + provider: HTTPProvider, + thirdPartyContractRegistry: ThirdPartyContractRegistry +): Promise { + const logger = logs.getLogger('item-checker') + const requestManager = new RequestManager(provider) + const erc721ContractFactory = new ContractFactory(requestManager, erc721Abi) + const erc1155ContractFactory = new ContractFactory(requestManager, erc1155Abi) + + async function checkThirdPartyItems(ethAddress: string, itemUrns: string[]): Promise { + if (itemUrns.length === 0) { + logger.debug('No third party items to check') + return [] + } + + logger.info(`Checking third party items for ${ethAddress}: ${JSON.stringify(itemUrns)}`) + + const allUrns: Record = itemUrns.reduce( + (acc, urn) => { + acc[urn] = { urn } + return acc + }, + {} as Record + ) + + // Mark as false all urns that cannot be parsed + for (const urn of itemUrns) { + const parsed = await parseUrn(urn) + if (!parsed) { + allUrns[urn].result = false + } else { + const thirdPartyItem = parsed as BlockchainCollectionThirdPartyItem + allUrns[urn].network = thirdPartyItem.nftChain.toLowerCase() + allUrns[urn].contract = thirdPartyItem.nftContractAddress.toLowerCase() + allUrns[urn].nftId = thirdPartyItem.nftTokenId + allUrns[urn].assetUrn = urn.split(':').slice(0, 7).join(':') + } + } + + // Fetch wearables from the content server for checking that the mappings are valid. + const entitiesToFetch = new Set( + Object.values(allUrns) + .map((tempData: TempData) => tempData.assetUrn ?? '') + .filter((assetUrn: string) => !!assetUrn) + ) + const entities = await entitiesFetcher.fetchEntities(Array.from(entitiesToFetch)) + const entitiesByPointer = entities.reduce( + (acc, entity: Entity | undefined) => { + if (entity?.metadata) { + acc[entity.metadata.id] = entity + } + return acc + }, + {} as Record + ) + + // Mark as false all items with invalid mapping + Object.values(allUrns) + .filter((tempData) => tempData.result !== false) + .forEach((tempData) => { + if (!entitiesByPointer[tempData.assetUrn]) { + tempData.result = false + } + const entity = entitiesByPointer[tempData.assetUrn] + const mappingsHelper = createMappingsHelper(entity.metadata.mappings) + if (!mappingsHelper.includesNft(tempData.network! as ContractNetwork, tempData.contract!, tempData.nftId!)) { + tempData.result = false + } + }) + + // Ensure all contracts are of a known type, otherwise try to determine it and store it. + await thirdPartyContractRegistry.ensureContractsKnown( + Object.values(allUrns) + .filter((tempData) => !!tempData.contract) + .map((asset) => asset.contract) + ) + + // Mark as false all contracts that are of unknown type + Object.values(allUrns) + .filter((tempData) => !!tempData.contract) + .forEach((tempData) => { + if (!tempData.result && thirdPartyContractRegistry.isUnknown(tempData.contract)) { + tempData.result = false + } + }) + + const filteredAssets: TempData[] = Object.values(allUrns).filter((tempData) => tempData.result === undefined) + + if (filteredAssets.length > 0) { + const contracts: any = await Promise.all( + filteredAssets.map((asset) => { + if (thirdPartyContractRegistry.isErc721(asset.contract!)) { + return erc721ContractFactory.at(asset.contract!) + } else if (thirdPartyContractRegistry.isErc1155(asset.contract!)) { + return erc1155ContractFactory.at(asset.contract!) + } + throw new Error('Unknown contract type') + }) + ) + const batch: RPCSendableMessage[] = await Promise.all( + contracts.map((contract: any, idx: number) => { + if (thirdPartyContractRegistry.isErc721(filteredAssets[idx].contract!)) { + return contract.ownerOf.toRPCMessage(filteredAssets[idx].nftId) + } else if (thirdPartyContractRegistry.isErc1155(filteredAssets[idx].contract!)) { + return contract.balanceOf.toRPCMessage(ethAddress, filteredAssets[idx].nftId) + } + throw new Error('Unknown contract type') + }) + ) + + const result = await sendBatch(provider, batch) + + result.forEach((r: any, idx: number) => { + if (!r.result) { + filteredAssets[idx].result = false + } else { + const data = toData(r.result) + if (thirdPartyContractRegistry.isErc721(filteredAssets[idx].contract!)) { + filteredAssets[idx].result = + (data === EMPTY_MESSAGE ? '' : contracts[idx].ownerOf.unpackOutput(data).toLowerCase()) === + ethAddress.toLowerCase() + } else if (thirdPartyContractRegistry.isErc1155(filteredAssets[idx].contract!)) { + filteredAssets[idx].result = (data === EMPTY_MESSAGE ? 0 : contracts[idx].balanceOf.unpackOutput(data)) > 0 + } + } + }) + } + + return itemUrns.map((itemUrn) => allUrns[itemUrn].result) + } + + return { + checkThirdPartyItems + } +} diff --git a/src/ports/ownership-checker/tpw-ownership-checker.ts b/src/ports/ownership-checker/tpw-ownership-checker.ts index 0d4bb3a0..0ecf678a 100644 --- a/src/ports/ownership-checker/tpw-ownership-checker.ts +++ b/src/ports/ownership-checker/tpw-ownership-checker.ts @@ -1,11 +1,22 @@ -import { parseUrn } from '@dcl/urn-resolver' -import { getCachedNFTsAndPendingCheckNFTs, fillCacheWithRecentlyCheckedWearables } from '../../logic/cache' -import { fetchUserThirdPartyAssets } from '../../logic/fetch-elements/fetch-third-party-wearables' +import { fillCacheWithRecentlyCheckedWearables, getCachedNFTsAndPendingCheckNFTs } from '../../logic/cache' import { mergeMapIntoMap } from '../../logic/maps' import { AppComponents, NFTsOwnershipChecker } from '../../types' +import { splitItemsURNsByTypeAndNetwork } from './contract-helpers' export function createTPWOwnershipChecker( - components: Pick + components: Pick< + AppComponents, + | 'alchemyNftFetcher' + | 'entitiesFetcher' + | 'contentServerUrl' + | 'l1ThirdPartyItemChecker' + | 'l2ThirdPartyItemChecker' + | 'thirdPartyProvidersStorage' + | 'fetch' + | 'ownershipCaches' + | 'logs' + | 'metrics' + > ): NFTsOwnershipChecker { let ownedTPWByAddress: Map = new Map() const cache = components.ownershipCaches.tpwCache @@ -21,13 +32,14 @@ export function createTPWOwnershipChecker( cache ) - // Check ownership for the non-cached nfts + // Check ownership for the non-cached NFTs ownedTPWByAddress = await ownedThirdPartyWearables(components, nftsToCheckByAddress) - // Traverse the checked nfts to set the cache depending on its ownership + // Traverse the checked NFTs to set the cache depending on its ownership fillCacheWithRecentlyCheckedWearables(nftsToCheckByAddress, ownedTPWByAddress, cache) - // Merge cachedOwnedNFTsByAddress (contains the nfts which ownershipwas cached) into ownedWearablesByAddress (recently checked ownnership map) + // Merge cachedOwnedNFTsByAddress (contains the NFTs for which ownership was cached) into ownedWearablesByAddress + // (recently checked ownership map) mergeMapIntoMap(cachedOwnedNFTsByAddress, ownedTPWByAddress) } @@ -44,65 +56,40 @@ export function createTPWOwnershipChecker( /* * It could happen that a user had a third-party wearable in its profile which it was - * selled through the blockchain without being reflected on the content server, so we - * need to make sure that every third-party wearable it is still owned by the user. + * sold through the blockchain without being reflected on the content server, so we + * need to make sure that the user still owns every third-party wearable. * This method gets the collection ids from a wearableIdsByAddress map, for each of them * gets its API resolver, gets the owned third party wearables for that collection, and * finally sanitize wearableIdsByAddress with the owned wearables. */ async function ownedThirdPartyWearables( { - metrics, - thirdPartyProvidersStorage, - fetch, - logs - }: Pick, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker + }: Pick, wearableIdsByAddress: Map ): Promise> { const response = new Map() - const collectionsByAddress = new Map() for (const [address, wearableIds] of wearableIdsByAddress) { - const collectionIds = await filterCollectionIdsFromWearables(wearableIds) - collectionsByAddress.set(address, collectionIds) - } - - for (const [address, collectionIds] of collectionsByAddress) { - const ownedTPW: Set = new Set() - await Promise.all( - collectionIds.map(async (collectionId) => { - for (const asset of await fetchUserThirdPartyAssets( - { thirdPartyProvidersStorage, fetch, logs, metrics }, - address, - collectionId - )) { - ownedTPW.add(asset.urn.decentraland.toLowerCase()) - } - }) - ) + const { v1, l1ThirdParty, l2ThirdParty } = await splitItemsURNsByTypeAndNetwork(wearableIds) - const wearablesIds = wearableIdsByAddress.get(address) - response.set( - address, - wearablesIds!.filter((tpw) => ownedTPW.has(tpw.toLowerCase())) - ) + const results = await Promise.all([ + l1ThirdPartyItemChecker.checkThirdPartyItems( + address, + l1ThirdParty.map((tp) => tp.urn) + ), + l2ThirdPartyItemChecker.checkThirdPartyItems( + address, + l2ThirdParty.map((tp) => tp.urn) + ) + ]) + response.set(address, [ + ...v1.map((tp) => tp.urn), + ...l1ThirdParty.filter((_tpw, idx) => results[0][idx]).map((tp) => tp.urn), + ...l2ThirdParty.filter((_tpw, idx) => results[1][idx]).map((tp) => tp.urn) + ]) } return response } - -async function filterCollectionIdsFromWearables(wearableIds: string[]): Promise { - const collectionIds: string[] = [] - const parsedUrns = await Promise.allSettled(wearableIds.map(parseUrn)) - for (const result of parsedUrns) { - if (result.status === 'fulfilled') { - const parsedUrn = result.value - if (parsedUrn?.type === 'blockchain-collection-third-party') { - const collectionId = parsedUrn.uri.toString().split(':').slice(0, -1).join(':') - collectionIds.push(collectionId) - } - } - } - - return collectionIds -} diff --git a/src/types.ts b/src/types.ts index fc06c620..42505867 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,8 @@ import { ThirdPartyProvidersServiceFetcher } from './adapters/third-party-provid import { ThirdPartyProvidersGraphFetcher } from './adapters/third-party-providers-graph-fetcher' import { ThirdPartyProvidersStorage } from './logic/third-party-providers-storage' import { IProfilesComponent } from './adapters/profiles' +import { AlchemyNftFetcher } from './adapters/alchemy-nft-fetcher' +import { ThirdPartyItemChecker } from './ports/ownership-checker/third-party-item-checker' export type GlobalContext = { components: BaseComponents @@ -71,6 +73,9 @@ export type BaseComponents = { poisFetcher: POIsFetcher nameDenylistFetcher: NameDenylistFetcher profiles: IProfilesComponent + alchemyNftFetcher: AlchemyNftFetcher + l1ThirdPartyItemChecker: ThirdPartyItemChecker + l2ThirdPartyItemChecker: ThirdPartyItemChecker } // components used in runtime @@ -218,6 +223,7 @@ export type ThirdPartyProvider = { thirdParty: { name: string description: string + contracts?: { network: string; address: string }[] } } } diff --git a/test/components.ts b/test/components.ts index 1745acff..12177f92 100644 --- a/test/components.ts +++ b/test/components.ts @@ -22,6 +22,7 @@ import { main } from '../src/service' import { TestComponents } from '../src/types' import { createContentClientMock } from './mocks/content-mock' import { createTheGraphComponentMock } from './mocks/the-graph-mock' +import { createAlchemyNftFetcherMock } from './mocks/alchemy-mock' /** * Behaves like Jest "describe" function, used to describe a test for a @@ -78,15 +79,18 @@ async function initComponents( thirdParties: [ { id: 'urn:decentraland:matic:collections-thirdparty:baby-doge-coin', - resolver: 'https://decentraland-api.babydoge.com/v1' + resolver: 'https://decentraland-api.babydoge.com/v1', + metadata: {} }, { id: 'urn:decentraland:matic:collections-thirdparty:cryptoavatars', - resolver: 'https://api.cryptoavatars.io/' + resolver: 'https://api.cryptoavatars.io/', + metadata: {} }, { id: 'urn:decentraland:matic:collections-thirdparty:dolcegabbana-disco-drip', - resolver: 'https://wearables-api.unxd.com' + resolver: 'https://wearables-api.unxd.com', + metadata: {} } ] }) @@ -121,11 +125,14 @@ async function initComponents( contentServerUrl }) + const alchemyNftFetcher = createAlchemyNftFetcherMock() const metrics = createTestMetricsComponent(metricDeclarations) const thirdPartyWearablesFetcher = createElementsFetcherComponent({ logs }, async (address) => fetchAllThirdPartyWearables( { metrics, + contentServerUrl, + alchemyNftFetcher, thirdPartyProvidersStorage: components.thirdPartyProvidersStorage, fetch, logs, @@ -137,6 +144,7 @@ async function initComponents( return { ...components, + alchemyNftFetcher, config, metrics, localFetch: await createLocalFetchCompoment(config), diff --git a/test/data/wearables.ts b/test/data/wearables.ts index 8abcdecd..08c4e15d 100644 --- a/test/data/wearables.ts +++ b/test/data/wearables.ts @@ -105,6 +105,74 @@ export function generateWearableEntities(urns: string[]): Entity[] { return urns.map(generateWearableEntity) } +export function generateThirdPartyWearableEntity(urn: string): Entity { + return { + version: '3', + id: urn, + type: EntityType.WEARABLE, + pointers: [urn], + timestamp, + content: [ + { + file: 'file', + hash: 'id' + }, + { + file: imageFileNameFor(urn), + hash: 'imageHash' + }, + { + file: thumbnailNameFor(urn), + hash: 'thumbnailHash' + } + ], + metadata: { + id: urn, + name: `nameFor${urn}`, + description: `descFor${urn}`, + i18n: [], + thumbnail: thumbnailNameFor(urn), + image: imageFileNameFor(urn), + data: { + tags: ['aTag'], + category: WearableCategory.EARRING, + representations: [ + { + bodyShapes: [], + mainFile: `mainFileFor${urn}`, + contents: ['fileName'], + overrideHides: [], + overrideReplaces: [] + } + ] as WearableRepresentation[] + }, + content: { + file: 'id', + [imageFileNameFor(urn)]: 'imageHash', + [thumbnailNameFor(urn)]: 'thumbnailHash' + }, + merkleProof: { + index: 0, + proof: [], + hashingKeys: ['id', 'name', 'description', 'i18n', 'data', 'image', 'thumbnail', 'metrics', 'content'], + entityHash: 'dead7e51b278d8089b82bec014e128cad8a6be1db188f50fd0e7e9ac3501c7f2' + }, + mappings: { + sepolia: { + '0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c': [ + { type: 'single', id: '7' }, + { type: 'single', id: '70' } + ] + } + } + } + } +} + +export function generateThirdPartyWearableEntities(urns: string[]): Entity[] { + return urns.map(generateThirdPartyWearableEntity) +} + export function generateThirdPartyWearables(quantity: number): ThirdPartyAsset[] { const generatedThirdPartyWearables = [] for (let i = 0; i < quantity; i++) { diff --git a/test/integration/profile-adapter.spec.ts b/test/integration/profile-adapter.spec.ts index 66543bfc..2e357054 100644 --- a/test/integration/profile-adapter.spec.ts +++ b/test/integration/profile-adapter.spec.ts @@ -11,20 +11,24 @@ import { import { WearableCategory } from '@dcl/schemas' import { createProfilesComponent } from '../../src/adapters/profiles' import { createConfigComponent } from '@well-known-components/env-config-provider' +import { generateWearableEntity } from '../data/wearables' test('integration tests for profile adapter', function ({ components, stubComponents }) { it('calling with a single profile address, owning everything claimed', async () => { const { metrics, config, + contentServerUrl, ownershipCaches, thirdPartyProvidersStorage, logs, wearablesFetcher, emotesFetcher, - namesFetcher + namesFetcher, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker } = components - const { theGraph, fetch, content } = stubComponents + const { alchemyNftFetcher, entitiesFetcher, theGraph, fetch, content } = stubComponents const address = '0x1' content.fetchEntitiesByPointers.withArgs([address]).resolves(await Promise.all([profileEntityFull])) @@ -134,14 +138,22 @@ test('integration tests for profile adapter', function ({ components, stubCompon .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) .onCall(1) .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) + entitiesFetcher.fetchEntities + .withArgs(tpwResolverResponseFull.assets.map((a) => a.urn.decentraland)) + .resolves(tpwResolverResponseFull.assets.map((a) => generateWearableEntity(a.urn.decentraland))) const profilesComponent = await createProfilesComponent({ + alchemyNftFetcher, + entitiesFetcher, metrics, content, + contentServerUrl, theGraph, config, fetch, ownershipCaches, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker, thirdPartyProvidersStorage, logs, wearablesFetcher, @@ -200,14 +212,17 @@ testWithComponents(() => { const { metrics, config, + contentServerUrl, ownershipCaches, thirdPartyProvidersStorage, logs, wearablesFetcher, emotesFetcher, - namesFetcher + namesFetcher, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker } = components - const { theGraph, fetch, content } = stubComponents + const { alchemyNftFetcher, entitiesFetcher, theGraph, fetch, content } = stubComponents const address = '0x1' content.fetchEntitiesByPointers.withArgs([address]).resolves(await Promise.all([profileEntityFull])) @@ -334,14 +349,22 @@ testWithComponents(() => { .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) .onCall(1) .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) + entitiesFetcher.fetchEntities + .withArgs(tpwResolverResponseFull.assets.map((a) => a.urn.decentraland)) + .resolves(tpwResolverResponseFull.assets.map((a) => generateWearableEntity(a.urn.decentraland))) const profilesComponent = await createProfilesComponent({ + alchemyNftFetcher, + entitiesFetcher, metrics, content, + contentServerUrl, theGraph, config, fetch, ownershipCaches, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker, thirdPartyProvidersStorage, logs, wearablesFetcher, @@ -391,14 +414,17 @@ testWithComponents(() => { const { metrics, config, + contentServerUrl, ownershipCaches, thirdPartyProvidersStorage, logs, wearablesFetcher, emotesFetcher, - namesFetcher + namesFetcher, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker } = components - const { theGraph, fetch, content } = stubComponents + const { alchemyNftFetcher, entitiesFetcher, theGraph, fetch, content } = stubComponents const address = '0x1' content.fetchEntitiesByPointers @@ -544,14 +570,22 @@ testWithComponents(() => { .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) .onCall(1) .resolves(new Response(JSON.stringify(tpwResolverResponseFull))) + entitiesFetcher.fetchEntities + .withArgs(tpwResolverResponseFull.assets.map((a) => a.urn.decentraland)) + .resolves(tpwResolverResponseFull.assets.map((a) => generateWearableEntity(a.urn.decentraland))) const profilesComponent = await createProfilesComponent({ + alchemyNftFetcher, + entitiesFetcher, metrics, content, + contentServerUrl, theGraph, config, fetch, ownershipCaches, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker, thirdPartyProvidersStorage, logs, wearablesFetcher, @@ -584,14 +618,19 @@ testWithComponents(() => { test('integration tests for profile adapter', function ({ components, stubComponents }) { it('calling with a single profile address, two eth wearables, one of them not owned', async () => { const { + alchemyNftFetcher, + entitiesFetcher, metrics, config, + contentServerUrl, ownershipCaches, thirdPartyProvidersStorage, logs, wearablesFetcher, emotesFetcher, - namesFetcher + namesFetcher, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker } = components const { theGraph, content, fetch } = stubComponents const addresses = ['0x3'] @@ -625,12 +664,17 @@ test('integration tests for profile adapter', function ({ components, stubCompon theGraph.ensSubgraph.query = jest.fn().mockResolvedValue({ nfts: [] }) const profilesComponent = await createProfilesComponent({ + alchemyNftFetcher, + entitiesFetcher, metrics, content, + contentServerUrl, theGraph, config, fetch, ownershipCaches, + l1ThirdPartyItemChecker, + l2ThirdPartyItemChecker, thirdPartyProvidersStorage, logs, wearablesFetcher, diff --git a/test/integration/third-party-wearables-handler/with-single-provider.spec.ts b/test/integration/third-party-wearables-handler/with-single-provider.spec.ts index 40f11582..2a5b832f 100644 --- a/test/integration/third-party-wearables-handler/with-single-provider.spec.ts +++ b/test/integration/third-party-wearables-handler/with-single-provider.spec.ts @@ -1,6 +1,6 @@ import { nameZA } from '../../../src/logic/sorting' import { testWithComponents } from '../../components' -import { generateThirdPartyWearables, generateWearableEntities } from '../../data/wearables' +import { generateThirdPartyWearables, generateWearableEntities, getThirdPartyProviders } from '../../data/wearables' import { generateRandomAddress } from '../../helpers' import { createTheGraphComponentMock } from '../../mocks/the-graph-mock' import { convertToThirdPartyWearableResponse } from './convert-to-model-third-party' @@ -9,12 +9,7 @@ import { convertToThirdPartyWearableResponse } from './convert-to-model-third-pa testWithComponents(() => { const theGraphMock = createTheGraphComponentMock() const resolverResponse = { - thirdParties: [ - { - id: 'urn:decentraland:matic:collections-thirdparty:baby-doge-coin', - resolver: 'https://decentraland-api.babydoge.com/v1' - } - ] + thirdParties: [getThirdPartyProviders()[0]] } theGraphMock.thirdPartyRegistrySubgraph.query = jest.fn().mockResolvedValue(resolverResponse) diff --git a/test/mocks/alchemy-mock.ts b/test/mocks/alchemy-mock.ts new file mode 100644 index 00000000..77e35d0a --- /dev/null +++ b/test/mocks/alchemy-mock.ts @@ -0,0 +1,9 @@ +import { AlchemyNftFetcher } from '../../src/adapters/alchemy-nft-fetcher' + +export function createAlchemyNftFetcherMock(): AlchemyNftFetcher { + const getNFTsForOwner = jest.fn() + + return { + getNFTsForOwner + } +} diff --git a/test/mocks/http-provider-mock.ts b/test/mocks/http-provider-mock.ts new file mode 100644 index 00000000..4ead2ba3 --- /dev/null +++ b/test/mocks/http-provider-mock.ts @@ -0,0 +1,17 @@ +import { Callback, HTTPProvider, RPCMessage } from 'eth-connect' + +export function createHttpProviderMock(messages: any[] = []): HTTPProvider { + let i = 0 + return { + host: '', + options: {}, + debug: false, + send: () => {}, + sendAsync: async (_payload: RPCMessage | RPCMessage[], _callback: Callback): Promise => { + if (i >= messages.length) { + throw new Error('No more messages mocked to send') + } + _callback(null, messages[i++] || {}) + } + } +} diff --git a/test/mocks/the-graph-mock.ts b/test/mocks/the-graph-mock.ts index 44898fdb..08b6bba6 100644 --- a/test/mocks/the-graph-mock.ts +++ b/test/mocks/the-graph-mock.ts @@ -1,6 +1,6 @@ -import { ISubgraphComponent } from "@well-known-components/thegraph-component" -import { TheGraphComponent } from "../../src/ports/the-graph" -import { QueryGraph } from "../../src/types" +import { ISubgraphComponent } from '@well-known-components/thegraph-component' +import { TheGraphComponent } from '../../src/ports/the-graph' +import { QueryGraph } from '../../src/types' const createMockSubgraphComponent = (mock?: QueryGraph): ISubgraphComponent => ({ query: mock ?? (jest.fn() as jest.MockedFunction) @@ -8,11 +8,11 @@ const createMockSubgraphComponent = (mock?: QueryGraph): ISubgraphComponent => ( export function createTheGraphComponentMock(): TheGraphComponent { return { - start: async () => { }, - stop: async () => { }, + start: async () => {}, + stop: async () => {}, ethereumCollectionsSubgraph: createMockSubgraphComponent(), maticCollectionsSubgraph: createMockSubgraphComponent(), ensSubgraph: createMockSubgraphComponent(), thirdPartyRegistrySubgraph: createMockSubgraphComponent() } -} \ No newline at end of file +} diff --git a/test/mocks/third-party-registry-mock.ts b/test/mocks/third-party-registry-mock.ts new file mode 100644 index 00000000..0754de67 --- /dev/null +++ b/test/mocks/third-party-registry-mock.ts @@ -0,0 +1,12 @@ +import { ThirdPartyContractRegistry } from '../../src/ports/ownership-checker/third-party-contract-registry' + +export function createThirdPartyContractRegistryMock(mock?: ThirdPartyContractRegistry): ThirdPartyContractRegistry { + return ( + mock ?? { + isErc721: jest.fn(), + isErc1155: jest.fn(), + isUnknown: jest.fn(), + ensureContractsKnown: jest.fn() + } + ) +} diff --git a/test/unit/adapters/third-party-providers-graph-fetcher.spec.ts b/test/unit/adapters/third-party-providers-graph-fetcher.spec.ts index 0f5629c5..f8b8801d 100644 --- a/test/unit/adapters/third-party-providers-graph-fetcher.spec.ts +++ b/test/unit/adapters/third-party-providers-graph-fetcher.spec.ts @@ -1,26 +1,14 @@ import { createLogComponent } from '@well-known-components/logger' import { createTheGraphComponentMock } from '../../mocks/the-graph-mock' import { createThirdPartyProvidersGraphFetcherComponent } from '../../../src/adapters/third-party-providers-graph-fetcher' +import { getThirdPartyProviders } from '../../data/wearables' describe('third-party-providers-graph-fetcher', () => { const mockedTheGraph = createTheGraphComponentMock() it('should fetch third party providers from TheGraph', async () => { // Arrange - const expectedResponse = [ - { - id: 'urn:decentraland:matic:collections-thirdparty:baby-doge-coin', - resolver: 'https://decentraland-api.babydoge.com/v1' - }, - { - id: 'urn:decentraland:matic:collections-thirdparty:cryptoavatars', - resolver: 'https://api.cryptoavatars.io/' - }, - { - id: 'urn:decentraland:matic:collections-thirdparty:dolcegabbana-disco-drip', - resolver: 'https://wearables-api.unxd.com' - } - ] + const expectedResponse = getThirdPartyProviders() mockedTheGraph.thirdPartyRegistrySubgraph.query = jest.fn().mockResolvedValue({ thirdParties: expectedResponse diff --git a/test/unit/adapters/third-party-providers-service-fetcher.spec.ts b/test/unit/adapters/third-party-providers-service-fetcher.spec.ts index fd4d1de8..82aae7c9 100644 --- a/test/unit/adapters/third-party-providers-service-fetcher.spec.ts +++ b/test/unit/adapters/third-party-providers-service-fetcher.spec.ts @@ -1,5 +1,6 @@ import { createConfigComponent } from '@well-known-components/env-config-provider' import { createThirdPartyProvidersServiceFetcherComponent } from '../../../src/adapters/third-party-providers-service-fetcher' +import { getThirdPartyProviders } from '../../data/wearables' describe('third-party-providers-service-fetcher', () => { it('should call service when DISABLE_THIRD_PARTY_PROVIDERS_RESOLVER_SERVICE_USAGE is not set', async () => { @@ -46,20 +47,8 @@ describe('third-party-providers-service-fetcher', () => { it('should return fetched Third Party Providers', async () => { // Arrange - const expectedResponse = [ - { - id: 'urn:decentraland:matic:collections-thirdparty:baby-doge-coin', - resolver: 'https://decentraland-api.babydoge.com/v1' - }, - { - id: 'urn:decentraland:matic:collections-thirdparty:cryptoavatars', - resolver: 'https://api.cryptoavatars.io/' - }, - { - id: 'urn:decentraland:matic:collections-thirdparty:dolcegabbana-disco-drip', - resolver: 'https://wearables-api.unxd.com' - } - ] + const expectedResponse = getThirdPartyProviders() + const config = createConfigComponent({ DISABLE_THIRD_PARTY_PROVIDERS_RESOLVER_SERVICE_USAGE: 'false' }) const mockedFetch = { fetch: jest diff --git a/test/unit/ports/third-party-contract-registry.spec.ts b/test/unit/ports/third-party-contract-registry.spec.ts new file mode 100644 index 00000000..383feecf --- /dev/null +++ b/test/unit/ports/third-party-contract-registry.spec.ts @@ -0,0 +1,57 @@ +import { ILoggerComponent } from '@well-known-components/interfaces' +import { createLogComponent } from '@well-known-components/logger' +import { createConfigComponent } from '@well-known-components/env-config-provider' +import { createHttpProviderMock } from '../../mocks/http-provider-mock' +import { HTTPProvider } from 'eth-connect' +import path from 'path' +import fs from 'fs' +import os from 'os' +import { + createThirdPartyContractRegistry, + ThirdPartyContractRegistry +} from '../../../src/ports/ownership-checker/third-party-contract-registry' + +describe('third party contract registry', () => { + let logs: ILoggerComponent + let httpProvider: HTTPProvider + let registry: ThirdPartyContractRegistry + let tempFolder: string + + beforeEach(async () => { + logs = await createLogComponent({ config: createConfigComponent({ LOG_LEVEL: 'DEBUG' }) }) + httpProvider = createHttpProviderMock([ + [{ jsonrpc: '2.0', id: 1, result: '0x' }], + [{ jsonrpc: '2.0', id: 2, result: '0x' }], + [{ jsonrpc: '2.0', id: 3, error: { code: -32000, message: 'execution reverted' } }], + [{ jsonrpc: '2.0', id: 4, result: '0x000000000000000000000000edae96f7739af8a7fb16e2a888c1e578e1328299' }], + [{ jsonrpc: '2.0', id: 5, result: '0x0000000000000000000000000000000000000000000000000000000000000000' }] + ]) + tempFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'test-')) + + registry = await createThirdPartyContractRegistry(logs, httpProvider, 'sepolia', tempFolder) + }) + + afterEach(() => { + fs.rm(tempFolder, { recursive: true }, () => {}) + }) + + it('correct validation of nfts', async () => { + await registry.ensureContractsKnown([ + '0x49f94A887Efc16993E69d4F07Ef3dE11A2C90897', + '0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c', + '0x1aca797764bd5c1e9F3c2933432a2be770A33941' + ]) + + expect(registry.isErc1155('0x1aca797764bd5c1e9f3c2933432a2be770a33941')).toBe(true) + expect(registry.isErc1155('0x49f94A887Efc16993E69d4F07Ef3dE11A2C90897')).toBe(false) + expect(registry.isErc1155('0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c')).toBe(false) + + expect(registry.isErc721('0x1aca797764bd5c1e9f3c2933432a2be770a33941')).toBe(false) + expect(registry.isErc721('0x49f94A887Efc16993E69d4F07Ef3dE11A2C90897')).toBe(false) + expect(registry.isErc721('0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c')).toBe(true) + + expect(registry.isUnknown('0x1aca797764bd5c1e9f3c2933432a2be770a33941')).toBe(false) + expect(registry.isUnknown('0x49f94A887Efc16993E69d4F07Ef3dE11A2C90897')).toBe(true) + expect(registry.isUnknown('0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c')).toBe(false) + }) +}) diff --git a/test/unit/ports/third-party-item-checker.spec.ts b/test/unit/ports/third-party-item-checker.spec.ts new file mode 100644 index 00000000..f3aa0642 --- /dev/null +++ b/test/unit/ports/third-party-item-checker.spec.ts @@ -0,0 +1,76 @@ +import { ILoggerComponent } from '@well-known-components/interfaces' +import { createLogComponent } from '@well-known-components/logger' +import { createConfigComponent } from '@well-known-components/env-config-provider' +import { HTTPProvider } from 'eth-connect' +import { + createThirdPartyItemChecker, + ThirdPartyItemChecker +} from '../../../src/ports/ownership-checker/third-party-item-checker' +import { ThirdPartyContractRegistry } from '../../../src/ports/ownership-checker/third-party-contract-registry' +import { createHttpProviderMock } from '../../mocks/http-provider-mock' +import { createThirdPartyContractRegistryMock } from '../../mocks/third-party-registry-mock' +import { EntitiesFetcher } from '../../../src/adapters/entities-fetcher' +import { generateThirdPartyWearableEntities } from '../../data/wearables' + +describe('third party item checker', () => { + let logs: ILoggerComponent + let thirdPartyItemChecker: ThirdPartyItemChecker + let httpProvider: HTTPProvider + let entitiesFetcher: EntitiesFetcher + let registry: ThirdPartyContractRegistry + + beforeEach(async () => { + logs = await createLogComponent({ config: createConfigComponent({ LOG_LEVEL: 'DEBUG' }) }) + httpProvider = createHttpProviderMock([ + [ + { jsonrpc: '2.0', id: 1, result: '0x00000000000000000000000069d30b1875d39e13a01af73ccfed6d84839e84f2' }, + { + jsonrpc: '2.0', + id: 2, + error: { + code: 3, + data: '0x7e2732890000000000000000000000000000000000000000000000000000000000000046', + message: 'execution reverted' + } + } + ] + ]) + entitiesFetcher = { + fetchEntities: async (urns: string[]) => { + return generateThirdPartyWearableEntities(urns) + } + } + registry = createThirdPartyContractRegistryMock() + thirdPartyItemChecker = await createThirdPartyItemChecker({ entitiesFetcher, logs }, httpProvider, registry) + }) + + it('correct validation of nfts', async () => { + registry.isErc1155 = jest.fn().mockImplementation((contractAddress) => { + return contractAddress === '0x1aca797764bd5c1e9f3c2933432a2be770a33941' + }) + registry.isErc721 = jest.fn().mockImplementation((contractAddress) => { + return contractAddress === '0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c' + }) + registry.isUnknown = jest.fn().mockImplementation((contractAddress) => { + return contractAddress === '0x7020117712a3fe09b7162ee3f932dae7673c6bdd' + }) + const result = await thirdPartyItemChecker.checkThirdPartyItems('0x49f94A887Efc16993E69d4F07Ef3dE11A2C90897', [ + 'urn:decentraland:amoy:collections-thirdparty:back-to-the-future:sepolia-8a50:f-bananacrown-4685:sepolia:0x7020117712a3fE09B7162eE3F932dae7673C6BDD:q34rasf', + 'urn:decentraland:amoy:collections-thirdparty:back-to-the-future:sepolia-8a50:f-bananacrown-4685:sepolia:0x7020117712a3fE09B7162eE3F932dae7673C6BDD:34', + 'urn:decentraland:amoy:collections-thirdparty:back-to-the-future:sepolia-8a50:f-bananacrown-4685:sepolia:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c:7', + 'urn:decentraland:amoy:collections-thirdparty:back-to-the-future:sepolia-8a50:f-bananacrown-4685:sepolia:0x74c78f5a4ab22f01d5fd08455cf0ff5c3367535c:70', + 'urn:decentraland:amoy:collections-thirdparty:back-to-the-future:sepolia-8a50:f-bananacrown-4685:sepolia:0x1aca797764bd5c1e9F3c2933432a2be770A33941:5' + ]) + + expect(result).toEqual([false, false, false, false, false]) + }) + + it('correct validation of nfts when nothing is requested', async () => { + const result = await thirdPartyItemChecker.checkThirdPartyItems('0x49f94A887Efc16993E69d4F07Ef3dE11A2C90897', []) + + expect(result).toEqual([]) + expect(registry.isErc1155).not.toBeCalled() + expect(registry.isErc721).not.toBeCalled() + expect(registry.isUnknown).not.toBeCalled() + }) +})