From 860c397f8ec5d5b82ec0312f157376f86925f772 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Mon, 9 Mar 2026 14:20:04 -0500 Subject: [PATCH 1/5] 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 a183dedf..9735fb28 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 f745cc0b6e7f6c5656c761a8c967ee303887d18d Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Wed, 11 Mar 2026 17:51:23 -0500 Subject: [PATCH 2/5] 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: From 5318c7ea465b6f2a6ac06a539376367f91dc8248 Mon Sep 17 00:00:00 2001 From: Gary Ghayrat <61768337+garyghayrat@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:45:32 -0400 Subject: [PATCH 3/5] Consolidate subgraph config to Ponder URL --- .github/workflows/ci.yaml | 7 +------ frontend/.env.example | 7 +------ frontend/src/utils/constants.ts | 23 ++++++++--------------- umbra-js/.env.example | 7 +------ umbra-js/src/classes/Umbra.ts | 21 +++++++-------------- 5 files changed, 18 insertions(+), 47 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4454ae42..93f54c51 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,12 +12,7 @@ env: OPTIMISTIC_ETHERSCAN_API_KEY: ${{ secrets.OPTIMISTIC_ETHERSCAN_API_KEY }} POLYGONSCAN_API_KEY: ${{ secrets.POLYGONSCAN_API_KEY }} # Subgraphs - MAINNET_SUBGRAPH_URL: ${{ secrets.MAINNET_SUBGRAPH_URL }} - OPTIMISM_SUBGRAPH_URL: ${{ secrets.OPTIMISM_SUBGRAPH_URL }} - POLYGON_SUBGRAPH_URL: ${{ secrets.POLYGON_SUBGRAPH_URL }} - BASE_SUBGRAPH_URL: ${{ secrets.BASE_SUBGRAPH_URL }} - ARBITRUM_ONE_SUBGRAPH_URL: ${{ secrets.ARBITRUM_ONE_SUBGRAPH_URL }} - SEPOLIA_SUBGRAPH_URL: ${{ secrets.SEPOLIA_SUBGRAPH_URL }} + PONDER_SUBGRAPH_URL: ${{ secrets.PONDER_SUBGRAPH_URL }} # Other. MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} POLYGON_RPC_URL: ${{ secrets.POLYGON_RPC_URL }} diff --git a/frontend/.env.example b/frontend/.env.example index 9735fb28..29d8632c 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -15,12 +15,7 @@ SEPOLIA_RPC_URL=yourSepoliaRpcUrl BASE_RPC_URL=yourBaseRpcUrl WALLET_CONNECT_PROJECT_ID=yourId -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 +PONDER_SUBGRAPH_URL=yourPonderSubgraphUrl LOG_LEVEL=DEBUG MAINTENANCE_MODE_SEND=0 diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index fd2797ab..f7196148 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -9,21 +9,14 @@ 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 || ''); +export const PONDER_SUBGRAPH_URL = String(process.env.PONDER_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 ponderSubgraphUrl = PONDER_SUBGRAPH_URL.length > 0 ? PONDER_SUBGRAPH_URL : false; const umbraChainConfigs: Record = { 1: { @@ -31,42 +24,42 @@ const umbraChainConfigs: Record = { umbraAddress: UMBRA_ADDRESS, batchSendAddress: BATCH_SEND_ADDRESS, startBlock: 12343914, - subgraphUrl: toOptionalSubgraphUrl(MAINNET_SUBGRAPH_URL), + subgraphUrl: ponderSubgraphUrl, }, 10: { chainId: 10, umbraAddress: UMBRA_ADDRESS, batchSendAddress: BATCH_SEND_ADDRESS, startBlock: 4069556, - subgraphUrl: toOptionalSubgraphUrl(OPTIMISM_SUBGRAPH_URL), + subgraphUrl: ponderSubgraphUrl, }, 137: { chainId: 137, umbraAddress: UMBRA_ADDRESS, batchSendAddress: BATCH_SEND_ADDRESS, startBlock: 20717318, - subgraphUrl: toOptionalSubgraphUrl(POLYGON_SUBGRAPH_URL), + subgraphUrl: ponderSubgraphUrl, }, 8453: { chainId: 8453, umbraAddress: UMBRA_ADDRESS, batchSendAddress: BATCH_SEND_ADDRESS, startBlock: 10761374, - subgraphUrl: toOptionalSubgraphUrl(BASE_SUBGRAPH_URL), + subgraphUrl: ponderSubgraphUrl, }, 42161: { chainId: 42161, umbraAddress: UMBRA_ADDRESS, batchSendAddress: BATCH_SEND_ADDRESS, startBlock: 7285883, - subgraphUrl: toOptionalSubgraphUrl(ARBITRUM_ONE_SUBGRAPH_URL), + subgraphUrl: ponderSubgraphUrl, }, 11155111: { chainId: 11155111, umbraAddress: UMBRA_ADDRESS, batchSendAddress: BATCH_SEND_ADDRESS, startBlock: 3590825, - subgraphUrl: toOptionalSubgraphUrl(SEPOLIA_SUBGRAPH_URL), + subgraphUrl: ponderSubgraphUrl, }, }; diff --git a/umbra-js/.env.example b/umbra-js/.env.example index 01095da4..3503b3db 100644 --- a/umbra-js/.env.example +++ b/umbra-js/.env.example @@ -8,9 +8,4 @@ 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 +PONDER_SUBGRAPH_URL=yourPonderSubgraphUrl diff --git a/umbra-js/src/classes/Umbra.ts b/umbra-js/src/classes/Umbra.ts index d2254283..ce6bf8e3 100644 --- a/umbra-js/src/classes/Umbra.ts +++ b/umbra-js/src/classes/Umbra.ts @@ -48,14 +48,7 @@ const getOptionalEnv = (name: string): string | false => { 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 ponderSubgraphUrl = getOptionalEnv('PONDER_SUBGRAPH_URL'); const normalizePonderAnnouncement = (item: Record): SubgraphAnnouncement => ({ amount: String(item.amount), @@ -71,18 +64,18 @@ const normalizePonderAnnouncement = (item: Record): SubgraphAnn }); 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 - 137: { chainId: 137, umbraAddress, batchSendAddress, startBlock: 20717318, subgraphUrl: subgraphs[137] }, // Polygon + 1: { chainId: 1, umbraAddress, batchSendAddress, startBlock: 12343914, subgraphUrl: ponderSubgraphUrl }, // Mainnet + 10: { chainId: 10, umbraAddress, batchSendAddress, startBlock: 4069556, subgraphUrl: ponderSubgraphUrl }, // Optimism + 137: { chainId: 137, umbraAddress, batchSendAddress, startBlock: 20717318, subgraphUrl: ponderSubgraphUrl }, // Polygon 1337: { chainId: 1337, umbraAddress, batchSendAddress, startBlock: 8505089, subgraphUrl: false }, // Local - 8453: { chainId: 8453, umbraAddress, batchSendAddress, startBlock: 10761374, subgraphUrl: subgraphs[8453] }, // Base - 42161: { chainId: 42161, umbraAddress, batchSendAddress, startBlock: 7285883, subgraphUrl: subgraphs[42161] }, // Arbitrum + 8453: { chainId: 8453, umbraAddress, batchSendAddress, startBlock: 10761374, subgraphUrl: ponderSubgraphUrl }, // Base + 42161: { chainId: 42161, umbraAddress, batchSendAddress, startBlock: 7285883, subgraphUrl: ponderSubgraphUrl }, // Arbitrum 11155111: { chainId: 11155111, umbraAddress, batchSendAddress, startBlock: 3590825, - subgraphUrl: subgraphs[11155111], + subgraphUrl: ponderSubgraphUrl, }, // Sepolia }; From db73aba22bce0a461f6c6acd385af72c2aa1137a Mon Sep 17 00:00:00 2001 From: Gary Ghayrat <61768337+garyghayrat@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:53:25 -0400 Subject: [PATCH 4/5] Add Ponder subgraph fallback and smoke test --- frontend/.env.example | 6 ++ frontend/.eslintignore | 1 + frontend/README.md | 11 ++ frontend/package.json | 2 + frontend/quasar.conf.js | 14 +++ frontend/scripts/check-subgraph-env.ts | 90 ++++++++++++++++ frontend/scripts/ponder-smoke-test.ts | 138 +++++++++++++++++++++++++ frontend/scripts/tsconfig.json | 9 ++ frontend/src/env.d.ts | 7 ++ frontend/src/index.template.html | 1 + frontend/src/utils/constants.ts | 36 +++++-- umbra-js/.env.example | 6 ++ umbra-js/src/classes/Umbra.ts | 24 +++-- 13 files changed, 332 insertions(+), 13 deletions(-) create mode 100644 frontend/scripts/check-subgraph-env.ts create mode 100644 frontend/scripts/ponder-smoke-test.ts create mode 100644 frontend/scripts/tsconfig.json diff --git a/frontend/.env.example b/frontend/.env.example index 29d8632c..0631ba39 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -16,6 +16,12 @@ BASE_RPC_URL=yourBaseRpcUrl WALLET_CONNECT_PROJECT_ID=yourId PONDER_SUBGRAPH_URL=yourPonderSubgraphUrl +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/.eslintignore b/frontend/.eslintignore index 6d1b9771..c0a47e44 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -5,5 +5,6 @@ /.quasar /node_modules /src-ssr +/scripts .eslintrc.js /*.* diff --git a/frontend/README.md b/frontend/README.md index fb41fac2..05dfe69a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -18,6 +18,9 @@ The required parameters are: `OPTIMISM_RPC_URL`
`ARBITRUM_ONE_RPC_URL`
`SEPOLIA_RPC_URL`
+`BASE_RPC_URL`
+`PONDER_SUBGRAPH_URL` - Preferred Ponder GraphQL endpoint used for receive scans
+`*_SUBGRAPH_URL` - Legacy per-chain subgraph URLs used when `PONDER_SUBGRAPH_URL` is not configured
Optional parameters are: @@ -40,6 +43,14 @@ yarn build # build a static version of the site for deployment yarn clean # clear previous build artifacts ``` +Receive scans prefer `PONDER_SUBGRAPH_URL` and fall back to the legacy per-chain `*_SUBGRAPH_URL` values while Ponder is being configured everywhere. `yarn build` fails unless `PONDER_SUBGRAPH_URL` is set or `OPTIMISM_SUBGRAPH_URL`, `POLYGON_SUBGRAPH_URL`, and `BASE_SUBGRAPH_URL` are all set, since receive scans on those chains cannot fall back to RPC logs. + +To verify a deployed preview or production build has a working Ponder configuration, run the smoke test against its URL. The build stamps `PONDER_SUBGRAPH_URL` into an `umbra:ponder-subgraph-url` meta tag in `index.html`; the smoke test reads it (and checks it matches `PONDER_SUBGRAPH_URL` if set in your shell), verifies the URL is inlined in the deployed JS bundles, and runs a basic Ponder announcements scan against it: + +```bash +yarn smoke-test:ponder https://deploy-preview-123--umbra.netlify.app +``` + 1. Create a file called `.env` and populate it with the contents of `.env.template` 2. Fill in the `.env` file with your Infura ID. You only need the Portis and Fortmatic API keys if you plan on using those wallets 3. Install dependencies with `yarn` diff --git a/frontend/package.json b/frontend/package.json index 38fc5e5f..854b4550 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "test": "jest", "dev": "quasar dev", "build": "quasar build && cp _redirects dist/spa/", + "smoke-test:ponder": "ts-node --project scripts/tsconfig.json scripts/ponder-smoke-test.ts", "clean": "rimraf dist", "precommit": "lint-staged" }, @@ -59,6 +60,7 @@ "postinstall-postinstall": "^2.1.0", "quasar-app-extension-system-environment-variables": "^2.0.4", "ts-jest": "^27.1.3", + "ts-node": "^9.0.0", "vue-i18n-extract": "^2.0.7" }, "browserslist": [ diff --git a/frontend/quasar.conf.js b/frontend/quasar.conf.js index ebc392e9..1a1a9782 100644 --- a/frontend/quasar.conf.js +++ b/frontend/quasar.conf.js @@ -11,8 +11,22 @@ const { configure } = require('quasar/wrappers'); const ESLintPlugin = require('eslint-webpack-plugin'); const path = require('path'); +process.env.TS_NODE_PROJECT = path.join(__dirname, 'scripts/tsconfig.json'); +require('ts-node/register/transpile-only'); +const { assertSubgraphEnv, getEnv } = require('./scripts/check-subgraph-env.ts'); + module.exports = configure(function (ctx) { + if (ctx.prod) { + assertSubgraphEnv(); + } + return { + // Stamped into index.html (see src/index.template.html) so deploy smoke tests can verify + // the build was produced with a Ponder subgraph URL. See scripts/ponder-smoke-test.ts. + htmlVariables: { + ponderSubgraphUrl: getEnv('PONDER_SUBGRAPH_URL'), + }, + // https://quasar.dev/quasar-cli/supporting-ts supportTS: { tsCheckerConfig: { diff --git a/frontend/scripts/check-subgraph-env.ts b/frontend/scripts/check-subgraph-env.ts new file mode 100644 index 00000000..ed608413 --- /dev/null +++ b/frontend/scripts/check-subgraph-env.ts @@ -0,0 +1,90 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Build-time guard for subgraph configuration. + * + * Receive scans need a subgraph URL on Optimism, Polygon, and Base because the RPC log + * fallback throws on those chains (see umbra-js Umbra.fetchAllAnnouncementFromLogs). A build + * with no usable URL would ship a bundle whose L2 scans always fail, so we fail the build + * instead: either PONDER_SUBGRAPH_URL or the legacy per-chain URL must be set for each of + * those chains. + * + * Reads process.env first (CI/deploy providers), then falls back to the local .env file. + */ + +const REQUIRED_LEGACY_VARS = ['OPTIMISM_SUBGRAPH_URL', 'POLYGON_SUBGRAPH_URL', 'BASE_SUBGRAPH_URL']; + +type SubgraphEnvStatus = + | { + ok: true; + message: string; + missing: []; + } + | { + ok: false; + message: string; + missing: string[]; + }; + +function readDotEnv(file: string): Record { + if (!fs.existsSync(file)) return {}; + const vars: Record = {}; + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!match) continue; + vars[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2'); + } + return vars; +} + +const dotEnv = readDotEnv(path.join(__dirname, '..', '.env')); + +// Also used by quasar.conf.js, where .env may not be loaded into process.env yet. +export const getEnv = (name: string): string => (process.env[name] || dotEnv[name] || '').trim(); + +export function getSubgraphEnvStatus(): SubgraphEnvStatus { + if (getEnv('PONDER_SUBGRAPH_URL')) { + return { + ok: true, + message: 'Subgraph check passed: PONDER_SUBGRAPH_URL is configured.', + missing: [], + }; + } + + const missing = REQUIRED_LEGACY_VARS.filter((name) => !getEnv(name)); + if (missing.length === 0) { + return { + ok: true, + message: 'Subgraph check passed: PONDER_SUBGRAPH_URL is not set, using legacy per-chain subgraph URLs.', + missing: [], + }; + } + + return { + ok: false, + message: + 'Subgraph check failed: no usable subgraph URL for Optimism/Polygon/Base receive scans.\n' + + 'Set PONDER_SUBGRAPH_URL, or set the legacy per-chain URLs. Missing:\n' + + missing.map((name) => ` - ${name}`).join('\n') + + '\nWithout one of these, L2 receive scans fall back to RPC logs and throw at runtime.', + missing, + }; +} + +export function assertSubgraphEnv(): SubgraphEnvStatus { + const status = getSubgraphEnvStatus(); + if (!status.ok) throw new Error(status.message); + return status; +} + +if (require.main === module) { + const status = getSubgraphEnvStatus(); + if (status.ok) { + console.log(status.message); + process.exit(0); + } + + console.error(status.message); + process.exit(1); +} diff --git a/frontend/scripts/ponder-smoke-test.ts b/frontend/scripts/ponder-smoke-test.ts new file mode 100644 index 00000000..9c2fdf2f --- /dev/null +++ b/frontend/scripts/ponder-smoke-test.ts @@ -0,0 +1,138 @@ +/** + * Smoke test for a deployed (preview or prod) frontend bundle. + * + * 1. Fetches the deployment's index.html and reads the Ponder subgraph URL that the build + * stamps into the `umbra:ponder-subgraph-url` meta tag (see quasar.conf.js). If + * PONDER_SUBGRAPH_URL is set in the environment, the stamped URL must match it exactly. + * 2. Verifies the URL is also inlined into one of the JS bundles the page loads. + * 3. Runs a basic Ponder announcements scan against that URL and verifies it succeeds. + * + * Usage: + * yarn smoke-test:ponder + * PONDER_SUBGRAPH_URL=https://... yarn smoke-test:ponder https://deploy-preview.example.com + * + * Exits non-zero on any failure so it can gate CI on preview/prod deploys. + */ + +const META_NAME = 'umbra:ponder-subgraph-url'; + +const SCAN_QUERY = `{ + announcements( + where: { network: "mainnet" } + orderBy: "blockNumber" + orderDirection: "desc" + limit: 5 + ) { + items { + id + receiver + blockNumber + } + pageInfo { + hasNextPage + endCursor + } + } +}`; + +type GraphQlError = { + message: string; +}; + +type PonderScanPayload = { + data?: { + announcements?: { + items?: unknown; + }; + }; + errors?: GraphQlError[]; +}; + +function fail(message: string): never { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +async function fetchText(url: string): Promise { + const response = await fetch(url, { headers: { 'user-agent': 'umbra-ponder-smoke-test' } }); + if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`); + return response.text(); +} + +// The production HTML minifier strips attribute quotes, so match quoted and unquoted values. +const attrValue = (tag: string, name: string): string | undefined => { + const match = tag.match(new RegExp(`${name}=(?:"([^"]*)"|'([^']*)'|([^\\s>]*))`)); + return match ? match[1] ?? match[2] ?? match[3] : undefined; +}; + +function getStampedPonderUrl(html: string): string | undefined { + for (const [tag] of html.matchAll(/]*>/g)) { + if (attrValue(tag, 'name') === META_NAME) return (attrValue(tag, 'content') || '').trim(); + } + return undefined; +} + +async function main(): Promise { + const deploymentUrl = process.argv[2]; + if (!deploymentUrl) { + fail('Usage: yarn smoke-test:ponder '); + } + + const html = await fetchText(deploymentUrl); + + const ponderUrl = getStampedPonderUrl(html); + if (ponderUrl === undefined) { + throw new Error( + `No tag found in ${deploymentUrl}. ` + + 'The deployment predates the smoke-test stamp in src/index.template.html.' + ); + } + if (!ponderUrl) { + throw new Error('The deployed build was produced without PONDER_SUBGRAPH_URL (its meta tag is empty).'); + } + const expected = (process.env.PONDER_SUBGRAPH_URL || '').trim(); + if (expected && expected !== ponderUrl) { + throw new Error(`Deployed Ponder URL (${ponderUrl}) does not match expected PONDER_SUBGRAPH_URL (${expected}).`); + } + console.log(`Deployed build is stamped with Ponder URL ${ponderUrl}`); + + const scriptSrcs = [...html.matchAll(/]*>/g)] + .map(([tag]) => attrValue(tag, 'src')) + .filter((src): src is string => src !== undefined); + if (scriptSrcs.length === 0) throw new Error(`No