Skip to content
Merged
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
22 changes: 20 additions & 2 deletions coordinator/src/server/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router } from "express";
import { createHash } from "node:crypto";

function getBuildEnv(): "testnet" | "mainnet" {
const v = (process.env.NETWORK_MODE ?? "testnet").toLowerCase();
Expand Down Expand Up @@ -58,6 +59,21 @@ function stellarNetworkLabel(passphrase: string | undefined): string {
return "unknown";
}

function networkPassphraseHash(passphrase: string | undefined): string | null {
const value = (passphrase ?? "").trim();
if (!value) return null;
return createHash("sha256").update(value, "utf8").digest("hex");
}

function configuredStellarPassphrase(): string {
return (
process.env.STELLAR_NETWORK_PASSPHRASE ??
(getBuildEnv() === "mainnet"
? "Public Global Stellar Network ; September 2015"
: "Test SDF Network ; September 2015")
);
}

export function healthRoutes(): Router {
const router = Router();
const startedAt = Date.now();
Expand Down Expand Up @@ -137,7 +153,7 @@ export function healthRoutes(): Router {
: 11_155_111; // default to Sepolia for testnet mode

const sorobanRpcUrl = process.env.SOROBAN_RPC_URL ?? undefined;
const sorobanPassphrase = process.env.STELLAR_NETWORK_PASSPHRASE ?? undefined;
const sorobanPassphrase = configuredStellarPassphrase();

const databaseMode = inferDatabaseMode(process.env.DATABASE_URL);
// We only report reachability, never the connection string itself.
Expand All @@ -157,6 +173,8 @@ export function healthRoutes(): Router {
},
stellar: {
network: stellarNetworkLabel(sorobanPassphrase),
// Expose a comparison-safe fingerprint rather than the passphrase.
networkPassphraseHash: networkPassphraseHash(sorobanPassphrase),
rpcConfigured: Boolean(sorobanRpcUrl),
},
database: {
Expand All @@ -170,4 +188,4 @@ export function healthRoutes(): Router {
});

return router;
}
}
16 changes: 11 additions & 5 deletions coordinator/test/readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,16 @@ describe("GET /readiness", () => {
expect(res.body.ethereum.chainName).toBe("holesky");
});

it("reports Stellar network label as testnet from passphrase", async () => {
const res = await request(makeApp()).get("/readiness").expect(200);
expect(res.body.stellar.network).toBe("testnet");
});
it("reports Stellar network label as testnet from passphrase", async () => {
const res = await request(makeApp()).get("/readiness").expect(200);
expect(res.body.stellar.network).toBe("testnet");
});

it("reports a passphrase fingerprint without exposing the passphrase", async () => {
const res = await request(makeApp()).get("/readiness").expect(200);
expect(res.body.stellar.networkPassphraseHash).toMatch(/^[a-f0-9]{64}$/);
expect(JSON.stringify(res.body)).not.toContain("Test SDF Network ; September 2015");
});

it("reports Stellar network label as mainnet from passphrase", async () => {
process.env.STELLAR_NETWORK_PASSPHRASE =
Expand Down Expand Up @@ -148,4 +154,4 @@ describe("GET /readiness", () => {
expect(res.body.secret).toBeUndefined();
expect(res.body.hashlock).toBeUndefined();
});
});
});
14 changes: 14 additions & 0 deletions resolver/src/commands/readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { privateKeyToAccount } from "viem/accounts";
import { sepolia, mainnet } from "viem/chains";
import { rpc, Keypair } from "@stellar/stellar-sdk";
import { resolveEthereumRpcUrl } from "../ethereum-rpc-url.js";
import { checkCoordinatorNetwork, redactUrl } from "../network-agreement.js";

// Load .env from CWD. dotenv is a no-op if the file is missing, so this is
// safe for tests and prod. Existing env vars take precedence over .env.
Expand Down Expand Up @@ -281,6 +282,19 @@ export async function assessReadiness(): Promise<ReadinessResult> {
detail: coordUrlDisplay
});

// When an upstream coordinator is explicitly configured, verify that both
// services target the same Ethereum chain and Stellar network. The
// coordinator returns only a passphrase fingerprint, never the passphrase.
if (rawCoordUrl) {
const agreement = await checkCoordinatorNetwork(rawCoordUrl, network, evmPing.chainId);
checks.push({
id: "coordinator-network",
label: "Coordinator and resolver network agreement",
status: agreement.status,
detail: agreement.detail
});
}

checks.push({
id: "dry-run-mode",
label: "Dry-run mode",
Expand Down
16 changes: 16 additions & 0 deletions resolver/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SorobanListener } from "../listeners/soroban.js";
import { checkPreflight } from "./check.js";
import { buildPlan } from "../planner/index.js";
import { observedFromEthereumEvent } from "../planner/index.js";
import { checkCoordinatorNetwork } from "../network-agreement.js";

export interface RunOptions {
dryRun?: boolean;
Expand All @@ -16,6 +17,21 @@ export async function runCommand(opts: RunOptions = {}): Promise<void> {
const log = getLogger(cfg.logLevel);
log.info({ network: cfg.network, dryRun }, "OverSync resolver starting");

// Refuse to start when the configured coordinator targets a different
// chain. Unreachable coordinators remain a warning so observation mode can
// still be used during local setup.
const networkAgreement = await checkCoordinatorNetwork(
cfg.coordinatorUrl,
cfg.network,
cfg.ethereum.chainId
);
if (networkAgreement.status === "fail") {
throw new Error(`Coordinator/resolver network mismatch: ${networkAgreement.detail}`);
}
if (networkAgreement.status === "warn") {
log.warn({ reason: networkAgreement.detail }, "Could not verify coordinator network agreement");
}

// Run preflight check in warning mode
const preflightResults = await checkPreflight();
for (const r of preflightResults) {
Expand Down
5 changes: 3 additions & 2 deletions resolver/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ResolverConfig {
}

import { resolveEthereumRpcUrl } from "./ethereum-rpc-url.js";
import { redactUrl } from "./network-agreement.js";

function requireEnv(name: string): string {
const v = process.env[name];
Expand Down Expand Up @@ -99,11 +100,11 @@ export function loadConfig(): ResolverConfig {
// Print startup configuration indicators safely
logger.info("Initializing OverSync Resolver engine instance configurations...");
logger.info(`Network operating target: ${config.network}`);
logger.info(`Coordinator upstream mapping endpoint: ${config.coordinatorUrl}`);
logger.info(`Coordinator upstream mapping endpoint: ${redactUrl(config.coordinatorUrl)}`);
logger.info(`Polling cycle state intervals: ${config.pollIntervalMs}ms`);

// Emits complete settings object topology (The deep hook in logger.ts strips secret keys instantly)
logger.info({ msg: "OverSync active module runtime mappings configuration payload", runtimeConfig: config });

return config;
}
}
88 changes: 88 additions & 0 deletions resolver/src/network-agreement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createHash } from "node:crypto";

export type SupportedNetwork = "testnet" | "mainnet";

export const NETWORK_PASSPHRASES: Record<SupportedNetwork, string> = {
testnet: "Test SDF Network ; September 2015",
mainnet: "Public Global Stellar Network ; September 2015"
};

export function networkPassphraseHash(passphrase: string): string {
return createHash("sha256").update(passphrase, "utf8").digest("hex");
}

export interface CoordinatorReadiness {
networkMode?: string;
ethereum?: { chainId?: number };
stellar?: { networkPassphraseHash?: string; network?: string };
}

export interface NetworkAgreementResult {
status: "ok" | "warn" | "fail";
detail: string;
}

export function redactUrl(raw: string): string {
try {
const url = new URL(raw);
return `${url.protocol}//${url.host}`;
} catch {
return "[REDACTED]";
}
}

export function compareNetworkAgreement(
network: SupportedNetwork,
evmChainId: number | null,
coordinator: CoordinatorReadiness
): NetworkAgreementResult {
const expectedChainId = network === "mainnet" ? 1 : 11_155_111;
const mismatches: string[] = [];

if (coordinator.networkMode && coordinator.networkMode !== network) {
mismatches.push(`network mode differs (coordinator=${coordinator.networkMode}, resolver=${network})`);
}
if (coordinator.ethereum?.chainId !== undefined && evmChainId !== null && coordinator.ethereum.chainId !== evmChainId) {
mismatches.push(`Ethereum chain ID differs (coordinator=${coordinator.ethereum.chainId}, resolver=${evmChainId})`);
} else if (coordinator.ethereum?.chainId !== undefined && coordinator.ethereum.chainId !== expectedChainId) {
mismatches.push(`Ethereum chain ID differs (coordinator=${coordinator.ethereum.chainId}, expected=${expectedChainId})`);
}

const expectedPassphraseHash = networkPassphraseHash(NETWORK_PASSPHRASES[network]);
const coordinatorHash = coordinator.stellar?.networkPassphraseHash;
if (coordinatorHash) {
if (coordinatorHash !== expectedPassphraseHash) {
mismatches.push("Stellar network passphrase differs");
}
} else if (coordinator.stellar?.network && coordinator.stellar.network !== network) {
// Compatibility with coordinators predating networkPassphraseHash.
mismatches.push(`Stellar network differs (coordinator=${coordinator.stellar.network}, resolver=${network})`);
}

return mismatches.length
? { status: "fail", detail: mismatches.join("; ") }
: { status: "ok", detail: "Coordinator and resolver networks agree (Ethereum chain ID and Stellar network passphrase)" };
}

export async function checkCoordinatorNetwork(
coordinatorUrl: string,
network: SupportedNetwork,
evmChainId: number | null
): Promise<NetworkAgreementResult> {
const endpoint = `${coordinatorUrl.replace(/\/+$/, "")}/readiness`;
try {
const response = await fetch(endpoint, { signal: AbortSignal.timeout(4000) });
if (!response.ok) {
return { status: "warn", detail: `Coordinator readiness unavailable (HTTP ${response.status})` };
}
const payload = (await response.json()) as CoordinatorReadiness;
return compareNetworkAgreement(network, evmChainId, payload);
} catch {
// Do not include the fetch error text: some clients echo the request URL,
// which could contain credentials supplied in COORDINATOR_URL.
return {
status: "warn",
detail: `Coordinator readiness unavailable at ${redactUrl(coordinatorUrl)}`
};
}
}
35 changes: 35 additions & 0 deletions resolver/test/network-agreement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
compareNetworkAgreement,
networkPassphraseHash,
NETWORK_PASSPHRASES
} from "../src/network-agreement.js";

describe("coordinator/resolver network agreement", () => {
it("accepts matching Ethereum chain and Stellar passphrase", () => {
const result = compareNetworkAgreement("testnet", 11_155_111, {
networkMode: "testnet",
ethereum: { chainId: 11_155_111 },
stellar: {
networkPassphraseHash: networkPassphraseHash(NETWORK_PASSPHRASES.testnet)
}
});

expect(result.status).toBe("ok");
});

it("fails when either chain configuration differs", () => {
const result = compareNetworkAgreement("testnet", 1, {
networkMode: "mainnet",
ethereum: { chainId: 1 },
stellar: {
networkPassphraseHash: networkPassphraseHash(NETWORK_PASSPHRASES.mainnet)
}
});

expect(result.status).toBe("fail");
expect(result.detail).toContain("Ethereum chain ID differs");
expect(result.detail).toContain("Stellar network passphrase differs");
expect(result.detail).not.toContain(NETWORK_PASSPHRASES.mainnet);
});
});
29 changes: 29 additions & 0 deletions resolver/test/readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ vi.mock("dotenv", async (importOriginal) => {
});

import { assessReadiness, readinessCommand } from "../src/commands/readiness.js";
import { networkPassphraseHash, NETWORK_PASSPHRASES } from "../src/network-agreement.js";

const TEST_ENV_KEY = "0x" + "ab".repeat(32);
const TEST_ENV_STELLAR_SECRET = "S" + "A".repeat(55); // S + 55 base32 chars = 56 chars total
Expand Down Expand Up @@ -118,10 +119,19 @@ describe("assessReadiness", () => {
vi.clearAllMocks();
mockGetChainId.mockResolvedValue(11_155_111);
mockGetLatestLedger.mockResolvedValue({ sequence: 12345 });
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
networkMode: "testnet",
ethereum: { chainId: 11_155_111 },
stellar: { networkPassphraseHash: networkPassphraseHash(NETWORK_PASSPHRASES.testnet) }
})
}));
});

afterEach(() => {
clearEnv();
vi.unstubAllGlobals();
});

it("returns ready=true when all required env is valid and both RPCs respond", async () => {
Expand Down Expand Up @@ -314,6 +324,25 @@ describe("assessReadiness", () => {
expect(detail).not.toContain("coord-query-key");
});

it("fails when the coordinator targets a different chain", async () => {
setEnv({ ...FULL_ENV, COORDINATOR_URL: "https://coord.example.test" });
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
networkMode: "mainnet",
ethereum: { chainId: 1 },
stellar: { networkPassphraseHash: networkPassphraseHash(NETWORK_PASSPHRASES.mainnet) }
})
}));

const result = await assessReadiness();
const agreement = findCheck(result, "coordinator-network");
expect(agreement.status).toBe("fail");
expect(agreement.detail).toContain("Ethereum chain ID differs");
expect(agreement.detail).toContain("Stellar network passphrase differs");
expect(result.ready).toBe(false);
});

it("always emits the dry-run assertion check", async () => {
setEnv(FULL_ENV);
const result = await assessReadiness();
Expand Down
Loading