Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/bundle-size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
run: ANALYZE=true pnpm build
env:
ANALYZE: true
# Placeholder contract address: satisfies the prebuild env check and
# the app treats it as "no contract configured".
NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

- name: Upload bundle analysis
uses: actions/upload-artifact@v4
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ on:
branches:
- "**"

# The all-A placeholder contract address satisfies the prebuild env check; the
# app treats it as "no contract configured".
env:
NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

jobs:
check-lock-files:
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ jobs:

- name: Build Next.js app
run: pnpm build
env:
# Placeholder contract address: satisfies the prebuild env check and
# the app treats it as "no contract configured".
NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v12
Expand Down
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,15 +374,24 @@ corepack prepare pnpm@9.0.0 --activate

### Environment variables

Copy `.env.example` to `.env.local` and set:

| Variable | Description |
|----------|-------------|
| `NEXT_PUBLIC_CONTRACT_ADDRESS_MAINNET` | Soroban contract address on mainnet (56-char, `C...`). |
| `NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET` | Soroban contract address on testnet (56-char, `C...`). |
| `NEXT_PUBLIC_CONTRACT_ADDRESS` | (Optional) Legacy: used for both networks if the two above are not set. |
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | WalletConnect project ID (optional). |
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | Plausible Analytics domain for privacy-friendly page-view tracking (optional). |
Copy `.env.example` to `.env.local` and set the values below. `pnpm build` runs `pnpm validate:env --build` first (the `prebuild` script), which fails the build and names the variable when a required value is missing.

- **Build-time** variables (`NEXT_PUBLIC_*`) are inlined into the client bundle by `next build`, so they must be set when building; changing one requires a rebuild. A missing required one fails the build.
- **Runtime** variables are read by server code on each request, so the deployment can supply them after the build. A missing required one only warns at build time, then fails the requests that need it.

| Variable | Phase | Required | Description | Where to get a value |
|----------|-------|----------|-------------|----------------------|
| `NEXT_PUBLIC_CONTRACT_ADDRESS_MAINNET` | Build | One of the three contract addresses | Soroban contract address on mainnet (56-char, `C...`). | The contract ID printed by `stellar contract deploy --network mainnet`, or from the maintainers. |
| `NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET` | Build | One of the three contract addresses | Soroban contract address on testnet (56-char, `C...`). | The contract ID printed by `stellar contract deploy --network testnet`, or from the maintainers. |
| `NEXT_PUBLIC_CONTRACT_ADDRESS` | Build | One of the three contract addresses | Legacy: used for both networks if the two above are not set. | Same as above. |
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | Build | No | WalletConnect project ID. | Create a project at [cloud.reown.com](https://cloud.reown.com). |
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | Build | No | Plausible Analytics domain for privacy-friendly page-view tracking. | The site domain registered in your [Plausible](https://plausible.io) account. |
| `NEXT_PUBLIC_SOROBAN_RPC_URL_MAINNET` | Build | No | Soroban RPC endpoint for mainnet (a default is provided). | A mainnet RPC provider from the [Stellar RPC providers list](https://developers.stellar.org/docs/data/apis/rpc/providers). |
| `NEXT_PUBLIC_SOROBAN_RPC_URL_TESTNET` | Build | No | Soroban RPC endpoint for testnet (a default is provided). | A testnet RPC provider from the [Stellar RPC providers list](https://developers.stellar.org/docs/data/apis/rpc/providers). |
| `CRON_SECRET` | Runtime | Yes | Bearer token that authorizes `/api/notifications/dispatch` cron requests. | Generate a random string, e.g. `openssl rand -hex 32`, and set the same value in your deployment (Vercel sends it to cron jobs). |
| `RATE_LIMIT_WINDOW_SECONDS` | Runtime | No | `/api/wrapped` rate-limit window in seconds (default `60`). | Choose a value. |
| `RATE_LIMIT_IP_MAX` | Runtime | No | Maximum `/api/wrapped` requests per IP per window (default `30`). | Choose a value. |
| `RATE_LIMIT_ACCOUNT_MAX` | Runtime | No | Maximum `/api/wrapped` requests per queried Stellar account per window (default `10`). | Choose a value. |

Contract addresses are loaded per network; the app uses the selected network (mainnet/testnet) to choose the contract. When you switch networks in the UI, the contract instance is re-loaded for the new network.

Expand Down
51 changes: 51 additions & 0 deletions __tests__/validate-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import fs from "fs";
import path from "path";
import {
ENV_VARS,
CONTRACT_ADDRESS_VARS,
parseEnvExample,
checkBuildEnv,
} from "../scripts/validate-env.js";

const VALID_ADDRESS = "C" + "B".repeat(55);

describe("scripts/validate-env.js", () => {
it("covers exactly the variables declared in .env.example", () => {
const envExample = fs.readFileSync(path.join(__dirname, "../.env.example"), "utf8");
const documented = parseEnvExample(envExample).sort();
const validated = ENV_VARS.map((v: { name: string }) => v.name).sort();

expect(validated).toEqual(documented);
});

it("fails a build without a contract address, naming the variables", () => {
const { errors } = checkBuildEnv({ CRON_SECRET: "secret" });

expect(errors).toHaveLength(1);
CONTRACT_ADDRESS_VARS.forEach((name: string) => expect(errors[0]).toContain(name));
});

it("fails a build with a malformed contract address", () => {
const { errors } = checkBuildEnv({
NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET: "CBAD",
CRON_SECRET: "secret",
});

expect(errors).toEqual([expect.stringContaining("NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET")]);
});

it("only warns about a missing runtime variable", () => {
const { errors, warnings } = checkBuildEnv({
NEXT_PUBLIC_CONTRACT_ADDRESS_MAINNET: VALID_ADDRESS,
});

expect(errors).toEqual([]);
expect(warnings).toEqual([expect.stringContaining("CRON_SECRET")]);
});

it("passes when every required variable is set", () => {
expect(
checkBuildEnv({ NEXT_PUBLIC_CONTRACT_ADDRESS: VALID_ADDRESS, CRON_SECRET: "secret" })
).toEqual({ errors: [], warnings: [] });
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"scripts": {
"dev": "next dev",
"prebuild": "pnpm validate:env --build",
"build": "next build",
"start": "next start",
"lint": "eslint .",
Expand Down
152 changes: 110 additions & 42 deletions scripts/validate-env.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/**
* Environment variable validation script for CI
* Validates .env.example documentation and contract address formats
*
* Usage: node scripts/validate-env.js
* Environment variable validation script
* Validates .env.example documentation and contract address formats. With
* --build (run by `prebuild`), validates the values set in the environment
* instead, so a build missing a required variable fails.
*
* Usage: node scripts/validate-env.js [--build]
* Exit codes:
* 0 - All validations passed
* 1 - Validation failed
Expand Down Expand Up @@ -48,20 +50,30 @@ function info(message) {
log(`ℹ️ ${message}`, colors.blue);
}

// Required environment variables that must be documented
const REQUIRED_ENV_VARS = [
'NEXT_PUBLIC_CONTRACT_ADDRESS_MAINNET',
'NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET',
'CRON_SECRET',
// Every variable in .env.example (kept in sync by __tests__/validate-env.test.ts).
// phase "build": NEXT_PUBLIC_* values are inlined into the bundle by `next build`,
// so a missing one cannot be fixed without rebuilding. Missing + required fails the build.
// phase "runtime": read by server code per request, so the deployment can provide it
// after the build. Missing + required only warns at build time.
const ENV_VARS = [
{ name: 'NEXT_PUBLIC_CONTRACT_ADDRESS_MAINNET', phase: 'build', required: false },
{ name: 'NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET', phase: 'build', required: false },
{ name: 'NEXT_PUBLIC_CONTRACT_ADDRESS', phase: 'build', required: false },
{ name: 'NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID', phase: 'build', required: false },
{ name: 'NEXT_PUBLIC_PLAUSIBLE_DOMAIN', phase: 'build', required: false },
{ name: 'NEXT_PUBLIC_SOROBAN_RPC_URL_MAINNET', phase: 'build', required: false },
{ name: 'NEXT_PUBLIC_SOROBAN_RPC_URL_TESTNET', phase: 'build', required: false },
{ name: 'CRON_SECRET', phase: 'runtime', required: true },
{ name: 'RATE_LIMIT_WINDOW_SECONDS', phase: 'runtime', required: false },
{ name: 'RATE_LIMIT_IP_MAX', phase: 'runtime', required: false },
{ name: 'RATE_LIMIT_ACCOUNT_MAX', phase: 'runtime', required: false },
];

// Optional but recommended environment variables
const OPTIONAL_ENV_VARS = [
// At least one contract address must be set at build time (see .env.example).
const CONTRACT_ADDRESS_VARS = [
'NEXT_PUBLIC_CONTRACT_ADDRESS_MAINNET',
'NEXT_PUBLIC_CONTRACT_ADDRESS_TESTNET',
'NEXT_PUBLIC_CONTRACT_ADDRESS',
'NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID',
'NEXT_PUBLIC_PLAUSIBLE_DOMAIN',
'NEXT_PUBLIC_SOROBAN_RPC_URL_MAINNET',
'NEXT_PUBLIC_SOROBAN_RPC_URL_TESTNET',
];

// Soroban contract address validation (C + 55 base32 chars = 56 total)
Expand All @@ -74,6 +86,68 @@ function isValidContractAddress(address) {
return CONTRACT_ADDRESS_REGEX.test(address);
}

/** Variable names declared in .env.example content. */
function parseEnvExample(content) {
return content
.split('\n')
.map((line) => line.match(/^([A-Z_][A-Z0-9_]*)=/))
.filter(Boolean)
.map((match) => match[1]);
}

/** Checks the values in `env` for a build; returns error and warning messages. */
function checkBuildEnv(env) {
const errors = [];
const warnings = [];
const isSet = (name) => Boolean(env[name] && env[name].trim());

if (!CONTRACT_ADDRESS_VARS.some(isSet)) {
errors.push(
`Missing required build-time variable: set at least one of ${CONTRACT_ADDRESS_VARS.join(', ')}`
);
}

CONTRACT_ADDRESS_VARS.filter(isSet).forEach((name) => {
if (!isValidContractAddress(env[name].trim())) {
errors.push(
`${name} is not a valid contract address (56 characters: C followed by 55 base32 chars)`
);
}
});

ENV_VARS.filter((v) => v.required && !isSet(v.name)).forEach(({ name, phase }) => {
if (phase === 'build') {
errors.push(`Missing required build-time variable: ${name}`);
} else {
warnings.push(
`Missing runtime variable: ${name} (required at runtime; set it in the deployment environment)`
);
}
});

return { errors, warnings };
}

function validateBuildEnv() {
log('\n' + '='.repeat(70), colors.bold);
log('Build Environment Validation', colors.bold);
log('='.repeat(70) + '\n', colors.bold);

// Load .env files with the same loader and precedence `next build` uses.
// eslint-disable-next-line @typescript-eslint/no-require-imports -- CommonJS script, like the requires above
const { loadEnvConfig } = require(
require.resolve('@next/env', { paths: [require.resolve('next')] })
);
loadEnvConfig(rootDir);

const { errors, warnings } = checkBuildEnv(process.env);
errors.forEach(error);
warnings.forEach(warning);
if (errors.length === 0) {
success('Required build-time variables are set');
}
}

function validateEnvExample() {
log('\n' + '='.repeat(70), colors.bold);
log('Environment Variable Validation', colors.bold);
Expand All @@ -92,35 +166,21 @@ function validateEnvExample() {

// Read .env.example
const envExampleContent = fs.readFileSync(envExamplePath, 'utf8');
const documentedVars = new Set();
const documentedVars = new Set(parseEnvExample(envExampleContent));

// Parse documented variables
envExampleContent.split('\n').forEach((line) => {
const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
if (match) {
documentedVars.add(match[1]);
}
});

log('\n📋 Checking required environment variables...\n');
log('\n📋 Checking environment variables are documented...\n');

// Check required variables are documented
REQUIRED_ENV_VARS.forEach((varName) => {
if (documentedVars.has(varName)) {
success(`${varName} is documented`);
ENV_VARS.forEach(({ name }) => {
if (documentedVars.has(name)) {
success(`${name} is documented`);
} else {
error(`${varName} is not documented in .env.example`);
error(`${name} is not documented in .env.example`);
}
});

log('\n📋 Checking optional environment variables...\n');

// Check optional variables
OPTIONAL_ENV_VARS.forEach((varName) => {
if (documentedVars.has(varName)) {
success(`${varName} is documented`);
} else {
info(`${varName} could be documented (optional)`);
documentedVars.forEach((name) => {
if (!ENV_VARS.some((v) => v.name === name)) {
error(`${name} is in .env.example but not in ENV_VARS in scripts/validate-env.js`);
}
});

Expand Down Expand Up @@ -250,9 +310,13 @@ function validateREADME() {
function main() {
log('\n🚀 Starting environment variable validation...\n', colors.bold);

validateEnvExample();
validateContractConfig();
validateREADME();
if (process.argv.includes('--build')) {
validateBuildEnv();
} else {
validateEnvExample();
validateContractConfig();
validateREADME();
}

// Summary
log('\n' + '='.repeat(70), colors.bold);
Expand All @@ -274,4 +338,8 @@ function main() {
}
}

main();
if (require.main === module) {
main();
}

module.exports = { ENV_VARS, CONTRACT_ADDRESS_VARS, parseEnvExample, checkBuildEnv };