Skip to content

Commit d6fb097

Browse files
authored
Merge pull request #367 from raizo07/multi-network-support
feat: Add multi-network support
2 parents 6a7dc25 + 43ede14 commit d6fb097

11 files changed

Lines changed: 1668 additions & 716 deletions

backend/.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ ADMIN_IP_ALLOWLIST=
2121
# Server
2222
PORT=3001
2323

24-
# Indexer — tikka-indexer internal API
24+
# Stellar — testnet (default) or mainnet; drives Horizon / contract defaults and indexer URL if INDEXER_URL is unset
25+
STELLAR_NETWORK=testnet
26+
# Optional overrides (otherwise derived from STELLAR_NETWORK)
27+
# STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
28+
# STELLAR_CONTRACT_ID=C...
29+
30+
# Indexer — tikka-indexer internal API (omit to use the default URL for STELLAR_NETWORK)
2531
INDEXER_URL=http://localhost:3002
2632
INDEXER_TIMEOUT_MS=5000
2733

backend/README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,32 @@ The endpoint returns **HTTP 503** when `status` is `degraded`, so orchestrators
159159

160160
---
161161

162+
## Stellar network (Testnet / Mainnet)
163+
164+
The backend selects a Stellar network with **`STELLAR_NETWORK`** (`testnet` or `mainnet`). That value drives:
165+
166+
- **Horizon URL** — defaults to the public Horizon for the chosen network (`https://horizon-testnet.stellar.org` or `https://horizon.stellar.org`). Override with **`STELLAR_HORIZON_URL`** if you use a proxy or custom Horizon.
167+
- **Network passphrase** — exposed at runtime via `env.stellar.networkPassphrase` (same constants as the Stellar SDK) for any logic that must sign or verify against a specific network.
168+
- **Contract ID** — defaults are empty until you deploy; set **`STELLAR_CONTRACT_ID`** to your raffle (or other) contract for the environment you are running.
169+
- **Indexer base URL** — if **`INDEXER_URL`** is not set, it defaults to the URL in `stellar.constants.ts` for that network (currently `http://localhost:3002` for both). In production, set **`INDEXER_URL`** explicitly to the tikka-indexer instance that indexes the same chain as **`STELLAR_NETWORK`**.
170+
171+
Injectable services should read **`INDEXER_URL`** and **`INDEXER_TIMEOUT_MS`** from Nest **`ConfigService`** (validated at startup). For scripts or non-DI code, use **`env.indexer`** and **`env.stellar`** from `src/config/env.config.ts`.
172+
173+
Example `.env` fragments:
174+
175+
```dotenv
176+
# Local development against testnet
177+
STELLAR_NETWORK=testnet
178+
INDEXER_URL=http://localhost:3002
179+
180+
# Production-style: mainnet Horizon defaults; point indexer at your fleet
181+
STELLAR_NETWORK=mainnet
182+
STELLAR_CONTRACT_ID=YOUR_MAINNET_CONTRACT_ID
183+
INDEXER_URL=https://your-indexer.example.com
184+
```
185+
186+
---
187+
162188
## Environment Variables
163189

164190
Copy `.env.example` to `.env` and fill in the required values before starting the server.
@@ -186,7 +212,10 @@ These must be set or the app will refuse to start:
186212
| Variable | Default | Description |
187213
| -------------------------- | -------------------------- | ------------------------------------------------ |
188214
| `PORT` | `3001` | HTTP port the server listens on |
189-
| `INDEXER_URL` | `http://localhost:3002` | Base URL of the tikka-indexer internal API |
215+
| `STELLAR_NETWORK` | `testnet` | `testnet` or `mainnet` — Horizon, contract, and default indexer base |
216+
| `STELLAR_HORIZON_URL` | (from network) | Override Horizon URL (optional) |
217+
| `STELLAR_CONTRACT_ID` | (none) | On-chain contract id for this deployment (optional) |
218+
| `INDEXER_URL` | (per `STELLAR_NETWORK`) | Base URL of tikka-indexer; set explicitly in prod |
190219
| `INDEXER_TIMEOUT_MS` | `5000` | HTTP timeout for indexer requests (ms) |
191220
| `JWT_EXPIRES_IN` | `7d` | JWT expiry duration (e.g. `1h`, `7d`) |
192221
| `SIWS_DOMAIN` | `tikka.io` | Domain shown in the SIWS sign-in message |

backend/pnpm-lock.yaml

Lines changed: 1381 additions & 693 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/src/config/env.config.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,45 @@
22
* Environment configuration for tikka-backend.
33
* Using getters ensures environment variables are read at runtime,
44
* avoiding issues where they might be cached before values are loaded.
5+
*
6+
* Stellar network fields align with Nest `ConfigService` after `env.schema` validation.
7+
* Prefer `ConfigService` in injectable services for INDEXER_URL / timeouts;
8+
* use this module for `stellar` resolution and non-DI contexts.
59
*/
10+
import {
11+
resolveIndexerBaseUrl,
12+
resolveStellarContractId,
13+
resolveStellarHorizonUrl,
14+
resolveStellarNetworkId,
15+
resolveStellarNetworkPassphrase,
16+
} from './stellar.constants';
17+
18+
function envLikeFromProcess(): Record<string, string | undefined> {
19+
return { ...process.env };
20+
}
21+
622
export const env = {
723
get supabase() {
824
return {
9-
url: process.env.SUPABASE_URL ?? "",
10-
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
25+
url: process.env.SUPABASE_URL ?? '',
26+
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
1127
};
1228
},
1329
get indexer() {
30+
const envLike = envLikeFromProcess();
1431
return {
15-
url: process.env.INDEXER_URL ?? "http://localhost:3002",
16-
timeoutMs: parseInt(process.env.INDEXER_TIMEOUT_MS ?? "5000", 10),
32+
url: resolveIndexerBaseUrl(envLike),
33+
timeoutMs: parseInt(process.env.INDEXER_TIMEOUT_MS ?? '5000', 10),
34+
};
35+
},
36+
get stellar() {
37+
const envLike = envLikeFromProcess();
38+
const network = resolveStellarNetworkId(envLike);
39+
return {
40+
network,
41+
horizonUrl: resolveStellarHorizonUrl(envLike),
42+
networkPassphrase: resolveStellarNetworkPassphrase(envLike),
43+
contractId: resolveStellarContractId(envLike),
1744
};
1845
},
1946
get jwt() {
@@ -25,7 +52,7 @@ export const env = {
2552
},
2653
get siws() {
2754
return {
28-
domain: process.env.SIWS_DOMAIN ?? "tikka.io",
55+
domain: process.env.SIWS_DOMAIN ?? 'tikka.io',
2956
};
3057
},
3158
get fcm() {

backend/src/config/env.schema.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const validEnv: Record<string, string> = {
44
PORT: '3001',
55
SUPABASE_URL: 'https://test.supabase.co',
66
SUPABASE_SERVICE_ROLE_KEY: 'test-service-role-key',
7+
STELLAR_NETWORK: 'testnet',
78
INDEXER_URL: 'http://localhost:3002',
89
INDEXER_TIMEOUT_MS: '5000',
910
JWT_SECRET: 'a'.repeat(32),
@@ -25,6 +26,8 @@ describe('env.schema validate()', () => {
2526
const minimal: Record<string, string> = {
2627
SUPABASE_URL: 'https://test.supabase.co',
2728
SUPABASE_SERVICE_ROLE_KEY: 'key',
29+
STELLAR_NETWORK: 'testnet',
30+
INDEXER_URL: '',
2831
JWT_SECRET: 'b'.repeat(32),
2932
VITE_FRONTEND_URL: 'https://app.tikka.io',
3033
ADMIN_TOKEN: 'my-admin-token',
@@ -96,4 +99,39 @@ describe('env.schema validate()', () => {
9699
const result = validate({ ...validEnv, ADMIN_IP_ALLOWLIST: '192.168.1.0/24,10.0.0.1' });
97100
expect(result.ADMIN_IP_ALLOWLIST).toBe('192.168.1.0/24,10.0.0.1');
98101
});
102+
103+
it('defaults STELLAR_NETWORK to testnet when omitted', () => {
104+
const prev = process.env.STELLAR_NETWORK;
105+
delete process.env.STELLAR_NETWORK;
106+
try {
107+
const { STELLAR_NETWORK: _, ...withoutStellar } = validEnv;
108+
const result = validate(withoutStellar);
109+
expect(result.STELLAR_NETWORK).toBe('testnet');
110+
} finally {
111+
if (prev !== undefined) process.env.STELLAR_NETWORK = prev;
112+
}
113+
});
114+
115+
it('fills INDEXER_URL from network defaults when INDEXER_URL is omitted', () => {
116+
const prevIndexer = process.env.INDEXER_URL;
117+
delete process.env.INDEXER_URL;
118+
try {
119+
const { INDEXER_URL: _, ...rest } = validEnv;
120+
const result = validate({ ...rest, STELLAR_NETWORK: 'mainnet' });
121+
expect(result.INDEXER_URL).toBe('http://localhost:3002');
122+
expect(result.STELLAR_NETWORK).toBe('mainnet');
123+
} finally {
124+
if (prevIndexer !== undefined) process.env.INDEXER_URL = prevIndexer;
125+
}
126+
});
127+
128+
it('accepts STELLAR_CONTRACT_ID and STELLAR_HORIZON_URL overrides', () => {
129+
const result = validate({
130+
...validEnv,
131+
STELLAR_CONTRACT_ID: 'CCONTRACTTEST1234567890123456789012',
132+
STELLAR_HORIZON_URL: 'https://horizon-custom.example.com',
133+
});
134+
expect(result.STELLAR_CONTRACT_ID).toBe('CCONTRACTTEST1234567890123456789012');
135+
expect(result.STELLAR_HORIZON_URL).toBe('https://horizon-custom.example.com');
136+
});
99137
});

backend/src/config/env.schema.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,46 @@
11
import { z } from 'zod';
2+
import {
3+
resolveIndexerBaseUrl,
4+
resolveStellarNetworkId,
5+
} from './stellar.constants';
6+
7+
function toEnvLike(config: Record<string, unknown>): Record<string, string | undefined> {
8+
const merged: Record<string, string | undefined> = { ...process.env };
9+
for (const [k, v] of Object.entries(config)) {
10+
if (v === undefined || v === null) continue;
11+
merged[k] = String(v);
12+
}
13+
return merged;
14+
}
15+
16+
/**
17+
* Normalize STELLAR_NETWORK and fill INDEXER_URL when omitted so it matches
18+
* the active Stellar network (see stellar.constants).
19+
*/
20+
function preprocessStellarEnv(
21+
input: Record<string, unknown>,
22+
): Record<string, unknown> {
23+
const c = { ...input };
24+
const envLike = toEnvLike(c);
25+
const network = resolveStellarNetworkId(envLike);
26+
c.STELLAR_NETWORK = network;
27+
28+
const idx = c.INDEXER_URL;
29+
if (idx === undefined || idx === null || String(idx).trim() === '') {
30+
c.INDEXER_URL = resolveIndexerBaseUrl({
31+
...envLike,
32+
STELLAR_NETWORK: network,
33+
INDEXER_URL: undefined,
34+
});
35+
}
36+
37+
const hz = c.STELLAR_HORIZON_URL;
38+
if (hz === '' || hz === null) delete c.STELLAR_HORIZON_URL;
39+
const cid = c.STELLAR_CONTRACT_ID;
40+
if (cid === '' || cid === null) delete c.STELLAR_CONTRACT_ID;
41+
42+
return c;
43+
}
244

345
/**
446
* Zod schema for process.env validation.
@@ -10,7 +52,7 @@ import { z } from 'zod';
1052
* through without failing validation — ConfigModule passes the entire
1153
* process.env object to the validate function.
1254
*/
13-
export const envSchema = z
55+
const envSchemaInner = z
1456
.object({
1557
// Server
1658
PORT: z.coerce.number().int().positive().default(3001),
@@ -19,8 +61,13 @@ export const envSchema = z
1961
SUPABASE_URL: z.string().url(),
2062
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
2163

22-
// Indexer
23-
INDEXER_URL: z.string().url().default('http://localhost:3002'),
64+
// Stellar — network drives Horizon / contract defaults unless overridden
65+
STELLAR_NETWORK: z.enum(['testnet', 'mainnet']).default('testnet'),
66+
STELLAR_HORIZON_URL: z.string().url().optional(),
67+
STELLAR_CONTRACT_ID: z.string().min(1).optional(),
68+
69+
// Indexer (INDEXER_URL filled in preprocess when empty)
70+
INDEXER_URL: z.string().url(),
2471
INDEXER_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
2572

2673
// JWT
@@ -56,7 +103,9 @@ export const envSchema = z
56103
})
57104
.passthrough();
58105

59-
export type EnvConfig = z.infer<typeof envSchema>;
106+
export const envSchema = z.preprocess(preprocessStellarEnv, envSchemaInner);
107+
108+
export type EnvConfig = z.infer<typeof envSchemaInner>;
60109

61110
/**
62111
* Validate function for `ConfigModule.forRoot({ validate })`.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Stellar network identifiers and defaults (Horizon, passphrase, indexer base URL).
3+
* Used by env validation and runtime env.config getters so behavior stays aligned.
4+
*/
5+
export const STELLAR_NETWORK_IDS = ['testnet', 'mainnet'] as const;
6+
export type StellarNetworkId = (typeof STELLAR_NETWORK_IDS)[number];
7+
8+
export const STELLAR_NETWORK_DEFAULTS: Record<
9+
StellarNetworkId,
10+
{
11+
horizonUrl: string;
12+
networkPassphrase: string;
13+
/** Base URL when INDEXER_URL is unset — override INDEXER_URL in production. */
14+
defaultIndexerUrl: string;
15+
/** Placeholder; set STELLAR_CONTRACT_ID for your deployment. */
16+
contractId: string;
17+
}
18+
> = {
19+
testnet: {
20+
horizonUrl: 'https://horizon-testnet.stellar.org',
21+
networkPassphrase: 'Test SDF Network ; September 2015',
22+
defaultIndexerUrl: 'http://localhost:3002',
23+
contractId: '',
24+
},
25+
mainnet: {
26+
horizonUrl: 'https://horizon.stellar.org',
27+
networkPassphrase: 'Public Global Stellar Network ; September 2015',
28+
defaultIndexerUrl: 'http://localhost:3002',
29+
contractId: '',
30+
},
31+
};
32+
33+
function normalizeNetwork(
34+
raw: string | undefined,
35+
): StellarNetworkId {
36+
const n = (raw ?? 'testnet').toLowerCase();
37+
return n === 'mainnet' ? 'mainnet' : 'testnet';
38+
}
39+
40+
/** Resolve network id from a loose env record (e.g. validate() input merged with process.env). */
41+
export function resolveStellarNetworkId(
42+
envLike: Record<string, string | undefined>,
43+
): StellarNetworkId {
44+
return normalizeNetwork(envLike.STELLAR_NETWORK);
45+
}
46+
47+
/** Indexer API base URL: explicit INDEXER_URL wins, else per-network default. */
48+
export function resolveIndexerBaseUrl(
49+
envLike: Record<string, string | undefined>,
50+
): string {
51+
const explicit = envLike.INDEXER_URL?.trim();
52+
if (explicit) return explicit;
53+
const network = resolveStellarNetworkId(envLike);
54+
return STELLAR_NETWORK_DEFAULTS[network].defaultIndexerUrl;
55+
}
56+
57+
export function resolveStellarHorizonUrl(
58+
envLike: Record<string, string | undefined>,
59+
): string {
60+
const override = envLike.STELLAR_HORIZON_URL?.trim();
61+
if (override) return override;
62+
const network = resolveStellarNetworkId(envLike);
63+
return STELLAR_NETWORK_DEFAULTS[network].horizonUrl;
64+
}
65+
66+
export function resolveStellarContractId(
67+
envLike: Record<string, string | undefined>,
68+
): string {
69+
const override = envLike.STELLAR_CONTRACT_ID?.trim();
70+
if (override) return override;
71+
const network = resolveStellarNetworkId(envLike);
72+
return STELLAR_NETWORK_DEFAULTS[network].contractId;
73+
}
74+
75+
export function resolveStellarNetworkPassphrase(
76+
envLike: Record<string, string | undefined>,
77+
): string {
78+
const network = resolveStellarNetworkId(envLike);
79+
return STELLAR_NETWORK_DEFAULTS[network].networkPassphrase;
80+
}

backend/src/health/health.service.spec.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ConfigService } from '@nestjs/config';
12
import { Test, TestingModule } from '@nestjs/testing';
23
import { HealthService } from './health.service';
34

@@ -18,7 +19,20 @@ describe('HealthService', () => {
1819

1920
beforeEach(async () => {
2021
const module: TestingModule = await Test.createTestingModule({
21-
providers: [HealthService],
22+
providers: [
23+
HealthService,
24+
{
25+
provide: ConfigService,
26+
useValue: {
27+
getOrThrow: (key: string) => {
28+
if (key === 'INDEXER_URL') return 'http://indexer.test';
29+
throw new Error(`unexpected key ${key}`);
30+
},
31+
get: (key: string, def?: number) =>
32+
key === 'INDEXER_TIMEOUT_MS' ? 3000 : def,
33+
},
34+
},
35+
],
2236
}).compile();
2337

2438
service = module.get<HealthService>(HealthService);

backend/src/health/health.service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Injectable } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
23
import { env } from '../config/env.config';
34

45
export interface HealthResult {
@@ -15,9 +16,11 @@ export class HealthService {
1516
private readonly supabaseUrl: string;
1617
private readonly supabaseKey: string;
1718

18-
constructor() {
19-
this.indexerUrl = env.indexer.url.replace(/\/$/, '');
20-
this.indexerTimeoutMs = env.indexer.timeoutMs;
19+
constructor(private readonly config: ConfigService) {
20+
this.indexerUrl = this.config
21+
.getOrThrow<string>('INDEXER_URL')
22+
.replace(/\/$/, '');
23+
this.indexerTimeoutMs = this.config.get<number>('INDEXER_TIMEOUT_MS', 5000);
2124
this.supabaseUrl = env.supabase.url.replace(/\/$/, '');
2225
this.supabaseKey = env.supabase.serviceRoleKey;
2326
}

0 commit comments

Comments
 (0)