Skip to content
Closed
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
14 changes: 14 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NextConfig } from "next";
import withBundleAnalyzer from "@next/bundle-analyzer";
import { validateEnv } from "./src/config/validateEnv";

const raw = process.env.BASE_PATH?.trim() ?? "";
const basePath = raw.startsWith("/") ? raw : raw ? `/${raw}` : "";
Expand All @@ -13,6 +14,19 @@ const basePath = raw.startsWith("/") ? raw : raw ? `/${raw}` : "";
* the Stellar Horizon API directly from the browser.
*/
const isStaticExport = process.env.NEXT_EXPORT === "true";

// Fail the build immediately on missing/malformed env vars instead of
// silently falling back to demo mode or a runtime 500 (issue #199).
//
// Netlify sets CONTEXT to "production" | "deploy-preview" | "branch-deploy"
// (unset locally and in most other CI). Preview/branch-deploy builds don't
// necessarily carry production Soroban config, and previously fell into
// silent demo mode the same as any other missing-config build — so those
// contexts warn instead of failing the build. Local dev/build and the
// production context stay strict.
const netlifyContext = process.env.CONTEXT;
const isNetlifyPreview = netlifyContext === "deploy-preview" || netlifyContext === "branch-deploy";
validateEnv({ isStaticExport, strict: !isNetlifyPreview });
const backendApiOrigin = (() => {
try {
return new URL(
Expand Down
96 changes: 96 additions & 0 deletions src/config/validateEnv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StrKey } from '@stellar/stellar-sdk';
import { validateEnv } from './validateEnv';

// Built via StrKey.encode*, not Keypair.random()/fromSecret(): the latter
// route through @noble/ed25519 signing, which hits a pre-existing
// Vitest-only Buffer-polyfill bug (see feeBumpGuard.test.ts's note on
// Transaction.hash()) — unrelated to this change.
const VALID_CONTRACT_ID = StrKey.encodeContract(new Uint8Array(32).fill(1));
const VALID_SPONSOR_SECRET = StrKey.encodeEd25519SecretSeed(new Uint8Array(32).fill(2));

const ENV_KEYS = [
'NEXT_PUBLIC_FACTORY_CONTRACT_ID',
'NEXT_PUBLIC_SOROBAN_RPC_URL',
'STELLAR_FEE_SPONSOR_SECRET',
] as const;

function setValidEnv() {
process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ID = VALID_CONTRACT_ID;
process.env.NEXT_PUBLIC_SOROBAN_RPC_URL = 'https://soroban-testnet.stellar.org';
process.env.STELLAR_FEE_SPONSOR_SECRET = VALID_SPONSOR_SECRET;
}

describe('validateEnv', () => {
afterEach(() => {
for (const key of ENV_KEYS) delete process.env[key];
});

it('passes with a fully valid server-mode env', () => {
setValidEnv();
expect(() => validateEnv({ isStaticExport: false })).not.toThrow();
});

it('throws when NEXT_PUBLIC_FACTORY_CONTRACT_ID is missing', () => {
setValidEnv();
delete process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ID;
expect(() => validateEnv({ isStaticExport: false })).toThrow(
/NEXT_PUBLIC_FACTORY_CONTRACT_ID is not set/,
);
});

it('throws when NEXT_PUBLIC_FACTORY_CONTRACT_ID is not a valid contract id', () => {
setValidEnv();
process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ID = 'not-a-contract-id';
expect(() => validateEnv({ isStaticExport: false })).toThrow(
/not a valid Soroban contract id/,
);
});

it('throws when NEXT_PUBLIC_SOROBAN_RPC_URL is set but not a valid URL', () => {
setValidEnv();
process.env.NEXT_PUBLIC_SOROBAN_RPC_URL = 'not a url';
expect(() => validateEnv({ isStaticExport: false })).toThrow(/not a valid URL/);
});

it('does not require STELLAR_FEE_SPONSOR_SECRET for static export', () => {
setValidEnv();
delete process.env.STELLAR_FEE_SPONSOR_SECRET;
expect(() => validateEnv({ isStaticExport: true })).not.toThrow();
});

it('requires STELLAR_FEE_SPONSOR_SECRET in server mode', () => {
setValidEnv();
delete process.env.STELLAR_FEE_SPONSOR_SECRET;
expect(() => validateEnv({ isStaticExport: false })).toThrow(
/STELLAR_FEE_SPONSOR_SECRET is not set/,
);
});

it('throws when STELLAR_FEE_SPONSOR_SECRET is not a valid secret key', () => {
setValidEnv();
process.env.STELLAR_FEE_SPONSOR_SECRET = 'not-a-secret-key';
expect(() => validateEnv({ isStaticExport: false })).toThrow(
/not a valid Stellar secret key/,
);
});

it('warns instead of throwing when strict is false', () => {
delete process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ID;
delete process.env.STELLAR_FEE_SPONSOR_SECRET;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(() => validateEnv({ isStaticExport: false, strict: false })).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('NEXT_PUBLIC_FACTORY_CONTRACT_ID is not set'),
);
warnSpy.mockRestore();
});

it('defaults to strict when the option is omitted', () => {
delete process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ID;
delete process.env.STELLAR_FEE_SPONSOR_SECRET;
expect(() => validateEnv({ isStaticExport: false })).toThrow(
/NEXT_PUBLIC_FACTORY_CONTRACT_ID is not set/,
);
});
});
61 changes: 61 additions & 0 deletions src/config/validateEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { StrKey } from '@stellar/stellar-sdk';

/**
* Fails fast with a clear message instead of letting a missing or malformed
* env var fall through to demo-mode data or a runtime 500 (issue #199).
* Called from next.config.ts, which Next.js loads before both `next build`
* and `next dev`/`next start`.
*
* `strict: false` downgrades a failure to a console warning instead of
* throwing. next.config.ts uses this for Netlify's `deploy-preview` /
* `branch-deploy` contexts (Netlify sets `CONTEXT`), which don't necessarily
* have production Soroban config — those builds fell into silent demo mode
* before this change too, so warn-and-continue there is not a regression,
* just a louder version of the previous behavior. Local dev/build and
* Netlify's `production` context stay strict.
*/
export function validateEnv(options: { isStaticExport: boolean; strict?: boolean }): void {
const { strict = true } = options;
const problems: string[] = [];

const factoryContractId = process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ID;
if (!factoryContractId) {
problems.push('NEXT_PUBLIC_FACTORY_CONTRACT_ID is not set.');
} else if (!StrKey.isValidContract(factoryContractId)) {
problems.push(
`NEXT_PUBLIC_FACTORY_CONTRACT_ID ("${factoryContractId}") is not a valid Soroban contract id.`,
);
}

const sorobanRpcUrl = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL;
if (sorobanRpcUrl) {
try {
new URL(sorobanRpcUrl);
} catch {
problems.push(`NEXT_PUBLIC_SOROBAN_RPC_URL ("${sorobanRpcUrl}") is not a valid URL.`);
}
}

// STELLAR_FEE_SPONSOR_SECRET only backs the /api/sign-fee-bump route, which
// doesn't exist in static-export builds (see next.config.ts).
if (!options.isStaticExport) {
const sponsorSecret = process.env.STELLAR_FEE_SPONSOR_SECRET;
if (!sponsorSecret) {
problems.push(
'STELLAR_FEE_SPONSOR_SECRET is not set (required in server mode for /api/sign-fee-bump).',
);
} else if (!StrKey.isValidEd25519SecretSeed(sponsorSecret)) {
problems.push('STELLAR_FEE_SPONSOR_SECRET is set but is not a valid Stellar secret key.');
}
}

if (problems.length > 0) {
const message =
`Invalid environment configuration:\n - ${problems.join('\n - ')}\n` +
'Set the required variables in .env.local before building or starting the app.';
if (strict) {
throw new Error(message);
}
console.warn(`\n⚠️ ${message}\n`);
}
}
3 changes: 2 additions & 1 deletion src/lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import {
Account,
Contract,
TransactionBuilder,
BASE_FEE,
Expand Down Expand Up @@ -1484,7 +1485,7 @@ export class SorobanService {
async getPoolHistory(
poolId: string,
days: number = 7,
): Promise<{ date: string; tvl: string; truncated?: boolean }> {
): Promise<{ date: string; tvl: string; truncated?: boolean }[]> {
try {
const latest = await this.rpcServer.getLatestLedger();
// ~5 s per ledger; days * 86400 / 5
Expand Down