diff --git a/app/Providers.tsx b/app/Providers.tsx index bf8d82e..5fa3777 100644 --- a/app/Providers.tsx +++ b/app/Providers.tsx @@ -5,17 +5,10 @@ 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 - -function getDefaultNetwork(): WalletNetwork { - const stellarConfig = getStellarConfig() - return stellarConfig.network.toLowerCase() === "mainnet" ? "stellar-mainnet" : "stellar-testnet" -} - -export const Providers: FC<{ children: ReactNode }> = ({ children }) => { - const defaultNetwork = getDefaultNetwork() - - return ( +const privyAppId = process.env.NEXT_PUBLIC_PRIVY_APP_ID + +export const Providers: FC<{ children: ReactNode }> = ({ children }) => { + return ( ({})) + +import { getHorizonServer, getSorobanRpcServer, getStellarClient, getStellarNetworkPassphrase } from "./client" + +describe("Stellar client helpers", () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + delete process.env.STELLAR_NETWORK + delete process.env.STELLAR_HORIZON_URL + delete process.env.STELLAR_RPC_URL + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it("creates Horizon and Soroban RPC clients for the default testnet configuration", () => { + const client = getStellarClient() + + 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(client.networkPassphrase).toBe("Test SDF Network ; September 2015") + }) + + it("creates Horizon and Soroban RPC clients for mainnet", () => { + process.env.STELLAR_NETWORK = "mainnet" + + expect(getHorizonServer().serverURL.toString()).toBe("https://horizon.stellar.org/") + expect(getSorobanRpcServer().serverURL.toString()).toBe("https://soroban-mainnet.stellar.org/") + expect(getStellarNetworkPassphrase()).toBe("Public Global Stellar Network ; September 2015") + }) + + it("rejects invalid network selections", () => { + process.env.STELLAR_NETWORK = "futurenet" + + expect(() => getStellarClient()).toThrow(/Invalid Stellar network/) + }) +}) diff --git a/lib/stellar/client.ts b/lib/stellar/client.ts new file mode 100644 index 0000000..53e54e4 --- /dev/null +++ b/lib/stellar/client.ts @@ -0,0 +1,44 @@ +import "server-only" + +import { Horizon, Networks, rpc } from "@stellar/stellar-sdk" + +import { getStellarConfig, parseStellarNetwork, type StellarConfig, type StellarNetwork } from "./config" + +/** + * The Stellar transaction network passphrase for a configured ChainMove + * network. Use this when constructing or parsing transactions. + */ +export function getStellarNetworkPassphrase(network: StellarNetwork = getStellarConfig().network): string { + return parseStellarNetwork(network) === "mainnet" ? Networks.PUBLIC : Networks.TESTNET +} + +/** Creates a Horizon client using ChainMove's selected Stellar network. */ +export function getHorizonServer(config: StellarConfig = getStellarConfig()): Horizon.Server { + return new Horizon.Server(config.horizonUrl) +} + +/** Creates a Soroban RPC client using ChainMove's selected Stellar network. */ +export function getSorobanRpcServer(config: StellarConfig = getStellarConfig()): rpc.Server { + return new rpc.Server(config.rpcUrl) +} + +export interface StellarClient { + config: StellarConfig + networkPassphrase: string + horizon: Horizon.Server + sorobanRpc: rpc.Server +} + +/** + * Creates all server-side Stellar clients from one resolved configuration. + * This module is marked `server-only` to prevent SDK clients from being + * imported into frontend components. + */ +export function getStellarClient(config: StellarConfig = getStellarConfig()): StellarClient { + return { + config, + networkPassphrase: getStellarNetworkPassphrase(config.network), + horizon: getHorizonServer(config), + sorobanRpc: getSorobanRpcServer(config), + } +} diff --git a/lib/stellar/config.test.ts b/lib/stellar/config.test.ts index 55dfe47..e5338c2 100644 --- a/lib/stellar/config.test.ts +++ b/lib/stellar/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest" -import { getStellarConfig } from "./config" +import { getStellarConfig, parseStellarNetwork } from "./config" describe("getStellarConfig", () => { const originalEnv = { ...process.env } @@ -48,10 +48,8 @@ describe("getStellarConfig", () => { }) }) - it("should return configuration with custom environment overrides", () => { + it("should resolve mainnet defaults and 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..." @@ -73,6 +71,13 @@ describe("getStellarConfig", () => { }) }) + 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("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" diff --git a/lib/stellar/config.ts b/lib/stellar/config.ts index bea1b4f..6b42f2b 100644 --- a/lib/stellar/config.ts +++ b/lib/stellar/config.ts @@ -1,5 +1,7 @@ +export type StellarNetwork = "testnet" | "mainnet" + export interface StellarConfig { - network: string + network: StellarNetwork horizonUrl: string rpcUrl: string assetCode: string @@ -15,23 +17,49 @@ 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" -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", + 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" + + if (network === "testnet" || network === "mainnet") { + return network + } + + throw new Error(`Invalid Stellar network "${value}". Expected "testnet" or "mainnet".`) } export function getStellarConfig(): StellarConfig { - const network = process.env.STELLAR_NETWORK || "testnet" + const network = parseStellarNetwork(process.env.STELLAR_NETWORK) + const defaults = NETWORK_DEFAULTS[network] const mock = process.env.ENABLE_MOCK_STELLAR === "true" 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", + 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 || getDefaultExplorerBaseUrl(network), + explorerBaseUrl: process.env.STELLAR_EXPLORER_BASE_URL || defaults.explorerBaseUrl, mock, demoPublicKey: process.env.NEXT_PUBLIC_STELLAR_DEMO_PUBLIC_KEY || process.env.STELLAR_DEMO_PUBLIC_KEY || FALLBACK_DEMO_PUBLIC_KEY, } diff --git a/package-lock.json b/package-lock.json index c798d30..f5d658e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@solana-program/token": "^0.6.0", "@solana/kit": "^3.0.3", "@solana/sysvars": "^6.0.1", + "@stellar/stellar-sdk": "^14.6.1", "@tanstack/react-query": "^5.81.2", "@vercel/blob": "^2.3.3", "autoprefixer": "^10.4.20", @@ -71,6 +72,7 @@ "react-resizable-panels": "^2.1.7", "recharts": "latest", "resend": "^4.0.0", + "server-only": "^0.0.1", "sonner": "^1.7.1", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", @@ -9419,6 +9421,89 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@stellar/js-xdr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" + }, + "node_modules/@stellar/stellar-base": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", + "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.6", + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.3.1", + "buffer": "^6.0.3", + "sha.js": "^2.4.12" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-base/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@stellar/stellar-base/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", + "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^14.1.0", + "axios": "^1.13.3", + "bignumber.js": "^9.3.1", + "commander": "^14.0.2", + "eventsource": "^2.0.2", + "feaxios": "^0.0.23", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -12852,6 +12937,15 @@ "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -12906,6 +13000,15 @@ "url": "https://opencollective.com/bigjs" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -15167,6 +15270,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expect": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", @@ -15313,6 +15425,15 @@ } } }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, "node_modules/fetch-retry": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-6.0.0.tgz", @@ -16443,6 +16564,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-set": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", @@ -18995,6 +19128,15 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -19714,6 +19856,12 @@ "node": ">=10" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -20751,6 +20899,12 @@ "node": ">=8.0" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -21248,6 +21402,12 @@ "punycode": "^2.1.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", diff --git a/package.json b/package.json index 75dafb0..d431b63 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@solana-program/token": "^0.6.0", "@solana/kit": "^3.0.3", "@solana/sysvars": "^6.0.1", + "@stellar/stellar-sdk": "^14.6.1", "@tanstack/react-query": "^5.81.2", "@vercel/blob": "^2.3.3", "autoprefixer": "^10.4.20", @@ -75,6 +76,7 @@ "react-resizable-panels": "^2.1.7", "recharts": "latest", "resend": "^4.0.0", + "server-only": "^0.0.1", "sonner": "^1.7.1", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", @@ -95,10 +97,10 @@ "picomatch": "2.3.2" }, "devDependencies": { - "@types/jest": "^30.0.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.0.1", + "@types/jest": "^30.0.0", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19",