diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e0845ed5..611514f1 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: /api/ponder # Other. MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} POLYGON_RPC_URL: ${{ secrets.POLYGON_RPC_URL }} @@ -123,9 +118,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 a183dedf..3c7a4f47 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -15,12 +15,13 @@ 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= +PONDER_SUBGRAPH_URL=/api/ponder +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..4ca2fa52 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. On Netlify, use the same-origin proxy path `/api/ponder`; this value is public and bundled into the frontend.
+`*_SUBGRAPH_URL` - Legacy per-chain subgraph URLs used when `PONDER_SUBGRAPH_URL` is not configured
Optional parameters are: @@ -40,6 +43,23 @@ 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. + +For a Netlify deployment, build the frontend with `PONDER_SUBGRAPH_URL=/api/ponder`. The Netlify Function at that path forwards GraphQL requests to Ponder and adds the API key server-side. Configure these variables in the Netlify UI, CLI, or API so they are available to Functions at runtime: + +```text +PONDER_UPSTREAM_URL=https://your-ponder-service.onrender.com/graphql +PONDER_API_TOKEN=your-secret-token +``` + +Do not add either runtime variable to `frontend/.env`, the frontend build environment, or `netlify.toml`: values used by the static frontend are compiled into browser assets. Configure the same `PONDER_API_TOKEN` on the Render Ponder service. The proxy accepts JSON GraphQL `POST` requests up to 64 KiB, times out upstream calls after 15 seconds, and uses Netlify's built-in per-domain-and-IP limit of 300 requests per minute. + +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 path is inlined in the deployed JS bundles, resolves it against the deployment URL, and runs a basic Ponder announcements scan through the proxy: + +```bash +PONDER_SUBGRAPH_URL=/api/ponder 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..62e891d6 --- /dev/null +++ b/frontend/scripts/ponder-smoke-test.ts @@ -0,0 +1,141 @@ +/** + * 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. Resolves the stamped path against the deployment URL, then runs a basic Ponder + * announcements scan and verifies it succeeds. + * + * Usage: + * yarn smoke-test:ponder + * PONDER_SUBGRAPH_URL=/api/ponder 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 ponderEndpoint = new URL(ponderUrl, deploymentUrl).href; + + 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