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
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@moonlight-protocol/network-dashboard-platform",
"version": "0.1.3",
"version": "0.1.4",
"license": "MIT",
"exports": "./src/main.ts",
"tasks": {
Expand Down
67 changes: 9 additions & 58 deletions src/config/logger.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,10 @@
import chalk from "chalk";
import { LOG_LEVEL } from "@/config/env.ts";

export enum LogLevel {
FATAL = 0,
ERROR = 1,
WARN = 2,
INFO = 3,
DEBUG = 4,
TRACE = 5,
}

class Logger {
constructor(private level: LogLevel) {}

private format(args: unknown[]): string {
return args
.map((arg) => {
if (typeof arg === "string") return arg;
try {
return chalk.cyan(JSON.stringify(arg));
} catch (err) {
return chalk.cyan(`[Unstringifiable: ${(err as Error).message}]`);
}
})
.join(" ");
}

private write(level: LogLevel, color: typeof chalk.blue, ...args: unknown[]) {
if (this.level < level) return;
const ts = new Date().toISOString();
const prefix = chalk.gray(`[${ts}::${LogLevel[level]}]`);
console.log(`${prefix} ${color(this.format(args))}`);
}

trace(...args: unknown[]) {
this.write(LogLevel.TRACE, chalk.white, ...args);
}
debug(...args: unknown[]) {
this.write(LogLevel.DEBUG, chalk.green, ...args);
}
info(...args: unknown[]) {
this.write(LogLevel.INFO, chalk.blue, ...args);
}
warn(...args: unknown[]) {
this.write(LogLevel.WARN, chalk.yellow, ...args);
}
error(...args: unknown[]) {
this.write(LogLevel.ERROR, chalk.red, ...args);
}
fatal(...args: unknown[]) {
this.write(LogLevel.FATAL, chalk.bgRed.white, ...args);
}
import { type Logger, newLogger, parseLevel } from "@/utils/logger/index.ts";

/**
* Creates the root logger from `LOG_LEVEL` env var. Called once in main.ts;
* the returned logger is threaded through to every service and free function
* via dependency injection. There is no module-level singleton.
*/
export function createLogger(): Logger {
return newLogger(parseLevel(Deno.env.get("LOG_LEVEL")));
}

const resolvedLevel = LogLevel[LOG_LEVEL as keyof typeof LogLevel] ??
LogLevel.INFO;

export const LOG = new Logger(resolvedLevel);
20 changes: 15 additions & 5 deletions src/core/events/bus.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Logger } from "@/utils/logger/index.ts";
import type { NetworkEvent } from "./types.ts";

type Listener = (event: NetworkEvent) => void;
Expand All @@ -11,24 +12,35 @@ type Listener = (event: NetworkEvent) => void;
* replaced.
*
* A misbehaving listener must never break the publish loop — every
* delivery is wrapped in a try/catch.
* delivery is wrapped in a try/catch and reported via the injected logger.
*/
class NetworkEventBus {
export class NetworkEventBus {
private listeners = new Set<Listener>();
private log: Logger;

constructor(deps: { log: Logger }) {
this.log = deps.log.scope("NetworkEventBus");
}

subscribe(listener: Listener): () => void {
this.log.info("subscribe");
this.listeners.add(listener);
this.log.debug("listenerCount", this.listeners.size);
return () => {
this.log.info("unsubscribe");
this.listeners.delete(listener);
};
}

publish(event: NetworkEvent): void {
this.log.info("publish");
this.log.debug("eventKind", event.kind);
this.log.debug("listenerCount", this.listeners.size);
for (const listener of this.listeners) {
try {
listener(event);
} catch (err) {
console.warn("[network-event-bus] listener threw:", err);
this.log.error(err, "listener threw during publish");
}
}
}
Expand All @@ -37,5 +49,3 @@ class NetworkEventBus {
return this.listeners.size;
}
}

export const networkEventBus = new NetworkEventBus();
32 changes: 20 additions & 12 deletions src/core/events/bus_test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assertEquals } from "@std/assert";
import { networkEventBus } from "./bus.ts";
import { NetworkEventBus } from "./bus.ts";
import { newNoop } from "@/utils/logger/index.ts";
import type { NetworkEvent } from "./types.ts";

function ev(id: string): NetworkEvent {
Expand All @@ -14,33 +15,40 @@ function ev(id: string): NetworkEvent {
};
}

function newBus(): NetworkEventBus {
return new NetworkEventBus({ log: newNoop() });
}

Deno.test("subscribe delivers, unsubscribe stops delivery", () => {
const bus = newBus();
const received: string[] = [];
const unsub = networkEventBus.subscribe((e) => received.push(e.id));
networkEventBus.publish(ev("a"));
networkEventBus.publish(ev("b"));
const unsub = bus.subscribe((e) => received.push(e.id));
bus.publish(ev("a"));
bus.publish(ev("b"));
unsub();
networkEventBus.publish(ev("c"));
bus.publish(ev("c"));
assertEquals(received, ["a", "b"]);
});

Deno.test("publish survives a throwing listener", () => {
const bus = newBus();
const received: string[] = [];
const u1 = networkEventBus.subscribe(() => {
const u1 = bus.subscribe(() => {
throw new Error("boom");
});
const u2 = networkEventBus.subscribe((e) => received.push(e.id));
networkEventBus.publish(ev("x"));
const u2 = bus.subscribe((e) => received.push(e.id));
bus.publish(ev("x"));
u1();
u2();
assertEquals(received, ["x"]);
});

Deno.test("listenerCount reflects subscriptions", () => {
const u1 = networkEventBus.subscribe(() => {});
const u2 = networkEventBus.subscribe(() => {});
assertEquals(networkEventBus.listenerCount(), 2);
const bus = newBus();
const u1 = bus.subscribe(() => {});
const u2 = bus.subscribe(() => {});
assertEquals(bus.listenerCount(), 2);
u1();
u2();
assertEquals(networkEventBus.listenerCount(), 0);
assertEquals(bus.listenerCount(), 0);
});
51 changes: 30 additions & 21 deletions src/core/sync/contract-init-listener.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Address, xdr } from "stellar-sdk";
import { Server } from "stellar-sdk/rpc";
import { LOG } from "@/config/logger.ts";
import type { Logger } from "@/utils/logger/index.ts";
import { STELLAR_RPC_URL } from "@/config/env.ts";
import { networkState } from "@/core/state/store.ts";
import { refreshTopology } from "./topology-refresh.ts";
import type { NetworkEventBus } from "@/core/events/bus.ts";
import {
isKnownChannelAuthHash,
isReady as isWasmRegistryReady,
Expand Down Expand Up @@ -74,45 +75,51 @@ export function isContractInitListenerEnabled(): boolean {
*/
export async function evaluateUnknownContract(
contractId: string,
deps: { log: Logger; bus: NetworkEventBus },
): Promise<void> {
const log = deps.log.scope("evaluateUnknownContract");
log.info("evaluateUnknownContract");
log.debug("contractId", contractId);

if (!isContractInitListenerEnabled()) return;
if (!contractId) return;
if (pendingAdoption.has(contractId)) return;
if (notMoonlight.has(contractId)) return;
if (networkState.hasCouncil(contractId)) return;

const wasmHash = await fetchWasmHash(contractId);
const wasmHash = await fetchWasmHash(contractId, { log });
if (wasmHash === null) return;
if (!isKnownChannelAuthHash(wasmHash)) {
notMoonlight.add(contractId);
LOG.debug("Ignored contract_initialized from non-Moonlight contract", {
contractId,
wasmHash,
});
log.debug("wasmHash", wasmHash);
log.event("ignored contract_initialized from non-Moonlight contract");
return;
}
pendingAdoption.add(contractId);
LOG.info("Detected new Channel Auth deploy via contract_initialized", {
contractId,
wasmHash,
});
await drainPendingAdoptions();
log.debug("wasmHash", wasmHash);
log.event("detected new Channel Auth deploy via contract_initialized");
await drainPendingAdoptions(deps);
}

/**
* Retry topology refresh while we still have contracts whose Channel Auth
* WASM hash matched but which council-platform hasn't yet registered.
* Called once per pollTick — no-op when nothing is pending.
*/
export async function drainPendingAdoptions(): Promise<void> {
export async function drainPendingAdoptions(
deps: { log: Logger; bus: NetworkEventBus },
): Promise<void> {
if (pendingAdoption.size === 0) return;
await refreshTopology(`pending=${pendingAdoption.size}`);
const log = deps.log.scope("drainPendingAdoptions");
log.info("drainPendingAdoptions");
log.debug("pendingCount", pendingAdoption.size);

await refreshTopology(`pending=${pendingAdoption.size}`, deps);
for (const cid of [...pendingAdoption]) {
if (networkState.hasCouncil(cid)) {
pendingAdoption.delete(cid);
LOG.info("Pending Channel Auth contract adopted into topology", {
contractId: cid,
});
log.debug("contractId", cid);
log.event("pending Channel Auth contract adopted into topology");
}
}
}
Expand All @@ -123,7 +130,11 @@ export function __resetForTests(): void {
pendingAdoption.clear();
}

async function fetchWasmHash(contractId: string): Promise<string | null> {
async function fetchWasmHash(
contractId: string,
deps: { log: Logger },
): Promise<string | null> {
const log = deps.log.scope("fetchWasmHash");
try {
const server = getServer();
const key = xdr.LedgerKey.contractData(
Expand All @@ -144,10 +155,8 @@ async function fetchWasmHash(contractId: string): Promise<string | null> {
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
} catch (err) {
LOG.debug("fetchWasmHash failed", {
contractId,
error: err instanceof Error ? err.message : String(err),
});
log.debug("contractId", contractId);
log.error(err, "fetchWasmHash failed");
return null;
}
}
15 changes: 12 additions & 3 deletions src/core/sync/council-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LOG } from "@/config/logger.ts";
import type { Logger } from "@/utils/logger/index.ts";
import { COUNCIL_PLATFORM_URL } from "@/config/env.ts";
import type { CouncilTopologyEntry } from "@/core/events/types.ts";

Expand All @@ -20,17 +20,25 @@ type PublicCouncil = {

type PublicCouncilsResponse = { data?: PublicCouncil[] };

export async function fetchCouncilTopology(): Promise<CouncilTopologyEntry[]> {
export async function fetchCouncilTopology(
deps: { log: Logger },
): Promise<CouncilTopologyEntry[]> {
const log = deps.log.scope("fetchCouncilTopology");
log.info("fetchCouncilTopology");

const base = COUNCIL_PLATFORM_URL.replace(/\/+$/, "");
const url = `${base}/api/v1/public/councils`;
log.debug("url", url);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);

try {
log.event("requesting council list");
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`council-platform returned HTTP ${res.status}`);
}
log.event("council list received");
const body = (await res.json()) as PublicCouncilsResponse;
const entries: CouncilTopologyEntry[] = [];
for (const c of body.data ?? []) {
Expand Down Expand Up @@ -60,7 +68,8 @@ export async function fetchCouncilTopology(): Promise<CouncilTopologyEntry[]> {
.filter((code): code is string => !!code),
});
}
LOG.info("Fetched council-platform topology", { count: entries.length });
log.debug("count", entries.length);
log.event("council-platform topology built");
return entries;
} finally {
clearTimeout(timer);
Expand Down
Loading
Loading