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(/