From ae33b592cbfed1544abf58b19616ddbeca4fe172 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Fri, 19 Jun 2026 15:15:07 +0100 Subject: [PATCH] feat(stellar): add typed config and client utilities --- .env.example | 4 +- README.md | 6 +- app/Providers.tsx | 4 +- .../__tests__/stellar-activity-panel.test.tsx | 2 +- .../stellar-activity-panel.tsx | 6 +- lib/stellar/client.test.ts | 26 ++++ lib/stellar/client.ts | 20 +++ lib/stellar/config.test.ts | 119 +++++----------- lib/stellar/config.ts | 130 +++++++++++++----- lib/stellar/display-config.ts | 36 +++++ lib/stellar/mock-activity.ts | 4 +- 11 files changed, 235 insertions(+), 122 deletions(-) create mode 100644 lib/stellar/client.test.ts create mode 100644 lib/stellar/client.ts create mode 100644 lib/stellar/display-config.ts diff --git a/.env.example b/.env.example index 676ead3..52dc6c1 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/README.md b/README.md index 7644eed..cb6ea9b 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,11 @@ npm run dev - `PAYSTACK_SECRET_KEY` is required for wallet funding flows. - `PAYSTACK_DVA_PREFERRED_BANK` optionally overrides the bank slug used when provisioning Paystack dedicated virtual accounts. For test keys, the app defaults to `test-bank`. - `RESEND_API_KEY` is only needed for email features. -- Stellar variables are not required by the current code yet. The expected integration pass should introduce explicit values such as `STELLAR_NETWORK`, `STELLAR_HORIZON_URL`, `STELLAR_RPC_URL`, issuer account public keys, distribution account public keys, and contract IDs. +- Stellar configuration is centralized in `lib/stellar/config.ts`. For local UI development, set `ENABLE_MOCK_STELLAR=true`; missing or placeholder deployment identifiers are then accepted. +- With mock mode disabled, `STELLAR_ISSUER_PUBLIC_KEY`, `STELLAR_DISTRIBUTION_PUBLIC_KEY`, and `STELLAR_CONTRACT_ID` are required and validated. `STELLAR_NETWORK` accepts `testnet` or `mainnet`. +- Testnet defaults are `https://horizon-testnet.stellar.org`, `https://soroban-testnet.stellar.org`, and the `CMOVE` asset code. These can be overridden with the variables in `.env.example`. +- This configuration contains public keys and service URLs only. Stellar private/secret keys must never be added to environment examples, committed, or required by this layer. +- Future mainnet deployments use the same variables with `STELLAR_NETWORK=mainnet`; mainnet Horizon, RPC, and network passphrase defaults are selected automatically. ## Driver Dedicated Repayment Accounts diff --git a/app/Providers.tsx b/app/Providers.tsx index 0e4a331..4cd94c6 100644 --- a/app/Providers.tsx +++ b/app/Providers.tsx @@ -5,13 +5,13 @@ import { liskSepolia } from "viem/chains" import { PrivyProvider } from "@/lib/privy/react-auth" import { WalletProvider } from "@/contexts/wallet-context" -import { getStellarConfig } from "@/lib/stellar/config" +import { getStellarDisplayConfig } from "@/lib/stellar/display-config" import type { WalletNetwork } from "@/types/wallet" const privyAppId = process.env.NEXT_PUBLIC_PRIVY_APP_ID function getDefaultNetwork(): WalletNetwork { - const stellarConfig = getStellarConfig() + const stellarConfig = getStellarDisplayConfig() return stellarConfig.network.toLowerCase() === "mainnet" ? "stellar-mainnet" : "stellar-testnet" } 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 new file mode 100644 index 0000000..f3cab50 --- /dev/null +++ b/lib/stellar/client.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest" + +import type { StellarConfig } from "./config" +import { getHorizonUrl, getStellarNetworkPassphrase, getStellarRpcUrl } from "./client" + +const config: StellarConfig = { + network: "testnet", + horizonUrl: "https://example.com/horizon", + rpcUrl: "https://example.com/rpc", + assetCode: "CMOVE", + issuerPublicKey: "", + distributionPublicKey: "", + contractId: "", + mock: true, +} + +describe("Stellar client utilities", () => { + it("returns configured endpoints", () => { + expect(getHorizonUrl(config)).toBe("https://example.com/horizon") + expect(getStellarRpcUrl(config)).toBe("https://example.com/rpc") + }) + + it("returns the network passphrase", () => { + expect(getStellarNetworkPassphrase(config)).toBe("Test SDF Network ; September 2015") + }) +}) diff --git a/lib/stellar/client.ts b/lib/stellar/client.ts new file mode 100644 index 0000000..4e14ba0 --- /dev/null +++ b/lib/stellar/client.ts @@ -0,0 +1,20 @@ +import { getStellarConfig, type StellarConfig, type StellarNetwork } from "./config" + +const NETWORK_PASSPHRASES: Record = { + testnet: "Test SDF Network ; September 2015", + mainnet: "Public Global Stellar Network ; September 2015", +} + +export function getHorizonUrl(config: StellarConfig = getStellarConfig()): string { + return config.horizonUrl +} + +export function getStellarRpcUrl(config: StellarConfig = getStellarConfig()): string { + return config.rpcUrl +} + +export function getStellarNetworkPassphrase( + config: Pick = getStellarConfig(), +): string { + return NETWORK_PASSPHRASES[config.network] +} diff --git a/lib/stellar/config.test.ts b/lib/stellar/config.test.ts index 5ac1a36..37d43f4 100644 --- a/lib/stellar/config.test.ts +++ b/lib/stellar/config.test.ts @@ -1,37 +1,13 @@ -import { describe, expect, it, beforeEach, afterEach } from "vitest" -import { getStellarConfig } from "./config" - -describe("getStellarConfig", () => { - const originalEnv = { ...process.env } - - 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", - "CHAINMOVE_CA", - "ENABLE_MOCK_STELLAR", - ] - keysToRemove.forEach((key) => { - delete process.env[key] - }) - }) +import { describe, expect, it } from "vitest" - afterEach(() => { - // Restore original env vars after each test - process.env = { ...originalEnv } - }) +import { getStellarConfig } 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 defaults without deployment identifiers", () => { + expect(getStellarConfig({ ENABLE_MOCK_STELLAR: "true" })).toMatchObject({ network: "testnet", horizonUrl: "https://horizon-testnet.stellar.org", rpcUrl: "https://soroban-testnet.stellar.org", @@ -39,66 +15,47 @@ describe("getStellarConfig", () => { issuerPublicKey: "", distributionPublicKey: "", contractId: "", - mock: false, + mock: true, }) }) - it("should return configuration with custom environment overrides", () => { - process.env.STELLAR_NETWORK = "mainnet" - process.env.STELLAR_HORIZON_URL = "https://horizon.stellar.org" - process.env.STELLAR_RPC_URL = "https://soroban-mainnet.stellar.org" - 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...", - mock: false, - }) - }) - - 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() - - expect(config.rpcUrl).toBe("https://fallback-rpc.stellar.org") - expect(config.contractId).toBe("CC_FALLBACK_123") + 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 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("returns configured testnet 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://stellar-rpc.org") - expect(config.contractId).toBe("C_STELLAR_1") + expect(config.horizonUrl).toBe("https://example.com/horizon") + expect(config.rpcUrl).toBe("https://example.com/rpc") }) - 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"') }) - 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 dc9b15a..c6b19c8 100644 --- a/lib/stellar/config.ts +++ b/lib/stellar/config.ts @@ -1,52 +1,120 @@ +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: string + network: StellarNetwork horizonUrl: string rpcUrl: string assetCode: string 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> -function getDefaultExplorerBaseUrl(network: string) { - return network.toLowerCase() === "mainnet" ? MAINNET_EXPLORER_BASE_URL : TESTNET_EXPLORER_BASE_URL +const NETWORK_DEFAULTS: Record> = { + testnet: { + horizonUrl: "https://horizon-testnet.stellar.org", + rpcUrl: "https://soroban-testnet.stellar.org", + }, + mainnet: { + horizonUrl: "https://horizon.stellar.org", + rpcUrl: "https://soroban-mainnet.stellar.org", + }, } -export function getStellarConfig(): StellarConfig { - const network = process.env.STELLAR_NETWORK || "testnet" - const mock = process.env.ENABLE_MOCK_STELLAR ? process.env.ENABLE_MOCK_STELLAR === "true" : process.env.NODE_ENV !== "production" +const REQUIRED_DEPLOYMENT_FIELDS = [ + "STELLAR_ISSUER_PUBLIC_KEY", + "STELLAR_DISTRIBUTION_PUBLIC_KEY", + "STELLAR_CONTRACT_ID", +] as const - return { - network, - horizonUrl: process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org", - rpcUrl: process.env.STELLAR_RPC_URL || process.env.RPC_URL || "https://soroban-testnet.stellar.org", - 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 || getDefaultExplorerBaseUrl(network), - mock, - demoPublicKey: process.env.NEXT_PUBLIC_STELLAR_DEMO_PUBLIC_KEY || process.env.STELLAR_DEMO_PUBLIC_KEY || FALLBACK_DEMO_PUBLIC_KEY, +function value(env: StellarEnvironment, name: keyof StellarEnvironment): string { + return env[name]?.trim() ?? "" +} + +function isPlaceholder(input: string): boolean { + return input.toLowerCase().startsWith("replace_") +} + +function parseNetwork(input: string): StellarNetwork { + const network = (input || "testnet").toLowerCase() + if (!STELLAR_NETWORKS.includes(network as StellarNetwork)) { + throw new Error(`Unsupported STELLAR_NETWORK: "${input}". Supported values are testnet and mainnet.`) } + return network as StellarNetwork } -export function getStellarNetworkLabel(network: string) { - const normalized = network.trim().toLowerCase() - if (!normalized) return "Stellar" - return normalized.charAt(0).toUpperCase() + normalized.slice(1) +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 buildStellarReferenceUrl(reference: string, config = getStellarConfig()) { - const normalizedReference = reference.trim() - if (!normalizedReference) return null +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. This layer intentionally accepts only + * public account identifiers and endpoints; private/secret keys never belong here. + */ +export function getStellarConfig(env: StellarEnvironment = process.env): StellarConfig { + const network = parseNetwork(value(env, "STELLAR_NETWORK")) + const mock = value(env, "ENABLE_MOCK_STELLAR").toLowerCase() === "true" + const defaults = NETWORK_DEFAULTS[network] + + 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: 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, + } + + 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/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,