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