diff --git a/next.config.ts b/next.config.ts index 1767b99..bb33b73 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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}` : ""; @@ -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( diff --git a/src/config/validateEnv.test.ts b/src/config/validateEnv.test.ts new file mode 100644 index 0000000..ccb10f6 --- /dev/null +++ b/src/config/validateEnv.test.ts @@ -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/, + ); + }); +}); diff --git a/src/config/validateEnv.ts b/src/config/validateEnv.ts new file mode 100644 index 0000000..349e7bc --- /dev/null +++ b/src/config/validateEnv.ts @@ -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`); + } +} diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts index 0b75857..f0e8378 100644 --- a/src/lib/soroban.ts +++ b/src/lib/soroban.ts @@ -4,6 +4,7 @@ */ import { + Account, Contract, TransactionBuilder, BASE_FEE, @@ -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