From 05c014b17fa57ec02931dd8e459dfd2356f25ff7 Mon Sep 17 00:00:00 2001 From: jipstavenuiter Date: Mon, 25 Mar 2024 18:43:42 -0600 Subject: [PATCH] (feat): add support for querying from multiple chains at the same time --- pnpm-lock.yaml | 17 +-- sdk/package.json | 5 +- sdk/src/constants.ts | 7 +- sdk/src/indexer.ts | 198 ++++++++++++++++++++++----------- sdk/src/indexer/gql/graphql.ts | 2 + sdk/src/types/client.ts | 9 ++ sdk/src/types/indexer.ts | 5 +- sdk/test/utils/config.test.ts | 2 +- 8 files changed, 170 insertions(+), 75 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17a77b2f..679811f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -574,8 +574,8 @@ importers: specifier: ^1.0.5 version: 1.0.5 '@urql/core': - specifier: ^4.2.0 - version: 4.2.0(graphql@16.8.1) + specifier: ^4.3.0 + version: 4.3.0(graphql@16.8.1) '@whatwg-node/fetch': specifier: ^0.9.13 version: 0.9.14 @@ -603,6 +603,9 @@ importers: viem: specifier: ^1.21.4 version: 1.21.4(typescript@5.3.2) + wonka: + specifier: ^6.3.4 + version: 6.3.4 devDependencies: '@babel/core': specifier: ^7.23.5 @@ -7356,7 +7359,7 @@ packages: '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1) '@hypercerts-org/contracts': 1.1.2(typescript@5.1.6) '@openzeppelin/merkle-tree': 1.0.5 - '@urql/core': 4.2.0(graphql@16.8.1) + '@urql/core': 4.3.0(graphql@16.8.1) '@whatwg-node/fetch': 0.9.14 ajv: 8.12.0 axios: 1.6.2(debug@4.3.4) @@ -12319,8 +12322,8 @@ packages: /@ungap/structured-clone@1.2.0: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - /@urql/core@4.2.0(graphql@16.8.1): - resolution: {integrity: sha512-GRkZ4kECR9UohWAjiSk2UYUetco6/PqSrvyC4AH6g16tyqEShA63M232cfbE1J9XJPaGNjia14Gi+oOqzp144w==} + /@urql/core@4.3.0(graphql@16.8.1): + resolution: {integrity: sha512-wT+FeL8DG4x5o6RfHEnONNFVDM3616ouzATMYUClB6CB+iIu2mwfBKd7xSUxYOZmwtxna5/hDRQdMl3nbQZlnw==} dependencies: '@0no-co/graphql.web': 1.0.4(graphql@16.8.1) wonka: 6.3.4 @@ -27790,7 +27793,7 @@ packages: /puppeteer@18.2.1: resolution: {integrity: sha512-7+UhmYa7wxPh2oMRwA++k8UGVDxh3YdWFB52r9C3tM81T6BU7cuusUSxImz0GEYSOYUKk/YzIhkQ6+vc0gHbxQ==} engines: {node: '>=14.1.0'} - deprecated: < 21.5.0 is no longer supported + deprecated: < 21.8.0 is no longer supported requiresBuild: true dependencies: https-proxy-agent: 5.0.1 @@ -32033,7 +32036,7 @@ packages: peerDependencies: react: '>= 16.8.0' dependencies: - '@urql/core': 4.2.0(graphql@16.8.1) + '@urql/core': 4.3.0(graphql@16.8.1) react: 18.2.0 wonka: 6.3.4 transitivePeerDependencies: diff --git a/sdk/package.json b/sdk/package.json index 3ac4d84b..f5b6b93c 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -26,7 +26,7 @@ "@graphql-typed-document-node/core": "^3.2.0", "@hypercerts-org/contracts": "1.1.2", "@openzeppelin/merkle-tree": "^1.0.5", - "@urql/core": "^4.2.0", + "@urql/core": "^4.3.0", "@whatwg-node/fetch": "^0.9.13", "ajv": "^8.11.2", "axios": "^1.6.2", @@ -35,7 +35,8 @@ "graphql": "^16.8.1", "loglevel": "^1.8.1", "urql": "^4.0.6", - "viem": "^1.21.4" + "viem": "^1.21.4", + "wonka": "^6.3.4" }, "devDependencies": { "@babel/core": "^7.23.5", diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts index b6956c20..85582795 100644 --- a/sdk/src/constants.ts +++ b/sdk/src/constants.ts @@ -5,7 +5,7 @@ import { Deployment, SupportedChainIds } from "./types"; import { deployments } from "@hypercerts-org/contracts"; -const DEFAULT_GRAPH_BASE_URL = "https://api.thegraph.com/subgraphs/name/hypercerts-admin"; +const DEFAULT_GRAPH_BASE_URL = "https://api.thegraph.com/subgraphs/name/hypercerts-org"; // The APIs we expose @@ -20,26 +20,31 @@ const DEPLOYMENTS: { [key in SupportedChainIds]: Partial } = { addresses: deployments[10], graphName: "hypercerts-optimism-mainnet", graphUrl: `${DEFAULT_GRAPH_BASE_URL}/hypercerts-optimism-mainnet`, + isTestnet: false, } as const, 42220: { addresses: deployments[42220], graphName: "hypercerts-celo", graphUrl: `${DEFAULT_GRAPH_BASE_URL}/hypercerts-celo`, + isTestnet: false, }, 11155111: { addresses: deployments[11155111], graphName: "hypercerts-sepolia", graphUrl: `${DEFAULT_GRAPH_BASE_URL}/hypercerts-sepolia`, + isTestnet: true, } as const, 84532: { addresses: deployments[84532], graphName: "hypercerts-base-sepolia", graphUrl: `${DEFAULT_GRAPH_BASE_URL}/hypercerts-base-sepolia`, + isTestnet: true, } as const, 8453: { addresses: deployments[8453], graphName: "hypercerts-base-mainnet", graphUrl: `${DEFAULT_GRAPH_BASE_URL}/hypercerts-base-mainnet`, + isTestnet: false, } as const, }; diff --git a/sdk/src/indexer.ts b/sdk/src/indexer.ts index 7abaeb53..d143799b 100644 --- a/sdk/src/indexer.ts +++ b/sdk/src/indexer.ts @@ -1,37 +1,46 @@ import { logger } from "./utils"; import { defaultQueryParams } from "./indexer/utils"; -import { HypercertClientConfig, HypercertIndexerInterface, QueryParams } from "./types"; -import { Client, cacheExchange, fetchExchange } from "@urql/core"; import { - ClaimsByOwnerDocument, - ClaimsByOwnerQueryVariables, + HypercertClientConfig, + HypercertIndexerInterface, + IndexerEnvironment, + QueryParams, + QueryParamsWithChainId, +} from "./types"; + +import { AnyVariables, cacheExchange, Client, fetchExchange } from "@urql/core"; +import { ClaimByIdDocument, ClaimByIdQueryVariables, - RecentClaimsDocument, - RecentClaimsQueryVariables, - ClaimTokensByOwnerDocument, - ClaimTokensByOwnerQueryVariables, - ClaimTokensByClaimDocument, - ClaimTokensByClaimQueryVariables, + ClaimsByOwnerDocument, + ClaimsByOwnerQueryVariables, ClaimTokenByIdDocument, ClaimTokenByIdQueryVariables, + ClaimTokensByClaimDocument, + ClaimTokensByClaimQueryVariables, + ClaimTokensByOwnerDocument, + ClaimTokensByOwnerQueryVariables, + RecentClaimsDocument, + RecentClaimsQueryVariables, } from "./indexer/gql/graphql"; +import { DEPLOYMENTS } from "./constants"; +import { TypedDocumentNode } from "@graphql-typed-document-node/core"; + /** * A class that provides indexing functionality for Hypercerts. * * This class implements the `HypercertIndexerInterface` and provides methods for retrieving claims by owner and by ID. It uses the Graph client for indexing. * Because of the autogenerated Graph client packed with the SDK, this class is not recommended for custom Graph deployments. * - * @property {GraphClient} _graphClient - The Graph client used by the indexer. - * * @example * const indexer = new HypercertIndexer({ graphUrl: 'your-graph-url', graphName: 'your-graph-name' }); * const claims = await indexer.claimsByOwner('your-address'); */ export class HypercertIndexer implements HypercertIndexerInterface { /** The Graph client used by the indexer. */ - private _graphName?: string; - private _graphUrl: string; + private environment: IndexerEnvironment; + + private graphClients: Map; /** * Creates a new instance of the `HypercertIndexer` class. @@ -40,19 +49,77 @@ export class HypercertIndexer implements HypercertIndexerInterface { constructor(options: Partial) { logger.info("Creating HypercertIndexer", "constructor", { name: options.graphName, url: options.graphUrl }); if (!options.graphUrl) throw new Error("Missing graphUrl"); - this._graphName = options.graphName; - this._graphUrl = options.graphUrl; + this.environment = options.indexerEnvironment || "test"; + + const environments = HypercertIndexer.getDeploymentsForEnvironment(this.environment); + + this.graphClients = new Map(); + for (const [chainId, deployment] of environments) { + if (!deployment.graphUrl) { + console.log(`Missing graphUrl for chain ${chainId}`); + continue; + } + this.graphClients.set( + parseInt(chainId), + new Client({ + url: deployment.graphUrl, + exchanges: [cacheExchange, fetchExchange], + }), + ); + } + } + + static getDeploymentsForEnvironment(environment: IndexerEnvironment) { + return Object.entries(DEPLOYMENTS).filter(([_, deployment]) => { + if (environment === "test") { + return deployment.isTestnet; + } + + if (environment === "production") { + return !deployment.isTestnet; + } + + return true; + }); } + performQuery = async ( + query: TypedDocumentNode, + variables: Variables, + chainId?: number, + ) => { + const chains = chainId ? [chainId] : Array.from(this.graphClients.keys()); + return await Promise.all( + chains.map(async (c) => { + const client = this.graphClients.get(c); + if (!client) { + throw new Error(`No client found for chain ${chainId}`); + } + + return client + .query(query, variables) + .toPromise() + .then((res) => { + if (res.error) { + throw res.error; + } + + return res.data; + }); + }), + ); + }; + /** * Gets the Graph client used by the indexer. * @returns The Graph client. */ - get graphClient(): Client { - return new Client({ - url: this._graphUrl, - exchanges: [cacheExchange, fetchExchange], - }); + getGraphClient(chainId: number): Client { + const client = this.graphClients.get(chainId); + if (!client) { + throw new Error(`No client found for chain ${chainId}`); + } + return client; } /** @@ -61,57 +128,51 @@ export class HypercertIndexer implements HypercertIndexerInterface { * @param params The query parameters. * @returns A Promise that resolves to the claims. */ - claimsByOwner = async (owner: string, params: QueryParams = defaultQueryParams) => { + claimsByOwner = async (owner: string, { chainId, ...params }: QueryParamsWithChainId = defaultQueryParams) => { const query = ClaimsByOwnerDocument; const variables: ClaimsByOwnerQueryVariables = { owner, ...params, }; - const result = await this.graphClient.query(query, variables); - - if (result.error) { - throw result.error; - } - - return result.data; + const results = await this.performQuery(query, variables, chainId); + const claims = results.flatMap((result) => result?.claims || []); + return { + claims, + }; }; /** * Gets a claim by its ID. - * @param id The ID of the claim. + * @param claimId The ID of the claim. * @returns A Promise that resolves to the claim. */ - claimById = async (id: string) => { + claimById = async (claimId: string) => { const query = ClaimByIdDocument; + const { chainId } = this.parseClaimId(claimId); const variables: ClaimByIdQueryVariables = { - id, + id: claimId, }; - const result = await this.graphClient.query(query, variables); - - if (result.error) { - throw result.error; - } + const results = await this.performQuery(query, variables, chainId); - return result.data; + return results[0]; }; /** * Gets the most recent claims. * @param params The query parameters. * @returns A Promise that resolves to the claims. */ - firstClaims = async (params: QueryParams = defaultQueryParams) => { + firstClaims = async ({ chainId, ...params }: QueryParamsWithChainId = defaultQueryParams) => { const query = RecentClaimsDocument; const variables: RecentClaimsQueryVariables = { ...params, }; - const result = await this.graphClient.query(query, variables); - - if (result.error) { - throw result.error; - } - return result.data; + const results = await this.performQuery(query, variables, chainId); + const claims = results.flatMap((result) => result?.claims || []); + return { + claims, + }; }; /** @@ -120,19 +181,18 @@ export class HypercertIndexer implements HypercertIndexerInterface { * @param params The query parameters. * @returns A Promise that resolves to the claim tokens. */ - fractionsByOwner = async (owner: string, params: QueryParams = defaultQueryParams) => { + fractionsByOwner = async (owner: string, { chainId, ...params }: QueryParamsWithChainId = defaultQueryParams) => { const query = ClaimTokensByOwnerDocument; const variables: ClaimTokensByOwnerQueryVariables = { owner, ...params, }; - const result = await this.graphClient.query(query, variables); - - if (result.error) { - throw result.error; - } - return result.data; + const results = await this.performQuery(query, variables, chainId); + const claimTokens = results.flatMap((result) => result?.claimTokens || []); + return { + claimTokens, + }; }; /** @@ -143,17 +203,14 @@ export class HypercertIndexer implements HypercertIndexerInterface { */ fractionsByClaim = async (claimId: string, params: QueryParams = defaultQueryParams) => { const query = ClaimTokensByClaimDocument; + const { chainId } = this.parseClaimId(claimId); const variables: ClaimTokensByClaimQueryVariables = { claimId, ...params, }; - const result = await this.graphClient.query(query, variables); - if (result.error) { - throw result.error; - } - - return result.data; + const results = await this.performQuery(query, variables, chainId); + return results[0]; }; /** @@ -163,15 +220,30 @@ export class HypercertIndexer implements HypercertIndexerInterface { */ fractionById = async (fractionId: string) => { const query = ClaimTokenByIdDocument; + const { chainId } = this.parseClaimId(fractionId); + const variables: ClaimTokenByIdQueryVariables = { claimTokenId: fractionId, }; - const result = await this.graphClient.query(query, variables); - if (result.error) { - throw result.error; + const results = await this.performQuery(query, variables, chainId); + return results[0]; + }; + + private parseClaimId(claimId: string) { + const [chainId, contractAddress, tokenId] = claimId.split("-"); + + if (!chainId || !contractAddress || !tokenId) { + console.log("Invalid claimId format. Expected 'chainId-contractAddress-tokenId'"); + throw new Error(`Invalid claimId format (claimId given: ${claimId}}. Expected "chainId-contractAddress-tokenId"`); } - return result.data; - }; + const chainIdInt = parseInt(chainId, 10); + const tokenIdBigInt = BigInt(tokenId); + return { + chainId: chainIdInt, + contractAddress, + tokenId: tokenIdBigInt, + }; + } } diff --git a/sdk/src/indexer/gql/graphql.ts b/sdk/src/indexer/gql/graphql.ts index 16905658..071b0a89 100644 --- a/sdk/src/indexer/gql/graphql.ts +++ b/sdk/src/indexer/gql/graphql.ts @@ -1085,6 +1085,8 @@ export type _Block_ = { hash?: Maybe; /** The block number */ number: Scalars["Int"]["output"]; + /** The hash of the parent block */ + parentHash?: Maybe; /** Integer representation of the timestamp stored in blocks for the chain */ timestamp?: Maybe; }; diff --git a/sdk/src/types/client.ts b/sdk/src/types/client.ts index fab055bc..0925f18d 100644 --- a/sdk/src/types/client.ts +++ b/sdk/src/types/client.ts @@ -63,6 +63,7 @@ export type Deployment = { /** The url to the subgraph that indexes the contract events. Override for localized testing */ graphUrl: string; graphName: string; + isTestnet: boolean; }; /** @@ -80,8 +81,16 @@ export type HypercertClientConfig = Deployment & readOnly: boolean; /** Reason for readOnly mode */ readOnlyReason?: string; + /** The environment to run the indexer in. This can be either production or test. */ + indexerEnvironment: IndexerEnvironment; }; +/** + * The environment to run the indexer in. + * Production will run against all mainnet chains, while test will run against testnet chains. + */ +export type IndexerEnvironment = "production" | "test"; + /** * Configuration options for the Hypercert storage layer. * @note The API tokens are optional, but required for storing data on NFT.storage and Web3.storage. diff --git a/sdk/src/types/indexer.ts b/sdk/src/types/indexer.ts index 9417e95d..2374e377 100644 --- a/sdk/src/types/indexer.ts +++ b/sdk/src/types/indexer.ts @@ -7,6 +7,7 @@ import { ClaimTokensByClaimQuery, ClaimTokenByIdQuery, } from "../indexer/gql/graphql"; + export type QueryParams = { orderDirections: "asc" | "desc"; skip: number; @@ -14,8 +15,10 @@ export type QueryParams = { [key: string]: string | number | undefined; }; +export type QueryParamsWithChainId = QueryParams & { chainId?: number }; + export interface HypercertIndexerInterface { - graphClient: Client; + getGraphClient(chainId: number): Client; claimsByOwner: (owner: string, params?: QueryParams) => Promise; claimById: (id: string) => Promise; firstClaims: (params?: QueryParams) => Promise; diff --git a/sdk/test/utils/config.test.ts b/sdk/test/utils/config.test.ts index b1af9e36..33f75089 100644 --- a/sdk/test/utils/config.test.ts +++ b/sdk/test/utils/config.test.ts @@ -18,7 +18,7 @@ describe("Config: graphUrl", () => { it("should return the default graphUrl when no overrides are specified", () => { const result = getConfig({ chain: { id: 11155111 } }); - expect(result.graphUrl).to.equal("https://api.thegraph.com/subgraphs/name/hypercerts-admin/hypercerts-sepolia"); + expect(result.graphUrl).to.equal("https://api.thegraph.com/subgraphs/name/hypercerts-org/hypercerts-sepolia"); }); it("should return the config specified by overrides", () => {