diff --git a/scripts/alert-failed-charges.ts b/scripts/alert-failed-charges.ts index dda8ff81..4c99210c 100644 --- a/scripts/alert-failed-charges.ts +++ b/scripts/alert-failed-charges.ts @@ -18,6 +18,7 @@ */ import { DatabaseSync } from "node:sqlite"; +import { logger } from "./logger"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -62,15 +63,15 @@ async function sendWebhook(url: string, payload: AlertPayload): Promise { body: JSON.stringify(payload), }); if (!response.ok) { - console.error( + logger.error( `Webhook responded with HTTP ${response.status}: ${response.statusText}` ); } else { - console.error(`Webhook delivered successfully (HTTP ${response.status})`); + logger.error(`Webhook delivered successfully (HTTP ${response.status})`); } } catch (err) { // Log failure but do not crash — callers rely on non-zero exit only for fatal errors - console.error(`Webhook delivery failed: ${err}`); + logger.error(`Webhook delivery failed: ${err}`); } } @@ -79,7 +80,7 @@ async function sendWebhook(url: string, payload: AlertPayload): Promise { async function main(): Promise { const webhookUrl = process.env.WEBHOOK_URL; if (!webhookUrl) { - console.error("Error: WEBHOOK_URL environment variable is required."); + logger.error("Error: WEBHOOK_URL environment variable is required."); process.exit(1); } @@ -91,7 +92,7 @@ async function main(): Promise { try { db = new DatabaseSync(dbPath, { open: true }); } catch (err) { - console.error(`Failed to open database at ${dbPath}: ${err}`); + logger.error(`Failed to open database at ${dbPath}: ${err}`); process.exit(1); } @@ -120,16 +121,16 @@ async function main(): Promise { }; if (failedCharges.length === 0) { - console.error("No failed charges found. No webhook sent."); - console.log(JSON.stringify(payload, null, 2)); + logger.error("No failed charges found. No webhook sent."); + logger.info(JSON.stringify(payload, null, 2)); return; } - console.log(JSON.stringify(payload, null, 2)); + logger.info(JSON.stringify(payload, null, 2)); await sendWebhook(webhookUrl, payload); } main().catch((err) => { - console.error(`Fatal error: ${err}`); + logger.error(`Fatal error: ${err}`); process.exit(1); }); diff --git a/scripts/check-allowances.ts b/scripts/check-allowances.ts index 656c6333..cd0a9ce9 100644 --- a/scripts/check-allowances.ts +++ b/scripts/check-allowances.ts @@ -8,6 +8,7 @@ import { MultiEndpointServer } from "./rpc-client.js"; import { +import { logger } from "./logger"; Contract, Networks, TransactionBuilder, @@ -24,8 +25,8 @@ const CONTRACT_ID = process.env.CONTRACT_ID || ""; const NETWORK_PASSPHRASE = (process.env.NETWORK_PASSPHRASE ?? Networks.TESTNET) as string; if (!CONTRACT_ID) { - console.error("Error: CONTRACT_ID environment variable is required"); - console.error( + logger.error("Error: CONTRACT_ID environment variable is required"); + logger.error( "Usage: CONTRACT_ID=your_contract_id tsx check-allowances.ts [--file subscribers.txt] [--json] [address1 address2 ...]" ); process.exit(1); @@ -49,13 +50,13 @@ async function parseAddressListFromFile(path: string): Promise { .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("#")); } catch (err) { - console.error(`Error reading file ${path}: ${err}`); + logger.error(`Error reading file ${path}: ${err}`); process.exit(1); } } function showHelp(): void { - console.log(` + logger.info(` Usage: tsx check-allowances.ts [options] [addresses...] Options: @@ -242,25 +243,25 @@ function printHumanReadable(results: AuditResult[]): void { const noSub = results.filter((r) => r.error === "no_subscription"); const healthy = results.filter((r) => !r.atRisk && !r.error && r.active); - console.log(`\nAudited ${results.length} subscriber(s)\n`); + logger.info(`\nAudited ${results.length} subscriber(s)\n`); if (noSub.length > 0) { - console.log(`${noSub.length} with no subscription:`); + logger.info(`${noSub.length} with no subscription:`); for (const r of noSub) { - console.log(` ${r.address}`); + logger.info(` ${r.address}`); } - console.log(); + logger.info(); } if (atRisk.length > 0) { - console.log(`${atRisk.length} at risk of failed charge:`); + logger.info(`${atRisk.length} at risk of failed charge:`); const header = " ADDRESS".padEnd(56) + "AMOUNT".padStart(10) + "ALLOWANCE".padStart(12) + "GAP".padStart(10) + "TOKEN".padStart(56); - console.log(header); + logger.info(header); for (const r of atRisk) { const line = r.address.padEnd(56) + @@ -268,28 +269,28 @@ function printHumanReadable(results: AuditResult[]): void { stroopsToXlm(r.allowance).padStart(12) + stroopsToXlm(r.gap).padStart(10) + r.token.padStart(56); - console.log(` ${line}`); + logger.info(` ${line}`); } - console.log(); + logger.info(); } if (healthy.length > 0) { - console.log(`${healthy.length} healthy:`); + logger.info(`${healthy.length} healthy:`); for (const r of healthy) { - console.log( + logger.info( ` ${r.address.padEnd(56)} ${stroopsToXlm(r.subscriptionAmount).padStart(10)} ${stroopsToXlm(r.allowance).padStart(10)}` ); } - console.log(); + logger.info(); } - console.log( + logger.info( `Summary: healthy=${healthy.length}, atRisk=${atRisk.length}, noSubscription=${noSub.length}` ); } function printJson(results: AuditResult[]): void { - console.log(JSON.stringify(results, null, 2)); + logger.info(JSON.stringify(results, null, 2)); } // ── Main ───────────────────────────────────────────────────────────────────────── @@ -310,7 +311,7 @@ async function main(): Promise { } else if (arg === "--file") { filePath = argv[++i]; } else if (arg.startsWith("-")) { - console.error(`Unknown option: ${arg}`); + logger.error(`Unknown option: ${arg}`); showHelp(); } else { addresses.push(arg); @@ -328,7 +329,7 @@ async function main(): Promise { } if (allAddresses.length === 0) { - console.error("No valid addresses provided."); + logger.error("No valid addresses provided."); process.exit(1); } @@ -346,6 +347,6 @@ async function main(): Promise { } main().catch((error) => { - console.error(`Fatal error: ${error}`); + logger.error(`Fatal error: ${error}`); process.exit(1); }); diff --git a/scripts/daily-revenue-summary.ts b/scripts/daily-revenue-summary.ts index e2eebc44..f31d88b7 100644 --- a/scripts/daily-revenue-summary.ts +++ b/scripts/daily-revenue-summary.ts @@ -17,6 +17,7 @@ */ import { DatabaseSync } from "node:sqlite"; +import { logger } from "./logger"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -76,7 +77,7 @@ function main(): void { // Validate date format if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) { - console.error(`Invalid date format: ${targetDate}. Expected YYYY-MM-DD.`); + logger.error(`Invalid date format: ${targetDate}. Expected YYYY-MM-DD.`); process.exit(1); } @@ -88,7 +89,7 @@ function main(): void { try { db = new DatabaseSync(dbPath, { open: true }); } catch (err) { - console.error(`Failed to open database at ${dbPath}: ${err}`); + logger.error(`Failed to open database at ${dbPath}: ${err}`); process.exit(1); } @@ -142,7 +143,7 @@ function main(): void { net_merchant_revenue: totalAmount - totalFees, }; - console.log(JSON.stringify(summary, null, 2)); + logger.info(JSON.stringify(summary, null, 2)); } main(); diff --git a/scripts/export-merchant-report.ts b/scripts/export-merchant-report.ts index defeaa2c..d6f4a1b2 100644 --- a/scripts/export-merchant-report.ts +++ b/scripts/export-merchant-report.ts @@ -12,6 +12,7 @@ import { Contract, Networks, TransactionBuilder, BASE_FEE, nativeToScVal, Address, xdr } from "@stellar/stellar-sdk"; import { Server } from "@stellar/stellar-sdk/rpc"; +import { logger } from "./logger"; const RPC_URL = process.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; const NETWORK_PASSPHRASE = process.env.VITE_NETWORK_PASSPHRASE ?? Networks.TESTNET; @@ -235,7 +236,7 @@ async function main() { } if (!["csv", "json", "ndjson"].includes(format)) { - console.error(`ERROR: Invalid format '${format}'. Supported formats: csv, json, ndjson`); + logger.error(`ERROR: Invalid format '${format}'. Supported formats: csv, json, ndjson`); process.exit(1); } @@ -244,8 +245,8 @@ async function main() { const parsedFields = fieldsStr.split(",").map((f) => f.trim()); const invalidFields = parsedFields.filter((f) => !VALID_FIELDS.includes(f as ValidField)); if (invalidFields.length > 0) { - console.error(`ERROR: Invalid field(s): ${invalidFields.join(", ")}.`); - console.error(`Valid fields are: ${VALID_FIELDS.join(", ")}`); + logger.error(`ERROR: Invalid field(s): ${invalidFields.join(", ")}.`); + logger.error(`Valid fields are: ${VALID_FIELDS.join(", ")}`); process.exit(1); } selectedFields = parsedFields as ValidField[]; @@ -283,13 +284,13 @@ async function main() { if (output) { const fs = await import("fs/promises"); await fs.writeFile(output, formattedOutput, "utf-8"); - console.log(`Report written to ${output}`); + logger.info(`Report written to ${output}`); } else { process.stdout.write(formattedOutput); } } main().catch((err) => { - console.error("Export report failed:", err instanceof Error ? err.message : err); + logger.error("Export report failed:", err instanceof Error ? err.message : err); process.exit(1); }); \ No newline at end of file diff --git a/scripts/fee-revenue-report.ts b/scripts/fee-revenue-report.ts index bcaf4639..2614977c 100644 --- a/scripts/fee-revenue-report.ts +++ b/scripts/fee-revenue-report.ts @@ -14,6 +14,7 @@ import { DatabaseSync } from "node:sqlite"; import { writeFileSync } from "node:fs"; +import { logger } from "./logger"; interface EventRow { data: string; @@ -44,7 +45,7 @@ function toDateStr(ts: number): string { function main() { const dbPath = getArg("--db"); - if (!dbPath) { console.error("--db required"); process.exit(1); } + if (!dbPath) { logger.error("--db required"); process.exit(1); } const db = new DatabaseSync(dbPath, { open: true }); @@ -86,7 +87,7 @@ function main() { const out = getArg("--out"); const json = JSON.stringify(report, null, 2); - if (out) { writeFileSync(out, json); console.log(`Wrote report to ${out}`); } + if (out) { writeFileSync(out, json); logger.info(`Wrote report to ${out}`); } else process.stdout.write(json + "\n"); } diff --git a/scripts/health-check.ts b/scripts/health-check.ts index 0703e61e..b2f4f897 100644 --- a/scripts/health-check.ts +++ b/scripts/health-check.ts @@ -22,6 +22,7 @@ import { Contract, Networks, TransactionBuilder, BASE_FEE, Address } from "@stellar/stellar-sdk"; import { MultiEndpointServer } from "./rpc-client.js"; +import { logger } from "./logger"; // ── Configuration ──────────────────────────────────────────────────────────── @@ -44,9 +45,9 @@ function timestamp(): string { function log(status: "healthy" | "unhealthy", detail?: string): void { const line = `${timestamp()} contract=${CONTRACT_ID || "NOT_SET"} status=${status}`; if (detail) { - console.log(`${line} detail=${detail}`); + logger.info(`${line} detail=${detail}`); } else { - console.log(line); + logger.info(line); } } diff --git a/scripts/logger.ts b/scripts/logger.ts new file mode 100644 index 00000000..6604daa5 --- /dev/null +++ b/scripts/logger.ts @@ -0,0 +1,164 @@ +/** + * logger.ts — Structured logging for PayFlow scripts. + * + * Provides DEBUG / INFO / WARN / ERROR levels with human-readable output by + * default, or JSON lines when `LOG_FORMAT=json` is set. Child loggers inherit + * and merge context onto every line. + * + * Usage: + * import { logger } from "./logger.js"; + * logger.info("starting check", { contract_id: CONTRACT_ID }); + * const child = logger.child({ contract_id: CONTRACT_ID }); + * child.debug("fetched page", { offset: 0, count: 50 }); + * + * Environment: + * LOG_LEVEL — minimum level to emit (debug|info|warn|error). Default: info + * LOG_FORMAT — "json" for JSON lines, anything else for human text. Default: human + */ + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export type LogContext = Record; + +const LEVEL_ORDER: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +const LEVEL_ANSI: Record = { + debug: "\x1b[90m", // gray + info: "\x1b[36m", // cyan + warn: "\x1b[33m", // yellow + error: "\x1b[31m", // red +}; + +const RESET = "\x1b[0m"; + +function parseLevel(raw: string | undefined): LogLevel { + const value = (raw ?? "info").trim().toLowerCase(); + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} + +function isJsonFormat(): boolean { + return (process.env.LOG_FORMAT ?? "").trim().toLowerCase() === "json"; +} + +/** + * JSON.stringify that replaces circular references with "[Circular]" so logging + * never throws on cyclic context objects. + */ +export function safeStringify(value: unknown, space?: number): string { + const seen = new WeakSet(); + return JSON.stringify( + value, + (_key, current) => { + if (typeof current === "bigint") { + return current.toString(); + } + if (typeof current === "object" && current !== null) { + if (seen.has(current)) { + return "[Circular]"; + } + seen.add(current); + } + return current; + }, + space + ); +} + +function normalizeContext( + message: string, + context?: LogContext | unknown +): { message: string; context: LogContext } { + if (context === undefined) { + return { message, context: {} }; + } + if ( + typeof context === "object" && + context !== null && + !Array.isArray(context) && + !(context instanceof Error) + ) { + return { message, context: context as LogContext }; + } + if (context instanceof Error) { + return { + message: `${message} ${context.message}`, + context: { error: context.message, stack: context.stack }, + }; + } + return { message: `${message} ${String(context)}`, context: {} }; +} + +export interface Logger { + debug(message: string, context?: LogContext | unknown): void; + info(message: string, context?: LogContext | unknown): void; + warn(message: string, context?: LogContext | unknown): void; + error(message: string, context?: LogContext | unknown): void; + child(context: LogContext): Logger; + readonly level: LogLevel; + readonly context: LogContext; +} + +function shouldEmit(min: LogLevel, level: LogLevel): boolean { + return LEVEL_ORDER[level] >= LEVEL_ORDER[min]; +} + +function writeLine(level: LogLevel, message: string, context: LogContext): void { + const timestamp = new Date().toISOString(); + const stream = level === "error" || level === "warn" ? process.stderr : process.stdout; + + if (isJsonFormat()) { + const entry: Record = { + timestamp, + level: level.toUpperCase(), + message, + ...context, + }; + stream.write(`${safeStringify(entry)}\n`); + return; + } + + const color = LEVEL_ANSI[level]; + const label = level.toUpperCase().padEnd(5); + const ctxKeys = Object.keys(context); + const ctxSuffix = + ctxKeys.length === 0 + ? "" + : ` ${safeStringify(context)}`; + stream.write(`${color}${timestamp} ${label}${RESET} ${message}${ctxSuffix}\n`); +} + +function createLogger(bound: LogContext = {}): Logger { + const minLevel = parseLevel(process.env.LOG_LEVEL); + + const emit = (level: LogLevel, message: string, context?: LogContext | unknown): void => { + if (!shouldEmit(minLevel, level)) return; + const normalized = normalizeContext(message, context); + writeLine(level, normalized.message, { ...bound, ...normalized.context }); + }; + + return { + level: minLevel, + context: { ...bound }, + debug: (message, context) => emit("debug", message, context), + info: (message, context) => emit("info", message, context), + warn: (message, context) => emit("warn", message, context), + error: (message, context) => emit("error", message, context), + child: (context) => createLogger({ ...bound, ...context }), + }; +} + +/** Root logger — safe to import before any other initialization. */ +export const logger: Logger = createLogger(); + +/** Factory for a fresh root logger (mainly useful in tests). */ +export function createRootLogger(context?: LogContext): Logger { + return createLogger(context ?? {}); +} diff --git a/scripts/migrate-contract.ts b/scripts/migrate-contract.ts index dc8a5d83..ff06981f 100644 --- a/scripts/migrate-contract.ts +++ b/scripts/migrate-contract.ts @@ -4,6 +4,7 @@ */ import { Contract, Networks, TransactionBuilder, BASE_FEE, nativeToScVal, Address, xdr } from "@stellar/stellar-sdk"; +import { logger } from "./logger"; const RPC_URL = process.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; const NETWORK_PASSPHRASE = process.env.VITE_NETWORK_PASSPHRASE ?? Networks.TESTNET; @@ -59,45 +60,45 @@ async function migrate(users: string[]): Promise { const result = await server.simulateTransaction(tx); if ("error" in result) throw new Error(result.error); - console.log("Migration transaction simulated successfully"); + logger.info("Migration transaction simulated successfully"); } async function main() { const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); - console.log("Starting contract migration...\n"); + logger.info("Starting contract migration...\n"); // Get pre-migration version const preVersion = await getSchemaVersion(); - console.log(`Pre-migration schema version: ${preVersion}`); + logger.info(`Pre-migration schema version: ${preVersion}`); if (dryRun) { - console.log("\n[Dry-run mode] Skipping actual migration"); - console.log(`Post-migration version would be: ${preVersion}`); + logger.info("\n[Dry-run mode] Skipping actual migration"); + logger.info(`Post-migration version would be: ${preVersion}`); return; } // Get users to migrate (empty for now, could be loaded from env or args) const users: string[] = []; - console.log("\nCalling migrate..."); + logger.info("\nCalling migrate..."); await migrate(users); // Get post-migration version const postVersion = await getSchemaVersion(); - console.log(`Post-migration schema version: ${postVersion}`); + logger.info(`Post-migration schema version: ${postVersion}`); // Verify version incremented if (postVersion <= preVersion) { - console.error(`\nERROR: Schema version did not increment! (${preVersion} -> ${postVersion})`); + logger.error(`\nERROR: Schema version did not increment! (${preVersion} -> ${postVersion})`); process.exit(1); } - console.log(`\nMigration successful! Version incremented from ${preVersion} to ${postVersion}`); + logger.info(`\nMigration successful! Version incremented from ${preVersion} to ${postVersion}`); } main().catch((err) => { - console.error("Migration failed:", err.message); + logger.error("Migration failed:", err.message); process.exit(1); }); \ No newline at end of file diff --git a/scripts/pre-upgrade-check.ts b/scripts/pre-upgrade-check.ts index f85efb5e..65b0a163 100644 --- a/scripts/pre-upgrade-check.ts +++ b/scripts/pre-upgrade-check.ts @@ -22,6 +22,7 @@ import { xdr, } from "@stellar/stellar-sdk"; import { MultiEndpointServer } from "./rpc-client.js"; +import { logger } from "./logger"; // ── Config ─────────────────────────────────────────────────────────────────── @@ -82,28 +83,28 @@ function scValToString(val: xdr.ScVal): string { async function main(): Promise { if (!CONTRACT_ID) { - console.error("Error: CONTRACT_ID environment variable is required."); + logger.error("Error: CONTRACT_ID environment variable is required."); process.exit(1); } - console.log("=== FlowPay Pre-Upgrade Check ==="); - console.log(`Contract : ${CONTRACT_ID}`); - console.log(`RPC URL : ${RPC_URL}`); - console.log(`Network : ${NETWORK_PASSPHRASE}`); - console.log(""); + logger.info("=== FlowPay Pre-Upgrade Check ==="); + logger.info(`Contract : ${CONTRACT_ID}`); + logger.info(`RPC URL : ${RPC_URL}`); + logger.info(`Network : ${NETWORK_PASSPHRASE}`); + logger.info(""); // 1. Admin address const adminVal = await simulateReadOnly("get_admin"); const admin = scValToString(adminVal); - console.log(`Admin address : ${admin}`); + logger.info(`Admin address : ${admin}`); // 2. Active subscription count const countVal = await simulateReadOnly("get_active_count"); const activeCount = scValToString(countVal); - console.log(`Active subscriptions: ${activeCount}`); + logger.info(`Active subscriptions: ${activeCount}`); if (Number(activeCount) > 0) { - console.warn( + logger.warn( ` ⚠ ${activeCount} active subscription(s) will be affected by a storage migration.` ); } @@ -111,23 +112,23 @@ async function main(): Promise { // 3. Schema version const versionVal = await simulateReadOnly("get_schema_version"); const schemaVersion = scValToString(versionVal); - console.log(`Schema version : ${schemaVersion}`); + logger.info(`Schema version : ${schemaVersion}`); if (Number(schemaVersion) < 2) { - console.warn(" ⚠ Schema is below current version 2 — run migrate() after upgrading."); + logger.warn(" ⚠ Schema is below current version 2 — run migrate() after upgrading."); } - console.log(""); + logger.info(""); // 4. Confirmation gate if (!CONFIRM) { - console.log("Checks complete. Re-run with --confirm to proceed with the upgrade."); + logger.info("Checks complete. Re-run with --confirm to proceed with the upgrade."); process.exit(0); } - console.log("✔ --confirm flag present. Safe to proceed with upgrade."); + logger.info("✔ --confirm flag present. Safe to proceed with upgrade."); } main().catch((err) => { - console.error("Pre-upgrade check failed:", err instanceof Error ? err.message : err); + logger.error("Pre-upgrade check failed:", err instanceof Error ? err.message : err); process.exit(1); }); diff --git a/scripts/replay-events.ts b/scripts/replay-events.ts index 77ee59a4..bcf0c6a2 100644 --- a/scripts/replay-events.ts +++ b/scripts/replay-events.ts @@ -18,6 +18,7 @@ import { Contract, Networks } from "@stellar/stellar-sdk"; import { MultiEndpointServer } from "./rpc-client.js"; +import { logger } from "./logger"; // ── Configuration ──────────────────────────────────────────────────────────── @@ -55,30 +56,30 @@ function parseArgs(argv: string[]): ReplayArgs { toLedger = parseInt(argv[++i], 10); break; default: - console.error(`Unknown argument: ${argv[i]}`); - console.error("Usage: replay-events.ts --from-ledger --to-ledger "); + logger.error(`Unknown argument: ${argv[i]}`); + logger.error("Usage: replay-events.ts --from-ledger --to-ledger "); process.exit(1); } } if (fromLedger === undefined || toLedger === undefined) { - console.error("ERROR: Both --from-ledger and --to-ledger are required."); - console.error("Usage: replay-events.ts --from-ledger --to-ledger "); + logger.error("ERROR: Both --from-ledger and --to-ledger are required."); + logger.error("Usage: replay-events.ts --from-ledger --to-ledger "); process.exit(1); } if (!Number.isInteger(fromLedger) || !Number.isInteger(toLedger)) { - console.error("ERROR: --from-ledger and --to-ledger must be integers."); + logger.error("ERROR: --from-ledger and --to-ledger must be integers."); process.exit(1); } if (fromLedger < 0 || toLedger < 0) { - console.error("ERROR: Ledger values must be non-negative."); + logger.error("ERROR: Ledger values must be non-negative."); process.exit(1); } if (toLedger < fromLedger) { - console.error("ERROR: --to-ledger must be >= --from-ledger."); + logger.error("ERROR: --to-ledger must be >= --from-ledger."); process.exit(1); } @@ -192,16 +193,16 @@ async function main(): Promise { const { fromLedger, toLedger } = parseArgs(process.argv); if (!CONTRACT_ID) { - console.error("ERROR: CONTRACT_ID environment variable is not set."); + logger.error("ERROR: CONTRACT_ID environment variable is not set."); process.exit(1); } const server = new MultiEndpointServer(RPC_URL); - console.log("Replay started"); - console.log(""); - console.log(`Ledgers: ${fromLedger} → ${toLedger}`); - console.log(""); + logger.info("Replay started"); + logger.info(""); + logger.info(`Ledgers: ${fromLedger} → ${toLedger}`); + logger.info(""); let totalEvents = 0; let batchCount = 0; @@ -226,23 +227,23 @@ async function main(): Promise { 100, Math.round(((batchEnd - fromLedger + 1) / (toLedger - fromLedger + 1)) * 100) ); - console.log( + logger.info( ` Batch ${batchCount}: ledgers ${batchStart}–${batchEnd} | ` + `${events.length} events | ${progress}% complete` ); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - console.error(`ERROR processing batch ${batchCount} (ledgers ${batchStart}–${batchEnd}): ${message}`); + logger.error(`ERROR processing batch ${batchCount} (ledgers ${batchStart}–${batchEnd}): ${message}`); process.exit(1); } } - console.log(""); - console.log(`Processed batches: ${batchCount}`); - console.log(""); - console.log(`Events replayed: ${totalEvents}`); - console.log(""); - console.log("Replay completed"); + logger.info(""); + logger.info(`Processed batches: ${batchCount}`); + logger.info(""); + logger.info(`Events replayed: ${totalEvents}`); + logger.info(""); + logger.info("Replay completed"); process.exit(0); } diff --git a/scripts/rotate-fee-collector.ts b/scripts/rotate-fee-collector.ts index 05f35e37..54dbb0ce 100644 --- a/scripts/rotate-fee-collector.ts +++ b/scripts/rotate-fee-collector.ts @@ -1,17 +1,18 @@ import { parseArgs } from "util"; import readline from "readline"; +import { logger } from "./logger"; // Mocking contract invocation wrapper based on project standard patterns // Replace these placeholders with your actual contract client import if available async function get_fee(): Promise<{ collector: string; fee_bps: number }> { // Simulates fetching current fee config - console.log("Fetching current fee configuration..."); + logger.info("Fetching current fee configuration..."); return { collector: "GDQW...OLD_ADDRESS", fee_bps: 250 }; } async function set_fee(newCollector: string, feeBps: number): Promise { // Simulates executing the contract transaction - console.log(`Executing set_fee with Collector: ${newCollector}, BPS: ${feeBps}...`); + logger.info(`Executing set_fee with Collector: ${newCollector}, BPS: ${feeBps}...`); } const rl = readline.createInterface({ @@ -37,50 +38,50 @@ async function main() { // Acceptance Criteria: Requires --new-collector argument if (!newCollector) { - console.error("Error: Missing required argument --new-collector"); + logger.error("Error: Missing required argument --new-collector"); process.exit(1); } // Acceptance Criteria: Reads and displays current fee collector before change const currentFee = await get_fee(); - console.log("\n=== Current Fee Configuration ==="); - console.log(`Collector: ${currentFee.collector}`); - console.log(`Fee BPS: ${currentFee.fee_bps}\n`); + logger.info("\n=== Current Fee Configuration ==="); + logger.info(`Collector: ${currentFee.collector}`); + logger.info(`Fee BPS: ${currentFee.fee_bps}\n`); - console.log(`Target New Collector: ${newCollector}`); + logger.info(`Target New Collector: ${newCollector}`); // Acceptance Criteria: Prompts for confirmation (unless --yes flag) if (!autoConfirm) { const answer = await question("Are you sure you want to rotate the fee collector? (y/N): "); rl.close(); if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") { - console.log("Operation aborted by user."); + logger.info("Operation aborted by user."); process.exit(0); } } else { rl.close(); - console.log("--yes flag detected. Skipping interactive confirmation."); + logger.info("--yes flag detected. Skipping interactive confirmation."); } // Acceptance Criteria: Calls set_fee preserving existing fee_bps - console.log("\nInitiating rotation..."); + logger.info("\nInitiating rotation..."); await set_fee(newCollector, currentFee.fee_bps); - console.log("Transaction successfully confirmed on-chain."); + logger.info("Transaction successfully confirmed on-chain."); // Acceptance Criteria: Verifies change by reading get_fee after update - console.log("\n=== Verifying On-Chain Update ==="); + logger.info("\n=== Verifying On-Chain Update ==="); const updatedFee = await get_fee(); if (updatedFee.collector === newCollector) { - console.log("✅ Success: Fee collector rotated correctly!"); - console.log(`New Verification -> Collector: ${updatedFee.collector}, BPS: ${updatedFee.fee_bps}`); + logger.info("✅ Success: Fee collector rotated correctly!"); + logger.info(`New Verification -> Collector: ${updatedFee.collector}, BPS: ${updatedFee.fee_bps}`); } else { - console.error("❌ Error: Verification failed. Collector address does not match expected update."); + logger.error("❌ Error: Verification failed. Collector address does not match expected update."); process.exit(1); } } main().catch((err) => { - console.error("Fatal execution error:", err); + logger.error("Fatal execution error:", err); process.exit(1); }); diff --git a/scripts/subscriber-churn-report.ts b/scripts/subscriber-churn-report.ts index 814a8339..8527af31 100644 --- a/scripts/subscriber-churn-report.ts +++ b/scripts/subscriber-churn-report.ts @@ -20,6 +20,7 @@ import { DatabaseSync } from "node:sqlite"; import { writeFileSync } from "node:fs"; +import { logger } from "./logger"; interface EventRow { timestamp: number; @@ -57,7 +58,7 @@ function tableExists(db: DatabaseSync, name: string): boolean { function main() { const dbPath = getArg("--db"); - if (!dbPath) { console.error("--db required"); process.exit(1); } + if (!dbPath) { logger.error("--db required"); process.exit(1); } const db = new DatabaseSync(dbPath, { open: true }); @@ -111,7 +112,7 @@ function main() { const out = getArg("--out"); const json = JSON.stringify(report, null, 2); - if (out) { writeFileSync(out, json); console.log(`Wrote report to ${out}`); } + if (out) { writeFileSync(out, json); logger.info(`Wrote report to ${out}`); } else process.stdout.write(json + "\n"); } diff --git a/scripts/subscription-snapshot.ts b/scripts/subscription-snapshot.ts index f235f17c..52611d7e 100644 --- a/scripts/subscription-snapshot.ts +++ b/scripts/subscription-snapshot.ts @@ -26,6 +26,7 @@ import { } from "@stellar/stellar-sdk"; import { MultiEndpointServer } from "./rpc-client.js"; import { readFileSync, writeFileSync } from "node:fs"; +import { logger } from "./logger"; const CONTRACT_ID = process.env.CONTRACT_ID ?? ""; const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; @@ -117,10 +118,10 @@ function readAddresses(): string[] { } async function main() { - if (!CONTRACT_ID) { console.error("CONTRACT_ID required"); process.exit(1); } + if (!CONTRACT_ID) { logger.error("CONTRACT_ID required"); process.exit(1); } const addresses = readAddresses(); - if (addresses.length === 0) { console.error("No addresses provided"); process.exit(1); } + if (addresses.length === 0) { logger.error("No addresses provided"); process.exit(1); } const subscriptions: SubscriptionEntry[] = []; for (const addr of addresses) { @@ -144,10 +145,10 @@ async function main() { const outIdx = process.argv.indexOf("--out"); if (outIdx !== -1) { writeFileSync(process.argv[outIdx + 1], JSON.stringify(snapshot, null, 2)); - console.log(`Wrote snapshot to ${process.argv[outIdx + 1]}`); + logger.info(`Wrote snapshot to ${process.argv[outIdx + 1]}`); } else { process.stdout.write(JSON.stringify(snapshot, null, 2) + "\n"); } } -main().catch((e) => { console.error(e instanceof Error ? e.message : e); process.exit(1); }); +main().catch((e) => { logger.error(e instanceof Error ? e.message : e); process.exit(1); }); diff --git a/scripts/testnet-setup.ts b/scripts/testnet-setup.ts index cb2ade68..2cad6c2f 100644 --- a/scripts/testnet-setup.ts +++ b/scripts/testnet-setup.ts @@ -28,6 +28,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from import { join } from "node:path"; import { Keypair, Contract, Networks, TransactionBuilder, BASE_FEE, nativeToScVal, Address, xdr } from "@stellar/stellar-sdk"; import { Server } from "@stellar/stellar-sdk/rpc"; +import { logger } from "./logger"; const RPC_URL = process.env.RPC_URL || process.env.VITE_RPC_URL || "https://soroban-testnet.stellar.org"; const FRIENDBOT_URL = process.env.FRIENDBOT_URL || "https://friendbot.stellar.org"; @@ -112,16 +113,16 @@ async function main() { const args = process.argv.slice(2); const reset = args.includes("--reset"); - console.log(`====================================================`); - console.log(`FlowPay Testnet Faucet & Environment Setup`); - console.log(`Reset Mode: ${reset ? "YES (--reset)" : "NO"}`); - console.log(`RPC Endpoint: ${RPC_URL}`); - console.log(`====================================================\n`); + logger.info(`====================================================`); + logger.info(`FlowPay Testnet Faucet & Environment Setup`); + logger.info(`Reset Mode: ${reset ? "YES (--reset)" : "NO"}`); + logger.info(`RPC Endpoint: ${RPC_URL}`); + logger.info(`====================================================\n`); mkdirSync(join(process.cwd(), "data"), { recursive: true }); if (reset && existsSync(MANIFEST_PATH)) { - console.log(`Backing up existing manifest to: ${BACKUP_MANIFEST_PATH}`); + logger.info(`Backing up existing manifest to: ${BACKUP_MANIFEST_PATH}`); copyFileSync(MANIFEST_PATH, BACKUP_MANIFEST_PATH); } @@ -129,7 +130,7 @@ async function main() { if (!reset && existsSync(MANIFEST_PATH)) { try { manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")); - console.log(`Loaded existing testnet manifest from ${MANIFEST_PATH}`); + logger.info(`Loaded existing testnet manifest from ${MANIFEST_PATH}`); } catch { manifest = null; } @@ -168,44 +169,44 @@ async function main() { const server = new Server(RPC_URL); // 1. Fund Accounts via Friendbot - console.log(`Step 1: Funding test accounts via Friendbot...`); + logger.info(`Step 1: Funding test accounts via Friendbot...`); const allAccounts = [manifest.admin, manifest.merchant, ...manifest.subscribers]; for (const acc of allAccounts) { const funded = await isAccountFunded(server, acc.publicKey); if (funded) { - console.log(` [OK] ${acc.name} (${acc.publicKey}) is already funded.`); + logger.info(` [OK] ${acc.name} (${acc.publicKey}) is already funded.`); } else { - console.log(` [FUNDING] ${acc.name} (${acc.publicKey})...`); + logger.info(` [FUNDING] ${acc.name} (${acc.publicKey})...`); await fundViaFriendbot(acc.publicKey); - console.log(` [OK] ${acc.name} funded.`); + logger.info(` [OK] ${acc.name} funded.`); } } // 2. Setup Contract Environment Details - console.log(`\nStep 2: Configuring contract and subscriptions...`); - console.log(` Contract ID: ${manifest.contractId}`); - console.log(` Token SAC: ${manifest.tokenAddress}`); - console.log(` Admin Address: ${manifest.admin.publicKey}`); - console.log(` Merchant Address: ${manifest.merchant.publicKey}`); + logger.info(`\nStep 2: Configuring contract and subscriptions...`); + logger.info(` Contract ID: ${manifest.contractId}`); + logger.info(` Token SAC: ${manifest.tokenAddress}`); + logger.info(` Admin Address: ${manifest.admin.publicKey}`); + logger.info(` Merchant Address: ${manifest.merchant.publicKey}`); - console.log(`\nStep 3: Creating 5 test subscriptions...`); + logger.info(`\nStep 3: Creating 5 test subscriptions...`); for (const sub of manifest.subscribers) { const details = sub.subscription!; - console.log(` Subscribed ${sub.name} (${sub.publicKey}) -> Merchant (${manifest.merchant.publicKey})`); - console.log(` Amount: ${details.amountXlm} XLM (${details.amountStroops} stroops), Interval: ${details.intervalSeconds}s`); + logger.info(` Subscribed ${sub.name} (${sub.publicKey}) -> Merchant (${manifest.merchant.publicKey})`); + logger.info(` Amount: ${details.amountXlm} XLM (${details.amountStroops} stroops), Interval: ${details.intervalSeconds}s`); } manifest.updatedAt = new Date().toISOString(); writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf-8"); - console.log(`\n====================================================`); - console.log(`Testnet setup complete!`); - console.log(`Manifest written to: ${MANIFEST_PATH}`); - console.log(`====================================================`); + logger.info(`\n====================================================`); + logger.info(`Testnet setup complete!`); + logger.info(`Manifest written to: ${MANIFEST_PATH}`); + logger.info(`====================================================`); } main().catch((err) => { - console.error("Testnet setup failed:", err instanceof Error ? err.message : err); + logger.error("Testnet setup failed:", err instanceof Error ? err.message : err); process.exit(1); }); diff --git a/scripts/top-merchants.ts b/scripts/top-merchants.ts index 21d7e4cf..77ac1d4b 100644 --- a/scripts/top-merchants.ts +++ b/scripts/top-merchants.ts @@ -14,6 +14,7 @@ import { DatabaseSync } from "node:sqlite"; import { writeFileSync } from "node:fs"; +import { logger } from "./logger"; interface EventRow { data: string; @@ -32,11 +33,11 @@ function getArg(flag: string): string | undefined { function main() { const dbPath = getArg("--db"); - if (!dbPath) { console.error("--db required"); process.exit(1); } + if (!dbPath) { logger.error("--db required"); process.exit(1); } const limitArg = getArg("--limit"); const limit = limitArg ? parseInt(limitArg, 10) : 20; - if (isNaN(limit) || limit < 1) { console.error("--limit must be a positive integer"); process.exit(1); } + if (isNaN(limit) || limit < 1) { logger.error("--limit must be a positive integer"); process.exit(1); } const db = new DatabaseSync(dbPath, { open: true }); const rows = db @@ -63,7 +64,7 @@ function main() { const out = getArg("--out"); const json = JSON.stringify(leaderboard, null, 2); - if (out) { writeFileSync(out, json); console.log(`Wrote leaderboard to ${out}`); } + if (out) { writeFileSync(out, json); logger.info(`Wrote leaderboard to ${out}`); } else process.stdout.write(json + "\n"); } diff --git a/scripts/validate-config.ts b/scripts/validate-config.ts index da13e005..7aeecc2b 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -26,6 +26,7 @@ import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { ConfigSchema, formatConfigErrors } from "./config"; +import { logger } from "./logger"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -74,18 +75,18 @@ function loadEnv(projectRoot: string): Map { const envDefault = resolve(projectRoot, ".env"); if (existsSync(envLocal)) { - console.log(`Reading configuration from: .env.local`); + logger.info(`Reading configuration from: .env.local`); return parseEnvFile(envLocal); } if (existsSync(envDefault)) { - console.log(`Reading configuration from: .env`); + logger.info(`Reading configuration from: .env`); return parseEnvFile(envDefault); } - console.error("ERROR: No .env or .env.local file found in project root."); - console.error(" Create one from .env.example or set required variables:"); - console.error(" CONTRACT_ID, RPC_URL, SECRET_KEY, BATCH_SIZE, INTERVAL_SECONDS"); + logger.error("ERROR: No .env or .env.local file found in project root."); + logger.error(" Create one from .env.example or set required variables:"); + logger.error(" CONTRACT_ID, RPC_URL, SECRET_KEY, BATCH_SIZE, INTERVAL_SECONDS"); process.exit(1); } @@ -101,38 +102,38 @@ function main(): void { envObject[key] = value; } - console.log(""); + logger.info(""); const result = ConfigSchema.safeParse(envObject); if (result.success) { const config = result.data; - console.log("✓ CONTRACT_ID ..............", config.CONTRACT_ID); - console.log("✓ RPC_URL ..................", config.RPC_URL); - console.log("✓ SECRET_KEY ...............", "******** (valid)"); - console.log("✓ BATCH_SIZE ...............", config.BATCH_SIZE); - console.log("✓ INTERVAL_SECONDS .........", config.INTERVAL_SECONDS); + logger.info("✓ CONTRACT_ID ..............", config.CONTRACT_ID); + logger.info("✓ RPC_URL ..................", config.RPC_URL); + logger.info("✓ SECRET_KEY ...............", "******** (valid)"); + logger.info("✓ BATCH_SIZE ...............", config.BATCH_SIZE); + logger.info("✓ INTERVAL_SECONDS .........", config.INTERVAL_SECONDS); if (config.WEBHOOK_URL) { - console.log("✓ WEBHOOK_URL ..............", config.WEBHOOK_URL); + logger.info("✓ WEBHOOK_URL ..............", config.WEBHOOK_URL); } if (config.NETWORK_PASSPHRASE) { - console.log("✓ NETWORK_PASSPHRASE .......", config.NETWORK_PASSPHRASE); + logger.info("✓ NETWORK_PASSPHRASE .......", config.NETWORK_PASSPHRASE); } - console.log("\nAll configuration checks passed.\n"); + logger.info("\nAll configuration checks passed.\n"); process.exit(0); } // Validation failed — display all issues with human-readable messages const errors = formatConfigErrors(result.error); - console.log("Configuration validation failed:\n"); + logger.info("Configuration validation failed:\n"); for (const msg of errors) { - console.log(` ${msg}`); + logger.info(` ${msg}`); } - console.log(""); - console.log(`Validation failed: ${errors.length} issue(s) found.`); - console.log("Fix the above errors and re-run."); + logger.info(""); + logger.info(`Validation failed: ${errors.length} issue(s) found.`); + logger.info("Fix the above errors and re-run."); process.exit(1); } diff --git a/scripts/watch-events.ts b/scripts/watch-events.ts index e9070443..88190dad 100644 --- a/scripts/watch-events.ts +++ b/scripts/watch-events.ts @@ -9,6 +9,7 @@ import { Server } from "@stellar/stellar-sdk/rpc"; import { EventDedupCache, createCacheKey } from "./event-dedup.js"; import { MultiEndpointServer } from "./rpc-client.js"; +import { logger } from "./logger"; // ── Configuration ──────────────────────────────────────────────────────────────── @@ -18,8 +19,8 @@ const POLL_INTERVAL_MS = 3000; const DEBUG = process.env.DEBUG === "1" || process.env.DEBUG?.includes("payflow"); if (!CONTRACT_ID) { - console.error("Error: CONTRACT_ID environment variable is required"); - console.error("Usage: CONTRACT_ID=your_contract_id RPC_URL=https://... tsx watch-events.ts"); + logger.error("Error: CONTRACT_ID environment variable is required"); + logger.error("Usage: CONTRACT_ID=your_contract_id RPC_URL=https://... tsx watch-events.ts"); process.exit(1); } @@ -181,7 +182,7 @@ function printEvent(event: ParsedEvent): void { const merchant = event.merchant ? shortenAddress(event.merchant) : "N/A"; const amount = event.amount ? `${stroopsToXlm(event.amount)} XLM` : "N/A"; - console.log( + logger.info( `${colors.dim}${timestamp}${colors.reset} ` + `${color}${colors.bright}${event.type}${colors.reset} ` + `${colors.dim}|${colors.reset} ` + @@ -202,7 +203,7 @@ function printEvent(event: ParsedEvent): void { */ function debugLog(...args: unknown[]): void { if (DEBUG) { - console.error(colors.dim + "[DEBUG]" + colors.reset, ...args); + logger.error(colors.dim + "[DEBUG]" + colors.reset, ...args); } } @@ -248,7 +249,7 @@ async function fetchAndPrintEvents(): Promise { totalEventsSeen += response.events.length; if (totalEventsSeen >= 100) { const s = dedupCache.stats; - console.error( + logger.error( colors.dim + `[DEDUP] ${s.deduplicatedTotal} duplicates skipped, ` + `${s.totalProcessed} unique processed, ` + `${s.size}/${s.maxSize} cache entries, ` + @@ -264,21 +265,21 @@ async function fetchAndPrintEvents(): Promise { } if (newEvents.length > 0) { - console.log(colors.dim + `─ ${newEvents.length} new event(s) ─` + colors.reset); + logger.info(colors.dim + `─ ${newEvents.length} new event(s) ─` + colors.reset); } } catch (error) { const errorMsg = error instanceof Error ? error.message : JSON.stringify(error); - console.error(colors.red + `Error fetching events: ${errorMsg}` + colors.reset); + logger.error(colors.red + `Error fetching events: ${errorMsg}` + colors.reset); } } async function main(): Promise { - console.log(colors.bright + "FlowPay Event Watcher" + colors.reset); - console.log(colors.dim + `RPC: ${RPC_URL}` + colors.reset); - console.log(colors.dim + `Contract: ${CONTRACT_ID}` + colors.reset); - console.log(colors.dim + `Polling every ${POLL_INTERVAL_MS}ms...` + colors.reset); - console.log(colors.dim + `Dedup cache: ${dedupCache.stats.maxSize} entries` + (dedupCache.stats.maxSize > 0 ? `, TTL: ${process.env.EVENT_DEDUP_TTL_MS || "none"}` : "") + colors.reset); - console.log(""); + logger.info(colors.bright + "FlowPay Event Watcher" + colors.reset); + logger.info(colors.dim + `RPC: ${RPC_URL}` + colors.reset); + logger.info(colors.dim + `Contract: ${CONTRACT_ID}` + colors.reset); + logger.info(colors.dim + `Polling every ${POLL_INTERVAL_MS}ms...` + colors.reset); + logger.info(colors.dim + `Dedup cache: ${dedupCache.stats.maxSize} entries` + (dedupCache.stats.maxSize > 0 ? `, TTL: ${process.env.EVENT_DEDUP_TTL_MS || "none"}` : "") + colors.reset); + logger.info(""); // Initial fetch await fetchAndPrintEvents(); @@ -293,15 +294,15 @@ async function main(): Promise { // ── Error Handling ─────────────────────────────────────────────────────────────── process.on("uncaughtException", (error) => { - console.error(colors.red + `Uncaught exception: ${error}` + colors.reset); + logger.error(colors.red + `Uncaught exception: ${error}` + colors.reset); }); process.on("unhandledRejection", (reason) => { - console.error(colors.red + `Unhandled rejection: ${reason}` + colors.reset); + logger.error(colors.red + `Unhandled rejection: ${reason}` + colors.reset); }); // Start the watcher main().catch((error) => { - console.error(colors.red + `Fatal error: ${error}` + colors.reset); + logger.error(colors.red + `Fatal error: ${error}` + colors.reset); process.exit(1); });