Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 7 additions & 6 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/.eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
/.quasar
/node_modules
/src-ssr
/scripts
.eslintrc.js
/*.*
20 changes: 20 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ The required parameters are:
`OPTIMISM_RPC_URL` <br />
`ARBITRUM_ONE_RPC_URL` <br />
`SEPOLIA_RPC_URL` <br />
`BASE_RPC_URL` <br />
`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. <br />
`*_SUBGRAPH_URL` - Legacy per-chain subgraph URLs used when `PONDER_SUBGRAPH_URL` is not configured <br />

Optional parameters are:

Expand All @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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": [
Expand Down
14 changes: 14 additions & 0 deletions frontend/quasar.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
90 changes: 90 additions & 0 deletions frontend/scripts/check-subgraph-env.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
if (!fs.existsSync(file)) return {};
const vars: Record<string, string> = {};
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);
}
141 changes: 141 additions & 0 deletions frontend/scripts/ponder-smoke-test.ts
Original file line number Diff line number Diff line change
@@ -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 <deployment-url>
* 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<string> {
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(/<meta[^>]*>/g)) {
if (attrValue(tag, 'name') === META_NAME) return (attrValue(tag, 'content') || '').trim();
}
return undefined;
}

async function main(): Promise<void> {
const deploymentUrl = process.argv[2];
if (!deploymentUrl) {
fail('Usage: yarn smoke-test:ponder <deployment-url>');
}

const html = await fetchText(deploymentUrl);

const ponderUrl = getStampedPonderUrl(html);
if (ponderUrl === undefined) {
throw new Error(
`No <meta name="${META_NAME}"> 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(/<script[^>]*>/g)]
.map(([tag]) => attrValue(tag, 'src'))
.filter((src): src is string => src !== undefined);
if (scriptSrcs.length === 0) throw new Error(`No <script src> tags found in ${deploymentUrl}`);
let foundInBundle = false;
for (const src of scriptSrcs) {
const bundleUrl = new URL(src, deploymentUrl).href;
if ((await fetchText(bundleUrl)).includes(ponderUrl)) {
console.log(`Found Ponder URL inlined in bundle ${bundleUrl}`);
foundInBundle = true;
break;
}
}
if (!foundInBundle) {
throw new Error(`Ponder URL ${ponderUrl} is not inlined in any of the ${scriptSrcs.length} JS bundle(s).`);
}

const response = await fetch(ponderEndpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: SCAN_QUERY }),
});
if (!response.ok) throw new Error(`Ponder scan POST ${ponderEndpoint} returned ${response.status}`);
const payload = (await response.json()) as PonderScanPayload;
if (payload.errors && payload.errors.length) {
throw new Error(`Ponder scan returned errors: ${payload.errors.map((e) => e.message).join('; ')}`);
}
const items = payload.data && payload.data.announcements && payload.data.announcements.items;
if (!Array.isArray(items)) {
throw new Error(`Ponder scan response missing announcements.items: ${JSON.stringify(payload).slice(0, 500)}`);
}
console.log(`Ponder scan succeeded: received ${items.length} mainnet announcement(s).`);
if (items.length === 0) {
console.warn('WARN: scan returned zero announcements; the Ponder instance may still be indexing.');
}

console.log('PASS: deployed bundle has a Ponder URL and a basic Ponder scan succeeds.');
}

main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
9 changes: 9 additions & 0 deletions frontend/scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"noEmit": true,
"types": ["node"]
},
"include": ["*.ts"]
}
7 changes: 7 additions & 0 deletions frontend/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: string;
PONDER_SUBGRAPH_URL: string | undefined;
MAINNET_SUBGRAPH_URL: string | undefined;
OPTIMISM_SUBGRAPH_URL: string | undefined;
POLYGON_SUBGRAPH_URL: string | undefined;
BASE_SUBGRAPH_URL: string | undefined;
ARBITRUM_ONE_SUBGRAPH_URL: string | undefined;
SEPOLIA_SUBGRAPH_URL: string | undefined;
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
VUE_ROUTER_BASE: string | undefined;
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/index.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

<meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>" />
<meta name="umbra:ponder-subgraph-url" content="<%= ponderSubgraphUrl %>" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
Expand Down
Loading
Loading