From 0b2a467d721834dfcd901318d7fbd7af6c4ff400 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Mon, 9 Mar 2026 14:20:04 -0500 Subject: [PATCH 1/2] Support Ponder subgraph schemas in umbra-js and frontend Add compatibility fetching and tests for both Ponder and legacy subgraph schemas, wire frontend chain configs to explicit subgraph URLs, and restore the frontend Umbra API default to the production endpoint. --- frontend/.env.example | 12 +- frontend/src/store/wallet.ts | 10 +- frontend/src/utils/constants.ts | 63 ++++++ umbra-js/.env.example | 6 + umbra-js/src/classes/Umbra.ts | 110 +++++++--- umbra-js/src/utils/utils.ts | 244 +++++++++++++++++++-- umbra-js/test/subgraph-schema.test.ts | 301 ++++++++++++++++++++++++++ yarn.lock | 12 +- 8 files changed, 693 insertions(+), 65 deletions(-) create mode 100644 umbra-js/test/subgraph-schema.test.ts 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" From 3d133c8244a8e6177fd7192e83327ee57481c73e Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Wed, 11 Mar 2026 17:51:23 -0500 Subject: [PATCH 2/2] Upgrade coverage reporter to ignore inconsistent LCOV --- .github/workflows/ci.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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: