diff --git a/packages/renderer/src/lib/ens.ts b/packages/renderer/src/lib/ens.ts new file mode 100644 index 00000000..50cb0307 --- /dev/null +++ b/packages/renderer/src/lib/ens.ts @@ -0,0 +1,224 @@ +import { type ChainId } from '@dcl/schemas/dist/dapps/chain-id'; +import { isDev } from '../modules/store/ens/utils'; + +const BATCH_SIZE = 1000; + +export type Domain = { name: string }; +export type DomainsQueryResult = { data: { domains: Domain[] } } | { errors: any }; + +export type OwnerByENSTuple = { + name: string; + wrappedOwner: { + id: string; + }; +}; +export type OwnerByENSQueryResult = + | { + data: { + domains: OwnerByENSTuple[]; + }; + } + | { errors: any }; + +export class ENS { + private subgraph = 'https://subgraph.decentraland.org/ens'; + constructor(chainId: ChainId) { + if (isDev(chainId)) { + this.subgraph = 'https://subgraph.decentraland.org/ens-sepolia'; + } + } + + public async fetchNames(address: string) { + const response: Response = await fetch(this.subgraph, { + method: 'POST', + body: JSON.stringify({ + query: `{ + domains( + where: {or: [ + { wrappedOwner: "${address.toLowerCase()}" }, + { registrant: "${address.toLowerCase()}" } + ]} + ) { + name + } + }`, + }), + }); + + if (!response.ok) { + throw new Error(response.status.toString()); + } + + const queryResult: DomainsQueryResult = await response.json(); + + if ('errors' in queryResult) { + throw new Error(JSON.stringify(queryResult.errors)); + } + + return queryResult.data.domains.map(domain => domain.name); + } + + public async fetchNamesOwners(domains: string[]): Promise> { + if (!domains) { + return {}; + } + + const response: Response = await fetch(this.subgraph, { + method: 'POST', + body: JSON.stringify({ + query: `query getOwners($domains: [String]) { + domains(where: { name_in: $domains }) { + name + wrappedOwner { + id + } + } + }`, + variables: { domains }, + }), + }); + + if (!response.ok) { + throw new Error(response.status.toString()); + } + + const queryResult: OwnerByENSQueryResult = await response.json(); + + if ('errors' in queryResult) { + throw new Error(JSON.stringify(queryResult.errors)); + } + + const results: Record = {}; + queryResult.data.domains.forEach(({ wrappedOwner, name }) => { + results[name] = wrappedOwner.id; + }); + return results; + } +} + +export type DCLDomainsQueryResult = + | { data: { nfts: { ens: { subdomain: string } }[] } } + | { errors: any }; + +export type DCLOwnerByNameTuple = { + owner: { + address: string; + }; + ens: { + subdomain: string; + }; +}; +export type DCLOwnerByNameQueryResult = { + data: { + nfts: DCLOwnerByNameTuple[]; + }; +}; + +export class DCLNames { + private subgraph = 'https://subgraph.decentraland.org/marketplace'; + constructor(chainId: ChainId) { + if (isDev(chainId)) { + this.subgraph = 'https://subgraph.decentraland.org/marketplace-sepolia'; + } + } + + public async fetchNames(address: string) { + let results: string[] = []; + let offset = 0; + let nextPage = true; + + while (nextPage) { + const response: Response = await fetch(this.subgraph, { + method: 'POST', + body: JSON.stringify({ + query: `{ + nfts( + first: ${BATCH_SIZE}, + skip: ${offset}, + where: { + owner_: { id: "${address.toLowerCase()}" }, + category: ens + } + ) { + ens { + subdomain + } + } + }`, + }), + }); + + if (!response.ok) { + throw new Error(response.status.toString()); + } + + const queryResult: DCLDomainsQueryResult = await response.json(); + + if ('errors' in queryResult) { + throw new Error(JSON.stringify(queryResult.errors)); + } + const domains: string[] = queryResult.data.nfts.map( + nft => `${nft.ens.subdomain.toString()}.dcl.eth`, + ); + results = results.concat(domains); + + if (domains.length === BATCH_SIZE) { + offset += BATCH_SIZE; + } else { + nextPage = false; + } + } + + return results; + } + + public async fetchNamesOwners(domains: string[]) { + if (!domains) { + return {}; + } + + const results: Record = {}; + let offset = 0; + let nextPage = true; + + while (nextPage) { + const response: Response = await fetch(this.subgraph, { + method: 'POST', + body: JSON.stringify({ + query: `query getOwners($domains: [String!], $offset: Int) { + nfts(first: ${BATCH_SIZE}, skip: $offset, where: { name_in: $domains, category: ens }) { + owner { + address + } + ens { + subdomain + } + } + }`, + variables: { domains, offset }, + }), + }); + + if (!response.ok) { + throw new Error(response.status.toString()); + } + + const queryResult: DCLOwnerByNameQueryResult = await response.json(); + + if ('errors' in queryResult) { + throw new Error(JSON.stringify(queryResult.errors)); + } + queryResult.data.nfts.forEach(({ ens, owner }) => { + results[ens.subdomain] = owner.address; + }); + + if (queryResult.data.nfts.length === BATCH_SIZE) { + offset += BATCH_SIZE; + } else { + nextPage = false; + } + } + + return results; + } +} diff --git a/packages/renderer/src/lib/worlds.ts b/packages/renderer/src/lib/worlds.ts index 1d10106a..ddd73577 100644 --- a/packages/renderer/src/lib/worlds.ts +++ b/packages/renderer/src/lib/worlds.ts @@ -1,5 +1,6 @@ import fetch from 'decentraland-crypto-fetch'; import type { AuthIdentity } from '@dcl/crypto'; +import { localStorageGetIdentity } from '@dcl/single-sign-on-client'; import { DEPLOY_URLS } from '/shared/types/deploy'; import type { ContributableDomain } from '../modules/store/ens/types'; @@ -145,6 +146,14 @@ export class Worlds { } } + private withIdentity(address: string): AuthIdentity { + const identity = localStorageGetIdentity(address); + if (!identity) { + throw new Error('No identity found'); + } + return identity; + } + public async fetchWorld(name: string) { try { const result = await fetch(`${this.url}/entities/active`, { @@ -187,7 +196,7 @@ export class Worlds { }; public postPermissionType = async ( - identity: AuthIdentity, + address: string, worldName: string, worldPermissionNames: WorldPermissionNames, worldPermissionType: WorldPermissionType, @@ -199,48 +208,46 @@ export class Worlds { metadata: { type: worldPermissionType, }, - identity, + identity: this.withIdentity(address), }, ); return result.status === 204; }; public putPermissionType = async ( - identity: AuthIdentity, + address: string, worldName: string, worldPermissionNames: WorldPermissionNames, - address: string, ) => { const result = await fetch( `${this.url}/world/${worldName}/permissions/${worldPermissionNames}/${address}`, { method: 'PUT', - identity, + identity: this.withIdentity(address), }, ); return result.status === 204; }; public deletePermissionType = async ( - identity: AuthIdentity, + address: string, worldName: string, worldPermissionNames: WorldPermissionNames, - address: string, ) => { const result = await fetch( `${this.url}/world/${worldName}/permissions/${worldPermissionNames}/${address}`, { method: 'DELETE', - identity, + identity: this.withIdentity(address), }, ); return result.status === 204; }; - public fetchContributableDomains = async (identity: AuthIdentity) => { + public fetchContributableDomains = async (address: string) => { const result = await fetch(`${this.url}/wallet/contribute`, { method: 'GET', - identity, + identity: this.withIdentity(address), }); if (result.ok) { diff --git a/packages/renderer/src/modules/store/ens/slice.ts b/packages/renderer/src/modules/store/ens/slice.ts index 061c0f72..d9c5a3db 100644 --- a/packages/renderer/src/modules/store/ens/slice.ts +++ b/packages/renderer/src/modules/store/ens/slice.ts @@ -4,105 +4,39 @@ import { namehash } from '@ethersproject/hash'; import pLimit from 'p-limit'; import type { ChainId } from '@dcl/schemas/dist/dapps/chain-id'; import { getContract, ContractName } from 'decentraland-transactions'; +import { DCLNames, ENS as ENSApi } from '/@/lib/ens'; import { Worlds } from '/@/lib/worlds'; import type { Async } from '/@/modules/async'; import { ens as ensContract, ensResolver } from './contracts'; import { getEnsProvider, isValidENSName, isDev } from './utils'; -import type { DCLDomainsQueryResult, DomainsQueryResult, ENS, ENSError } from './types'; +import { USER_PERMISSIONS, type ENS, type ENSError } from './types'; const REQUESTS_BATCH_SIZE = 25; -const BATCH_SIZE = 1000; const limit = pLimit(REQUESTS_BATCH_SIZE); // actions -export const fetchDCLENSNames = async (address: string, chainId: ChainId) => { - let dclEnsSubgraph = 'https://subgraph.decentraland.org/marketplace'; - if (isDev(chainId)) { - dclEnsSubgraph = 'https://subgraph.decentraland.org/marketplace-sepolia'; +export const fetchWorldStatus = async (domain: string, chainId: ChainId) => { + const WorldAPI = new Worlds(isDev(chainId)); + const world = await WorldAPI.fetchWorld(domain); + if (world && world.length > 0) { + const [{ id: entityId }] = world; + return { + scene: { + entityId, + }, + }; } - - let results: string[] = []; - let offset = 0; - let nextPage = true; - - while (nextPage) { - const response: Response = await fetch(dclEnsSubgraph, { - method: 'POST', - body: JSON.stringify({ - query: `{ - nfts( - first: ${BATCH_SIZE}, - skip: ${offset}, - where: { - owner_: { id: "${address.toLowerCase()}" }, - category: ens - } - ) { - ens { - subdomain - } - } - }`, - }), - }); - - if (!response.ok) { - throw new Error(response.status.toString()); - } - - const queryResult: DCLDomainsQueryResult = await response.json(); - - if ('errors' in queryResult) { - throw new Error(JSON.stringify(queryResult.errors)); - } - const domains: string[] = queryResult.data.nfts.map( - nft => `${nft.ens.subdomain.toString()}.dcl.eth`, - ); - results = results.concat(domains); - - if (domains.length === BATCH_SIZE) { - offset += BATCH_SIZE; - } else { - nextPage = false; - } - } - - return results; + return null; }; -export const fetchENSNames = async (address: string, chainId: ChainId) => { - let ensSubgraph = 'https://subgraph.decentraland.org/ens'; - if (isDev(chainId)) { - ensSubgraph = 'https://subgraph.decentraland.org/ens-sepolia'; - } - - const response: Response = await fetch(ensSubgraph, { - method: 'POST', - body: JSON.stringify({ - query: `{ - domains( - where: {or: [ - { wrappedOwner: "${address.toLowerCase()}" }, - { registrant: "${address.toLowerCase()}" } - ]} - ) { - name - } - }`, - }), - }); - - if (!response.ok) { - throw new Error(response.status.toString()); - } - - const queryResult: DomainsQueryResult = await response.json(); - - if ('errors' in queryResult) { - throw new Error(JSON.stringify(queryResult.errors)); +export const fetchContributeENSNames = async (address: string, chainId: ChainId) => { + try { + const WorldAPI = new Worlds(isDev(chainId)); + const domains = await WorldAPI.fetchContributableDomains(address); + return domains.filter(domain => domain.user_permissions.includes(USER_PERMISSIONS.DEPLOYMENT)); + } catch (_) { + return []; } - - return queryResult.data.domains.map(domain => domain.name); }; export const fetchBannedNames = async (chainId: ChainId) => { @@ -124,17 +58,14 @@ export const fetchBannedNames = async (chainId: ChainId) => { return bannedNames; }; -export const fetchENSList = createAsyncThunk( - 'ens/fetchENSList', - async (payload: { address: string; chainId: ChainId }) => { - const { address, chainId } = payload; - +export const fetchDCLNames = createAsyncThunk( + 'ens/fetchNames', + async ({ address, chainId }: { address: string; chainId: ChainId }) => { if (!address) return []; const provider = new ethers.JsonRpcProvider( `https://rpc.decentraland.org/${isDev(chainId) ? 'sepolia' : 'mainnet'}`, ); - const WorldAPI = new Worlds(isDev(chainId)); // TODO: Implement logic to fetch lands from the builder-server // const lands: Land[] @@ -157,22 +88,19 @@ export const fetchENSList = createAsyncThunk( provider, ); - const [dclENSNames, ENSNames] = await Promise.all([ - fetchDCLENSNames(address, chainId), - fetchENSNames(address, chainId), - ]); - let domains: string[] = [...dclENSNames, ...ENSNames]; - const bannedDomains: string[] = await fetchBannedNames(chainId); - domains = domains.filter(domain => !bannedDomains.includes(domain)).filter(isValidENSName); + const dclNamesApi = new DCLNames(chainId); + const bannedNames = await fetchBannedNames(chainId); + + let names = await dclNamesApi.fetchNames(address); + names = names.filter(domain => !bannedNames.includes(domain)).filter(isValidENSName); - const promisesOfENS: Promise[] = domains.map(data => { + const promisesOfDCLENS: Promise[] = names.map(data => { return limit(async () => { - const subdomain = data.toLocaleLowerCase(); + const subdomain = data.toLowerCase(); const name = subdomain.split('.')[0]; // TODO: Implement logic to fetch lands from the builder-server const landId: string | undefined = undefined; let content = ''; - let worldStatus = null; let ensAddressRecord = ''; const nodehash = namehash(subdomain); const [resolverAddress, owner, tokenId]: [string, string, string] = await Promise.all([ @@ -214,17 +142,9 @@ export const fetchENSList = createAsyncThunk( } } - const world = await WorldAPI.fetchWorld(subdomain); - if (world && world.length > 0) { - const [{ id: entityId }] = world; - worldStatus = { - scene: { - entityId, - }, - }; - } + const worldStatus = await fetchWorldStatus(subdomain, chainId); - const ens: ENS = { + return { name, subdomain, provider: getEnsProvider(subdomain), @@ -237,32 +157,124 @@ export const fetchENSList = createAsyncThunk( landId, worldStatus, }; + }); + }); + + return Promise.all(promisesOfDCLENS); + }, +); + +export const fetchENS = createAsyncThunk( + 'ens/fetchENS', + async ({ address, chainId }: { address: string; chainId: ChainId }) => { + const ensApi = new ENSApi(chainId); + const bannedNames = await fetchBannedNames(chainId); + const bannedNamesSet = new Set(bannedNames.map(x => x.toLowerCase())); + + let names = await ensApi.fetchNames(address); + names = names + .filter(name => name.split('.').every(nameSegment => !bannedNamesSet.has(nameSegment))) + .filter(isValidENSName); + + const promisesOfENS: Promise[] = names.map(data => { + return limit(async () => { + const subdomain = data.toLowerCase(); + const name = subdomain.split('.')[0]; + + const worldStatus = await fetchWorldStatus(name, chainId); + + return { + name, + subdomain, + provider: getEnsProvider(subdomain), + nftOwnerAddress: address, + content: '', + ensOwnerAddress: '', + resolver: '', + tokenId: '', + worldStatus, + }; + }); + }); + return Promise.all(promisesOfENS); + }, +); + +export const fetchContributableNames = createAsyncThunk( + 'ens/fetchContributableNames', + async ({ address, chainId }: { address: string; chainId: ChainId }) => { + const dclNamesApi = new DCLNames(chainId); + const ensApi = new ENSApi(chainId); + const bannedNames = await fetchBannedNames(chainId); + const bannedNamesSet = new Set(bannedNames.map(x => x.toLowerCase())); + + let names = await fetchContributeENSNames(address, chainId); + names = names.filter(({ name }) => + name.split('.').every(nameSegment => !bannedNamesSet.has(nameSegment)), + ); - return ens; + const [ownerByNameDomain, ownerByEnsDomain]: [Record, Record] = + await Promise.all([ + dclNamesApi.fetchNamesOwners( + names + .filter(item => item.name.endsWith('dcl.eth')) + .map(item => item.name.replace('.dcl.eth', '')), + ), + ensApi.fetchNamesOwners( + names.filter(item => !item.name.endsWith('dcl.eth')).map(item => item.name), + ), + ]); + + const promisesOfContributableENSNames: Promise[] = names.map(data => { + return limit(async () => { + const subdomain = data.name.toLowerCase(); + const name = subdomain.split('.')[0]; + + const worldStatus = await fetchWorldStatus(name, chainId); + + return { + name, + subdomain, + provider: getEnsProvider(subdomain), + nftOwnerAddress: subdomain.includes('dcl.eth') + ? ownerByNameDomain[name] + : ownerByEnsDomain[subdomain], + content: '', + ensOwnerAddress: '', + resolver: '', + tokenId: '', + userPermissions: data.user_permissions, + size: data.size, + worldStatus, + }; }); }); - const ensList: ENS[] = await Promise.all(promisesOfENS); - return ensList; + return Promise.all(promisesOfContributableENSNames); + }, +); + +export const fetchENSList = createAsyncThunk( + 'ens/fetchENSList', + async (payload: { address: string; chainId: ChainId }, thunkApi) => { + const dclNames = await thunkApi.dispatch(fetchDCLNames(payload)).unwrap(); + const ensNames = await thunkApi.dispatch(fetchENS(payload)).unwrap(); + const contributableNames = await thunkApi.dispatch(fetchContributableNames(payload)).unwrap(); + + return [...dclNames, ...ensNames, ...contributableNames]; }, ); // state export type ENSState = { data: Record; - externalNames: Record; - contributableNames: Record; error: ENSError | null; - contributableNamesError: ENSError | null; }; export const initialState: Async = { data: {}, - externalNames: {}, - contributableNames: {}, status: 'idle', error: null, - contributableNamesError: null, }; // slice @@ -286,6 +298,7 @@ export const slice = createSlice({ { ...state.data }, ), }; + state.status = 'succeeded'; }); }, }); diff --git a/packages/renderer/src/modules/store/ens/types.ts b/packages/renderer/src/modules/store/ens/types.ts index 4df0014b..98390ade 100644 --- a/packages/renderer/src/modules/store/ens/types.ts +++ b/packages/renderer/src/modules/store/ens/types.ts @@ -72,15 +72,14 @@ export type WorldStatus = { }; }; +export enum USER_PERMISSIONS { + DEPLOYMENT = 'deployment', + STREAMING = 'streaming', +} + export type ContributableDomain = { name: string; - user_permissions: string[]; + user_permissions: USER_PERMISSIONS[]; owner: string; size: string; }; - -export type Domain = { name: string }; -export type DomainsQueryResult = { data: { domains: Domain[] } } | { errors: any }; -export type DCLDomainsQueryResult = - | { data: { nfts: { ens: { subdomain: string } }[] } } - | { errors: any };