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
67 changes: 67 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
# Local:
# DATABASE_URL="postgresql://wraith:wraith@localhost:5432/wraith"
# DIRECT_DATABASE_URL="postgresql://wraith:wraith@localhost:5432/wraith"
# Both database URLs are required at startup. All other values below are
# optional unless a comment describes a conditional requirement.
#
# Supabase (pooler for queries, direct for migrations):
# DATABASE_URL="postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres?pgbouncer=true&sslmode=require"
Expand Down Expand Up @@ -151,3 +153,68 @@ LP_POOL_CONTRACT_IDS_MAINNET=
# HTTP tests run against a seeded database without an ingest racing them, and
# without needing live RPC. Not for production.
SKIP_INDEXER=false

# ─── Additional runtime settings ────────────────────────────────────────────
# Every value in this section is optional. Empty values are placeholders only.

# Prisma read replicas (comma-separated connection URLs).
DATABASE_REPLICAS= # Optional; URLs for additional read-only database replicas.
# Use "development", "test", or "production" to control Prisma logging and caching.
NODE_ENV= # Optional; runtime environment name.

# Indexer network selection; NETWORKS overrides the single STELLAR_NETWORK default.
NETWORKS= # Optional; comma-separated networks to index (testnet,mainnet).
# Optional per-network RPC overrides and legacy alias for STELLAR_RPC_URL.
SOROBAN_RPC_URL_TESTNET= # Optional; testnet Soroban RPC endpoint.
SOROBAN_RPC_URL_MAINNET= # Required when indexing mainnet, unless a legacy RPC alias is used.
STELLAR_RPC_URL= # Optional; legacy RPC endpoint for a single-network deployment.
STELLAR_RPC_URL_TESTNET= # Optional; legacy testnet RPC endpoint.
STELLAR_RPC_URL_MAINNET= # Required when indexing mainnet unless another mainnet RPC URL is set.
# Optional Horizon endpoints and event path for indexing / price lookup.
HORIZON_URL= # Optional; legacy Horizon endpoint.
HORIZON_URL_TESTNET= # Optional; testnet Horizon endpoint.
HORIZON_URL_MAINNET= # Optional; mainnet Horizon endpoint and price lookup source.
HORIZON_EVENTS_PATH= # Optional; Horizon events API path.
# Optional per-network start ledger overrides; blank resumes from database state.
START_LEDGER_TESTNET= # Optional; testnet ledger to start from.
START_LEDGER_MAINNET= # Optional; mainnet ledger to start from.

# Optional rate limits (defaults: 60 seconds and 60 requests).
RATE_LIMIT_WINDOW_MS=60000 # Optional; rate-limit window in milliseconds.
RATE_LIMIT_MAX=60 # Optional; requests allowed per IP in each window.
# Optional maximum number of rows returned by transfer exports (default: 50000).
EXPORT_MAX_ROWS=50000 # Optional; maximum rows per export.
# Optional interval between partition-retention runs (default: six hours).
PARTITION_RETENTION_INTERVAL_MS=21600000 # Optional; retention job interval in milliseconds.
# Optional persisted GraphQL query file path and query complexity limits.
PERSISTED_QUERIES_PATH= # Optional; persisted GraphQL query file.
GRAPHQL_MAX_DEPTH=10 # Optional; GraphQL query depth limit.
GRAPHQL_MAX_COST=1000 # Optional; GraphQL query complexity limit.
# npm supplies this automatically when the service is launched by npm.
# npm_package_version=1.0.0

# Optional OPA authorization settings (defaults to localhost:8181 and wraith/authz).
OPA_URL=http://localhost:8181 # Optional; Open Policy Agent URL.
OPA_POLICY=wraith/authz # Optional; OPA policy path.
# Optional mainnet CoinGecko/Horizon price lookup endpoint.
# HORIZON_URL_MAINNET is also used by the indexer; leave blank to use its code default.

# Optional LINQ integration. Keep actual credentials in deployment secrets, never here.
LINQ_API_KEY= # Optional; secret API key for LINQ integrations.
LINQ_BASE_URL= # Optional; LINQ API base URL.
LINQ_WEBHOOK_SECRET= # Optional; secret used to verify LINQ webhook requests.
OFFRAMP_LOOKUP_WINDOW_MS=900000 # Optional; off-ramp lookup rate-limit window.
OFFRAMP_LOOKUP_MAX_FAILURES=10 # Optional; failed lookups allowed per window.

# Optional platform fee configuration. Fees are disabled unless a valid destination
# and asset configuration are supplied.
VEIL_FEE_DESTINATION= # Optional; fee recipient account (required to enable fees).
VEIL_FEE_PERCENT= # Optional; fee rate percentage.
VEIL_FEE_AMOUNT= # Optional; flat fee for the configured issued asset.
VEIL_FEE_MIN= # Optional; minimum fee for the configured issued asset.
VEIL_FEE_MAX= # Optional; maximum fee for the configured issued asset.
VEIL_FEE_ASSET_CODE=USDC # Optional; issued asset code (default: USDC).
VEIL_FEE_ASSET_ISSUER= # Optional; issued asset issuer account.
VEIL_FEE_AMOUNT_XLM= # Optional; flat fee in native XLM.
VEIL_FEE_MIN_XLM= # Optional; minimum fee in native XLM.
VEIL_FEE_MAX_XLM= # Optional; maximum fee in native XLM.
95 changes: 95 additions & 0 deletions src/__tests__/envValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { readFileSync, readdirSync } from "fs";
import path from "path";
import { validateEnv } from "../validateEnv";

const sourceRoot = path.resolve(__dirname, "..");
const repositoryRoot = path.resolve(sourceRoot, "..");

function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return entry.name === "__tests__" ? [] : sourceFiles(fullPath);
}
return entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")
? [fullPath]
: [];
});
}

function environmentReads(): Set<string> {
const names = new Set<string>();
const staticRead = /\b(?:process\.)?env\.([A-Z][A-Z0-9_]*)|\bprocess\.env\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/g;
const dynamicNetworkRead = /\bprocess\.env\[\s*`([A-Z][A-Z0-9_]*)\$\{/g;
const namedRead = /readPositiveInt\(\s*["']([A-Z][A-Z0-9_]*)["']/g;

for (const file of sourceFiles(sourceRoot)) {
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(staticRead)) {
names.add(match[1] ?? match[2]);
}
for (const match of source.matchAll(dynamicNetworkRead)) {
names.add(`${match[1]}TESTNET`);
names.add(`${match[1]}MAINNET`);
}
for (const match of source.matchAll(namedRead)) {
names.add(match[1]);
}
}

return names;
}

describe("environment configuration", () => {
it("documents every environment variable read by runtime source", () => {
const example = readFileSync(path.join(repositoryRoot, ".env.example"), "utf8");
const documented = new Set(
[...example.matchAll(/^\s*#?\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map(
(match) => match[1],
),
);
const undocumented = [...environmentReads()].filter((name) => !documented.has(name)).sort();

expect(undocumented).toEqual([]);
});

it("reports all missing required variables together", () => {
expect(() => validateEnv({ STELLAR_NETWORK: "mainnet" })).toThrow(
"Missing required env vars: DATABASE_URL, DIRECT_DATABASE_URL, SOROBAN_RPC_URL_MAINNET",
);
});

it("accepts the legacy RPC URL for a single-network mainnet deployment", () => {
expect(() =>
validateEnv({
DATABASE_URL: "postgresql://localhost/wraith",
DIRECT_DATABASE_URL: "postgresql://localhost/wraith",
STELLAR_NETWORK: "mainnet",
SOROBAN_RPC_URL: "https://rpc.example.invalid",
}),
).not.toThrow();
});

it("requires a network-specific RPC URL when indexing multiple networks", () => {
expect(() =>
validateEnv({
DATABASE_URL: "postgresql://localhost/wraith",
DIRECT_DATABASE_URL: "postgresql://localhost/wraith",
STELLAR_NETWORK: "testnet",
NETWORKS: "testnet,mainnet",
SOROBAN_RPC_URL: "https://rpc.example.invalid",
}),
).toThrow(/SOROBAN_RPC_URL_MAINNET/);
});

it("does not require RPC when the indexer is disabled", () => {
expect(() =>
validateEnv({
DATABASE_URL: "postgresql://localhost/wraith",
DIRECT_DATABASE_URL: "postgresql://localhost/wraith",
STELLAR_NETWORK: "mainnet",
SKIP_INDEXER: "true",
}),
).not.toThrow();
});
});
30 changes: 18 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import "dotenv/config";
import http from "http";
import { execSync } from "child_process";
import { createApp } from "./api";
import { startAllIndexers } from "./indexer";
import { prisma } from "./db";
import { attachWebSocketServer } from "./ws";
import { attachGraphQLSubscriptions, SUBSCRIPTIONS_PATH } from "./graphql/subscriptions";
import { startWebhookWorker } from "./workers/webhooks";
import { startPartitionRetentionJob } from "./jobs/retention";
import { initTokenCache } from "./tokenCache";
import { enabledNetworks } from "./network";
import { onlyAddsUniqueConstraints, pushSchema } from "./schemaGuard";
import { validateEnv } from "./validateEnv";

validateEnv();

const http = require("http") as typeof import("http");
const { createApp } = require("./api") as typeof import("./api");
const { startAllIndexers } = require("./indexer") as typeof import("./indexer");
const { prisma } = require("./db") as typeof import("./db");
const { attachWebSocketServer } = require("./ws") as typeof import("./ws");
const {
attachGraphQLSubscriptions,
SUBSCRIPTIONS_PATH,
} = require("./graphql/subscriptions") as typeof import("./graphql/subscriptions");
const { startWebhookWorker } = require("./workers/webhooks") as typeof import("./workers/webhooks");
const { startPartitionRetentionJob } = require("./jobs/retention") as typeof import("./jobs/retention");
const { initTokenCache } = require("./tokenCache") as typeof import("./tokenCache");
const { enabledNetworks } = require("./network") as typeof import("./network");
const { onlyAddsUniqueConstraints, pushSchema } = require("./schemaGuard") as typeof import("./schemaGuard");

const PORT = parseInt(process.env.PORT ?? "3000", 10);

Expand Down
34 changes: 34 additions & 0 deletions src/validateEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Validate startup configuration before the service opens its listeners. */
export function validateEnv(env: NodeJS.ProcessEnv = process.env): void {
const missing = ["DATABASE_URL", "DIRECT_DATABASE_URL"].filter(
(name) => !env[name]?.trim(),
);

const configuredNetwork = env.STELLAR_NETWORK?.trim().toLowerCase();
const defaultNetwork = configuredNetwork === "mainnet" ? "mainnet" : "testnet";
const networks = (env.NETWORKS ?? "")
.split(",")
.map((network) => network.trim().toLowerCase())
.filter((network): network is "testnet" | "mainnet" =>
network === "testnet" || network === "mainnet",
);
const enabledNetworks = networks.length > 0 ? [...new Set(networks)] : [defaultNetwork];

if (env.SKIP_INDEXER !== "true" && enabledNetworks.includes("mainnet")) {
const hasMainnetRpc =
env.SOROBAN_RPC_URL_MAINNET?.trim() ||
env.STELLAR_RPC_URL_MAINNET?.trim() ||
(defaultNetwork === "mainnet" &&
(env.SOROBAN_RPC_URL?.trim() || env.STELLAR_RPC_URL?.trim()));

if (!hasMainnetRpc) {
missing.push(
"SOROBAN_RPC_URL_MAINNET (or STELLAR_RPC_URL_MAINNET; single-network deployments may use SOROBAN_RPC_URL or STELLAR_RPC_URL)",
);
}
}

if (missing.length > 0) {
throw new Error(`[wraith] Missing required env vars: ${missing.join(", ")}`);
}
}
Loading