diff --git a/mock/entry.mock.ts b/mock/entry.mock.ts index 72e59fd..d67b185 100644 --- a/mock/entry.mock.ts +++ b/mock/entry.mock.ts @@ -1,26 +1,24 @@ -import { namehash } from '@ensdomains/ensjs/utils'; -import { - keccak256, - toUtf8Bytes -} from 'ethers'; -import nock from 'nock'; -import { Version } from '../src/base'; -import { ADDRESS_NAME_WRAPPER } from '../src/config'; -import { Metadata } from '../src/service/metadata'; -import getNetwork from '../src/service/network'; -import { decodeFuses, getWrapperState } from '../src/utils/fuse'; +import { namehash } from '@ensdomains/ensjs/utils'; +import { keccak256, toUtf8Bytes } from 'ethers'; +import nock from 'nock'; +import { Version } from '../src/base'; +import { ADDRESS_NAME_WRAPPER } from '../src/config'; +import { Metadata } from '../src/service/metadata'; +import getNetwork from '../src/service/network'; +import { createBatchQuery } from '../src/utils/batchQuery'; +import { decodeFuses, getWrapperState } from '../src/utils/fuse'; + import { GET_DOMAINS, GET_REGISTRATIONS, GET_WRAPPED_DOMAIN, -} from '../src/service/subgraph'; +} from '../src/service/subgraph'; import { DomainResponse, MockEntryBody, RegistrationResponse, WrappedDomainResponse, -} from './interface'; - +} from './interface'; const { SUBGRAPH_URL: subgraph_url } = getNetwork('goerli'); const SUBGRAPH_URL = new URL(subgraph_url); @@ -53,13 +51,17 @@ export class MockEntry { if (!registered) { this.expect = 'No results found.'; + const newBatchQuery = createBatchQuery('getDomainInfo') + .add(GET_DOMAINS) + .add(GET_REGISTRATIONS) + .add(GET_WRAPPED_DOMAIN); nock(SUBGRAPH_URL.origin) .post(SUBGRAPH_PATH, { - query: GET_DOMAINS, + query: newBatchQuery.query(), variables: { tokenId: this.namehash, }, - operationName: 'getDomains', + operationName: 'getDomainInfo', }) .reply(statusCode, { data: null, @@ -77,16 +79,20 @@ export class MockEntry { version: Version.v1, }); this.expect = JSON.parse(JSON.stringify(unknownMetadata)); + const newBatchQuery = createBatchQuery('getDomainInfo') + .add(GET_DOMAINS) + .add(GET_REGISTRATIONS) + .add(GET_WRAPPED_DOMAIN); nock(SUBGRAPH_URL.origin) .post(SUBGRAPH_PATH, { - query: GET_DOMAINS, + query: newBatchQuery.query(), variables: { tokenId: this.namehash, }, - operationName: 'getDomains', + operationName: 'getDomainInfo', }) .reply(statusCode, { - data: { domain: {} }, + data: { domain: {}, registrations: {}, wrappedDomain: {} }, }) .persist(persist); return; @@ -147,19 +153,6 @@ export class MockEntry { display_type: 'date', value: expiryDate * 1000, }); - - nock(SUBGRAPH_URL.origin) - .post(SUBGRAPH_PATH, { - query: GET_REGISTRATIONS, - variables: { - labelhash, - }, - operationName: 'getRegistration', - }) - .reply(statusCode, { - data: this.registrationResponse, - }) - .persist(persist); } if (version === Version.v2) { @@ -183,7 +176,6 @@ export class MockEntry { display_type: 'date', value: expiryDate * 1000, }); - _metadata.addAttribute({ trait_type: 'Namewrapper State', display_type: 'string', @@ -191,35 +183,33 @@ export class MockEntry { }); _metadata.description += _metadata.generateRuggableWarning( - _metadata.name, version, getWrapperState(decodedFuses) - ) - - nock(SUBGRAPH_URL.origin) - .post(SUBGRAPH_PATH, { - query: GET_WRAPPED_DOMAIN, - variables: { - tokenId: this.namehash, - }, - operationName: 'getWrappedDomain', - }) - .reply(statusCode, { - data: this.wrappedDomainResponse, - }) - .persist(persist); + _metadata.name, + version, + getWrapperState(decodedFuses) + ); } this.expect = JSON.parse(JSON.stringify(_metadata)); //todo: find better serialization option + const newBatchQuery = createBatchQuery('getDomainInfo') + .add(GET_DOMAINS) + .add(GET_REGISTRATIONS) + .add(GET_WRAPPED_DOMAIN); + nock(SUBGRAPH_URL.origin) .post(SUBGRAPH_PATH, { - query: GET_DOMAINS, + query: newBatchQuery.query(), variables: { tokenId: this.namehash, }, - operationName: 'getDomains', + operationName: 'getDomainInfo', }) .reply(statusCode, { - data: this.domainResponse, + data: { + ...this.domainResponse, + ...this.registrationResponse, + ...this.wrappedDomainResponse, + }, }) .persist(persist); } diff --git a/src/controller/ensMetadata.ts b/src/controller/ensMetadata.ts index 7d63753..7ec0cb4 100644 --- a/src/controller/ensMetadata.ts +++ b/src/controller/ensMetadata.ts @@ -1,7 +1,6 @@ import { strict as assert } from 'assert'; import { Contract } from 'ethers'; import { Request, Response } from 'express'; -import { FetchError } from 'node-fetch'; import { ContractMismatchError, ExpiredNameError, diff --git a/src/service/contract.ts b/src/service/contract.ts index 123f812..ded8a0b 100644 --- a/src/service/contract.ts +++ b/src/service/contract.ts @@ -38,8 +38,22 @@ async function checkV1Contract( ); assert(isInterfaceSupported); return { tokenId: _tokenId, version: Version.v1w }; - } catch (error) { - console.warn(`checkV1Contract: nft ownership check fails for ${_tokenId}`); + } catch (error: any) { + if ( + // ethers error: given address is not contract, or does not have the supportsInterface method available + error?.info?.method === 'supportsInterface' || + // assert error: given address is a contract but given INAMEWRAPPER interface is not available + (typeof error?.actual === 'boolean' && !error?.actual) + ) { + // fail is expected for regular owners since the owner is not a contract and do not have supportsInterface method + console.warn( + `checkV1Contract: supportsInterface check fails for ${_tokenId}` + ); + } else { + console.warn( + `checkV1Contract: nft ownership check fails for ${_tokenId}` + ); + } } return { tokenId: _tokenId, version: Version.v1 }; } diff --git a/src/service/domain.test.ts b/src/service/domain.test.ts index acb12f9..63246a9 100644 --- a/src/service/domain.test.ts +++ b/src/service/domain.test.ts @@ -5,9 +5,10 @@ import { nockProvider } from '../../mock/helper'; import { TestContext } from '../../mock/interface'; import { NamehashMismatchError, Version } from '../base'; import { ADDRESS_ETH_REGISTRAR, ADDRESS_ETH_REGISTRY } from '../config'; +import { createBatchQuery } from '../utils/batchQuery'; import { getDomain } from './domain'; import getNetwork from './network'; -import { GET_DOMAINS_BY_LABELHASH, GET_REGISTRATIONS } from './subgraph'; +import { GET_DOMAINS_BY_LABELHASH, GET_REGISTRATIONS, GET_WRAPPED_DOMAIN } from './subgraph'; const test = avaTest as TestFn; const NETWORK = 'mainnet'; @@ -61,15 +62,20 @@ test.before(async (t: ExecutionContext) => { } ); + const newBatchQuery = createBatchQuery('getDomainInfo') + .add(GET_DOMAINS_BY_LABELHASH) + .add(GET_REGISTRATIONS) + .add(GET_WRAPPED_DOMAIN); + // fake vitalik.eth with nullifier nock(SUBGRAPH_URL.origin) - .post(SUBGRAPH_PATH, { - query: GET_DOMAINS_BY_LABELHASH, + .post(SUBGRAPH_URL.pathname + SUBGRAPH_URL.search, { + query: newBatchQuery.query(), variables: { tokenId: '0x3581397a478dcebdc1ee778deed625697f624c6f7dbed8bb7f780a6ac094b772', }, - operationName: 'getDomains', + operationName: 'getDomainInfo', }) .reply(200, { data: { @@ -86,18 +92,20 @@ test.before(async (t: ExecutionContext) => { resolver: null, }, ], + registrations: [], + wrappedDomain: null, }, }); // original vitalik.eth nock(SUBGRAPH_URL.origin) - .post(SUBGRAPH_PATH, { - query: GET_DOMAINS_BY_LABELHASH, + .post(SUBGRAPH_URL.pathname + SUBGRAPH_URL.search, { + query: newBatchQuery.query(), variables: { tokenId: '0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc', }, - operationName: 'getDomains', + operationName: 'getDomainInfo', }) .reply(200, { data: { @@ -117,20 +125,6 @@ test.before(async (t: ExecutionContext) => { }, }, ], - }, - }); - - nock(SUBGRAPH_URL.origin) - .post(SUBGRAPH_PATH, { - query: GET_REGISTRATIONS, - variables: { - labelhash: - '0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc', - }, - operationName: 'getRegistration', - }) - .reply(200, { - data: { registrations: [ { labelName: 'vitalik', @@ -138,6 +132,7 @@ test.before(async (t: ExecutionContext) => { expiryDate: '2032977474', }, ], + wrappedDomain: null, }, }); }); diff --git a/src/service/domain.ts b/src/service/domain.ts index 7193849..744f1e7 100644 --- a/src/service/domain.ts +++ b/src/service/domain.ts @@ -9,9 +9,9 @@ import { GET_DOMAINS, GET_DOMAINS_BY_LABELHASH, GET_WRAPPED_DOMAIN, -} from './subgraph'; -import { Metadata } from './metadata'; -import { getAvatarImage } from './avatar'; +} from './subgraph'; +import { Metadata } from './metadata'; +import { getAvatarImage } from './avatar'; import { ExpiredNameError, NamehashMismatchError, @@ -23,6 +23,7 @@ import { decodeFuses, getWrapperState } from '../utils/fuse'; +import { createBatchQuery } from '../utils/batchQuery'; import { getNamehash } from '../utils/namehash'; import { bigIntToUint8Array } from '../utils/bigIntToUint8Array'; @@ -49,11 +50,16 @@ export async function getDomain( } const queryDocument: string = version !== Version.v2 ? GET_DOMAINS_BY_LABELHASH : GET_DOMAINS; - const result = await request(SUBGRAPH_URL, queryDocument, { tokenId: hexId }); - const domain = version !== Version.v2 ? result.domains[0] : result.domain; + + const newBatch = createBatchQuery('getDomainInfo'); + newBatch.add(queryDocument).add(GET_REGISTRATIONS).add(GET_WRAPPED_DOMAIN); + + const domainQueryResult = await request(SUBGRAPH_URL, newBatch.query(), { tokenId: hexId }); + + const domain = version !== Version.v2 ? domainQueryResult.domains[0] : domainQueryResult.domain; if (!(domain && Object.keys(domain).length)) throw new SubgraphRecordNotFound(`No record for ${hexId}`); - const { name, labelhash, createdAt, parent, resolver, id: namehash } = domain; + const { name, createdAt, parent, resolver, id: namehash } = domain; /** * IMPORTANT @@ -109,11 +115,8 @@ export async function getDomain( } async function requestAttributes() { - if (parent.id === eth) { - const { registrations } = await request(SUBGRAPH_URL, GET_REGISTRATIONS, { - labelhash, - }); - const registration = registrations[0]; + if (parent.id === eth && domainQueryResult.registrations?.length) { + const registration = domainQueryResult.registrations[0]; const registered_date = registration.registrationDate * 1000; const expiration_date = registration.expiryDate * 1000; if (expiration_date + GRACE_PERIOD_MS < +new Date()) { @@ -138,13 +141,12 @@ export async function getDomain( } } - if (version === Version.v2) { + if (version === Version.v2 && domainQueryResult.wrappedDomain) { const { wrappedDomain: { fuses, expiryDate }, - } = await request(SUBGRAPH_URL, GET_WRAPPED_DOMAIN, { - tokenId: namehash, - }); + } = domainQueryResult; const decodedFuses = decodeFuses(fuses); + metadata.addAttribute({ trait_type: 'Namewrapper Fuse States', display_type: 'object', diff --git a/src/service/metadata.ts b/src/service/metadata.ts index 9c67828..e50b562 100644 --- a/src/service/metadata.ts +++ b/src/service/metadata.ts @@ -182,7 +182,7 @@ export class Metadata { try { this.setImage('data:image/svg+xml;base64,' + base64EncodeUnicode(svg)); } catch (e) { - console.log(processedDomain, e); + console.log("generateImage", processedDomain, e); this.setImage(''); } } diff --git a/src/service/subgraph.ts b/src/service/subgraph.ts index 53b9959..4246cd9 100644 --- a/src/service/subgraph.ts +++ b/src/service/subgraph.ts @@ -45,11 +45,11 @@ export const GET_DOMAINS_BY_LABELHASH = gql` `; export const GET_REGISTRATIONS = gql` - query getRegistration($labelhash: String) { + query getRegistration($tokenId: String) { registrations( orderBy: registrationDate orderDirection: desc - where: { id: $labelhash } + where: { id: $tokenId } ) { labelName registrationDate @@ -59,17 +59,17 @@ export const GET_REGISTRATIONS = gql` `; export const GET_WRAPPED_DOMAIN = gql` -query getWrappedDomain($tokenId: String) { - wrappedDomain(id: $tokenId) { - id - owner { + query getWrappedDomain($tokenId: String) { + wrappedDomain(id: $tokenId) { id - } - fuses - expiryDate - domain { - name + owner { + id + } + fuses + expiryDate + domain { + name + } } } -} `; diff --git a/src/utils/batchQuery.test.ts b/src/utils/batchQuery.test.ts new file mode 100644 index 0000000..c169d41 --- /dev/null +++ b/src/utils/batchQuery.test.ts @@ -0,0 +1,29 @@ +import avaTest, { ExecutionContext, TestFn } from 'ava'; +import { gql } from 'graphql-request'; +import { TestContext } from '../../mock/interface'; +import { createBatchQuery } from './batchQuery'; + +const test = avaTest as TestFn; + +test('should retrieve letter character set for nick.eth', (t: ExecutionContext) => { + const query1 = gql` + query query1($id: String) { + domain(id: $id) { + name + } + } + `; + const query2 = gql` + query query2($name: String) { + registry(name: $name) { + id + } + } + `; + const batchedQuery = createBatchQuery('combinedQuery'); + batchedQuery.add(query1).add(query2); + t.deepEqual( + batchedQuery.query(), + 'query combinedQuery($id:String, $name:String) { domain(id: $id) { name },registry(name: $name) { id } }' + ); +}); diff --git a/src/utils/batchQuery.ts b/src/utils/batchQuery.ts new file mode 100644 index 0000000..3d5837b --- /dev/null +++ b/src/utils/batchQuery.ts @@ -0,0 +1,58 @@ +import { + DocumentNode, + OperationDefinitionNode, + VariableDefinitionNode, + parse, + print, +} from 'graphql'; +import { gql } from 'graphql-request'; + +class BatchedQuery { + documentNodes: DocumentNode[] = []; + queryName: string = ''; + constructor(queryName: string) { + this.queryName = queryName; + } + + add(document: string) { + if (!document) throw Error('Parameters cannot be empty.'); + const documentNode: DocumentNode = parse(document); + this.documentNodes.push(documentNode); + return this; + } + + _genNodes() { + const variables = new Set(); + const documentNodes: string[] = []; + this.documentNodes.forEach((documentNode: DocumentNode) => { + const vars = ( + documentNode.definitions[0] as OperationDefinitionNode + ).variableDefinitions + ?.map( + (def: VariableDefinitionNode) => + `$${def.variable.name.value}:${(def.type as any).name.value}` + ) + .toString(); + variables.add(vars); + const node = print(documentNode) + .replace(/query.*\{/, '') + .slice(0, -1) + .trim(); + documentNodes.push(node); + }); + return [[...variables].join(', '), documentNodes]; + } + + query() { + const [variables, documentNodes] = this._genNodes(); + return gql` + query ${this.queryName}(${variables}) { + ${documentNodes} + } + `.replace(/\n/g, '').replace(/\s\s+/g, ' ').trim(); + } +} + +export function createBatchQuery(queryName: string) { + return new BatchedQuery(queryName); +}