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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"frontend:build:prod": "NODE_ENV=production pnpm --filter frontend build",
"frontend:preview": "pnpm --filter frontend preview",
"check:evidence-links": "node scripts/check-evidence-links.mjs",
"check:env-docs": "node scripts/check-env-docs.mjs",
"health:check": "curl -s http://localhost:3001/health | jq .",
"deploy:setup": "cp .env.production .env && pnpm build:prod",
"verify:addresses": "node scripts/verify-addresses.mjs"
Expand Down
114 changes: 112 additions & 2 deletions resolver/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createPublicClient, http, parseAbi, type Address } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia, mainnet } from "viem/chains";
import { rpc, Contract, Keypair, TransactionBuilder, Networks, nativeToScVal } from "@stellar/stellar-sdk";
import { loadConfig } from "../config.js";
import { loadConfig, type ResolverConfig } from "../config.js";
import { getLogger } from "../logger.js";

const REGISTRY_ABI = parseAbi([
Expand All @@ -16,6 +16,23 @@ export type CheckResult = {
reason?: string;
};

export type JsonNetworkResult = {
chain: string;
configured: boolean;
rpcReachable: boolean;
registryConfigured: boolean;
resolverAddress: string | null;
active: boolean | "unknown";
warnings: string[];
};

export type JsonCheckOutput = {
generatedAt: string;
networks: JsonNetworkResult[];
warnings: string[];
status: "healthy" | "degraded" | "error";
};

export async function checkPreflight(): Promise<CheckResult[]> {
const cfg = loadConfig();
const results: CheckResult[] = [];
Expand Down Expand Up @@ -100,7 +117,82 @@ export async function checkPreflight(): Promise<CheckResult[]> {
return results;
}

export async function checkCommand(): Promise<void> {
function deriveResolverAddress(config: ResolverConfig): { ethereum: string | null; soroban: string | null } {
let ethAddr: string | null = null;
let sorobanAddr: string | null = null;

if (config.ethereum.resolverPrivateKey) {
try {
const account = privateKeyToAccount(config.ethereum.resolverPrivateKey);
ethAddr = account.address;
} catch {
// Cannot derive address; skip
}
}

if (config.soroban.resolverSecret) {
try {
const kp = Keypair.fromSecret(config.soroban.resolverSecret);
sorobanAddr = kp.publicKey();
} catch {
// Cannot derive address; skip
}
}

return { ethereum: ethAddr, soroban: sorobanAddr };
}

export function buildJsonOutput(results: CheckResult[], config: ResolverConfig): JsonCheckOutput {
const addresses = deriveResolverAddress(config);
const globalWarnings: string[] = [];
const networks: JsonNetworkResult[] = [];

for (const r of results) {
const networkWarnings: string[] = [];

if (!r.configured) {
networkWarnings.push(r.reason || "Not configured");
} else if (r.active === "unknown") {
if (r.reason) networkWarnings.push(r.reason);
} else if (r.active === false) {
networkWarnings.push("Resolver is not active. May need to stake/register.");
}

globalWarnings.push(...networkWarnings);

const addr = r.chain === "ethereum" ? addresses.ethereum : addresses.soroban;

networks.push({
chain: r.chain,
configured: r.configured,
rpcReachable: r.configured && r.active !== "unknown",
registryConfigured: r.configured,
resolverAddress: addr,
active: r.active,
warnings: networkWarnings,
});
}

let status: JsonCheckOutput["status"] = "healthy";
for (const n of networks) {
if (!n.configured) { status = "error"; break; }
if (n.active === false || n.active === "unknown") { status = "degraded"; }
}

return {
generatedAt: new Date().toISOString(),
networks,
warnings: globalWarnings,
status,
};
}

export async function checkCommand(options?: { json?: boolean }): Promise<void> {
if (options?.json) {
await checkCommandJson();
return;
}

const cfg = loadConfig();
const log = getLogger(cfg.logLevel);
log.info("Running resolver preflight checks...");
Expand All @@ -118,3 +210,21 @@ export async function checkCommand(): Promise<void> {
}
}
}

async function checkCommandJson(): Promise<void> {
try {
const cfg = loadConfig();
const results = await checkPreflight();
const output = buildJsonOutput(results, cfg);
console.log(JSON.stringify(output, null, 2));
process.exit(output.status === "healthy" ? 0 : output.status === "degraded" ? 1 : 2);
} catch (err: any) {
console.log(JSON.stringify({
generatedAt: new Date().toISOString(),
networks: [],
warnings: [`Fatal error: ${err.message || String(err)}`],
status: "error",
}, null, 2));
process.exit(2);
}
}
5 changes: 3 additions & 2 deletions resolver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ program
program
.command("check")
.description("Run a preflight check to verify if the resolver is active in configured registries.")
.action(async () => {
.option("--json", "Emit JSON output suitable for dashboards and monitoring")
.action(async (options) => {
const { checkCommand } = await import("./commands/check.js");
await checkCommand();
await checkCommand({ json: Boolean(options.json) });
});


Expand Down
110 changes: 109 additions & 1 deletion resolver/test/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ vi.mock("@stellar/stellar-sdk", async (importOriginal) => {
};
});

import { checkPreflight } from "../src/commands/check.js";
import { checkPreflight, buildJsonOutput } from "../src/commands/check.js";
import { __setMockConfig } from "../src/config.js";
import type { ResolverConfig } from "../src/config.js";

describe("checkPreflight", () => {
beforeEach(() => {
Expand Down Expand Up @@ -168,3 +169,110 @@ describe("checkPreflight", () => {
expect(results[1].active).toBe("unknown");
});
});

describe("buildJsonOutput", () => {
const baseConfig: ResolverConfig = {
network: "testnet",
pollIntervalMs: 15000,
coordinatorUrl: "http://localhost:3001",
logLevel: "info",
ethereum: {
rpcUrl: "http://localhost:8545",
chainId: 11155111,
htlcEscrow: "0x1111111111111111111111111111111111111111",
resolverRegistry: "0x2222222222222222222222222222222222222222",
resolverPrivateKey: "0xabc",
},
soroban: {
rpcUrl: "http://localhost:8000",
networkPassphrase: "Test SDF Network ; September 2015",
horizonUrl: "http://localhost:8001",
htlc: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBB4",
resolverRegistry: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBB5",
resolverSecret: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBB6",
},
};

it("produces healthy status when all checks pass", () => {
const results = [
{ chain: "ethereum", configured: true, active: true },
{ chain: "soroban", configured: true, active: true },
];
const output = buildJsonOutput(results, baseConfig);
expect(output.status).toBe("healthy");
expect(output.networks).toHaveLength(2);
expect(output.warnings).toHaveLength(0);
expect(output.generatedAt).toBeTruthy();
expect(() => JSON.parse(JSON.stringify(output))).not.toThrow();
});

it("produces degraded status when one chain is not active", () => {
const results = [
{ chain: "ethereum", configured: true, active: true },
{ chain: "soroban", configured: true, active: false, reason: "Not staked" },
];
const output = buildJsonOutput(results, baseConfig);
expect(output.status).toBe("degraded");
expect(output.networks[1].active).toBe(false);
expect(output.networks[1].warnings).toContain("Resolver is not active. May need to stake/register.");
});

it("produces error status when a chain is not configured", () => {
const results = [
{ chain: "ethereum", configured: false, active: "unknown", reason: "Missing registry" },
{ chain: "soroban", configured: true, active: true },
];
const output = buildJsonOutput(results, baseConfig);
expect(output.status).toBe("error");
expect(output.networks[0].configured).toBe(false);
expect(output.networks[0].warnings).toContain("Missing registry");
});

it("sets rpcReachable based on configured and active state", () => {
const results = [
{ chain: "ethereum", configured: false, active: "unknown" },
{ chain: "soroban", configured: true, active: true },
];
const output = buildJsonOutput(results, baseConfig);
expect(output.networks[0].rpcReachable).toBe(false);
expect(output.networks[1].rpcReachable).toBe(true);
});

it("sets resolverAddress from config when credentials are present", () => {
const results = [
{ chain: "ethereum", configured: true, active: true },
{ chain: "soroban", configured: true, active: true },
];
const output = buildJsonOutput(results, baseConfig);
expect(output.networks[0].resolverAddress).toBe("0x123");
expect(output.networks[1].resolverAddress).toBe("G123");
});

it("sets resolverAddress to null when credentials are missing", () => {
const noCredsConfig: ResolverConfig = {
...baseConfig,
ethereum: { ...baseConfig.ethereum, resolverPrivateKey: null },
soroban: { ...baseConfig.soroban, resolverSecret: null },
};
const results = [
{ chain: "ethereum", configured: false, active: "unknown" },
{ chain: "soroban", configured: false, active: "unknown" },
];
const output = buildJsonOutput(results, noCredsConfig);
expect(output.networks[0].resolverAddress).toBeNull();
expect(output.networks[1].resolverAddress).toBeNull();
});

it("emits no private keys in output", () => {
const results = [
{ chain: "ethereum", configured: true, active: true },
{ chain: "soroban", configured: true, active: true },
];
const output = buildJsonOutput(results, baseConfig);
const json = JSON.stringify(output);
expect(json).not.toContain("0xabc");
expect(json).not.toContain("SAAAAA");
expect(json).not.toContain("resolverPrivateKey");
expect(json).not.toContain("resolverSecret");
});
});
Loading