diff --git a/.env.example b/.env.example index 4eab2c3..9b2f657 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,9 @@ RESEND_API_KEY=replace_with_resend_key # File uploads / Vercel Blob BLOB_READ_WRITE_TOKEN=replace_with_local_or_preview_blob_token -# Stellar Testnet +# Stellar Testnet (server-side public identifiers and URLs only; never add private keys) +# Mock mode accepts these safe placeholders. Set ENABLE_MOCK_STELLAR=false only +# after replacing issuer, distribution, and contract values with deployed testnet IDs. STELLAR_NETWORK=testnet STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org STELLAR_RPC_URL=https://soroban-testnet.stellar.org diff --git a/app/Providers.tsx b/app/Providers.tsx index 5fa3777..12a0ae0 100644 --- a/app/Providers.tsx +++ b/app/Providers.tsx @@ -1,19 +1,19 @@ -"use client" - -import type { FC, ReactNode } from "react" - -import { PrivyProvider } from "@/lib/privy/react-auth" -import { embeddedWalletProviderConfig } from "@/lib/wallet/config" - +"use client" + +import type { FC, ReactNode } from "react" + +import { PrivyProvider } from "@/lib/privy/react-auth" +import { embeddedWalletProviderConfig } from "@/lib/wallet/config" + const privyAppId = process.env.NEXT_PUBLIC_PRIVY_APP_ID export const Providers: FC<{ children: ReactNode }> = ({ children }) => { return ( - - {children} - - ) -} + + {children} + + ) +} diff --git a/components/dashboard/investor-overview/__tests__/stellar-activity-panel.test.tsx b/components/dashboard/investor-overview/__tests__/stellar-activity-panel.test.tsx index 76760e1..e1e6f51 100644 --- a/components/dashboard/investor-overview/__tests__/stellar-activity-panel.test.tsx +++ b/components/dashboard/investor-overview/__tests__/stellar-activity-panel.test.tsx @@ -2,7 +2,7 @@ import { render } from "@testing-library/react" import { describe, expect, it } from "vitest" import { StellarActivityPanel } from "@/components/dashboard/investor-overview/stellar-activity-panel" -import { buildStellarReferenceUrl } from "@/lib/stellar/config" +import { buildStellarReferenceUrl } from "@/lib/stellar/display-config" describe("StellarActivityPanel", () => { it("shows the empty state when no Stellar account is linked", () => { diff --git a/components/dashboard/investor-overview/stellar-activity-panel.tsx b/components/dashboard/investor-overview/stellar-activity-panel.tsx index 0a3a35f..57d4aa5 100644 --- a/components/dashboard/investor-overview/stellar-activity-panel.tsx +++ b/components/dashboard/investor-overview/stellar-activity-panel.tsx @@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" import { cn } from "@/lib/utils" -import { getStellarConfig, getStellarNetworkLabel } from "@/lib/stellar/config" +import { getStellarDisplayConfig, getStellarNetworkLabel } from "@/lib/stellar/display-config" import { createMockStellarActivityFeed, type StellarActivityItem } from "@/lib/stellar/mock-activity" export interface StellarActivityPanelData { @@ -112,7 +112,7 @@ export function StellarActivityPanel({ isRefreshing = false, className, }: StellarActivityPanelProps) { - const config = useMemo(() => getStellarConfig(), []) + const config = useMemo(() => getStellarDisplayConfig(), []) const networkLabel = getStellarNetworkLabel(config.network) return ( @@ -266,7 +266,7 @@ export function StellarActivityPanel({ } export function InvestorStellarActivityPanel({ className }: { className?: string }) { - const config = useMemo(() => getStellarConfig(), []) + const config = useMemo(() => getStellarDisplayConfig(), []) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [data, setData] = useState(null) diff --git a/lib/stellar/client.test.ts b/lib/stellar/client.test.ts index 798be15..1fcd082 100644 --- a/lib/stellar/client.test.ts +++ b/lib/stellar/client.test.ts @@ -2,12 +2,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" vi.mock("server-only", () => ({})) -import { getHorizonServer, getSorobanRpcServer, getStellarClient, getStellarNetworkPassphrase } from "./client" +import { + getHorizonServer, + getHorizonUrl, + getSorobanRpcServer, + getStellarClient, + getStellarNetworkPassphrase, + getStellarRpcUrl, +} from "./client" describe("Stellar client helpers", () => { const originalEnv = { ...process.env } beforeEach(() => { + process.env.ENABLE_MOCK_STELLAR = "true" delete process.env.STELLAR_NETWORK delete process.env.STELLAR_HORIZON_URL delete process.env.STELLAR_RPC_URL @@ -23,6 +31,8 @@ describe("Stellar client helpers", () => { expect(client.config.network).toBe("testnet") expect(client.horizon.serverURL.toString()).toBe("https://horizon-testnet.stellar.org/") expect(client.sorobanRpc.serverURL.toString()).toBe("https://soroban-testnet.stellar.org/") + expect(getHorizonUrl(client.config)).toBe("https://horizon-testnet.stellar.org") + expect(getStellarRpcUrl(client.config)).toBe("https://soroban-testnet.stellar.org") expect(client.networkPassphrase).toBe("Test SDF Network ; September 2015") }) @@ -37,6 +47,6 @@ describe("Stellar client helpers", () => { it("rejects invalid network selections", () => { process.env.STELLAR_NETWORK = "futurenet" - expect(() => getStellarClient()).toThrow(/Invalid Stellar network/) + expect(() => getStellarClient()).toThrow(/Unsupported STELLAR_NETWORK/) }) }) diff --git a/lib/stellar/client.ts b/lib/stellar/client.ts index 53e54e4..e260349 100644 --- a/lib/stellar/client.ts +++ b/lib/stellar/client.ts @@ -12,6 +12,14 @@ export function getStellarNetworkPassphrase(network: StellarNetwork = getStellar return parseStellarNetwork(network) === "mainnet" ? Networks.PUBLIC : Networks.TESTNET } +export function getHorizonUrl(config: StellarConfig = getStellarConfig()): string { + return config.horizonUrl +} + +export function getStellarRpcUrl(config: StellarConfig = getStellarConfig()): string { + return config.rpcUrl +} + /** Creates a Horizon client using ChainMove's selected Stellar network. */ export function getHorizonServer(config: StellarConfig = getStellarConfig()): Horizon.Server { return new Horizon.Server(config.horizonUrl) diff --git a/lib/stellar/config.test.ts b/lib/stellar/config.test.ts index e5338c2..2aba2fc 100644 --- a/lib/stellar/config.test.ts +++ b/lib/stellar/config.test.ts @@ -1,40 +1,13 @@ -import { describe, expect, it, beforeEach, afterEach } from "vitest" -import { getStellarConfig, parseStellarNetwork } from "./config" - -describe("getStellarConfig", () => { - const originalEnv = { ...process.env } +import { describe, expect, it } from "vitest" - beforeEach(() => { - // Clear out related env vars to test defaults and ensure test isolation - const keysToRemove = [ - "STELLAR_NETWORK", - "STELLAR_HORIZON_URL", - "STELLAR_RPC_URL", - "RPC_URL", - "STELLAR_ASSET_CODE", - "STELLAR_ISSUER_PUBLIC_KEY", - "STELLAR_DISTRIBUTION_PUBLIC_KEY", - "STELLAR_CONTRACT_ID", - "STELLAR_EXPLORER_BASE_URL", - "NEXT_PUBLIC_STELLAR_DEMO_PUBLIC_KEY", - "STELLAR_DEMO_PUBLIC_KEY", - "CHAINMOVE_CA", - "ENABLE_MOCK_STELLAR", - ] - keysToRemove.forEach((key) => { - delete process.env[key] - }) - }) - - afterEach(() => { - // Restore original env vars after each test - process.env = { ...originalEnv } - }) +import { getStellarConfig, parseStellarNetwork } from "./config" - it("should return default testnet configuration when no env override is present", () => { - const config = getStellarConfig() +const VALID_PUBLIC_KEY = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" +const VALID_CONTRACT_SHAPE = `C${"A".repeat(55)}` - expect(config).toEqual({ +describe("getStellarConfig", () => { + it("lets mock mode use testnet defaults without deployment identifiers", () => { + expect(getStellarConfig({ ENABLE_MOCK_STELLAR: "true" })).toEqual({ network: "testnet", horizonUrl: "https://horizon-testnet.stellar.org", rpcUrl: "https://soroban-testnet.stellar.org", @@ -42,75 +15,58 @@ describe("getStellarConfig", () => { issuerPublicKey: "", distributionPublicKey: "", contractId: "", - explorerBaseUrl: "https://stellar.expert/explorer/testnet", - mock: false, - demoPublicKey: "GABCDMOCKSTELLARPUBLICKEYTESTNET000000000000000000000000000000", + mock: true, }) }) - it("should resolve mainnet defaults and custom environment overrides", () => { - process.env.STELLAR_NETWORK = "mainnet" - process.env.STELLAR_ASSET_CODE = "TEST" - process.env.STELLAR_ISSUER_PUBLIC_KEY = "GD123..." - process.env.STELLAR_DISTRIBUTION_PUBLIC_KEY = "GD456..." - process.env.STELLAR_CONTRACT_ID = "C123..." - - const config = getStellarConfig() - - expect(config).toEqual({ - network: "mainnet", - horizonUrl: "https://horizon.stellar.org", - rpcUrl: "https://soroban-mainnet.stellar.org", - assetCode: "TEST", - issuerPublicKey: "GD123...", - distributionPublicKey: "GD456...", - contractId: "C123...", - explorerBaseUrl: "https://stellar.expert/explorer/public", - mock: false, - demoPublicKey: "GABCDMOCKSTELLARPUBLICKEYTESTNET000000000000000000000000000000", - }) - }) - - it("should reject unsupported networks instead of falling back to testnet", () => { - process.env.STELLAR_NETWORK = "futurenet" - - expect(() => getStellarConfig()).toThrow('Invalid Stellar network "futurenet". Expected "testnet" or "mainnet".') - expect(() => parseStellarNetwork("futurenet")).toThrow(/Invalid Stellar network/) + it("names a missing required field when mock mode is disabled", () => { + expect(() => getStellarConfig({ ENABLE_MOCK_STELLAR: "false" })).toThrow( + "Missing required Stellar configuration: STELLAR_ISSUER_PUBLIC_KEY", + ) }) - it("should support RPC_URL and CHAINMOVE_CA fallbacks when STELLAR_ RPC/contract variables are missing", () => { - process.env.RPC_URL = "https://fallback-rpc.stellar.org" - process.env.CHAINMOVE_CA = "CC_FALLBACK_123" - - const config = getStellarConfig() + it("returns configured URLs for a valid live configuration", () => { + const config = getStellarConfig({ + STELLAR_NETWORK: "testnet", + STELLAR_HORIZON_URL: "https://example.com/horizon", + STELLAR_RPC_URL: "https://example.com/rpc", + STELLAR_ISSUER_PUBLIC_KEY: VALID_PUBLIC_KEY, + STELLAR_DISTRIBUTION_PUBLIC_KEY: VALID_PUBLIC_KEY, + STELLAR_CONTRACT_ID: VALID_CONTRACT_SHAPE, + ENABLE_MOCK_STELLAR: "false", + }) - expect(config.rpcUrl).toBe("https://fallback-rpc.stellar.org") - expect(config.contractId).toBe("CC_FALLBACK_123") + expect(config.horizonUrl).toBe("https://example.com/horizon") + expect(config.rpcUrl).toBe("https://example.com/rpc") }) - it("should prioritize STELLAR_ environment variables over their fallback counterparts", () => { - process.env.STELLAR_RPC_URL = "https://stellar-rpc.org" - process.env.RPC_URL = "https://fallback-rpc.org" - - process.env.STELLAR_CONTRACT_ID = "C_STELLAR_1" - process.env.CHAINMOVE_CA = "C_FALLBACK_1" - - const config = getStellarConfig() + it("selects mainnet endpoint defaults", () => { + const config = getStellarConfig({ + STELLAR_NETWORK: "mainnet", + ENABLE_MOCK_STELLAR: "true", + }) - expect(config.rpcUrl).toBe("https://stellar-rpc.org") - expect(config.contractId).toBe("C_STELLAR_1") + expect(config.horizonUrl).toBe("https://horizon.stellar.org") + expect(config.rpcUrl).toBe("https://soroban-mainnet.stellar.org") }) - it("should support mock mode when ENABLE_MOCK_STELLAR is 'true'", () => { - process.env.ENABLE_MOCK_STELLAR = "true" - expect(getStellarConfig().mock).toBe(true) + it("rejects unsupported networks even in mock mode", () => { + expect(() => + getStellarConfig({ STELLAR_NETWORK: "futurenet", ENABLE_MOCK_STELLAR: "true" }), + ).toThrow('Unsupported STELLAR_NETWORK: "futurenet"') + expect(() => parseStellarNetwork("futurenet")).toThrow(/Unsupported STELLAR_NETWORK/) }) - it("should not enable mock mode when ENABLE_MOCK_STELLAR is not 'true'", () => { - process.env.ENABLE_MOCK_STELLAR = "false" - expect(getStellarConfig().mock).toBe(false) + it("allows placeholders only in mock mode", () => { + const placeholders = { + STELLAR_ISSUER_PUBLIC_KEY: "replace_with_public_key", + STELLAR_DISTRIBUTION_PUBLIC_KEY: "replace_with_public_key", + STELLAR_CONTRACT_ID: "replace_after_deployment", + } - delete process.env.ENABLE_MOCK_STELLAR - expect(getStellarConfig().mock).toBe(false) + expect(getStellarConfig({ ...placeholders, ENABLE_MOCK_STELLAR: "true" }).mock).toBe(true) + expect(() => getStellarConfig({ ...placeholders, ENABLE_MOCK_STELLAR: "false" })).toThrow( + "STELLAR_ISSUER_PUBLIC_KEY", + ) }) }) diff --git a/lib/stellar/config.ts b/lib/stellar/config.ts index 6b42f2b..b84862b 100644 --- a/lib/stellar/config.ts +++ b/lib/stellar/config.ts @@ -1,4 +1,8 @@ -export type StellarNetwork = "testnet" | "mainnet" +import { isValidStellarPublicKey } from "@/lib/validation/stellar" + +export const STELLAR_NETWORKS = ["testnet", "mainnet"] as const + +export type StellarNetwork = (typeof STELLAR_NETWORKS)[number] export interface StellarConfig { network: StellarNetwork @@ -8,73 +12,110 @@ export interface StellarConfig { issuerPublicKey: string distributionPublicKey: string contractId: string - explorerBaseUrl: string mock: boolean - demoPublicKey: string } -const TESTNET_EXPLORER_BASE_URL = "https://stellar.expert/explorer/testnet" -const MAINNET_EXPLORER_BASE_URL = "https://stellar.expert/explorer/public" -const FALLBACK_DEMO_PUBLIC_KEY = "GABCDMOCKSTELLARPUBLICKEYTESTNET000000000000000000000000000000" +type StellarEnvironment = Partial> -const NETWORK_DEFAULTS: Record> = { +const NETWORK_DEFAULTS: Record> = { testnet: { horizonUrl: "https://horizon-testnet.stellar.org", rpcUrl: "https://soroban-testnet.stellar.org", - explorerBaseUrl: TESTNET_EXPLORER_BASE_URL, }, mainnet: { horizonUrl: "https://horizon.stellar.org", rpcUrl: "https://soroban-mainnet.stellar.org", - explorerBaseUrl: MAINNET_EXPLORER_BASE_URL, }, } -/** - * Normalizes the deployment network to a supported Stellar network. - * - * Keeping this validation in the shared config layer means all server clients - * select the same endpoints and fail fast instead of silently using Testnet. - */ -export function parseStellarNetwork(value: string | undefined): StellarNetwork { - const network = value?.trim().toLowerCase() || "testnet" +const REQUIRED_DEPLOYMENT_FIELDS = [ + "STELLAR_ISSUER_PUBLIC_KEY", + "STELLAR_DISTRIBUTION_PUBLIC_KEY", + "STELLAR_CONTRACT_ID", +] as const + +function value(env: StellarEnvironment, name: keyof StellarEnvironment): string { + return env[name]?.trim() ?? "" +} - if (network === "testnet" || network === "mainnet") { - return network +function isPlaceholder(input: string): boolean { + return input.toLowerCase().startsWith("replace_") +} + +export function parseStellarNetwork(input: string | undefined): StellarNetwork { + const network = input?.trim().toLowerCase() || "testnet" + if (!STELLAR_NETWORKS.includes(network as StellarNetwork)) { + throw new Error(`Unsupported STELLAR_NETWORK: "${input}". Supported values are testnet and mainnet.`) } + return network as StellarNetwork +} - throw new Error(`Invalid Stellar network "${value}". Expected "testnet" or "mainnet".`) +function validateUrl(name: string, input: string): void { + try { + const url = new URL(input) + if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error() + } catch { + throw new Error(`Invalid ${name}: expected an HTTP(S) URL.`) + } } -export function getStellarConfig(): StellarConfig { - const network = parseStellarNetwork(process.env.STELLAR_NETWORK) +function validateDeploymentConfig(config: StellarConfig): void { + if (!isValidStellarPublicKey(config.issuerPublicKey)) { + throw new Error("Invalid STELLAR_ISSUER_PUBLIC_KEY: expected a Stellar G... public key.") + } + if (!isValidStellarPublicKey(config.distributionPublicKey)) { + throw new Error("Invalid STELLAR_DISTRIBUTION_PUBLIC_KEY: expected a Stellar G... public key.") + } + if (!/^C[A-Z2-7]{55}$/.test(config.contractId)) { + throw new Error("Invalid STELLAR_CONTRACT_ID: expected a Stellar C... contract ID.") + } +} + +/** + * Reads server-side Stellar configuration. Only public account identifiers + * and endpoints belong here; private or secret keys must never be added. + */ +export function getStellarConfig(env: StellarEnvironment = process.env): StellarConfig { + const network = parseStellarNetwork(value(env, "STELLAR_NETWORK")) + const mock = value(env, "ENABLE_MOCK_STELLAR").toLowerCase() === "true" const defaults = NETWORK_DEFAULTS[network] - const mock = process.env.ENABLE_MOCK_STELLAR === "true" - return { + if (!mock) { + for (const field of REQUIRED_DEPLOYMENT_FIELDS) { + const fieldValue = value(env, field) + if (!fieldValue || isPlaceholder(fieldValue)) { + throw new Error(`Missing required Stellar configuration: ${field}.`) + } + } + } + + const config: StellarConfig = { network, - horizonUrl: process.env.STELLAR_HORIZON_URL || defaults.horizonUrl, - rpcUrl: process.env.STELLAR_RPC_URL || process.env.RPC_URL || defaults.rpcUrl, - assetCode: process.env.STELLAR_ASSET_CODE || "CMOVE", - issuerPublicKey: process.env.STELLAR_ISSUER_PUBLIC_KEY || "", - distributionPublicKey: process.env.STELLAR_DISTRIBUTION_PUBLIC_KEY || "", - contractId: process.env.STELLAR_CONTRACT_ID || process.env.CHAINMOVE_CA || "", - explorerBaseUrl: process.env.STELLAR_EXPLORER_BASE_URL || defaults.explorerBaseUrl, + horizonUrl: value(env, "STELLAR_HORIZON_URL") || defaults.horizonUrl, + rpcUrl: value(env, "STELLAR_RPC_URL") || defaults.rpcUrl, + assetCode: value(env, "STELLAR_ASSET_CODE") || "CMOVE", + issuerPublicKey: value(env, "STELLAR_ISSUER_PUBLIC_KEY"), + distributionPublicKey: value(env, "STELLAR_DISTRIBUTION_PUBLIC_KEY"), + contractId: value(env, "STELLAR_CONTRACT_ID"), mock, - demoPublicKey: process.env.NEXT_PUBLIC_STELLAR_DEMO_PUBLIC_KEY || process.env.STELLAR_DEMO_PUBLIC_KEY || FALLBACK_DEMO_PUBLIC_KEY, } -} -export function getStellarNetworkLabel(network: string) { - const normalized = network.trim().toLowerCase() - if (!normalized) return "Stellar" - return normalized.charAt(0).toUpperCase() + normalized.slice(1) -} - -export function buildStellarReferenceUrl(reference: string, config = getStellarConfig()) { - const normalizedReference = reference.trim() - if (!normalizedReference) return null + if (!mock) { + validateUrl("STELLAR_HORIZON_URL", config.horizonUrl) + validateUrl("STELLAR_RPC_URL", config.rpcUrl) + validateDeploymentConfig(config) + } - const baseUrl = config.explorerBaseUrl.replace(/\/$/, "") - return `${baseUrl}/tx/${encodeURIComponent(normalizedReference)}` + return config } diff --git a/lib/stellar/display-config.ts b/lib/stellar/display-config.ts new file mode 100644 index 0000000..666ae7c --- /dev/null +++ b/lib/stellar/display-config.ts @@ -0,0 +1,36 @@ +export type StellarDisplayNetwork = "testnet" | "mainnet" + +export interface StellarDisplayConfig { + network: StellarDisplayNetwork + explorerBaseUrl: string + mock: boolean + demoPublicKey: string +} + +const FALLBACK_DEMO_PUBLIC_KEY = "GABCDMOCKSTELLARPUBLICKEYTESTNET000000000000000000000000000000" + +// Browser code receives display-only defaults. Server environment values from +// config.ts are deliberately excluded from this module and the frontend bundle. +export function getStellarDisplayConfig(): StellarDisplayConfig { + return { + network: "testnet", + explorerBaseUrl: "https://stellar.expert/explorer/testnet", + mock: process.env.NODE_ENV !== "production", + demoPublicKey: FALLBACK_DEMO_PUBLIC_KEY, + } +} + +export function getStellarNetworkLabel(network: string): string { + const normalized = network.trim().toLowerCase() + if (!normalized) return "Stellar" + return normalized.charAt(0).toUpperCase() + normalized.slice(1) +} + +export function buildStellarReferenceUrl( + reference: string, + config: Pick = getStellarDisplayConfig(), +): string | null { + const normalizedReference = reference.trim() + if (!normalizedReference) return null + return `${config.explorerBaseUrl.replace(/\/$/, "")}/tx/${encodeURIComponent(normalizedReference)}` +} diff --git a/lib/stellar/indexer.test.ts b/lib/stellar/indexer.test.ts index 38a1512..c87e505 100644 --- a/lib/stellar/indexer.test.ts +++ b/lib/stellar/indexer.test.ts @@ -26,6 +26,7 @@ let mockCursorStore: Record = {} /** In-memory event store that replaces StellarIndexedEvent model calls. */ let mockEventStore: Record = {} +const originalMockStellar = process.env.ENABLE_MOCK_STELLAR // --------------------------------------------------------------------------- // Mock the DB models via vi.mock @@ -96,12 +97,18 @@ function makeFailedHorizonResponse(status = 500, statusText = "Internal Server E // --------------------------------------------------------------------------- beforeEach(() => { + process.env.ENABLE_MOCK_STELLAR = "true" mockCursorStore = {} mockEventStore = {} vi.restoreAllMocks() }) afterEach(() => { + if (originalMockStellar === undefined) { + delete process.env.ENABLE_MOCK_STELLAR + } else { + process.env.ENABLE_MOCK_STELLAR = originalMockStellar + } vi.unstubAllGlobals() }) diff --git a/lib/stellar/mock-activity.ts b/lib/stellar/mock-activity.ts index 08bc2ac..2c3cfeb 100644 --- a/lib/stellar/mock-activity.ts +++ b/lib/stellar/mock-activity.ts @@ -1,4 +1,4 @@ -import { buildStellarReferenceUrl, getStellarConfig } from "@/lib/stellar/config" +import { buildStellarReferenceUrl, getStellarDisplayConfig } from "@/lib/stellar/display-config" export type StellarActivityStatus = "Confirmed" | "Pending" | "Failed" @@ -54,7 +54,7 @@ export function createMockStellarActivityFeed(linkedAccount: string | null): Ste } } - const config = getStellarConfig() + const config = getStellarDisplayConfig() return { linkedAccount,