diff --git a/.gitignore b/.gitignore index bea4619..ac0c2db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,12 @@ coverage/ *.lcov coverage-final.json clover.xml + +# Local environment files. .env is loaded by dotenv at boot and holds live +# secrets (ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY, +# DEPLOYER_SECRET_KEY). The committed example file is the only environment +# file that belongs in git. +.env +.env.local +.env.*.local +!.env.example diff --git a/src/__tests__/stellar.service.test.ts b/src/__tests__/stellar.service.test.ts index e14b045..e543ced 100644 --- a/src/__tests__/stellar.service.test.ts +++ b/src/__tests__/stellar.service.test.ts @@ -1,4 +1,4 @@ -import { StellarService, InsufficientReserveError } from '../services/stellar.service'; +import { StellarService, InsufficientReserveError, resolveNetwork } from '../services/stellar.service'; import * as StellarSdk from '@stellar/stellar-sdk'; import { config } from '../config/env'; import { decrypt } from '../utils/encryption.util'; @@ -92,6 +92,30 @@ describe('StellarService', () => { mockRedisKeys.mockResolvedValue([]); }); + describe('resolveNetwork', () => { + it('should resolve TESTNET to the testnet horizon and passphrase', () => { + const network = resolveNetwork('TESTNET'); + + expect(network.horizonUrl).toBe('https://horizon-testnet.stellar.org'); + expect(network.passphrase).toBe(StellarSdk.Networks.TESTNET); + }); + + it('should resolve PUBLIC to the public horizon and passphrase', () => { + const network = resolveNetwork('PUBLIC'); + + expect(network.horizonUrl).toBe('https://horizon.stellar.org'); + expect(network.passphrase).toBe(StellarSdk.Networks.PUBLIC); + }); + + it('should reject unsupported network values naming the variable and accepted values', () => { + expect(() => resolveNetwork('testnet')).toThrow(/STELLAR_NETWORK/); + expect(() => resolveNetwork('testnet')).toThrow(/TESTNET/); + expect(() => resolveNetwork('testnet')).toThrow(/PUBLIC/); + expect(() => resolveNetwork('PUBLIC ')).toThrow(/STELLAR_NETWORK/); + expect(() => resolveNetwork('STAGING')).toThrow(/STELLAR_NETWORK/); + }); + }); + describe('generateWallet', () => { it('should return a generated keypair with encrypted raw seed', () => { const wallet = stellarService.generateWallet(); @@ -115,6 +139,13 @@ describe('StellarService', () => { expect(StellarSdk.Horizon.Server).toHaveBeenCalledWith('https://horizon.stellar.org'); }); + + it('should use the testnet horizon when configured for testnet', () => { + config.STELLAR_NETWORK = 'TESTNET'; + new StellarService(); + + expect(StellarSdk.Horizon.Server).toHaveBeenCalledWith('https://horizon-testnet.stellar.org'); + }); }); describe('fundTestnetAccount', () => { @@ -261,6 +292,21 @@ describe('StellarService', () => { ); }); + it('should use the testnet network passphrase when configured for testnet', async () => { + config.STELLAR_NETWORK = 'TESTNET'; + stellarService = new StellarService(); + + const validPublicKey = 'GBBM6BKZPEHWPI3VK3VNKEJEXTMIGNNCE2ZEXSVEEKSJNDYTK2E4QUDE'; + await stellarService.sendPayment(mockSeed, validPublicKey, '10.0'); + + expect(StellarSdk.TransactionBuilder).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + networkPassphrase: StellarSdk.Networks.TESTNET, + }), + ); + }); + it('should invalidate redis cache after sendPayment', async () => { mockRedisScan.mockResolvedValueOnce(['0', ['tx_history:G_MOCK:page:1']]); mockRedisScan.mockResolvedValueOnce(['0', ['tx_history:GBBM6BKZPEHWPI3VK3VNKEJEXTMIGNNCE2ZEXSVEEKSJNDYTK2E4QUDE:page:1']]); diff --git a/src/config/env.ts b/src/config/env.ts index 7d4b374..9faa387 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -13,6 +13,30 @@ if (!process.env.JWT_SECRET) { throw new Error('JWT_SECRET environment variable is required'); } +export const SUPPORTED_STELLAR_NETWORKS = ['TESTNET', 'PUBLIC'] as const; +export type StellarNetwork = typeof SUPPORTED_STELLAR_NETWORKS[Number]; + +const rawStellarNetwork = process.env.STELLAR_NETWORK || 'TESTNET'; +if (!SUPPORTED_STELLAR_NETWORKS.includes(rawStellarNetwork as StellarNetwork)) { + throw new Error( + `STELLAR_NETWORK must be one of ${SUPPORTED_STELLAR_NETWORKS.join(', ')} (got "${rawStellarNetwork}")` + ); +} +const stellarNetwork = rawStellarNetwork as StellarNetwork; + +export const STELLAR_NETWORK_CONFIG = { + TESTNET: { + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + }, + PUBLIC: { + horizonUrl: 'https://horizon.stellar.org', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + }, +} as const; + +export const stellarNetworkConfig = STELLAR_NETWORK_CONFIG[stellarNetwork]; + export const config = { PORT: process.env.PORT || 3000, WHATSAPP_TOKEN: process.env.WHATSAPP_TOKEN || '', @@ -20,7 +44,7 @@ export const config = { WHATSAPP_APP_SECRET: process.env.WHATSAPP_APP_SECRET || '', VERIFY_TOKEN: process.env.VERIFY_TOKEN || 'kolo_verify_token', DATABASE_URL: process.env.DATABASE_URL || '', - STELLAR_NETWORK: process.env.STELLAR_NETWORK || 'TESTNET', // TESTNET or PUBLIC + STELLAR_NETWORK: stellarNetwork, REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379', ENCRYPTION_KEY: process.env.ENCRYPTION_KEY || '', // Must be a 32-byte hex string CURRENT_ENCRYPTION_KEY_VERSION: currentVersionParsed, @@ -30,7 +54,7 @@ export const config = { 3: process.env.ENCRYPTION_KEY_V3 || '', } as Record, HMAC_KEY: process.env.HMAC_KEY || '', // Must be a 32-byte hex string, different from ENCRYPTION_KEY - USDC_ISSUER_PUBLIC_KEY: process.env.USDC_ISSUER_PUBLIC_KEY || '', // SDF testnet issuer or Circle/centre.io mainnet issuer, depending on STELLAR_NETWORK + USDC_ISSUER_PUBLIC_KEY: process.env.USDC_ISSUER_PUBLIC_KEY || '', // SDF Testnet issuer or Circle/centre.io mainnet issuer, depending on STELLAR_NETWORK SOROBAN_RPC_URL: process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org', CONTRACT_WASM_PATH: process.env.CONTRACT_WASM_PATH || 'contracts/savings_group.wasm', DEPLOYER_SECRET_KEY: process.env.DEPLOYER_SECRET_KEY || '', @@ -41,5 +65,6 @@ export const config = { SEP10_HOME_DOMAIN: process.env.SEP10_HOME_DOMAIN || 'localhost', JWT_SECRET: process.env.JWT_SECRET, SEP10_CHALLENGE_TTL_SECONDS: parseInt(process.env.SEP10_CHALLENGE_TTL_SECONDS || '300', 10), + STELLAR_HORIZON_URL: stellarNetworkConfig.horizonUrl, + STELLAR_NETWORK_PASSPHRASE: stellarNetworkConfig.networkPassphrase, }; - diff --git a/src/services/stellar.service.ts b/src/services/stellar.service.ts index 834d0d5..8e1bd01 100644 --- a/src/services/stellar.service.ts +++ b/src/services/stellar.service.ts @@ -31,15 +31,23 @@ export class InsufficientReserveError extends Error { */ export class StellarService { private server: StellarSdk.Horizon.Server; + private networkPassphrase: string; /** * Initializes the Stellar horizon connection based on configured network mode. */ constructor() { - if (config.STELLAR_NETWORK === 'PUBLIC') { + // config.STELLAR_NETWORK is validated at boot (see src/config/env.ts), so + // it is guaranteed to be either 'TESTNET' or 'PUBLIC' here. Derive both + // the Horizon host and the network passphrase from this single value so + // they can never disagree. + const network = config.STELLAR_NETWORK; + if (network === 'PUBLIC') { this.server = new StellarSdk.Horizon.Server('https://horizon.stellar.org'); + this.networkPassphrase = StellarSdk.Networks.PUBLIC; } else { this.server = new StellarSdk.Horizon.Server('https://horizon-testnet.stellar.org'); + this.networkPassphrase = StellarSdk.Networks.TESTNET; } } @@ -374,12 +382,6 @@ export class StellarService { return { hash: result.hash }; } - private networkPassphrase(): string { - return config.STELLAR_NETWORK === 'TESTNET' - ? StellarSdk.Networks.TESTNET - : StellarSdk.Networks.PUBLIC; - } - private isLowReserveError(error: any): boolean { const resultCodes = error?.response?.data?.extras?.result_codes; return resultCodes?.operations?.includes('op_low_reserve') || resultCodes?.transaction === 'tx_insufficient_balance';