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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 47 additions & 1 deletion src/__tests__/stellar.service.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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']]);
Expand Down
31 changes: 28 additions & 3 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,38 @@ 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 || '',
WHATSAPP_PHONE_NUMBER_ID: process.env.WHATSAPP_PHONE_NUMBER_ID || '',
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,
Expand All @@ -30,7 +54,7 @@ export const config = {
3: process.env.ENCRYPTION_KEY_V3 || '',
} as Record<number, string>,
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 || '',
Expand All @@ -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,
};

16 changes: 9 additions & 7 deletions src/services/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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';
Expand Down