Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ NODE_ENV=development

# Stellar
STELLAR_NETWORK=testnet
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
# STELLAR_RPC_URL is optional — derived from STELLAR_NETWORK by default.
# Set this only if you need a custom RPC endpoint (e.g., self-hosted or devnet).
# STELLAR_RPC_URL=https://soroban-testnet.stellar.org
STELLAR_AGENT_SECRET_KEY=your_agent_stellar_secret_key_here
VAULT_CONTRACT_ID=your_deployed_contract_id_here
USDC_TOKEN_ADDRESS=testnet_usdc_contract_address_here
Expand Down
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 92 additions & 29 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,41 @@ function requireEnv(key: string): string {
return value
}

/**
* Default RPC URLs for each Stellar network.
* Used when STELLAR_RPC_URL is not explicitly set.
*/
const STELLAR_RPC_URLS: Record<string, string> = {
testnet: 'https://soroban-testnet.stellar.org',
mainnet: 'https://soroban-mainnet.stellar.org',
futurenet: 'https://soroban-futurenet.stellar.org',
}

/**
* Explorer URLs for each Stellar network.
*/
export const STELLAR_EXPLORER_URLS: Record<string, string> = {
testnet: 'https://stellar.expert/explorer/testnet',
mainnet: 'https://stellar.expert/explorer/public',
futurenet: 'https://stellar.expert/explorer/futurenet',
}

/**
* Derive the Stellar RPC URL from the network configuration.
* Priority: STELLAR_RPC_URL env var > network default.
*/
function deriveRpcUrl(network: string): string {
const envUrl = process.env.STELLAR_RPC_URL
if (envUrl) return envUrl

const defaultUrl = STELLAR_RPC_URLS[network]
if (!defaultUrl) {
throw new Error(`No default RPC URL for network "${network}". Set STELLAR_RPC_URL.`)
}

return defaultUrl
}

/**
* Validate all required environment variables at startup.
* Collects ALL missing/invalid vars before throwing so the operator
Expand All @@ -16,7 +51,6 @@ function requireEnv(key: string): string {
function validateAllRequiredEnvVars(): void {
const requiredVars = [
'STELLAR_NETWORK',
'STELLAR_RPC_URL',
'STELLAR_AGENT_SECRET_KEY',
'VAULT_CONTRACT_ID',
'USDC_TOKEN_ADDRESS',
Expand Down Expand Up @@ -146,6 +180,55 @@ function validateStellarNetwork(network: string): 'testnet' | 'mainnet' | 'futur
return lowerNetwork as 'testnet' | 'mainnet' | 'futurenet'
}

/**
* Derive the Stellar network from the secret key prefix.
* Stellar keys encode the network in the prefix:
* - S... = testnet
* - S... = mainnet (same prefix, but the actual network is determined by passphrase)
*
* Since key prefixes don't distinguish testnet/mainnet, we use the account
* to derive a hint and validate against the configured network.
*/
function deriveNetworkFromKeypair(secretKey: string, network: string): string {
// All Stellar secret keys start with 'S', so we can't derive the network
// from the prefix alone. Instead, we validate key format and rely on
// the configured network. The validation here is format-based.
return network
}

/**
* Validate that the secret key matches the expected network.
* While Stellar keys don't encode network in the prefix, we can:
* 1. Validate key format (must start with 'S', 56 chars)
* 2. Warn on mainnet usage in non-production environments
* 3. Log the active network prominently
*/
function validateKeypairNetworkMatch(
secretKey: string,
network: 'testnet' | 'mainnet' | 'futurenet'
): void {
if (!secretKey.startsWith('S')) {
throw new Error('STELLAR_AGENT_SECRET_KEY must start with S (invalid Stellar secret key format)')
}

if (secretKey.length !== 56) {
throw new Error(
`STELLAR_AGENT_SECRET_KEY invalid length: ${secretKey.length}. Stellar keys must be 56 characters.`
)
}

const env = process.env.NODE_ENV || 'development'
logger.info(`✓ Stellar Agent configured for ${network.toUpperCase()} (NODE_ENV=${env})`)

if (network === 'mainnet' && env !== 'production') {
console.warn(
'\n⚠️ CRITICAL WARNING: Using MAINNET in non-production environment!\n' +
'⚠️ This could result in real financial loss!\n' +
'⚠️ Verify STELLAR_NETWORK and NODE_ENV settings immediately!\n'
)
}
}

/** Parse `CORS_ORIGINS` / `ALLOWED_ORIGINS` (comma-separated or `*`). */
function parseCorsOrigins(): string[] | '*' {
const raw = (process.env.CORS_ORIGINS ?? process.env.ALLOWED_ORIGINS)?.trim()
Expand Down Expand Up @@ -190,38 +273,18 @@ function parseTrustProxy(
return value.trim()
}

/**
* Validate Stellar secret key format and warn on mainnet in dev.
*/
function validateStellarKey(secretKey: string, network: 'testnet' | 'mainnet' | 'futurenet'): void {
if (!secretKey.startsWith('S')) {
throw new Error('STELLAR_AGENT_SECRET_KEY must start with S (invalid Stellar secret key format)')
}

if (secretKey.length !== 56) {
throw new Error(
`STELLAR_AGENT_SECRET_KEY invalid length: ${secretKey.length}. Stellar keys must be 56 characters.`
)
}

const env = process.env.NODE_ENV || 'development'
logger.info(`✓ Stellar Agent configured for ${network.toUpperCase()} (NODE_ENV=${env})`)

if (network === 'mainnet' && env !== 'production') {
console.warn(
'\n⚠️ CRITICAL WARNING: Using MAINNET in non-production environment!\n' +
'⚠️ This could result in real financial loss!\n' +
'⚠️ Verify STELLAR_NETWORK and NODE_ENV settings immediately!\n'
)
}
}

// ── Run all validations before anything else is exported ──────────────────
validateAllRequiredEnvVars()

const stellarNetwork = validateStellarNetwork(requireEnv('STELLAR_NETWORK'))
const agentSecretKey = requireEnv('STELLAR_AGENT_SECRET_KEY')
validateStellarKey(agentSecretKey, stellarNetwork)
validateKeypairNetworkMatch(agentSecretKey, stellarNetwork)
const stellarRpcUrl = deriveRpcUrl(stellarNetwork)

// Log the active network prominently at startup
logger.info(`🌐 Active Stellar network: ${stellarNetwork.toUpperCase()}`)
logger.info(` RPC URL: ${stellarRpcUrl}`)
logger.info(` Explorer: ${STELLAR_EXPLORER_URLS[stellarNetwork]}`)

const corsOrigins = parseCorsOrigins()
const bodySizeLimit = parseByteLimit(
Expand All @@ -238,7 +301,7 @@ export const config = {
nodeEnv,
stellar: {
network: stellarNetwork,
rpcUrl: requireEnv('STELLAR_RPC_URL'),
rpcUrl: stellarRpcUrl,
agentSecretKey,
vaultContractId: requireEnv('VAULT_CONTRACT_ID'),
usdcTokenAddress: requireEnv('USDC_TOKEN_ADDRESS'),
Expand Down
48 changes: 47 additions & 1 deletion src/config/readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,50 @@
* still down — so a load balancer or k8s readiness probe won't send traffic
* to a half-booted instance.
*/
type Subsystem = 'eventListener' | 'agentLoop' | 'database'
import { config } from './env'
import { logger } from '../utils/logger'

type Subsystem = 'eventListener' | 'agentLoop' | 'database' | 'stellarNetwork'

interface ReadinessState {
eventListener: boolean
agentLoop: boolean
database: boolean
stellarNetwork: boolean
}

const state: ReadinessState = {
eventListener: false,
agentLoop: false,
database: false,
stellarNetwork: false,
}

/**
* Validate that the configured network is consistent.
* This catches misconfigurations where a testnet key is used against mainnet RPC
* or vice versa.
*/
function validateNetworkConsistency(): void {
const network = config.stellar.network
const rpcUrl = config.stellar.rpcUrl

// Check if RPC URL matches the configured network
const expectedPatterns: Record<string, string[]> = {
testnet: ['soroban-testnet', 'testnet'],
mainnet: ['soroban-mainnet', 'mainnet'],
futurenet: ['soroban-futurenet', 'futurenet'],
}

const patterns = expectedPatterns[network]
if (patterns && !patterns.some(p => rpcUrl.toLowerCase().includes(p))) {
logger.warn(
`⚠️ Network/RPC mismatch: STELLAR_NETWORK=${network} but RPC URL "${rpcUrl}" ` +
`does not appear to be a ${network} endpoint. Verify your configuration.`
)
} else {
logger.info(`✓ Stellar network consistency validated: ${network}`)
}
}

export function markReady(subsystem: Subsystem): void {
Expand All @@ -36,3 +68,17 @@ export function getReadiness(): {
const ready = Object.values(state).every((v) => v)
return { ready, subsystems: { ...state } }
}

/**
* Initialize network validation at startup.
* Should be called once during application bootstrap.
*/
export function validateStellarNetworkReady(): void {
try {
validateNetworkConsistency()
markReady('stellarNetwork')
} catch (error) {
logger.error('Stellar network validation failed:', error)
markNotReady('stellarNetwork')
}
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { connectDb } from './db'
import { scheduleSessionCleanup } from './jobs/sessionCleanup'
import { scheduleDataRetention } from './jobs/dataRetention'
import { startEventListener, stopEventListener } from './stellar/events'
import { validateStellarNetworkReady } from './config/readiness'
import healthRouter from './routes/health'
import agentRouter from './routes/agent'
import authRouter from './routes/auth'
Expand Down Expand Up @@ -234,6 +235,9 @@ async function initServices(): Promise<void> {
}
logger.info('[Startup] Twilio auth token configured ✓')

// 0. Validate Stellar network configuration
validateStellarNetworkReady()

// 1. Database
try {
await connectDb()
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/stellar/network-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Unit tests for Stellar network configuration
* Tests network derivation, RPC URL resolution, and keypair validation
*/

import { resolveNetworkPassphrase } from '../../../src/stellar/client'
import { STELLAR_EXPLORER_URLS } from '../../../src/config/env'

describe('Stellar Network Configuration', () => {
describe('resolveNetworkPassphrase', () => {
it('should resolve testnet passphrase', () => {
const passphrase = resolveNetworkPassphrase('testnet')
expect(passphrase).toBe('Test SDF Network ; September 2015')
})

it('should resolve mainnet passphrase', () => {
const passphrase = resolveNetworkPassphrase('mainnet')
expect(passphrase).toBe('Public Global Stellar Network ; September 2015')
})

it('should resolve futurenet passphrase', () => {
const passphrase = resolveNetworkPassphrase('futurenet')
expect(passphrase).toBe('Test SDF Future Network ; October 2022')
})

it('should handle case-insensitive network names', () => {
expect(resolveNetworkPassphrase('TESTNET')).toBe('Test SDF Network ; September 2015')
expect(resolveNetworkPassphrase('Mainnet')).toBe('Public Global Stellar Network ; September 2015')
})

it('should throw for unknown network', () => {
expect(() => resolveNetworkPassphrase('unknown')).toThrow('Unknown STELLAR_NETWORK')
expect(() => resolveNetworkPassphrase('devnet')).toThrow('Unknown STELLAR_NETWORK')
expect(() => resolveNetworkPassphrase(undefined)).toThrow('Unknown STELLAR_NETWORK')
})
})

describe('STELLAR_EXPLORER_URLS', () => {
it('should have explorer URLs for all supported networks', () => {
expect(STELLAR_EXPLORER_URLS.testnet).toBeDefined()
expect(STELLAR_EXPLORER_URLS.mainnet).toBeDefined()
expect(STELLAR_EXPLORER_URLS.futurenet).toBeDefined()
})

it('should have valid HTTPS URLs', () => {
Object.values(STELLAR_EXPLORER_URLS).forEach(url => {
expect(url).toMatch(/^https:\/\/stellar\.expert/)
})
})
})

describe('Network configuration derivation', () => {
const originalEnv = process.env

beforeEach(() => {
jest.resetModules()
process.env = { ...originalEnv }
})

afterAll(() => {
process.env = originalEnv
})

it('should derive testnet RPC URL when not explicitly set', () => {
process.env.STELLAR_NETWORK = 'testnet'
process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55)
process.env.VAULT_CONTRACT_ID = 'C' + 'B'.repeat(55)
process.env.USDC_TOKEN_ADDRESS = 'C' + 'C'.repeat(55)
process.env.ANTHROPIC_API_KEY = 'sk-ant-12345678901234567890'
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/db'
process.env.JWT_SEED = 'a'.repeat(32)
process.env.WALLET_ENCRYPTION_KEY = 'a'.repeat(64)
process.env.TWILIO_AUTH_TOKEN = 'b'.repeat(32)
process.env.NODE_ENV = 'development'

const { config } = require('../../../src/config/env')
expect(config.stellar.rpcUrl).toBe('https://soroban-testnet.stellar.org')
})

it('should use explicit RPC URL when set', () => {
process.env.STELLAR_NETWORK = 'testnet'
process.env.STELLAR_RPC_URL = 'https://custom-rpc.example.com'
process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55)
process.env.VAULT_CONTRACT_ID = 'C' + 'B'.repeat(55)
process.env.USDC_TOKEN_ADDRESS = 'C' + 'C'.repeat(55)
process.env.ANTHROPIC_API_KEY = 'sk-ant-12345678901234567890'
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/db'
process.env.JWT_SEED = 'a'.repeat(32)
process.env.WALLET_ENCRYPTION_KEY = 'a'.repeat(64)
process.env.TWILIO_AUTH_TOKEN = 'b'.repeat(32)
process.env.NODE_ENV = 'development'

const { config } = require('../../../src/config/env')
expect(config.stellar.rpcUrl).toBe('https://custom-rpc.example.com')
})

it('should throw for invalid network', () => {
process.env.STELLAR_NETWORK = 'invalid'
process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55)

expect(() => require('../../../src/config/env')).toThrow('Invalid STELLAR_NETWORK')
})
})
})