diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e0845ed5..4454ae42 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -123,9 +123,10 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} # Adds a coverage summary comment to the PR. - name: Verify minimum coverage - uses: zgosalvez/github-actions-report-lcov@v2 + uses: zgosalvez/github-actions-report-lcov@v7 with: coverage-files: ./lcov.info + genhtml-ignore-errors: inconsistent minimum-coverage: 70 # Set coverage threshold. lint: diff --git a/frontend/.env.example b/frontend/.env.example index 9b9e7833..d5d6973d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -15,12 +15,12 @@ SEPOLIA_RPC_URL=yourSepoliaRpcUrl BASE_RPC_URL=yourBaseRpcUrl WALLET_CONNECT_PROJECT_ID=yourId -MAINNET_SUBGRAPH_URL= -OPTIMISM_SUBGRAPH_URL= -POLYGON_SUBGRAPH_URL= -BASE_SUBGRAPH_URL= -ARBITRUM_ONE_SUBGRAPH_URL= -SEPOLIA_SUBGRAPH_URL= +MAINNET_SUBGRAPH_URL=yourMainnetSubgraphUrl +OPTIMISM_SUBGRAPH_URL=yourOptimismSubgraphUrl +POLYGON_SUBGRAPH_URL=yourPolygonSubgraphUrl +BASE_SUBGRAPH_URL=yourBaseSubgraphUrl +ARBITRUM_ONE_SUBGRAPH_URL=yourArbitrumOneSubgraphUrl +SEPOLIA_SUBGRAPH_URL=yourSepoliaSubgraphUrl LOG_LEVEL=DEBUG MAINTENANCE_MODE_SEND=0 diff --git a/frontend/src/store/wallet.ts b/frontend/src/store/wallet.ts index cb35e55c..4c28b34e 100644 --- a/frontend/src/store/wallet.ts +++ b/frontend/src/store/wallet.ts @@ -18,7 +18,13 @@ import { TokenInfoExtended, } from 'components/models'; import { formatNameOrAddress, lookupEnsName, lookupCnsName } from 'src/utils/address'; -import { ERC20_ABI, MAINNET_PROVIDER, MULTICALL_ABI, MULTICALL_ADDRESS } from 'src/utils/constants'; +import { + ERC20_ABI, + MAINNET_PROVIDER, + MULTICALL_ABI, + MULTICALL_ADDRESS, + getUmbraChainConfig, +} from 'src/utils/constants'; import { BigNumber, Contract, ExternalProvider, Web3Provider, parseUnits } from 'src/utils/ethers'; import { UmbraApi } from 'src/utils/umbra-api'; import { getChainById } from 'src/utils/utils'; @@ -307,7 +313,7 @@ export default function useWalletStore() { // - https://github.com/vuejs/vue-next/issues/3024 // - https://stackoverflow.com/questions/65693108/threejs-component-working-in-vuejs-2-but-not-3 // - https://vuejs.org/api/reactivity-advanced.html#markraw - umbra.value = markRaw(new Umbra(provider.value, newChainId)); + umbra.value = markRaw(new Umbra(provider.value, getUmbraChainConfig(newChainId))); stealthKeyRegistry.value = markRaw(new StealthKeyRegistry(signer.value)); // Setup to check if user is connected with Argent, since we need to handle a few things differently in that case. diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 718fe824..fd2797ab 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -1,3 +1,4 @@ +import type { ChainConfig } from '@umbracash/umbra-js'; import { StaticJsonRpcProvider } from 'src/utils/ethers'; export const MAINNET_RPC_URL = String(process.env.MAINNET_RPC_URL); @@ -8,9 +9,71 @@ export const OPTIMISM_RPC_URL = String(process.env.OPTIMISM_RPC_URL); export const ARBITRUM_ONE_RPC_URL = String(process.env.ARBITRUM_ONE_RPC_URL); export const SEPOLIA_RPC_URL = String(process.env.SEPOLIA_RPC_URL); export const BASE_RPC_URL = String(process.env.BASE_RPC_URL); +export const MAINNET_SUBGRAPH_URL = String(process.env.MAINNET_SUBGRAPH_URL || ''); +export const OPTIMISM_SUBGRAPH_URL = String(process.env.OPTIMISM_SUBGRAPH_URL || ''); +export const POLYGON_SUBGRAPH_URL = String(process.env.POLYGON_SUBGRAPH_URL || ''); +export const BASE_SUBGRAPH_URL = String(process.env.BASE_SUBGRAPH_URL || ''); +export const ARBITRUM_ONE_SUBGRAPH_URL = String(process.env.ARBITRUM_ONE_SUBGRAPH_URL || ''); +export const SEPOLIA_SUBGRAPH_URL = String(process.env.SEPOLIA_SUBGRAPH_URL || ''); console.log(`MAINNET_RPC_URL ${MAINNET_RPC_URL}`); +const UMBRA_ADDRESS = '0xFb2dc580Eed955B528407b4d36FfaFe3da685401'; +const BATCH_SEND_ADDRESS = '0xDbD0f5EBAdA6632Dde7d47713ea200a7C2ff91EB'; + +const toOptionalSubgraphUrl = (value: string): string | false => { + return value.length > 0 ? value : false; +}; + +const umbraChainConfigs: Record = { + 1: { + chainId: 1, + umbraAddress: UMBRA_ADDRESS, + batchSendAddress: BATCH_SEND_ADDRESS, + startBlock: 12343914, + subgraphUrl: toOptionalSubgraphUrl(MAINNET_SUBGRAPH_URL), + }, + 10: { + chainId: 10, + umbraAddress: UMBRA_ADDRESS, + batchSendAddress: BATCH_SEND_ADDRESS, + startBlock: 4069556, + subgraphUrl: toOptionalSubgraphUrl(OPTIMISM_SUBGRAPH_URL), + }, + 137: { + chainId: 137, + umbraAddress: UMBRA_ADDRESS, + batchSendAddress: BATCH_SEND_ADDRESS, + startBlock: 20717318, + subgraphUrl: toOptionalSubgraphUrl(POLYGON_SUBGRAPH_URL), + }, + 8453: { + chainId: 8453, + umbraAddress: UMBRA_ADDRESS, + batchSendAddress: BATCH_SEND_ADDRESS, + startBlock: 10761374, + subgraphUrl: toOptionalSubgraphUrl(BASE_SUBGRAPH_URL), + }, + 42161: { + chainId: 42161, + umbraAddress: UMBRA_ADDRESS, + batchSendAddress: BATCH_SEND_ADDRESS, + startBlock: 7285883, + subgraphUrl: toOptionalSubgraphUrl(ARBITRUM_ONE_SUBGRAPH_URL), + }, + 11155111: { + chainId: 11155111, + umbraAddress: UMBRA_ADDRESS, + batchSendAddress: BATCH_SEND_ADDRESS, + startBlock: 3590825, + subgraphUrl: toOptionalSubgraphUrl(SEPOLIA_SUBGRAPH_URL), + }, +}; + +export const getUmbraChainConfig = (chainId: number): ChainConfig | number => { + return umbraChainConfigs[chainId] || chainId; +}; + export const ETH_NETWORK_LOGO = 'data:image/png;base64,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'; // prettier-ignore export const ERC20_ABI = [ diff --git a/umbra-js/.env.example b/umbra-js/.env.example index 1bc8a19c..01095da4 100644 --- a/umbra-js/.env.example +++ b/umbra-js/.env.example @@ -8,3 +8,9 @@ POLYGON_RPC_URL=yourPolygonRpcUrl ARBITRUM_ONE_RPC_URL=yourArbitrumOneRpcUrl SEPOLIA_RPC_URL=yourSepoliaRpcUrl BASE_RPC_URL=yourBaseRpcUrl +MAINNET_SUBGRAPH_URL=yourMainnetSubgraphUrl +OPTIMISM_SUBGRAPH_URL=yourOptimismSubgraphUrl +POLYGON_SUBGRAPH_URL=yourPolygonSubgraphUrl +BASE_SUBGRAPH_URL=yourBaseSubgraphUrl +ARBITRUM_ONE_SUBGRAPH_URL=yourArbitrumOneSubgraphUrl +SEPOLIA_SUBGRAPH_URL=yourSepoliaSubgraphUrl diff --git a/umbra-js/src/classes/Umbra.ts b/umbra-js/src/classes/Umbra.ts index 0cd71187..d2254283 100644 --- a/umbra-js/src/classes/Umbra.ts +++ b/umbra-js/src/classes/Umbra.ts @@ -33,7 +33,7 @@ import { getBlockNumberUserRegistered, assertSupportedAddress, checkSupportedAddresses, - recursiveGraphFetch, + recursiveCompatibleGraphFetch, } from '../utils/utils'; import { Umbra as UmbraContract, Umbra__factory, ERC20__factory } from '../typechain'; import { ETH_ADDRESS, UMBRA_BATCH_SEND_ABI } from '../utils/constants'; @@ -43,15 +43,33 @@ import { compressedPublicKeyFromX } from '../utils/sharedSecret'; // Mapping from chainId to contract information const umbraAddress = '0xFb2dc580Eed955B528407b4d36FfaFe3da685401'; // same on all supported networks const batchSendAddress = '0xDbD0f5EBAdA6632Dde7d47713ea200a7C2ff91EB'; // same on all supported networks -const subgraphs = { - 1: String(process.env.MAINNET_SUBGRAPH_URL), - 10: String(process.env.OPTIMISM_SUBGRAPH_URL), - 137: String(process.env.POLYGON_SUBGRAPH_URL), - 8453: String(process.env.BASE_SUBGRAPH_URL), - 42161: String(process.env.ARBITRUM_ONE_SUBGRAPH_URL), - 11155111: String(process.env.SEPOLIA_SUBGRAPH_URL), +const getOptionalEnv = (name: string): string | false => { + const value = process.env[name]; + return value && value.length > 0 ? value : false; }; +const subgraphs: Record = { + 1: getOptionalEnv('MAINNET_SUBGRAPH_URL'), + 10: getOptionalEnv('OPTIMISM_SUBGRAPH_URL'), + 137: getOptionalEnv('POLYGON_SUBGRAPH_URL'), + 8453: getOptionalEnv('BASE_SUBGRAPH_URL'), + 42161: getOptionalEnv('ARBITRUM_ONE_SUBGRAPH_URL'), + 11155111: getOptionalEnv('SEPOLIA_SUBGRAPH_URL'), +}; + +const normalizePonderAnnouncement = (item: Record): SubgraphAnnouncement => ({ + amount: String(item.amount), + block: String(item.blockNumber), + ciphertext: String(item.ciphertext), + from: String(item.from), + id: String(item.id), + pkx: String(item.pkx), + receiver: String(item.receiver), + timestamp: String(item.timestamp), + token: String(item.token), + txHash: String(item.txHash), +}); + const chainConfigs: Record = { 1: { chainId: 1, umbraAddress, batchSendAddress, startBlock: 12343914, subgraphUrl: subgraphs[1] }, // Mainnet 10: { chainId: 10, umbraAddress, batchSendAddress, startBlock: 4069556, subgraphUrl: subgraphs[10] }, // Optimism @@ -433,10 +451,25 @@ export class Umbra { yield await filterSupportedAddresses(announcements); } } catch (err) { + console.error('Umbra subgraph announcement fetch failed; falling back to RPC logs.', { + chainId: this.chainConfig.chainId, + subgraphUrl: this.chainConfig.subgraphUrl, + startBlock, + endBlock, + error: err instanceof Error ? err.message : String(err), + }); + if (err instanceof Error && err.stack) { + console.error(err.stack); + } const announcements = await this.fetchAllAnnouncementFromLogs(startBlock, endBlock); yield await filterSupportedAddresses(announcements); } } else { + console.warn('Umbra subgraph URL is not configured; falling back to RPC logs.', { + chainId: this.chainConfig.chainId, + startBlock, + endBlock, + }); const announcements = await this.fetchAllAnnouncementFromLogs(startBlock, endBlock); yield await filterSupportedAddresses(announcements); } @@ -481,29 +514,50 @@ export class Umbra { startBlock: string | number, endBlock: string | number ): AsyncGenerator { - if (!this.chainConfig.subgraphUrl) { + const subgraphUrl = this.chainConfig.subgraphUrl; + if (!subgraphUrl) { throw new Error('Subgraph URL must be defined to fetch via subgraph'); } - // Query subgraph - for await (const subgraphAnnouncements of recursiveGraphFetch( - this.chainConfig.subgraphUrl, - 'announcementEntities', - (filter: string) => `{ - announcementEntities(${filter}) { - amount - block - ciphertext - from - id - pkx - receiver - timestamp - token - txHash - } - }`, - [], + for await (const subgraphAnnouncements of recursiveCompatibleGraphFetch( + subgraphUrl, + { + chainId: this.chainConfig.chainId, + legacy: { + key: 'announcementEntities', + query: (filter: string) => `{ + announcementEntities(${filter}) { + amount + block + ciphertext + from + id + pkx + receiver + timestamp + token + txHash + } + }`, + }, + ponder: { + key: 'announcements', + orderBy: 'blockNumber', + selection: ` + amount + blockNumber + ciphertext + from + id + pkx + receiver + timestamp + token + txHash + `, + normalize: normalizePonderAnnouncement, + }, + }, { startBlock, endBlock, diff --git a/umbra-js/src/utils/utils.ts b/umbra-js/src/utils/utils.ts index 727b237d..5eacb9ab 100644 --- a/umbra-js/src/utils/utils.ts +++ b/umbra-js/src/utils/utils.ts @@ -52,6 +52,49 @@ export const invalidStealthAddresses = [ '0x59274E3aE531285c24e3cf57C11771ecBf72d9bf', // generated from hashing the zero public key, e.g. keccak256('0x000...000') ].map(getAddress); +type SubgraphSchemaKind = 'legacy' | 'ponder'; +type PonderNetworkName = 'mainnet' | 'optimism' | 'polygon' | 'base' | 'arbitrumOne' | 'sepolia'; + +type GraphQlPayload = { + data?: T; + errors?: Array<{ message: string }>; +}; + +type PonderPage = { + items: T[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; +}; + +type PonderQueryConfig = { + key: string; + orderBy: string; + selection: string; + normalize: (item: Record) => T; +}; + +type CompatibleSubgraphFetchConfig = { + chainId: number; + legacy: { + key: string; + query: (filter: string) => string; + }; + ponder: PonderQueryConfig; +}; + +const ponderNetworksByChainId: Record = { + 1: 'mainnet', + 10: 'optimism', + 137: 'polygon', + 8453: 'base', + 42161: 'arbitrumOne', + 11155111: 'sepolia', +}; + +const subgraphSchemaKinds = new Map(); + /** * @notice Given a transaction hash, return the public key of the transaction's sender * @dev See https://github.com/ethers-io/ethers.js/issues/700 for an example of @@ -368,6 +411,140 @@ export async function getMostRecentSubgraphStealthKeyChangedEventFromAddress( return theEvent; } +export function getPonderNetworkName(chainId: number): PonderNetworkName | null { + return ponderNetworksByChainId[chainId] || null; +} + +async function graphQlRequest(url: string, query: string): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + throw new Error(`GraphQL request failed with status ${response.status}`); + } + + const payload = (await response.json()) as GraphQlPayload; + if (payload.errors?.length) { + throw new Error(payload.errors.map((error) => error.message).join('; ')); + } + if (!payload.data) { + throw new Error('GraphQL request returned no data'); + } + + return payload.data; +} + +function isUnsupportedPonderQueryError(error: unknown, field: string) { + const message = error instanceof Error ? error.message : String(error); + return message.includes(`Cannot query field "${field}"`); +} + +async function* recursivePonderGraphFetch( + url: string, + network: PonderNetworkName, + config: PonderQueryConfig, + overrides?: GraphFilterOverride +): AsyncGenerator { + const startBlock = overrides?.startBlock ? overrides.startBlock.toString() : ''; + const endBlock = overrides?.endBlock ? overrides.endBlock.toString() : ''; + const registrant = overrides?.registrant ? overrides.registrant.toLowerCase() : ''; + let after = ''; + + while (true) { + const afterFilter = after ? `after: "${after}"` : ''; + const startBlockFilter = startBlock ? `blockNumber_gte: "${startBlock}"` : ''; + const endBlockFilter = endBlock && endBlock !== 'latest' ? `blockNumber_lte: "${endBlock}"` : ''; + const registrantFilter = registrant ? `registrant: "${registrant}"` : ''; + const data = await graphQlRequest>>>( + url, + `{ + ${config.key}( + where: { + network: "${network}" + ${startBlockFilter} + ${endBlockFilter} + ${registrantFilter} + } + orderBy: "${config.orderBy}" + orderDirection: "desc" + limit: 1000 + ${afterFilter} + ) { + items { + ${config.selection} + } + pageInfo { + hasNextPage + endCursor + } + } + }` + ); + + const page = data[config.key]; + if (!page) { + throw new Error(`Missing Ponder field in response: ${config.key}`); + } + + yield page.items.map(config.normalize); + + if (!page.pageInfo.hasNextPage) { + return; + } + + if (!page.pageInfo.endCursor) { + throw new Error(`Missing Ponder end cursor for field ${config.key}`); + } + + after = page.pageInfo.endCursor; + } +} + +export async function* recursiveCompatibleGraphFetch( + url: string, + config: CompatibleSubgraphFetchConfig, + overrides?: GraphFilterOverride +): AsyncGenerator { + const ponderNetwork = getPonderNetworkName(config.chainId); + const cachedSchemaKind = subgraphSchemaKinds.get(url); + + if (!ponderNetwork || cachedSchemaKind === 'legacy') { + yield* recursiveGraphFetch(url, config.legacy.key, config.legacy.query, [], overrides); + return; + } + + if (cachedSchemaKind === 'ponder') { + yield* recursivePonderGraphFetch(url, ponderNetwork, config.ponder, overrides); + return; + } + + try { + let usedPonder = false; + for await (const page of recursivePonderGraphFetch(url, ponderNetwork, config.ponder, overrides)) { + if (!usedPonder) { + subgraphSchemaKinds.set(url, 'ponder'); + usedPonder = true; + } + yield page; + } + + if (!usedPonder) { + subgraphSchemaKinds.set(url, 'ponder'); + } + return; + } catch (error) { + if (!isUnsupportedPonderQueryError(error, config.ponder.key)) { + throw error; + } + + subgraphSchemaKinds.set(url, 'legacy'); + yield* recursiveGraphFetch(url, config.legacy.key, config.legacy.query, [], overrides); + } +} + /** * @notice Fetches all Umbra event logs using a subgraph * @param startBlock Scanning start block @@ -386,25 +563,56 @@ async function* fetchAllStealthKeyChangedEventsForRecipientAddressFromSubgraph( throw new Error('Subgraph URL must be defined to fetch via subgraph'); } - // Query subgraph - for await (const stealthKeyChangedEvents of recursiveGraphFetch( + for await (const stealthKeyChangedEvents of recursiveCompatibleGraphFetch( chainConfig.subgraphUrl, - 'stealthKeyChangedEntities', - (filter: string) => `{ - stealthKeyChangedEntities(${filter}) { - block - from - id - registrant - spendingPubKey - spendingPubKeyPrefix - timestamp - txHash - viewingPubKey - viewingPubKeyPrefix - } - }`, - [], + { + chainId: chainConfig.chainId, + legacy: { + key: 'stealthKeyChangedEntities', + query: (filter: string) => `{ + stealthKeyChangedEntities(${filter}) { + block + from + id + registrant + spendingPubKey + spendingPubKeyPrefix + timestamp + txHash + viewingPubKey + viewingPubKeyPrefix + } + }`, + }, + ponder: { + key: 'stealthKeyChanges', + orderBy: 'blockNumber', + selection: ` + blockNumber + from + id + registrant + spendingPubKey + spendingPubKeyPrefix + timestamp + txHash + viewingPubKey + viewingPubKeyPrefix + `, + normalize: (item: Record) => ({ + block: String(item.blockNumber), + from: String(item.from), + id: String(item.id), + registrant: String(item.registrant), + spendingPubKey: BigNumber.from(String(item.spendingPubKey)), + spendingPubKeyPrefix: BigNumber.from(String(item.spendingPubKeyPrefix)), + timestamp: String(item.timestamp), + txHash: String(item.txHash), + viewingPubKey: BigNumber.from(String(item.viewingPubKey)), + viewingPubKeyPrefix: BigNumber.from(String(item.viewingPubKeyPrefix)), + }), + }, + }, { startBlock, endBlock, diff --git a/umbra-js/test/subgraph-schema.test.ts b/umbra-js/test/subgraph-schema.test.ts new file mode 100644 index 00000000..e3f62dc8 --- /dev/null +++ b/umbra-js/test/subgraph-schema.test.ts @@ -0,0 +1,301 @@ +import { expect } from 'chai'; +import { Umbra } from '../src/classes/Umbra'; +import { getMostRecentSubgraphStealthKeyChangedEventFromAddress } from '../src/utils/utils'; +import type { ChainConfig, SubgraphAnnouncement } from '../src/types'; + +type MockGraphQlPayload = { + data?: Record; + errors?: Array<{ message: string }>; +}; + +const umbraAddress = '0xFb2dc580Eed955B528407b4d36FfaFe3da685401'; + +const makeChainConfig = (subgraphUrl: string, chainId = 1): ChainConfig => ({ + chainId, + umbraAddress, + batchSendAddress: null, + startBlock: 1, + subgraphUrl, +}); + +const makeResponse = (payload: MockGraphQlPayload) => + ({ + ok: true, + json: async () => payload, + } as Response); + +async function collectPages(generator: AsyncGenerator): Promise { + const pages: T[][] = []; + for await (const page of generator) { + pages.push(page); + } + return pages; +} + +describe('Subgraph schema compatibility', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('fetches announcement pages from a Ponder endpoint', async () => { + const url = 'https://ponder-announcements.local/graphql'; + const calls: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { query: string }; + calls.push(body.query); + + if (calls.length === 1) { + return makeResponse({ + data: { + announcements: { + items: [ + { + id: 'announcement-2', + amount: '10', + blockNumber: '200', + ciphertext: '0x02', + from: '0xfrom2', + pkx: '0xpkx2', + receiver: '0xreceiver2', + timestamp: '2000', + token: '0xtoken2', + txHash: '0xtx2', + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor-1', + }, + }, + }, + }); + } + + return makeResponse({ + data: { + announcements: { + items: [ + { + id: 'announcement-1', + amount: '5', + blockNumber: '150', + ciphertext: '0x01', + from: '0xfrom1', + pkx: '0xpkx1', + receiver: '0xreceiver1', + timestamp: '1500', + token: '0xtoken1', + txHash: '0xtx1', + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }); + }) as typeof fetch; + + const fetchAnnouncements = Umbra.prototype.fetchAllAnnouncementsFromSubgraph as ( + this: { chainConfig: ChainConfig }, + startBlock: string | number, + endBlock: string | number + ) => AsyncGenerator; + + const pages = await collectPages(fetchAnnouncements.call({ chainConfig: makeChainConfig(url) }, 100, 200)); + + expect(calls).to.have.lengthOf(2); + expect(calls[0]).to.include('announcements('); + expect(calls[0]).to.include('network: "mainnet"'); + expect(calls[0]).to.include('blockNumber_gte: "100"'); + expect(calls[0]).to.include('blockNumber_lte: "200"'); + expect(calls[0]).to.include('orderBy: "blockNumber"'); + expect(calls[0]).to.include('orderDirection: "desc"'); + expect(calls[0]).to.include('limit: 1000'); + expect(calls[1]).to.include('after: "cursor-1"'); + expect(pages).to.deep.equal([ + [ + { + id: 'announcement-2', + amount: '10', + block: '200', + ciphertext: '0x02', + from: '0xfrom2', + pkx: '0xpkx2', + receiver: '0xreceiver2', + timestamp: '2000', + token: '0xtoken2', + txHash: '0xtx2', + }, + ], + [ + { + id: 'announcement-1', + amount: '5', + block: '150', + ciphertext: '0x01', + from: '0xfrom1', + pkx: '0xpkx1', + receiver: '0xreceiver1', + timestamp: '1500', + token: '0xtoken1', + txHash: '0xtx1', + }, + ], + ]); + }); + + it('falls back to the legacy subgraph schema and caches that choice', async () => { + const url = 'https://legacy-announcements.local/graphql'; + const fetchAnnouncements = Umbra.prototype.fetchAllAnnouncementsFromSubgraph as ( + this: { chainConfig: ChainConfig }, + startBlock: string | number, + endBlock: string | number + ) => AsyncGenerator; + + const firstCallQueries: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { query: string }; + firstCallQueries.push(body.query); + + if (firstCallQueries.length === 1) { + return makeResponse({ + errors: [{ message: 'Cannot query field "announcements" on type "Query".' }], + }); + } + + if (body.query.includes('id_lt:')) { + return makeResponse({ + data: { + announcementEntities: [], + }, + }); + } + + return makeResponse({ + data: { + announcementEntities: [ + { + id: 'legacy-announcement', + amount: '1', + block: '99', + ciphertext: '0xlegacy', + from: '0xlegacyfrom', + pkx: '0xlegacypkx', + receiver: '0xlegacyreceiver', + timestamp: '999', + token: '0xlegacytoken', + txHash: '0xlegacytx', + }, + ], + }, + }); + }) as typeof fetch; + + const firstPages = await collectPages(fetchAnnouncements.call({ chainConfig: makeChainConfig(url) }, 10, 99)); + + expect(firstCallQueries).to.have.lengthOf(3); + expect(firstCallQueries[0]).to.include('announcements('); + expect(firstCallQueries[1]).to.include('announcementEntities('); + expect(firstCallQueries[2]).to.include('announcementEntities('); + expect(firstCallQueries[2]).to.include('id_lt:'); + expect(firstPages).to.deep.equal([ + [ + { + id: 'legacy-announcement', + amount: '1', + block: '99', + ciphertext: '0xlegacy', + from: '0xlegacyfrom', + pkx: '0xlegacypkx', + receiver: '0xlegacyreceiver', + timestamp: '999', + token: '0xlegacytoken', + txHash: '0xlegacytx', + }, + ], + ]); + + const cachedQueries: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { query: string }; + cachedQueries.push(body.query); + + return makeResponse({ + data: { + announcementEntities: [], + }, + }); + }) as typeof fetch; + + await collectPages(fetchAnnouncements.call({ chainConfig: makeChainConfig(url) }, 10, 99)); + + expect(cachedQueries).to.have.lengthOf(1); + expect(cachedQueries[0]).to.include('announcementEntities('); + expect(cachedQueries[0]).to.not.include('announcements('); + }); + + it('fetches the most recent stealth key event from a Ponder endpoint', async () => { + const url = 'https://ponder-stealth.local/graphql'; + const address = '0xAbCd000000000000000000000000000000000000'; + const queries: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { query: string }; + queries.push(body.query); + + return makeResponse({ + data: { + stealthKeyChanges: { + items: [ + { + id: 'stealth-2', + blockNumber: '200', + from: '0xfrom2', + registrant: address.toLowerCase(), + spendingPubKey: '11', + spendingPubKeyPrefix: '2', + timestamp: '2000', + txHash: '0xtx2', + viewingPubKey: '22', + viewingPubKeyPrefix: '3', + }, + { + id: 'stealth-1', + blockNumber: '150', + from: '0xfrom1', + registrant: address.toLowerCase(), + spendingPubKey: '10', + spendingPubKeyPrefix: '2', + timestamp: '1500', + txHash: '0xtx1', + viewingPubKey: '20', + viewingPubKeyPrefix: '3', + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }); + }) as typeof fetch; + + const event = await getMostRecentSubgraphStealthKeyChangedEventFromAddress(address, makeChainConfig(url, 11155111)); + + expect(queries).to.have.lengthOf(1); + expect(queries[0]).to.include('stealthKeyChanges('); + expect(queries[0]).to.include('network: "sepolia"'); + expect(queries[0]).to.include(`registrant: "${address.toLowerCase()}"`); + expect(event.block).to.equal('200'); + expect(event.spendingPubKey.toString()).to.equal('11'); + expect(event.spendingPubKeyPrefix.toString()).to.equal('2'); + expect(event.viewingPubKey.toString()).to.equal('22'); + expect(event.viewingPubKeyPrefix.toString()).to.equal('3'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8f8e72c3..567df53b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27487,21 +27487,11 @@ xtend@~2.1.1: dependencies: object-keys "~0.4.0" -y18n@^3.2.1: - version "3.2.2" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.2.tgz#85c901bd6470ce71fc4bb723ad209b70f7f28696" - integrity sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ== - -y18n@^4.0.0: +y18n@^3.2.1, y18n@^4.0.0, y18n@^4.0.1, y18n@^5.0.5: version "4.0.3" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - yaeti@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/yaeti/-/yaeti-0.0.6.tgz#f26f484d72684cf42bedfb76970aa1608fbf9577"