From 18b452e3f0ed22335fc959bce1e9450ab132addc Mon Sep 17 00:00:00 2001 From: Gorka Date: Sun, 31 May 2026 11:34:40 -0300 Subject: [PATCH] feat: backend logging convention migration (squashed) --- deno.json | 2 +- src/config/logger.ts | 26 +- src/config/network.ts | 3 +- src/core/mempool/index.ts | 86 +- .../auth/challenge/create/create-challenge.ts | 135 +-- .../create/generate-challenge-jwt.ts | 65 +- .../challenge/store/create-challenge-db.ts | 117 ++- .../store/create-challenge-memory.ts | 82 +- .../challenge/store/update-challenge-db.ts | 72 +- .../store/update-challenge-session.ts | 131 +-- .../challenge/verify/compare-challenge.ts | 136 +-- .../auth/challenge/verify/verify-challenge.ts | 236 +++-- src/core/service/auth/dashboard-auth.test.ts | 59 +- src/core/service/auth/dashboard-auth.ts | 51 +- .../auth/service/service-auth-secret.ts | 4 +- .../sessions/in-memory-session-manager.ts | 71 +- src/core/service/bundle/add-bundle.process.ts | 408 ++++---- src/core/service/bundle/bundle.service.ts | 28 +- src/core/service/bundle/get-bundle.process.ts | 96 +- .../service/bundle/list-bundles.process.ts | 91 +- .../event-watcher/channel-registry.test.ts | 19 +- .../service/event-watcher/channel-registry.ts | 58 +- .../event-watcher/event-watcher.process.ts | 46 +- .../event-watcher.service.test.ts | 8 + .../event-watcher/event-watcher.service.ts | 11 + src/core/service/event-watcher/index.ts | 143 +-- src/core/service/events/emit-helpers.ts | 66 +- src/core/service/events/event-bus.ts | 32 +- src/core/service/events/index.ts | 2 +- src/core/service/executor/channel-resolver.ts | 25 +- .../executor/executor-failure.helpers.ts | 36 +- src/core/service/executor/executor.process.ts | 102 +- src/core/service/executor/executor.service.ts | 25 + .../mempool-metrics/metrics-collector.ts | 39 +- src/core/service/mempool/mempool.process.ts | 158 ++- src/core/service/pay/channel.service.ts | 13 +- src/core/service/pay/escrow.service.ts | 39 +- .../verifier/verifier-failure.helpers.ts | 40 +- src/core/service/verifier/verifier.process.ts | 105 +- src/core/service/verifier/verifier.service.ts | 15 + src/http/middleware/append-request-id.ts | 22 +- src/http/middleware/auth/index.ts | 63 +- src/http/pipelines/error-pipeline.ts | 12 +- src/http/pipelines/get-endpoint.ts | 9 +- src/http/pipelines/post-endpoint.ts | 9 +- src/http/plugins/process-error-response.ts | 14 +- src/http/processes/parse-request-body.ts | 36 +- src/http/processes/parse-request-query.ts | 36 +- src/http/processes/set-api-response.ts | 9 +- src/http/processes/set-successful-response.ts | 18 +- src/http/v1/bundle/get.ts | 73 +- src/http/v1/bundle/list.ts | 59 +- src/http/v1/bundle/post.ts | 66 +- src/http/v1/bundle/routes.ts | 49 +- src/http/v1/council/routes.ts | 7 +- src/http/v1/dashboard/audit-export.ts | 123 +-- src/http/v1/dashboard/auth/challenge.ts | 83 +- src/http/v1/dashboard/auth/verify.ts | 86 +- src/http/v1/dashboard/bundle-admin.ts | 188 ++-- src/http/v1/dashboard/bundles.ts | 316 +++--- src/http/v1/dashboard/channels.ts | 41 +- src/http/v1/dashboard/council.ts | 948 +++++++++--------- src/http/v1/dashboard/council_test.ts | 3 +- src/http/v1/dashboard/mempool.ts | 69 +- src/http/v1/dashboard/metrics.ts | 110 +- src/http/v1/dashboard/operations.ts | 90 +- src/http/v1/dashboard/pp.ts | 375 +++---- src/http/v1/dashboard/routes.ts | 219 ++-- src/http/v1/dashboard/transactions.ts | 245 ++--- src/http/v1/dashboard/treasury.ts | 106 +- src/http/v1/dashboard/utxos.ts | 105 +- src/http/v1/entities/post.ts | 147 +-- src/http/v1/entities/routes.ts | 15 +- src/http/v1/events/routes.ts | 16 +- src/http/v1/events/ws-handler.ts | 176 ++-- src/http/v1/pay/custodial/account.ts | 63 +- src/http/v1/pay/custodial/login.ts | 102 +- src/http/v1/pay/custodial/register.ts | 120 ++- src/http/v1/pay/custodial/send.ts | 30 +- src/http/v1/pay/demo/simulate-kyc.ts | 112 ++- src/http/v1/pay/escrow/summary.ts | 99 +- src/http/v1/pay/kyc/get.ts | 96 +- src/http/v1/pay/kyc/post.ts | 124 ++- src/http/v1/pay/report/post.ts | 68 +- src/http/v1/pay/routes.ts | 116 ++- src/http/v1/pay/self/balance.ts | 145 +-- src/http/v1/pay/self/send.ts | 30 +- .../v1/pay/tests/custodial_account_test.ts | 15 +- src/http/v1/pay/tests/custodial_login_test.ts | 19 +- .../v1/pay/tests/custodial_register_test.ts | 19 +- src/http/v1/pay/tests/custodial_send_test.ts | 35 +- .../v1/pay/tests/demo_simulate_kyc_test.ts | 21 +- src/http/v1/pay/tests/deno.lock | 27 +- src/http/v1/pay/tests/escrow_service_test.ts | 19 +- src/http/v1/pay/tests/escrow_summary_test.ts | 15 +- src/http/v1/pay/tests/kyc_get_test.ts | 15 +- src/http/v1/pay/tests/kyc_post_test.ts | 23 +- .../v1/pay/tests/mock_channel_resolver.ts | 11 +- src/http/v1/pay/tests/mock_channel_service.ts | 1 + src/http/v1/pay/tests/report_test.ts | 17 +- src/http/v1/pay/tests/self_balance_test.ts | 19 +- src/http/v1/pay/tests/self_send_test.ts | 23 +- .../v1/pay/tests/transactions_list_test.ts | 19 +- src/http/v1/pay/transactions/list.ts | 120 +-- src/http/v1/stellar/auth/get.ts | 63 +- src/http/v1/stellar/auth/post.ts | 65 +- src/http/v1/stellar/auth/routes.ts | 16 +- src/http/v1/stellar/routes.ts | 18 +- src/http/v1/v1.routes.ts | 103 +- src/http/v1/waitlist/discord-notify.ts | 14 +- src/http/v1/waitlist/routes.ts | 66 +- src/main.ts | 42 +- src/utils/error/assert-or-throw.ts | 4 +- src/utils/error/log-and-throw.ts | 9 +- src/utils/logger/index.ts | 247 ++++- 115 files changed, 4999 insertions(+), 3995 deletions(-) diff --git a/deno.json b/deno.json index 43462ca..e65161f 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight-protocol/provider-platform", - "version": "0.7.0", + "version": "0.6.31", "license": "MIT", "exports": "./src/main.ts", "tasks": { diff --git a/src/config/logger.ts b/src/config/logger.ts index 3d93c30..02da0bc 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -1,18 +1,10 @@ -import { Logger, LogLevel } from "@/utils/logger/index.ts"; -import { loadOptionalEnv } from "@/utils/env/loadEnv.ts"; - -export const LOG_LEVEL = loadOptionalEnv("LOG_LEVEL") as keyof typeof LogLevel; - -let LOG: Logger; - -if (LOG_LEVEL !== undefined && LOG_LEVEL in LogLevel) { - LOG = new Logger(LogLevel[LOG_LEVEL]); -} else { - LOG = new Logger(LogLevel.INFO); - - LOG.warn( - `LOG_LEVEL is not set or invalid. Defaulting to INFO. Received: ${LOG_LEVEL}`, - ); +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 route handler + * via dependency injection. There is no module-level singleton. + */ +export function createLogger(): Logger { + return newLogger(parseLevel(Deno.env.get("LOG_LEVEL"))); } - -export { LOG }; diff --git a/src/config/network.ts b/src/config/network.ts index 2bf7762..14e26c8 100644 --- a/src/config/network.ts +++ b/src/config/network.ts @@ -1,7 +1,6 @@ import { NetworkConfig, NetworkProviders } from "@colibri/core"; import { StellarNetworkId } from "@moonlight/moonlight-sdk"; import * as E from "@/config/error.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; import { loadOptionalEnv } from "@/utils/env/loadEnv.ts"; export function selectNetwork(envNetwork: string): { @@ -53,6 +52,6 @@ export function selectNetwork(envNetwork: string): { }; } default: - logAndThrow(new E.INVALID_NETWORK()); + throw new E.INVALID_NETWORK(); } } diff --git a/src/core/mempool/index.ts b/src/core/mempool/index.ts index 57033e8..aecdf63 100644 --- a/src/core/mempool/index.ts +++ b/src/core/mempool/index.ts @@ -6,56 +6,23 @@ import { MEMPOOL_SLOT_CAPACITY, MEMPOOL_TTL_CHECK_INTERVAL_MS, } from "@/config/env.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; -/** - * Singleton instance of the Mempool - * Will be initialized during application startup - */ export let mempool: Mempool; - -/** - * Singleton instance of the Executor - * Will be initialized during application startup - */ export let executor: Executor; - -/** - * Singleton instance of the Verifier - * Will be initialized during application startup - */ export let verifier: Verifier; - -/** - * Singleton instance of the MetricsCollector - */ export let metricsCollector: MetricsCollector; - -/** - * Platform version, read once at startup from deno.json. - */ export let platformVersion = "unknown"; -/** - * Interval ID for TTL check - */ let ttlCheckIntervalId: number | null = null; -/** - * Initializes the mempool singleton instance - * Should be called during application startup - */ -export function initializeMempool(): void { +export function initializeMempool(deps: { log: Logger }): void { if (mempool) { throw new Error("Mempool already initialized"); } - mempool = new Mempool(MEMPOOL_SLOT_CAPACITY); + mempool = new Mempool(MEMPOOL_SLOT_CAPACITY, deps); } -/** - * Gets the mempool instance - * Throws if not initialized - */ export function getMempool(): Mempool { if (!mempool) { throw new Error("Mempool not initialized. Call initializeMempool() first."); @@ -63,41 +30,37 @@ export function getMempool(): Mempool { return mempool; } -/** - * Initializes the complete mempool system - * - Initializes Mempool and loads pending bundles from database - * - Starts Executor service - * - Starts Verifier service - * - Starts periodic TTL check - */ -export async function initializeMempoolSystem(): Promise { - LOG.info("Initializing mempool system..."); +export async function initializeMempoolSystem( + deps: { log: Logger }, +): Promise { + const log = deps.log.scope("mempoolSystem"); + log.info("initializeMempoolSystem"); + log.event("initializing mempool system"); // Initialize Mempool - initializeMempool(); + initializeMempool(deps); await mempool.initialize(); // Initialize Executor - executor = new Executor(); + executor = new Executor(deps); executor.start(); // Initialize Verifier - verifier = new Verifier(); + verifier = new Verifier(deps); verifier.start(); - // Read platform version once at startup (import.meta.dirname resolves - // to the module's directory, so this works regardless of CWD) + // Read platform version once at startup try { const denoJsonPath = new URL("../../../deno.json", import.meta.url).pathname; const denoJson = JSON.parse(await Deno.readTextFile(denoJsonPath)); platformVersion = denoJson.version ?? "unknown"; - } catch { - LOG.warn("Could not read deno.json for platform version"); + } catch (err) { + log.error(err, "could not read deno.json for platform version"); } // Initialize MetricsCollector - metricsCollector = new MetricsCollector(platformVersion); + metricsCollector = new MetricsCollector(platformVersion, deps); metricsCollector.start(); // Start periodic TTL check @@ -105,21 +68,16 @@ export async function initializeMempoolSystem(): Promise { try { await mempool.expireBundles(); } catch (error) { - LOG.error("Error during TTL check", { - error: error instanceof Error ? error.message : String(error), - }); + log.error(error, "error during TTL check"); } }, MEMPOOL_TTL_CHECK_INTERVAL_MS) as unknown as number; - LOG.info("Mempool system initialized successfully"); + log.event("mempool system initialized successfully"); } -/** - * Shuts down the mempool system gracefully - * Stops all services and clears intervals - */ -export function shutdownMempoolSystem(): void { - LOG.info("Shutting down mempool system..."); +export function shutdownMempoolSystem(deps: { log: Logger }): void { + const log = deps.log.scope("mempoolSystem"); + log.event("shutting down mempool system"); if (executor) { executor.stop(); @@ -138,5 +96,5 @@ export function shutdownMempoolSystem(): void { ttlCheckIntervalId = null; } - LOG.info("Mempool system shut down successfully"); + log.event("mempool system shut down successfully"); } diff --git a/src/core/service/auth/challenge/create/create-challenge.ts b/src/core/service/auth/challenge/create/create-challenge.ts index f4d0842..ea5a7ce 100644 --- a/src/core/service/auth/challenge/create/create-challenge.ts +++ b/src/core/service/auth/challenge/create/create-challenge.ts @@ -17,70 +17,81 @@ import type { import { assertOrThrow } from "@/utils/error/assert-or-throw.ts"; import { isDefined } from "@/utils/type-guards/is-defined.ts"; import * as E from "@/core/service/auth/challenge/create/error.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; import { withSpan } from "@/core/tracing.ts"; - -export const P_CreateChallenge = ProcessEngine.create( - (input: GetChallengeInput): Promise => { - return withSpan("P_CreateChallenge", async (span) => { - const { ctx, query } = input; - const clientAccount = query.account; - - span.addEvent("validating_client_account", { - "client.account": clientAccount ?? "undefined", - }); - assertOrThrow(isDefined(clientAccount), new E.MISSING_CLIENT_ACCOUNT()); - - try { - span.addEvent("building_challenge_transaction"); - const { tx, nonce, minTime, maxTime } = getChallengeTransaction( - clientAccount, - ); - - const xdr = tx.toXDR(); - const txHash = tx.hash().toString("hex"); - - const dateCreated = new Date(minTime * 1000); - const expiresAt = new Date(maxTime * 1000); - - const { clientIp, userAgent, requestId } = extractRequestMetadata(ctx); - - span.addEvent("challenge_created", { - "challenge.txHash": txHash, - "challenge.clientAccount": clientAccount, - "challenge.requestId": requestId, - }); - - const output: ChallengeData = { - ctx, - challengeData: { - txHash: txHash, - clientAccount: clientAccount, - xdr, - nonce, - dateCreated: dateCreated, - requestId, - clientIp, - userAgent, - expiresAt, - }, - }; - - return await output; - } catch (error) { - span.addEvent("challenge_creation_failed", { - "error.message": error instanceof Error - ? error.message - : String(error), +import type { Logger } from "@/utils/logger/index.ts"; + +export const P_CreateChallenge = (deps: { log: Logger }) => + ProcessEngine.create( + (input: GetChallengeInput): Promise => { + return withSpan("P_CreateChallenge", async (span) => { + const log = deps.log.scope("P_CreateChallenge"); + log.info("P_CreateChallenge"); + const { ctx, query } = input; + const clientAccount = query.account; + log.debug("clientAccount", clientAccount); + + span.addEvent("validating_client_account", { + "client.account": clientAccount ?? "undefined", }); - logAndThrow(new E.FAILED_TO_CREATE_CHALLENGE(error)); - } - }); - }, - { - name: "CreateChallengeProcessEngine", - }, -); + log.event("validating client account"); + assertOrThrow(isDefined(clientAccount), new E.MISSING_CLIENT_ACCOUNT()); + + try { + span.addEvent("building_challenge_transaction"); + log.event("building challenge transaction"); + const { tx, nonce, minTime, maxTime } = getChallengeTransaction( + clientAccount, + ); + + const xdr = tx.toXDR(); + const txHash = tx.hash().toString("hex"); + + const dateCreated = new Date(minTime * 1000); + const expiresAt = new Date(maxTime * 1000); + + const { clientIp, userAgent, requestId } = extractRequestMetadata( + ctx, + ); + + span.addEvent("challenge_created", { + "challenge.txHash": txHash, + "challenge.clientAccount": clientAccount, + "challenge.requestId": requestId, + }); + log.debug("txHash", txHash); + log.event("challenge created"); + + const output: ChallengeData = { + ctx, + challengeData: { + txHash: txHash, + clientAccount: clientAccount, + xdr, + nonce, + dateCreated: dateCreated, + requestId, + clientIp, + userAgent, + expiresAt, + }, + }; + + return await output; + } catch (error) { + span.addEvent("challenge_creation_failed", { + "error.message": error instanceof Error + ? error.message + : String(error), + }); + log.error(error, "challenge creation failed"); + throw new E.FAILED_TO_CREATE_CHALLENGE(error); + } + }); + }, + { + name: "CreateChallengeProcessEngine", + }, + ); const getChallengeTransaction = ( clientAccount: string, diff --git a/src/core/service/auth/challenge/create/generate-challenge-jwt.ts b/src/core/service/auth/challenge/create/generate-challenge-jwt.ts index 46f3ee0..d9ff17c 100644 --- a/src/core/service/auth/challenge/create/generate-challenge-jwt.ts +++ b/src/core/service/auth/challenge/create/generate-challenge-jwt.ts @@ -10,36 +10,45 @@ import * as E from "@/core/service/auth/challenge/create/error.ts"; import { assertOrThrow } from "@/utils/error/assert-or-throw.ts"; import { isDefined } from "@/utils/type-guards/is-defined.ts"; import { withSpan } from "@/core/tracing.ts"; +import type { Logger } from "@/utils/logger/index.ts"; -export const P_GenerateChallengeJWT = ProcessEngine.create( - ( - input: PostChallengeInput, - _metadataHelper?: MetadataHelper, - ): Promise => { - return withSpan("P_GenerateChallengeJWT", async (span) => { - const { signedChallenge } = input.body; - const tx = new Transaction( - signedChallenge, - NETWORK_CONFIG.networkPassphrase, - ); +export const P_GenerateChallengeJWT = (deps: { log: Logger }) => + ProcessEngine.create( + ( + input: PostChallengeInput, + _metadataHelper?: MetadataHelper, + ): Promise => { + return withSpan("P_GenerateChallengeJWT", async (span) => { + const log = deps.log.scope("P_GenerateChallengeJWT"); + log.info("P_GenerateChallengeJWT"); - const key = tx.hash().toString("hex"); + const { signedChallenge } = input.body; + const tx = new Transaction( + signedChallenge, + NETWORK_CONFIG.networkPassphrase, + ); - const clientAccount = tx.operations[0].source; - assertOrThrow(isDefined(clientAccount), new E.MISSING_CLIENT_ACCOUNT()); + const key = tx.hash().toString("hex"); + log.debug("txHash", key); - span.addEvent("generating_jwt", { "client.account": clientAccount }); - const jwt = await generateJwt(clientAccount, key); - span.addEvent("jwt_generated"); + const clientAccount = tx.operations[0].source; + assertOrThrow(isDefined(clientAccount), new E.MISSING_CLIENT_ACCOUNT()); + log.debug("clientAccount", clientAccount); - return { - ctx: input.ctx, - body: input.body, - jwt, - }; - }); - }, - { - name: "GenerateChallengeJWT", - }, -); + span.addEvent("generating_jwt", { "client.account": clientAccount }); + log.event("generating JWT"); + const jwt = await generateJwt(clientAccount, key); + span.addEvent("jwt_generated"); + log.event("JWT generated"); + + return { + ctx: input.ctx, + body: input.body, + jwt, + }; + }); + }, + { + name: "GenerateChallengeJWT", + }, + ); diff --git a/src/core/service/auth/challenge/store/create-challenge-db.ts b/src/core/service/auth/challenge/store/create-challenge-db.ts index fcf1867..062256d 100644 --- a/src/core/service/auth/challenge/store/create-challenge-db.ts +++ b/src/core/service/auth/challenge/store/create-challenge-db.ts @@ -11,67 +11,78 @@ import { type NewEntity, } from "@/persistence/drizzle/entity/index.ts"; import type { ChallengeData } from "@/core/service/auth/challenge/types.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; import * as E from "@/core/service/auth/challenge/store/error.ts"; import { withSpan } from "@/core/tracing.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const challengeRepository = new ChallengeRepository(drizzleClient); const entityRepository = new EntityRepository(drizzleClient); const accountRepository = new AccountRepository(drizzleClient); -export const P_CreateChallengeDB = ProcessEngine.create( - (input: ChallengeData) => { - return withSpan("P_CreateChallengeDB", async (span) => { - const { challengeData } = input; - try { - span.addEvent("looking_up_account", { - "client.account": challengeData.clientAccount, - }); - let account = await accountRepository.findById( - challengeData.clientAccount, - ); +export const P_CreateChallengeDB = (deps: { log: Logger }) => + ProcessEngine.create( + (input: ChallengeData) => { + return withSpan("P_CreateChallengeDB", async (span) => { + const log = deps.log.scope("P_CreateChallengeDB"); + log.info("P_CreateChallengeDB"); + const { challengeData } = input; + log.debug("clientAccount", challengeData.clientAccount); + log.debug("txHash", challengeData.txHash); - let entity: NewEntity | undefined; - if (!account) { - span.addEvent("creating_new_entity_and_account"); - entity = await entityRepository.create({ - id: crypto.randomUUID(), - status: EntityStatus.UNVERIFIED, - } as NewEntity); + try { + span.addEvent("looking_up_account", { + "client.account": challengeData.clientAccount, + }); + log.event("looking up account"); + let account = await accountRepository.findById( + challengeData.clientAccount, + ); - account = await accountRepository.create({ - id: challengeData.clientAccount, - type: "USER", - entityId: entity.id, - } as NewAccount); - } else { - span.addEvent("account_exists"); - } + let entity: NewEntity | undefined; + if (!account) { + span.addEvent("creating_new_entity_and_account"); + log.event("creating new entity and account"); + entity = await entityRepository.create({ + id: crypto.randomUUID(), + status: EntityStatus.UNVERIFIED, + } as NewEntity); - span.addEvent("persisting_challenge", { - "challenge.txHash": challengeData.txHash, - }); - await challengeRepository.create({ - id: crypto.randomUUID(), - accountId: account.id, - status: ChallengeStatus.UNVERIFIED, - ttl: challengeData.expiresAt, - txHash: challengeData.txHash, - txXDR: challengeData.xdr, - } as NewChallenge); + account = await accountRepository.create({ + id: challengeData.clientAccount, + type: "USER", + entityId: entity.id, + } as NewAccount); + } else { + span.addEvent("account_exists"); + log.event("account exists"); + } - return await input; - } catch (error) { - span.addEvent("db_store_failed", { - "error.message": error instanceof Error - ? error.message - : String(error), - }); - logAndThrow(new E.FAILED_TO_STORE_CHALLENGE_IN_DATABASE(error)); - } - }); - }, - { - name: "CreateChallengeDB", - }, -); + span.addEvent("persisting_challenge", { + "challenge.txHash": challengeData.txHash, + }); + log.event("persisting challenge"); + await challengeRepository.create({ + id: crypto.randomUUID(), + accountId: account.id, + status: ChallengeStatus.UNVERIFIED, + ttl: challengeData.expiresAt, + txHash: challengeData.txHash, + txXDR: challengeData.xdr, + } as NewChallenge); + + return await input; + } catch (error) { + span.addEvent("db_store_failed", { + "error.message": error instanceof Error + ? error.message + : String(error), + }); + log.error(error, "challenge DB store failed"); + throw new E.FAILED_TO_STORE_CHALLENGE_IN_DATABASE(error); + } + }); + }, + { + name: "CreateChallengeDB", + }, + ); diff --git a/src/core/service/auth/challenge/store/create-challenge-memory.ts b/src/core/service/auth/challenge/store/create-challenge-memory.ts index 27d962e..7ba264c 100644 --- a/src/core/service/auth/challenge/store/create-challenge-memory.ts +++ b/src/core/service/auth/challenge/store/create-challenge-memory.ts @@ -1,50 +1,52 @@ import { ProcessEngine } from "@fifo/convee"; -import { sessionManager } from "@/core/service/auth/sessions/in-memory-session-manager.ts"; +import { getSessionManager } from "@/core/service/auth/sessions/in-memory-session-manager.ts"; import type { ChallengeData } from "@/core/service/auth/challenge/types.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import * as E from "@/core/service/auth/challenge/store/error.ts"; import { isDefined } from "@/utils/type-guards/is-defined.ts"; import { assertOrThrow } from "@/utils/error/assert-or-throw.ts"; import { withSpan } from "@/core/tracing.ts"; -export const P_CreateChallengeMemory = ProcessEngine.create( - (input: ChallengeData) => { - return withSpan("P_CreateChallengeMemory", async (span) => { - const { challengeData } = input; - try { - span.addEvent("checking_existing_session", { - "challenge.txHash": challengeData.txHash, - }); - const existingSession = await sessionManager.getSession( - challengeData.txHash, - ); +export const P_CreateChallengeMemory = (deps: { log: Logger }) => + ProcessEngine.create( + (input: ChallengeData) => { + return withSpan("P_CreateChallengeMemory", async (span) => { + const { challengeData } = input; + const sessionManager = getSessionManager(deps); + try { + span.addEvent("checking_existing_session", { + "challenge.txHash": challengeData.txHash, + }); + const existingSession = await sessionManager.getSession( + challengeData.txHash, + ); - assertOrThrow( - !isDefined(existingSession), - new E.SESSION_ALREADY_EXISTS(challengeData.txHash), - ); + assertOrThrow( + !isDefined(existingSession), + new E.SESSION_ALREADY_EXISTS(challengeData.txHash), + ); - span.addEvent("caching_session"); - await sessionManager.addSession( - challengeData.txHash, - challengeData.clientAccount, - challengeData.requestId, - challengeData.expiresAt, - ); + span.addEvent("caching_session"); + await sessionManager.addSession( + challengeData.txHash, + challengeData.clientAccount, + challengeData.requestId, + challengeData.expiresAt, + ); - span.addEvent("session_cached"); - return await input; - } catch (error) { - span.addEvent("memory_cache_failed", { - "error.message": error instanceof Error - ? error.message - : String(error), - }); - logAndThrow(new E.FAILED_TO_CACHE_CHALLENGE_IN_LIVE_SESSIONS(error)); - } - }); - }, - { - name: "CreateChallengeMemory", - }, -); + span.addEvent("session_cached"); + return await input; + } catch (error) { + span.addEvent("memory_cache_failed", { + "error.message": error instanceof Error + ? error.message + : String(error), + }); + throw new E.FAILED_TO_CACHE_CHALLENGE_IN_LIVE_SESSIONS(error); + } + }); + }, + { + name: "CreateChallengeMemory", + }, + ); diff --git a/src/core/service/auth/challenge/store/update-challenge-db.ts b/src/core/service/auth/challenge/store/update-challenge-db.ts index 3f0a4e3..f74bb9d 100644 --- a/src/core/service/auth/challenge/store/update-challenge-db.ts +++ b/src/core/service/auth/challenge/store/update-challenge-db.ts @@ -12,42 +12,50 @@ import * as E from "@/core/service/auth/challenge/store/error.ts"; import { assertOrThrow } from "@/utils/error/assert-or-throw.ts"; import { isDefined } from "@/utils/type-guards/is-defined.ts"; import { withSpan } from "@/core/tracing.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const challengeRepository = new ChallengeRepository(drizzleClient); -export const P_UpdateChallengeDB = ProcessEngine.create( - (input: PostChallengeWithJWT): Promise => { - return withSpan("P_UpdateChallengeDB", async (span) => { - const { signedChallenge } = input.body; - const tx = new Transaction( - signedChallenge, - NETWORK_CONFIG.networkPassphrase, - ); - const hash = tx.hash().toString("hex"); +export const P_UpdateChallengeDB = (deps: { log: Logger }) => + ProcessEngine.create( + (input: PostChallengeWithJWT): Promise => { + return withSpan("P_UpdateChallengeDB", async (span) => { + const log = deps.log.scope("P_UpdateChallengeDB"); + log.info("P_UpdateChallengeDB"); - span.addEvent("finding_challenge", { "challenge.txHash": hash }); - const challenge = await challengeRepository.findOneByTxHash(hash); - assertOrThrow( - isDefined(challenge), - new E.CHALLENGE_NOT_FOUND_IN_DATABASE(hash), - ); + const { signedChallenge } = input.body; + const tx = new Transaction( + signedChallenge, + NETWORK_CONFIG.networkPassphrase, + ); + const hash = tx.hash().toString("hex"); + log.debug("txHash", hash); - challenge.status = ChallengeStatus.VERIFIED; + span.addEvent("finding_challenge", { "challenge.txHash": hash }); + log.event("finding challenge"); + const challenge = await challengeRepository.findOneByTxHash(hash); + assertOrThrow( + isDefined(challenge), + new E.CHALLENGE_NOT_FOUND_IN_DATABASE(hash), + ); - span.addEvent("updating_challenge_status", { - "challenge.status": ChallengeStatus.VERIFIED, - }); - await challengeRepository.update(challenge.id, { - ...challenge, - }); + challenge.status = ChallengeStatus.VERIFIED; - return { - ctx: input.ctx, - jwt: input.jwt!, - }; - }); - }, - { - name: "UpdateChallengeDB", - }, -); + span.addEvent("updating_challenge_status", { + "challenge.status": ChallengeStatus.VERIFIED, + }); + log.event("marking challenge verified"); + await challengeRepository.update(challenge.id, { + ...challenge, + }); + + return { + ctx: input.ctx, + jwt: input.jwt!, + }; + }); + }, + { + name: "UpdateChallengeDB", + }, + ); diff --git a/src/core/service/auth/challenge/store/update-challenge-session.ts b/src/core/service/auth/challenge/store/update-challenge-session.ts index ff15e9a..a7ae9e2 100644 --- a/src/core/service/auth/challenge/store/update-challenge-session.ts +++ b/src/core/service/auth/challenge/store/update-challenge-session.ts @@ -1,15 +1,15 @@ import { Transaction } from "stellar-sdk"; import type { Operation } from "stellar-sdk"; import { type MetadataHelper, ProcessEngine } from "@fifo/convee"; -import { LOG } from "@/config/logger.ts"; import { NETWORK_CONFIG, SESSION_TTL } from "@/config/env.ts"; -import { sessionManager } from "@/core/service/auth/sessions/in-memory-session-manager.ts"; +import { getSessionManager } from "@/core/service/auth/sessions/in-memory-session-manager.ts"; import type { Session } from "@/models/auth/session/session.model.ts"; import { AccountRepository } from "@/persistence/drizzle/repository/account.repository.ts"; import { SessionRepository } from "@/persistence/drizzle/repository/session.repository.ts"; import { SessionStatus } from "@/persistence/drizzle/entity/session.entity.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import type { PostChallengeWithJWT } from "@/core/service/auth/challenge/types.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import * as E from "@/core/service/auth/challenge/store/error.ts"; import { assertOrThrow } from "@/utils/error/assert-or-throw.ts"; import { isDefined } from "@/utils/type-guards/is-defined.ts"; @@ -18,74 +18,81 @@ import { withSpan } from "@/core/tracing.ts"; const accountRepository = new AccountRepository(drizzleClient); const sessionRepository = new SessionRepository(drizzleClient); -export const P_UpdateChallengeSession = ProcessEngine.create( - ( - input: PostChallengeWithJWT, - _metadataHelper?: MetadataHelper, - ): Promise => { - return withSpan("P_UpdateChallengeSession", async (span) => { - const { signedChallenge } = input.body; - const tx = new Transaction( - signedChallenge, - NETWORK_CONFIG.networkPassphrase, - ); +export const P_UpdateChallengeSession = (deps: { log: Logger }) => + ProcessEngine.create( + ( + input: PostChallengeWithJWT, + _metadataHelper?: MetadataHelper, + ): Promise => { + return withSpan("P_UpdateChallengeSession", async (span) => { + const log = deps.log.scope("P_UpdateChallengeSession"); + const sessionManager = getSessionManager(deps); + const { signedChallenge } = input.body; + const tx = new Transaction( + signedChallenge, + NETWORK_CONFIG.networkPassphrase, + ); - const key = tx.hash().toString("hex"); + const key = tx.hash().toString("hex"); - LOG.debug("Updating session with key", key); - span.addEvent("updating_memory_session", { "session.key": key }); + log.debug("key", key); + log.event("updating memory session"); + span.addEvent("updating_memory_session", { "session.key": key }); - const ttl = SESSION_TTL * 1000; + const ttl = SESSION_TTL * 1000; - const memorySession = await sessionManager.getSession(key); + const memorySession = await sessionManager.getSession(key); - if (memorySession) { - span.addEvent("memory_session_found"); - const data = { - txHash: memorySession.txHash, - requestId: memorySession.requestId, - status: "ACTIVE", - expiresAt: new Date(Date.now() + ttl), - } as Session; + if (memorySession) { + span.addEvent("memory_session_found"); + const data = { + txHash: memorySession.txHash, + requestId: memorySession.requestId, + status: "ACTIVE", + expiresAt: new Date(Date.now() + ttl), + } as Session; - sessionManager.updateSession(data); - } else { - span.addEvent("no_memory_session"); - } + sessionManager.updateSession(data); + } else { + span.addEvent("no_memory_session"); + } - assertOrThrow( - isDefined(tx.operations) && tx.operations.length > 0, - new E.CHALLENGE_HAS_NO_OPERATIONS(key), - ); + assertOrThrow( + isDefined(tx.operations) && tx.operations.length > 0, + new E.CHALLENGE_HAS_NO_OPERATIONS(key), + ); - const txOperation = tx.operations[0] as Operation.ManageData; - const txClientAccount = txOperation.source; - assertOrThrow(isDefined(txClientAccount), new E.MISSING_CLIENT_ACCOUNT()); + const txOperation = tx.operations[0] as Operation.ManageData; + const txClientAccount = txOperation.source; + assertOrThrow( + isDefined(txClientAccount), + new E.MISSING_CLIENT_ACCOUNT(), + ); - span.addEvent("looking_up_account", { - "client.account": txClientAccount, - }); - const account = await accountRepository.findById(txClientAccount); - assertOrThrow( - isDefined(account), - new E.USER_NOT_FOUND_IN_DATABASE(txClientAccount), - ); + span.addEvent("looking_up_account", { + "client.account": txClientAccount, + }); + const account = await accountRepository.findById(txClientAccount); + assertOrThrow( + isDefined(account), + new E.USER_NOT_FOUND_IN_DATABASE(txClientAccount), + ); - span.addEvent("persisting_session"); - await sessionRepository.create({ - id: key, - status: SessionStatus.ACTIVE, - accountId: account.id, - jwtToken: input?.jwt, - createdAt: new Date(), - updatedAt: new Date(), - }); + span.addEvent("persisting_session"); + await sessionRepository.create({ + id: key, + status: SessionStatus.ACTIVE, + accountId: account.id, + jwtToken: input?.jwt, + createdAt: new Date(), + updatedAt: new Date(), + }); - span.addEvent("session_persisted"); - return input; - }); - }, - { - name: "UpdateChallengeSession", - }, -); + span.addEvent("session_persisted"); + return input; + }); + }, + { + name: "UpdateChallengeSession", + }, + ); diff --git a/src/core/service/auth/challenge/verify/compare-challenge.ts b/src/core/service/auth/challenge/verify/compare-challenge.ts index f4c8332..a6ea370 100644 --- a/src/core/service/auth/challenge/verify/compare-challenge.ts +++ b/src/core/service/auth/challenge/verify/compare-challenge.ts @@ -10,81 +10,95 @@ import { isDefined } from "@/utils/type-guards/is-defined.ts"; import { extractOperationFromChallengeTx } from "./extract-nonce-from-tx.ts"; import { isTransaction } from "@colibri/core"; import { withSpan } from "@/core/tracing.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const challengeRepository = new ChallengeRepository(drizzleClient); -export const P_CompareChallenge = ProcessEngine.create( - ( - input: PostChallengeInput, - _metadataHelper?: MetadataHelper, - ): Promise => { - return withSpan("P_CompareChallenge", async (span) => { - const { signedChallenge } = input.body; - const tx = new Transaction( - signedChallenge, - NETWORK_CONFIG.networkPassphrase, - ); - assertOrThrow(isTransaction(tx), new E.CHALLENGE_IS_NOT_TRANSACTION(tx)); +export const P_CompareChallenge = (deps: { log: Logger }) => + ProcessEngine.create( + ( + input: PostChallengeInput, + _metadataHelper?: MetadataHelper, + ): Promise => { + return withSpan("P_CompareChallenge", async (span) => { + const log = deps.log.scope("P_CompareChallenge"); + log.info("P_CompareChallenge"); + const { signedChallenge } = input.body; + const tx = new Transaction( + signedChallenge, + NETWORK_CONFIG.networkPassphrase, + ); + assertOrThrow( + isTransaction(tx), + new E.CHALLENGE_IS_NOT_TRANSACTION(tx), + ); - const incomingTtl = extractChallengeTtl(tx); - const txHash = tx.hash().toString("hex"); + const incomingTtl = extractChallengeTtl(tx); + const txHash = tx.hash().toString("hex"); + log.debug("txHash", txHash); - span.addEvent("looking_up_stored_challenge", { - "challenge.txHash": txHash, - }); - const localChallenge = await challengeRepository.findOneByTxHash(txHash); + span.addEvent("looking_up_stored_challenge", { + "challenge.txHash": txHash, + }); + log.event("looking up stored challenge"); + const localChallenge = await challengeRepository.findOneByTxHash( + txHash, + ); - assertOrThrow( - isDefined(localChallenge), - new E.CHALLENGE_NOT_FOUND(txHash), - ); + assertOrThrow( + isDefined(localChallenge), + new E.CHALLENGE_NOT_FOUND(txHash), + ); - const localChallengeTx = TransactionBuilder.fromXDR( - localChallenge.txXDR, - NETWORK_CONFIG.networkPassphrase, - ); + const localChallengeTx = TransactionBuilder.fromXDR( + localChallenge.txXDR, + NETWORK_CONFIG.networkPassphrase, + ); - assertOrThrow( - isTransaction(localChallengeTx), - new E.CHALLENGE_IS_NOT_TRANSACTION(localChallengeTx), - ); + assertOrThrow( + isTransaction(localChallengeTx), + new E.CHALLENGE_IS_NOT_TRANSACTION(localChallengeTx), + ); - span.addEvent("comparing_nonce_and_account"); - const { - clientAccount: localChallengeClientAccount, - nonce: localChallengeNonce, - } = extractOperationFromChallengeTx(localChallengeTx); + span.addEvent("comparing_nonce_and_account"); + log.event("comparing nonce and account"); + const { + clientAccount: localChallengeClientAccount, + nonce: localChallengeNonce, + } = extractOperationFromChallengeTx(localChallengeTx); - const { nonce: incomingNonce, clientAccount: incomingClientAccount } = - extractOperationFromChallengeTx(tx); + const { nonce: incomingNonce, clientAccount: incomingClientAccount } = + extractOperationFromChallengeTx(tx); - assertOrThrow( - localChallengeNonce === incomingNonce, - new E.NONCE_MISMATCH(localChallengeNonce, incomingNonce), - ); + assertOrThrow( + localChallengeNonce === incomingNonce, + new E.NONCE_MISMATCH(localChallengeNonce, incomingNonce), + ); - assertOrThrow( - localChallengeClientAccount === incomingClientAccount, - new E.CLIENT_ACCOUNT_MISMATCH( - localChallengeClientAccount, - incomingClientAccount, - ), - ); + assertOrThrow( + localChallengeClientAccount === incomingClientAccount, + new E.CLIENT_ACCOUNT_MISMATCH( + localChallengeClientAccount, + incomingClientAccount, + ), + ); - span.addEvent("comparing_ttl"); - assertOrThrow( - localChallenge.ttl.toDateString() === incomingTtl.toDateString(), - new E.CHALLENGE_TTL_MISMATCH(localChallenge.ttl, incomingTtl), - ); + span.addEvent("comparing_ttl"); + log.event("comparing TTL"); + assertOrThrow( + localChallenge.ttl.toDateString() === incomingTtl.toDateString(), + new E.CHALLENGE_TTL_MISMATCH(localChallenge.ttl, incomingTtl), + ); - span.addEvent("challenge_comparison_passed"); - return input; - }); - }, - { - name: "CompareChallengeProcessEngine", - }, -); + span.addEvent("challenge_comparison_passed"); + log.event("challenge comparison passed"); + return input; + }); + }, + { + name: "CompareChallengeProcessEngine", + }, + ); const extractChallengeTtl = (tx: Transaction): Date => { const maxTime = tx.timeBounds?.maxTime ? parseInt(tx.timeBounds.maxTime) : 0; diff --git a/src/core/service/auth/challenge/verify/verify-challenge.ts b/src/core/service/auth/challenge/verify/verify-challenge.ts index 4e22355..62b6758 100644 --- a/src/core/service/auth/challenge/verify/verify-challenge.ts +++ b/src/core/service/auth/challenge/verify/verify-challenge.ts @@ -7,116 +7,132 @@ import * as E from "@/core/service/auth/challenge/verify/error.ts"; import { assertOrThrow } from "@/utils/error/assert-or-throw.ts"; import { isDefined } from "@/utils/type-guards/is-defined.ts"; import { StrKey } from "@colibri/core"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; import { withSpan } from "@/core/tracing.ts"; - -export const P_VerifyChallenge = ProcessEngine.create( - (input: PostChallengeInput): Promise => { - return withSpan("P_VerifyChallenge", (span) => { - const { signedChallenge } = input.body; - try { - span.addEvent("deserializing_transaction"); - const tx = new Transaction( - signedChallenge, - NETWORK_CONFIG.networkPassphrase, - ); - - span.addEvent("validating_sequence_number"); - assertOrThrow( - tx.sequence === "0", - new E.INVALID_SEQUENCE_NUMBER(tx.sequence), - ); - - assertOrThrow(isDefined(tx.timeBounds), new E.MISSING_TIME_BOUNDS()); - - assertOrThrow( - isDefined(tx.operations && tx.operations.length > 0), - new E.MISSING_OPERATIONS(tx), - ); - - const firstOp = tx.operations[0]; - - const expectedOperationType = "manageData" as OperationType.ManageData; - assertOrThrow( - firstOp.type === expectedOperationType, - new E.WRONG_OPERATION_TYPE(expectedOperationType, firstOp.type), - ); - - assertOrThrow( - firstOp.name.startsWith(`${SERVICE_DOMAIN} auth`), - new E.OPERATION_KEY_MISMATCH(`${SERVICE_DOMAIN} auth`, firstOp.name), - ); - - span.addEvent("validating_timebounds"); - const currentTime = Math.floor(Date.now() / 1000); - const minTime = tx.timeBounds.minTime - ? parseInt(tx.timeBounds.minTime) - : 0; - const maxTime = tx.timeBounds.maxTime - ? parseInt(tx.timeBounds.maxTime) - : 0; - - assertOrThrow( - currentTime >= minTime, - new E.CHALLENGE_TOO_EARLY(currentTime, minTime), - ); - assertOrThrow( - currentTime <= maxTime, - new E.CHALLENGE_EXPIRED(currentTime, maxTime), - ); - - const clientPublicKey = firstOp.source; - - assertOrThrow( - isDefined(clientPublicKey) && - StrKey.isEd25519PublicKey(clientPublicKey), - new E.MISSING_CLIENT_ACCOUNT(), - ); - - span.addEvent("verifying_signatures", { - "client.publicKey": clientPublicKey, - }); - const clientKeypair = Keypair.fromPublicKey(clientPublicKey); - - let isSignedByServer = false; - let isSignedByClient = false; - - for (const sig of tx.signatures) { - if ( - getProviderAccount().verifySignature( - // deno-lint-ignore no-explicit-any - tx.hash() as any, // Forcing type to Buffer as there seems to be an issue with the lib type inference - // deno-lint-ignore no-explicit-any - sig.signature() as any, // Forcing type to Buffer as there seems to be an issue with the lib type inference - ) - ) { - isSignedByServer = true; - } - if (clientKeypair.verify(tx.hash(), sig.signature())) { - isSignedByClient = true; +import type { Logger } from "@/utils/logger/index.ts"; + +export const P_VerifyChallenge = (deps: { log: Logger }) => + ProcessEngine.create( + (input: PostChallengeInput): Promise => { + return withSpan("P_VerifyChallenge", (span) => { + const log = deps.log.scope("P_VerifyChallenge"); + log.info("P_VerifyChallenge"); + const { signedChallenge } = input.body; + try { + span.addEvent("deserializing_transaction"); + log.event("deserializing transaction"); + const tx = new Transaction( + signedChallenge, + NETWORK_CONFIG.networkPassphrase, + ); + + span.addEvent("validating_sequence_number"); + log.event("validating sequence number"); + assertOrThrow( + tx.sequence === "0", + new E.INVALID_SEQUENCE_NUMBER(tx.sequence), + ); + + assertOrThrow(isDefined(tx.timeBounds), new E.MISSING_TIME_BOUNDS()); + + assertOrThrow( + isDefined(tx.operations && tx.operations.length > 0), + new E.MISSING_OPERATIONS(tx), + ); + + const firstOp = tx.operations[0]; + + const expectedOperationType = + "manageData" as OperationType.ManageData; + assertOrThrow( + firstOp.type === expectedOperationType, + new E.WRONG_OPERATION_TYPE(expectedOperationType, firstOp.type), + ); + + assertOrThrow( + firstOp.name.startsWith(`${SERVICE_DOMAIN} auth`), + new E.OPERATION_KEY_MISMATCH( + `${SERVICE_DOMAIN} auth`, + firstOp.name, + ), + ); + + span.addEvent("validating_timebounds"); + log.event("validating timebounds"); + const currentTime = Math.floor(Date.now() / 1000); + const minTime = tx.timeBounds.minTime + ? parseInt(tx.timeBounds.minTime) + : 0; + const maxTime = tx.timeBounds.maxTime + ? parseInt(tx.timeBounds.maxTime) + : 0; + + assertOrThrow( + currentTime >= minTime, + new E.CHALLENGE_TOO_EARLY(currentTime, minTime), + ); + assertOrThrow( + currentTime <= maxTime, + new E.CHALLENGE_EXPIRED(currentTime, maxTime), + ); + + const clientPublicKey = firstOp.source; + + assertOrThrow( + isDefined(clientPublicKey) && + StrKey.isEd25519PublicKey(clientPublicKey), + new E.MISSING_CLIENT_ACCOUNT(), + ); + log.debug("clientPublicKey", clientPublicKey); + + span.addEvent("verifying_signatures", { + "client.publicKey": clientPublicKey, + }); + log.event("verifying signatures"); + const clientKeypair = Keypair.fromPublicKey(clientPublicKey); + + let isSignedByServer = false; + let isSignedByClient = false; + + for (const sig of tx.signatures) { + if ( + getProviderAccount().verifySignature( + // deno-lint-ignore no-explicit-any + tx.hash() as any, + // deno-lint-ignore no-explicit-any + sig.signature() as any, + ) + ) { + isSignedByServer = true; + } + if (clientKeypair.verify(tx.hash(), sig.signature())) { + isSignedByClient = true; + } } - } - span.addEvent("signature_verification_result", { - "signatures.server": isSignedByServer, - "signatures.client": isSignedByClient, - }); - - assertOrThrow(isSignedByServer, new E.MISSING_SERVER_SIGNATURE()); - assertOrThrow(isSignedByClient, new E.MISSING_CLIENT_SIGNATURE()); - - return input; - } catch (error) { - span.addEvent("verification_failed", { - "error.message": error instanceof Error - ? error.message - : String(error), - }); - logAndThrow(new E.CHALLENGE_VERIFICATION_FAILED(error)); - } - }); - }, - { - name: "VerifyChallengeProcessEngine", - }, -); + span.addEvent("signature_verification_result", { + "signatures.server": isSignedByServer, + "signatures.client": isSignedByClient, + }); + log.debug("signedByServer", isSignedByServer); + log.debug("signedByClient", isSignedByClient); + + assertOrThrow(isSignedByServer, new E.MISSING_SERVER_SIGNATURE()); + assertOrThrow(isSignedByClient, new E.MISSING_CLIENT_SIGNATURE()); + log.event("challenge signatures valid"); + + return input; + } catch (error) { + span.addEvent("verification_failed", { + "error.message": error instanceof Error + ? error.message + : String(error), + }); + log.error(error, "challenge verification failed"); + throw new E.CHALLENGE_VERIFICATION_FAILED(error); + } + }); + }, + { + name: "VerifyChallengeProcessEngine", + }, + ); diff --git a/src/core/service/auth/dashboard-auth.test.ts b/src/core/service/auth/dashboard-auth.test.ts index db71f87..6d96b7c 100644 --- a/src/core/service/auth/dashboard-auth.test.ts +++ b/src/core/service/auth/dashboard-auth.test.ts @@ -1,4 +1,5 @@ import { assertEquals, assertRejects } from "@std/assert"; +import { newNoop } from "@/utils/logger/index.ts"; import { Keypair } from "stellar-sdk"; import { Buffer } from "buffer"; import { @@ -49,14 +50,20 @@ async function signNonceSep53( } Deno.test("createDashboardChallenge - returns a nonce", () => { - const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); assertEquals(typeof nonce, "string"); assertEquals(nonce.length > 0, true); }); Deno.test("createDashboardChallenge - returns unique nonces", () => { - const { nonce: nonce1 } = createDashboardChallenge(TEST_PUBLIC_KEY); - const { nonce: nonce2 } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce: nonce1 } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); + const { nonce: nonce2 } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); assertEquals(nonce1 !== nonce2, true); }); @@ -68,6 +75,7 @@ Deno.test("verifyDashboardChallenge - rejects unknown nonce", async () => { "sig", TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG, + { log: newNoop() }, ), Error, "Challenge not found", @@ -75,18 +83,25 @@ Deno.test("verifyDashboardChallenge - rejects unknown nonce", async () => { }); Deno.test("verifyDashboardChallenge - rejects wrong public key", async () => { - const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); const otherKey = Keypair.random().publicKey(); await assertRejects( - () => verifyDashboardChallenge(nonce, "sig", otherKey, SELF_SIGNER_CONFIG), + () => + verifyDashboardChallenge(nonce, "sig", otherKey, SELF_SIGNER_CONFIG, { + log: newNoop(), + }), Error, "Public key mismatch", ); }); Deno.test("verifyDashboardChallenge - rejects short invalid signature", async () => { - const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); const badSig = btoa("too-short"); await assertRejects( @@ -96,6 +111,7 @@ Deno.test("verifyDashboardChallenge - rejects short invalid signature", async () badSig, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG, + { log: newNoop() }, ), Error, "Invalid signature", @@ -103,7 +119,9 @@ Deno.test("verifyDashboardChallenge - rejects short invalid signature", async () }); Deno.test("verifyDashboardChallenge - rejects valid-length but wrong signature", async () => { - const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); // 64-byte signature that's properly sized but wrong const wrongSig = signNonce(Keypair.random(), nonce); @@ -114,6 +132,7 @@ Deno.test("verifyDashboardChallenge - rejects valid-length but wrong signature", wrongSig, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG, + { log: newNoop() }, ), Error, "Invalid signature", @@ -121,7 +140,9 @@ Deno.test("verifyDashboardChallenge - rejects valid-length but wrong signature", }); Deno.test("verifyDashboardChallenge - valid signature + self signer = success", async () => { - const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); const signature = signNonce(TEST_KEYPAIR, nonce); const { token } = await verifyDashboardChallenge( @@ -129,6 +150,7 @@ Deno.test("verifyDashboardChallenge - valid signature + self signer = success", signature, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG, + { log: newNoop() }, ); assertEquals(typeof token, "string"); @@ -136,7 +158,9 @@ Deno.test("verifyDashboardChallenge - valid signature + self signer = success", }); Deno.test("verifyDashboardChallenge - valid signature + different provider (no Horizon) = rejected", async () => { - const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); const signature = signNonce(TEST_KEYPAIR, nonce); await assertRejects( @@ -146,6 +170,7 @@ Deno.test("verifyDashboardChallenge - valid signature + different provider (no H signature, TEST_PUBLIC_KEY, DIFFERENT_PROVIDER_CONFIG, + { log: newNoop() }, ), Error, "Signer is not authorized", @@ -153,7 +178,9 @@ Deno.test("verifyDashboardChallenge - valid signature + different provider (no H }); Deno.test("verifyDashboardChallenge - SEP-53 hex signature + self signer = success", async () => { - const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); const signature = await signNonceSep53(TEST_KEYPAIR, nonce); const { token } = await verifyDashboardChallenge( @@ -161,6 +188,7 @@ Deno.test("verifyDashboardChallenge - SEP-53 hex signature + self signer = succe signature, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG, + { log: newNoop() }, ); assertEquals(typeof token, "string"); @@ -168,7 +196,9 @@ Deno.test("verifyDashboardChallenge - SEP-53 hex signature + self signer = succe }); Deno.test("verifyDashboardChallenge - SEP-53 wrong key rejected", async () => { - const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); const signature = await signNonceSep53(Keypair.random(), nonce); await assertRejects( @@ -178,6 +208,7 @@ Deno.test("verifyDashboardChallenge - SEP-53 wrong key rejected", async () => { signature, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG, + { log: newNoop() }, ), Error, "Invalid signature", @@ -185,7 +216,9 @@ Deno.test("verifyDashboardChallenge - SEP-53 wrong key rejected", async () => { }); Deno.test("verifyDashboardChallenge - nonce is consumed after use", async () => { - const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY); + const { nonce } = createDashboardChallenge(TEST_PUBLIC_KEY, { + log: newNoop(), + }); const signature = signNonce(TEST_KEYPAIR, nonce); // First use succeeds @@ -194,6 +227,7 @@ Deno.test("verifyDashboardChallenge - nonce is consumed after use", async () => signature, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG, + { log: newNoop() }, ); // Second use fails @@ -204,6 +238,7 @@ Deno.test("verifyDashboardChallenge - nonce is consumed after use", async () => signature, TEST_PUBLIC_KEY, SELF_SIGNER_CONFIG, + { log: newNoop() }, ), Error, "Challenge not found", diff --git a/src/core/service/auth/dashboard-auth.ts b/src/core/service/auth/dashboard-auth.ts index bde33f6..a74f095 100644 --- a/src/core/service/auth/dashboard-auth.ts +++ b/src/core/service/auth/dashboard-auth.ts @@ -1,6 +1,6 @@ import { Keypair } from "stellar-sdk"; import { Buffer } from "buffer"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { withSpan } from "@/core/tracing.ts"; /** @@ -32,8 +32,15 @@ const pendingChallenges = new Map(); * @param publicKey - The Ed25519 public key of the operator requesting auth * @returns The nonce to be signed */ -export function createDashboardChallenge(publicKey: string): { nonce: string } { - cleanupExpiredChallenges(); +export function createDashboardChallenge( + publicKey: string, + deps: { log: Logger }, +): { nonce: string } { + const log = deps.log.scope("createDashboardChallenge"); + log.info("createDashboardChallenge"); + log.debug("publicKey", publicKey); + + cleanupExpiredChallenges(deps); if (pendingChallenges.size >= MAX_PENDING_CHALLENGES) { throw new Error("Too many pending challenges. Try again later."); @@ -48,7 +55,7 @@ export function createDashboardChallenge(publicKey: string): { nonce: string } { createdAt: Date.now(), }); - LOG.debug("Dashboard challenge created", { publicKey }); + log.event("dashboard challenge created"); return { nonce }; } @@ -77,7 +84,12 @@ export function verifyDashboardChallenge( signature: string, publicKey: string, config: DashboardAuthConfig, + deps: { log: Logger }, ): Promise<{ token: string }> { + const log = deps.log.scope("verifyDashboardChallenge"); + log.info("verifyDashboardChallenge"); + log.debug("publicKey", publicKey); + return withSpan("DashboardAuth.verify", async (span) => { span.addEvent("verifying_challenge", { "signer.publicKey": publicKey }); @@ -160,6 +172,7 @@ export function verifyDashboardChallenge( publicKey, config.providerPublicKey, config.horizonUrl, + log, ); if (!isAuth) { throw new Error("Signer is not authorized on the provider account"); @@ -175,7 +188,7 @@ export function verifyDashboardChallenge( .join(""); const token = await config.generateToken(publicKey, hashedSessionId); - LOG.info("Dashboard auth successful", { publicKey }); + log.event("dashboard auth successful"); return { token }; }); } @@ -186,7 +199,8 @@ export function verifyDashboardChallenge( async function isAuthorizedSigner( signerKey: string, accountId: string, - horizonUrl?: string, + horizonUrl: string | undefined, + log: Logger, ): Promise { // Direct match — the signer is the account itself if (signerKey === accountId) { @@ -194,8 +208,8 @@ async function isAuthorizedSigner( } if (!horizonUrl) { - LOG.warn( - "No Horizon URL configured, falling back to direct key match only", + log.event( + "no Horizon URL configured, falling back to direct key match only", ); return false; } @@ -204,10 +218,12 @@ async function isAuthorizedSigner( const baseUrl = horizonUrl.replace(/\/+$/, ""); const response = await fetch(`${baseUrl}/accounts/${accountId}`); if (!response.ok) { - LOG.error("Failed to fetch account from Horizon", { - status: response.status, - accountId, - }); + log.debug("status", response.status); + log.debug("accountId", accountId); + log.error( + new Error(`HTTP ${response.status}`), + "failed to fetch account from Horizon", + ); return false; } @@ -218,18 +234,21 @@ async function isAuthorizedSigner( return signers.some((s) => s.key === signerKey && s.weight > 0); } catch (error) { - LOG.error("Failed to verify signer authorization", { - error: error instanceof Error ? error.message : String(error), - }); + log.error(error, "failed to verify signer authorization"); return false; } } -function cleanupExpiredChallenges(): void { +function cleanupExpiredChallenges(deps: { log: Logger }): void { + const log = deps.log.scope("cleanupExpiredChallenges"); + log.info("cleanupExpiredChallenges"); const now = Date.now(); + let removed = 0; for (const [nonce, challenge] of pendingChallenges) { if (now - challenge.createdAt > challengeTtlMs) { pendingChallenges.delete(nonce); + removed++; } } + log.debug("removed", removed); } diff --git a/src/core/service/auth/service/service-auth-secret.ts b/src/core/service/auth/service/service-auth-secret.ts index f1b5094..a470607 100644 --- a/src/core/service/auth/service/service-auth-secret.ts +++ b/src/core/service/auth/service/service-auth-secret.ts @@ -14,9 +14,7 @@ if (!SERVICE_AUTH_SECRET) { "SERVICE_AUTH_SECRET must be set in production. A random secret would invalidate all JWTs on restart.", ); } - console.warn( - "WARNING: SERVICE_AUTH_SECRET is not set. Generating a random secret. This is NOT recommended for production environments.", - ); + // Dev mode: random secret generated below. main.ts surfaces the notice. } export const authSecret = SERVICE_AUTH_SECRET || generateSecret(); diff --git a/src/core/service/auth/sessions/in-memory-session-manager.ts b/src/core/service/auth/sessions/in-memory-session-manager.ts index edfa733..7539694 100644 --- a/src/core/service/auth/sessions/in-memory-session-manager.ts +++ b/src/core/service/auth/sessions/in-memory-session-manager.ts @@ -1,17 +1,23 @@ import { SESSION_TTL } from "@/config/env.ts"; import { memDb } from "@/persistence/kv/config.ts"; import type { Session } from "@/models/auth/session/session.model.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; + +export class InMemorySessionManager { + private log: Logger; + + constructor(deps: { log: Logger }) { + this.log = deps.log.scope("InMemorySessionManager"); + } -class InMemorySessionManager { public async addSession( txHash: string, clientAccount: string, requestId: string, expiresAt: Date, ): Promise { - LOG.debug(`Adding session to store: ${txHash}`); - LOG.debug(`Entries: ${await memDb.countAll()}`); + this.log.info("addSession"); + this.log.debug("txHash", txHash); const cr = await memDb.sessions.add({ txHash, @@ -22,44 +28,79 @@ class InMemorySessionManager { }); if (cr.ok) { - LOG.info("session", { txHash }, "added to store"); - LOG.debug("Entries:", await memDb.countAll()); + this.log.event("session added to store"); } } public async getSession(txHash: string): Promise { + this.log.info("getSession"); + this.log.debug("txHash", txHash); + + this.log.event("looking up session by txHash"); const session = await memDb.sessions.findByPrimaryIndex("txHash", txHash); if (session && Date.now() < session.value.expiresAt.getTime()) { + this.log.event("session found and valid"); return session.value; } + this.log.event("session missing or expired, deleting"); await memDb.sessions.delete(txHash); return undefined; } public async updateSession(session: Session): Promise { + this.log.info("updateSession"); + this.log.debug("txHash", session.txHash); + if (!(await this.getSession(session.txHash))) { - throw new Error(`Session with id ${session.txHash} not found or expired`); + const err = new Error( + `Session with id ${session.txHash} not found or expired`, + ); + this.log.error(err, "cannot update missing session"); + throw err; } + this.log.event("persisting session update"); await memDb.sessions.update(session.txHash, session); } public async cleanupExpired(): Promise { const now = Date.now(); - LOG.debug(`Cleaning expired sessions`); - LOG.debug("Entries B4:", await memDb.countAll()); + this.log.info("cleanupExpired"); - const cursor = await memDb.sessions.deleteMany({ + await memDb.sessions.deleteMany({ filter: (doc) => doc.value.expiresAt.getTime() < now, }); - LOG.debug("Cursor", cursor); - LOG.debug("Entries AFTER:", await memDb.countAll()); + this.log.event("expired sessions cleaned"); } } -export const sessionManager = new InMemorySessionManager(); +let _sessionManager: InMemorySessionManager | null = null; +let _cleanupInterval: number | null = null; -// Schedule cleanup every session TTL period -setInterval(() => sessionManager.cleanupExpired(), SESSION_TTL * 1000); +/** + * Lazy singleton accessor. The first caller wires up the logger and starts + * the cleanup interval; subsequent callers get the same instance. + */ +export function getSessionManager( + deps: { log: Logger }, +): InMemorySessionManager { + if (!_sessionManager) { + _sessionManager = new InMemorySessionManager(deps); + _cleanupInterval = setInterval( + () => _sessionManager!.cleanupExpired(), + SESSION_TTL * 1000, + ) as unknown as number; + } + return _sessionManager; +} + +/** Test helper / shutdown — clear the singleton and stop the cleanup interval. */ +export function _resetSessionManagerForTests(): void { + if (_cleanupInterval !== null) { + clearInterval(_cleanupInterval); + _cleanupInterval = null; + } + _sessionManager = null; +} diff --git a/src/core/service/bundle/add-bundle.process.ts b/src/core/service/bundle/add-bundle.process.ts index c63f322..a8935c5 100644 --- a/src/core/service/bundle/add-bundle.process.ts +++ b/src/core/service/bundle/add-bundle.process.ts @@ -3,7 +3,6 @@ import { Buffer } from "buffer"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; -import { LOG } from "@/config/logger.ts"; import type { requestSchema } from "@/http/v1/bundle/post.ts"; import type { PostEndpointInput } from "@/http/pipelines/types.ts"; import type { OperationTypes } from "@moonlight/moonlight-sdk"; @@ -31,7 +30,6 @@ import type { import { getMempool } from "@/core/mempool/index.ts"; import * as E from "@/core/service/bundle/bundle.errors.ts"; import type { ClassifiedOperations } from "@/core/service/bundle/bundle.types.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; import type { OperationsBundle } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { AccountRepository, @@ -42,8 +40,8 @@ import { } from "@/persistence/drizzle/repository/index.ts"; import { EntityStatus } from "@/persistence/drizzle/entity/index.ts"; import { withSpan } from "@/core/tracing.ts"; +import type { Logger } from "@/utils/logger/index.ts"; -// Repositories const sessionRepository = new SessionRepository(drizzleClient); const accountRepository = new AccountRepository(drizzleClient); const entityRepository = new EntityRepository(drizzleClient); @@ -52,41 +50,47 @@ const operationsBundleRepository = new OperationsBundleRepository( drizzleClient, ); -// Mempool configuration const MEMPOOL_WEIGHT_CONFIG: WeightConfig = { expensiveOpWeight: MEMPOOL_EXPENSIVE_OP_WEIGHT, cheapOpWeight: MEMPOOL_CHEAP_OP_WEIGHT, } as const; -// ========== HELPER FUNCTIONS ========== - -/** - * Validates the user session - */ -function validateSession(sessionId: string) { +function validateSession(sessionId: string, deps: { log: Logger }) { return withSpan("Bundle.validateSession", async (span) => { + const log = deps.log.scope("validateSession"); + log.info("validateSession"); + log.debug("sessionId", sessionId); + span.addEvent("looking_up_session", { "session.id": sessionId }); + log.event("looking up session"); const userSession = await sessionRepository.findById(sessionId); if (!userSession) { span.addEvent("session_not_found"); - logAndThrow(new E.INVALID_SESSION(sessionId)); + log.event("session not found"); + throw new E.INVALID_SESSION(sessionId); } span.addEvent("session_valid", { "account.id": userSession.accountId }); + log.event("session valid"); return userSession; }); } -/** - * Validates that a bundle with the given ID does not exist, or if it does, ensures it is expired. - * Throws an error if an active bundle exists. - */ -function assertBundleIsExpired(bundleId: string): Promise { +function assertBundleIsExpired( + bundleId: string, + deps: { log: Logger }, +): Promise { return withSpan("Bundle.assertBundleIsExpired", async (span) => { + const log = deps.log.scope("assertBundleIsExpired"); + log.info("assertBundleIsExpired"); + log.debug("bundleId", bundleId); + span.addEvent("checking_existing_bundle", { "bundle.id": bundleId }); + log.event("checking for existing bundle"); const existingBundle = await operationsBundleRepository.findById(bundleId); if (!existingBundle) { span.addEvent("bundle_not_found"); + log.event("no existing bundle"); return false; } @@ -97,21 +101,21 @@ function assertBundleIsExpired(bundleId: string): Promise { span.addEvent("bundle_exists_not_expired", { "bundle.status": existingBundle.status, }); - logAndThrow(new E.BUNDLE_ALREADY_EXISTS(bundleId)); + log.event("bundle exists and is active"); + throw new E.BUNDLE_ALREADY_EXISTS(bundleId); } span.addEvent("bundle_can_be_reused", { "bundle.status": existingBundle.status, }); + log.event("bundle is expired or failed, may be reused"); return true; }); } -/** - * Parses MLXDR operations - */ function parseOperations( operationsMLXDR: string[], + deps: { log: Logger }, ): Promise< Array< | OperationTypes.CreateOperation @@ -121,52 +125,60 @@ function parseOperations( > > { return withSpan("Bundle.parseOperations", async (span) => { + const log = deps.log.scope("parseOperations"); + log.info("parseOperations"); + log.debug("operationCount", operationsMLXDR.length); + span.addEvent("parsing_operations", { "operations.count": operationsMLXDR.length, }); + log.event("parsing MLXDR operations"); const operations = await Promise.all( operationsMLXDR.map((xdr) => MoonlightOperation.fromMLXDR(xdr)), ); if (operations.length === 0) { span.addEvent("no_operations"); - logAndThrow(new E.NO_OPERATIONS_PROVIDED()); + log.event("no operations parsed"); + throw new E.NO_OPERATIONS_PROVIDED(); } span.addEvent("operations_parsed", { "operations.count": operations.length, }); + log.event("operations parsed"); return operations; }); } -/** - * Validates spend operations - */ function validateSpendOperations( operations: OperationTypes.SpendOperation[], ): void { for (let i = 0; i < operations.length; i++) { const operation = operations[i]; if (!operation.isSignedByUTXO()) { - logAndThrow(new E.SPEND_OPERATION_NOT_SIGNED(i)); + throw new E.SPEND_OPERATION_NOT_SIGNED(i); } } } -/** - * Persists UTXOs in the database from create operations - */ function persistCreateOperations( operations: OperationTypes.CreateOperation[], bundleId: string, accountId: string, + deps: { log: Logger }, ): Promise { return withSpan("Bundle.persistCreateOperations", async (span) => { + const log = deps.log.scope("persistCreateOperations"); + log.info("persistCreateOperations"); + log.debug("operationCount", operations.length); + log.debug("bundleId", bundleId); + span.addEvent("persisting_create_utxos", { "operations.count": operations.length, "bundle.id": bundleId, }); + log.event("persisting CREATE UTXOs"); for (const operation of operations) { const utxoId = Buffer.from(operation.getUtxo()).toString("base64"); const utxo = await utxoRepository.findById(utxoId); @@ -185,20 +197,16 @@ function persistCreateOperations( }); } span.addEvent("create_utxos_persisted"); + log.event("CREATE UTXOs persisted"); }); } -/** - * Updates UTXOs in the database from spend operations - * - * Note: The spend amount is fetched directly from the network since - * SpendOperation intentionally does not have an amount attribute. - */ function persistSpendOperations( operations: OperationTypes.SpendOperation[], bundleId: string, accountId: string, channelClient: import("@moonlight/moonlight-sdk").PrivacyChannel, + deps: { log: Logger }, ): Promise { if (operations.length === 0) { return Promise.resolve(); @@ -210,20 +218,22 @@ function persistSpendOperations( "bundle.id": bundleId, }); - // Fetch all UTXO balances from the network in batch for better performance. const utxoPublicKeys = operations.map((op) => op.getUtxo()); - const balances = await fetchUtxoBalances(utxoPublicKeys, channelClient); + const balances = await fetchUtxoBalances( + utxoPublicKeys, + channelClient, + deps, + ); for (let i = 0; i < operations.length; i++) { const operation = operations[i]; const utxoPublicKey = operation.getUtxo(); - // Convert UTXO public key to base64 string to match the format used in persistCreateOperations. const utxoId = Buffer.from(utxoPublicKey).toString("base64"); const utxo = await utxoRepository.findById(utxoId); if (!utxo) { span.addEvent("utxo_not_found", { "utxo.id": utxoId }); - logAndThrow(new E.UTXO_NOT_FOUND(utxoId)); + throw new E.UTXO_NOT_FOUND(utxoId); } const spendAmount = balances[i] || BigInt(0); @@ -241,9 +251,6 @@ function persistSpendOperations( }); } -/** - * Creates a SlotBundle from bundle data - */ function aggregateBundleAmount( classified: ClassifiedOperations, ): string | null { @@ -254,8 +261,6 @@ function aggregateBundleAmount( if (classified.withdraw.length > 0) { return sum(classified.withdraw).toString(); } - // Sends: spend ops don't carry amounts (they reference UTXOs), but the - // create outputs do. Sum of created amounts ≈ amount being moved. if (classified.create.length > 0) return sum(classified.create).toString(); return null; } @@ -294,168 +299,175 @@ function createSlotBundle( // ========== MAIN PROCESS ========== -export const P_AddOperationsBundle = ProcessEngine.create( - (input: PostEndpointInput) => { - return withSpan("P_AddOperationsBundle", async (span) => { - const { operationsMLXDR, channelContractId } = input.body; - if (operationsMLXDR.length > BUNDLE_MAX_OPERATIONS) { - logAndThrow( - new E.TOO_MANY_OPERATIONS( +export const P_AddOperationsBundle = (deps: { log: Logger }) => + ProcessEngine.create( + (input: PostEndpointInput) => { + return withSpan("P_AddOperationsBundle", async (span) => { + const log = deps.log.scope("P_AddOperationsBundle"); + log.info("P_AddOperationsBundle"); + + const { operationsMLXDR, channelContractId } = input.body; + log.debug("operationCount", operationsMLXDR.length); + log.debug("channelContractId", channelContractId); + + if (operationsMLXDR.length > BUNDLE_MAX_OPERATIONS) { + throw new E.TOO_MANY_OPERATIONS( operationsMLXDR.length, BUNDLE_MAX_OPERATIONS, - ), + ); + } + const sessionData = input.ctx.state.session as JwtSessionData; + + const params = (input.ctx as unknown as { + params?: { ppPublicKey?: string }; + }).params; + const ppPublicKey = params?.ppPublicKey; + if (!ppPublicKey) { + throw new E.PP_PUBLIC_KEY_REQUIRED(); + } + span.setAttribute("pp.publicKey", ppPublicKey); + log.debug("ppPublicKey", ppPublicKey); + + log.event("resolving channel context for PP"); + const channelCtx = await resolveChannelContext( + channelContractId, + ppPublicKey, + deps, ); - } - const sessionData = input.ctx.state.session as JwtSessionData; - - // URL-scoped: the route is /providers/:ppPublicKey/bundles. Extract - // the PP identifier here — the executor uses THIS specific PP, not - // a default or first-match across the platform. - const params = (input.ctx as unknown as { - params?: { ppPublicKey?: string }; - }).params; - const ppPublicKey = params?.ppPublicKey; - if (!ppPublicKey) { - logAndThrow(new E.PP_PUBLIC_KEY_REQUIRED()); - } - span.setAttribute("pp.publicKey", ppPublicKey); - - // Resolve channel client for on-chain reads (UTXO balances). The - // resolver returns the PP-specific signer + channel client. - const channelCtx = await resolveChannelContext( - channelContractId, - ppPublicKey, - ); - const channelClient = channelCtx.channelClient; - - // 1. Session validation - span.addEvent("validating_session"); - const userSession = await validateSession(sessionData.sessionId); - - // 1b. Entity (KYC/KYB) gate — submitter must have an APPROVED entity. - span.addEvent("validating_entity_approval", { - "account.id": userSession.accountId, - }); - const submitterAccount = await accountRepository.findById( - userSession.accountId, - ); - const submitterEntity = submitterAccount - ? await entityRepository.findById(submitterAccount.entityId) - : null; - if ( - !submitterEntity || - submitterEntity.status !== EntityStatus.APPROVED - ) { - logAndThrow(new E.SUBMITTER_NOT_APPROVED(userSession.accountId)); - } - - // 2. Bundle ID generation and validation - span.addEvent("generating_bundle_id"); - const bundleId = await generateBundleId(operationsMLXDR); - span.setAttribute("bundle.id", bundleId); - const isBundleExpired = await assertBundleIsExpired(bundleId); - - // 3. Parse and classify operations - span.addEvent("parsing_and_classifying_operations"); - const operations = await parseOperations(operationsMLXDR); - const classified = classifyOperations(operations); - validateSpendOperations(classified.spend); - - span.addEvent("operations_classified", { - "operations.create": classified.create.length, - "operations.spend": classified.spend.length, - "operations.deposit": classified.deposit.length, - "operations.withdraw": classified.withdraw.length, - }); + const channelClient = channelCtx.channelClient; - // 4. Fee calculation - span.addEvent("calculating_fee"); - const amounts = await calculateOperationAmounts( - classified, - channelClient, - ); - LOG.info("amounts: ", amounts); - const feeCalculation = calculateFee(amounts); - - span.addEvent("fee_calculated", { - "fee.amount": feeCalculation.fee.toString(), - "fee.totalInflows": feeCalculation.totalInflows.toString(), - "fee.totalOutflows": feeCalculation.totalOutflows.toString(), - }); + span.addEvent("validating_session"); + log.event("validating session"); + const userSession = await validateSession(sessionData.sessionId, deps); - // 5. Bundle update or creation - let bundleEntity: OperationsBundle; - if (isBundleExpired) { - span.addEvent("updating_expired_bundle"); - bundleEntity = await operationsBundleRepository.update(bundleId, { - status: BundleStatus.PENDING, - channelContractId, - operationsMLXDR: operationsMLXDR, - fee: feeCalculation.fee, - retryCount: 0, - ppPublicKey, - updatedAt: new Date(), - updatedBy: userSession.accountId, + span.addEvent("validating_entity_approval", { + "account.id": userSession.accountId, }); - } else { - span.addEvent("creating_new_bundle"); - bundleEntity = await operationsBundleRepository.create({ - id: bundleId, - status: BundleStatus.PENDING, - channelContractId, - ttl: calculateBundleTtl(), - operationsMLXDR: operationsMLXDR, - fee: feeCalculation.fee, - ppPublicKey, - createdBy: userSession.accountId, - createdAt: new Date(), + log.event("validating entity approval"); + const submitterAccount = await accountRepository.findById( + userSession.accountId, + ); + const submitterEntity = submitterAccount + ? await entityRepository.findById(submitterAccount.entityId) + : null; + if ( + !submitterEntity || + submitterEntity.status !== EntityStatus.APPROVED + ) { + log.event("submitter entity not approved"); + throw new E.SUBMITTER_NOT_APPROVED(userSession.accountId); + } + + span.addEvent("generating_bundle_id"); + log.event("generating bundle ID"); + const bundleId = await generateBundleId(operationsMLXDR); + span.setAttribute("bundle.id", bundleId); + log.debug("bundleId", bundleId); + const isBundleExpired = await assertBundleIsExpired(bundleId, deps); + + span.addEvent("parsing_and_classifying_operations"); + log.event("parsing and classifying operations"); + const operations = await parseOperations(operationsMLXDR, deps); + const classified = classifyOperations(operations); + validateSpendOperations(classified.spend); + + span.addEvent("operations_classified", { + "operations.create": classified.create.length, + "operations.spend": classified.spend.length, + "operations.deposit": classified.deposit.length, + "operations.withdraw": classified.withdraw.length, }); - } - if (feeCalculation.fee < BigInt(1)) { - span.addEvent("zero_fee_warning"); - LOG.warn("This bundle doesn't have any fee"); - } + span.addEvent("calculating_fee"); + log.event("calculating fee"); + const amounts = await calculateOperationAmounts( + classified, + channelClient, + deps, + ); + const feeCalculation = calculateFee(amounts); + + span.addEvent("fee_calculated", { + "fee.amount": feeCalculation.fee.toString(), + "fee.totalInflows": feeCalculation.totalInflows.toString(), + "fee.totalOutflows": feeCalculation.totalOutflows.toString(), + }); + log.debug("fee", feeCalculation.fee.toString()); + + let bundleEntity: OperationsBundle; + if (isBundleExpired) { + span.addEvent("updating_expired_bundle"); + log.event("updating expired bundle"); + bundleEntity = await operationsBundleRepository.update(bundleId, { + status: BundleStatus.PENDING, + channelContractId, + operationsMLXDR: operationsMLXDR, + fee: feeCalculation.fee, + retryCount: 0, + ppPublicKey, + updatedAt: new Date(), + updatedBy: userSession.accountId, + }); + } else { + span.addEvent("creating_new_bundle"); + log.event("creating new bundle"); + bundleEntity = await operationsBundleRepository.create({ + id: bundleId, + status: BundleStatus.PENDING, + channelContractId, + ttl: calculateBundleTtl(), + operationsMLXDR: operationsMLXDR, + fee: feeCalculation.fee, + ppPublicKey, + createdBy: userSession.accountId, + createdAt: new Date(), + }); + } + + if (feeCalculation.fee < BigInt(1)) { + span.addEvent("zero_fee_warning"); + log.event("bundle has no fee"); + } + + span.addEvent("persisting_utxos"); + log.event("persisting UTXOs"); + await persistCreateOperations( + classified.create, + bundleEntity.id, + userSession.accountId, + deps, + ); + await persistSpendOperations( + classified.spend, + bundleEntity.id, + userSession.accountId, + channelClient, + deps, + ); + + span.addEvent("adding_to_mempool"); + log.event("adding to mempool"); + const slotBundle = createSlotBundle( + bundleEntity, + classified, + submitterEntity?.name ?? null, + submitterEntity?.jurisdictions ?? [], + ); + const mempool = getMempool(); + await mempool.addBundle(slotBundle); - // 6. Persist UTXOs - span.addEvent("persisting_utxos"); - await persistCreateOperations( - classified.create, - bundleEntity.id, - userSession.accountId, - ); - await persistSpendOperations( - classified.spend, - bundleEntity.id, - userSession.accountId, - channelClient, - ); - - // 7. Create SlotBundle (with submitter entity info) and add to Mempool - span.addEvent("adding_to_mempool"); - const slotBundle = createSlotBundle( - bundleEntity, - classified, - submitterEntity?.name ?? null, - submitterEntity?.jurisdictions ?? [], - ); - const mempool = getMempool(); - await mempool.addBundle(slotBundle); - - span.addEvent("bundle_added_to_mempool", { - "bundle.id": bundleEntity.id, + span.addEvent("bundle_added_to_mempool", { + "bundle.id": bundleEntity.id, + }); + log.event("bundle added to mempool for asynchronous processing"); + + return { + ctx: input.ctx, + operationsBundleId: bundleEntity.id, + }; }); - LOG.info( - `Bundle ${bundleEntity.id} added to mempool for asynchronous processing`, - ); - - return { - ctx: input.ctx, - operationsBundleId: bundleEntity.id, - }; - }); - }, - { - name: "ProcessNewBundleProcessEngine", - }, -); + }, + { + name: "ProcessNewBundleProcessEngine", + }, + ); diff --git a/src/core/service/bundle/bundle.service.ts b/src/core/service/bundle/bundle.service.ts index afbc088..d8de150 100644 --- a/src/core/service/bundle/bundle.service.ts +++ b/src/core/service/bundle/bundle.service.ts @@ -15,6 +15,7 @@ import type { OperationAmounts, } from "@/core/service/bundle/bundle.types.ts"; import type { OperationsBundle } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; +import type { Logger } from "@/utils/logger/index.ts"; /** * Classifies operations by type @@ -55,11 +56,17 @@ export function classifyOperations( export async function fetchUtxoBalances( utxoPublicKeys: UTXOPublicKey[], channelClient: PrivacyChannel, + deps: { log: Logger }, ): Promise { + const log = deps.log.scope("fetchUtxoBalances"); + log.info("fetchUtxoBalances"); + log.debug("utxoCount", utxoPublicKeys.length); + if (utxoPublicKeys.length === 0) { return []; } + log.event("reading on-chain UTXO balances"); const result = await channelClient.read({ method: ChannelReadMethods.utxo_balances, methodArgs: { @@ -67,7 +74,6 @@ export async function fetchUtxoBalances( }, }); - // The result is an array of balances, convert to bigint return (result as Array).map((balance) => BigInt(balance) ); @@ -82,8 +88,13 @@ export async function fetchUtxoBalances( export async function fetchUtxoBalance( utxoPublicKey: UTXOPublicKey, channelClient: PrivacyChannel, + deps: { log: Logger }, ): Promise { - const balances = await fetchUtxoBalances([utxoPublicKey], channelClient); + const balances = await fetchUtxoBalances( + [utxoPublicKey], + channelClient, + deps, + ); return balances[0] || BigInt(0); } @@ -113,10 +124,19 @@ export function calculateOperationsTotal( export async function calculateOperationAmounts( classified: ClassifiedOperations, channelClient: PrivacyChannel, + deps: { log: Logger }, ): Promise { - // Fetch spend operation amounts from the network + const log = deps.log.scope("calculateOperationAmounts"); + log.info("calculateOperationAmounts"); + log.debug("spendCount", classified.spend.length); + + log.event("fetching spend balances"); const spendUtxos = classified.spend.map((op) => op.getUtxo()); - const spendBalances = await fetchUtxoBalances(spendUtxos, channelClient); + const spendBalances = await fetchUtxoBalances( + spendUtxos, + channelClient, + deps, + ); const totalSpendAmount = spendBalances.reduce( (acc, balance) => acc + balance, diff --git a/src/core/service/bundle/get-bundle.process.ts b/src/core/service/bundle/get-bundle.process.ts index bfd5bfc..0e3b0fc 100644 --- a/src/core/service/bundle/get-bundle.process.ts +++ b/src/core/service/bundle/get-bundle.process.ts @@ -1,6 +1,5 @@ import { ProcessEngine } from "@fifo/convee"; import type { Context } from "@oak/oak"; -import { LOG } from "@/config/logger.ts"; import type { GetEndpointInput } from "@/http/pipelines/types.ts"; import type { requestSchema } from "@/http/v1/bundle/get.ts"; import { responseSchema } from "@/http/v1/bundle/get.ts"; @@ -10,8 +9,8 @@ import { SessionRepository } from "@/persistence/drizzle/repository/session.repo import { drizzleClient } from "@/persistence/drizzle/config.ts"; import type { OperationsBundle } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import * as E from "@/core/service/bundle/bundle.errors.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; import { toBundleDTO } from "@/core/service/bundle/bundle.service.ts"; import { withSpan } from "@/core/tracing.ts"; @@ -22,11 +21,20 @@ const sessionRepository = new SessionRepository(drizzleClient); // ========== HELPER FUNCTIONS ========== -async function findBundleOrThrow(bundleId: string): Promise { +async function findBundleOrThrow( + bundleId: string, + deps: { log: Logger }, +): Promise { + const log = deps.log.scope("findBundleOrThrow"); + log.info("findBundleOrThrow"); + log.debug("bundleId", bundleId); + + log.event("looking up bundle"); const bundle = await operationsBundleRepository.findById(bundleId); if (!bundle) { - logAndThrow(new E.BUNDLE_NOT_FOUND(bundleId)); + log.event("bundle not found"); + throw new E.BUNDLE_NOT_FOUND(bundleId); } return bundle; @@ -35,51 +43,61 @@ async function findBundleOrThrow(bundleId: string): Promise { async function assertBundleOwnership( ctx: Context, bundle: OperationsBundle, + deps: { log: Logger }, ): Promise { + const log = deps.log.scope("assertBundleOwnership"); + log.info("assertBundleOwnership"); + log.debug("bundleId", bundle.id); + const sessionData = ctx.state.session as JwtSessionData; + log.event("loading session"); const userSession = await sessionRepository.findById(sessionData.sessionId); if (!userSession) { - logAndThrow(new E.INVALID_SESSION(sessionData.sessionId)); + log.event("session invalid"); + throw new E.INVALID_SESSION(sessionData.sessionId); } if (bundle.createdBy !== userSession.accountId) { - logAndThrow( - new E.BUNDLE_ACCESS_FORBIDDEN(bundle.id, userSession.accountId), - ); + log.event("bundle ownership mismatch"); + throw new E.BUNDLE_ACCESS_FORBIDDEN(bundle.id, userSession.accountId); } + log.event("ownership verified"); } // ========== MAIN PROCESS ========== -export const P_GetBundleById = ProcessEngine.create( - ( - input: GetEndpointInput, - ): Promise => { - return withSpan("P_GetBundleById", async (span) => { - const { ctx, query } = input; - const { bundleId } = query; - - span.setAttribute("bundle.id", bundleId); - LOG.debug("Fetching bundle by ID", { bundleId }); - - span.addEvent("finding_bundle"); - const bundle = await findBundleOrThrow(bundleId); - - span.addEvent("checking_ownership"); - await assertBundleOwnership(ctx as Context, bundle); - - const dto = toBundleDTO(bundle); - const parsed = responseSchema.parse(dto); - - span.addEvent("bundle_retrieved", { "bundle.status": bundle.status }); - return { - ctx: ctx as Context, - bundle: parsed, - }; - }); - }, - { - name: "GetBundleByIdProcessEngine", - }, -); +export const P_GetBundleById = (deps: { log: Logger }) => + ProcessEngine.create( + ( + input: GetEndpointInput, + ): Promise => { + return withSpan("P_GetBundleById", async (span) => { + const log = deps.log.scope("P_GetBundleById"); + const { ctx, query } = input; + const { bundleId } = query; + + span.setAttribute("bundle.id", bundleId); + log.debug("bundleId", bundleId); + log.event("fetching bundle by ID"); + + span.addEvent("finding_bundle"); + const bundle = await findBundleOrThrow(bundleId, deps); + + span.addEvent("checking_ownership"); + await assertBundleOwnership(ctx as Context, bundle, deps); + + const dto = toBundleDTO(bundle); + const parsed = responseSchema.parse(dto); + + span.addEvent("bundle_retrieved", { "bundle.status": bundle.status }); + return { + ctx: ctx as Context, + bundle: parsed, + }; + }); + }, + { + name: "GetBundleByIdProcessEngine", + }, + ); diff --git a/src/core/service/bundle/list-bundles.process.ts b/src/core/service/bundle/list-bundles.process.ts index 2d54d09..e842968 100644 --- a/src/core/service/bundle/list-bundles.process.ts +++ b/src/core/service/bundle/list-bundles.process.ts @@ -1,6 +1,5 @@ import { ProcessEngine } from "@fifo/convee"; import type { Context } from "@oak/oak"; -import { LOG } from "@/config/logger.ts"; import type { GetEndpointInput } from "@/http/pipelines/types.ts"; import type { requestSchema } from "@/http/v1/bundle/list.ts"; import type { BundleListProcessOutput } from "@/http/v1/bundle/list.ts"; @@ -9,8 +8,8 @@ import { SessionRepository } from "@/persistence/drizzle/repository/session.repo import { drizzleClient } from "@/persistence/drizzle/config.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; import type { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import * as E from "@/core/service/bundle/bundle.errors.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; import { toBundleDTO } from "@/core/service/bundle/bundle.service.ts"; import { withSpan } from "@/core/tracing.ts"; @@ -26,11 +25,18 @@ const sessionRepository = new SessionRepository(drizzleClient); */ async function validateSessionAndGetAccountId( sessionId: string, + deps: { log: Logger }, ): Promise { + const log = deps.log.scope("validateSessionAndGetAccountId"); + log.info("validateSessionAndGetAccountId"); + log.debug("sessionId", sessionId); + + log.event("loading session"); const userSession = await sessionRepository.findById(sessionId); if (!userSession) { - logAndThrow(new E.INVALID_SESSION(sessionId)); + log.event("session not found"); + throw new E.INVALID_SESSION(sessionId); } return userSession.accountId; @@ -41,48 +47,59 @@ async function validateSessionAndGetAccountId( */ async function findBundlesByUser( accountId: string, - status?: BundleStatus, + status: BundleStatus | undefined, + deps: { log: Logger }, ): Promise[]> { + const log = deps.log.scope("findBundlesByUser"); + log.info("findBundlesByUser"); + log.debug("accountId", accountId); + log.debug("status", status ?? "any"); + + log.event("querying bundles by user"); const bundles = await operationsBundleRepository.findByCreatedBy( accountId, status, ); + log.debug("bundleCount", bundles.length); return bundles.map(toBundleDTO); } // ========== MAIN PROCESS ========== -export const P_ListBundlesByUser = ProcessEngine.create( - ( - input: GetEndpointInput, - ): Promise => { - return withSpan("P_ListBundlesByUser", async (span) => { - const { ctx, query } = input; - const sessionData = ctx.state.session as JwtSessionData; - - LOG.debug("Fetching bundles for user", { - sessionId: sessionData.sessionId, - status: query.status, - }); +export const P_ListBundlesByUser = (deps: { log: Logger }) => + ProcessEngine.create( + ( + input: GetEndpointInput, + ): Promise => { + return withSpan("P_ListBundlesByUser", async (span) => { + const log = deps.log.scope("P_ListBundlesByUser"); + const { ctx, query } = input; + const sessionData = ctx.state.session as JwtSessionData; - span.addEvent("validating_session"); - const accountId = await validateSessionAndGetAccountId( - sessionData.sessionId, - ); - - span.addEvent("finding_bundles", { "account.id": accountId }); - const bundles = await findBundlesByUser(accountId, query.status); - - span.addEvent("bundles_found", { "bundles.count": bundles.length }); - LOG.debug("Bundles found", { count: bundles.length, accountId }); - - return { - ctx: ctx as Context, - bundles, - }; - }); - }, - { - name: "ListBundlesByUserProcessEngine", - }, -); + log.debug("sessionId", sessionData.sessionId); + log.event("validating session"); + + span.addEvent("validating_session"); + const accountId = await validateSessionAndGetAccountId( + sessionData.sessionId, + deps, + ); + + log.debug("accountId", accountId); + log.event("listing bundles for account"); + + span.addEvent("finding_bundles", { "account.id": accountId }); + const bundles = await findBundlesByUser(accountId, query.status, deps); + + span.addEvent("bundles_found", { "bundles.count": bundles.length }); + + return { + ctx: ctx as Context, + bundles, + }; + }); + }, + { + name: "ListBundlesByUserProcessEngine", + }, + ); diff --git a/src/core/service/event-watcher/channel-registry.test.ts b/src/core/service/event-watcher/channel-registry.test.ts index 9955452..6dfa3f4 100644 --- a/src/core/service/event-watcher/channel-registry.test.ts +++ b/src/core/service/event-watcher/channel-registry.test.ts @@ -1,4 +1,5 @@ import { assertEquals } from "@std/assert"; +import { newNoop } from "@/utils/logger/index.ts"; import { ChannelRegistry } from "./channel-registry.ts"; import type { ChannelAuthEvent } from "./event-watcher.types.ts"; @@ -15,7 +16,7 @@ function makeEvent( } Deno.test("ChannelRegistry - provider_added for configured channel → active", () => { - const registry = new ChannelRegistry([CONTRACT_A]); + const registry = new ChannelRegistry([CONTRACT_A], { log: newNoop() }); registry.handleEvent(makeEvent("provider_added", CONTRACT_A, 100)); const channel = registry.get(CONTRACT_A); @@ -24,7 +25,7 @@ Deno.test("ChannelRegistry - provider_added for configured channel → active", }); Deno.test("ChannelRegistry - provider_added for unconfigured channel → pending", () => { - const registry = new ChannelRegistry([]); + const registry = new ChannelRegistry([], { log: newNoop() }); registry.handleEvent(makeEvent("provider_added", CONTRACT_A, 100)); const channel = registry.get(CONTRACT_A); @@ -32,7 +33,7 @@ Deno.test("ChannelRegistry - provider_added for unconfigured channel → pending }); Deno.test("ChannelRegistry - provider_removed → inactive", () => { - const registry = new ChannelRegistry([CONTRACT_A]); + const registry = new ChannelRegistry([CONTRACT_A], { log: newNoop() }); registry.handleEvent(makeEvent("provider_added", CONTRACT_A, 100)); registry.handleEvent(makeEvent("provider_removed", CONTRACT_A, 200)); @@ -42,7 +43,7 @@ Deno.test("ChannelRegistry - provider_removed → inactive", () => { }); Deno.test("ChannelRegistry - provider_removed for unknown channel → inactive record", () => { - const registry = new ChannelRegistry([]); + const registry = new ChannelRegistry([], { log: newNoop() }); registry.handleEvent(makeEvent("provider_removed", CONTRACT_A, 200)); const channel = registry.get(CONTRACT_A); @@ -51,7 +52,7 @@ Deno.test("ChannelRegistry - provider_removed for unknown channel → inactive r }); Deno.test("ChannelRegistry - getAll returns all channels", () => { - const registry = new ChannelRegistry([CONTRACT_A]); + const registry = new ChannelRegistry([CONTRACT_A], { log: newNoop() }); registry.handleEvent(makeEvent("provider_added", CONTRACT_A, 100)); registry.handleEvent(makeEvent("provider_added", CONTRACT_B, 101)); @@ -59,7 +60,7 @@ Deno.test("ChannelRegistry - getAll returns all channels", () => { }); Deno.test("ChannelRegistry - getByState filters correctly", () => { - const registry = new ChannelRegistry([CONTRACT_A]); + const registry = new ChannelRegistry([CONTRACT_A], { log: newNoop() }); registry.handleEvent(makeEvent("provider_added", CONTRACT_A, 100)); registry.handleEvent(makeEvent("provider_added", CONTRACT_B, 101)); @@ -69,7 +70,7 @@ Deno.test("ChannelRegistry - getByState filters correctly", () => { }); Deno.test("ChannelRegistry - activateChannel transitions pending → active", () => { - const registry = new ChannelRegistry([]); + const registry = new ChannelRegistry([], { log: newNoop() }); registry.handleEvent(makeEvent("provider_added", CONTRACT_A, 100)); assertEquals(registry.get(CONTRACT_A)?.state, "pending"); @@ -78,7 +79,7 @@ Deno.test("ChannelRegistry - activateChannel transitions pending → active", () }); Deno.test("ChannelRegistry - deactivateChannel transitions active → pending", () => { - const registry = new ChannelRegistry([CONTRACT_A]); + const registry = new ChannelRegistry([CONTRACT_A], { log: newNoop() }); registry.handleEvent(makeEvent("provider_added", CONTRACT_A, 100)); assertEquals(registry.get(CONTRACT_A)?.state, "active"); @@ -87,7 +88,7 @@ Deno.test("ChannelRegistry - deactivateChannel transitions active → pending", }); Deno.test("ChannelRegistry - contract_initialized does not create channel record", () => { - const registry = new ChannelRegistry([]); + const registry = new ChannelRegistry([], { log: newNoop() }); registry.handleEvent(makeEvent("contract_initialized", CONTRACT_A, 50)); assertEquals(registry.getAll().length, 0); diff --git a/src/core/service/event-watcher/channel-registry.ts b/src/core/service/event-watcher/channel-registry.ts index 76a7655..0026f43 100644 --- a/src/core/service/event-watcher/channel-registry.ts +++ b/src/core/service/event-watcher/channel-registry.ts @@ -1,4 +1,4 @@ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import type { ChannelAuthEvent } from "./event-watcher.types.ts"; /** @@ -40,9 +40,11 @@ export class ChannelRegistry { private channels: Map = new Map(); private configuredChannels: Set; private kv: Deno.Kv | null = null; + private log: Logger; - constructor(configuredChannelIds: string[]) { + constructor(configuredChannelIds: string[], deps: { log: Logger }) { this.configuredChannels = new Set(configuredChannelIds); + this.log = deps.log.scope("ChannelRegistry"); } /** @@ -59,13 +61,8 @@ export class ChannelRegistry { for (const record of stored.value) { this.channels.set(record.contractId, record); } - LOG.info("ChannelRegistry restored from KV", { - count: stored.value.length, - channels: stored.value.map((r) => ({ - contractId: r.contractId, - state: r.state, - })), - }); + this.log.debug("count", stored.value.length); + this.log.event("ChannelRegistry restored from KV"); } // Seed configured channels that aren't already tracked @@ -76,7 +73,8 @@ export class ChannelRegistry { state: "active", registeredAtLedger: 0, }); - LOG.info("Seeded configured channel as active", { contractId }); + this.log.debug("contractId", contractId); + this.log.event("seeded configured channel as active"); } } @@ -85,6 +83,8 @@ export class ChannelRegistry { /** Dynamically add a channel to track (e.g., when a PP joins a new council). */ addChannel(contractId: string): void { + this.log.info("addChannel"); + this.log.debug("contractId", contractId); this.configuredChannels.add(contractId); if (!this.channels.has(contractId)) { this.channels.set(contractId, { @@ -92,6 +92,9 @@ export class ChannelRegistry { state: "active", registeredAtLedger: 0, }); + this.log.event("channel registered as active"); + } else { + this.log.event("channel already tracked"); } } @@ -107,10 +110,9 @@ export class ChannelRegistry { await this.onProviderRemoved(event); break; case "contract_initialized": - LOG.debug("Channel Auth contract initialized", { - contractId: event.contractId, - admin: event.address, - }); + this.log.debug("contractId", event.contractId); + this.log.debug("admin", event.address); + this.log.event("Channel Auth contract initialized"); break; } } @@ -125,11 +127,10 @@ export class ChannelRegistry { registeredAtLedger: event.ledger, }); - LOG.info("Provider registered in channel", { - contractId: event.contractId, - state, - ledger: event.ledger, - }); + this.log.debug("contractId", event.contractId); + this.log.debug("state", state); + this.log.debug("ledger", event.ledger); + this.log.event("provider registered in channel"); await this.persist(); } @@ -148,10 +149,9 @@ export class ChannelRegistry { }); } - LOG.info("Provider removed from channel", { - contractId: event.contractId, - ledger: event.ledger, - }); + this.log.debug("contractId", event.contractId); + this.log.debug("ledger", event.ledger); + this.log.event("provider removed from channel"); await this.persist(); } @@ -164,9 +164,7 @@ export class ChannelRegistry { try { await this.kv.set(REGISTRY_KV_KEY, this.getAll()); } catch (error) { - LOG.error("Failed to persist channel registry", { - error: error instanceof Error ? error.message : String(error), - }); + this.log.error(error, "failed to persist channel registry"); } } @@ -195,10 +193,13 @@ export class ChannelRegistry { * Mark a channel as configured (operator activated it). */ async activateChannel(contractId: string): Promise { + this.log.info("activateChannel"); + this.log.debug("contractId", contractId); this.configuredChannels.add(contractId); const channel = this.channels.get(contractId); if (channel && channel.state === "pending") { channel.state = "active"; + this.log.event("channel transitioned to active"); await this.persist(); } } @@ -207,10 +208,13 @@ export class ChannelRegistry { * Mark a channel as no longer configured by the operator. */ async deactivateChannel(contractId: string): Promise { + this.log.info("deactivateChannel"); + this.log.debug("contractId", contractId); this.configuredChannels.delete(contractId); const channel = this.channels.get(contractId); if (channel && channel.state === "active") { channel.state = "pending"; + this.log.event("channel transitioned to pending"); await this.persist(); } } @@ -219,9 +223,11 @@ export class ChannelRegistry { * Close the KV handle. Call on shutdown. */ close(): void { + this.log.info("close"); if (this.kv) { this.kv.close(); this.kv = null; + this.log.event("KV handle closed"); } } } diff --git a/src/core/service/event-watcher/event-watcher.process.ts b/src/core/service/event-watcher/event-watcher.process.ts index 0506fc9..eeb6522 100644 --- a/src/core/service/event-watcher/event-watcher.process.ts +++ b/src/core/service/event-watcher/event-watcher.process.ts @@ -1,4 +1,4 @@ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { NETWORK_RPC_SERVER } from "@/config/env.ts"; import { fetchChannelAuthEvents } from "./event-watcher.service.ts"; import type { @@ -33,12 +33,17 @@ export class EventWatcher { private config: EventWatcherConfig; private handlers: EventHandler[] = []; private kv: Deno.Kv | null = null; + private log: Logger; - constructor(config: { contractId: string; intervalMs?: number }) { + constructor( + config: { contractId: string; intervalMs?: number }, + deps: { log: Logger }, + ) { this.config = { contractId: config.contractId, intervalMs: config.intervalMs ?? 30_000, }; + this.log = deps.log.scope("EventWatcher"); } /** @@ -53,7 +58,7 @@ export class EventWatcher { */ async start(): Promise { if (this.isRunning) { - LOG.warn("EventWatcher is already running"); + this.log.event("EventWatcher is already running"); return; } @@ -69,17 +74,15 @@ export class EventWatcher { ); if (stored.value !== null) { this.lastLedger = stored.value; - LOG.info("EventWatcher restored cursor from KV", { - contractId: this.config.contractId, - startLedger: this.lastLedger, - }); + this.log.debug("contractId", this.config.contractId); + this.log.debug("startLedger", this.lastLedger); + this.log.event("EventWatcher restored cursor from KV"); } else { const latestLedger = await NETWORK_RPC_SERVER.getLatestLedger(); this.lastLedger = latestLedger.sequence; - LOG.info("EventWatcher initialized from network (no saved cursor)", { - contractId: this.config.contractId, - startLedger: this.lastLedger, - }); + this.log.debug("contractId", this.config.contractId); + this.log.debug("startLedger", this.lastLedger); + this.log.event("EventWatcher initialized from network (no saved cursor)"); } // Start the self-scheduling loop @@ -101,7 +104,7 @@ export class EventWatcher { this.kv.close(); this.kv = null; } - LOG.info("EventWatcher stopped"); + this.log.event("EventWatcher stopped"); } /** @@ -116,12 +119,14 @@ export class EventWatcher { * Prevents concurrent polls when RPC is slow. */ private async scheduleNext(): Promise { + this.log.info("scheduleNext"); await this.poll(); if (this.isRunning) { this.timeoutId = setTimeout( () => this.scheduleNext(), this.config.intervalMs, ) as unknown as number; + this.log.event("next poll scheduled"); } } @@ -137,15 +142,16 @@ export class EventWatcher { NETWORK_RPC_SERVER, this.config.contractId, this.lastLedger, + { log: this.log }, ); if (events.length > 0) { span.addEvent("dispatching_events", { "events.count": events.length, }); - LOG.info(`EventWatcher found ${events.length} new event(s)`, { - types: events.map((e) => e.type).join(", "), - }); + this.log.debug("count", events.length); + this.log.debug("types", events.map((e) => e.type).join(", ")); + this.log.event("EventWatcher found new events"); for (const event of events) { await this.dispatch(event); @@ -168,9 +174,7 @@ export class EventWatcher { ? error.message : String(error), }); - LOG.error("EventWatcher poll error", { - error: error instanceof Error ? error.message : String(error), - }); + this.log.error(error, "EventWatcher poll error"); } }); } @@ -183,10 +187,8 @@ export class EventWatcher { try { await handler(event); } catch (error) { - LOG.error("EventWatcher handler error", { - eventType: event.type, - error: error instanceof Error ? error.message : String(error), - }); + this.log.debug("eventType", event.type); + this.log.error(error, "EventWatcher handler error"); } } } diff --git a/src/core/service/event-watcher/event-watcher.service.test.ts b/src/core/service/event-watcher/event-watcher.service.test.ts index f4938db..500c39d 100644 --- a/src/core/service/event-watcher/event-watcher.service.test.ts +++ b/src/core/service/event-watcher/event-watcher.service.test.ts @@ -2,6 +2,7 @@ import { assertEquals } from "@std/assert"; import { Address, Keypair, xdr } from "stellar-sdk"; import { fetchChannelAuthEvents } from "./event-watcher.service.ts"; import type { Server } from "stellar-sdk/rpc"; +import { newNoop } from "@/utils/logger/index.ts"; // Test addresses const TEST_ADDR_1 = Keypair.random().publicKey(); @@ -54,6 +55,7 @@ Deno.test("fetchChannelAuthEvents - parses provider_added event", async () => { mockServer, TEST_CONTRACT, 900, + { log: newNoop() }, ); assertEquals(events.length, 1); @@ -72,6 +74,7 @@ Deno.test("fetchChannelAuthEvents - parses provider_removed event", async () => mockServer, TEST_CONTRACT, 1900, + { log: newNoop() }, ); assertEquals(events.length, 1); @@ -88,6 +91,7 @@ Deno.test("fetchChannelAuthEvents - parses contract_initialized event", async () mockServer, TEST_CONTRACT, 400, + { log: newNoop() }, ); assertEquals(events.length, 1); @@ -104,6 +108,7 @@ Deno.test("fetchChannelAuthEvents - ignores unknown event topics", async () => { mockServer, TEST_CONTRACT, 900, + { log: newNoop() }, ); assertEquals(events.length, 0); @@ -116,6 +121,7 @@ Deno.test("fetchChannelAuthEvents - handles empty response", async () => { mockServer, TEST_CONTRACT, 900, + { log: newNoop() }, ); assertEquals(events.length, 0); @@ -133,6 +139,7 @@ Deno.test("fetchChannelAuthEvents - parses multiple events in order", async () = mockServer, TEST_CONTRACT, 900, + { log: newNoop() }, ); assertEquals(events.length, 3); @@ -168,6 +175,7 @@ Deno.test("fetchChannelAuthEvents - skips events with insufficient topics", asyn mockServer, TEST_CONTRACT, 900, + { log: newNoop() }, ); assertEquals(events.length, 0); diff --git a/src/core/service/event-watcher/event-watcher.service.ts b/src/core/service/event-watcher/event-watcher.service.ts index 555cb35..001bb5f 100644 --- a/src/core/service/event-watcher/event-watcher.service.ts +++ b/src/core/service/event-watcher/event-watcher.service.ts @@ -5,6 +5,7 @@ import type { ChannelAuthEvent, ChannelAuthEventType, } from "./event-watcher.types.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const KNOWN_TOPICS: Record = { contract_initialized: "contract_initialized", @@ -43,14 +44,21 @@ export function fetchChannelAuthEvents( rpcServer: Server, contractId: string, startLedger: number, + deps: { log: Logger }, ): Promise<{ events: ChannelAuthEvent[]; latestLedger: number }> { return withSpan( "EventWatcher.fetchChannelAuthEvents", async (span) => { + const log = deps.log.scope("fetchChannelAuthEvents"); + log.info("fetchChannelAuthEvents"); + log.debug("contractId", contractId); + log.debug("startLedger", startLedger); + span.addEvent("fetching_events", { "contract.id": contractId, "start.ledger": startLedger, }); + log.event("fetching contract events from RPC"); const response = await rpcServer.getEvents({ startLedger, @@ -87,6 +95,9 @@ export function fetchChannelAuthEvents( "events.count": parsed.length, "latest.ledger": latestLedger, }); + log.debug("eventCount", parsed.length); + log.debug("latestLedger", latestLedger); + log.event("events parsed"); return { events: parsed, latestLedger }; }, diff --git a/src/core/service/event-watcher/index.ts b/src/core/service/event-watcher/index.ts index 44f1c28..67e56df 100644 --- a/src/core/service/event-watcher/index.ts +++ b/src/core/service/event-watcher/index.ts @@ -6,40 +6,29 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PpRepository } from "@/persistence/drizzle/repository/pp.repository.ts"; import { CouncilMembershipRepository } from "@/persistence/drizzle/repository/council-membership.repository.ts"; import { CouncilMembershipStatus } from "@/persistence/drizzle/entity/council-membership.entity.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { emitForPp } from "@/core/service/events/emit-helpers.ts"; // Wire env CHALLENGE_TTL (seconds) to dashboard auth (ms) setChallengeTtlMs(CHALLENGE_TTL * 1000); -/** - * Multi-PP event watching. - * - * Each PP watches the Channel Auth contract(s) of the council(s) it has joined. - * Provider addresses and watchers are loaded from the DB on startup - * and updated dynamically via addProviderAddress/removeProviderAddress. - */ - const registeredProviders = new Set(); -const activeWatchers = new Map(); // channelAuthId → watcher +const activeWatchers = new Map(); -export const channelRegistry = new ChannelRegistry([]); +export let channelRegistry: ChannelRegistry; +let watcherLog: Logger | null = null; const ppRepo = new PpRepository(drizzleClient); const membershipRepo = new CouncilMembershipRepository(drizzleClient); -/** - * Initialize event watchers for all active PPs and their council memberships. - * Called once at startup. - */ async function initFromDb(): Promise { + const log = watcherLog!.scope("eventWatcher"); try { const pps = await ppRepo.listAll(); for (const pp of pps) { registeredProviders.add(pp.publicKey); } - // Find all active memberships to determine which councils to watch for (const pp of pps) { const membership = await membershipRepo.getCurrentForPp(pp.publicKey); if ( @@ -50,24 +39,28 @@ async function initFromDb(): Promise { } } - LOG.info("Event watchers initialized from DB", { - providers: registeredProviders.size, - watchers: activeWatchers.size, - }); + log.debug("providers", registeredProviders.size); + log.debug("watchers", activeWatchers.size); + log.event("event watchers initialized from DB"); } catch (err) { - LOG.warn("Failed to initialize event watchers from DB", { - error: err instanceof Error ? err.message : String(err), - }); + log.error(err, "failed to initialize event watchers from DB"); } } async function ensureWatcher(channelAuthId: string): Promise { - if (activeWatchers.has(channelAuthId)) return; + const log = watcherLog!.scope("ensureWatcher"); + log.info("ensureWatcher"); + log.debug("channelAuthId", channelAuthId); + if (activeWatchers.has(channelAuthId)) { + log.event("watcher already active"); + return; + } const watcher = new EventWatcher({ contractId: channelAuthId, intervalMs: EVENT_WATCHER_INTERVAL_MS, - }); + }, { log: watcherLog! }); + watcher.onEvent(async (event) => { if ( registeredProviders.has(event.address) || @@ -75,7 +68,6 @@ async function ensureWatcher(channelAuthId: string): Promise { ) { await channelRegistry.handleEvent(event); - // When a registered PP is added on-chain, activate its membership if ( event.type === "provider_added" && registeredProviders.has(event.address) @@ -86,10 +78,9 @@ async function ensureWatcher(channelAuthId: string): Promise { ts: Date.now(), scope, payload: { channelContractId: channelAuthId }, - })); + }), { log: watcherLog! }); } - // When a registered PP is removed on-chain, update its membership if ( event.type === "provider_removed" && registeredProviders.has(event.address) @@ -100,36 +91,38 @@ async function ensureWatcher(channelAuthId: string): Promise { ts: Date.now(), scope, payload: { channelContractId: channelAuthId }, - })); + }), { log: watcherLog! }); } } else { - LOG.debug("Ignoring event for unregistered provider", { - eventType: event.type, - eventAddress: event.address, - registeredCount: registeredProviders.size, - }); + log.debug("eventType", event.type); + log.debug("eventAddress", event.address); + log.debug("registeredCount", registeredProviders.size); + log.event("ignoring event for unregistered provider"); } }); try { await watcher.start(); } catch (err) { - LOG.error("Failed to start event watcher", { - channelAuthId, - error: err instanceof Error ? err.message : String(err), - }); + log.debug("channelAuthId", channelAuthId); + log.error(err, "failed to start event watcher"); return; } activeWatchers.set(channelAuthId, watcher); channelRegistry.addChannel(channelAuthId); - LOG.info("Started event watcher for council", { channelAuthId }); + log.debug("channelAuthId", channelAuthId); + log.event("started event watcher for council"); } async function activateMembership( ppPublicKey: string, channelAuthId: string, ): Promise { + const log = watcherLog!.scope("activateMembership"); + log.info("activateMembership"); + log.debug("ppPublicKey", ppPublicKey); + log.debug("channelAuthId", channelAuthId); try { const membership = await membershipRepo.getCurrentForPp(ppPublicKey); if (!membership || membership.status === CouncilMembershipStatus.ACTIVE) { @@ -137,7 +130,6 @@ async function activateMembership( } if (membership.channelAuthId !== channelAuthId) return; - // Fetch council config from the council's public API let configJson: string | null = null; let councilName = membership.councilName; try { @@ -158,16 +150,13 @@ async function activateMembership( configJson, councilName, }); - LOG.info("PP membership activated via on-chain event", { - ppPublicKey, - channelAuthId, - }); + log.debug("ppPublicKey", ppPublicKey); + log.debug("channelAuthId", channelAuthId); + log.event("PP membership activated via on-chain event"); } catch (err) { - LOG.error("Failed to activate membership from event", { - ppPublicKey, - channelAuthId, - error: err instanceof Error ? err.message : String(err), - }); + log.debug("ppPublicKey", ppPublicKey); + log.debug("channelAuthId", channelAuthId); + log.error(err, "failed to activate membership from event"); } } @@ -175,6 +164,10 @@ async function deactivateMembership( ppPublicKey: string, channelAuthId: string, ): Promise { + const log = watcherLog!.scope("deactivateMembership"); + log.info("deactivateMembership"); + log.debug("ppPublicKey", ppPublicKey); + log.debug("channelAuthId", channelAuthId); try { const membership = await membershipRepo.getCurrentForPp(ppPublicKey); if (!membership || membership.status !== CouncilMembershipStatus.ACTIVE) { @@ -185,42 +178,60 @@ async function deactivateMembership( await membershipRepo.update(membership.id, { status: CouncilMembershipStatus.REJECTED, }); - LOG.info("PP membership deactivated via on-chain event", { - ppPublicKey, - channelAuthId, - }); + log.debug("ppPublicKey", ppPublicKey); + log.debug("channelAuthId", channelAuthId); + log.event("PP membership deactivated via on-chain event"); } catch (err) { - LOG.error("Failed to deactivate membership from event", { - ppPublicKey, - channelAuthId, - error: err instanceof Error ? err.message : String(err), - }); + log.debug("ppPublicKey", ppPublicKey); + log.debug("channelAuthId", channelAuthId); + log.error(err, "failed to deactivate membership from event"); } } export function addProviderAddress(publicKey: string): void { + if (watcherLog) { + const log = watcherLog.scope("addProviderAddress"); + log.info("addProviderAddress"); + log.debug("publicKey", publicKey); + log.event("registered provider address for event watching"); + } registeredProviders.add(publicKey); - LOG.info("Registered provider address for event watching", { publicKey }); } export function removeProviderAddress(publicKey: string): void { + if (watcherLog) { + const log = watcherLog.scope("removeProviderAddress"); + log.info("removeProviderAddress"); + log.debug("publicKey", publicKey); + } registeredProviders.delete(publicKey); } -/** Add a council to watch (e.g., when a PP's membership becomes active). */ export function addCouncilWatcher(channelAuthId: string): void { + if (watcherLog) { + const log = watcherLog.scope("addCouncilWatcher"); + log.info("addCouncilWatcher"); + log.debug("channelAuthId", channelAuthId); + log.event("scheduling watcher for council"); + } ensureWatcher(channelAuthId).catch((err) => { - LOG.error("Failed to add council watcher", { - channelAuthId, - error: err instanceof Error ? err.message : String(err), - }); + if (watcherLog) { + const log = watcherLog.scope("addCouncilWatcher"); + log.error(err, "failed to add council watcher"); + } }); } -export async function startEventWatcher(): Promise { +export async function startEventWatcher(deps: { log: Logger }): Promise { + watcherLog = deps.log; + if (!channelRegistry) { + channelRegistry = new ChannelRegistry([], { log: watcherLog }); + } + const log = watcherLog.scope("startEventWatcher"); + log.info("startEventWatcher"); await initFromDb(); if (activeWatchers.size === 0) { - LOG.info("No active council memberships — no event watchers started"); + log.event("no active council memberships — no event watchers started"); } } diff --git a/src/core/service/events/emit-helpers.ts b/src/core/service/events/emit-helpers.ts index 19ff33c..0f51042 100644 --- a/src/core/service/events/emit-helpers.ts +++ b/src/core/service/events/emit-helpers.ts @@ -1,7 +1,7 @@ import { inArray } from "drizzle-orm"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; -import { eventBus } from "@/core/service/events/event-bus.ts"; +import { getEventBus } from "@/core/service/events/event-bus.ts"; import { resolveAllPpScopes, resolveScopeForPp, @@ -15,63 +15,67 @@ import type { /** * Resolves the active PP scopes for the given channel and emits one event - * per scope (so single-PP-bound WebSocket subscribers see only their own - * events). The builder is called once per scope and must return a fully - * typed ProviderEvent. Errors during resolution are logged, never thrown, - * so emission paths cannot crash the calling service. + * per scope. Errors during resolution are logged, never thrown, so emission + * paths cannot crash the calling service. */ export async function emitForChannel( channelContractId: string, build: (scope: EventScope) => ProviderEvent, + deps: { log: Logger }, ): Promise { if (!channelContractId) return; + const log = deps.log.scope("emitForChannel"); + log.info("emitForChannel"); + log.debug("channelContractId", channelContractId); try { + log.event("resolving scopes for channel"); const scopes = await resolveScopesForChannel(channelContractId); for (const scope of scopes) { - eventBus.emit(build(scope)); + getEventBus(deps).emit(build(scope)); } } catch (error) { - LOG.error("emitForChannel failed", { - channelContractId, - error: error instanceof Error ? error.message : String(error), - }); + log.error(error, "emitForChannel failed"); } } /** - * Resolves the scope for a known PP and emits a single event. Used for - * channel.provider_* events where the watcher already knows which PP changed. + * Resolves the scope for a known PP and emits a single event. */ export async function emitForPp( ppPublicKey: string, build: (scope: EventScope) => ProviderEvent, + deps: { log: Logger }, ): Promise { if (!ppPublicKey) return; + const log = deps.log.scope("emitForPp"); + log.info("emitForPp"); + log.debug("ppPublicKey", ppPublicKey); try { + log.event("resolving scope for PP"); const scope = await resolveScopeForPp(ppPublicKey); if (!scope) return; - eventBus.emit(build(scope)); + getEventBus(deps).emit(build(scope)); } catch (error) { - LOG.error("emitForPp failed", { - ppPublicKey, - error: error instanceof Error ? error.message : String(error), - }); + log.error(error, "emitForPp failed"); } } /** * Looks up the distinct PPs that own the given bundle IDs and emits one event - * per PP scope. Used by bundle-success paths (mempool.bundle_added, executor. - * transaction_submitted, verifier.bundle_completed) so dashboards only see - * events for bundles that actually belong to them — and not every PP that - * happens to share a channel. + * per PP scope. Used by bundle-success paths so dashboards only see events + * for bundles that actually belong to them. */ export async function emitForBundles( bundleIds: string[], build: (scope: EventScope) => ProviderEvent, + deps: { log: Logger }, ): Promise { if (!bundleIds.length) return; + const log = deps.log.scope("emitForBundles"); + log.info("emitForBundles"); + log.debug("bundleIdCount", bundleIds.length); try { + log.event("loading distinct PPs that own these bundles"); const rows = await drizzleClient .select({ ppPublicKey: operationsBundle.ppPublicKey }) .from(operationsBundle) @@ -80,16 +84,14 @@ export async function emitForBundles( for (const r of rows) { if (r.ppPublicKey) distinct.add(r.ppPublicKey); } + log.debug("distinctPpCount", distinct.size); for (const pk of distinct) { const scope = await resolveScopeForPp(pk); if (!scope) continue; - eventBus.emit(build(scope)); + getEventBus(deps).emit(build(scope)); } } catch (error) { - LOG.error("emitForBundles failed", { - bundleIds, - error: error instanceof Error ? error.message : String(error), - }); + log.error(error, "emitForBundles failed"); } } @@ -100,15 +102,17 @@ export async function emitForBundles( */ export async function emitForAllPps( build: (scope: EventScope) => ProviderEvent, + deps: { log: Logger }, ): Promise { + const log = deps.log.scope("emitForAllPps"); + log.info("emitForAllPps"); try { + log.event("resolving all PP scopes"); const scopes = await resolveAllPpScopes(); for (const scope of scopes) { - eventBus.emit(build(scope)); + getEventBus(deps).emit(build(scope)); } } catch (error) { - LOG.error("emitForAllPps failed", { - error: error instanceof Error ? error.message : String(error), - }); + log.error(error, "emitForAllPps failed"); } } diff --git a/src/core/service/events/event-bus.ts b/src/core/service/events/event-bus.ts index 075b794..36b0df2 100644 --- a/src/core/service/events/event-bus.ts +++ b/src/core/service/events/event-bus.ts @@ -1,4 +1,4 @@ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import type { ProviderEvent } from "@/core/service/events/event.types.ts"; export type EventListener = (event: ProviderEvent) => void; @@ -10,23 +10,31 @@ export type EventListener = (event: ProviderEvent) => void; */ export class EventBus { private listeners = new Set(); + private log: Logger; + + constructor(deps: { log: Logger }) { + this.log = deps.log.scope("EventBus"); + } subscribe(listener: EventListener): () => 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); }; } emit(event: ProviderEvent): void { + this.log.info("emit"); + this.log.debug("kind", event.kind); + this.log.debug("listenerCount", this.listeners.size); for (const listener of this.listeners) { try { listener(event); } catch (error) { - LOG.error("EventBus listener error", { - kind: event.kind, - error: error instanceof Error ? error.message : String(error), - }); + this.log.error(error, "EventBus listener error"); } } } @@ -36,4 +44,16 @@ export class EventBus { } } -export const eventBus = new EventBus(); +let _eventBus: EventBus | null = null; + +/** + * Lazy singleton accessor. The first caller wires up the logger; subsequent + * callers receive the same instance. main.ts must call this once before any + * publisher/subscriber so the bus exists. + */ +export function getEventBus(deps: { log: Logger }): EventBus { + if (!_eventBus) { + _eventBus = new EventBus(deps); + } + return _eventBus; +} diff --git a/src/core/service/events/index.ts b/src/core/service/events/index.ts index fd6d3be..93419b3 100644 --- a/src/core/service/events/index.ts +++ b/src/core/service/events/index.ts @@ -1,4 +1,4 @@ -export { EventBus, eventBus, type EventListener } from "./event-bus.ts"; +export { EventBus, type EventListener, getEventBus } from "./event-bus.ts"; export { emitForAllPps, emitForBundles, diff --git a/src/core/service/executor/channel-resolver.ts b/src/core/service/executor/channel-resolver.ts index c161038..6ee9395 100644 --- a/src/core/service/executor/channel-resolver.ts +++ b/src/core/service/executor/channel-resolver.ts @@ -15,6 +15,7 @@ import { decryptSk } from "@/core/crypto/encrypt-sk.ts"; import { getChannelClient } from "@/core/channel-client/index.ts"; import { NETWORK_FEE, SERVICE_AUTH_SECRET } from "@/config/env.ts"; import type { PrivacyChannel } from "@moonlight/moonlight-sdk"; +import type { Logger } from "@/utils/logger/index.ts"; export interface ChannelContext { signer: LocalSigner; @@ -23,16 +24,22 @@ export interface ChannelContext { txConfig: TransactionConfig; } +const ppRepo = new PpRepository(drizzleClient); +const membershipRepo = new CouncilMembershipRepository(drizzleClient); + /** * Returns a channel client suitable for READS only (no signer, no txConfig). * For writes use resolveChannelContext(channelContractId, ppPublicKey). - * - * Uses any active membership that references the channel to find the - * channelAuthId + assetContractId — these are immutable per-channel. */ export async function resolveChannelClient( channelContractId: string, + deps: { log: Logger }, ): Promise<{ channelClient: PrivacyChannel; channelAuthId: string }> { + const log = deps.log.scope("resolveChannelClient"); + log.info("resolveChannelClient"); + log.debug("channelContractId", channelContractId); + + log.event("listing active PPs to find channel membership"); const pps = await ppRepo.listActive(); for (const pp of pps) { const membership = await membershipRepo.getActiveForPp(pp.publicKey); @@ -72,17 +79,21 @@ export async function resolveChannelClient( ); } -const ppRepo = new PpRepository(drizzleClient); -const membershipRepo = new CouncilMembershipRepository(drizzleClient); - export async function resolveChannelContext( channelContractId: string, ppPublicKey: string, + deps: { log: Logger }, ): Promise { + const log = deps.log.scope("resolveChannelContext"); + log.info("resolveChannelContext"); + log.debug("channelContractId", channelContractId); + log.debug("ppPublicKey", ppPublicKey); + if (!ppPublicKey) { throw new Error("resolveChannelContext: ppPublicKey is required"); } + log.event("loading PP"); const pp = await ppRepo.findByPublicKey(ppPublicKey); if (!pp || !pp.isActive) { throw new Error( @@ -90,6 +101,7 @@ export async function resolveChannelContext( ); } + log.event("loading membership for PP"); const membership = await membershipRepo.getActiveForPp(ppPublicKey); if (!membership?.configJson) { throw new Error( @@ -127,6 +139,7 @@ export async function resolveChannelContext( const channelAuthId = config.council?.channelAuthId ?? membership.channelAuthId; + log.event("decrypting PP secret key"); const sk = await decryptSk(pp.encryptedSk, SERVICE_AUTH_SECRET); if (!sk.startsWith("S")) { throw new Error( diff --git a/src/core/service/executor/executor-failure.helpers.ts b/src/core/service/executor/executor-failure.helpers.ts index 51faf15..ef0cdcf 100644 --- a/src/core/service/executor/executor-failure.helpers.ts +++ b/src/core/service/executor/executor-failure.helpers.ts @@ -1,4 +1,4 @@ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import type { OperationsBundleRepository } from "@/persistence/drizzle/repository/operations-bundle.repository.ts"; import type { SlotBundle } from "@/core/service/bundle/bundle.types.ts"; @@ -22,15 +22,21 @@ export function handleExecutionFailure( deps: { operationsBundleRepository: OperationsBundleRepository; maxRetryAttempts: number; + log: Logger; }, ): Promise { return withSpan("Executor.handleExecutionFailure", async (span) => { + const log = deps.log.scope("handleExecutionFailure"); + log.info("handleExecutionFailure"); + log.debug("bundleIdCount", bundleIds.length); + log.debug("lastFailureReason", lastFailureReason); + const errorMessage = error.message || "Unknown error"; span.addEvent("handling_failure", { "error.message": errorMessage, "bundles.count": bundleIds.length, }); - LOG.error("Execution failed", { error: errorMessage, bundleIds }); + log.error(error, "execution failed"); const bundlesToRetry: ExecutionFailureResult[] = []; @@ -38,9 +44,8 @@ export function handleExecutionFailure( try { const bundle = await deps.operationsBundleRepository.findById(bundleId); if (!bundle) { - LOG.warn( - `Bundle ${bundleId} not found while handling execution failure`, - ); + log.debug("bundleId", bundleId); + log.event("bundle not found while handling execution failure"); continue; } @@ -54,13 +59,9 @@ export function handleExecutionFailure( lastFailureReason, updatedAt: new Date(), }); - LOG.warn( - "Bundle moved to dead-letter after max retry attempts reached", - { - bundleId, - retryCount: nextRetryCount, - }, - ); + log.debug("bundleId", bundleId); + log.debug("retryCount", nextRetryCount); + log.event("bundle moved to dead-letter after max retry attempts"); } else { await deps.operationsBundleRepository.update(bundleId, { status: BundleStatus.PENDING, @@ -74,9 +75,8 @@ export function handleExecutionFailure( } } catch (updateError) { span.addEvent("bundle_reset_failed", { "bundle.id": bundleId }); - LOG.error(`Failed to update bundle ${bundleId} status`, { - error: updateError, - }); + log.debug("bundleId", bundleId); + log.error(updateError, "failed to update bundle status"); } } @@ -91,7 +91,12 @@ export function handleExecutionFailure( export function buildRetryBundles( slot: { getBundles(): SlotBundle[] }, metaList: ExecutionFailureResult[], + deps: { log: Logger }, ): SlotBundle[] { + const log = deps.log.scope("buildRetryBundles"); + log.info("buildRetryBundles"); + log.debug("metaCount", metaList.length); + const metaByBundleId = new Map(metaList.map((m) => [m.bundleId, m] as const)); const eligible = slot.getBundles().filter((b) => @@ -105,5 +110,6 @@ export function buildRetryBundles( bundle.lastFailureReason = meta.lastFailureReason; } + log.debug("eligibleCount", eligible.length); return eligible; } diff --git a/src/core/service/executor/executor.process.ts b/src/core/service/executor/executor.process.ts index 9e4ef25..17c618d 100644 --- a/src/core/service/executor/executor.process.ts +++ b/src/core/service/executor/executor.process.ts @@ -1,4 +1,4 @@ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { TransactionStatus } from "@/persistence/drizzle/entity/transaction.entity.ts"; import { getMempool } from "@/core/mempool/index.ts"; @@ -91,11 +91,13 @@ function submitTransactionToNetwork( expiration: number, channelContractId: string, ppPublicKey: string, + log: Logger, ): Promise { return withSpan("Executor.submitTransactionToNetwork", async (span) => { const { signer, channelClient, txConfig } = await resolveChannelContext( channelContractId, ppPublicKey, + { log }, ); span.setAttribute("pp.publicKey", ppPublicKey); @@ -147,7 +149,7 @@ function submitTransactionToNetwork( const errorMessage = error instanceof Error ? error.message : String(error); - LOG.error("Transaction submission failed", { error: errorMessage }); + log.error(error, "transaction submission failed"); span.addEvent("submission_failed", { "error.message": errorMessage }); const baseError = error instanceof Error ? error @@ -172,28 +174,20 @@ function submitTransactionToNetwork( "error.message": e instanceof Error ? e.message : String(e), }); } - LOG.error("Network error details", { - code: networkCtx.code, - source: networkCtx.source, - txHash: networkCtx.txHash, - txSeqNum: networkCtx.txSeqNum, - errorResult: networkCtx.errorResult, - diagnosticEvents: networkCtx.diagnosticEvents, - }); + log.error(error, "network error details"); + log.debug("code", networkCtx.code); + log.debug("source", networkCtx.source); } const simError = error as SIM_ERRORS.SIMULATION_FAILED; if (simError?.meta?.data) { const simResponse = simError.meta.data.simulationResponse ?? simError.meta.data; - LOG.error("Simulation details", { - simError: JSON.stringify(simResponse, null, 2), - }); + log.error(error, "simulation details"); + log.debug("simError", JSON.stringify(simResponse, null, 2)); if (simError.meta.data.input?.transaction) { - LOG.error("Failed transaction XDR", { - xdr: simError.meta.data.input.transaction.toXDR(), - }); + log.debug("xdr", simError.meta.data.input.transaction.toXDR()); } const failedTxXdr = simError.meta.data.input?.transaction @@ -221,10 +215,18 @@ function submitTransactionToNetwork( async function createTransactionRecord( txHash: string, bundleIds: string[], + deps: { log: Logger }, accountId: string = "system", ): Promise { + const log = deps.log.scope("createTransactionRecord"); + log.info("createTransactionRecord"); + log.debug("txHash", txHash); + log.debug("bundleCount", bundleIds.length); + + log.event("fetching latest ledger"); const latestLedger = await NETWORK_RPC_SERVER.getLatestLedger(); + log.event("inserting transaction row"); await transactionRepository.create({ id: txHash, status: TransactionStatus.UNVERIFIED, @@ -238,7 +240,7 @@ async function createTransactionRecord( createdBy: accountId, }); - // Link bundles to transaction + log.event("linking bundles to transaction"); for (const bundleId of bundleIds) { await bundleTransactionRepository.create({ transactionId: txHash, @@ -258,10 +260,12 @@ function handleExecutionFailure( error: Error, bundleIds: string[], lastFailureReason: string, + log: Logger, ) { return _handleExecutionFailure(error, bundleIds, lastFailureReason, { operationsBundleRepository, maxRetryAttempts: EXECUTOR_CONFIG.MAX_RETRY_ATTEMPTS, + log, }); } @@ -272,18 +276,23 @@ export class Executor { private intervalId: number | null = null; private isRunning: boolean = false; private isProcessing: boolean = false; + private log: Logger; + + constructor(deps: { log: Logger }) { + this.log = deps.log.scope("Executor"); + } /** * Starts the executor loop */ start(): void { if (this.isRunning) { - LOG.warn("Executor is already running"); + this.log.event("Executor is already running"); return; } this.isRunning = true; - LOG.info("Executor started", { intervalMs: EXECUTOR_CONFIG.INTERVAL_MS }); + this.log.event("Executor started"); // Execute immediately, then on interval this.executeNext(); @@ -306,7 +315,7 @@ export class Executor { clearInterval(this.intervalId); this.intervalId = null; } - LOG.info("Executor stopped"); + this.log.event("Executor stopped"); } /** @@ -357,11 +366,12 @@ export class Executor { const channelCtx = await resolveChannelContext( channelContractId, ppPublicKey, + { log: this.log }, ); // Build transaction from slot using the resolved context const { txBuilder, bundleIds: buildBundleIds } = - await buildTransactionFromSlot(slot, channelCtx); + await buildTransactionFromSlot(slot, channelCtx, { log: this.log }); // Use bundleIds from build result to ensure consistency bundleIds = buildBundleIds; @@ -375,22 +385,20 @@ export class Executor { expiration, channelContractId, ppPublicKey, + this.log, ); - LOG.info("Transaction submitted successfully", { - transactionHash, - bundleCount: bundleIds.length, - bundleIds, - }); + this.log.debug("transactionHash", transactionHash); + this.log.debug("bundleCount", bundleIds.length); + this.log.event("transaction submitted successfully"); // Create transaction record and link bundles - await createTransactionRecord(transactionHash, bundleIds); - - LOG.info("Slot executed successfully", { - transactionHash, - bundleCount: bundleIds.length, + await createTransactionRecord(transactionHash, bundleIds, { + log: this.log, }); + this.log.event("Slot executed successfully"); + await emitForBundles(bundleIds, (scope) => ({ kind: "executor.transaction_submitted", ts: Date.now(), @@ -400,7 +408,7 @@ export class Executor { bundleIds, channelContractId, }, - })); + }), { log: this.log }); } catch (error) { const errorMessage = error instanceof Error ? error.message @@ -446,10 +454,10 @@ export class Executor { const lastFailureReason = safeJsonStringify(lastFailureReasonPayload) ?? truncate(errorMessage, 2000); - LOG.error("Slot execution failed", { - error: errorMessage, - bundleIds, - }); + this.log.error( + new Error(String("Slot execution failed")), + "Slot execution failed", + ); const failedChannelContractId = slot?.getBundles()[0] ?.channelContractId ?? null; @@ -463,7 +471,7 @@ export class Executor { channelContractId: failedChannelContractId, reason: errorMessage, }, - })); + }), { log: this.log }); } // Handle failure: re-add bundles to mempool (only those still elegible) and update status @@ -473,24 +481,26 @@ export class Executor { errorInstance, bundleIds, lastFailureReason, + this.log, ); // Reuse the in-memory slot bundle objects, but update retryCount/lastFailureReason // based on the decision computed from the database. - const bundlesToRetry = buildRetryBundles(slot, bundlesToRetryMeta); + const bundlesToRetry = buildRetryBundles(slot, bundlesToRetryMeta, { + log: this.log, + }); if (bundlesToRetry.length > 0) { await mempool.reAddBundles(bundlesToRetry); - LOG.info("Bundles re-added to mempool for retry", { - bundleIds: bundlesToRetry.map((b) => b.bundleId), - }); + this.log.event("Bundles re-added to mempool for retry"); } } else { - LOG.error("Execution error with no slot or bundles to re-add", { - error: errorMessage, - hasSlot: !!slot, - bundleCount: bundleIds.length, - }); + this.log.error( + new Error( + String("Execution error with no slot or bundles to re-add"), + ), + "Execution error with no slot or bundles to re-add", + ); } } finally { this.isProcessing = false; diff --git a/src/core/service/executor/executor.service.ts b/src/core/service/executor/executor.service.ts index cb68de3..ddfd8fc 100644 --- a/src/core/service/executor/executor.service.ts +++ b/src/core/service/executor/executor.service.ts @@ -7,6 +7,7 @@ import type { TransactionBuildResult } from "@/core/service/executor/executor.ty import { UtxoBasedStellarAccount, UTXOStatus } from "@moonlight/moonlight-sdk"; import { withSpan } from "@/core/tracing.ts"; import type { ChannelContext } from "@/core/service/executor/channel-resolver.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const EXECUTOR_CONFIG = { OPEX_UTXO_BATCH_SIZE: 200, @@ -20,15 +21,20 @@ const EXECUTOR_CONFIG = { export function buildTransactionFromSlot( slot: Slot, ctx: ChannelContext, + deps: { log: Logger }, ): Promise { return withSpan("Executor.buildTransactionFromSlot", async (span) => { + const log = deps.log.scope("buildTransactionFromSlot"); + log.info("buildTransactionFromSlot"); const bundles = slot.getBundles(); + log.debug("bundleCount", bundles.length); if (bundles.length === 0) { throw new Error("Cannot build transaction from empty slot"); } span.addEvent("setting_up_tx_builder", { "bundles.count": bundles.length }); + log.event("setting up transaction builder"); const txBuilder = MoonlightTransactionBuilder.fromPrivacyChannel( ctx.channelClient, ); @@ -41,9 +47,11 @@ export function buildTransactionFromSlot( }); span.addEvent("ensuring_opex_utxos"); + log.event("ensuring OPEX UTXOs"); await ensureOpexUtxosAvailable( opexHandler, EXECUTOR_CONFIG.REQUIRED_OPEX_UTXOS, + deps, ); const reservedUtxos = opexHandler.reserveUTXOs( EXECUTOR_CONFIG.REQUIRED_OPEX_UTXOS, @@ -53,6 +61,11 @@ export function buildTransactionFromSlot( const availableCount = opexHandler.getUTXOsByState(UTXOStatus.FREE).length; span.addEvent("insufficient_utxos", { "available": availableCount }); + log.debug("availableUtxos", availableCount); + log.error( + new Error("insufficient OPEX UTXOs"), + "cannot reserve OPEX UTXOs", + ); throw new Error( `Insufficient UTXOs. Required: ${EXECUTOR_CONFIG.REQUIRED_OPEX_UTXOS}, Available: ${availableCount}`, ); @@ -63,6 +76,7 @@ export function buildTransactionFromSlot( BigInt(0), ); span.addEvent("fee_calculated", { "fee.total": totalFee.toString() }); + log.debug("totalFee", totalFee.toString()); const feeOperation = MoonlightOperation.create( reservedUtxos[0].publicKey, @@ -71,6 +85,7 @@ export function buildTransactionFromSlot( txBuilder.addOperation(feeOperation); span.addEvent("adding_bundle_operations"); + log.event("adding bundle operations to transaction"); for (const bundle of bundles) { bundle.operations.deposit.forEach((op) => txBuilder.addOperation(op)); bundle.operations.create.forEach((op) => txBuilder.addOperation(op)); @@ -80,6 +95,7 @@ export function buildTransactionFromSlot( const bundleIds = bundles.map((b) => b.bundleId); span.addEvent("transaction_built", { "bundles.count": bundleIds.length }); + log.event("transaction built"); return { txBuilder, @@ -95,15 +111,22 @@ export function buildTransactionFromSlot( function ensureOpexUtxosAvailable( opexHandler: UtxoBasedStellarAccount, requiredCount: number, + deps: { log: Logger }, ): Promise { return withSpan("Executor.ensureOpexUtxosAvailable", async (span) => { + const log = deps.log.scope("ensureOpexUtxosAvailable"); + log.info("ensureOpexUtxosAvailable"); + log.debug("requiredCount", requiredCount); + span.addEvent("checking_free_utxos", { "required": requiredCount }); + log.event("checking free UTXOs"); let iterations = 0; while ( opexHandler.getUTXOsByState(UTXOStatus.FREE).length < requiredCount + 1 ) { iterations++; span.addEvent("deriving_batch", { "iteration": iterations }); + log.event("deriving UTXO batch"); await opexHandler.deriveBatch({}); await opexHandler.batchLoad(); } @@ -111,5 +134,7 @@ function ensureOpexUtxosAvailable( "free.count": opexHandler.getUTXOsByState(UTXOStatus.FREE).length, "iterations": iterations, }); + log.debug("iterations", iterations); + log.event("OPEX UTXOs ready"); }); } diff --git a/src/core/service/mempool-metrics/metrics-collector.ts b/src/core/service/mempool-metrics/metrics-collector.ts index ed754d2..e28ef0e 100644 --- a/src/core/service/mempool-metrics/metrics-collector.ts +++ b/src/core/service/mempool-metrics/metrics-collector.ts @@ -9,7 +9,7 @@ import { } from "@/persistence/drizzle/entity/council-membership.entity.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { getMempool } from "@/core/mempool/index.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; /** * Pull every active privacy-channel contract id out of a PP's memberships. @@ -55,40 +55,46 @@ export class MetricsCollector { private ppRepo: PpRepository; private membershipRepo: CouncilMembershipRepository; private platformVersion: string; + private log: Logger; - constructor(platformVersion: string) { + constructor(platformVersion: string, deps: { log: Logger }) { this.metricRepo = new MempoolMetricRepository(drizzleClient); this.bundleRepo = new OperationsBundleRepository(drizzleClient); this.ppRepo = new PpRepository(drizzleClient); this.membershipRepo = new CouncilMembershipRepository(drizzleClient); this.platformVersion = platformVersion; + this.log = deps.log.scope("MetricsCollector"); } start(): void { - if (this.intervalId !== null) return; + this.log.info("start"); + if (this.intervalId !== null) { + this.log.event("already started, skipping"); + return; + } - // Collect immediately on start, then every interval this.collect(); this.intervalId = setInterval( () => this.collect(), COLLECTION_INTERVAL_MS, ) as unknown as number; - LOG.info("MetricsCollector started", { - intervalMs: COLLECTION_INTERVAL_MS, - platformVersion: this.platformVersion, - }); + this.log.debug("intervalMs", COLLECTION_INTERVAL_MS); + this.log.debug("platformVersion", this.platformVersion); + this.log.event("MetricsCollector started"); } stop(): void { + this.log.info("stop"); if (this.intervalId !== null) { clearInterval(this.intervalId); this.intervalId = null; - LOG.info("MetricsCollector stopped"); + this.log.event("MetricsCollector stopped"); } } private async collect(): Promise { + this.log.info("collect"); try { const mempool = getMempool(); const windowStart = new Date(Date.now() - COLLECTION_INTERVAL_MS); @@ -157,24 +163,19 @@ export class MetricsCollector { recorded++; } - LOG.debug("Per-PP metrics snapshot recorded", { - ppsRecorded: recorded, - }); + this.log.debug("ppsRecorded", recorded); const retentionCutoff = new Date( Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000, ); const deleted = await this.metricRepo.deleteOlderThan(retentionCutoff); if (deleted > 0) { - LOG.debug("Cleaned up old metrics", { - deleted, - retentionDays: RETENTION_DAYS, - }); + this.log.debug("deleted", deleted); + this.log.debug("retentionDays", RETENTION_DAYS); + this.log.event("cleaned up old metrics"); } } catch (error) { - LOG.error("MetricsCollector failed to collect", { - error: error instanceof Error ? error.message : String(error), - }); + this.log.error(error, "MetricsCollector failed to collect"); } } } diff --git a/src/core/service/mempool/mempool.process.ts b/src/core/service/mempool/mempool.process.ts index 9400c22..c5f3bff 100644 --- a/src/core/service/mempool/mempool.process.ts +++ b/src/core/service/mempool/mempool.process.ts @@ -1,4 +1,4 @@ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import type { OperationsBundle } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; @@ -54,6 +54,7 @@ const entityRepository = new EntityRepository(drizzleClient); */ async function parseOperationsFromBundle( operationsMLXDR: string[], + deps: { log: Logger }, ): Promise< Array< | OperationTypes.CreateOperation @@ -62,6 +63,10 @@ async function parseOperationsFromBundle( | OperationTypes.WithdrawOperation > > { + const log = deps.log.scope("parseOperationsFromBundle"); + log.info("parseOperationsFromBundle"); + log.debug("count", operationsMLXDR.length); + log.event("parsing operations from MLXDR"); const { MoonlightOperation } = await import("@moonlight/moonlight-sdk"); const operations = await Promise.all( operationsMLXDR.map((xdr) => MoonlightOperation.fromMLXDR(xdr)), @@ -104,8 +109,17 @@ async function lookupSubmitter( export async function createSlotBundleFromEntity( bundle: OperationsBundle, + deps: { log: Logger }, ): Promise { - const operations = await parseOperationsFromBundle(bundle.operationsMLXDR); + const log = deps.log.scope("createSlotBundleFromEntity"); + log.info("createSlotBundleFromEntity"); + log.debug("bundleId", bundle.id); + + log.event("parsing operations and classifying"); + const operations = await parseOperationsFromBundle( + bundle.operationsMLXDR, + deps, + ); const classified = classifyOperations(operations); const weight = calculateBundleWeight( classified, @@ -117,8 +131,11 @@ export async function createSlotBundleFromEntity( createdAt: bundle.createdAt, }); + log.event("looking up submitter entity"); const submitter = await lookupSubmitter(bundle.createdBy); + log.debug("weight", weight); + log.debug("priorityScore", priorityScore); return { bundleId: bundle.id, channelContractId: bundle.channelContractId ?? "", @@ -141,11 +158,18 @@ export async function createSlotBundleFromEntity( /** * Loads pending and processing bundles from database */ -async function loadPendingBundlesFromDB(): Promise { +async function loadPendingBundlesFromDB( + deps: { log: Logger }, +): Promise { + const log = deps.log.scope("loadPendingBundlesFromDB"); + log.info("loadPendingBundlesFromDB"); + log.event("querying pending/processing bundles"); const bundles = await operationsBundleRepository.findPendingOrProcessing(); + log.debug("count", bundles.length); + log.event("hydrating SlotBundles"); const slotBundles = await Promise.all( bundles.map((bundle: OperationsBundle) => - createSlotBundleFromEntity(bundle) + createSlotBundleFromEntity(bundle, deps) ), ); return slotBundles; @@ -158,12 +182,18 @@ export class Slot { private bundles: SlotBundle[] = []; private currentWeight: number = 0; private capacity: number; + private log: Logger; /** All bundles in a slot must target the same channel. */ readonly channelContractId: string; - constructor(capacity: number, channelContractId: string) { + constructor( + capacity: number, + channelContractId: string, + deps: { log: Logger }, + ) { this.capacity = capacity; this.channelContractId = channelContractId; + this.log = deps.log.scope("Slot"); } /** @@ -171,15 +201,18 @@ export class Slot { * Returns the bundle that was removed (if any) or null if the new bundle fits */ add(bundle: SlotBundle): SlotBundle | null { - // Check if bundle fits directly + this.log.info("add"); + this.log.debug("bundleId", bundle.bundleId); + this.log.debug("bundleWeight", bundle.weight); + if (this.currentWeight + bundle.weight <= this.capacity) { this.bundles.push(bundle); this.bundles.sort(compareBundlePriority); this.currentWeight += bundle.weight; + this.log.event("bundle added directly (capacity available)"); return null; } - // Check if we can replace a lower priority bundle const lowestPriority = findLowestPriorityBundle({ bundles: this.bundles, currentWeight: this.currentWeight, @@ -187,7 +220,6 @@ export class Slot { }); if (lowestPriority && bundle.priorityScore > lowestPriority.priorityScore) { - // Replace the lowest priority bundle const removedIndex = this.bundles.indexOf(lowestPriority); this.bundles.splice(removedIndex, 1); this.currentWeight -= lowestPriority.weight; @@ -196,10 +228,11 @@ export class Slot { this.bundles.sort(compareBundlePriority); this.currentWeight += bundle.weight; + this.log.event("bundle replaced lower-priority occupant"); return lowestPriority; } - // Bundle doesn't fit and can't replace any existing bundle + this.log.event("bundle rejected (no fit, no replaceable occupant)"); return bundle; } @@ -207,6 +240,8 @@ export class Slot { * Checks if a bundle can fit in this slot */ canFit(bundle: SlotBundle): boolean { + this.log.info("canFit"); + this.log.debug("bundleId", bundle.bundleId); return canBundleFitInSlot(bundle, { bundles: this.bundles, currentWeight: this.currentWeight, @@ -218,12 +253,16 @@ export class Slot { * Removes and returns the first bundle (highest priority) */ removeFirst(): SlotBundle | null { + this.log.info("removeFirst"); if (this.bundles.length === 0) { + this.log.event("slot empty"); return null; } const bundle = this.bundles.shift()!; this.currentWeight -= bundle.weight; + this.log.debug("bundleId", bundle.bundleId); + this.log.event("first bundle removed"); return bundle; } @@ -260,13 +299,17 @@ export class Slot { * Returns true if bundle was found and removed, false otherwise */ removeBundle(bundleId: string): boolean { + this.log.info("removeBundle"); + this.log.debug("bundleId", bundleId); const index = this.bundles.findIndex((b) => b.bundleId === bundleId); if (index !== -1) { const bundle = this.bundles[index]; this.bundles.splice(index, 1); this.currentWeight -= bundle.weight; + this.log.event("bundle removed"); return true; } + this.log.event("bundle not in slot"); return false; } } @@ -277,16 +320,22 @@ export class Slot { export class Mempool { private slots: Slot[] = []; private capacity: number; + private log: Logger; - constructor(capacity: number = MEMPOOL_CONFIG.SLOT_CAPACITY) { + constructor( + capacity: number = MEMPOOL_CONFIG.SLOT_CAPACITY, + deps: { log: Logger }, + ) { this.capacity = capacity; + this.log = deps.log.scope("Mempool"); } /** * Initializes the mempool by loading pending and processing bundles from database */ async initialize(): Promise { - LOG.info("Initializing mempool from database..."); + this.log.info("initialize"); + this.log.event("Initializing mempool from database..."); if (MEMPOOL_STARTUP_MAX_BUNDLE_AGE_MS > 0) { const STARTUP_EXPIRY_BATCH_LIMIT = 10_000; @@ -300,21 +349,23 @@ export class Mempool { ], STARTUP_EXPIRY_BATCH_LIMIT); totalExpired += batch.length; } while (batch.length >= STARTUP_EXPIRY_BATCH_LIMIT); - LOG.info( + this.log.event( `Startup expiry: marked ${totalExpired} stale bundle(s) as EXPIRED (older than ${MEMPOOL_STARTUP_MAX_BUNDLE_AGE_MS}ms)`, ); } else { - LOG.info("Startup expiry disabled (MEMPOOL_STARTUP_MAX_BUNDLE_AGE_MS=0)"); + this.log.event( + "Startup expiry disabled (MEMPOOL_STARTUP_MAX_BUNDLE_AGE_MS=0)", + ); } - const bundles = await loadPendingBundlesFromDB(); + const bundles = await loadPendingBundlesFromDB({ log: this.log }); // Create slots and distribute bundles for (const bundle of bundles) { await this.addBundle(bundle); } - LOG.info( + this.log.event( `Mempool initialized with ${this.slots.length} slots and ${this.getTotalBundles()} bundles`, ); } @@ -325,12 +376,16 @@ export class Mempool { */ addBundle(bundleData: SlotBundle): Promise { return withSpan("Mempool.addBundle", async (span) => { + this.log.info("addBundle"); + this.log.debug("bundleId", bundleData.bundleId); + this.log.debug("bundleWeight", bundleData.weight); + span.setAttribute("bundle.id", bundleData.bundleId); span.setAttribute("bundle.weight", bundleData.weight); if (isBundleExpired(bundleData)) { span.addEvent("bundle_expired"); - LOG.warn( + this.log.event( `Bundle ${bundleData.bundleId} is expired, marking as EXPIRED`, ); await operationsBundleRepository.update(bundleData.bundleId, { @@ -345,7 +400,7 @@ export class Mempool { bundleId: bundleData.bundleId, channelContractId: bundleData.channelContractId, }, - })); + }), { log: this.log }); return; } @@ -361,7 +416,9 @@ export class Mempool { if (removed === null) { bundleToAdd = null; span.addEvent("added_to_existing_slot"); - LOG.debug(`Bundle ${bundleData.bundleId} added to existing slot`); + this.log.event( + `Bundle ${bundleData.bundleId} added to existing slot`, + ); await emitForPp(bundleData.ppPublicKey, (scope) => ({ kind: "mempool.bundle_added", ts: Date.now(), @@ -375,19 +432,19 @@ export class Mempool { jurisdictions: bundleData.jurisdictions, amount: bundleData.amount, }, - })); + }), { log: this.log }); } else if (removed !== bundleToAdd) { bundleToAdd = removed; } } if (bundleToAdd) { - const newSlot = new Slot(this.capacity, channel); + const newSlot = new Slot(this.capacity, channel, { log: this.log }); const result = newSlot.add(bundleToAdd); if (result === null) { this.slots.push(newSlot); span.addEvent("added_to_new_slot"); - LOG.debug(`Bundle ${bundleData.bundleId} added to new slot`); + this.log.event(`Bundle ${bundleData.bundleId} added to new slot`); await emitForPp(bundleData.ppPublicKey, (scope) => ({ kind: "mempool.bundle_added", ts: Date.now(), @@ -401,14 +458,15 @@ export class Mempool { jurisdictions: bundleData.jurisdictions, amount: bundleData.amount, }, - })); + }), { log: this.log }); } else { span.addEvent("slot_full", { "bundle.weight": bundleData.weight, "slot.capacity": this.capacity, }); - LOG.error( - `Bundle ${bundleData.bundleId} cannot fit in any slot, weight: ${bundleData.weight}, capacity: ${this.capacity}`, + this.log.error( + new E.SLOT_FULL(bundleData.weight, this.capacity), + `Bundle ${bundleData.bundleId} cannot fit in any slot`, ); throw new E.SLOT_FULL(bundleData.weight, this.capacity); } @@ -424,17 +482,15 @@ export class Mempool { if (updated) return; span.addEvent("bundle_status_not_active"); - LOG.warn( + this.log.event( `Bundle ${bundleData.bundleId} was concurrently moved to a terminal status, removing from mempool`, ); this.purgeBundles([bundleData.bundleId]); } catch (error) { span.addEvent("bundle_status_update_failed"); - LOG.error( - `Failed to mark bundle ${bundleData.bundleId} as PROCESSING; removing from mempool`, - { - error: error instanceof Error ? error.message : String(error), - }, + this.log.error( + error, + `Failed to mark bundle ${bundleData.bundleId} as PROCESSING`, ); this.purgeBundles([bundleData.bundleId]); throw error; @@ -446,6 +502,7 @@ export class Mempool { * Gets the next slot (first in queue) */ getNextSlot(): Slot | null { + this.log.info("getNextSlot"); return this.slots.length > 0 ? this.slots[0] : null; } @@ -453,9 +510,12 @@ export class Mempool { * Removes and returns the first slot */ removeFirstSlot(): Slot | null { + this.log.info("removeFirstSlot"); if (this.slots.length === 0) { + this.log.event("no slots to remove"); return null; } + this.log.event("removing first slot"); return this.slots.shift() || null; } @@ -467,10 +527,10 @@ export class Mempool { */ reAddBundles(bundles: SlotBundle[]): Promise { return withSpan("Mempool.reAddBundles", async (span) => { + this.log.info("reAddBundles"); span.addEvent("re_adding_bundles", { "bundles.count": bundles.length }); - LOG.debug( - `Re-adding ${bundles.length} bundles to mempool after execution failure`, - ); + this.log.debug("count", bundles.length); + this.log.event("re-adding bundles to mempool after execution failure"); let succeeded = 0; let failed = 0; @@ -478,13 +538,14 @@ export class Mempool { try { await this.addBundle(bundle); succeeded++; - LOG.debug(`Bundle ${bundle.bundleId} re-added to mempool`); - } catch (error) { + this.log.event(`Bundle ${bundle.bundleId} re-added to mempool`); + } catch (_error) { failed++; span.addEvent("re_add_failed", { "bundle.id": bundle.bundleId }); - LOG.error(`Failed to re-add bundle ${bundle.bundleId}`, { - error: error instanceof Error ? error.message : String(error), - }); + this.log.error( + new Error(String(`Failed to re-add bundle ${bundle.bundleId}`)), + `Failed to re-add bundle ${bundle.bundleId}`, + ); } } span.addEvent("re_add_complete", { @@ -499,12 +560,14 @@ export class Mempool { */ expireBundles(): Promise { return withSpan("Mempool.expireBundles", async (span) => { + this.log.info("expireBundles"); const expired: Array<{ bundleId: string; channelContractId: string; ppPublicKey: string; }> = []; + this.log.event("scanning for expired bundles"); for (let i = this.slots.length - 1; i >= 0; i--) { const slot = this.slots[i]; const bundles = slot.getBundles(); @@ -536,13 +599,13 @@ export class Mempool { status: BundleStatus.EXPIRED, updatedAt: new Date(), }); - LOG.info(`Bundle ${bundleId} expired and marked as EXPIRED`); + this.log.event(`Bundle ${bundleId} expired and marked as EXPIRED`); await emitForPp(ppPublicKey, (scope) => ({ kind: "mempool.bundle_expired", ts: Date.now(), scope, payload: { bundleId, channelContractId }, - })); + }), { log: this.log }); } }); } @@ -553,14 +616,16 @@ export class Mempool { * Returns the number of bundles that were actually found and removed. */ purgeBundles(bundleIds: string[]): number { + this.log.info("purgeBundles"); + this.log.debug("requestedCount", bundleIds.length); if (bundleIds.length === 0) return 0; const idSet = new Set(bundleIds); let removed = 0; + this.log.event("scanning slots for bundles to purge"); for (let i = this.slots.length - 1; i >= 0; i--) { const slot = this.slots[i]; - // Slot#getBundles returns a defensive copy; iterating over it is safe while mutating the slot. for (const bundle of slot.getBundles()) { if (idSet.has(bundle.bundleId)) { this.removeBundleFromSlot(slot, bundle.bundleId); @@ -572,6 +637,8 @@ export class Mempool { } } + this.log.debug("removed", removed); + this.log.event("purge complete"); return removed; } @@ -579,9 +646,11 @@ export class Mempool { * Removes a specific bundle from a slot by bundleId */ private removeBundleFromSlot(slot: Slot, bundleId: string): void { + this.log.info("removeBundleFromSlot"); + this.log.debug("bundleId", bundleId); const removed = slot.removeBundle(bundleId); if (!removed) { - LOG.warn(`Bundle ${bundleId} not found in slot for removal`); + this.log.event(`Bundle ${bundleId} not found in slot for removal`); } } @@ -589,6 +658,7 @@ export class Mempool { * Gets statistics about the mempool */ getStats(): MempoolStats { + this.log.info("getStats"); const totalBundles = this.getTotalBundles(); const totalWeight = this.slots.reduce( (sum, slot) => sum + slot.getTotalWeight(), @@ -596,6 +666,8 @@ export class Mempool { ); const totalSlots = this.slots.length; + this.log.debug("totalSlots", totalSlots); + this.log.debug("totalBundles", totalBundles); return { totalSlots, totalBundles, @@ -612,6 +684,8 @@ export class Mempool { getStatsForChannels( channelContractIds: string[], ): { queueDepth: number; slotCount: number } { + this.log.info("getStatsForChannels"); + this.log.debug("channelCount", channelContractIds.length); if (channelContractIds.length === 0) { return { queueDepth: 0, slotCount: 0 }; } diff --git a/src/core/service/pay/channel.service.ts b/src/core/service/pay/channel.service.ts index cb15423..26d4fad 100644 --- a/src/core/service/pay/channel.service.ts +++ b/src/core/service/pay/channel.service.ts @@ -5,11 +5,11 @@ * Reads contract IDs and network config from the existing env/config modules. */ import { Buffer } from "buffer"; -import { LOG } from "@/config/logger.ts"; import { ChannelReadMethods, type PrivacyChannel, } from "@moonlight/moonlight-sdk"; +import type { Logger } from "@/utils/logger/index.ts"; /** * Queries on-chain UTXO balances for the given public keys. @@ -20,11 +20,17 @@ import { export async function queryBalances( publicKeys: Uint8Array[], channelClient: PrivacyChannel, + deps: { log: Logger }, ): Promise { + const log = deps.log.scope("queryBalances"); + if (publicKeys.length === 0) { return []; } + log.debug("utxoCount", publicKeys.length); + log.event("querying on-chain UTXO balances"); + try { const result = await channelClient.read({ method: ChannelReadMethods.utxo_balances, @@ -37,10 +43,7 @@ export async function queryBalances( BigInt(balance) ); } catch (error) { - LOG.error("Failed to query UTXO balances", { - error: error instanceof Error ? error.message : String(error), - utxoCount: publicKeys.length, - }); + log.error(error, "failed to query UTXO balances"); throw error; } } diff --git a/src/core/service/pay/escrow.service.ts b/src/core/service/pay/escrow.service.ts index 42c81bf..7286bd0 100644 --- a/src/core/service/pay/escrow.service.ts +++ b/src/core/service/pay/escrow.service.ts @@ -26,7 +26,7 @@ import { } from "@/persistence/drizzle/entity/pay-transaction.entity.ts"; import { payCustodialAccount } from "@/persistence/drizzle/entity/pay-custodial-account.entity.ts"; import { PayKycStatus } from "@/persistence/drizzle/entity/pay-kyc.entity.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const escrowRepo = new PayEscrowRepository(drizzleClient); const kycRepo = new PayKycRepository(drizzleClient); @@ -41,7 +41,9 @@ export async function createEscrow(opts: { mode: "self" | "custodial"; bundleId?: string; utxoPublicKeys?: string[]; -}): Promise { +}, deps: { log: Logger }): Promise { + const log = deps.log.scope("createEscrow"); + log.info("createEscrow"); const id = crypto.randomUUID(); await escrowRepo.create({ @@ -59,12 +61,11 @@ export async function createEscrow(opts: { updatedAt: new Date(), }); - LOG.info("Escrow created", { - id, - heldFor: opts.receiverAddress, - amount: opts.amount.toString(), - mode: opts.mode, - }); + log.debug("id", id); + log.debug("heldFor", opts.receiverAddress); + log.debug("amount", opts.amount.toString()); + log.debug("mode", opts.mode); + log.event("escrow created"); return id; } @@ -79,10 +80,17 @@ export async function createEscrow(opts: { * * Uses a DB transaction with row-level locking to prevent double-claims. */ -export async function claimEscrowForAddress(address: string): Promise<{ +export async function claimEscrowForAddress( + address: string, + deps: { log: Logger }, +): Promise<{ claimed: number; totalAmount: bigint; }> { + const log = deps.log.scope("claimEscrowForAddress"); + log.info("claimEscrowForAddress"); + log?.debug("address", address); + const kyc = await kycRepo.findByAddress(address); if (!kyc || kyc.status !== PayKycStatus.VERIFIED) { throw new Error("KYC not verified for this address"); @@ -158,11 +166,9 @@ export async function claimEscrowForAddress(address: string): Promise<{ totalAmount += escrow.amount; } - LOG.info("Escrow claimed", { - address, - claimed: held.length, - totalAmount: totalAmount.toString(), - }); + log?.debug("claimed", held.length); + log?.debug("totalAmount", totalAmount.toString()); + log?.event("escrow claimed"); return { claimed: held.length, totalAmount }; }); @@ -171,7 +177,10 @@ export async function claimEscrowForAddress(address: string): Promise<{ /** * Get pending escrow summary for an address. */ -export async function getEscrowSummary(address: string): Promise<{ +export async function getEscrowSummary( + address: string, + _deps: { log: Logger }, +): Promise<{ count: number; totalAmount: bigint; }> { diff --git a/src/core/service/verifier/verifier-failure.helpers.ts b/src/core/service/verifier/verifier-failure.helpers.ts index ac6762a..a45507f 100644 --- a/src/core/service/verifier/verifier-failure.helpers.ts +++ b/src/core/service/verifier/verifier-failure.helpers.ts @@ -1,4 +1,4 @@ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { TransactionStatus } from "@/persistence/drizzle/entity/transaction.entity.ts"; import type { OperationsBundle } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; @@ -12,6 +12,7 @@ export type VerifierFailureDeps = { createSlotBundleFn: (bundle: OperationsBundle) => Promise; reAddBundlesFn: (bundles: SlotBundle[]) => Promise; maxRetryAttempts: number; + log: Logger; }; /** @@ -28,8 +29,14 @@ export async function handleVerificationFailure( bundleIds: string[], deps: VerifierFailureDeps, ): Promise { - LOG.warn("Transaction verification failed", { txId, reason, bundleIds }); + const log = deps.log.scope("handleVerificationFailure"); + log.info("handleVerificationFailure"); + log.debug("txId", txId); + log.debug("reason", reason); + log.debug("bundleIdCount", bundleIds.length); + log.event("transaction verification failed"); + log.event("marking transaction FAILED"); await deps.updateTxStatus(txId, TransactionStatus.FAILED); const retryableBundleIds: string[] = []; @@ -38,9 +45,8 @@ export async function handleVerificationFailure( try { const bundle = await deps.operationsBundleRepository.findById(bundleId); if (!bundle) { - LOG.warn( - `Bundle ${bundleId} not found while handling verification failure`, - ); + log.debug("bundleId", bundleId); + log.event("bundle not found while handling verification failure"); continue; } @@ -67,16 +73,15 @@ export async function handleVerificationFailure( if (!hasReachedMaxAttempts) { retryableBundleIds.push(bundleId); } else { - LOG.warn( - "Bundle moved to dead-letter after max verification retry attempts reached", - { - bundleId, - retryCount: nextRetryCount, - }, + log.debug("bundleId", bundleId); + log.debug("retryCount", nextRetryCount); + log.event( + "bundle moved to dead-letter after max verification retry attempts reached", ); } } catch (error) { - LOG.error(`Failed to update bundle ${bundleId} status`, { error }); + log.debug("bundleId", bundleId); + log.error(error, "failed to update bundle status"); } } @@ -92,10 +97,8 @@ export async function handleVerificationFailure( if (!updated) return null; return await deps.createSlotBundleFn(updated); } catch (error) { - LOG.error( - `Failed to build SlotBundle for retry of bundle ${bundleId}`, - { error }, - ); + log.debug("bundleId", bundleId); + log.error(error, "failed to build SlotBundle for retry"); return null; } }), @@ -104,8 +107,7 @@ export async function handleVerificationFailure( if (slotBundles.length > 0) { await deps.reAddBundlesFn(slotBundles); - LOG.info("Bundles re-added to mempool after verification failure", { - bundleIds: slotBundles.map((b) => b.bundleId), - }); + log.debug("bundleIds", slotBundles.map((b) => b.bundleId)); + log.event("bundles re-added to mempool after verification failure"); } } diff --git a/src/core/service/verifier/verifier.process.ts b/src/core/service/verifier/verifier.process.ts index a1e5b50..778cfd7 100644 --- a/src/core/service/verifier/verifier.process.ts +++ b/src/core/service/verifier/verifier.process.ts @@ -1,4 +1,4 @@ -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { TransactionStatus } from "@/persistence/drizzle/entity/transaction.entity.ts"; import { @@ -24,7 +24,12 @@ import { MoonlightOperation } from "@moonlight/moonlight-sdk"; async function findFirstBundleChannel( bundleIds: string[], + deps: { log: Logger }, ): Promise { + const log = deps.log.scope("findFirstBundleChannel"); + log.info("findFirstBundleChannel"); + log.debug("bundleIdCount", bundleIds.length); + log.event("scanning bundles for channelContractId"); for (const bundleId of bundleIds) { const bundle = await operationsBundleRepository.findById(bundleId); if (bundle?.channelContractId) return bundle.channelContractId; @@ -48,7 +53,13 @@ const operationsBundleRepository = new OperationsBundleRepository( async function updateTransactionStatus( txId: string, status: TransactionStatus, + deps: { log: Logger }, ): Promise { + const log = deps.log.scope("updateTransactionStatus"); + log.info("updateTransactionStatus"); + log.debug("txId", txId); + log.debug("status", status); + log.event("updating transaction status in DB"); await transactionRepository.update(txId, { status, updatedAt: new Date(), @@ -66,13 +77,16 @@ function handleVerificationFailure( txId: string, reason: string, bundleIds: string[], + log: Logger, ): Promise { return _handleVerificationFailure(txId, reason, bundleIds, { operationsBundleRepository, - updateTxStatus: (id, status) => updateTransactionStatus(id, status), - createSlotBundleFn: createSlotBundleFromEntity, + updateTxStatus: (id, status) => + updateTransactionStatus(id, status, { log }), + createSlotBundleFn: (bundle) => createSlotBundleFromEntity(bundle, { log }), reAddBundlesFn: (bundles) => getMempool().reAddBundles(bundles), maxRetryAttempts: VERIFIER_MAX_RETRY_ATTEMPTS, + log, }); } @@ -82,16 +96,17 @@ function handleVerificationFailure( async function handleVerificationSuccess( txId: string, bundleIds: string[], + log: Logger, ): Promise { - LOG.info("Transaction verified successfully", { - txId, - bundleCount: bundleIds.length, - }); + log.info("handleVerificationSuccess"); + log.debug("txId", txId); + log.debug("bundleCount", bundleIds.length); + log.event("transaction verified successfully"); - const channelContractId = await findFirstBundleChannel(bundleIds); + const channelContractId = await findFirstBundleChannel(bundleIds, { log }); // Update transaction status to VERIFIED - await updateTransactionStatus(txId, TransactionStatus.VERIFIED); + await updateTransactionStatus(txId, TransactionStatus.VERIFIED, { log }); for (const bundleId of bundleIds) { try { @@ -100,7 +115,8 @@ async function handleVerificationSuccess( updatedAt: new Date(), }); } catch (error) { - LOG.error(`Failed to update bundle ${bundleId} status`, { error }); + log.debug("bundleId", bundleId); + log.error(error, "failed to update bundle status"); } } @@ -110,8 +126,8 @@ async function handleVerificationSuccess( ts: Date.now(), scope, payload: { txId, bundleIds, channelContractId }, - })); - await emitDepositAndWithdrawEvents(txId, bundleIds, channelContractId); + }), { log }); + await emitDepositAndWithdrawEvents(txId, bundleIds, channelContractId, log); } } @@ -124,7 +140,11 @@ async function emitDepositAndWithdrawEvents( txId: string, bundleIds: string[], channelContractId: string, + log: Logger, ): Promise { + log.info("emitDepositAndWithdrawEvents"); + log.debug("txId", txId); + log.debug("bundleCount", bundleIds.length); for (const bundleId of bundleIds) { const bundle = await operationsBundleRepository.findById(bundleId); if (!bundle) continue; @@ -133,10 +153,8 @@ async function emitDepositAndWithdrawEvents( try { op = MoonlightOperation.fromMLXDR(mlxdr); } catch (error) { - LOG.error("Failed to parse operation MLXDR for event emit", { - bundleId, - error: error instanceof Error ? error.message : String(error), - }); + log.debug("bundleId", bundleId); + log.error(error, "failed to parse operation MLXDR for event emit"); continue; } if (op.isDeposit()) { @@ -153,7 +171,7 @@ async function emitDepositAndWithdrawEvents( depositorAddress, amount, }, - })); + }), { log }); } else if (op.isWithdraw()) { const recipientAddress = op.getPublicKey().toString(); const amount = op.getAmount().toString(); @@ -168,7 +186,7 @@ async function emitDepositAndWithdrawEvents( recipientAddress, amount, }, - })); + }), { log }); } } } @@ -180,6 +198,11 @@ async function emitDepositAndWithdrawEvents( export class Verifier { private intervalId: number | null = null; private isRunning: boolean = false; + private log: Logger; + + constructor(deps: { log: Logger }) { + this.log = deps.log.scope("Verifier"); + } /** * Starts the verifier loop @@ -190,7 +213,7 @@ export class Verifier { } this.isRunning = true; - LOG.info("Verifier started", { intervalMs: VERIFIER_CONFIG.INTERVAL_MS }); + this.log.event("Verifier started"); // Verify immediately, then on interval this.verifyTransactions(); @@ -213,7 +236,7 @@ export class Verifier { clearInterval(this.intervalId); this.intervalId = null; } - LOG.info("Verifier stopped"); + this.log.event("Verifier stopped"); } /** @@ -234,7 +257,9 @@ export class Verifier { span.addEvent("verifying_transactions", { "transactions.count": unverifiedTransactions.length, }); - LOG.debug(`Verifying ${unverifiedTransactions.length} transactions`); + this.log.event( + `Verifying ${unverifiedTransactions.length} transactions`, + ); for (const transaction of unverifiedTransactions) { await this.verifyTransaction(transaction.id); @@ -247,9 +272,10 @@ export class Verifier { ? error.message : String(error), }); - LOG.error("Error during transaction verification", { - error: error instanceof Error ? error.message : String(error), - }); + this.log.error( + new Error(String("Error during transaction verification")), + "Error during transaction verification", + ); } }); } @@ -269,16 +295,15 @@ export class Verifier { if (bundleIds.length === 0) { span.addEvent("no_bundles_found"); - LOG.warn(`No bundles found for transaction ${txId}`); + this.log.event(`No bundles found for transaction ${txId}`); return; } - span.addEvent("verifying_on_network", { - "bundles.count": bundleIds.length, - }); + span.addEvent("verifying_on_network"); const result = await verifyTransactionOnNetwork( txId, NETWORK_RPC_SERVER, + { log: this.log }, ); span.addEvent("verification_result", { @@ -286,10 +311,17 @@ export class Verifier { }); if (result.status === "VERIFIED") { - await handleVerificationSuccess(txId, bundleIds); + await handleVerificationSuccess(txId, bundleIds, this.log); } else if (result.status === "FAILED") { - const channelContractId = await findFirstBundleChannel(bundleIds); - await handleVerificationFailure(txId, result.reason, bundleIds); + const channelContractId = await findFirstBundleChannel(bundleIds, { + log: this.log, + }); + await handleVerificationFailure( + txId, + result.reason, + bundleIds, + this.log, + ); if (channelContractId) { await emitForBundles(bundleIds, (scope) => ({ kind: "verifier.bundle_failed", @@ -301,10 +333,10 @@ export class Verifier { channelContractId, reason: result.reason, }, - })); + }), { log: this.log }); } } else { - LOG.debug(`Transaction ${txId} still pending verification`); + this.log.event(`Transaction ${txId} still pending verification`); } } catch (error) { span.addEvent("verification_failed", { @@ -312,9 +344,10 @@ export class Verifier { ? error.message : String(error), }); - LOG.error(`Failed to verify transaction ${txId}`, { - error: error instanceof Error ? error.message : String(error), - }); + this.log.error( + new Error(String(`Failed to verify transaction ${txId}`)), + `Failed to verify transaction ${txId}`, + ); } }); } diff --git a/src/core/service/verifier/verifier.service.ts b/src/core/service/verifier/verifier.service.ts index 0e5f63f..481c725 100644 --- a/src/core/service/verifier/verifier.service.ts +++ b/src/core/service/verifier/verifier.service.ts @@ -1,6 +1,7 @@ import type { Server } from "stellar-sdk/rpc"; import type { VerificationResult } from "@/core/service/verifier/verifier.types.ts"; import { withSpan } from "@/core/tracing.ts"; +import type { Logger } from "@/utils/logger/index.ts"; /** * Verifies a transaction on the Stellar network @@ -13,14 +14,21 @@ import { withSpan } from "@/core/tracing.ts"; export function verifyTransactionOnNetwork( txHash: string, rpcServer: Server, + deps: { log: Logger }, ): Promise { return withSpan("Verifier.verifyTransactionOnNetwork", async (span) => { + const log = deps.log.scope("verifyTransactionOnNetwork"); + log.info("verifyTransactionOnNetwork"); + log.debug("txHash", txHash); + span.setAttribute("tx.hash", txHash); try { span.addEvent("querying_rpc"); + log.event("querying Stellar RPC"); const txResponse = await rpcServer.getTransaction(txHash); if (!txResponse) { span.addEvent("transaction_not_found"); + log.event("transaction not found yet"); return { status: "PENDING" }; } @@ -28,6 +36,8 @@ export function verifyTransactionOnNetwork( span.addEvent("transaction_verified", { "ledger": txResponse.ledger?.toString() ?? "unknown", }); + log.event("transaction verified"); + log.debug("ledger", txResponse.ledger?.toString() ?? "unknown"); return { status: "VERIFIED", ledgerSequence: txResponse.ledger?.toString(), @@ -39,6 +49,8 @@ export function verifyTransactionOnNetwork( span.addEvent("transaction_failed_on_network", { "resultCode": String(resultCode), }); + log.event("transaction failed on network"); + log.debug("resultCode", String(resultCode)); return { status: "FAILED", reason: `Transaction failed with result code: ${resultCode}`, @@ -46,6 +58,7 @@ export function verifyTransactionOnNetwork( } span.addEvent("transaction_status_unclear"); + log.event("transaction status unclear, treating as pending"); return { status: "PENDING" }; } catch (error) { const errorMessage = error instanceof Error @@ -54,10 +67,12 @@ export function verifyTransactionOnNetwork( if (errorMessage.includes("not found") || errorMessage.includes("404")) { span.addEvent("transaction_pending_not_found"); + log.event("RPC reports not found, treating as pending"); return { status: "PENDING" }; } span.addEvent("verification_error", { "error.message": errorMessage }); + log.error(error, "verification RPC failure"); return { status: "FAILED", reason: errorMessage, diff --git a/src/http/middleware/append-request-id.ts b/src/http/middleware/append-request-id.ts index f966a4a..91981e9 100644 --- a/src/http/middleware/append-request-id.ts +++ b/src/http/middleware/append-request-id.ts @@ -1,12 +1,16 @@ import type { Context } from "@oak/oak"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; -export async function appendRequestIdMiddleware( - ctx: Context, - next: () => Promise, -) { - const requestId = crypto.randomUUID(); - ctx.state.requestId = requestId; - LOG.info("Incoming request with ID:", { requestId }); - await next(); +export function appendRequestIdMiddleware( + deps: { log: Logger }, +): (ctx: Context, next: () => Promise) => Promise { + const log = deps.log.scope("requestId"); + + return async (ctx, next) => { + const requestId = crypto.randomUUID(); + ctx.state.requestId = requestId; + log.debug("requestId", requestId); + log.event("incoming request"); + await next(); + }; } diff --git a/src/http/middleware/auth/index.ts b/src/http/middleware/auth/index.ts index e42cc57..c7ec487 100644 --- a/src/http/middleware/auth/index.ts +++ b/src/http/middleware/auth/index.ts @@ -2,46 +2,47 @@ import type { Context } from "@oak/oak"; import { verify } from "@zaubrik/djwt"; import { SERVICE_AUTH_SECRET_AS_CRYPTO_KEY } from "@/core/service/auth/service/service-auth-secret.ts"; import type { JwtPayload } from "@/core/service/auth/generate-jwt.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { isDefined } from "@/utils/type-guards/is-defined.ts"; import * as E from "@/http/middleware/auth/error.ts"; import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts"; -export async function jwtMiddleware( - ctx: Context, - next: () => Promise, -) { - const authorization = ctx.request.headers.get("authorization"); - if (!isDefined(authorization)) { - return await PIPE_APIError(ctx).run(new E.MISSING_AUTHORIZATION_HEADER()); - } +export function jwtMiddleware( + deps: { log: Logger }, +): (ctx: Context, next: () => Promise) => Promise { + return async (ctx, next) => { + const authorization = ctx.request.headers.get("authorization"); + if (!isDefined(authorization)) { + await PIPE_APIError(ctx, deps).run(new E.MISSING_AUTHORIZATION_HEADER()); + return; + } - const parts = authorization.split(" "); - if (parts.length !== 2 || parts[0] !== "Bearer") { - return await PIPE_APIError(ctx).run(new E.INVALID_AUTHORIZATION_HEADER()); - } - const token = parts[1]; + const parts = authorization.split(" "); + if (parts.length !== 2 || parts[0] !== "Bearer") { + await PIPE_APIError(ctx, deps).run(new E.INVALID_AUTHORIZATION_HEADER()); + return; + } + const token = parts[1]; - try { - const secretKey = SERVICE_AUTH_SECRET_AS_CRYPTO_KEY; - // verify() will throw if verification fails. - const payload = await verify(token, secretKey); + try { + const secretKey = SERVICE_AUTH_SECRET_AS_CRYPTO_KEY; + const payload = await verify(token, secretKey); - // Optionally, you can decode the token to inspect all fields - // (verify already returns the payload if ) - // const payload = decode(token); + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp === "number" && now > payload.exp) { + await PIPE_APIError(ctx, deps).run(new E.EXPIRED_TOKEN()); + return; + } - // Check expiration manually if needed - const now = Math.floor(Date.now() / 1000); - if (typeof payload.exp === "number" && now > payload.exp) { - return await PIPE_APIError(ctx).run(new E.EXPIRED_TOKEN()); + ctx.state.session = payload; + } catch (error) { + await PIPE_APIError(ctx, deps).run( + new E.JWT_VERIFICATION_FAILED(error), + ); + return; } - - // Attach the verified payload to ctx.state for later use. - ctx.state.session = payload; - } catch (error) { - return await PIPE_APIError(ctx).run(new E.JWT_VERIFICATION_FAILED(error)); - } - await next(); + await next(); + }; } export type JwtSessionData = JwtPayload; diff --git a/src/http/pipelines/error-pipeline.ts b/src/http/pipelines/error-pipeline.ts index a138ee7..409b376 100644 --- a/src/http/pipelines/error-pipeline.ts +++ b/src/http/pipelines/error-pipeline.ts @@ -1,10 +1,14 @@ import { Pipeline } from "@fifo/convee"; import type { Context } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; import { P_SetErrorResponse } from "@/http/processes/set-api-response.ts"; import { P_ErrorToApiResponse } from "@/http/processes/error-to-api-response.ts"; -export const PIPE_APIError = (ctx: Context) => { - return Pipeline.create([P_ErrorToApiResponse(), P_SetErrorResponse(ctx)], { - name: "APIErrorProcessingPipeline", - }); +export const PIPE_APIError = (ctx: Context, deps: { log: Logger }) => { + return Pipeline.create( + [P_ErrorToApiResponse(), P_SetErrorResponse(ctx, deps)], + { + name: "APIErrorProcessingPipeline", + }, + ); }; diff --git a/src/http/pipelines/get-endpoint.ts b/src/http/pipelines/get-endpoint.ts index f0f6952..9d198b7 100644 --- a/src/http/pipelines/get-endpoint.ts +++ b/src/http/pipelines/get-endpoint.ts @@ -1,5 +1,6 @@ import { Pipeline, type PipelineStep, type PipelineSteps } from "@fifo/convee"; import type { ZodSchema } from "zod"; +import type { Logger } from "@/utils/logger/index.ts"; import { P_ParseRequestQuery } from "@/http/processes/parse-request-query.ts"; import { P_SetSuccessResponse } from "@/http/processes/set-successful-response.ts"; import { PLG_ProcessErrorResponse } from "@/http/plugins/process-error-response.ts"; @@ -25,17 +26,17 @@ export const PIPE_GetEndpoint = < name?: string; requestSchema: Req; responseSchema: Res; -}) => { +}, deps: { log: Logger }) => { const pipe = Pipeline.create( [ - P_ParseRequestQuery(requestSchema), + P_ParseRequestQuery(requestSchema, deps), ...steps, - P_SetSuccessResponse(responseSchema), + P_SetSuccessResponse(responseSchema, deps), ], { name }, ); - pipe.addPlugin(PLG_ProcessErrorResponse(), name); + pipe.addPlugin(PLG_ProcessErrorResponse(deps), name); return pipe; }; diff --git a/src/http/pipelines/post-endpoint.ts b/src/http/pipelines/post-endpoint.ts index 771b577..c9557a7 100644 --- a/src/http/pipelines/post-endpoint.ts +++ b/src/http/pipelines/post-endpoint.ts @@ -1,5 +1,6 @@ import type { ZodSchema } from "zod"; import { Pipeline, type PipelineStep, type PipelineSteps } from "@fifo/convee"; +import type { Logger } from "@/utils/logger/index.ts"; import { P_ParseRequestBody } from "@/http/processes/parse-request-body.ts"; import { P_SetSuccessResponse } from "@/http/processes/set-successful-response.ts"; import type { @@ -25,17 +26,17 @@ export const PIPE_PostEndpoint = < name?: string; requestSchema: Req; responseSchema: Res; -}) => { +}, deps: { log: Logger }) => { const pipe = Pipeline.create( [ - P_ParseRequestBody(requestSchema), + P_ParseRequestBody(requestSchema, deps), ...steps, - P_SetSuccessResponse(responseSchema), + P_SetSuccessResponse(responseSchema, deps), ], { name }, ); - pipe.addPlugin(PLG_ProcessErrorResponse(), name); + pipe.addPlugin(PLG_ProcessErrorResponse(deps), name); return pipe; }; diff --git a/src/http/plugins/process-error-response.ts b/src/http/plugins/process-error-response.ts index 3bd4c17..17e4f66 100644 --- a/src/http/plugins/process-error-response.ts +++ b/src/http/plugins/process-error-response.ts @@ -6,17 +6,17 @@ import { type Transformer, } from "@fifo/convee"; import type { Context } from "@oak/oak"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import * as E from "@/http/plugins/error.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts"; -export const PLG_ProcessErrorResponse = () => { +export const PLG_ProcessErrorResponse = (deps: { log: Logger }) => { + const log = deps.log.scope("errorPlugin"); + const processInput: Modifier = ( input: Context, metadataHelper?: MetadataHelper, ): Context => { - LOG.trace("Storing input context for error plugin processing"); if (metadataHelper) metadataHelper.add("input-context", input); return input; }; @@ -28,16 +28,16 @@ export const PLG_ProcessErrorResponse = () => { error: ConveeError, metadataHelper?: MetadataHelper, ): Promise | Context> => { - LOG.error("Plugin captured an error: ", error.message); + log.error(error, "plugin captured an error"); const ctx = metadataHelper!.get("input-context") as Context; - const errorPipeline = PIPE_APIError(ctx); + const errorPipeline = PIPE_APIError(ctx, deps); try { return await errorPipeline.run(error); } catch (e) { - logAndThrow(new E.PROCESSING_ERROR_RESPONSE_FAILED(e)); + throw new E.PROCESSING_ERROR_RESPONSE_FAILED(e); } }; diff --git a/src/http/processes/parse-request-body.ts b/src/http/processes/parse-request-body.ts index 0d18dc3..686a658 100644 --- a/src/http/processes/parse-request-body.ts +++ b/src/http/processes/parse-request-body.ts @@ -1,38 +1,22 @@ import { ProcessEngine } from "@fifo/convee"; import type { Context } from "@oak/oak"; import { type infer as ZodInfer, ZodError, type ZodSchema } from "zod"; -import { LOG } from "@/config/logger.ts"; import type { ContextWithParsedBody } from "@/http/processes/types.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import * as E from "@/http/processes/error.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; const PROCESS_NAME = "ParseRequestBody" as const; -/** - * Factory Process that parses and validates the request body - * - * @param schema - Zod schema to validate the request body against - * @returns A Process that takes a Context and returns a ContextWithParsedBody - * @throws Error if the request body is invalid - * - * @example - * ```ts - * import { P_ParseRequestBody } from "@/http/processes/parse-request-query.ts"; - * import { ZodSchema, z } from "zod"; - * - * const bodySchema = z.object({ - * search: z.string(), - * page: z.number().optional(), - * }); - * - * const parseBodyProcess = P_ParseRequestBody(bodySchema); - * ``` - */ -const P_ParseRequestBody = (schema: S) => { +const P_ParseRequestBody = ( + schema: S, + deps: { log: Logger }, +) => { + const log = deps.log.scope("parseRequestBody"); + const parseRequestProcess = async ( ctx: Context, ): Promise>> => { - LOG.trace("Parsing request body"); + log.event("parsing request body"); try { const bodyPayload = await ctx.request.body.json(); @@ -41,10 +25,10 @@ const P_ParseRequestBody = (schema: S) => { return { ctx, body: validatedPayload }; } catch (error) { if (error instanceof ZodError) { - logAndThrow(new E.INVALID_PAYLOAD(error, error.issues)); + throw new E.INVALID_PAYLOAD(error, error.issues); } - logAndThrow(new E.FAILED_TO_PARSE_BODY(error)); + throw new E.FAILED_TO_PARSE_BODY(error); } }; diff --git a/src/http/processes/parse-request-query.ts b/src/http/processes/parse-request-query.ts index 5452f6c..7a909f2 100644 --- a/src/http/processes/parse-request-query.ts +++ b/src/http/processes/parse-request-query.ts @@ -1,38 +1,22 @@ import { ProcessEngine } from "@fifo/convee"; import type { Context } from "@oak/oak"; import { type infer as ZodInfer, ZodError, type ZodSchema } from "zod"; -import { LOG } from "@/config/logger.ts"; import type { ContextWithParsedQuery } from "@/http/processes/types.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import * as E from "@/http/processes/error.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; const PROCESS_NAME = "ParseRequestQuery" as const; -/** - * Factory Process that parses and validates URL query parameters - * - * @param schema - Zod schema to validate the query parameters against - * @returns A Process that takes a Context and returns a ContextWithParsedQuery - * @throws Error if the query parameters are invalid - * - * @example - * ```ts - * import { P_ParseRequestQuery } from "@/http/processes/parse-request-query.ts"; - * import { ZodSchema, z } from "zod"; - * - * const querySchema = z.object({ - * search: z.string(), - * page: z.number().optional(), - * }); - * - * const parseQueryProcess = P_ParseRequestQuery(querySchema); - * ``` - */ -const P_ParseRequestQuery = (schema: S) => { +const P_ParseRequestQuery = ( + schema: S, + deps: { log: Logger }, +) => { + const log = deps.log.scope("parseRequestQuery"); + const parseRequestProcess = ( ctx: Context, ): ContextWithParsedQuery> => { - LOG.trace("Parsing request query"); + log.event("parsing request query"); try { const queryPayload = Object.fromEntries( @@ -42,10 +26,10 @@ const P_ParseRequestQuery = (schema: S) => { return { ctx, query: validatedPayload }; } catch (error) { if (error instanceof ZodError) { - logAndThrow(new E.INVALID_QUERY_PARAMS(error, error.issues)); + throw new E.INVALID_QUERY_PARAMS(error, error.issues); } - logAndThrow(new E.FAILED_TO_PARSE_QUERY_PARAMS(error)); + throw new E.FAILED_TO_PARSE_QUERY_PARAMS(error); } }; diff --git a/src/http/processes/set-api-response.ts b/src/http/processes/set-api-response.ts index 1ca84d2..4d19047 100644 --- a/src/http/processes/set-api-response.ts +++ b/src/http/processes/set-api-response.ts @@ -2,16 +2,19 @@ import type { Context } from "@oak/oak"; import { ProcessEngine } from "@fifo/convee"; import type { MetadataHelper } from "@fifo/convee"; import type { ErrorResponse } from "@/http/default-schemas.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const PROCESS_NAME = "SetErrorResponse" as const; -const P_SetErrorResponse = (ctx: Context) => { +const P_SetErrorResponse = (ctx: Context, deps: { log: Logger }) => { + const log = deps.log.scope("setApiResponse"); + const setApiResponse = ( response: ErrorResponse, _metadataHelper?: MetadataHelper, ): Context => { - LOG.trace("Setting API response on context", { status: response.status }); + log.debug("status", response.status); + log.event("setting API response on context"); ctx.response.status = response.status; ctx.response.body = response; return ctx; diff --git a/src/http/processes/set-successful-response.ts b/src/http/processes/set-successful-response.ts index 3a148c6..5f78785 100644 --- a/src/http/processes/set-successful-response.ts +++ b/src/http/processes/set-successful-response.ts @@ -2,20 +2,24 @@ import { ProcessEngine } from "@fifo/convee"; import { type Context, Status } from "@oak/oak"; import type { infer as ZodInfer, ZodSchema } from "zod"; import type { SuccessResponseInput } from "@/http/processes/types.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import * as E from "@/http/processes/error.ts"; -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; const PROCESS_NAME = "setSuccessResponse" as const; -const P_SetSuccessResponse = (schema: S) => { +const P_SetSuccessResponse = ( + schema: S, + deps: { log: Logger }, +) => { + const log = deps.log.scope("setSuccessResponse"); + const setSuccessResponseProcess = ( input: SuccessResponseInput>, ): Context => { const { ctx, data, status, message } = input; - LOG.trace("Setting success response"); - LOG.debug(`Has data to append to response : ${!!data}`); + log.debug("hasData", !!data); + log.event("setting success response"); try { ctx.response.status = Status.OK; @@ -28,10 +32,10 @@ const P_SetSuccessResponse = (schema: S) => { data: validatedData, } as SuccessResponseInput>; - LOG.debug("Response body set with status:", ctx.response.status); + log.debug("status", ctx.response.status); return ctx; } catch (error) { - logAndThrow(new E.FAILED_TO_SET_SUCCESS_RESPONSE(error)); + throw new E.FAILED_TO_SET_SUCCESS_RESPONSE(error); } }; diff --git a/src/http/v1/bundle/get.ts b/src/http/v1/bundle/get.ts index a93b15f..8703e81 100644 --- a/src/http/v1/bundle/get.ts +++ b/src/http/v1/bundle/get.ts @@ -3,7 +3,7 @@ import { type Context, Status } from "@oak/oak"; import { P_GetBundleById } from "@/core/service/bundle/get-bundle.process.ts"; import type { GetEndpointOutput } from "@/http/pipelines/types.ts"; import { PIPE_GetEndpoint } from "@/http/pipelines/get-endpoint.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; export const requestSchema = z.object({ bundleId: z.string().min(1), @@ -24,40 +24,45 @@ export type BundleGetProcessOutput = { bundle: z.infer; }; -const assembleResponse = ( - input: BundleGetProcessOutput, -): GetEndpointOutput => { - const message = "Bundle successfully retrieved"; +export function handleGetBundle( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getBundle"); - LOG.info(message); + const assembleResponse = ( + input: BundleGetProcessOutput, + ): GetEndpointOutput => { + log.event("bundle successfully retrieved"); - return { - ctx: input.ctx, - status: Status.OK, - message, - data: input.bundle, + return { + ctx: input.ctx, + status: Status.OK, + message: "Bundle successfully retrieved", + data: input.bundle, + }; }; -}; -export const getBundleHandler = (ctx: Context) => { - // Map path param :bundleId into query string so PIPE_GetEndpoint + - // P_ParseRequestQuery can validate and pass it through normally. - type RouteParams = { bundleId?: string }; - const params = (ctx as unknown as { params?: RouteParams }).params; - if (params?.bundleId) { - const bundleId = params.bundleId; - ctx.request.url.searchParams.set("bundleId", bundleId); - } - - const handler = PIPE_GetEndpoint({ - name: "GetBundleEndpointPipeline", - requestSchema, - responseSchema, - steps: [ - P_GetBundleById, - assembleResponse, - ], - }); - - return handler.run(ctx); -}; + return (ctx) => { + log.info("getBundle"); + // Map path param :bundleId into query string so PIPE_GetEndpoint + + // P_ParseRequestQuery can validate and pass it through normally. + type RouteParams = { bundleId?: string }; + const params = (ctx as unknown as { params?: RouteParams }).params; + if (params?.bundleId) { + const bundleId = params.bundleId; + ctx.request.url.searchParams.set("bundleId", bundleId); + } + + const handler = PIPE_GetEndpoint({ + name: "GetBundleEndpointPipeline", + requestSchema, + responseSchema, + steps: [ + P_GetBundleById(deps), + assembleResponse, + ], + }, deps); + + return handler.run(ctx); + }; +} diff --git a/src/http/v1/bundle/list.ts b/src/http/v1/bundle/list.ts index 48a654d..85f87d4 100644 --- a/src/http/v1/bundle/list.ts +++ b/src/http/v1/bundle/list.ts @@ -3,10 +3,9 @@ import { type Context, Status } from "@oak/oak"; import { P_ListBundlesByUser } from "@/core/service/bundle/list-bundles.process.ts"; import type { GetEndpointOutput } from "@/http/pipelines/types.ts"; import { PIPE_GetEndpoint } from "@/http/pipelines/get-endpoint.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; -// Reuse the bundle item schema from get.ts const bundleItemSchema = z.object({ id: z.string(), status: z.string(), @@ -35,33 +34,39 @@ export type BundleListProcessOutput = { bundles: z.infer[]; }; -const assembleResponse = ( - input: BundleListProcessOutput, -): GetEndpointOutput => { - const message = "Bundles successfully retrieved"; +export function handleListBundles( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("listBundles"); - LOG.info(message, { count: input.bundles.length }); + const assembleResponse = ( + input: BundleListProcessOutput, + ): GetEndpointOutput => { + log.debug("count", input.bundles.length); + log.event("bundles successfully retrieved"); - return { - ctx: input.ctx, - status: Status.OK, - message, - data: { - bundles: input.bundles, - }, + return { + ctx: input.ctx, + status: Status.OK, + message: "Bundles successfully retrieved", + data: { + bundles: input.bundles, + }, + }; }; -}; -export const listBundlesHandler = (ctx: Context) => { - const handler = PIPE_GetEndpoint({ - name: "ListBundlesEndpointPipeline", - requestSchema, - responseSchema, - steps: [ - P_ListBundlesByUser, - assembleResponse, - ], - }); + return (ctx) => { + log.info("listBundles"); + const handler = PIPE_GetEndpoint({ + name: "ListBundlesEndpointPipeline", + requestSchema, + responseSchema, + steps: [ + P_ListBundlesByUser(deps), + assembleResponse, + ], + }, deps); - return handler.run(ctx); -}; + return handler.run(ctx); + }; +} diff --git a/src/http/v1/bundle/post.ts b/src/http/v1/bundle/post.ts index b6ed1d3..9eefd6b 100644 --- a/src/http/v1/bundle/post.ts +++ b/src/http/v1/bundle/post.ts @@ -3,7 +3,7 @@ import { type Context, Status } from "@oak/oak"; import { P_AddOperationsBundle } from "@/core/service/bundle/add-bundle.process.ts"; import type { PostEndpointOutput } from "@/http/pipelines/types.ts"; import { PIPE_PostEndpoint } from "@/http/pipelines/post-endpoint.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { BUNDLE_MAX_OPERATIONS } from "@/config/env.ts"; import { bundleRequestSchema } from "@/http/v1/bundle/bundle.schemas.ts"; @@ -19,34 +19,40 @@ type BundleProcessOutput = { operationsBundleId: string; }; -const assembleResponse = ( - input: BundleProcessOutput, -): PostEndpointOutput => { - const message = "Bundle received and queued for processing"; - - LOG.info(message, { bundleId: input.operationsBundleId }); - - return { - ctx: input.ctx, - status: Status.OK, - message, - data: { - operationsBundleId: input.operationsBundleId, - status: "PENDING", - }, +export function handlePostBundle( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postBundle"); + + const assembleResponse = ( + input: BundleProcessOutput, + ): PostEndpointOutput => { + log.debug("bundleId", input.operationsBundleId); + log.event("bundle received and queued for processing"); + + return { + ctx: input.ctx, + status: Status.OK, + message: "Bundle received and queued for processing", + data: { + operationsBundleId: input.operationsBundleId, + status: "PENDING", + }, + }; }; -}; -export const postBundleHandler = (ctx: Context) => { - const handler = PIPE_PostEndpoint({ - name: "PostBundleEndpointPipeline", - requestSchema: requestSchema, - responseSchema: responseSchema, - steps: [ - P_AddOperationsBundle, - assembleResponse, - ], - }); - - return handler.run(ctx); -}; + return (ctx) => { + log.info("postBundle"); + const handler = PIPE_PostEndpoint({ + name: "PostBundleEndpointPipeline", + requestSchema: requestSchema, + responseSchema: responseSchema, + steps: [ + P_AddOperationsBundle(deps), + assembleResponse, + ], + }, deps); + + return handler.run(ctx); + }; +} diff --git a/src/http/v1/bundle/routes.ts b/src/http/v1/bundle/routes.ts index fd6889c..3f91618 100644 --- a/src/http/v1/bundle/routes.ts +++ b/src/http/v1/bundle/routes.ts @@ -1,27 +1,28 @@ import { Router } from "@oak/oak"; -import { postBundleHandler } from "@/http/v1/bundle/post.ts"; -import { getBundleHandler } from "@/http/v1/bundle/get.ts"; -import { listBundlesHandler } from "@/http/v1/bundle/list.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import { handlePostBundle } from "@/http/v1/bundle/post.ts"; +import { handleGetBundle } from "@/http/v1/bundle/get.ts"; +import { handleListBundles } from "@/http/v1/bundle/list.ts"; import { jwtMiddleware } from "@/http/middleware/auth/index.ts"; -const bundleRouter = new Router(); - -// PP-scoped bundle endpoints. ppPublicKey in the URL is required — there is -// no "default" PP; submitters must address one explicitly. -bundleRouter.post( - "/providers/:ppPublicKey/bundles", - jwtMiddleware, - postBundleHandler, -); -bundleRouter.get( - "/providers/:ppPublicKey/bundles/:bundleId", - jwtMiddleware, - getBundleHandler, -); -bundleRouter.get( - "/providers/:ppPublicKey/bundles", - jwtMiddleware, - listBundlesHandler, -); - -export default bundleRouter; +export function buildBundleRouter(deps: { log: Logger }): Router { + const bundleRouter = new Router(); + // PP-scoped bundle endpoints. ppPublicKey in the URL is required — there + // is no "default" PP; submitters must address one explicitly. + bundleRouter.post( + "/providers/:ppPublicKey/bundles", + jwtMiddleware(deps), + handlePostBundle(deps), + ); + bundleRouter.get( + "/providers/:ppPublicKey/bundles/:bundleId", + jwtMiddleware(deps), + handleGetBundle(deps), + ); + bundleRouter.get( + "/providers/:ppPublicKey/bundles", + jwtMiddleware(deps), + handleListBundles(deps), + ); + return bundleRouter; +} diff --git a/src/http/v1/council/routes.ts b/src/http/v1/council/routes.ts index bcf6108..1ba4ea9 100644 --- a/src/http/v1/council/routes.ts +++ b/src/http/v1/council/routes.ts @@ -1,9 +1,10 @@ import { Router } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; // Callback endpoints (config-push, status-update) removed. // PP now determines its own state via on-chain queries and // the council's public membership-status endpoint. -const councilRouter = new Router(); - -export default councilRouter; +export function buildCouncilRouter(_deps: { log: Logger }): Router { + return new Router(); +} diff --git a/src/http/v1/dashboard/audit-export.ts b/src/http/v1/dashboard/audit-export.ts index d0c2283..972be3d 100644 --- a/src/http/v1/dashboard/audit-export.ts +++ b/src/http/v1/dashboard/audit-export.ts @@ -2,7 +2,7 @@ import { type Context, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { OperationsBundleRepository } from "@/persistence/drizzle/repository/index.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const bundleRepo = new OperationsBundleRepository(drizzleClient); @@ -19,66 +19,75 @@ function csvEscape(val: string): string { * Returns bundle data as CSV for compliance reporting. * Date filtering is done in SQL, not in-memory. */ -export const getAuditExportHandler = async (ctx: Context) => { - const params = ctx.request.url.searchParams; - const statusParam = params.get("status") || "COMPLETED"; +export function handleGetAuditExport( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getAuditExport"); - if (!(statusParam in BundleStatus)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: `Invalid status. Must be one of: ${ - Object.keys(BundleStatus).join(", ") - }`, - }; - return; - } + return async (ctx) => { + log.info("getAuditExport"); + const params = ctx.request.url.searchParams; + const statusParam = params.get("status") || "COMPLETED"; - const status = statusParam as BundleStatus; - const fromRaw = params.get("from"); - const toRaw = params.get("to"); - const from = fromRaw ? new Date(fromRaw) : undefined; - const to = toRaw ? new Date(toRaw) : undefined; + if (!(statusParam in BundleStatus)) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: `Invalid status. Must be one of: ${ + Object.keys(BundleStatus).join(", ") + }`, + }; + return; + } - if ((from && isNaN(from.getTime())) || (to && isNaN(to.getTime()))) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "Invalid date format. Use ISO 8601 (e.g., 2026-01-01).", - }; - return; - } + const status = statusParam as BundleStatus; + const fromRaw = params.get("from"); + const toRaw = params.get("to"); + const from = fromRaw ? new Date(fromRaw) : undefined; + const to = toRaw ? new Date(toRaw) : undefined; - try { - const bundles = await bundleRepo.findByStatusAndDateRange(status, from, to); + if ((from && isNaN(from.getTime())) || (to && isNaN(to.getTime()))) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "Invalid date format. Use ISO 8601 (e.g., 2026-01-01).", + }; + return; + } - const headers = ["id", "status", "fee", "createdAt", "updatedAt"]; - const rows = bundles.map((b) => - [ - csvEscape(b.id), - csvEscape(b.status), - csvEscape(b.fee?.toString() ?? ""), - csvEscape(b.createdAt.toISOString()), - csvEscape(b.updatedAt?.toISOString() ?? ""), - ].join(",") - ); - const csv = [headers.join(","), ...rows].join("\n"); + try { + const bundles = await bundleRepo.findByStatusAndDateRange( + status, + from, + to, + ); - ctx.response.status = Status.OK; - ctx.response.headers.set("Content-Type", "text/csv"); - ctx.response.headers.set( - "Content-Disposition", - `attachment; filename="audit-export-${status}-${ - new Date().toISOString().slice(0, 10) - }.csv"`, - ); - ctx.response.body = csv; - } catch (error) { - LOG.error("Audit export failed", { - error: error instanceof Error ? error.message : String(error), - }); + const headers = ["id", "status", "fee", "createdAt", "updatedAt"]; + const rows = bundles.map((b) => + [ + csvEscape(b.id), + csvEscape(b.status), + csvEscape(b.fee?.toString() ?? ""), + csvEscape(b.createdAt.toISOString()), + csvEscape(b.updatedAt?.toISOString() ?? ""), + ].join(",") + ); + const csv = [headers.join(","), ...rows].join("\n"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { - message: "Failed to generate audit export", - }; - } -}; + ctx.response.status = Status.OK; + ctx.response.headers.set("Content-Type", "text/csv"); + ctx.response.headers.set( + "Content-Disposition", + `attachment; filename="audit-export-${status}-${ + new Date().toISOString().slice(0, 10) + }.csv"`, + ); + ctx.response.body = csv; + } catch (error) { + log.error(error, "audit export failed"); + + ctx.response.status = Status.InternalServerError; + ctx.response.body = { + message: "Failed to generate audit export", + }; + } + }; +} diff --git a/src/http/v1/dashboard/auth/challenge.ts b/src/http/v1/dashboard/auth/challenge.ts index f772430..22da811 100644 --- a/src/http/v1/dashboard/auth/challenge.ts +++ b/src/http/v1/dashboard/auth/challenge.ts @@ -1,52 +1,49 @@ import { type Context, Status } from "@oak/oak"; import { Keypair } from "stellar-sdk"; import { createDashboardChallenge } from "@/core/service/auth/dashboard-auth.ts"; +import type { Logger } from "@/utils/logger/index.ts"; -/** - * POST /dashboard/auth/challenge - * - * Request body: { publicKey: string } - * Response: { nonce: string } - * - * The client must sign the nonce with their Ed25519 key - * and submit it to /dashboard/auth/verify. - */ -export const postChallengeHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { publicKey } = body; +export function handlePostChallenge( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postChallenge"); - if (!publicKey || typeof publicKey !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "publicKey is required" }; - return; - } - - // Validate Stellar public key format + return async (ctx) => { + log.info("postChallenge"); try { - Keypair.fromPublicKey(publicKey); - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid Stellar public key format" }; - return; - } + const body = await ctx.request.body.json(); + const { publicKey } = body; + + if (!publicKey || typeof publicKey !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "publicKey is required" }; + return; + } + + try { + Keypair.fromPublicKey(publicKey); + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid Stellar public key format" }; + return; + } - const { nonce } = createDashboardChallenge(publicKey); + const { nonce } = createDashboardChallenge(publicKey, { log }); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Challenge created", - data: { nonce }, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - // Challenge store overflow → 429 - if (message.includes("Too many pending challenges")) { - ctx.response.status = 429; - ctx.response.body = { message }; - return; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Challenge created", + data: { nonce }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Too many pending challenges")) { + ctx.response.status = 429; + ctx.response.body = { message }; + return; + } + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to create challenge" }; } - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to create challenge" }; - } -}; + }; +} diff --git a/src/http/v1/dashboard/auth/verify.ts b/src/http/v1/dashboard/auth/verify.ts index bab8967..522b26d 100644 --- a/src/http/v1/dashboard/auth/verify.ts +++ b/src/http/v1/dashboard/auth/verify.ts @@ -3,7 +3,7 @@ import { verifyDashboardChallenge } from "@/core/service/auth/dashboard-auth.ts" import generateJwt from "@/core/service/auth/generate-jwt.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { WalletUserRepository } from "@/persistence/drizzle/repository/wallet-user.repository.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const walletUserRepo = new WalletUserRepository(drizzleClient); @@ -17,46 +17,52 @@ const walletUserRepo = new WalletUserRepository(drizzleClient); * The signer check against the provider's Stellar account is skipped — * the dashboard is the operator's console, not a user-facing API. */ -export const postVerifyHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { nonce, signature, publicKey } = body; +export function handlePostVerify( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postVerify"); - if (!nonce || !signature || !publicKey) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "nonce, signature, and publicKey are required", - }; - return; - } + return async (ctx) => { + log.info("postVerify"); + try { + const body = await ctx.request.body.json(); + const { nonce, signature, publicKey } = body; - // providerPublicKey = publicKey skips the Horizon signer check. - // This is intentional: any wallet can operate the dashboard. - const { token } = await verifyDashboardChallenge( - nonce, - signature, - publicKey, - { - providerPublicKey: publicKey, - generateToken: generateJwt, - }, - ); + if (!nonce || !signature || !publicKey) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "nonce, signature, and publicKey are required", + }; + return; + } - // Create user record on first sign-in - await walletUserRepo.findOrCreate(publicKey); + // providerPublicKey = publicKey skips the Horizon signer check. + // This is intentional: any wallet can operate the dashboard. + const { token } = await verifyDashboardChallenge( + nonce, + signature, + publicKey, + { + providerPublicKey: publicKey, + generateToken: generateJwt, + }, + { log }, + ); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Authentication successful", - data: { token }, - }; - } catch (error) { - LOG.warn("Dashboard auth failed", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.Unauthorized; - ctx.response.body = { - message: "Authentication failed", - }; - } -}; + // Create user record on first sign-in + await walletUserRepo.findOrCreate(publicKey); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Authentication successful", + data: { token }, + }; + } catch (error) { + log.error(error, "dashboard auth failed"); + ctx.response.status = Status.Unauthorized; + ctx.response.body = { + message: "Authentication failed", + }; + } + }; +} diff --git a/src/http/v1/dashboard/bundle-admin.ts b/src/http/v1/dashboard/bundle-admin.ts index ead95f8..e831b54 100644 --- a/src/http/v1/dashboard/bundle-admin.ts +++ b/src/http/v1/dashboard/bundle-admin.ts @@ -3,7 +3,7 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { OperationsBundleRepository } from "@/persistence/drizzle/repository/index.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { getMempool } from "@/core/mempool/index.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; let testBundleRepoOverride: OperationsBundleRepository | null = null; @@ -34,107 +34,115 @@ const MAX_EXPIRE_IDS = 200; * * The age-filter path processes records in 10k batches until completion. */ -export const postExpireBundlesHandler = async (ctx: Context) => { - let body: { olderThanMs?: number; bundleIds?: string[] }; - try { - const raw = await ctx.request.body.json(); - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { +export function handlePostExpireBundles( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postExpireBundles"); + + return async (ctx) => { + log.info("postExpireBundles"); + let body: { olderThanMs?: number; bundleIds?: string[] }; + try { + const raw = await ctx.request.body.json(); + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Body must be a JSON object" }; + return; + } + body = raw; + } catch { ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Body must be a JSON object" }; + ctx.response.body = { message: "Invalid JSON body" }; return; } - body = raw; - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid JSON body" }; - return; - } - const { olderThanMs, bundleIds } = body; + const { olderThanMs, bundleIds } = body; - const hasAgeFilter = typeof olderThanMs === "number" && - Number.isFinite(olderThanMs) && olderThanMs > 0; - const hasIdFilter = Array.isArray(bundleIds) && bundleIds.length > 0; + const hasAgeFilter = typeof olderThanMs === "number" && + Number.isFinite(olderThanMs) && olderThanMs > 0; + const hasIdFilter = Array.isArray(bundleIds) && bundleIds.length > 0; - if (!hasAgeFilter && !hasIdFilter) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: - "Provide at least one of: olderThanMs (positive number) or bundleIds (non-empty array)", - }; - return; - } - - if ( - hasIdFilter && - !bundleIds!.every((id) => typeof id === "string" && id.length > 0) - ) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "All bundleIds must be non-empty strings" }; - return; - } - - if (hasIdFilter && bundleIds!.length > MAX_EXPIRE_IDS) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: `bundleIds may contain at most ${MAX_EXPIRE_IDS} entries, got ${ - bundleIds!.length - }`, - }; - return; - } - - const AGE_FILTER_LIMIT = 10_000; - let ageExpiredCount = 0; - - // 1. Age-filter path: bounded atomic UPDATE batches until done - if (hasAgeFilter) { - const cutoff = new Date(Date.now() - olderThanMs!); - let batch: string[] = []; - do { - batch = await getBundleRepo().expireOlderThan( - cutoff, + if (!hasAgeFilter && !hasIdFilter) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: + "Provide at least one of: olderThanMs (positive number) or bundleIds (non-empty array)", + }; + return; + } + + if ( + hasIdFilter && + !bundleIds!.every((id) => typeof id === "string" && id.length > 0) + ) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "All bundleIds must be non-empty strings", + }; + return; + } + + if (hasIdFilter && bundleIds!.length > MAX_EXPIRE_IDS) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: + `bundleIds may contain at most ${MAX_EXPIRE_IDS} entries, got ${ + bundleIds!.length + }`, + }; + return; + } + + const AGE_FILTER_LIMIT = 10_000; + let ageExpiredCount = 0; + + if (hasAgeFilter) { + const cutoff = new Date(Date.now() - olderThanMs!); + let batch: string[] = []; + do { + batch = await getBundleRepo().expireOlderThan( + cutoff, + ACTIVE_STATUSES, + AGE_FILTER_LIMIT, + ); + ageExpiredCount += batch.length; + if (batch.length > 0) { + getMempool().purgeBundles(batch); + } + } while (batch.length >= AGE_FILTER_LIMIT); + } + + let idExpiredCount = 0; + if (hasIdFilter) { + const idExpiredIds = await getBundleRepo().expireByIds( + bundleIds!, ACTIVE_STATUSES, - AGE_FILTER_LIMIT, ); - ageExpiredCount += batch.length; - if (batch.length > 0) { - getMempool().purgeBundles(batch); + idExpiredCount = idExpiredIds.length; + if (idExpiredCount > 0) { + getMempool().purgeBundles(idExpiredIds); } - } while (batch.length >= AGE_FILTER_LIMIT); - } - - // 2. Explicit-IDs path: DB-first, then mempool-purge. - // expireByIds filters by ACTIVE_STATUSES, so IDs already expired by the age path are safe - // no-ops — no deduplication needed here. - let idExpiredCount = 0; - if (hasIdFilter) { - const idExpiredIds = await getBundleRepo().expireByIds( - bundleIds!, - ACTIVE_STATUSES, - ); - idExpiredCount = idExpiredIds.length; - if (idExpiredCount > 0) { - getMempool().purgeBundles(idExpiredIds); - } - const skipped = bundleIds!.length - idExpiredCount; - if (skipped > 0) { - LOG.warn( - `Admin expire: ${skipped} bundle(s) from bundleIds were not active and were skipped`, - ); + const skipped = bundleIds!.length - idExpiredCount; + if (skipped > 0) { + log.debug("skipped", skipped); + log.event( + "admin expire: some bundle(s) from bundleIds were not active and were skipped", + ); + } } - } - const totalExpired = ageExpiredCount + idExpiredCount; + const totalExpired = ageExpiredCount + idExpiredCount; - LOG.info( - `Admin expire: expired ${totalExpired} bundle(s) (age: ${ageExpiredCount}, ids: ${idExpiredCount})`, - ); + log.debug("totalExpired", totalExpired); + log.debug("ageExpiredCount", ageExpiredCount); + log.debug("idExpiredCount", idExpiredCount); + log.event("admin expire complete"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: `Expired ${totalExpired} bundle(s)`, - data: { expired: totalExpired, truncated: false }, + ctx.response.status = Status.OK; + ctx.response.body = { + message: `Expired ${totalExpired} bundle(s)`, + data: { expired: totalExpired, truncated: false }, + }; }; -}; +} diff --git a/src/http/v1/dashboard/bundles.ts b/src/http/v1/dashboard/bundles.ts index 4c0bf67..c2961dd 100644 --- a/src/http/v1/dashboard/bundles.ts +++ b/src/http/v1/dashboard/bundles.ts @@ -10,7 +10,7 @@ import { PpRepository } from "@/persistence/drizzle/repository/pp.repository.ts" import { operationsBundle } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { account } from "@/persistence/drizzle/entity/account.entity.ts"; import { entity } from "@/persistence/drizzle/entity/entity.entity.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const bundleRepo = new OperationsBundleRepository(drizzleClient); const ppRepo = new PpRepository(drizzleClient); @@ -49,8 +49,6 @@ function aggregateAmountFromMLXDR(mlxdrs: string[]): string | null { withdrawSum += op.getAmount(); hasWithdraw = true; } else if (op.isCreate()) { - // For Send bundles, sum of create-outputs ≈ amount moved. - // (SpendOperation intentionally has no amount — UTXO ref only.) createSum += op.getAmount(); hasCreate = true; } @@ -87,166 +85,178 @@ function classify(op: ParsedOp): OpView { /** * GET /dashboard/bundles/:id * - * Returns one bundle with its decoded operations (kind + addr/amount for - * deposit/withdraw). Used by the provider-console preview table to expand - * a row and show what's inside the bundle. + * Returns one bundle with its decoded operations. */ -export const getBundleDetailHandler = async (ctx: Context) => { - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const bundleId = params?.id; - if (!bundleId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Bundle id is required" }; - return; - } - const bundle = await bundleRepo.findById(bundleId); - if (!bundle) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Bundle not found" }; - return; - } - const operations: OpView[] = []; - for (const mlxdr of bundle.operationsMLXDR) { - try { - const op = MoonlightOperation.fromMLXDR(mlxdr); - operations.push(classify(op)); - } catch (error) { - LOG.warn("Skipping unparseable operation MLXDR", { - error: error instanceof Error ? error.message : String(error), - }); - operations.push({ kind: "unknown" }); +export function handleGetBundleDetail( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getBundleDetail"); + + return async (ctx) => { + log.info("getBundleDetail"); + try { + const params = (ctx as unknown as { params?: RouteParams }).params; + const bundleId = params?.id; + if (!bundleId) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Bundle id is required" }; + return; } - } - let entityName: string | null = null; - let jurisdictions: string[] = []; - if (bundle.createdBy) { - const submitterAccount = await drizzleClient - .select({ entityId: account.entityId }) - .from(account) - .where(eq(account.id, bundle.createdBy)) - .limit(1); - const entityId = submitterAccount[0]?.entityId; - if (entityId) { - const submitterEntity = await drizzleClient - .select({ name: entity.name, jurisdictions: entity.jurisdictions }) - .from(entity) - .where(eq(entity.id, entityId)) + log.debug("bundleId", bundleId); + log.event("loading bundle"); + const bundle = await bundleRepo.findById(bundleId); + if (!bundle) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Bundle not found" }; + return; + } + const operations: OpView[] = []; + for (const mlxdr of bundle.operationsMLXDR) { + try { + const op = MoonlightOperation.fromMLXDR(mlxdr); + operations.push(classify(op)); + } catch (error) { + log.error(error, "skipping unparseable operation MLXDR"); + operations.push({ kind: "unknown" }); + } + } + let entityName: string | null = null; + let jurisdictions: string[] = []; + if (bundle.createdBy) { + log.event("loading submitter entity"); + const submitterAccount = await drizzleClient + .select({ entityId: account.entityId }) + .from(account) + .where(eq(account.id, bundle.createdBy)) .limit(1); - if (submitterEntity[0]) { - entityName = submitterEntity[0].name ?? null; - jurisdictions = submitterEntity[0].jurisdictions ?? []; + const entityId = submitterAccount[0]?.entityId; + if (entityId) { + const submitterEntity = await drizzleClient + .select({ name: entity.name, jurisdictions: entity.jurisdictions }) + .from(entity) + .where(eq(entity.id, entityId)) + .limit(1); + if (submitterEntity[0]) { + entityName = submitterEntity[0].name ?? null; + jurisdictions = submitterEntity[0].jurisdictions ?? []; + } } } + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Bundle detail", + data: { + id: bundle.id, + status: bundle.status, + channelContractId: bundle.channelContractId, + operations, + entityName, + jurisdictions, + amount: aggregateAmountFromMLXDR(bundle.operationsMLXDR), + }, + }; + log.event("bundle detail response assembled"); + } catch (error) { + log.error(error, "failed to fetch bundle detail"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to fetch bundle detail" }; } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Bundle detail", - data: { - id: bundle.id, - status: bundle.status, - channelContractId: bundle.channelContractId, - operations, - entityName, - jurisdictions, - amount: aggregateAmountFromMLXDR(bundle.operationsMLXDR), - }, - }; - } catch (error) { - LOG.error("Failed to fetch bundle detail", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to fetch bundle detail" }; - } -}; + }; +} /** - * GET /dashboard/bundles?limit=N + * GET /dashboard/bundles?ppPublicKey=...&limit=N * - * Returns most-recent bundles (by updatedAt desc) so the dashboard can - * populate the recent-bundles table on initial load instead of waiting - * for new events to stream in. + * Returns most-recent bundles (by updatedAt desc) for the requested PP. */ -export const listRecentBundlesHandler = async (ctx: Context) => { - try { - const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); - if (!ppPublicKey) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "ppPublicKey query parameter is required", - }; - return; - } - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const pp = await ppRepo.findByPublicKeyAndOwner( - ppPublicKey, - ownerPublicKey, - ); - if (!pp) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } +export function handleListRecentBundles( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("listRecentBundles"); - const limitParam = ctx.request.url.searchParams.get("limit"); - const limit = limitParam - ? Math.min(LIST_MAX_LIMIT, Math.max(1, Number(limitParam))) - : LIST_DEFAULT_LIMIT; + return async (ctx) => { + log.info("listRecentBundles"); + try { + const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); + if (!ppPublicKey) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "ppPublicKey query parameter is required", + }; + return; + } + log.debug("ppPublicKey", ppPublicKey); - const windowStart = new Date(Date.now() - LIST_WINDOW_MS); - // URL-scoped bundles: each bundle row carries pp_public_key. A PP only - // sees its own traffic — every status, including FAILED and EXPIRED. - // No cross-PP visibility. - const rows = await drizzleClient - .select({ - id: operationsBundle.id, - status: operationsBundle.status, - channelContractId: operationsBundle.channelContractId, - operationsMLXDR: operationsBundle.operationsMLXDR, - createdAt: operationsBundle.createdAt, - updatedAt: operationsBundle.updatedAt, - entityName: entity.name, - entityJurisdictions: entity.jurisdictions, - }) - .from(operationsBundle) - .leftJoin(account, eq(operationsBundle.createdBy, account.id)) - .leftJoin(entity, eq(account.entityId, entity.id)) - .where( - and( - isNull(operationsBundle.deletedAt), - gte(operationsBundle.updatedAt, windowStart), - eq(operationsBundle.ppPublicKey, ppPublicKey), - ), - ) - .orderBy(desc(operationsBundle.updatedAt)) - .limit(limit); + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + log.event("verifying PP ownership"); + const pp = await ppRepo.findByPublicKeyAndOwner( + ppPublicKey, + ownerPublicKey, + ); + if (!pp) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Provider not found" }; + return; + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Recent bundles", - data: { - bundles: rows.map((r) => ({ - id: r.id, - status: r.status, - channelContractId: r.channelContractId, - entityName: r.entityName, - jurisdictions: r.entityJurisdictions ?? [], - amount: aggregateAmountFromMLXDR(r.operationsMLXDR), - createdAt: r.createdAt instanceof Date - ? r.createdAt.toISOString() - : r.createdAt, - updatedAt: r.updatedAt instanceof Date - ? r.updatedAt.toISOString() - : r.updatedAt, - })), - }, - }; - } catch (error) { - LOG.error("Failed to list bundles", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to list bundles" }; - } -}; + const limitParam = ctx.request.url.searchParams.get("limit"); + const limit = limitParam + ? Math.min(LIST_MAX_LIMIT, Math.max(1, Number(limitParam))) + : LIST_DEFAULT_LIMIT; + log.debug("limit", limit); + + const windowStart = new Date(Date.now() - LIST_WINDOW_MS); + log.event("querying recent bundles for PP"); + const rows = await drizzleClient + .select({ + id: operationsBundle.id, + status: operationsBundle.status, + channelContractId: operationsBundle.channelContractId, + operationsMLXDR: operationsBundle.operationsMLXDR, + createdAt: operationsBundle.createdAt, + updatedAt: operationsBundle.updatedAt, + entityName: entity.name, + entityJurisdictions: entity.jurisdictions, + }) + .from(operationsBundle) + .leftJoin(account, eq(operationsBundle.createdBy, account.id)) + .leftJoin(entity, eq(account.entityId, entity.id)) + .where( + and( + isNull(operationsBundle.deletedAt), + gte(operationsBundle.updatedAt, windowStart), + eq(operationsBundle.ppPublicKey, ppPublicKey), + ), + ) + .orderBy(desc(operationsBundle.updatedAt)) + .limit(limit); + + log.debug("rowCount", rows.length); + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Recent bundles", + data: { + bundles: rows.map((r) => ({ + id: r.id, + status: r.status, + channelContractId: r.channelContractId, + entityName: r.entityName, + jurisdictions: r.entityJurisdictions ?? [], + amount: aggregateAmountFromMLXDR(r.operationsMLXDR), + createdAt: r.createdAt instanceof Date + ? r.createdAt.toISOString() + : r.createdAt, + updatedAt: r.updatedAt instanceof Date + ? r.updatedAt.toISOString() + : r.updatedAt, + })), + }, + }; + log.event("recent bundles response assembled"); + } catch (error) { + log.error(error, "failed to list bundles"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to list bundles" }; + } + }; +} diff --git a/src/http/v1/dashboard/channels.ts b/src/http/v1/dashboard/channels.ts index e0487f4..f6edb78 100644 --- a/src/http/v1/dashboard/channels.ts +++ b/src/http/v1/dashboard/channels.ts @@ -1,5 +1,6 @@ import { type Context, Status } from "@oak/oak"; import { channelRegistry } from "@/core/service/event-watcher/index.ts"; +import type { Logger } from "@/utils/logger/index.ts"; /** * GET /dashboard/channels @@ -8,20 +9,32 @@ import { channelRegistry } from "@/core/service/event-watcher/index.ts"; * States: active (registered + configured), pending (registered, not configured), * inactive (removed on-chain). */ -export const getChannelsHandler = (ctx: Context) => { - const channels = channelRegistry.getAll(); +export function handleGetChannels( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getChannels"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Channels retrieved", - data: { - channels, - summary: { - total: channels.length, - active: channels.filter((c) => c.state === "active").length, - pending: channels.filter((c) => c.state === "pending").length, - inactive: channels.filter((c) => c.state === "inactive").length, + return (ctx) => { + log.info("getChannels"); + + log.event("reading channel registry"); + const channels = channelRegistry.getAll(); + log.debug("channelCount", channels.length); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Channels retrieved", + data: { + channels, + summary: { + total: channels.length, + active: channels.filter((c) => c.state === "active").length, + pending: channels.filter((c) => c.state === "pending").length, + inactive: channels.filter((c) => c.state === "inactive").length, + }, }, - }, + }; + log.event("channels response assembled"); + return Promise.resolve(); }; -}; +} diff --git a/src/http/v1/dashboard/council.ts b/src/http/v1/dashboard/council.ts index 3c12177..eb87a11 100644 --- a/src/http/v1/dashboard/council.ts +++ b/src/http/v1/dashboard/council.ts @@ -8,7 +8,7 @@ import { addProviderAddress, } from "@/core/service/event-watcher/index.ts"; import { MODE, PROVIDER_BASE_URL } from "@/config/env.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; /** Reject URLs targeting internal/private network addresses. Skipped in development mode. */ function isInternalUrl(url: URL): boolean { @@ -58,538 +58,562 @@ const ppRepo = new PpRepository(drizzleClient); * POST /dashboard/council/discover * Fetches council info from the council-platform's public API. */ -export const discoverCouncilHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { councilUrl } = body; - - if (!councilUrl || typeof councilUrl !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilUrl is required" }; - return; - } +export function handleDiscoverCouncil( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("discoverCouncil"); - // Validate URL format — must be HTTP(S) - let parsed: URL; + return async (ctx) => { + log.info("discoverCouncil"); try { - parsed = new URL(councilUrl); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("bad protocol"); + const body = await ctx.request.body.json(); + const { councilUrl } = body; + + if (!councilUrl || typeof councilUrl !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "councilUrl is required" }; + return; } - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilUrl must be a valid HTTP(S) URL" }; - return; - } - if (isInternalUrl(parsed)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "councilUrl must not target internal addresses", - }; - return; - } + // Validate URL format — must be HTTP(S) + let parsed: URL; + try { + parsed = new URL(councilUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("bad protocol"); + } + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "councilUrl must be a valid HTTP(S) URL", + }; + return; + } - // Parse the URL to extract the base and optional council ID. - // The council ID can be in the query string (?council=C...) or in a - // hash fragment (#/join?council=C...) — the latter is the format used - // by council-console join links. URL strips fragments, so we extract - // it from the raw input first. - let councilId = parsed.searchParams.get("council"); - if (!councilId) { - const hashMatch = councilUrl.match(/[#?&]council=([A-Z0-9]+)/); - if (hashMatch) councilId = hashMatch[1]; - } - const baseUrl = `${parsed.origin}`; - const councilQs = councilId - ? `?councilId=${encodeURIComponent(councilId)}` - : ""; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10_000); - let res: Response; - try { - res = await fetch(`${baseUrl}/api/v1/public/council${councilQs}`, { - signal: controller.signal, - }); - } catch (err) { + if (isInternalUrl(parsed)) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "councilUrl must not target internal addresses", + }; + return; + } + + // Parse the URL to extract the base and optional council ID. + // The council ID can be in the query string (?council=C...) or in a + // hash fragment (#/join?council=C...) — the latter is the format used + // by council-console join links. URL strips fragments, so we extract + // it from the raw input first. + let councilId = parsed.searchParams.get("council"); + if (!councilId) { + const hashMatch = councilUrl.match(/[#?&]council=([A-Z0-9]+)/); + if (hashMatch) councilId = hashMatch[1]; + } + const baseUrl = `${parsed.origin}`; + const councilQs = councilId + ? `?councilId=${encodeURIComponent(councilId)}` + : ""; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + let res: Response; + try { + res = await fetch(`${baseUrl}/api/v1/public/council${councilQs}`, { + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timeoutId); + if (err instanceof DOMException && err.name === "AbortError") { + ctx.response.status = Status.GatewayTimeout; + ctx.response.body = { message: "Council request timed out" }; + return; + } + throw err; + } clearTimeout(timeoutId); - if (err instanceof DOMException && err.name === "AbortError") { - ctx.response.status = Status.GatewayTimeout; - ctx.response.body = { message: "Council request timed out" }; + + const contentLength = parseInt( + res.headers.get("Content-Length") ?? "0", + 10, + ); + if (contentLength > 1_048_576) { + await res.body?.cancel(); + ctx.response.status = Status.BadGateway; + ctx.response.body = { message: "Council response too large" }; return; } - throw err; - } - clearTimeout(timeoutId); - - const contentLength = parseInt( - res.headers.get("Content-Length") ?? "0", - 10, - ); - if (contentLength > 1_048_576) { - await res.body?.cancel(); - ctx.response.status = Status.BadGateway; - ctx.response.body = { message: "Council response too large" }; - return; - } - if (!res.ok) { - ctx.response.status = Status.BadGateway; - ctx.response.body = { - message: `Failed to reach council: HTTP ${res.status}`, - }; - return; - } + if (!res.ok) { + ctx.response.status = Status.BadGateway; + ctx.response.body = { + message: `Failed to reach council: HTTP ${res.status}`, + }; + return; + } - const { data } = await res.json(); + const { data } = await res.json(); - if (!data?.council) { - ctx.response.status = Status.BadGateway; - ctx.response.body = { message: "Council not found at this URL" }; - return; - } + if (!data?.council) { + ctx.response.status = Status.BadGateway; + ctx.response.body = { message: "Council not found at this URL" }; + return; + } + + // If a specific council ID was in the URL, verify it matches + if (councilId && data.council.channelAuthId !== councilId) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: + "Council ID in URL does not match the council at this endpoint", + }; + return; + } - // If a specific council ID was in the URL, verify it matches - if (councilId && data.council.channelAuthId !== councilId) { - ctx.response.status = Status.BadRequest; + ctx.response.status = Status.OK; ctx.response.body = { - message: - "Council ID in URL does not match the council at this endpoint", + message: "Council discovered", + data: { + councilUrl: baseUrl, + council: data.council, + jurisdictions: data.jurisdictions, + channels: data.channels, + providers: data.providers, + }, }; - return; + } catch (error) { + log.error(error, "council discovery failed"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to discover council" }; } - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Council discovered", - data: { - councilUrl: baseUrl, - council: data.council, - jurisdictions: data.jurisdictions, - channels: data.channels, - providers: data.providers, - }, - }; - } catch (error) { - LOG.error("Council discovery failed", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to discover council" }; - } -}; + }; +} /** * POST /dashboard/council/join - * Submits a signed join request to the council-platform. - * Requires ppPublicKey to identify which PP is joining. */ -export const joinCouncilHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { - councilUrl, - councilId: bodyCouncilId, - councilName, - councilPublicKey, - ppPublicKey, - } = body; - - const envelopeJurisdictions = (body.signedEnvelope as - | { payload?: { jurisdictions?: unknown } } - | undefined)?.payload?.jurisdictions; - const claimedJurisdictions: string | null = - Array.isArray(envelopeJurisdictions) && envelopeJurisdictions.length > 0 - ? JSON.stringify(envelopeJurisdictions) - : null; - - if (!councilUrl || typeof councilUrl !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilUrl is required" }; - return; - } - - if (!ppPublicKey || typeof ppPublicKey !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "ppPublicKey is required" }; - return; - } +export function handleJoinCouncil( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("joinCouncil"); - // SSRF protection + return async (ctx) => { + log.info("joinCouncil"); try { - const parsedUrl = new URL(councilUrl); - if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + const body = await ctx.request.body.json(); + const { + councilUrl, + councilId: bodyCouncilId, + councilName, + councilPublicKey, + ppPublicKey, + } = body; + + const envelopeJurisdictions = (body.signedEnvelope as + | { payload?: { jurisdictions?: unknown } } + | undefined)?.payload?.jurisdictions; + const claimedJurisdictions: string | null = + Array.isArray(envelopeJurisdictions) && envelopeJurisdictions.length > 0 + ? JSON.stringify(envelopeJurisdictions) + : null; + + if (!councilUrl || typeof councilUrl !== "string") { ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "councilUrl is required" }; + return; + } + + if (!ppPublicKey || typeof ppPublicKey !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "ppPublicKey is required" }; + return; + } + + // SSRF protection + try { + const parsedUrl = new URL(councilUrl); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "councilUrl must be a valid HTTP(S) URL", + }; + return; + } + if (isInternalUrl(parsedUrl)) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "councilUrl must not target internal addresses", + }; + return; + } + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "councilUrl must be a valid URL" }; + return; + } + + // Verify PP is registered and owned by this user + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const pp = await ppRepo.findByPublicKeyAndOwner( + ppPublicKey, + ownerPublicKey, + ); + if (!pp) { + ctx.response.status = Status.NotFound; ctx.response.body = { - message: "councilUrl must be a valid HTTP(S) URL", + message: "PP not registered. Register it first.", + }; + return; + } + + const baseUrl = councilUrl.replace(/\/+$/, ""); + + // Check if this PP already has a membership for this council + const existing = await membershipRepo.findByCouncilUrlAndPp( + baseUrl, + ppPublicKey, + ); + if (existing) { + ctx.response.status = Status.Conflict; + ctx.response.body = { + message: + `This PP is already ${existing.status.toLowerCase()} for this council`, + data: { status: existing.status }, }; return; } - if (isInternalUrl(parsedUrl)) { + + if (!bodyCouncilId || typeof bodyCouncilId !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "councilId is required" }; + return; + } + const councilId = bodyCouncilId; + + // The client must provide a pre-signed join request envelope + const { signedEnvelope } = body; + if ( + !signedEnvelope || !signedEnvelope.payload || + !signedEnvelope.signature || + !signedEnvelope.publicKey + ) { ctx.response.status = Status.BadRequest; ctx.response.body = { - message: "councilUrl must not target internal addresses", + message: + "signedEnvelope is required (payload, signature, publicKey, timestamp)", }; return; } - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilUrl must be a valid URL" }; - return; - } - // Verify PP is registered and owned by this user - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const pp = await ppRepo.findByPublicKeyAndOwner( - ppPublicKey, - ownerPublicKey, - ); - if (!pp) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "PP not registered. Register it first." }; - return; - } + // Verify the envelope's publicKey matches the claimed ppPublicKey + if (signedEnvelope.publicKey !== ppPublicKey) { + log.debug("envelopePublicKey", signedEnvelope.publicKey); + log.debug("ppPublicKey", ppPublicKey); + log.error( + new Error("publicKey mismatch"), + "join request publicKey mismatch", + ); + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "signedEnvelope publicKey does not match ppPublicKey", + }; + return; + } - const baseUrl = councilUrl.replace(/\/+$/, ""); + // Relay the pre-signed envelope to the council-platform + const joinController = new AbortController(); + const joinTimeoutId = setTimeout(() => joinController.abort(), 10_000); + let res: Response; + try { + res = await fetch(`${baseUrl}/api/v1/public/provider/join-request`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...signedEnvelope, + providerUrl: PROVIDER_BASE_URL, + }), + signal: joinController.signal, + }); + } catch (err) { + clearTimeout(joinTimeoutId); + if (err instanceof DOMException && err.name === "AbortError") { + ctx.response.status = Status.GatewayTimeout; + ctx.response.body = { message: "Council join request timed out" }; + return; + } + throw err; + } + clearTimeout(joinTimeoutId); - // Check if this PP already has a membership for this council - const existing = await membershipRepo.findByCouncilUrlAndPp( - baseUrl, - ppPublicKey, - ); - if (existing) { - ctx.response.status = Status.Conflict; - ctx.response.body = { - message: - `This PP is already ${existing.status.toLowerCase()} for this council`, - data: { status: existing.status }, - }; - return; - } + if (res.status === 409) { + ctx.response.status = Status.Conflict; + ctx.response.body = { + message: "A pending request already exists for this provider", + }; + return; + } - if (!bodyCouncilId || typeof bodyCouncilId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "councilId is required" }; - return; - } - const councilId = bodyCouncilId; - - // The client must provide a pre-signed join request envelope - const { signedEnvelope } = body; - if ( - !signedEnvelope || !signedEnvelope.payload || !signedEnvelope.signature || - !signedEnvelope.publicKey - ) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: - "signedEnvelope is required (payload, signature, publicKey, timestamp)", - }; - return; - } + if (!res.ok) { + await res.body?.cancel(); + ctx.response.status = Status.BadGateway; + ctx.response.body = { message: "Council rejected the request" }; + return; + } + + const { data: responseData } = await res.json(); - // Verify the envelope's publicKey matches the claimed ppPublicKey - if (signedEnvelope.publicKey !== ppPublicKey) { - LOG.warn("Join request publicKey mismatch", { - envelopePublicKey: signedEnvelope.publicKey, + // Create membership record scoped to this PP + await membershipRepo.create({ + id: crypto.randomUUID(), + councilUrl: baseUrl, + councilName: councilName ?? null, + councilPublicKey: councilPublicKey ?? "", + channelAuthId: councilId, + status: CouncilMembershipStatus.PENDING, + claimedJurisdictions, + joinRequestId: responseData?.id ?? null, ppPublicKey, + createdAt: new Date(), + updatedAt: new Date(), }); - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "signedEnvelope publicKey does not match ppPublicKey", - }; - return; - } - // Relay the pre-signed envelope to the council-platform - const joinController = new AbortController(); - const joinTimeoutId = setTimeout(() => joinController.abort(), 10_000); - let res: Response; - try { - res = await fetch(`${baseUrl}/api/v1/public/provider/join-request`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...signedEnvelope, - providerUrl: PROVIDER_BASE_URL, - }), - signal: joinController.signal, - }); - } catch (err) { - clearTimeout(joinTimeoutId); - if (err instanceof DOMException && err.name === "AbortError") { - ctx.response.status = Status.GatewayTimeout; - ctx.response.body = { message: "Council join request timed out" }; - return; - } - throw err; - } - clearTimeout(joinTimeoutId); + // Start watching this council's Channel Auth contract for provider_added/removed events + addCouncilWatcher(councilId); + addProviderAddress(ppPublicKey); - if (res.status === 409) { - ctx.response.status = Status.Conflict; + log.debug("councilUrl", baseUrl); + log.debug("ppPublicKey", ppPublicKey); + log.debug("joinRequestId", responseData?.id); + log.event("join request submitted to council"); + + ctx.response.status = Status.OK; ctx.response.body = { - message: "A pending request already exists for this provider", + message: "Join request submitted", + data: { + joinRequestId: responseData?.id, + status: "PENDING", + }, }; - return; + } catch (error) { + log.error(error, "failed to join council"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to submit join request" }; } - - if (!res.ok) { - await res.body?.cancel(); - ctx.response.status = Status.BadGateway; - ctx.response.body = { message: "Council rejected the request" }; - return; - } - - const { data: responseData } = await res.json(); - - // Create membership record scoped to this PP - await membershipRepo.create({ - id: crypto.randomUUID(), - councilUrl: baseUrl, - councilName: councilName ?? null, - councilPublicKey: councilPublicKey ?? "", - channelAuthId: councilId, - status: CouncilMembershipStatus.PENDING, - claimedJurisdictions, - joinRequestId: responseData?.id ?? null, - ppPublicKey, - createdAt: new Date(), - updatedAt: new Date(), - }); - - // Start watching this council's Channel Auth contract for provider_added/removed events - addCouncilWatcher(councilId); - addProviderAddress(ppPublicKey); - - LOG.info("Join request submitted to council", { - councilUrl: baseUrl, - ppPublicKey, - joinRequestId: responseData?.id, - }); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Join request submitted", - data: { - joinRequestId: responseData?.id, - status: "PENDING", - }, - }; - } catch (error) { - LOG.error("Failed to join council", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to submit join request" }; - } -}; + }; +} /** * GET /dashboard/council/membership - * Returns council membership for a specific PP. - * Query: ?ppPublicKey=G... */ -export const getMembershipHandler = async (ctx: Context) => { - try { - const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); +export function handleGetMembership( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getMembership"); - if (!ppPublicKey) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "ppPublicKey query parameter is required", - }; - return; - } + return async (ctx) => { + log.info("getMembership"); + try { + const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); - // Verify PP ownership - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const pp = await ppRepo.findByPublicKeyAndOwner( - ppPublicKey, - ownerPublicKey, - ); - if (!pp) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } + if (!ppPublicKey) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "ppPublicKey query parameter is required", + }; + return; + } + + // Verify PP ownership + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const pp = await ppRepo.findByPublicKeyAndOwner( + ppPublicKey, + ownerPublicKey, + ); + if (!pp) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Provider not found" }; + return; + } - const membership = await membershipRepo.getCurrentForPp(ppPublicKey); + const membership = await membershipRepo.getCurrentForPp(ppPublicKey); + + if (!membership) { + ctx.response.status = Status.OK; + ctx.response.body = { + message: "No council membership", + data: null, + }; + return; + } - if (!membership) { ctx.response.status = Status.OK; ctx.response.body = { - message: "No council membership", - data: null, + message: "Council membership", + data: { + id: membership.id, + councilUrl: membership.councilUrl, + councilName: membership.councilName, + councilPublicKey: membership.councilPublicKey, + channelAuthId: membership.channelAuthId, + status: membership.status, + config: membership.configJson + ? (() => { + try { + return JSON.parse(membership.configJson); + } catch { + return null; + } + })() + : null, + joinRequestId: membership.joinRequestId, + ppPublicKey: membership.ppPublicKey, + createdAt: membership.createdAt.toISOString(), + }, }; - return; + } catch (error) { + log.error(error, "failed to get membership"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to retrieve membership" }; } - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Council membership", - data: { - id: membership.id, - councilUrl: membership.councilUrl, - councilName: membership.councilName, - councilPublicKey: membership.councilPublicKey, - channelAuthId: membership.channelAuthId, - status: membership.status, - config: membership.configJson - ? (() => { - try { - return JSON.parse(membership.configJson); - } catch { - return null; - } - })() - : null, - joinRequestId: membership.joinRequestId, - ppPublicKey: membership.ppPublicKey, - createdAt: membership.createdAt.toISOString(), - }, - }; - } catch (error) { - LOG.error("Failed to get membership", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve membership" }; - } -}; + }; +} /** * POST /dashboard/council/membership - * Syncs a PP's membership status by querying the council's public endpoint. - * Updates the local DB if the status has changed. - * Body: { ppPublicKey: string } */ -export const syncMembershipHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { ppPublicKey } = body; - - if (!ppPublicKey || typeof ppPublicKey !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "ppPublicKey is required" }; - return; - } +export function handleSyncMembership( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("syncMembership"); - // Verify PP ownership - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const pp = await ppRepo.findByPublicKeyAndOwner( - ppPublicKey, - ownerPublicKey, - ); - if (!pp) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } + return async (ctx) => { + log.info("syncMembership"); + try { + const body = await ctx.request.body.json(); + const { ppPublicKey } = body; - const membership = await membershipRepo.getCurrentForPp(ppPublicKey); - if (!membership) { - ctx.response.status = Status.OK; - ctx.response.body = { message: "No membership", data: { status: null } }; - return; - } + if (!ppPublicKey || typeof ppPublicKey !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "ppPublicKey is required" }; + return; + } - // Query the council's public membership-status endpoint - const { councilUrl, channelAuthId } = membership; - let remoteStatus: number | null = null; - let remoteBody: { status?: string } | null = null; - try { - const res = await fetch( - `${councilUrl}/api/v1/public/provider/membership-status?councilId=${ - encodeURIComponent(channelAuthId) - }&publicKey=${encodeURIComponent(ppPublicKey)}`, - ); - remoteStatus = res.status; - try { - remoteBody = await res.json(); - } catch { /* body may not be JSON */ } - } catch (err) { - LOG.warn("Failed to query council membership status", { - councilUrl, - channelAuthId, + // Verify PP ownership + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const pp = await ppRepo.findByPublicKeyAndOwner( ppPublicKey, - error: err instanceof Error ? err.message : String(err), - }); - } + ownerPublicKey, + ); + if (!pp) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Provider not found" }; + return; + } - if ( - remoteStatus === 200 && - membership.status !== CouncilMembershipStatus.ACTIVE - ) { - // Fetch config from council - let configJson: string | null = membership.configJson; - let councilName = membership.councilName; + const membership = await membershipRepo.getCurrentForPp(ppPublicKey); + if (!membership) { + ctx.response.status = Status.OK; + ctx.response.body = { + message: "No membership", + data: { status: null }, + }; + return; + } + + // Query the council's public membership-status endpoint + const { councilUrl, channelAuthId } = membership; + let remoteStatus: number | null = null; + let remoteBody: { status?: string } | null = null; try { - const configRes = await fetch( - `${councilUrl}/api/v1/public/council?councilId=${ + const res = await fetch( + `${councilUrl}/api/v1/public/provider/membership-status?councilId=${ encodeURIComponent(channelAuthId) - }`, + }&publicKey=${encodeURIComponent(ppPublicKey)}`, ); - if (configRes.ok) { - const { data } = await configRes.json(); - configJson = JSON.stringify(data); - councilName = data.council?.name ?? councilName; - } - } catch { /* best effort */ } - - await membershipRepo.update(membership.id, { - status: CouncilMembershipStatus.ACTIVE, - configJson, - councilName, - }); - LOG.info("Membership synced to ACTIVE", { ppPublicKey, channelAuthId }); + remoteStatus = res.status; + try { + remoteBody = await res.json(); + } catch { /* body may not be JSON */ } + } catch (err) { + log.debug("councilUrl", councilUrl); + log.debug("channelAuthId", channelAuthId); + log.debug("ppPublicKey", ppPublicKey); + log.error(err, "failed to query council membership status"); + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Membership synced", - data: { status: "ACTIVE" }, - }; - return; - } + if ( + remoteStatus === 200 && + membership.status !== CouncilMembershipStatus.ACTIVE + ) { + // Fetch config from council + let configJson: string | null = membership.configJson; + let councilName = membership.councilName; + try { + const configRes = await fetch( + `${councilUrl}/api/v1/public/council?councilId=${ + encodeURIComponent(channelAuthId) + }`, + ); + if (configRes.ok) { + const { data } = await configRes.json(); + configJson = JSON.stringify(data); + councilName = data.council?.name ?? councilName; + } + } catch { /* best effort */ } + + await membershipRepo.update(membership.id, { + status: CouncilMembershipStatus.ACTIVE, + configJson, + councilName, + }); + log.debug("ppPublicKey", ppPublicKey); + log.debug("channelAuthId", channelAuthId); + log.event("membership synced to ACTIVE"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Membership synced", + data: { status: "ACTIVE" }, + }; + return; + } - // Treat as REJECTED when the council explicitly says so, OR when the - // council returns NOT_FOUND (404) for a locally PENDING membership — - // the request is no longer pending on their side, so it was rejected. - // (The public endpoint uses 404 + NOT_FOUND for absent providers by design, - // to limit enumeration; local PENDING + that response implies a rejected join.) - const isExplicitReject = remoteBody?.status === "REJECTED"; - const isImplicitReject = remoteStatus === 404 && - remoteBody?.status === "NOT_FOUND" && - membership.status === CouncilMembershipStatus.PENDING; - - if ( - (isExplicitReject || isImplicitReject) && - membership.status !== CouncilMembershipStatus.REJECTED - ) { - await membershipRepo.update(membership.id, { - status: CouncilMembershipStatus.REJECTED, - }); - LOG.info("Membership synced to REJECTED", { ppPublicKey, channelAuthId }); + // Treat as REJECTED when the council explicitly says so, OR when the + // council returns NOT_FOUND (404) for a locally PENDING membership — + // the request is no longer pending on their side, so it was rejected. + // (The public endpoint uses 404 + NOT_FOUND for absent providers by design, + // to limit enumeration; local PENDING + that response implies a rejected join.) + const isExplicitReject = remoteBody?.status === "REJECTED"; + const isImplicitReject = remoteStatus === 404 && + remoteBody?.status === "NOT_FOUND" && + membership.status === CouncilMembershipStatus.PENDING; + + if ( + (isExplicitReject || isImplicitReject) && + membership.status !== CouncilMembershipStatus.REJECTED + ) { + await membershipRepo.update(membership.id, { + status: CouncilMembershipStatus.REJECTED, + }); + log.debug("ppPublicKey", ppPublicKey); + log.debug("channelAuthId", channelAuthId); + log.event("membership synced to REJECTED"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Membership synced", + data: { status: "REJECTED" }, + }; + return; + } ctx.response.status = Status.OK; ctx.response.body = { - message: "Membership synced", - data: { status: "REJECTED" }, + message: "Membership unchanged", + data: { status: membership.status }, }; - return; + } catch (error) { + log.error(error, "failed to sync membership"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to sync membership" }; } - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Membership unchanged", - data: { status: membership.status }, - }; - } catch (error) { - LOG.error("Failed to sync membership", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to sync membership" }; - } -}; + }; +} diff --git a/src/http/v1/dashboard/council_test.ts b/src/http/v1/dashboard/council_test.ts index b126a93..6d8d469 100644 --- a/src/http/v1/dashboard/council_test.ts +++ b/src/http/v1/dashboard/council_test.ts @@ -32,7 +32,8 @@ function createMockContext(body: unknown): any { let discoverCouncilHandler: ((ctx: any) => Promise) | null = null; try { const mod = await import("./council.ts"); - discoverCouncilHandler = mod.discoverCouncilHandler; + const { newNoop } = await import("@/utils/logger/index.ts"); + discoverCouncilHandler = mod.handleDiscoverCouncil({ log: newNoop() }); } catch { // DB not available — skip handler tests } diff --git a/src/http/v1/dashboard/mempool.ts b/src/http/v1/dashboard/mempool.ts index 9d03984..e6db0e9 100644 --- a/src/http/v1/dashboard/mempool.ts +++ b/src/http/v1/dashboard/mempool.ts @@ -10,6 +10,7 @@ import { MEMPOOL_TTL_CHECK_INTERVAL_MS, MEMPOOL_VERIFIER_INTERVAL_MS, } from "@/config/env.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const metricRepo = new MempoolMetricRepository(drizzleClient); @@ -19,38 +20,50 @@ const metricRepo = new MempoolMetricRepository(drizzleClient); * Returns live mempool state, historical averages, and configuration. * Averages are computed from the last hour of metric snapshots. */ -export const getMempoolHandler = async (ctx: Context) => { - const mempool = getMempool(); - const stats = mempool.getStats(); +export function handleGetMempool( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getMempool"); - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); - const averages = await metricRepo.getAveragesSince(oneHourAgo); + return async (ctx) => { + log.info("getMempool"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Mempool state retrieved", - data: { - platformVersion, - live: stats, - averages: { - windowMinutes: 60, - sampleCount: averages.sampleCount, - avgQueueDepth: round(averages.avgQueueDepth), - avgSlotCount: round(averages.avgSlotCount), - avgProcessingMs: round(averages.avgProcessingMs), - avgThroughputPerMin: round(averages.avgThroughputPerMin), - }, - config: { - slotCapacity: MEMPOOL_SLOT_CAPACITY, - expensiveOpWeight: MEMPOOL_EXPENSIVE_OP_WEIGHT, - cheapOpWeight: MEMPOOL_CHEAP_OP_WEIGHT, - executorIntervalMs: MEMPOOL_EXECUTOR_INTERVAL_MS, - verifierIntervalMs: MEMPOOL_VERIFIER_INTERVAL_MS, - ttlCheckIntervalMs: MEMPOOL_TTL_CHECK_INTERVAL_MS, + log.event("reading live mempool stats"); + const mempool = getMempool(); + const stats = mempool.getStats(); + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + log.event("fetching historical averages"); + const averages = await metricRepo.getAveragesSince(oneHourAgo); + log.debug("sampleCount", averages.sampleCount); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Mempool state retrieved", + data: { + platformVersion, + live: stats, + averages: { + windowMinutes: 60, + sampleCount: averages.sampleCount, + avgQueueDepth: round(averages.avgQueueDepth), + avgSlotCount: round(averages.avgSlotCount), + avgProcessingMs: round(averages.avgProcessingMs), + avgThroughputPerMin: round(averages.avgThroughputPerMin), + }, + config: { + slotCapacity: MEMPOOL_SLOT_CAPACITY, + expensiveOpWeight: MEMPOOL_EXPENSIVE_OP_WEIGHT, + cheapOpWeight: MEMPOOL_CHEAP_OP_WEIGHT, + executorIntervalMs: MEMPOOL_EXECUTOR_INTERVAL_MS, + verifierIntervalMs: MEMPOOL_VERIFIER_INTERVAL_MS, + ttlCheckIntervalMs: MEMPOOL_TTL_CHECK_INTERVAL_MS, + }, }, - }, + }; + log.event("mempool response assembled"); }; -}; +} function round(n: number, decimals = 2): number { const factor = 10 ** decimals; diff --git a/src/http/v1/dashboard/metrics.ts b/src/http/v1/dashboard/metrics.ts index ed36574..d146ff6 100644 --- a/src/http/v1/dashboard/metrics.ts +++ b/src/http/v1/dashboard/metrics.ts @@ -2,6 +2,7 @@ import type { Context } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { MempoolMetricRepository } from "@/persistence/drizzle/repository/mempool-metric.repository.ts"; import { PpRepository } from "@/persistence/drizzle/repository/pp.repository.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const DEFAULT_RANGE_MIN = 60; // 1h at 60s snapshots const MAX_RANGE_MIN = 10_080; // matches MetricsCollector retention (7 days) @@ -27,54 +28,77 @@ function parseRangeMin(raw: string | null): number | null { return Math.min(parsed, MAX_RANGE_MIN); } -export async function getMetricsHandler(ctx: Context): Promise { - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); - if (!ppPublicKey) { - ctx.response.status = 400; - ctx.response.body = { error: "Missing required query param: ppPublicKey" }; - return; - } +export function handleGetMetrics( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getMetrics"); - const rangeMin = parseRangeMin( - ctx.request.url.searchParams.get("rangeMin"), - ); - if (rangeMin === null) { - ctx.response.status = 400; - ctx.response.body = { - error: "rangeMin must be a positive integer", - }; - return; - } + return async (ctx) => { + log.info("getMetrics"); + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); + log.debug("ppPublicKey", ppPublicKey); - const pp = await ppRepo.findByPublicKeyAndOwner(ppPublicKey, ownerPublicKey); - if (!pp) { - ctx.response.status = 403; - ctx.response.body = { error: "PP not owned by authenticated operator" }; - return; - } + if (!ppPublicKey) { + ctx.response.status = 400; + ctx.response.body = { + error: "Missing required query param: ppPublicKey", + }; + return; + } - const since = new Date(Date.now() - rangeMin * 60_000); - const rows = await metricRepo.findRecentForPp(ppPublicKey, since, rangeMin); + const rangeMin = parseRangeMin( + ctx.request.url.searchParams.get("rangeMin"), + ); + log.debug("rangeMin", rangeMin); + if (rangeMin === null) { + ctx.response.status = 400; + ctx.response.body = { + error: "rangeMin must be a positive integer", + }; + return; + } - ctx.response.status = 200; - ctx.response.body = { - data: { + log.event("verifying PP ownership"); + const pp = await ppRepo.findByPublicKeyAndOwner( ppPublicKey, + ownerPublicKey, + ); + if (!pp) { + ctx.response.status = 403; + ctx.response.body = { error: "PP not owned by authenticated operator" }; + return; + } + + const since = new Date(Date.now() - rangeMin * 60_000); + log.event("fetching metric snapshots"); + const rows = await metricRepo.findRecentForPp( + ppPublicKey, + since, rangeMin, - since: since.toISOString(), - snapshots: rows.map((row) => ({ - recordedAt: row.recordedAt, - platformVersion: row.platformVersion, - queueDepth: row.queueDepth, - slotCount: row.slotCount, - bundlesCompleted: row.bundlesCompleted, - bundlesExpired: row.bundlesExpired, - bundlesFailed: row.bundlesFailed, - avgProcessingMs: row.avgProcessingMs, - p95ProcessingMs: row.p95ProcessingMs, - throughputPerMin: row.throughputPerMin, - })), - }, + ); + log.debug("snapshotCount", rows.length); + + ctx.response.status = 200; + ctx.response.body = { + data: { + ppPublicKey, + rangeMin, + since: since.toISOString(), + snapshots: rows.map((row) => ({ + recordedAt: row.recordedAt, + platformVersion: row.platformVersion, + queueDepth: row.queueDepth, + slotCount: row.slotCount, + bundlesCompleted: row.bundlesCompleted, + bundlesExpired: row.bundlesExpired, + bundlesFailed: row.bundlesFailed, + avgProcessingMs: row.avgProcessingMs, + p95ProcessingMs: row.p95ProcessingMs, + throughputPerMin: row.throughputPerMin, + })), + }, + }; + log.event("metrics response assembled"); }; } diff --git a/src/http/v1/dashboard/operations.ts b/src/http/v1/dashboard/operations.ts index c30514f..fcdf977 100644 --- a/src/http/v1/dashboard/operations.ts +++ b/src/http/v1/dashboard/operations.ts @@ -6,6 +6,7 @@ import { } from "@/persistence/drizzle/repository/index.ts"; import { BundleStatus } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { TransactionStatus } from "@/persistence/drizzle/entity/transaction.entity.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const bundleRepo = new OperationsBundleRepository(drizzleClient); const txRepo = new TransactionRepository(); @@ -16,46 +17,59 @@ const txRepo = new TransactionRepository(); * Returns bundle processing stats: counts by status, success/failure rates. * Uses COUNT(*) queries instead of loading all rows. */ -export const getOperationsHandler = async (ctx: Context) => { - const [pending, processing, completed, expired] = await Promise.all([ - bundleRepo.countByStatus(BundleStatus.PENDING), - bundleRepo.countByStatus(BundleStatus.PROCESSING), - bundleRepo.countByStatus(BundleStatus.COMPLETED), - bundleRepo.countByStatus(BundleStatus.EXPIRED), - ]); +export function handleGetOperations( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getOperations"); - const [verified, failed, unverified] = await Promise.all([ - txRepo.countByStatus(TransactionStatus.VERIFIED), - txRepo.countByStatus(TransactionStatus.FAILED), - txRepo.countByStatus(TransactionStatus.UNVERIFIED), - ]); + return async (ctx) => { + log.info("getOperations"); - const totalBundles = pending + processing + completed + expired; - const totalTransactions = verified + failed + unverified; + log.event("counting bundle statuses"); + const [pending, processing, completed, expired] = await Promise.all([ + bundleRepo.countByStatus(BundleStatus.PENDING), + bundleRepo.countByStatus(BundleStatus.PROCESSING), + bundleRepo.countByStatus(BundleStatus.COMPLETED), + bundleRepo.countByStatus(BundleStatus.EXPIRED), + ]); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Operations stats retrieved", - data: { - bundles: { - total: totalBundles, - pending, - processing, - completed, - expired, - successRate: totalBundles > 0 - ? ((completed / totalBundles) * 100).toFixed(1) + "%" - : "N/A", - }, - transactions: { - total: totalTransactions, - verified, - failed, - unverified, - successRate: totalTransactions > 0 - ? ((verified / totalTransactions) * 100).toFixed(1) + "%" - : "N/A", + log.event("counting transaction statuses"); + const [verified, failed, unverified] = await Promise.all([ + txRepo.countByStatus(TransactionStatus.VERIFIED), + txRepo.countByStatus(TransactionStatus.FAILED), + txRepo.countByStatus(TransactionStatus.UNVERIFIED), + ]); + + const totalBundles = pending + processing + completed + expired; + const totalTransactions = verified + failed + unverified; + log.debug("totalBundles", totalBundles); + log.debug("totalTransactions", totalTransactions); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Operations stats retrieved", + data: { + bundles: { + total: totalBundles, + pending, + processing, + completed, + expired, + successRate: totalBundles > 0 + ? ((completed / totalBundles) * 100).toFixed(1) + "%" + : "N/A", + }, + transactions: { + total: totalTransactions, + verified, + failed, + unverified, + successRate: totalTransactions > 0 + ? ((verified / totalTransactions) * 100).toFixed(1) + "%" + : "N/A", + }, }, - }, + }; + log.event("operations response assembled"); }; -}; +} diff --git a/src/http/v1/dashboard/pp.ts b/src/http/v1/dashboard/pp.ts index bb03d83..4700df4 100644 --- a/src/http/v1/dashboard/pp.ts +++ b/src/http/v1/dashboard/pp.ts @@ -9,226 +9,239 @@ import { removeProviderAddress, } from "@/core/service/event-watcher/index.ts"; import { SERVICE_AUTH_SECRET } from "@/config/env.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const ppRepo = new PpRepository(drizzleClient); const membershipRepo = new CouncilMembershipRepository(drizzleClient); /** * POST /dashboard/pp/register - * Registers a new PP. Encrypts the secret key and stores it. - * Adds the provider address to the event watcher. */ -export const registerPpHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { secretKey, derivationIndex, label } = body; - - if (!secretKey || typeof secretKey !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "secretKey is required" }; - return; - } +export function handleRegisterPp( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("registerPp"); - let publicKey: string; + return async (ctx) => { + log.info("registerPp"); try { - publicKey = Keypair.fromSecret(secretKey).publicKey(); - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid Stellar secret key" }; - return; - } + const body = await ctx.request.body.json(); + const { secretKey, derivationIndex, label } = body; - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + if (!secretKey || typeof secretKey !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "secretKey is required" }; + return; + } - // Check if already registered - const existing = await ppRepo.findByPublicKey(publicKey); - if (existing) { - if ( - existing.ownerPublicKey && existing.ownerPublicKey !== ownerPublicKey - ) { - ctx.response.status = Status.Forbidden; + let publicKey: string; + try { + publicKey = Keypair.fromSecret(secretKey).publicKey(); + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid Stellar secret key" }; + return; + } + + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + + const existing = await ppRepo.findByPublicKey(publicKey); + if (existing) { + if ( + existing.ownerPublicKey && existing.ownerPublicKey !== ownerPublicKey + ) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { + message: "This provider belongs to another user", + }; + return; + } + if (!existing.isActive) { + await ppRepo.activate(existing.id); + addProviderAddress(publicKey); + } + ctx.response.status = Status.OK; ctx.response.body = { - message: "This provider belongs to another user", + message: "Provider already registered", + data: { publicKey, isActive: true }, }; return; } - // Re-activate if it was deactivated - if (!existing.isActive) { - await ppRepo.activate(existing.id); - addProviderAddress(publicKey); - } + + const encrypted = await encryptSk(secretKey, SERVICE_AUTH_SECRET); + + const pp = await ppRepo.create({ + id: crypto.randomUUID(), + publicKey, + encryptedSk: encrypted, + derivationIndex, + ownerPublicKey, + isActive: true, + label: label?.trim() ?? null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + addProviderAddress(publicKey); + + log.debug("publicKey", publicKey); + log.event("PP registered"); + ctx.response.status = Status.OK; ctx.response.body = { - message: "Provider already registered", - data: { publicKey, isActive: true }, + message: "Provider registered", + data: { publicKey: pp.publicKey, isActive: pp.isActive }, }; - return; + } catch (error) { + log.error(error, "failed to register PP"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to register provider" }; } - - const encrypted = await encryptSk(secretKey, SERVICE_AUTH_SECRET); - - const pp = await ppRepo.create({ - id: crypto.randomUUID(), - publicKey, - encryptedSk: encrypted, - derivationIndex, - ownerPublicKey, - isActive: true, - label: label?.trim() ?? null, - createdAt: new Date(), - updatedAt: new Date(), - }); - - // Register with event watcher - addProviderAddress(publicKey); - - LOG.info("PP registered", { publicKey }); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Provider registered", - data: { publicKey: pp.publicKey, isActive: pp.isActive }, - }; - } catch (error) { - LOG.error("Failed to register PP", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to register provider" }; - } -}; + }; +} /** * GET /dashboard/pp/list - * Lists PPs owned by the authenticated user, with council membership status. */ -export const listPpsHandler = async (ctx: Context) => { - try { - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const pps = await ppRepo.listByOwner(ownerPublicKey); - - const data = await Promise.all(pps.map(async (pp) => { - const memberships = await membershipRepo.listAllForPp(pp.publicKey); - - const councilMemberships = memberships.map((membership) => { - let claimedJurisdictions: string[] | null = null; - if (membership.claimedJurisdictions) { - try { - claimedJurisdictions = JSON.parse(membership.claimedJurisdictions); - } catch { - claimedJurisdictions = null; +export function handleListPps( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("listPps"); + + return async (ctx) => { + log.info("listPps"); + try { + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const pps = await ppRepo.listByOwner(ownerPublicKey); + + const data = await Promise.all(pps.map(async (pp) => { + const memberships = await membershipRepo.listAllForPp(pp.publicKey); + + const councilMemberships = memberships.map((membership) => { + let claimedJurisdictions: string[] | null = null; + if (membership.claimedJurisdictions) { + try { + claimedJurisdictions = JSON.parse( + membership.claimedJurisdictions, + ); + } catch { + claimedJurisdictions = null; + } } - } - let councilJurisdictions: string[] | null = null; - let channels: Array<{ - channelContractId: string; - assetCode: string; - assetContractId: string; - label: string | null; - }> = []; - if (membership.configJson) { - try { - const cfg = JSON.parse(membership.configJson) as { - jurisdictions?: Array<{ countryCode: string }>; - channels?: Array<{ - channelContractId: string; - assetCode: string; - assetContractId: string; - label: string | null; - }>; - }; - councilJurisdictions = (cfg.jurisdictions || []).map((j) => - j.countryCode - ); - channels = cfg.channels || []; - } catch { - councilJurisdictions = null; + let councilJurisdictions: string[] | null = null; + let channels: Array<{ + channelContractId: string; + assetCode: string; + assetContractId: string; + label: string | null; + }> = []; + if (membership.configJson) { + try { + const cfg = JSON.parse(membership.configJson) as { + jurisdictions?: Array<{ countryCode: string }>; + channels?: Array<{ + channelContractId: string; + assetCode: string; + assetContractId: string; + label: string | null; + }>; + }; + councilJurisdictions = (cfg.jurisdictions || []).map((j) => + j.countryCode + ); + channels = cfg.channels || []; + } catch { + councilJurisdictions = null; + } } - } + + return { + councilUrl: membership.councilUrl, + councilName: membership.councilName, + status: membership.status, + channelAuthId: membership.channelAuthId, + claimedJurisdictions, + councilJurisdictions, + channels, + }; + }); return { - councilUrl: membership.councilUrl, - councilName: membership.councilName, - status: membership.status, - channelAuthId: membership.channelAuthId, - claimedJurisdictions, - councilJurisdictions, - channels, + publicKey: pp.publicKey, + derivationIndex: pp.derivationIndex, + label: pp.label, + isActive: pp.isActive, + createdAt: pp.createdAt.toISOString(), + councilMemberships, }; - }); + })); - return { - publicKey: pp.publicKey, - derivationIndex: pp.derivationIndex, - label: pp.label, - isActive: pp.isActive, - createdAt: pp.createdAt.toISOString(), - councilMemberships, - }; - })); - - ctx.response.status = Status.OK; - ctx.response.body = { message: "Providers listed", data }; - } catch (error) { - LOG.error("Failed to list PPs", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to list providers" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { message: "Providers listed", data }; + } catch (error) { + log.error(error, "failed to list PPs"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to list providers" }; + } + }; +} /** * DELETE /dashboard/pp/delete - * Hard-deletes a PP and its council memberships. Owner must match. */ -export const deletePpHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { publicKey } = body; - - if (!publicKey || typeof publicKey !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "publicKey is required" }; - return; - } +export function handleDeletePp( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("deletePp"); - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + return async (ctx) => { + log.info("deletePp"); + try { + const body = await ctx.request.body.json(); + const { publicKey } = body; - const pp = await ppRepo.findByPublicKey(publicKey); - if (!pp) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } + if (!publicKey || typeof publicKey !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "publicKey is required" }; + return; + } - if (pp.ownerPublicKey !== ownerPublicKey) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { message: "This provider belongs to another user" }; - return; - } + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - removeProviderAddress(publicKey); + const pp = await ppRepo.findByPublicKey(publicKey); + if (!pp) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Provider not found" }; + return; + } - // Delete council memberships for this PP - const memberships = await membershipRepo.listAllForPp(publicKey); - for (const m of memberships) { - await membershipRepo.delete(m.id); - } + if (pp.ownerPublicKey !== ownerPublicKey) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { + message: "This provider belongs to another user", + }; + return; + } - await ppRepo.hardDelete(pp.id); + removeProviderAddress(publicKey); - LOG.info("PP deleted", { publicKey }); + const memberships = await membershipRepo.listAllForPp(publicKey); + for (const m of memberships) { + await membershipRepo.delete(m.id); + } + + await ppRepo.hardDelete(pp.id); + + log.debug("publicKey", publicKey); + log.event("PP deleted"); - ctx.response.status = Status.OK; - ctx.response.body = { message: "Provider deleted" }; - } catch (error) { - LOG.error("Failed to delete PP", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to delete provider" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { message: "Provider deleted" }; + } catch (error) { + log.error(error, "failed to delete PP"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to delete provider" }; + } + }; +} diff --git a/src/http/v1/dashboard/routes.ts b/src/http/v1/dashboard/routes.ts index 8abfb78..96bce1c 100644 --- a/src/http/v1/dashboard/routes.ts +++ b/src/http/v1/dashboard/routes.ts @@ -1,107 +1,138 @@ import { Router } from "@oak/oak"; -import { postChallengeHandler } from "./auth/challenge.ts"; -import { postVerifyHandler } from "./auth/verify.ts"; -import { getChannelsHandler } from "./channels.ts"; -import { getMempoolHandler } from "./mempool.ts"; -import { getOperationsHandler } from "./operations.ts"; -import { getTreasuryHandler } from "./treasury.ts"; -import { getUtxosHandler } from "./utxos.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import { handlePostChallenge } from "./auth/challenge.ts"; +import { handlePostVerify } from "./auth/verify.ts"; +import { handleGetChannels } from "./channels.ts"; +import { handleGetMempool } from "./mempool.ts"; +import { handleGetOperations } from "./operations.ts"; +import { handleGetTreasury } from "./treasury.ts"; +import { handleGetUtxos } from "./utxos.ts"; import { - getTransactionDetailHandler, - listTransactionsHandler, + handleGetTransactionDetail, + handleListDashboardTransactions, } from "./transactions.ts"; -import { getAuditExportHandler } from "./audit-export.ts"; +import { handleGetAuditExport } from "./audit-export.ts"; import { - discoverCouncilHandler, - getMembershipHandler, - joinCouncilHandler, - syncMembershipHandler, + handleDiscoverCouncil, + handleGetMembership, + handleJoinCouncil, + handleSyncMembership, } from "./council.ts"; -import { deletePpHandler, listPpsHandler, registerPpHandler } from "./pp.ts"; -import { postExpireBundlesHandler } from "./bundle-admin.ts"; -import { getBundleDetailHandler, listRecentBundlesHandler } from "./bundles.ts"; -import { getMetricsHandler } from "./metrics.ts"; +import { handleDeletePp, handleListPps, handleRegisterPp } from "./pp.ts"; +import { handlePostExpireBundles } from "./bundle-admin.ts"; +import { handleGetBundleDetail, handleListRecentBundles } from "./bundles.ts"; +import { handleGetMetrics } from "./metrics.ts"; import { jwtMiddleware } from "@/http/middleware/auth/index.ts"; -const dashboardRouter = new Router(); +export function buildDashboardRouter(deps: { log: Logger }): Router { + const dashboardRouter = new Router(); -// --- Auth (public) --- -dashboardRouter.post("/dashboard/auth/challenge", postChallengeHandler); -dashboardRouter.post("/dashboard/auth/verify", postVerifyHandler); + // --- Auth (public) --- + dashboardRouter.post("/dashboard/auth/challenge", handlePostChallenge(deps)); + dashboardRouter.post("/dashboard/auth/verify", handlePostVerify(deps)); -// --- Admin (JWT required) --- -dashboardRouter.post( - "/dashboard/bundles/expire", - jwtMiddleware, - postExpireBundlesHandler, -); + // --- Admin (JWT required) --- + dashboardRouter.post( + "/dashboard/bundles/expire", + jwtMiddleware(deps), + handlePostExpireBundles(deps), + ); -// --- Protected endpoints (JWT checked inline per-route) --- -dashboardRouter.get("/dashboard/channels", jwtMiddleware, getChannelsHandler); -dashboardRouter.get("/dashboard/mempool", jwtMiddleware, getMempoolHandler); -dashboardRouter.get( - "/dashboard/operations", - jwtMiddleware, - getOperationsHandler, -); -dashboardRouter.get("/dashboard/treasury", jwtMiddleware, getTreasuryHandler); -dashboardRouter.get("/dashboard/utxos", jwtMiddleware, getUtxosHandler); -dashboardRouter.get( - "/dashboard/transactions", - jwtMiddleware, - listTransactionsHandler, -); -dashboardRouter.get( - "/dashboard/transactions/:id", - jwtMiddleware, - getTransactionDetailHandler, -); -dashboardRouter.get( - "/dashboard/bundles", - jwtMiddleware, - listRecentBundlesHandler, -); -dashboardRouter.get( - "/dashboard/bundles/:id", - jwtMiddleware, - getBundleDetailHandler, -); -dashboardRouter.get( - "/dashboard/audit-export", - jwtMiddleware, - getAuditExportHandler, -); -dashboardRouter.get("/dashboard/metrics", jwtMiddleware, getMetricsHandler); + // --- Protected endpoints --- + dashboardRouter.get( + "/dashboard/channels", + jwtMiddleware(deps), + handleGetChannels(deps), + ); + dashboardRouter.get( + "/dashboard/mempool", + jwtMiddleware(deps), + handleGetMempool(deps), + ); + dashboardRouter.get( + "/dashboard/operations", + jwtMiddleware(deps), + handleGetOperations(deps), + ); + dashboardRouter.get( + "/dashboard/treasury", + jwtMiddleware(deps), + handleGetTreasury(deps), + ); + dashboardRouter.get( + "/dashboard/utxos", + jwtMiddleware(deps), + handleGetUtxos(deps), + ); + dashboardRouter.get( + "/dashboard/transactions", + jwtMiddleware(deps), + handleListDashboardTransactions(deps), + ); + dashboardRouter.get( + "/dashboard/transactions/:id", + jwtMiddleware(deps), + handleGetTransactionDetail(deps), + ); + dashboardRouter.get( + "/dashboard/bundles", + jwtMiddleware(deps), + handleListRecentBundles(deps), + ); + dashboardRouter.get( + "/dashboard/bundles/:id", + jwtMiddleware(deps), + handleGetBundleDetail(deps), + ); + dashboardRouter.get( + "/dashboard/audit-export", + jwtMiddleware(deps), + handleGetAuditExport(deps), + ); + dashboardRouter.get( + "/dashboard/metrics", + jwtMiddleware(deps), + handleGetMetrics(deps), + ); -// --- PP management --- -dashboardRouter.post( - "/dashboard/pp/register", - jwtMiddleware, - registerPpHandler, -); -dashboardRouter.get("/dashboard/pp/list", jwtMiddleware, listPpsHandler); -dashboardRouter.post("/dashboard/pp/delete", jwtMiddleware, deletePpHandler); + // --- PP management --- + dashboardRouter.post( + "/dashboard/pp/register", + jwtMiddleware(deps), + handleRegisterPp(deps), + ); + dashboardRouter.get( + "/dashboard/pp/list", + jwtMiddleware(deps), + handleListPps(deps), + ); + dashboardRouter.post( + "/dashboard/pp/delete", + jwtMiddleware(deps), + handleDeletePp(deps), + ); -// --- Council (UC2) --- -dashboardRouter.post( - "/dashboard/council/discover", - jwtMiddleware, - discoverCouncilHandler, -); -dashboardRouter.post( - "/dashboard/council/join", - jwtMiddleware, - joinCouncilHandler, -); -dashboardRouter.get( - "/dashboard/council/membership", - jwtMiddleware, - getMembershipHandler, -); -dashboardRouter.post( - "/dashboard/council/membership", - jwtMiddleware, - syncMembershipHandler, -); + // --- Council (UC2) --- + dashboardRouter.post( + "/dashboard/council/discover", + jwtMiddleware(deps), + handleDiscoverCouncil(deps), + ); + dashboardRouter.post( + "/dashboard/council/join", + jwtMiddleware(deps), + handleJoinCouncil(deps), + ); + dashboardRouter.get( + "/dashboard/council/membership", + jwtMiddleware(deps), + handleGetMembership(deps), + ); + dashboardRouter.post( + "/dashboard/council/membership", + jwtMiddleware(deps), + handleSyncMembership(deps), + ); -export default dashboardRouter; + return dashboardRouter; +} diff --git a/src/http/v1/dashboard/transactions.ts b/src/http/v1/dashboard/transactions.ts index 4a61a35..dc8c5a5 100644 --- a/src/http/v1/dashboard/transactions.ts +++ b/src/http/v1/dashboard/transactions.ts @@ -10,7 +10,7 @@ import { UtxoRepository } from "@/persistence/drizzle/repository/utxo.repository import { transaction } from "@/persistence/drizzle/entity/transaction.entity.ts"; import { operationsBundle } from "@/persistence/drizzle/entity/operations-bundle.entity.ts"; import { bundleTransaction } from "@/persistence/drizzle/entity/bundle-transaction.entity.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const ppRepo = new PpRepository(drizzleClient); const txRepo = new TransactionRepository(); @@ -27,7 +27,7 @@ type ParsedOps = { createCount: number; }; -function parseBundleOps(operationsMLXDR: string[]): ParsedOps { +function parseBundleOps(operationsMLXDR: string[], log: Logger): ParsedOps { const deposits: ParsedOps["deposits"] = []; const withdraws: ParsedOps["withdraws"] = []; let spendCount = 0; @@ -51,18 +51,21 @@ function parseBundleOps(operationsMLXDR: string[]): ParsedOps { createCount++; } } catch (error) { - LOG.warn("Skipping unparseable operation MLXDR", { - error: error instanceof Error ? error.message : String(error), - }); + log.error(error, "skipping unparseable operation MLXDR"); } } return { deposits, withdraws, spendCount, createCount }; } -async function buildTxDetail(txId: string) { +async function buildTxDetail(txId: string, log: Logger) { + log.info("buildTxDetail"); + log.debug("txId", txId); + log.event("loading transaction"); const tx = await txRepo.findById(txId); if (!tx) return null; + log.event("aggregating bundle/utxo details"); + const bundleLinks = await bundleTxRepo.findByTransactionId(txId); const bundles = []; let earliestBundleCreatedAt: Date | null = null; @@ -85,7 +88,7 @@ async function buildTxDetail(txId: string) { ) { earliestBundleCreatedAt = bundle.createdAt; } - const ops = parseBundleOps(bundle.operationsMLXDR); + const ops = parseBundleOps(bundle.operationsMLXDR, log); aggregatedDeposits.push(...ops.deposits); aggregatedWithdraws.push(...ops.withdraws); bundles.push({ @@ -132,16 +135,17 @@ async function buildTxDetail(txId: string) { }; } -/** - * Find tx ids whose linked bundles target the given channel AND whose - * tx.createdAt falls inside the range. Ordered newest-first. - */ async function findTxIdsInRange( channelContractId: string, from: Date, to: Date, limit: number, + log: Logger, ): Promise { + log.info("findTxIdsInRange"); + log.debug("channelContractId", channelContractId); + log.debug("limit", limit); + log.event("querying transactions by channel + time range"); const rows = await drizzleClient .selectDistinct({ id: transaction.id, createdAt: transaction.createdAt }) .from(transaction) @@ -167,126 +171,125 @@ async function findTxIdsInRange( type RouteParams = { id?: string }; -/** - * GET /dashboard/transactions?ppPublicKey=...&channelContractId=...&fromIso=...&toIso=... - * - * Lists transactions in the given range scoped to the channel. Each entry - * has the same shape as the single-tx detail endpoint so the client can - * fan it out across the dashboard columns. - */ -export const listTransactionsHandler = async (ctx: Context) => { - try { - const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); - const channelContractId = ctx.request.url.searchParams.get( - "channelContractId", - ); - const fromIso = ctx.request.url.searchParams.get("fromIso"); - const toIso = ctx.request.url.searchParams.get("toIso"); - - if (!ppPublicKey || !channelContractId || !fromIso || !toIso) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "ppPublicKey, channelContractId, fromIso, toIso required", - }; - return; - } +export function handleListDashboardTransactions( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("listDashboardTransactions"); - const from = new Date(fromIso); - const to = new Date(toIso); - if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "fromIso/toIso must be ISO datetimes" }; - return; - } + return async (ctx) => { + log.info("listDashboardTransactions"); + try { + const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); + const channelContractId = ctx.request.url.searchParams.get( + "channelContractId", + ); + const fromIso = ctx.request.url.searchParams.get("fromIso"); + const toIso = ctx.request.url.searchParams.get("toIso"); - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const pp = await ppRepo.findByPublicKeyAndOwner( - ppPublicKey, - ownerPublicKey, - ); - if (!pp) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } + if (!ppPublicKey || !channelContractId || !fromIso || !toIso) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "ppPublicKey, channelContractId, fromIso, toIso required", + }; + return; + } - const txIds = await findTxIdsInRange( - channelContractId, - from, - to, - MAX_LIST_RESULTS, - ); + const from = new Date(fromIso); + const to = new Date(toIso); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "fromIso/toIso must be ISO datetimes" }; + return; + } - const items = []; - for (const txId of txIds) { - const detail = await buildTxDetail(txId); - if (detail) items.push(detail); - } + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const pp = await ppRepo.findByPublicKeyAndOwner( + ppPublicKey, + ownerPublicKey, + ); + if (!pp) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Provider not found" }; + return; + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Transactions retrieved", - data: items, - truncated: txIds.length >= MAX_LIST_RESULTS, - }; - } catch (error) { - LOG.error("Failed to list transactions", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to list transactions" }; - } -}; + const txIds = await findTxIdsInRange( + channelContractId, + from, + to, + MAX_LIST_RESULTS, + log, + ); -/** - * GET /dashboard/transactions/:id?ppPublicKey=G... - * - * Returns the full lifecycle picture of one tx. - */ -export const getTransactionDetailHandler = async (ctx: Context) => { - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const txId = params?.id; - if (!txId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Transaction id is required" }; - return; - } + const items = []; + for (const txId of txIds) { + const detail = await buildTxDetail(txId, log); + if (detail) items.push(detail); + } - const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); - if (!ppPublicKey) { - ctx.response.status = Status.BadRequest; + ctx.response.status = Status.OK; ctx.response.body = { - message: "ppPublicKey query parameter is required", + message: "Transactions retrieved", + data: items, + truncated: txIds.length >= MAX_LIST_RESULTS, }; - return; + } catch (error) { + log.error(error, "failed to list transactions"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to list transactions" }; } + }; +} - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const pp = await ppRepo.findByPublicKeyAndOwner( - ppPublicKey, - ownerPublicKey, - ); - if (!pp) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } +export function handleGetTransactionDetail( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getTransactionDetail"); - const detail = await buildTxDetail(txId); - if (!detail) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Transaction not found" }; - return; - } + return async (ctx) => { + log.info("getTransactionDetail"); + try { + const params = (ctx as unknown as { params?: RouteParams }).params; + const txId = params?.id; + if (!txId) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Transaction id is required" }; + return; + } - ctx.response.status = Status.OK; - ctx.response.body = { message: "Transaction detail", data: detail }; - } catch (error) { - LOG.error("Failed to fetch transaction detail", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to fetch transaction detail" }; - } -}; + const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); + if (!ppPublicKey) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "ppPublicKey query parameter is required", + }; + return; + } + + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const pp = await ppRepo.findByPublicKeyAndOwner( + ppPublicKey, + ownerPublicKey, + ); + if (!pp) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Provider not found" }; + return; + } + + const detail = await buildTxDetail(txId, log); + if (!detail) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Transaction not found" }; + return; + } + + ctx.response.status = Status.OK; + ctx.response.body = { message: "Transaction detail", data: detail }; + } catch (error) { + log.error(error, "failed to fetch transaction detail"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to fetch transaction detail" }; + } + }; +} diff --git a/src/http/v1/dashboard/treasury.ts b/src/http/v1/dashboard/treasury.ts index 408eeac..016867f 100644 --- a/src/http/v1/dashboard/treasury.ts +++ b/src/http/v1/dashboard/treasury.ts @@ -2,7 +2,7 @@ import { type Context, Status } from "@oak/oak"; import { NETWORK_CONFIG } from "@/config/env.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PpRepository } from "@/persistence/drizzle/repository/pp.repository.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const ppRepo = new PpRepository(drizzleClient); @@ -12,57 +12,67 @@ const ppRepo = new PpRepository(drizzleClient); * Returns the treasury (PP account) balance and info. * Each PP's public key is its on-chain account address. */ -export const getTreasuryHandler = async (ctx: Context) => { - const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); - if (!ppPublicKey) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "ppPublicKey query parameter is required" }; - return; - } +export function handleGetTreasury( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getTreasury"); - // Verify PP ownership - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const pp = await ppRepo.findByPublicKeyAndOwner(ppPublicKey, ownerPublicKey); - if (!pp) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } - - const horizonUrl = NETWORK_CONFIG.horizonUrl; - if (!horizonUrl) { - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { message: "No Horizon URL configured" }; - return; - } + return async (ctx) => { + log.info("getTreasury"); + const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); + if (!ppPublicKey) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "ppPublicKey query parameter is required", + }; + return; + } - try { - const response = await fetch(`${horizonUrl}/accounts/${ppPublicKey}`); + // Verify PP ownership + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const pp = await ppRepo.findByPublicKeyAndOwner( + ppPublicKey, + ownerPublicKey, + ); + if (!pp) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Provider not found" }; + return; + } - if (!response.ok) { - throw new Error(`Horizon returned ${response.status}`); + const horizonUrl = NETWORK_CONFIG.horizonUrl; + if (!horizonUrl) { + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { message: "No Horizon URL configured" }; + return; } - const accountData = await response.json(); + try { + const response = await fetch(`${horizonUrl}/accounts/${ppPublicKey}`); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Treasury info retrieved", - data: { - address: ppPublicKey, - sequence: accountData.sequence, - balances: accountData.balances, - lastModifiedLedger: accountData.last_modified_ledger, - }, - }; - } catch (error) { - LOG.error("Failed to fetch treasury balance", { - error: error instanceof Error ? error.message : String(error), - }); + if (!response.ok) { + throw new Error(`Horizon returned ${response.status}`); + } - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { - message: "Failed to fetch treasury info from network", - }; - } -}; + const accountData = await response.json(); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Treasury info retrieved", + data: { + address: ppPublicKey, + sequence: accountData.sequence, + balances: accountData.balances, + lastModifiedLedger: accountData.last_modified_ledger, + }, + }; + } catch (error) { + log.error(error, "failed to fetch treasury balance"); + + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { + message: "Failed to fetch treasury info from network", + }; + } + }; +} diff --git a/src/http/v1/dashboard/utxos.ts b/src/http/v1/dashboard/utxos.ts index ffbcc84..7751715 100644 --- a/src/http/v1/dashboard/utxos.ts +++ b/src/http/v1/dashboard/utxos.ts @@ -2,7 +2,7 @@ import { type Context, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PpRepository } from "@/persistence/drizzle/repository/pp.repository.ts"; import { UtxoRepository } from "@/persistence/drizzle/repository/utxo.repository.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const ppRepo = new PpRepository(drizzleClient); const utxoRepo = new UtxoRepository(drizzleClient); @@ -15,56 +15,61 @@ const utxoRepo = new UtxoRepository(drizzleClient); * have not yet been spent or withdrawn. The dashboard surfaces them as the * "ready to be withdrawn" pool. */ -export const getUtxosHandler = async (ctx: Context) => { - try { - const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); - const channelContractId = ctx.request.url.searchParams.get( - "channelContractId", - ); +export function handleGetUtxos( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getUtxos"); - if (!ppPublicKey) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "ppPublicKey query parameter is required", - }; - return; - } - if (!channelContractId) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "channelContractId query parameter is required", - }; - return; - } + return async (ctx) => { + log.info("getUtxos"); + try { + const ppPublicKey = ctx.request.url.searchParams.get("ppPublicKey"); + const channelContractId = ctx.request.url.searchParams.get( + "channelContractId", + ); - const ownerPublicKey = (ctx.state.session as { sub: string }).sub; - const pp = await ppRepo.findByPublicKeyAndOwner( - ppPublicKey, - ownerPublicKey, - ); - if (!pp) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Provider not found" }; - return; - } + if (!ppPublicKey) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "ppPublicKey query parameter is required", + }; + return; + } + if (!channelContractId) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "channelContractId query parameter is required", + }; + return; + } + + const ownerPublicKey = (ctx.state.session as { sub: string }).sub; + const pp = await ppRepo.findByPublicKeyAndOwner( + ppPublicKey, + ownerPublicKey, + ); + if (!pp) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Provider not found" }; + return; + } - const rows = await utxoRepo.findUnspentByChannel(channelContractId); + const rows = await utxoRepo.findUnspentByChannel(channelContractId); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "UTXOs retrieved", - data: rows.map((row) => ({ - id: row.id, - amount: row.amount.toString(), - createdAtBundleId: row.createdAtBundleId, - createdAt: row.createdAt.toISOString(), - })), - }; - } catch (error) { - LOG.error("Failed to list UTXOs", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to list UTXOs" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "UTXOs retrieved", + data: rows.map((row) => ({ + id: row.id, + amount: row.amount.toString(), + createdAtBundleId: row.createdAtBundleId, + createdAt: row.createdAt.toISOString(), + })), + }; + } catch (error) { + log.error(error, "failed to list UTXOs"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to list UTXOs" }; + } + }; +} diff --git a/src/http/v1/entities/post.ts b/src/http/v1/entities/post.ts index a08b4e8..9a01b89 100644 --- a/src/http/v1/entities/post.ts +++ b/src/http/v1/entities/post.ts @@ -8,7 +8,7 @@ import { type NewAccount, type NewEntity, } from "@/persistence/drizzle/entity/index.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const entityRepo = new EntityRepository(drizzleClient); const accountRepo = new AccountRepository(drizzleClient); @@ -26,90 +26,93 @@ const bodySchema = z.object({ * data. Auto-accept: the entity is created (or its existing record promoted) * to APPROVED, and a USER-type account is created for the pubkey if one * doesn't exist yet. - * - * Returns 409 if an account already exists for that pubkey and its entity - * is already APPROVED — idempotent-but-not-silent. */ -export const postEntityHandler = async (ctx: Context) => { - try { - const raw = await ctx.request.body.json(); - const parsed = bodySchema.safeParse(raw); - if (!parsed.success) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "Invalid body", - issues: parsed.error.issues, - }; - return; - } - const { pubkey, name, jurisdictions } = parsed.data; +export function handlePostEntity( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postEntity"); - const existingAccount = await accountRepo.findById(pubkey); + return async (ctx) => { + log.info("postEntity"); + try { + const raw = await ctx.request.body.json(); + const parsed = bodySchema.safeParse(raw); + if (!parsed.success) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "Invalid body", + issues: parsed.error.issues, + }; + return; + } + const { pubkey, name, jurisdictions } = parsed.data; + log.debug("pubkey", pubkey); + log.debug("name", name); + + log.event("checking for existing account"); + const existingAccount = await accountRepo.findById(pubkey); - if (existingAccount) { - const existingEntity = await entityRepo.findById( - existingAccount.entityId, - ); - if (existingEntity?.status === EntityStatus.APPROVED) { - ctx.response.status = Status.Conflict; + if (existingAccount) { + const existingEntity = await entityRepo.findById( + existingAccount.entityId, + ); + if (existingEntity?.status === EntityStatus.APPROVED) { + log.event("entity already approved for pubkey"); + ctx.response.status = Status.Conflict; + ctx.response.body = { + message: "Entity already approved for this pubkey", + data: { pubkey, entityId: existingEntity.id }, + }; + return; + } + log.event("promoting existing entity to APPROVED"); + const updated = await entityRepo.update(existingAccount.entityId, { + name, + jurisdictions, + status: EntityStatus.APPROVED, + }); + log.debug("entityId", updated?.id ?? existingAccount.entityId); + ctx.response.status = Status.OK; ctx.response.body = { - message: "Entity already approved for this pubkey", - data: { pubkey, entityId: existingEntity.id }, + message: "Entity updated", + data: { + pubkey, + entityId: updated?.id ?? existingAccount.entityId, + status: EntityStatus.APPROVED, + }, }; return; } - const updated = await entityRepo.update(existingAccount.entityId, { + + log.event("creating new entity + account"); + const newEntity = await entityRepo.create({ + id: crypto.randomUUID(), + status: EntityStatus.APPROVED, name, jurisdictions, - status: EntityStatus.APPROVED, - }); - LOG.info("Entity promoted to APPROVED", { - pubkey, - entityId: updated?.id ?? existingAccount.entityId, - }); - ctx.response.status = Status.OK; + } as NewEntity); + + await accountRepo.create({ + id: pubkey, + type: "USER", + entityId: newEntity.id, + } as NewAccount); + + log.debug("entityId", newEntity.id); + log.event("entity registered and approved"); + ctx.response.status = Status.Created; ctx.response.body = { - message: "Entity updated", + message: "Entity created", data: { pubkey, - entityId: updated?.id ?? existingAccount.entityId, + entityId: newEntity.id, status: EntityStatus.APPROVED, }, }; - return; + } catch (error) { + log.error(error, "failed to create/update entity"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to create/update entity" }; } - - const newEntity = await entityRepo.create({ - id: crypto.randomUUID(), - status: EntityStatus.APPROVED, - name, - jurisdictions, - } as NewEntity); - - await accountRepo.create({ - id: pubkey, - type: "USER", - entityId: newEntity.id, - } as NewAccount); - - LOG.info("Entity registered and approved", { - pubkey, - entityId: newEntity.id, - }); - ctx.response.status = Status.Created; - ctx.response.body = { - message: "Entity created", - data: { - pubkey, - entityId: newEntity.id, - status: EntityStatus.APPROVED, - }, - }; - } catch (error) { - LOG.error("Failed to create/update entity", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to create/update entity" }; - } -}; + }; +} diff --git a/src/http/v1/entities/routes.ts b/src/http/v1/entities/routes.ts index 795302f..64588ab 100644 --- a/src/http/v1/entities/routes.ts +++ b/src/http/v1/entities/routes.ts @@ -1,9 +1,10 @@ import { Router } from "@oak/oak"; -import { postEntityHandler } from "./post.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import { handlePostEntity } from "./post.ts"; -const entitiesRouter = new Router(); - -// Public — KYC/KYB-style entity registration. Auto-accept on submit. -entitiesRouter.post("/entities", postEntityHandler); - -export default entitiesRouter; +export function buildEntitiesRouter(deps: { log: Logger }): Router { + const entitiesRouter = new Router(); + // Public — KYC/KYB-style entity registration. Auto-accept on submit. + entitiesRouter.post("/entities", handlePostEntity(deps)); + return entitiesRouter; +} diff --git a/src/http/v1/events/routes.ts b/src/http/v1/events/routes.ts index b8ba5fb..b342cbf 100644 --- a/src/http/v1/events/routes.ts +++ b/src/http/v1/events/routes.ts @@ -1,11 +1,9 @@ import { Router } from "@oak/oak"; -import { eventsWsHandler } from "./ws-handler.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import { handleEventsWs } from "./ws-handler.ts"; -const eventsRouter = new Router(); - -// Auth is handled inline in the handler — browsers cannot send custom headers -// on a WebSocket handshake, so JWT verification reads the -// `Sec-WebSocket-Protocol: bearer.` subprotocol entry. -eventsRouter.get("/events/ws", eventsWsHandler); - -export default eventsRouter; +export function buildEventsRouter(deps: { log: Logger }): Router { + const eventsRouter = new Router(); + eventsRouter.get("/events/ws", handleEventsWs(deps)); + return eventsRouter; +} diff --git a/src/http/v1/events/ws-handler.ts b/src/http/v1/events/ws-handler.ts index a27bb1b..c69e5b4 100644 --- a/src/http/v1/events/ws-handler.ts +++ b/src/http/v1/events/ws-handler.ts @@ -1,30 +1,21 @@ import type { Context } from "@oak/oak"; import { verify } from "@zaubrik/djwt"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { SERVICE_AUTH_SECRET_AS_CRYPTO_KEY } from "@/core/service/auth/service/service-auth-secret.ts"; import type { JwtPayload } from "@/core/service/auth/generate-jwt.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PpRepository } from "@/persistence/drizzle/repository/pp.repository.ts"; -import { eventBus } from "@/core/service/events/event-bus.ts"; +import { getEventBus } from "@/core/service/events/event-bus.ts"; import type { ProviderEvent } from "@/core/service/events/event.types.ts"; -/** - * WebSocket subprotocol the server echoes back on a successful upgrade. - * Clients must offer this AND a "bearer." entry. Echoing this - * non-secret name (rather than the bearer.* one) avoids leaking the - * JWT into response logs. - */ export const EVENTS_WS_SUBPROTOCOL = "moonlight.events.v1"; -/** Bearer-style subprotocol entries are prefixed with this. */ const BEARER_PROTO_PREFIX = "bearer."; -/** Idle ping cadence. Deno sends a ping after this many seconds of silence. */ const IDLE_TIMEOUT_SECONDS = 30; let ppRepository = new PpRepository(drizzleClient); -/** Test-only seam: inject a PP repository backed by the test DB. */ export function setPpRepoForTests(repo: PpRepository): void { ppRepository = repo; } @@ -53,95 +44,98 @@ async function verifyJwtToken(token: string): Promise { } } -export async function eventsWsHandler(ctx: Context): Promise { - if (!ctx.isUpgradable) { - ctx.response.status = 426; - ctx.response.body = { error: "WebSocket upgrade required" }; - return; - } +export function handleEventsWs( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("eventsWs"); + + return async (ctx) => { + log.info("eventsWs"); + if (!ctx.isUpgradable) { + ctx.response.status = 426; + ctx.response.body = { error: "WebSocket upgrade required" }; + return; + } - const protoHeader = ctx.request.headers.get("Sec-WebSocket-Protocol"); - const token = extractBearerToken(protoHeader); - if (!token) { - ctx.response.status = 401; - ctx.response.body = { - error: "Missing bearer. Sec-WebSocket-Protocol entry", - }; - return; - } + const protoHeader = ctx.request.headers.get("Sec-WebSocket-Protocol"); + const token = extractBearerToken(protoHeader); + if (!token) { + ctx.response.status = 401; + ctx.response.body = { + error: "Missing bearer. Sec-WebSocket-Protocol entry", + }; + return; + } - const session = await verifyJwtToken(token); - if (!session) { - ctx.response.status = 401; - ctx.response.body = { error: "Invalid or expired token" }; - return; - } + const session = await verifyJwtToken(token); + if (!session) { + ctx.response.status = 401; + ctx.response.body = { error: "Invalid or expired token" }; + return; + } - const ppPublicKey = ctx.request.url.searchParams.get("pp"); - if (!ppPublicKey) { - ctx.response.status = 400; - ctx.response.body = { error: "Missing ?pp= query param" }; - return; - } + const ppPublicKey = ctx.request.url.searchParams.get("pp"); + if (!ppPublicKey) { + ctx.response.status = 400; + ctx.response.body = { error: "Missing ?pp= query param" }; + return; + } - const pp = await ppRepository.findByPublicKeyAndOwner( - ppPublicKey, - session.sub, - ); - if (!pp) { - ctx.response.status = 403; - ctx.response.body = { error: "PP not owned by authenticated operator" }; - return; - } + const pp = await ppRepository.findByPublicKeyAndOwner( + ppPublicKey, + session.sub, + ); + if (!pp) { + ctx.response.status = 403; + ctx.response.body = { error: "PP not owned by authenticated operator" }; + return; + } - const boundPpPublicKey = pp.publicKey; - const boundPpLabel = pp.label; + const boundPpPublicKey = pp.publicKey; + const boundPpLabel = pp.label; - const socket = ctx.upgrade({ - protocol: EVENTS_WS_SUBPROTOCOL, - idleTimeout: IDLE_TIMEOUT_SECONDS, - }); + const socket = ctx.upgrade({ + protocol: EVENTS_WS_SUBPROTOCOL, + idleTimeout: IDLE_TIMEOUT_SECONDS, + }); - let unsubscribe: (() => void) | null = null; - let closed = false; + let unsubscribe: (() => void) | null = null; + let closed = false; - const cleanup = () => { - if (closed) return; - closed = true; - if (unsubscribe) unsubscribe(); - unsubscribe = null; - }; + const cleanup = () => { + if (closed) return; + closed = true; + if (unsubscribe) unsubscribe(); + unsubscribe = null; + }; - const listener = (event: ProviderEvent) => { - if (event.scope.ppPublicKey !== boundPpPublicKey) return; - if (socket.readyState !== WebSocket.OPEN) return; - try { - socket.send(JSON.stringify(event)); - } catch (error) { - LOG.error("Failed to send event over WS", { - kind: event.kind, - error: error instanceof Error ? error.message : String(error), - }); - } - }; + const listener = (event: ProviderEvent) => { + if (event.scope.ppPublicKey !== boundPpPublicKey) return; + if (socket.readyState !== WebSocket.OPEN) return; + try { + socket.send(JSON.stringify(event)); + } catch (error) { + log.debug("kind", event.kind); + log.error(error, "failed to send event over WS"); + } + }; - socket.onopen = () => { - unsubscribe = eventBus.subscribe(listener); - LOG.info("Events WS opened", { - ownerPublicKey: session.sub, - ppPublicKey: boundPpPublicKey, - ppLabel: boundPpLabel, - }); - }; - socket.onclose = () => { - cleanup(); - LOG.info("Events WS closed", { ppPublicKey: boundPpPublicKey }); - }; - socket.onerror = (event) => { - LOG.warn("Events WS error", { - ppPublicKey: boundPpPublicKey, - message: event instanceof ErrorEvent ? event.message : "unknown", - }); - cleanup(); + socket.onopen = () => { + unsubscribe = getEventBus(deps).subscribe(listener); + log.debug("ownerPublicKey", session.sub); + log.debug("ppPublicKey", boundPpPublicKey); + log.debug("ppLabel", boundPpLabel); + log.event("events WS opened"); + }; + socket.onclose = () => { + cleanup(); + log.debug("ppPublicKey", boundPpPublicKey); + log.event("events WS closed"); + }; + socket.onerror = (event) => { + log.debug("ppPublicKey", boundPpPublicKey); + log.error(event, "events WS error"); + cleanup(); + }; }; } diff --git a/src/http/v1/pay/custodial/account.ts b/src/http/v1/pay/custodial/account.ts index 71653b3..672f04e 100644 --- a/src/http/v1/pay/custodial/account.ts +++ b/src/http/v1/pay/custodial/account.ts @@ -2,37 +2,42 @@ import { type Context, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayCustodialAccountRepository } from "@/persistence/drizzle/repository/pay-custodial-account.repository.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const accountRepo = new PayCustodialAccountRepository(drizzleClient); -export const getCustodialAccountHandler = async (ctx: Context) => { - try { - const session = ctx.state.session as JwtSessionData; - const accountId = session.sub; +export function handleGetCustodialAccount( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getCustodialAccount"); - const account = await accountRepo.findById(accountId); - if (!account) { - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Account not found" }; - return; - } + return async (ctx) => { + log.info("getCustodialAccount"); + try { + const session = ctx.state.session as JwtSessionData; + const accountId = session.sub; + + const account = await accountRepo.findById(accountId); + if (!account) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Account not found" }; + return; + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Account retrieved", - data: { - id: account.id, - depositAddress: account.depositAddress, - balance: account.balance.toString(), - status: account.status.toLowerCase(), - }, - }; - } catch (error) { - LOG.warn("Get custodial account failed", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve account" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Account retrieved", + data: { + id: account.id, + depositAddress: account.depositAddress, + balance: account.balance.toString(), + status: account.status.toLowerCase(), + }, + }; + } catch (error) { + log.error(error, "get custodial account failed"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to retrieve account" }; + } + }; +} diff --git a/src/http/v1/pay/custodial/login.ts b/src/http/v1/pay/custodial/login.ts index aabf980..c6a2ce6 100644 --- a/src/http/v1/pay/custodial/login.ts +++ b/src/http/v1/pay/custodial/login.ts @@ -3,58 +3,68 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayCustodialAccountRepository } from "@/persistence/drizzle/repository/pay-custodial-account.repository.ts"; import { PayCustodialStatus } from "@/persistence/drizzle/entity/pay-custodial-account.entity.ts"; import generateJwt from "@/core/service/auth/generate-jwt.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { verifyPassword } from "@/http/v1/pay/custodial/crypto.ts"; const accountRepo = new PayCustodialAccountRepository(drizzleClient); -export const postCustodialLoginHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { username, password } = body; +export function handlePostCustodialLogin( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postCustodialLogin"); - if (!username || !password) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "username and password are required" }; - return; - } + return async (ctx) => { + log.info("postCustodialLogin"); + try { + const body = await ctx.request.body.json(); + const { username, password } = body; - const account = await accountRepo.findByUsername(username); - if (!account) { - ctx.response.status = Status.Unauthorized; - ctx.response.body = { message: "Invalid credentials" }; - return; - } + if (!username || !password) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "username and password are required" }; + return; + } - const valid = await verifyPassword(password, account.passwordHash); - if (!valid) { - ctx.response.status = Status.Unauthorized; - ctx.response.body = { message: "Invalid credentials" }; - return; - } + const account = await accountRepo.findByUsername(username); + if (!account) { + ctx.response.status = Status.Unauthorized; + ctx.response.body = { message: "Invalid credentials" }; + return; + } - // Suspended check after password verification — return same generic message - // to avoid confirming the password is correct for a suspended account - if (account.status === PayCustodialStatus.SUSPENDED) { - LOG.warn("Login attempt on suspended account", { username }); - ctx.response.status = Status.Unauthorized; - ctx.response.body = { message: "Invalid credentials" }; - return; - } + const valid = await verifyPassword(password, account.passwordHash); + if (!valid) { + ctx.response.status = Status.Unauthorized; + ctx.response.body = { message: "Invalid credentials" }; + return; + } - const token = await generateJwt(account.id, crypto.randomUUID(), { - type: "custodial", - }); - - LOG.info("Custodial login successful", { username }); - - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Login successful", - data: { token }, - }; - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } -}; + if (account.status === PayCustodialStatus.SUSPENDED) { + log.debug("username", username); + log.error( + new Error("login on suspended account"), + "login attempt on suspended account", + ); + ctx.response.status = Status.Unauthorized; + ctx.response.body = { message: "Invalid credentials" }; + return; + } + + const token = await generateJwt(account.id, crypto.randomUUID(), { + type: "custodial", + }); + + log.debug("username", username); + log.event("custodial login successful"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Login successful", + data: { token }, + }; + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid request body" }; + } + }; +} diff --git a/src/http/v1/pay/custodial/register.ts b/src/http/v1/pay/custodial/register.ts index 732ec90..10da9fd 100644 --- a/src/http/v1/pay/custodial/register.ts +++ b/src/http/v1/pay/custodial/register.ts @@ -3,7 +3,7 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayCustodialAccountRepository } from "@/persistence/drizzle/repository/pay-custodial-account.repository.ts"; import { Keypair } from "stellar-sdk"; import generateJwt from "@/core/service/auth/generate-jwt.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { hashPassword } from "@/http/v1/pay/custodial/crypto.ts"; const accountRepo = new PayCustodialAccountRepository(drizzleClient); @@ -21,66 +21,76 @@ function generateDepositAddress(): string { return keypair.publicKey(); } -export const postCustodialRegisterHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { username, password } = body; +export function handlePostCustodialRegister( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postCustodialRegister"); - if (!username || !password) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "username and password are required" }; - return; - } + return async (ctx) => { + log.info("postCustodialRegister"); + try { + const body = await ctx.request.body.json(); + const { username, password } = body; - if (username.length < 3 || username.length > 50) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Username must be 3-50 characters" }; - return; - } + if (!username || !password) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "username and password are required" }; + return; + } - if (password.length < 8) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Password must be at least 8 characters" }; - return; - } + if (username.length < 3 || username.length > 50) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Username must be 3-50 characters" }; + return; + } - const existing = await accountRepo.findByUsername(username); - if (existing) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Registration failed" }; - return; - } + if (password.length < 8) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "Password must be at least 8 characters", + }; + return; + } + + const existing = await accountRepo.findByUsername(username); + if (existing) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Registration failed" }; + return; + } - const passwordHashValue = await hashPassword(password); - const depositAddress = generateDepositAddress(); - const accountId = crypto.randomUUID(); + const passwordHashValue = await hashPassword(password); + const depositAddress = generateDepositAddress(); + const accountId = crypto.randomUUID(); - await accountRepo.create({ - id: accountId, - username, - passwordHash: passwordHashValue, - depositAddress, - balance: 0n, - createdAt: new Date(), - updatedAt: new Date(), - }); + await accountRepo.create({ + id: accountId, + username, + passwordHash: passwordHashValue, + depositAddress, + balance: 0n, + createdAt: new Date(), + updatedAt: new Date(), + }); - const token = await generateJwt(accountId, crypto.randomUUID(), { - type: "custodial", - }); + const token = await generateJwt(accountId, crypto.randomUUID(), { + type: "custodial", + }); - LOG.info("Custodial account registered", { username }); + log.debug("username", username); + log.event("custodial account registered"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Account created", - data: { - token, - depositAddress, - }, - }; - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Account created", + data: { + token, + depositAddress, + }, + }; + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid request body" }; + } + }; +} diff --git a/src/http/v1/pay/custodial/send.ts b/src/http/v1/pay/custodial/send.ts index da0f0f8..8a8bfba 100644 --- a/src/http/v1/pay/custodial/send.ts +++ b/src/http/v1/pay/custodial/send.ts @@ -15,13 +15,21 @@ import { import { PayKycStatus } from "@/persistence/drizzle/entity/pay-kyc.entity.ts"; import { createEscrow } from "@/core/service/pay/escrow.service.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const kycRepo = new PayKycRepository(drizzleClient); -export const postCustodialSendHandler = async (ctx: Context) => { +export const handlePostCustodialSend = ( + deps: { log: Logger }, +): (ctx: Context) => Promise => +async (ctx) => { + const log = deps.log.scope("postCustodialSend"); + log.info("postCustodialSend"); try { const body = await ctx.request.body.json(); const { to, amount } = body; + log.debug("to", to); + log.debug("amount", amount); if (!to || !amount) { ctx.response.status = Status.BadRequest; @@ -64,14 +72,14 @@ export const postCustodialSendHandler = async (ctx: Context) => { } const accountId = session.sub; + log.debug("accountId", accountId); - // Check receiver KYC before entering transaction (read doesn't need the lock, - // and using the module-level kycRepo inside a drizzleClient.transaction() - // uses a separate connection — not part of the transaction) + log.event("checking receiver KYC status"); const receiverKyc = await kycRepo.findByAddress(to); const receiverIsVerified = receiverKyc?.status === PayKycStatus.VERIFIED; + log.debug("receiverVerified", receiverIsVerified); - // Atomic balance debit within a DB transaction with row-level locking + log.event("executing atomic debit transaction"); const result = await drizzleClient.transaction(async (tx) => { // SELECT with FOR UPDATE to lock the row const [account] = await tx @@ -133,26 +141,28 @@ export const postCustodialSendHandler = async (ctx: Context) => { }); if ("error" in result) { + log.event("debit rejected"); + log.debug("rejectionReason", result.error); ctx.response.status = result.status!; ctx.response.body = { message: result.error }; return; } const { txId, isVerified, depositAddress } = result; + log.debug("txId", txId); let escrowId: string | undefined; if (!isVerified) { + log.event("creating escrow for unverified receiver"); escrowId = await createEscrow({ senderAddress: depositAddress, receiverAddress: to, amount: sendAmount, mode: "custodial", bundleId: txId, - }); + }, deps); } - // TODO: Build privacy bundle and submit to mempool - ctx.response.status = Status.OK; ctx.response.body = { message: isVerified @@ -164,7 +174,9 @@ export const postCustodialSendHandler = async (ctx: Context) => { escrowId, }, }; - } catch { + log.event("custodial send succeeded"); + } catch (error) { + log.error(error, "custodial send failed"); ctx.response.status = Status.BadRequest; ctx.response.body = { message: "Invalid request body" }; } diff --git a/src/http/v1/pay/demo/simulate-kyc.ts b/src/http/v1/pay/demo/simulate-kyc.ts index 3808ccd..2fdb9ef 100644 --- a/src/http/v1/pay/demo/simulate-kyc.ts +++ b/src/http/v1/pay/demo/simulate-kyc.ts @@ -3,65 +3,69 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayKycRepository } from "@/persistence/drizzle/repository/pay-kyc.repository.ts"; import { PayKycStatus } from "@/persistence/drizzle/entity/pay-kyc.entity.ts"; import { claimEscrowForAddress } from "@/core/service/pay/escrow.service.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const kycRepo = new PayKycRepository(drizzleClient); -export const postSimulateKycHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { address, jurisdiction } = body; +export function handlePostSimulateKyc( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postSimulateKyc"); - if (!address || !jurisdiction) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "address and jurisdiction are required" }; - return; - } + return async (ctx) => { + log.info("postSimulateKyc"); + try { + const body = await ctx.request.body.json(); + const { address, jurisdiction } = body; - // Set KYC to VERIFIED - const existing = await kycRepo.findByAddress(address); - if (existing) { - await kycRepo.update(existing.id, { - status: PayKycStatus.VERIFIED, - jurisdiction, - verifiedAt: new Date(), - updatedAt: new Date(), - }); - } else { - await kycRepo.create({ - id: crypto.randomUUID(), - address, - status: PayKycStatus.VERIFIED, - jurisdiction, - verifiedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }); - } + if (!address || !jurisdiction) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "address and jurisdiction are required", + }; + return; + } - // Claim any held escrow for this address - const escrowResult = await claimEscrowForAddress(address); + const existing = await kycRepo.findByAddress(address); + if (existing) { + await kycRepo.update(existing.id, { + status: PayKycStatus.VERIFIED, + jurisdiction, + verifiedAt: new Date(), + updatedAt: new Date(), + }); + } else { + await kycRepo.create({ + id: crypto.randomUUID(), + address, + status: PayKycStatus.VERIFIED, + jurisdiction, + verifiedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + } - LOG.info("KYC simulated + escrow claimed", { - address, - escrowClaimed: escrowResult.claimed, - escrowAmount: escrowResult.totalAmount.toString(), - }); + const escrowResult = await claimEscrowForAddress(address, { log }); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "KYC simulated", - data: { - status: "VERIFIED", - escrowClaimed: escrowResult.claimed, - escrowAmount: escrowResult.totalAmount.toString(), - }, - }; - } catch (error) { - LOG.warn("Simulate KYC failed", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } -}; + log.debug("address", address); + log.debug("escrowClaimed", escrowResult.claimed); + log.debug("escrowAmount", escrowResult.totalAmount.toString()); + log.event("KYC simulated + escrow claimed"); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "KYC simulated", + data: { + status: "VERIFIED", + escrowClaimed: escrowResult.claimed, + escrowAmount: escrowResult.totalAmount.toString(), + }, + }; + } catch (error) { + log.error(error, "simulate KYC failed"); + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid request body" }; + } + }; +} diff --git a/src/http/v1/pay/escrow/summary.ts b/src/http/v1/pay/escrow/summary.ts index 165e5fc..da20a60 100644 --- a/src/http/v1/pay/escrow/summary.ts +++ b/src/http/v1/pay/escrow/summary.ts @@ -3,64 +3,69 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayCustodialAccountRepository } from "@/persistence/drizzle/repository/pay-custodial-account.repository.ts"; import { getEscrowSummary } from "@/core/service/pay/escrow.service.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const accountRepo = new PayCustodialAccountRepository(drizzleClient); type RouteParams = { address?: string }; -export const getEscrowSummaryHandler = async (ctx: Context) => { - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const address = params?.address; +export function handleGetEscrowSummary( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getEscrowSummary"); - if (!address) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Address is required" }; - return; - } + return async (ctx) => { + log.info("getEscrowSummary"); + try { + const params = (ctx as unknown as { params?: RouteParams }).params; + const address = params?.address; - // Ownership check: ensure the address belongs to the authenticated user - const session = ctx.state.session as JwtSessionData; - if (session.type === "custodial") { - const account = await accountRepo.findById(session.sub); - if (!account || account.depositAddress !== address) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { - message: "Address does not belong to this account", - }; + if (!address) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Address is required" }; return; } - } else if (!session.type || session.type === "sep10") { - // Self-custodial: session.sub is the Stellar address - if (address !== session.sub) { + + // Ownership check: ensure the address belongs to the authenticated user + const session = ctx.state.session as JwtSessionData; + if (session.type === "custodial") { + const account = await accountRepo.findById(session.sub); + if (!account || account.depositAddress !== address) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { + message: "Address does not belong to this account", + }; + return; + } + } else if (!session.type || session.type === "sep10") { + // Self-custodial: session.sub is the Stellar address + if (address !== session.sub) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { + message: "Address does not match authenticated account", + }; + return; + } + } else { ctx.response.status = Status.Forbidden; - ctx.response.body = { - message: "Address does not match authenticated account", - }; + ctx.response.body = { message: "Unknown session type" }; return; } - } else { - ctx.response.status = Status.Forbidden; - ctx.response.body = { message: "Unknown session type" }; - return; - } - const summary = await getEscrowSummary(address); + const summary = await getEscrowSummary(address, { log }); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Escrow summary retrieved", - data: { - count: summary.count, - totalAmount: summary.totalAmount.toString(), - }, - }; - } catch (error) { - LOG.warn("Get escrow summary failed", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve escrow summary" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Escrow summary retrieved", + data: { + count: summary.count, + totalAmount: summary.totalAmount.toString(), + }, + }; + } catch (error) { + log.error(error, "get escrow summary failed"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to retrieve escrow summary" }; + } + }; +} diff --git a/src/http/v1/pay/kyc/get.ts b/src/http/v1/pay/kyc/get.ts index 438f0ec..c6a6ba3 100644 --- a/src/http/v1/pay/kyc/get.ts +++ b/src/http/v1/pay/kyc/get.ts @@ -3,64 +3,68 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayKycRepository } from "@/persistence/drizzle/repository/pay-kyc.repository.ts"; import { PayCustodialAccountRepository } from "@/persistence/drizzle/repository/pay-custodial-account.repository.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const kycRepo = new PayKycRepository(drizzleClient); const accountRepo = new PayCustodialAccountRepository(drizzleClient); type RouteParams = { address?: string }; -export const getKycHandler = async (ctx: Context) => { - try { - const params = (ctx as unknown as { params?: RouteParams }).params; - const address = params?.address; +export function handleGetKyc( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getKyc"); - if (!address) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Address is required" }; - return; - } + return async (ctx) => { + log.info("getKyc"); + try { + const params = (ctx as unknown as { params?: RouteParams }).params; + const address = params?.address; - // Ownership check: ensure the address belongs to the authenticated user - const session = ctx.state.session as JwtSessionData; - if (session.type === "custodial") { - const account = await accountRepo.findById(session.sub); - if (!account || account.depositAddress !== address) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { - message: "Address does not belong to this account", - }; + if (!address) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Address is required" }; return; } - } else if (!session.type || session.type === "sep10") { - if (address !== session.sub) { + + const session = ctx.state.session as JwtSessionData; + if (session.type === "custodial") { + const account = await accountRepo.findById(session.sub); + if (!account || account.depositAddress !== address) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { + message: "Address does not belong to this account", + }; + return; + } + } else if (!session.type || session.type === "sep10") { + if (address !== session.sub) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { + message: "Address does not match authenticated account", + }; + return; + } + } else { ctx.response.status = Status.Forbidden; - ctx.response.body = { - message: "Address does not match authenticated account", - }; + ctx.response.body = { message: "Unknown session type" }; return; } - } else { - ctx.response.status = Status.Forbidden; - ctx.response.body = { message: "Unknown session type" }; - return; - } - const kyc = await kycRepo.findByAddress(address); + const kyc = await kycRepo.findByAddress(address); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "KYC status retrieved", - data: { - status: kyc?.status ?? "NONE", - jurisdiction: kyc?.jurisdiction ?? null, - }, - }; - } catch (error) { - LOG.warn("Get KYC status failed", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve KYC status" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "KYC status retrieved", + data: { + status: kyc?.status ?? "NONE", + jurisdiction: kyc?.jurisdiction ?? null, + }, + }; + } catch (error) { + log.error(error, "get KYC status failed"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to retrieve KYC status" }; + } + }; +} diff --git a/src/http/v1/pay/kyc/post.ts b/src/http/v1/pay/kyc/post.ts index c7daf59..7ce1c82 100644 --- a/src/http/v1/pay/kyc/post.ts +++ b/src/http/v1/pay/kyc/post.ts @@ -4,70 +4,88 @@ import { PayKycRepository } from "@/persistence/drizzle/repository/pay-kyc.repos import { PayCustodialAccountRepository } from "@/persistence/drizzle/repository/pay-custodial-account.repository.ts"; import { PayKycStatus } from "@/persistence/drizzle/entity/pay-kyc.entity.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const kycRepo = new PayKycRepository(drizzleClient); const accountRepo = new PayCustodialAccountRepository(drizzleClient); -export const postKycHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { address, jurisdiction } = body; +export function handlePostKyc( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postKyc"); - if (!address || !jurisdiction) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "address and jurisdiction are required" }; - return; - } + return async (ctx) => { + log.info("postKyc"); + try { + const body = await ctx.request.body.json(); + const { address, jurisdiction } = body; - const session = ctx.state.session as JwtSessionData; + log.debug("address", address); + log.debug("jurisdiction", jurisdiction); - // Ownership check: ensure the address belongs to the authenticated user - if (session.type === "custodial") { - // For custodial users, session.sub is the account UUID — look up the deposit address - const account = await accountRepo.findById(session.sub); - if (!account || account.depositAddress !== address) { - ctx.response.status = Status.Forbidden; + if (!address || !jurisdiction) { + ctx.response.status = Status.BadRequest; ctx.response.body = { - message: "Address does not belong to this account", + message: "address and jurisdiction are required", }; return; } - } else { - // For self-custodial (SEP-10), session.sub IS the Stellar address - if (address !== session.sub) { - ctx.response.status = Status.Forbidden; - ctx.response.body = { - message: "Address does not match authenticated account", - }; - return; + + const session = ctx.state.session as JwtSessionData; + log.debug("sessionType", session.type); + + // Ownership check: ensure the address belongs to the authenticated user + log.event("verifying address ownership"); + if (session.type === "custodial") { + const account = await accountRepo.findById(session.sub); + if (!account || account.depositAddress !== address) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { + message: "Address does not belong to this account", + }; + return; + } + } else { + if (address !== session.sub) { + ctx.response.status = Status.Forbidden; + ctx.response.body = { + message: "Address does not match authenticated account", + }; + return; + } } - } - const existing = await kycRepo.findByAddress(address); - if (existing) { - await kycRepo.update(existing.id, { - jurisdiction, - status: PayKycStatus.PENDING, - updatedAt: new Date(), - }); - } else { - await kycRepo.create({ - id: crypto.randomUUID(), - address, - jurisdiction, - status: PayKycStatus.PENDING, - createdAt: new Date(), - updatedAt: new Date(), - }); - } + log.event("looking up existing KYC record"); + const existing = await kycRepo.findByAddress(address); + if (existing) { + log.event("updating existing KYC record"); + await kycRepo.update(existing.id, { + jurisdiction, + status: PayKycStatus.PENDING, + updatedAt: new Date(), + }); + } else { + log.event("creating new KYC record"); + await kycRepo.create({ + id: crypto.randomUUID(), + address, + jurisdiction, + status: PayKycStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + }); + } - ctx.response.status = Status.OK; - ctx.response.body = { - message: "KYC submitted", - data: { status: PayKycStatus.PENDING }, - }; - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "KYC submitted", + data: { status: PayKycStatus.PENDING }, + }; + log.event("KYC submission succeeded"); + } catch (error) { + log.error(error, "post KYC failed"); + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid request body" }; + } + }; +} diff --git a/src/http/v1/pay/report/post.ts b/src/http/v1/pay/report/post.ts index df7fa2d..564a8bf 100644 --- a/src/http/v1/pay/report/post.ts +++ b/src/http/v1/pay/report/post.ts @@ -1,5 +1,5 @@ import { type Context, Status } from "@oak/oak"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; /** * POST /pay/report @@ -7,37 +7,43 @@ import { LOG } from "@/config/logger.ts"; * Receives error reports from moonlight-pay apps. * Logs them for now — a proper error tracking pipeline can be added later. */ -export const postReportHandler = async (ctx: Context) => { - try { - const body = await ctx.request.body.json(); - const { description, steps, debug } = body; +export function handlePostReport( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postReport"); - if (!description || typeof description !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "description is required and must be a string", - }; - return; - } + return async (ctx) => { + log.info("postReport"); + try { + const body = await ctx.request.body.json(); + const { description, steps, debug } = body; + + if (!description || typeof description !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "description is required and must be a string", + }; + return; + } - const truncate = (v: unknown, max: number) => - typeof v === "string" ? v.slice(0, max) : undefined; + const truncate = (v: unknown, max: number) => + typeof v === "string" ? v.slice(0, max) : undefined; - LOG.info("Error report received", { - description: truncate(description, 500), - steps: truncate(steps, 500), - userAgent: truncate(debug?.userAgent, 500), - url: truncate(debug?.url, 500), - timestamp: truncate(debug?.timestamp, 100), - }); + log.debug("description", truncate(description, 500)); + log.debug("steps", truncate(steps, 500)); + log.debug("userAgent", truncate(debug?.userAgent, 500)); + log.debug("url", truncate(debug?.url, 500)); + log.debug("timestamp", truncate(debug?.timestamp, 100)); + log.event("error report received"); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Report received", - data: { id: crypto.randomUUID() }, - }; - } catch { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid request body" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Report received", + data: { id: crypto.randomUUID() }, + }; + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid request body" }; + } + }; +} diff --git a/src/http/v1/pay/routes.ts b/src/http/v1/pay/routes.ts index 6ec4f8c..90dabab 100644 --- a/src/http/v1/pay/routes.ts +++ b/src/http/v1/pay/routes.ts @@ -1,55 +1,79 @@ import { Router } from "@oak/oak"; import { jwtMiddleware } from "@/http/middleware/auth/index.ts"; -import { getKycHandler } from "@/http/v1/pay/kyc/get.ts"; -import { postKycHandler } from "@/http/v1/pay/kyc/post.ts"; -import { listTransactionsHandler } from "@/http/v1/pay/transactions/list.ts"; -import { postSelfBalanceHandler } from "@/http/v1/pay/self/balance.ts"; -import { postSelfSendHandler } from "@/http/v1/pay/self/send.ts"; -import { getCustodialAccountHandler } from "@/http/v1/pay/custodial/account.ts"; -import { postCustodialSendHandler } from "@/http/v1/pay/custodial/send.ts"; -import { postCustodialLoginHandler } from "@/http/v1/pay/custodial/login.ts"; -import { postCustodialRegisterHandler } from "@/http/v1/pay/custodial/register.ts"; -import { postSimulateKycHandler } from "@/http/v1/pay/demo/simulate-kyc.ts"; -import { getEscrowSummaryHandler } from "@/http/v1/pay/escrow/summary.ts"; -import { postReportHandler } from "@/http/v1/pay/report/post.ts"; -import { LOG } from "@/config/logger.ts"; +import { handleGetKyc } from "@/http/v1/pay/kyc/get.ts"; +import { handlePostKyc } from "@/http/v1/pay/kyc/post.ts"; +import { handleListTransactions } from "@/http/v1/pay/transactions/list.ts"; +import { handlePostSelfBalance } from "@/http/v1/pay/self/balance.ts"; +import { handlePostSelfSend } from "@/http/v1/pay/self/send.ts"; +import { handleGetCustodialAccount } from "@/http/v1/pay/custodial/account.ts"; +import { handlePostCustodialSend } from "@/http/v1/pay/custodial/send.ts"; +import { handlePostCustodialLogin } from "@/http/v1/pay/custodial/login.ts"; +import { handlePostCustodialRegister } from "@/http/v1/pay/custodial/register.ts"; +import { handlePostSimulateKyc } from "@/http/v1/pay/demo/simulate-kyc.ts"; +import { handleGetEscrowSummary } from "@/http/v1/pay/escrow/summary.ts"; +import { handlePostReport } from "@/http/v1/pay/report/post.ts"; +import type { Logger } from "@/utils/logger/index.ts"; import { loadOptionalEnv } from "@/utils/env/loadEnv.ts"; -const payRouter = new Router(); +export function buildPayRouter(deps: { log: Logger }): Router { + const log = deps.log.scope("pay.routes"); + const payRouter = new Router(); -// --- Public auth endpoints (no JWT) --- -payRouter.post("/pay/custodial/login", postCustodialLoginHandler); -payRouter.post("/pay/custodial/register", postCustodialRegisterHandler); + // --- Public auth endpoints (no JWT) --- + payRouter.post("/pay/custodial/login", handlePostCustodialLogin(deps)); + payRouter.post("/pay/custodial/register", handlePostCustodialRegister(deps)); -// --- Authenticated endpoints --- -payRouter.get("/pay/kyc/:address", jwtMiddleware, getKycHandler); -payRouter.post("/pay/kyc", jwtMiddleware, postKycHandler); -payRouter.get("/pay/transactions", jwtMiddleware, listTransactionsHandler); -payRouter.post("/pay/self/balance", jwtMiddleware, postSelfBalanceHandler); -payRouter.post("/pay/self/send", jwtMiddleware, postSelfSendHandler); -payRouter.get( - "/pay/custodial/account", - jwtMiddleware, - getCustodialAccountHandler, -); -payRouter.post("/pay/custodial/send", jwtMiddleware, postCustodialSendHandler); -payRouter.get("/pay/escrow/:address", jwtMiddleware, getEscrowSummaryHandler); -payRouter.post("/pay/report", jwtMiddleware, postReportHandler); - -// --- Demo endpoints (local/standalone only) --- -const networkEnv = loadOptionalEnv("NETWORK") ?? ""; -const demoEnabled = loadOptionalEnv("PAY_DEMO_ENABLED") === "true"; -if (networkEnv === "local" || networkEnv === "standalone" || demoEnabled) { - LOG.info("Pay demo routes enabled", { network: networkEnv, demoEnabled }); + // --- Authenticated endpoints --- + payRouter.get("/pay/kyc/:address", jwtMiddleware(deps), handleGetKyc(deps)); + payRouter.post("/pay/kyc", jwtMiddleware(deps), handlePostKyc(deps)); + payRouter.get( + "/pay/transactions", + jwtMiddleware(deps), + handleListTransactions(deps), + ); payRouter.post( - "/pay/demo/simulate-kyc", - jwtMiddleware, - postSimulateKycHandler, + "/pay/self/balance", + jwtMiddleware(deps), + handlePostSelfBalance(deps), ); -} else { - LOG.info("Pay demo routes disabled (non-local network)", { - network: networkEnv, - }); -} + payRouter.post( + "/pay/self/send", + jwtMiddleware(deps), + handlePostSelfSend(deps), + ); + payRouter.get( + "/pay/custodial/account", + jwtMiddleware(deps), + handleGetCustodialAccount(deps), + ); + payRouter.post( + "/pay/custodial/send", + jwtMiddleware(deps), + handlePostCustodialSend(deps), + ); + payRouter.get( + "/pay/escrow/:address", + jwtMiddleware(deps), + handleGetEscrowSummary(deps), + ); + payRouter.post("/pay/report", jwtMiddleware(deps), handlePostReport(deps)); -export default payRouter; + // --- Demo endpoints (local/standalone only) --- + const networkEnv = loadOptionalEnv("NETWORK") ?? ""; + const demoEnabled = loadOptionalEnv("PAY_DEMO_ENABLED") === "true"; + if (networkEnv === "local" || networkEnv === "standalone" || demoEnabled) { + log.debug("network", networkEnv); + log.debug("demoEnabled", demoEnabled); + log.event("pay demo routes enabled"); + payRouter.post( + "/pay/demo/simulate-kyc", + jwtMiddleware(deps), + handlePostSimulateKyc(deps), + ); + } else { + log.debug("network", networkEnv); + log.event("pay demo routes disabled (non-local network)"); + } + + return payRouter; +} diff --git a/src/http/v1/pay/self/balance.ts b/src/http/v1/pay/self/balance.ts index 641ae75..0b6bd0c 100644 --- a/src/http/v1/pay/self/balance.ts +++ b/src/http/v1/pay/self/balance.ts @@ -6,7 +6,7 @@ import { queryBalances, } from "@/core/service/pay/channel.service.ts"; import { resolveChannelClient } from "@/core/service/executor/channel-resolver.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; /** * POST /pay/self/balance @@ -14,7 +14,7 @@ import { LOG } from "@/config/logger.ts"; * Queries on-chain UTXO balances for the authenticated self-custodial user. * Accepts hex-encoded P256 public keys and returns per-UTXO and total balances. * - * Body: { publicKeys: string[] } — hex-encoded P256 public keys + * Body: { publicKeys: string[], channelContractId: string } * Response: { * totalBalance: string, * utxoCount: number, @@ -22,80 +22,91 @@ import { LOG } from "@/config/logger.ts"; * utxos: Array<{ publicKey: string, balance: string }> * } */ -export const postSelfBalanceHandler = async (ctx: Context) => { - const session = ctx.state.session as JwtSessionData; +export function handlePostSelfBalance( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postSelfBalance"); - // Reject custodial JWTs — this endpoint is for self-custodial (SEP-10) users only - if (session.type === "custodial") { - ctx.response.status = Status.Forbidden; - ctx.response.body = { - message: "This endpoint is for self-custodial users only", - }; - return; - } + return async (ctx) => { + log.info("postSelfBalance"); + const session = ctx.state.session as JwtSessionData; - const _accountId = session.sub; - - try { - const body = await ctx.request.body.json(); - const { publicKeys, channelContractId } = body; - - if (!channelContractId || typeof channelContractId !== "string") { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "channelContractId is required" }; - return; - } - - if (!Array.isArray(publicKeys) || publicKeys.length === 0) { - ctx.response.status = Status.BadRequest; + if (session.type === "custodial") { + ctx.response.status = Status.Forbidden; ctx.response.body = { - message: "publicKeys must be a non-empty array of hex strings", + message: "This endpoint is for self-custodial users only", }; return; } - if (publicKeys.length > MAX_UTXO_SLOTS) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: `publicKeys array exceeds maximum of ${MAX_UTXO_SLOTS}`, - }; - return; - } + try { + const body = await ctx.request.body.json(); + const { publicKeys, channelContractId } = body; + + if (!channelContractId || typeof channelContractId !== "string") { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "channelContractId is required" }; + return; + } + + if (!Array.isArray(publicKeys) || publicKeys.length === 0) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "publicKeys must be a non-empty array of hex strings", + }; + return; + } + + if (publicKeys.length > MAX_UTXO_SLOTS) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: `publicKeys array exceeds maximum of ${MAX_UTXO_SLOTS}`, + }; + return; + } - // Convert hex-encoded public keys to Uint8Array - const utxoPublicKeys: Uint8Array[] = publicKeys.map( - (hexKey: string) => new Uint8Array(Buffer.from(hexKey, "hex")), - ); + log.debug("channelContractId", channelContractId); + log.debug("utxoCount", publicKeys.length); - const { channelClient } = await resolveChannelClient(channelContractId); - const balances = await queryBalances( - utxoPublicKeys, - channelClient, - ); + const utxoPublicKeys: Uint8Array[] = publicKeys.map( + (hexKey: string) => new Uint8Array(Buffer.from(hexKey, "hex")), + ); - const totalBalance = balances.reduce((sum, b) => sum + b, 0n); - const utxoCount = balances.filter((b) => b > 0n).length; + log.event("resolving read-only channel client"); + const { channelClient } = await resolveChannelClient( + channelContractId, + deps, + ); + log.event("querying balances"); + const balances = await queryBalances( + utxoPublicKeys, + channelClient, + deps, + ); - const utxos = publicKeys.map((pk: string, i: number) => ({ - publicKey: pk, - balance: balances[i].toString(), - })); + const totalBalance = balances.reduce((sum, b) => sum + b, 0n); + const utxoCount = balances.filter((b) => b > 0n).length; - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Balance retrieved", - data: { - totalBalance: totalBalance.toString(), - utxoCount, - freeSlots: Math.max(0, MAX_UTXO_SLOTS - publicKeys.length), - utxos, - }, - }; - } catch (error) { - LOG.warn("Self balance query failed", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to query balance" }; - } -}; + const utxos = publicKeys.map((pk: string, i: number) => ({ + publicKey: pk, + balance: balances[i].toString(), + })); + + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Balance retrieved", + data: { + totalBalance: totalBalance.toString(), + utxoCount, + freeSlots: Math.max(0, MAX_UTXO_SLOTS - publicKeys.length), + utxos, + }, + }; + log.event("balance response assembled"); + } catch (error) { + log.error(error, "self balance query failed"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to query balance" }; + } + }; +} diff --git a/src/http/v1/pay/self/send.ts b/src/http/v1/pay/self/send.ts index 205617e..33ccced 100644 --- a/src/http/v1/pay/self/send.ts +++ b/src/http/v1/pay/self/send.ts @@ -10,6 +10,7 @@ import { import { PayKycStatus } from "@/persistence/drizzle/entity/pay-kyc.entity.ts"; import { createEscrow } from "@/core/service/pay/escrow.service.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const txRepo = new PayTransactionRepository(drizzleClient); const kycRepo = new PayKycRepository(drizzleClient); @@ -20,10 +21,17 @@ const kycRepo = new PayKycRepository(drizzleClient); * Send from self-custodial wallet. Checks receiver KYC — if unverified, * creates an escrow record instead of direct UTXO transfer. */ -export const postSelfSendHandler = async (ctx: Context) => { +export const handlePostSelfSend = ( + deps: { log: Logger }, +): (ctx: Context) => Promise => +async (ctx) => { + const log = deps.log.scope("postSelfSend"); + log.info("postSelfSend"); try { const body = await ctx.request.body.json(); const { to, amount } = body; + log.debug("to", to); + log.debug("amount", amount); if (!to || !amount) { ctx.response.status = Status.BadRequest; @@ -31,7 +39,6 @@ export const postSelfSendHandler = async (ctx: Context) => { return; } - // Validate `to` is a valid Stellar public key if (typeof to !== "string" || !StrKey.isValidEd25519PublicKey(to)) { ctx.response.status = Status.BadRequest; ctx.response.body = { @@ -40,7 +47,6 @@ export const postSelfSendHandler = async (ctx: Context) => { return; } - // Validate amount is a valid positive integer string if (typeof amount !== "string" || !/^\d+$/.test(amount)) { ctx.response.status = Status.BadRequest; ctx.response.body = { @@ -58,7 +64,6 @@ export const postSelfSendHandler = async (ctx: Context) => { const session = ctx.state.session as JwtSessionData; - // Reject custodial JWTs — this endpoint is for self-custodial (SEP-10) users only if (session.type === "custodial") { ctx.response.status = Status.Forbidden; ctx.response.body = { @@ -68,13 +73,16 @@ export const postSelfSendHandler = async (ctx: Context) => { } const accountId = session.sub; + log.debug("accountId", accountId); - // Check receiver KYC status + log.event("checking receiver KYC status"); const receiverKyc = await kycRepo.findByAddress(to); const isVerified = receiverKyc?.status === PayKycStatus.VERIFIED; + log.debug("receiverVerified", isVerified); - // Create transaction record const txId = crypto.randomUUID(); + log.debug("txId", txId); + log.event("creating transaction record"); await txRepo.create({ id: txId, type: PayTransactionType.SEND, @@ -93,18 +101,16 @@ export const postSelfSendHandler = async (ctx: Context) => { let escrowId: string | undefined; if (!isVerified) { - // Receiver not KYC'd — create escrow + log.event("creating escrow for unverified receiver"); escrowId = await createEscrow({ senderAddress: accountId, receiverAddress: to, amount: sendAmount, mode: "self", bundleId: txId, - }); + }, deps); } - // TODO: Build privacy operations and submit bundle to mempool - ctx.response.status = Status.OK; ctx.response.body = { message: isVerified @@ -116,7 +122,9 @@ export const postSelfSendHandler = async (ctx: Context) => { escrowId, }, }; - } catch { + log.event("self send succeeded"); + } catch (error) { + log.error(error, "self send failed"); ctx.response.status = Status.BadRequest; ctx.response.body = { message: "Invalid request body" }; } diff --git a/src/http/v1/pay/tests/custodial_account_test.ts b/src/http/v1/pay/tests/custodial_account_test.ts index 6e8d241..90762c6 100644 --- a/src/http/v1/pay/tests/custodial_account_test.ts +++ b/src/http/v1/pay/tests/custodial_account_test.ts @@ -5,7 +5,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/custodial_account_test.ts */ import { assertEquals } from "@std/assert"; -import { getCustodialAccountHandler } from "@/http/v1/pay/custodial/account.ts"; +import { newNoop } from "@/utils/logger/index.ts"; +import { handleGetCustodialAccount } from "@/http/v1/pay/custodial/account.ts"; import { createTestAccount, ensureInitialized, @@ -22,7 +23,7 @@ type MockResponse = { status: number; body: unknown }; function createMockContext( session: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -79,7 +80,7 @@ Deno.test("custodial account - returns account info for valid session", async () const { ctx, getResponse } = createMockContext(custodialSession(account.id)); - await getCustodialAccountHandler(ctx); + await handleGetCustodialAccount({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -114,7 +115,7 @@ Deno.test("custodial account - returns 404 for non-existent account", async () = const fakeId = crypto.randomUUID(); const { ctx, getResponse } = createMockContext(custodialSession(fakeId)); - await getCustodialAccountHandler(ctx); + await handleGetCustodialAccount({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 404); @@ -133,7 +134,7 @@ Deno.test("custodial account - returns zero balance as '0'", async () => { const { ctx, getResponse } = createMockContext(custodialSession(account.id)); - await getCustodialAccountHandler(ctx); + await handleGetCustodialAccount({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -149,7 +150,7 @@ Deno.test("custodial account - returns large balance as string", async () => { const { ctx, getResponse } = createMockContext(custodialSession(account.id)); - await getCustodialAccountHandler(ctx); + await handleGetCustodialAccount({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -172,7 +173,7 @@ Deno.test("custodial account - suspended account returns status 'suspended'", as const { ctx, getResponse } = createMockContext(custodialSession(account.id)); - await getCustodialAccountHandler(ctx); + await handleGetCustodialAccount({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); diff --git a/src/http/v1/pay/tests/custodial_login_test.ts b/src/http/v1/pay/tests/custodial_login_test.ts index 4fb4ea9..b5d2f3a 100644 --- a/src/http/v1/pay/tests/custodial_login_test.ts +++ b/src/http/v1/pay/tests/custodial_login_test.ts @@ -6,7 +6,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/custodial_login_test.ts */ import { assert, assertEquals } from "@std/assert"; -import { postCustodialLoginHandler } from "@/http/v1/pay/custodial/login.ts"; +import { newNoop } from "@/utils/logger/index.ts"; +import { handlePostCustodialLogin } from "@/http/v1/pay/custodial/login.ts"; import { createTestAccount, ensureInitialized, @@ -23,7 +24,7 @@ type MockResponse = { status: number; body: unknown }; function createMockContext( body: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -73,7 +74,7 @@ Deno.test("custodial login - valid login returns 200 and token", async () => { password, }); - await postCustodialLoginHandler(ctx); + await handlePostCustodialLogin({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -106,7 +107,7 @@ Deno.test("custodial login - wrong password returns 401", async () => { password: "wrong-password-123", }); - await postCustodialLoginHandler(ctx); + await handlePostCustodialLogin({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 401); @@ -129,7 +130,7 @@ Deno.test("custodial login - non-existent username returns 401", async () => { password: "some-password-123", }); - await postCustodialLoginHandler(ctx); + await handlePostCustodialLogin({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 401); @@ -159,7 +160,7 @@ Deno.test("custodial login - suspended account returns 401 (same as invalid cred password, }); - await postCustodialLoginHandler(ctx); + await handlePostCustodialLogin({ log: newNoop() })(ctx); const res = getResponse(); // Suspended accounts get the same response as invalid credentials @@ -183,7 +184,7 @@ Deno.test("custodial login - missing username returns 400", async () => { password: "some-password-123", }); - await postCustodialLoginHandler(ctx); + await handlePostCustodialLogin({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -201,7 +202,7 @@ Deno.test("custodial login - missing password returns 400", async () => { username: "some_user", }); - await postCustodialLoginHandler(ctx); + await handlePostCustodialLogin({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -217,7 +218,7 @@ Deno.test("custodial login - empty body returns 400", async () => { const { ctx, getResponse } = createMockContext({}); - await postCustodialLoginHandler(ctx); + await handlePostCustodialLogin({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); diff --git a/src/http/v1/pay/tests/custodial_register_test.ts b/src/http/v1/pay/tests/custodial_register_test.ts index 6919991..c1d7a96 100644 --- a/src/http/v1/pay/tests/custodial_register_test.ts +++ b/src/http/v1/pay/tests/custodial_register_test.ts @@ -6,8 +6,9 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/custodial_register_test.ts */ import { assert, assertEquals } from "@std/assert"; +import { newNoop } from "@/utils/logger/index.ts"; import { StrKey } from "@colibri/core"; -import { postCustodialRegisterHandler } from "@/http/v1/pay/custodial/register.ts"; +import { handlePostCustodialRegister } from "@/http/v1/pay/custodial/register.ts"; import { createTestAccount, ensureInitialized, @@ -24,7 +25,7 @@ type MockResponse = { status: number; body: unknown }; function createMockContext( body: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -72,7 +73,7 @@ Deno.test("custodial register - successful registration returns token and deposi password: "secure-password-123", }); - await postCustodialRegisterHandler(ctx); + await handlePostCustodialRegister({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -110,7 +111,7 @@ Deno.test("custodial register - duplicate username returns 400", async () => { password: "another-password-123", }); - await postCustodialRegisterHandler(ctx); + await handlePostCustodialRegister({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -133,7 +134,7 @@ Deno.test("custodial register - username too short returns 400", async () => { password: "secure-password-123", }); - await postCustodialRegisterHandler(ctx); + await handlePostCustodialRegister({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -156,7 +157,7 @@ Deno.test("custodial register - password too short returns 400", async () => { password: "short", }); - await postCustodialRegisterHandler(ctx); + await handlePostCustodialRegister({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -178,7 +179,7 @@ Deno.test("custodial register - missing username returns 400", async () => { password: "secure-password-123", }); - await postCustodialRegisterHandler(ctx); + await handlePostCustodialRegister({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -196,7 +197,7 @@ Deno.test("custodial register - missing password returns 400", async () => { username: testUsername(), }); - await postCustodialRegisterHandler(ctx); + await handlePostCustodialRegister({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -212,7 +213,7 @@ Deno.test("custodial register - empty body returns 400", async () => { const { ctx, getResponse } = createMockContext({}); - await postCustodialRegisterHandler(ctx); + await handlePostCustodialRegister({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); diff --git a/src/http/v1/pay/tests/custodial_send_test.ts b/src/http/v1/pay/tests/custodial_send_test.ts index d1bb376..443a57d 100644 --- a/src/http/v1/pay/tests/custodial_send_test.ts +++ b/src/http/v1/pay/tests/custodial_send_test.ts @@ -5,7 +5,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/custodial_send_test.ts */ import { assertEquals, assertExists } from "@std/assert"; -import { postCustodialSendHandler } from "@/http/v1/pay/custodial/send.ts"; +import { handlePostCustodialSend } from "@/http/v1/pay/custodial/send.ts"; +import { newNoop } from "@/utils/logger/index.ts"; import { createTestAccount, createTestKyc, @@ -31,7 +32,7 @@ function createMockContext( body: unknown, session: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -104,7 +105,7 @@ Deno.test("custodial send - successful send debits balance and creates SEND tran custodialSession(account.id), ); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -141,7 +142,7 @@ Deno.test("custodial send - send to unverified address creates escrow", async () custodialSession(account.id), ); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -173,7 +174,7 @@ Deno.test("custodial send - insufficient balance returns 400", async () => { custodialSession(account.id), ); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); assertEquals( @@ -200,7 +201,7 @@ Deno.test("custodial send - suspended account returns 403", async () => { custodialSession(account.id), ); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 403); assertEquals( @@ -223,7 +224,7 @@ Deno.test("custodial send - non-custodial JWT type returns 403", async () => { sep10Session(account.id), ); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 403); assertEquals( @@ -250,7 +251,7 @@ Deno.test("custodial send - concurrent sends don't double-spend", async () => { { to: receiverAddress, amount: "10000000" }, custodialSession(account.id), ); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); return getResponse(); })(), (async () => { @@ -258,7 +259,7 @@ Deno.test("custodial send - concurrent sends don't double-spend", async () => { { to: receiverAddress, amount: "10000000" }, custodialSession(account.id), ); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); return getResponse(); })(), ]); @@ -310,7 +311,7 @@ Deno.test("custodial send - invalid amount 'abc' returns 400", async () => { to: testAddress(), amount: "abc", }, custodialSession(account.id)); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -322,7 +323,7 @@ Deno.test("custodial send - invalid amount '-1' returns 400", async () => { to: testAddress(), amount: "-1", }, custodialSession(account.id)); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -334,7 +335,7 @@ Deno.test("custodial send - invalid amount '1.5' returns 400", async () => { to: testAddress(), amount: "1.5", }, custodialSession(account.id)); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -346,7 +347,7 @@ Deno.test("custodial send - invalid amount '' returns 400", async () => { to: testAddress(), amount: "", }, custodialSession(account.id)); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -362,7 +363,7 @@ Deno.test("custodial send - invalid to address returns 400", async () => { to: "not-an-address", amount: "5000000", }, custodialSession(account.id)); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); assertEquals( (getResponse().body as { message: string }).message, @@ -382,7 +383,7 @@ Deno.test("custodial send - missing 'to' field returns 400", async () => { { amount: "5000000" }, custodialSession(account.id), ); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -394,7 +395,7 @@ Deno.test("custodial send - missing 'amount' field returns 400", async () => { { to: testAddress() }, custodialSession(account.id), ); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -410,7 +411,7 @@ Deno.test("custodial send - zero amount returns 400", async () => { to: testAddress(), amount: "0", }, custodialSession(account.id)); - await postCustodialSendHandler(ctx); + await handlePostCustodialSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); assertEquals( (getResponse().body as { message: string }).message, diff --git a/src/http/v1/pay/tests/demo_simulate_kyc_test.ts b/src/http/v1/pay/tests/demo_simulate_kyc_test.ts index dbab5d0..b9e42ef 100644 --- a/src/http/v1/pay/tests/demo_simulate_kyc_test.ts +++ b/src/http/v1/pay/tests/demo_simulate_kyc_test.ts @@ -5,7 +5,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/demo_simulate_kyc_test.ts */ import { assertEquals } from "@std/assert"; -import { postSimulateKycHandler } from "@/http/v1/pay/demo/simulate-kyc.ts"; +import { newNoop } from "@/utils/logger/index.ts"; +import { handlePostSimulateKyc } from "@/http/v1/pay/demo/simulate-kyc.ts"; import { createTestAccount, createTestEscrow, @@ -33,7 +34,7 @@ type MockResponse = { status: number; body: unknown }; function createMockContext( body: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -82,7 +83,7 @@ Deno.test("demo simulate-kyc - creates VERIFIED KYC record for new address", asy jurisdiction: "US", }); - await postSimulateKycHandler(ctx); + await handlePostSimulateKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -121,7 +122,7 @@ Deno.test("demo simulate-kyc - updates existing PENDING KYC record to VERIFIED", jurisdiction: "EU", }); - await postSimulateKycHandler(ctx); + await handlePostSimulateKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -160,7 +161,7 @@ Deno.test("demo simulate-kyc - claims held custodial escrow after KYC simulation jurisdiction: "US", }); - await postSimulateKycHandler(ctx); + await handlePostSimulateKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -226,7 +227,7 @@ Deno.test("demo simulate-kyc - claims multiple held escrows", async () => { jurisdiction: "US", }); - await postSimulateKycHandler(ctx); + await handlePostSimulateKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -263,7 +264,7 @@ Deno.test("demo simulate-kyc - self-custodial escrow is claimed but no balance c jurisdiction: "US", }); - await postSimulateKycHandler(ctx); + await handlePostSimulateKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -299,7 +300,7 @@ Deno.test("demo simulate-kyc - missing address returns 400", async () => { jurisdiction: "US", }); - await postSimulateKycHandler(ctx); + await handlePostSimulateKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -317,7 +318,7 @@ Deno.test("demo simulate-kyc - missing jurisdiction returns 400", async () => { address: testAddress(), }); - await postSimulateKycHandler(ctx); + await handlePostSimulateKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -333,7 +334,7 @@ Deno.test("demo simulate-kyc - empty body returns 400", async () => { const { ctx, getResponse } = createMockContext({}); - await postSimulateKycHandler(ctx); + await handlePostSimulateKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); diff --git a/src/http/v1/pay/tests/deno.lock b/src/http/v1/pay/tests/deno.lock index 94e624d..b53e66d 100644 --- a/src/http/v1/pay/tests/deno.lock +++ b/src/http/v1/pay/tests/deno.lock @@ -4,6 +4,10 @@ "jsr:@colibri/core@~0.20.2": "0.20.2", "jsr:@fifo/convee@0.10": "0.10.0", "jsr:@fifo/convee@1.0.0": "1.0.0", + "jsr:@moonlight/moonlight-sdk@0.8": "0.8.0", + "jsr:@noble/curves@^1.8.0": "1.9.0", + "jsr:@noble/hashes@1.8.0": "1.8.0", + "jsr:@noble/hashes@^1.6.1": "1.8.0", "jsr:@oak/commons@1": "1.0.1", "jsr:@oak/oak@^17.1.4": "17.2.0", "jsr:@std/assert@*": "1.0.19", @@ -27,6 +31,7 @@ "npm:@stellar/stellar-sdk@^15.0.1": "15.0.1", "npm:@types/node@*": "24.2.0", "npm:asn1js@3.0.5": "3.0.5", + "npm:buffer@6.0.3": "6.0.3", "npm:buffer@^6.0.3": "6.0.3", "npm:chalk@^5.3.0": "5.6.2", "npm:drizzle-kit@~0.31.6": "0.31.9_esbuild@0.25.12", @@ -43,7 +48,7 @@ "jsr:@fifo/convee@1.0.0", "jsr:@std/toml", "npm:@stellar/stellar-sdk", - "npm:buffer" + "npm:buffer@^6.0.3" ] }, "@fifo/convee@0.10.0": { @@ -52,6 +57,26 @@ "@fifo/convee@1.0.0": { "integrity": "b61bfa222b9b8a53f0f2af1f35148fd54d86afc2fc3d6b5d073d56aaa369d9a3" }, + "@moonlight/moonlight-sdk@0.8.0": { + "integrity": "680e75432c2d84707a9d7bf9df9564304066eb08d5dbc88eafdfe167eecfd423", + "dependencies": [ + "jsr:@colibri/core", + "jsr:@noble/curves", + "jsr:@noble/hashes@^1.6.1", + "npm:@stellar/stellar-sdk", + "npm:asn1js", + "npm:buffer@6.0.3" + ] + }, + "@noble/curves@1.9.0": { + "integrity": "efa55b3375b755706462a083060ee91e1f79973568cb670f02e885538ed1661b", + "dependencies": [ + "jsr:@noble/hashes@1.8.0" + ] + }, + "@noble/hashes@1.8.0": { + "integrity": "b52a2fcb4d02f8d8137871564a31f1ee9e2b0d15eedabbf32d2f7333f0abc939" + }, "@oak/commons@1.0.1": { "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c", "dependencies": [ diff --git a/src/http/v1/pay/tests/escrow_service_test.ts b/src/http/v1/pay/tests/escrow_service_test.ts index d3a98ea..7183c85 100644 --- a/src/http/v1/pay/tests/escrow_service_test.ts +++ b/src/http/v1/pay/tests/escrow_service_test.ts @@ -5,6 +5,7 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/escrow_service_test.ts */ import { assert, assertEquals, assertExists } from "@std/assert"; +import { newNoop } from "@/utils/logger/index.ts"; import { claimEscrowForAddress, createEscrow, @@ -43,7 +44,7 @@ Deno.test("createEscrow - creates a HELD record in the database", async () => { receiverAddress, amount: 5000n, mode: "custodial", - }); + }, { log: newNoop() }); assertExists(escrowId, "createEscrow should return an ID"); @@ -80,7 +81,9 @@ Deno.test("claimEscrowForAddress - claims held escrows for custodial account", a mode: "custodial", }); - const result = await claimEscrowForAddress(account.depositAddress); + const result = await claimEscrowForAddress(account.depositAddress, { + log: newNoop(), + }); assertEquals(result.claimed, 1); assertEquals(result.totalAmount, 1000n); @@ -107,7 +110,7 @@ Deno.test("claimEscrowForAddress - returns 0 when no held escrows exist", async const address = testAddress(); await createTestKyc(address, PayKycStatus.VERIFIED); - const result = await claimEscrowForAddress(address); + const result = await claimEscrowForAddress(address, { log: newNoop() }); assertEquals(result.claimed, 0); assertEquals(result.totalAmount, 0n); }); @@ -131,8 +134,8 @@ Deno.test("claimEscrowForAddress - concurrent claims don't double-credit (race c }); const results = await Promise.allSettled([ - claimEscrowForAddress(account.depositAddress), - claimEscrowForAddress(account.depositAddress), + claimEscrowForAddress(account.depositAddress, { log: newNoop() }), + claimEscrowForAddress(account.depositAddress, { log: newNoop() }), ]); const fulfilled = results.filter( @@ -187,7 +190,7 @@ Deno.test("getEscrowSummary - returns correct count and total for held escrows", mode: "custodial", }); - const summary = await getEscrowSummary(receiverAddress); + const summary = await getEscrowSummary(receiverAddress, { log: newNoop() }); assertEquals(summary.count, 2); assertEquals(summary.totalAmount, 10000n); }); @@ -195,7 +198,7 @@ Deno.test("getEscrowSummary - returns correct count and total for held escrows", Deno.test("getEscrowSummary - returns 0 for address with no escrows", async () => { await ensureInitialized(); await resetDb(); - const summary = await getEscrowSummary(testAddress()); + const summary = await getEscrowSummary(testAddress(), { log: newNoop() }); assertEquals(summary.count, 0); assertEquals(summary.totalAmount, 0n); }); @@ -222,7 +225,7 @@ Deno.test("getEscrowSummary - excludes claimed escrows", async () => { status: PayEscrowStatus.CLAIMED, }); - const summary = await getEscrowSummary(receiverAddress); + const summary = await getEscrowSummary(receiverAddress, { log: newNoop() }); assertEquals(summary.count, 1, "Only HELD escrows should be counted"); assertEquals( summary.totalAmount, diff --git a/src/http/v1/pay/tests/escrow_summary_test.ts b/src/http/v1/pay/tests/escrow_summary_test.ts index ee57b5a..e15d98b 100644 --- a/src/http/v1/pay/tests/escrow_summary_test.ts +++ b/src/http/v1/pay/tests/escrow_summary_test.ts @@ -5,7 +5,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/escrow_summary_test.ts */ import { assertEquals } from "@std/assert"; -import { getEscrowSummaryHandler } from "@/http/v1/pay/escrow/summary.ts"; +import { newNoop } from "@/utils/logger/index.ts"; +import { handleGetEscrowSummary } from "@/http/v1/pay/escrow/summary.ts"; import { createTestEscrow, ensureInitialized, @@ -25,7 +26,7 @@ function createMockContext( params: { address?: string }, session?: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -89,7 +90,7 @@ Deno.test("escrow summary - returns correct count and total for held escrows", a { sub: receiverAddress }, ); - await getEscrowSummaryHandler(ctx); + await handleGetEscrowSummary({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -122,7 +123,7 @@ Deno.test("escrow summary - returns 0 for address with no escrows", async () => { sub: addr }, ); - await getEscrowSummaryHandler(ctx); + await handleGetEscrowSummary({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -163,7 +164,7 @@ Deno.test("escrow summary - excludes claimed escrows from count", async () => { { sub: receiverAddress }, ); - await getEscrowSummaryHandler(ctx); + await handleGetEscrowSummary({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -187,7 +188,7 @@ Deno.test("escrow summary - missing address returns 400", async () => { const { ctx, getResponse } = createMockContext({}); - await getEscrowSummaryHandler(ctx); + await handleGetEscrowSummary({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -227,7 +228,7 @@ Deno.test("escrow summary - undefined params returns 400", async () => { }; // deno-lint-ignore no-explicit-any - await getEscrowSummaryHandler(ctx as any); + await handleGetEscrowSummary({ log: newNoop() })(ctx as any); assertEquals(responseStatus, 400); assertEquals( diff --git a/src/http/v1/pay/tests/kyc_get_test.ts b/src/http/v1/pay/tests/kyc_get_test.ts index 4c586a5..6fbf8b7 100644 --- a/src/http/v1/pay/tests/kyc_get_test.ts +++ b/src/http/v1/pay/tests/kyc_get_test.ts @@ -5,7 +5,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/kyc_get_test.ts */ import { assertEquals } from "@std/assert"; -import { getKycHandler } from "@/http/v1/pay/kyc/get.ts"; +import { newNoop } from "@/utils/logger/index.ts"; +import { handleGetKyc } from "@/http/v1/pay/kyc/get.ts"; import { createTestKyc, ensureInitialized, @@ -25,7 +26,7 @@ function createMockContext( params: { address?: string }, session?: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -73,7 +74,7 @@ Deno.test("kyc get - returns VERIFIED status for verified address", async () => const { ctx, getResponse } = createMockContext({ address }, { sub: address }); - await getKycHandler(ctx); + await handleGetKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -102,7 +103,7 @@ Deno.test("kyc get - returns PENDING status for pending address", async () => { const { ctx, getResponse } = createMockContext({ address }, { sub: address }); - await getKycHandler(ctx); + await handleGetKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -124,7 +125,7 @@ Deno.test("kyc get - returns NONE for address with no KYC record", async () => { const address = testAddress(); const { ctx, getResponse } = createMockContext({ address }, { sub: address }); - await getKycHandler(ctx); + await handleGetKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -145,7 +146,7 @@ Deno.test("kyc get - missing address returns 400", async () => { const { ctx, getResponse } = createMockContext({}); - await getKycHandler(ctx); + await handleGetKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -185,7 +186,7 @@ Deno.test("kyc get - undefined params returns 400", async () => { }; // deno-lint-ignore no-explicit-any - await getKycHandler(ctx as any); + await handleGetKyc({ log: newNoop() })(ctx as any); assertEquals(responseStatus, 400); assertEquals( diff --git a/src/http/v1/pay/tests/kyc_post_test.ts b/src/http/v1/pay/tests/kyc_post_test.ts index 74d031b..26d6773 100644 --- a/src/http/v1/pay/tests/kyc_post_test.ts +++ b/src/http/v1/pay/tests/kyc_post_test.ts @@ -5,7 +5,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/kyc_post_test.ts */ import { assertEquals } from "@std/assert"; -import { postKycHandler } from "@/http/v1/pay/kyc/post.ts"; +import { handlePostKyc } from "@/http/v1/pay/kyc/post.ts"; +import { newNoop } from "@/utils/logger/index.ts"; import { createTestAccount, createTestKyc, @@ -26,7 +27,7 @@ function createMockContext( body: unknown, session: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -97,7 +98,7 @@ Deno.test("kyc post - self-custodial user submits KYC for own address returns 20 selfCustodialSession(address), ); - await postKycHandler(ctx); + await handlePostKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -133,7 +134,7 @@ Deno.test("kyc post - self-custodial user submits KYC for different address retu selfCustodialSession(ownAddress), ); - await postKycHandler(ctx); + await handlePostKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 403); @@ -158,7 +159,7 @@ Deno.test("kyc post - custodial user submits KYC for own deposit address returns custodialSession(account.id), ); - await postKycHandler(ctx); + await handlePostKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -190,7 +191,7 @@ Deno.test("kyc post - custodial user submits KYC for wrong address returns 403", custodialSession(account.id), ); - await postKycHandler(ctx); + await handlePostKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 403); @@ -215,7 +216,7 @@ Deno.test("kyc post - creates new KYC record if none exists", async () => { selfCustodialSession(address), ); - await postKycHandler(ctx); + await handlePostKyc({ log: newNoop() })(ctx); assertEquals(getResponse().status, 200); @@ -243,7 +244,7 @@ Deno.test("kyc post - updates existing KYC record to PENDING", async () => { selfCustodialSession(address), ); - await postKycHandler(ctx); + await handlePostKyc({ log: newNoop() })(ctx); assertEquals(getResponse().status, 200); @@ -268,7 +269,7 @@ Deno.test("kyc post - missing address returns 400", async () => { selfCustodialSession(testAddress()), ); - await postKycHandler(ctx); + await handlePostKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -288,7 +289,7 @@ Deno.test("kyc post - missing jurisdiction returns 400", async () => { selfCustodialSession(address), ); - await postKycHandler(ctx); + await handlePostKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -307,7 +308,7 @@ Deno.test("kyc post - empty body returns 400", async () => { selfCustodialSession(testAddress()), ); - await postKycHandler(ctx); + await handlePostKyc({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); diff --git a/src/http/v1/pay/tests/mock_channel_resolver.ts b/src/http/v1/pay/tests/mock_channel_resolver.ts index 27a6bc0..8e8f961 100644 --- a/src/http/v1/pay/tests/mock_channel_resolver.ts +++ b/src/http/v1/pay/tests/mock_channel_resolver.ts @@ -9,7 +9,11 @@ const stubChannelClient = {} as any; // deno-lint-ignore require-await -- mock satisfies resolveChannelContext async contract -export async function resolveChannelContext(_channelContractId: string) { +export async function resolveChannelContext( + _channelContractId: string, + _ppPublicKey?: string, + _deps?: unknown, +) { return { signer: null, ppSecretKey: "", @@ -19,7 +23,10 @@ export async function resolveChannelContext(_channelContractId: string) { } // deno-lint-ignore require-await -- mock satisfies resolveChannelClient async contract -export async function resolveChannelClient(_channelContractId: string) { +export async function resolveChannelClient( + _channelContractId: string, + _deps?: unknown, +) { return { channelClient: stubChannelClient, channelAuthId: "", diff --git a/src/http/v1/pay/tests/mock_channel_service.ts b/src/http/v1/pay/tests/mock_channel_service.ts index db62bf3..828b6ac 100644 --- a/src/http/v1/pay/tests/mock_channel_service.ts +++ b/src/http/v1/pay/tests/mock_channel_service.ts @@ -25,6 +25,7 @@ export function _resetMockBalances(): void { export async function queryBalances( publicKeys: Uint8Array[], _channelClient?: unknown, + _deps?: unknown, ): Promise { if (_mockBalances !== null) { return _mockBalances; diff --git a/src/http/v1/pay/tests/report_test.ts b/src/http/v1/pay/tests/report_test.ts index b9f1ac6..5904c0c 100644 --- a/src/http/v1/pay/tests/report_test.ts +++ b/src/http/v1/pay/tests/report_test.ts @@ -5,7 +5,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/report_test.ts */ import { assertEquals, assertExists } from "@std/assert"; -import { postReportHandler } from "@/http/v1/pay/report/post.ts"; +import { newNoop } from "@/utils/logger/index.ts"; +import { handlePostReport } from "@/http/v1/pay/report/post.ts"; // --------------------------------------------------------------------------- // Mock Oak Context helper @@ -16,7 +17,7 @@ type MockResponse = { status: number; body: unknown }; function createMockContext( body: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -65,7 +66,7 @@ Deno.test("report post - valid report returns 200 and id", async () => { }, }); - await postReportHandler(ctx); + await handlePostReport({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -87,7 +88,7 @@ Deno.test("report post - minimal valid report (description only) returns 200", a description: "Error occurred", }); - await postReportHandler(ctx); + await handlePostReport({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -104,7 +105,7 @@ Deno.test("report post - missing description returns 400", async () => { steps: "Some steps", }); - await postReportHandler(ctx); + await handlePostReport({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -117,7 +118,7 @@ Deno.test("report post - missing description returns 400", async () => { Deno.test("report post - empty body returns 400", async () => { const { ctx, getResponse } = createMockContext({}); - await postReportHandler(ctx); + await handlePostReport({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -132,7 +133,7 @@ Deno.test("report post - empty string description returns 400", async () => { description: "", }); - await postReportHandler(ctx); + await handlePostReport({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -174,7 +175,7 @@ Deno.test("report post - invalid JSON body returns 400", async () => { }; // deno-lint-ignore no-explicit-any - await postReportHandler(ctx as any); + await handlePostReport({ log: newNoop() })(ctx as any); assertEquals(responseStatus, 400); assertEquals( diff --git a/src/http/v1/pay/tests/self_balance_test.ts b/src/http/v1/pay/tests/self_balance_test.ts index 932b2a1..f427d86 100644 --- a/src/http/v1/pay/tests/self_balance_test.ts +++ b/src/http/v1/pay/tests/self_balance_test.ts @@ -6,7 +6,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/self_balance_test.ts */ import { assertEquals } from "@std/assert"; -import { postSelfBalanceHandler } from "@/http/v1/pay/self/balance.ts"; +import { newNoop } from "@/utils/logger/index.ts"; +import { handlePostSelfBalance } from "@/http/v1/pay/self/balance.ts"; import { _resetMockBalances, _setMockBalances, @@ -23,7 +24,7 @@ function createMockContext( body: unknown, session: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -90,7 +91,7 @@ Deno.test("self balance - returns balances for given public keys", async () => { selfCustodialSession(testAddress()), ); - await postSelfBalanceHandler(ctx); + await handlePostSelfBalance({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -129,7 +130,7 @@ Deno.test("self balance - returns zero balances for empty UTXOs", async () => { selfCustodialSession(testAddress()), ); - await postSelfBalanceHandler(ctx); + await handlePostSelfBalance({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -151,7 +152,7 @@ Deno.test("self balance - empty publicKeys array returns 400", async () => { selfCustodialSession(testAddress()), ); - await postSelfBalanceHandler(ctx); + await handlePostSelfBalance({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -171,7 +172,7 @@ Deno.test("self balance - non-array publicKeys returns 400", async () => { selfCustodialSession(testAddress()), ); - await postSelfBalanceHandler(ctx); + await handlePostSelfBalance({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -191,7 +192,7 @@ Deno.test("self balance - missing publicKeys returns 400", async () => { selfCustodialSession(testAddress()), ); - await postSelfBalanceHandler(ctx); + await handlePostSelfBalance({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -213,7 +214,7 @@ Deno.test("self balance - exceeding MAX_UTXO_SLOTS returns 400", async () => { selfCustodialSession(testAddress()), ); - await postSelfBalanceHandler(ctx); + await handlePostSelfBalance({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -237,7 +238,7 @@ Deno.test("self balance - single public key works", async () => { selfCustodialSession(testAddress()), ); - await postSelfBalanceHandler(ctx); + await handlePostSelfBalance({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); diff --git a/src/http/v1/pay/tests/self_send_test.ts b/src/http/v1/pay/tests/self_send_test.ts index 83f2dc2..bcd84a8 100644 --- a/src/http/v1/pay/tests/self_send_test.ts +++ b/src/http/v1/pay/tests/self_send_test.ts @@ -5,7 +5,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/self_send_test.ts */ import { assertEquals, assertExists } from "@std/assert"; -import { postSelfSendHandler } from "@/http/v1/pay/self/send.ts"; +import { handlePostSelfSend } from "@/http/v1/pay/self/send.ts"; +import { newNoop } from "@/utils/logger/index.ts"; import { createTestKyc, ensureInitialized, @@ -29,7 +30,7 @@ function createMockContext( body: unknown, session: unknown, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -91,7 +92,7 @@ Deno.test("self send - successful send to verified address creates SEND transact selfCustodialSession(senderAddress), ); - await postSelfSendHandler(ctx); + await handlePostSelfSend({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -129,7 +130,7 @@ Deno.test("self send - send to unverified address creates escrow", async () => { selfCustodialSession(senderAddress), ); - await postSelfSendHandler(ctx); + await handlePostSelfSend({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -165,7 +166,7 @@ Deno.test("self send - invalid amount 'abc' returns 400", async () => { { to: testAddress(), amount: "abc" }, selfCustodialSession(testAddress()), ); - await postSelfSendHandler(ctx); + await handlePostSelfSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -176,7 +177,7 @@ Deno.test("self send - invalid amount '-1' returns 400", async () => { { to: testAddress(), amount: "-1" }, selfCustodialSession(testAddress()), ); - await postSelfSendHandler(ctx); + await handlePostSelfSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -187,7 +188,7 @@ Deno.test("self send - invalid amount '1.5' returns 400", async () => { { to: testAddress(), amount: "1.5" }, selfCustodialSession(testAddress()), ); - await postSelfSendHandler(ctx); + await handlePostSelfSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); }); @@ -202,7 +203,7 @@ Deno.test("self send - invalid to address returns 400", async () => { { to: "not-an-address", amount: "5000000" }, selfCustodialSession(testAddress()), ); - await postSelfSendHandler(ctx); + await handlePostSelfSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); assertEquals( (getResponse().body as { message: string }).message, @@ -221,7 +222,7 @@ Deno.test("self send - missing 'to' field returns 400", async () => { { amount: "5000000" }, selfCustodialSession(testAddress()), ); - await postSelfSendHandler(ctx); + await handlePostSelfSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); assertEquals( (getResponse().body as { message: string }).message, @@ -236,7 +237,7 @@ Deno.test("self send - missing 'amount' field returns 400", async () => { { to: testAddress() }, selfCustodialSession(testAddress()), ); - await postSelfSendHandler(ctx); + await handlePostSelfSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); assertEquals( (getResponse().body as { message: string }).message, @@ -255,7 +256,7 @@ Deno.test("self send - zero amount returns 400", async () => { { to: testAddress(), amount: "0" }, selfCustodialSession(testAddress()), ); - await postSelfSendHandler(ctx); + await handlePostSelfSend({ log: newNoop() })(ctx); assertEquals(getResponse().status, 400); assertEquals( (getResponse().body as { message: string }).message, diff --git a/src/http/v1/pay/tests/transactions_list_test.ts b/src/http/v1/pay/tests/transactions_list_test.ts index ece1c36..a7842a3 100644 --- a/src/http/v1/pay/tests/transactions_list_test.ts +++ b/src/http/v1/pay/tests/transactions_list_test.ts @@ -5,7 +5,8 @@ * Run with: deno test --allow-all --config src/http/v1/pay/tests/deno.json src/http/v1/pay/tests/transactions_list_test.ts */ import { assertEquals } from "@std/assert"; -import { listTransactionsHandler } from "@/http/v1/pay/transactions/list.ts"; +import { newNoop } from "@/utils/logger/index.ts"; +import { handleListTransactions } from "@/http/v1/pay/transactions/list.ts"; import { createTestAccount, createTestTransaction, @@ -27,7 +28,7 @@ function createMockContext( session: unknown, searchParams?: Record, ): { - ctx: Parameters[0]; + ctx: Parameters>[0]; getResponse: () => MockResponse; } { let responseStatus = 200; @@ -108,7 +109,7 @@ Deno.test("transactions list - returns transactions for account", async () => { const { ctx, getResponse } = createMockContext(custodialSession(account.id)); - await listTransactionsHandler(ctx); + await handleListTransactions({ log: newNoop() })(ctx); const res = getResponse(); assertEquals( @@ -150,7 +151,7 @@ Deno.test("transactions list - pagination limit works", async () => { { limit: "2" }, ); - await listTransactionsHandler(ctx); + await handleListTransactions({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -182,7 +183,7 @@ Deno.test("transactions list - pagination offset works", async () => { { limit: "2", offset: "3" }, ); - await listTransactionsHandler(ctx); + await handleListTransactions({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -226,7 +227,7 @@ Deno.test("transactions list - status filter returns only matching transactions" { status: "PENDING" }, ); - await listTransactionsHandler(ctx); + await handleListTransactions({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -255,7 +256,7 @@ Deno.test("transactions list - invalid status returns 400", async () => { { status: "INVALID_STATUS" }, ); - await listTransactionsHandler(ctx); + await handleListTransactions({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 400); @@ -279,7 +280,7 @@ Deno.test("transactions list - empty result returns empty array", async () => { const { ctx, getResponse } = createMockContext(custodialSession(account.id)); - await listTransactionsHandler(ctx); + await handleListTransactions({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); @@ -310,7 +311,7 @@ Deno.test("transactions list - returns correct transaction data shape", async () const { ctx, getResponse } = createMockContext(custodialSession(account.id)); - await listTransactionsHandler(ctx); + await handleListTransactions({ log: newNoop() })(ctx); const res = getResponse(); assertEquals(res.status, 200); diff --git a/src/http/v1/pay/transactions/list.ts b/src/http/v1/pay/transactions/list.ts index f126b35..afd5a95 100644 --- a/src/http/v1/pay/transactions/list.ts +++ b/src/http/v1/pay/transactions/list.ts @@ -3,72 +3,76 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { PayTransactionRepository } from "@/persistence/drizzle/repository/pay-transaction.repository.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; import { PayTransactionStatus } from "@/persistence/drizzle/entity/pay-transaction.entity.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; const txRepo = new PayTransactionRepository(drizzleClient); const validStatuses = new Set(Object.values(PayTransactionStatus)); -export const listTransactionsHandler = async (ctx: Context) => { - try { - const session = ctx.state.session as JwtSessionData; - const accountId = session.sub; +export function handleListTransactions( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("listTransactions"); - const params = ctx.request.url.searchParams; - const limit = Math.min(Number(params.get("limit") || "50"), 100); - const offset = Number(params.get("offset") || "0"); - const statusParam = params.get("status"); + return async (ctx) => { + log.info("listTransactions"); + try { + const session = ctx.state.session as JwtSessionData; + const accountId = session.sub; - // Validate status parameter against actual enum values - if (statusParam && !validStatuses.has(statusParam)) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: `Invalid status. Must be one of: ${ - [...validStatuses].join(", ") - }`, - }; - return; - } + const params = ctx.request.url.searchParams; + const limit = Math.min(Number(params.get("limit") || "50"), 100); + const offset = Number(params.get("offset") || "0"); + const statusParam = params.get("status"); + + if (statusParam && !validStatuses.has(statusParam)) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: `Invalid status. Must be one of: ${ + [...validStatuses].join(", ") + }`, + }; + return; + } - const status = statusParam as PayTransactionStatus | null; + const status = statusParam as PayTransactionStatus | null; - const transactions = await txRepo.findByAccountId(accountId, { - limit, - offset, - status: status ?? undefined, - }); + const transactions = await txRepo.findByAccountId(accountId, { + limit, + offset, + status: status ?? undefined, + }); - const total = await txRepo.countByAccountId(accountId, { - status: status ?? undefined, - }); + const total = await txRepo.countByAccountId(accountId, { + status: status ?? undefined, + }); - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Transactions retrieved", - data: { - transactions: transactions.map((tx) => ({ - id: tx.id, - type: tx.type.toLowerCase(), - status: tx.status.toLowerCase(), - amount: tx.amount.toString(), - assetCode: tx.assetCode, - from: tx.fromAddress, - to: tx.toAddress, - jurisdiction: { - from: tx.jurisdictionFrom, - to: tx.jurisdictionTo, - }, - createdAt: tx.createdAt.toISOString(), - updatedAt: tx.updatedAt.toISOString(), - })), - total, - }, - }; - } catch (error) { - LOG.warn("List transactions failed", { - error: error instanceof Error ? error.message : String(error), - }); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to retrieve transactions" }; - } -}; + ctx.response.status = Status.OK; + ctx.response.body = { + message: "Transactions retrieved", + data: { + transactions: transactions.map((tx) => ({ + id: tx.id, + type: tx.type.toLowerCase(), + status: tx.status.toLowerCase(), + amount: tx.amount.toString(), + assetCode: tx.assetCode, + from: tx.fromAddress, + to: tx.toAddress, + jurisdiction: { + from: tx.jurisdictionFrom, + to: tx.jurisdictionTo, + }, + createdAt: tx.createdAt.toISOString(), + updatedAt: tx.updatedAt.toISOString(), + })), + total, + }, + }; + } catch (error) { + log.error(error, "list transactions failed"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to retrieve transactions" }; + } + }; +} diff --git a/src/http/v1/stellar/auth/get.ts b/src/http/v1/stellar/auth/get.ts index fb0a6db..5ba1e2f 100644 --- a/src/http/v1/stellar/auth/get.ts +++ b/src/http/v1/stellar/auth/get.ts @@ -7,7 +7,7 @@ import { P_CreateChallenge } from "@/core/service/auth/challenge/create/create-c import { PIPE_GetEndpoint } from "@/http/pipelines/get-endpoint.ts"; import type { GetEndpointOutput } from "@/http/pipelines/types.ts"; import type { ChallengeData } from "@/core/service/auth/challenge/types.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; export const requestSchema = z.object({ account: z.string().regex(regex.ed25519PublicKey), @@ -18,36 +18,41 @@ export const responseSchema = z.object({ challenge: z.string(), }); -const assembleResponse = ( - input: ChallengeData, -): GetEndpointOutput => { - const message = "Auth challenge successfully created"; +export function handleGetAuth( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("getAuth"); - LOG.info(message); + const assembleResponse = ( + input: ChallengeData, + ): GetEndpointOutput => { + log.event("auth challenge successfully created"); - return { - ctx: input.ctx, - status: Status.OK, - message, - data: { - hash: input.challengeData.txHash, - challenge: input.challengeData.xdr, - }, + return { + ctx: input.ctx, + status: Status.OK, + message: "Auth challenge successfully created", + data: { + hash: input.challengeData.txHash, + challenge: input.challengeData.xdr, + }, + }; }; -}; -export const getAuthHandler = (ctx: Context) => { - const handler = PIPE_GetEndpoint({ - name: "GetAuthEndpointPipeline", - requestSchema: requestSchema, - responseSchema: responseSchema, - steps: [ - P_CreateChallenge, - P_CreateChallengeDB, - P_CreateChallengeMemory, - assembleResponse, - ], - }); + return (ctx) => { + log.info("getAuth"); + const handler = PIPE_GetEndpoint({ + name: "GetAuthEndpointPipeline", + requestSchema: requestSchema, + responseSchema: responseSchema, + steps: [ + P_CreateChallenge(deps), + P_CreateChallengeDB(deps), + P_CreateChallengeMemory(deps), + assembleResponse, + ], + }, deps); - return handler.run(ctx); -}; + return handler.run(ctx); + }; +} diff --git a/src/http/v1/stellar/auth/post.ts b/src/http/v1/stellar/auth/post.ts index b8d6c40..6f7a5ff 100644 --- a/src/http/v1/stellar/auth/post.ts +++ b/src/http/v1/stellar/auth/post.ts @@ -8,7 +8,7 @@ import type { PostEndpointOutput } from "@/http/pipelines/types.ts"; import { PIPE_PostEndpoint } from "@/http/pipelines/post-endpoint.ts"; import { P_CompareChallenge } from "@/core/service/auth/challenge/verify/compare-challenge.ts"; import type { ContextWithJWT } from "@/core/service/auth/challenge/types.ts"; -import { LOG } from "@/config/logger.ts"; +import type { Logger } from "@/utils/logger/index.ts"; export const requestSchema = z.object({ signedChallenge: z.string(), @@ -18,37 +18,42 @@ export const responseSchema = z.object({ jwt: z.string(), }); -const assembleResponse = ( - input: ContextWithJWT, -): PostEndpointOutput => { - const message = "Auth challenge verified successfully"; +export function handlePostAuth( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postAuth"); - LOG.info(message); + const assembleResponse = ( + input: ContextWithJWT, + ): PostEndpointOutput => { + log.event("auth challenge verified successfully"); - return { - ctx: input.ctx, - status: Status.OK, - message, - data: { - jwt: input.jwt, - }, + return { + ctx: input.ctx, + status: Status.OK, + message: "Auth challenge verified successfully", + data: { + jwt: input.jwt, + }, + }; }; -}; -export const postAuthHandler = (ctx: Context) => { - const handler = PIPE_PostEndpoint({ - name: "PostAuthEndpointPipeline", - requestSchema: requestSchema, - responseSchema: responseSchema, - steps: [ - P_VerifyChallenge, - P_CompareChallenge, - P_GenerateChallengeJWT, - P_UpdateChallengeSession, - P_UpdateChallengeDB, - assembleResponse, - ], - }); + return (ctx) => { + log.info("postAuth"); + const handler = PIPE_PostEndpoint({ + name: "PostAuthEndpointPipeline", + requestSchema: requestSchema, + responseSchema: responseSchema, + steps: [ + P_VerifyChallenge(deps), + P_CompareChallenge(deps), + P_GenerateChallengeJWT(deps), + P_UpdateChallengeSession(deps), + P_UpdateChallengeDB(deps), + assembleResponse, + ], + }, deps); - return handler.run(ctx); -}; + return handler.run(ctx); + }; +} diff --git a/src/http/v1/stellar/auth/routes.ts b/src/http/v1/stellar/auth/routes.ts index dc2272a..0cb067b 100644 --- a/src/http/v1/stellar/auth/routes.ts +++ b/src/http/v1/stellar/auth/routes.ts @@ -1,9 +1,11 @@ import { Router } from "@oak/oak"; -import { postAuthHandler } from "@/http/v1/stellar/auth/post.ts"; -import { getAuthHandler } from "@/http/v1/stellar/auth/get.ts"; -const authRouter = new Router(); +import type { Logger } from "@/utils/logger/index.ts"; +import { handlePostAuth } from "@/http/v1/stellar/auth/post.ts"; +import { handleGetAuth } from "@/http/v1/stellar/auth/get.ts"; -authRouter.post("/auth", postAuthHandler); -authRouter.get("/auth", getAuthHandler); - -export default authRouter; +export function buildAuthRouter(deps: { log: Logger }): Router { + const authRouter = new Router(); + authRouter.post("/auth", handlePostAuth(deps)); + authRouter.get("/auth", handleGetAuth(deps)); + return authRouter; +} diff --git a/src/http/v1/stellar/routes.ts b/src/http/v1/stellar/routes.ts index 397927d..18cc170 100644 --- a/src/http/v1/stellar/routes.ts +++ b/src/http/v1/stellar/routes.ts @@ -1,8 +1,14 @@ import { Router } from "@oak/oak"; -import authRouter from "@/http/v1/stellar/auth/routes.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import { buildAuthRouter } from "@/http/v1/stellar/auth/routes.ts"; -const stellarRouter = new Router(); - -stellarRouter.use("/stellar", authRouter.routes(), authRouter.allowedMethods()); - -export default stellarRouter; +export function buildStellarRouter(deps: { log: Logger }): Router { + const stellarRouter = new Router(); + const authRouter = buildAuthRouter(deps); + stellarRouter.use( + "/stellar", + authRouter.routes(), + authRouter.allowedMethods(), + ); + return stellarRouter; +} diff --git a/src/http/v1/v1.routes.ts b/src/http/v1/v1.routes.ts index f1e676b..1ab1591 100644 --- a/src/http/v1/v1.routes.ts +++ b/src/http/v1/v1.routes.ts @@ -1,45 +1,68 @@ import { Router } from "@oak/oak"; - -import stellarRouter from "@/http/v1/stellar/routes.ts"; -import bundleRouter from "@/http/v1/bundle/routes.ts"; -import dashboardRouter from "@/http/v1/dashboard/routes.ts"; -import payRouter from "@/http/v1/pay/routes.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import { buildStellarRouter } from "@/http/v1/stellar/routes.ts"; +import { buildBundleRouter } from "@/http/v1/bundle/routes.ts"; +import { buildDashboardRouter } from "@/http/v1/dashboard/routes.ts"; +import { buildPayRouter } from "@/http/v1/pay/routes.ts"; import healthRouter from "@/http/v1/health/routes.ts"; -import waitlistRouter from "@/http/v1/waitlist/routes.ts"; -import councilRouter from "@/http/v1/council/routes.ts"; -import eventsRouter from "@/http/v1/events/routes.ts"; -import entitiesRouter from "@/http/v1/entities/routes.ts"; +import { buildWaitlistRouter } from "@/http/v1/waitlist/routes.ts"; +import { buildCouncilRouter } from "@/http/v1/council/routes.ts"; +import { buildEventsRouter } from "@/http/v1/events/routes.ts"; +import { buildEntitiesRouter } from "@/http/v1/entities/routes.ts"; + +export function buildApiRouter(deps: { log: Logger }): Router { + const apiRouter = new Router(); -const apiRouter = new Router(); + const stellarRouter = buildStellarRouter(deps); + const bundleRouter = buildBundleRouter(deps); + const dashboardRouter = buildDashboardRouter(deps); + const payRouter = buildPayRouter(deps); + const waitlistRouter = buildWaitlistRouter(deps); + const councilRouter = buildCouncilRouter(deps); + const eventsRouter = buildEventsRouter(deps); + const entitiesRouter = buildEntitiesRouter(deps); -apiRouter.use("/api/v1", healthRouter.routes(), healthRouter.allowedMethods()); -apiRouter.use( - "/api/v1", - stellarRouter.routes(), - stellarRouter.allowedMethods(), -); -apiRouter.use( - "/api/v1", - dashboardRouter.routes(), - dashboardRouter.allowedMethods(), -); -apiRouter.use( - "/api/v1", - councilRouter.routes(), - councilRouter.allowedMethods(), -); -apiRouter.use("/api/v1", payRouter.routes(), payRouter.allowedMethods()); -apiRouter.use("/api/v1", bundleRouter.routes(), bundleRouter.allowedMethods()); -apiRouter.use( - "/api/v1", - waitlistRouter.routes(), - waitlistRouter.allowedMethods(), -); -apiRouter.use("/api/v1", eventsRouter.routes(), eventsRouter.allowedMethods()); -apiRouter.use( - "/api/v1", - entitiesRouter.routes(), - entitiesRouter.allowedMethods(), -); + apiRouter.use( + "/api/v1", + healthRouter.routes(), + healthRouter.allowedMethods(), + ); + apiRouter.use( + "/api/v1", + stellarRouter.routes(), + stellarRouter.allowedMethods(), + ); + apiRouter.use( + "/api/v1", + dashboardRouter.routes(), + dashboardRouter.allowedMethods(), + ); + apiRouter.use( + "/api/v1", + councilRouter.routes(), + councilRouter.allowedMethods(), + ); + apiRouter.use("/api/v1", payRouter.routes(), payRouter.allowedMethods()); + apiRouter.use( + "/api/v1", + bundleRouter.routes(), + bundleRouter.allowedMethods(), + ); + apiRouter.use( + "/api/v1", + waitlistRouter.routes(), + waitlistRouter.allowedMethods(), + ); + apiRouter.use( + "/api/v1", + eventsRouter.routes(), + eventsRouter.allowedMethods(), + ); + apiRouter.use( + "/api/v1", + entitiesRouter.routes(), + entitiesRouter.allowedMethods(), + ); -export default apiRouter; + return apiRouter; +} diff --git a/src/http/v1/waitlist/discord-notify.ts b/src/http/v1/waitlist/discord-notify.ts index 3f7f64d..462dcc9 100644 --- a/src/http/v1/waitlist/discord-notify.ts +++ b/src/http/v1/waitlist/discord-notify.ts @@ -1,23 +1,21 @@ +import type { Logger } from "@/utils/logger/index.ts"; + /** * Fire-and-forget Discord webhook notification for waitlist requests. - * Logs a warning and skips if DISCORD_WEBHOOK_URL is not set — the - * disappearance of that warning is the operational signal that the - * dormant path flipped to live once the secret is provided. */ export function notifyDiscord( email: string, wallet: string | null, source: string, + deps: { log: Logger }, ): void { + const log = deps.log.scope("discordNotify"); const webhookUrl = Deno.env.get("DISCORD_WEBHOOK_URL"); if (!webhookUrl) { - console.warn( - "[waitlist] DISCORD_WEBHOOK_URL unset — skipping Discord notification", - ); + log.event("DISCORD_WEBHOOK_URL unset — skipping Discord notification"); return; } - // Fire-and-forget — do not await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -34,6 +32,6 @@ export function notifyDiscord( }], }), }).catch((err) => { - console.warn("[waitlist] Discord notification failed:", err.message); + log.error(err, "Discord notification failed"); }); } diff --git a/src/http/v1/waitlist/routes.ts b/src/http/v1/waitlist/routes.ts index 19507aa..3ef1080 100644 --- a/src/http/v1/waitlist/routes.ts +++ b/src/http/v1/waitlist/routes.ts @@ -1,4 +1,5 @@ import { Router, Status } from "@oak/oak"; +import type { Logger } from "@/utils/logger/index.ts"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { WaitlistRequestRepository } from "@/persistence/drizzle/repository/waitlist-request.repository.ts"; import { notifyDiscord } from "@/http/v1/waitlist/discord-notify.ts"; @@ -7,44 +8,45 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const SOURCE = "provider-console"; const waitlistRepo = new WaitlistRequestRepository(drizzleClient); +let injectedRepo: WaitlistRequestRepository | null = null; -const waitlistRouter = new Router(); - -/** Allow tests to inject a different repository instance. */ export function setWaitlistRepoForTests(repo: WaitlistRequestRepository): void { - Object.assign(waitlistRouter, { _repo: repo }); + injectedRepo = repo; } function getRepo(): WaitlistRequestRepository { - return (waitlistRouter as unknown as { _repo?: WaitlistRequestRepository }) - ._repo ?? waitlistRepo; + return injectedRepo ?? waitlistRepo; } -waitlistRouter.post("/waitlist", async (ctx) => { - const body = await ctx.request.body.json().catch(() => null); - const email = body?.email; - const walletPublicKey = body?.walletPublicKey ?? null; - - if ( - typeof email !== "string" || !EMAIL_RE.test(email) || email.length > 254 - ) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "Invalid email" }; - return; - } - - const { isNew } = await getRepo().upsert({ - email, - walletPublicKey: typeof walletPublicKey === "string" - ? walletPublicKey - : null, - source: SOURCE, +export function buildWaitlistRouter(deps: { log: Logger }): Router { + const waitlistRouter = new Router(); + + waitlistRouter.post("/waitlist", async (ctx) => { + const body = await ctx.request.body.json().catch(() => null); + const email = body?.email; + const walletPublicKey = body?.walletPublicKey ?? null; + + if ( + typeof email !== "string" || !EMAIL_RE.test(email) || email.length > 254 + ) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid email" }; + return; + } + + const { isNew } = await getRepo().upsert({ + email, + walletPublicKey: typeof walletPublicKey === "string" + ? walletPublicKey + : null, + source: SOURCE, + }); + + notifyDiscord(email, walletPublicKey, SOURCE, deps); + + ctx.response.status = isNew ? Status.Created : Status.OK; + ctx.response.body = { message: "Added to waitlist" }; }); - notifyDiscord(email, walletPublicKey, SOURCE); - - ctx.response.status = isNew ? Status.Created : Status.OK; - ctx.response.body = { message: "Added to waitlist" }; -}); - -export default waitlistRouter; + return waitlistRouter; +} diff --git a/src/main.ts b/src/main.ts index 2ef127e..97761b5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,14 @@ import { Application } from "@oak/oak"; -import apiVi from "@/http/v1/v1.routes.ts"; +import { buildApiRouter } from "@/http/v1/v1.routes.ts"; import { appendRequestIdMiddleware } from "@/http/middleware/append-request-id.ts"; import { appendResponseHeadersMiddleware } from "@/http/middleware/append-response-headers.ts"; import { traceContextMiddleware } from "@/http/middleware/trace-context.ts"; import { corsMiddleware } from "@/http/middleware/cors.ts"; import { PORT } from "@/config/env.ts"; -import { LOG } from "@/config/logger.ts"; +import { createLogger } from "@/config/logger.ts"; +import { getEventBus } from "@/core/service/events/event-bus.ts"; +import { getSessionManager } from "@/core/service/auth/sessions/in-memory-session-manager.ts"; import { initializeMempoolSystem, shutdownMempoolSystem, @@ -17,29 +19,37 @@ import { } from "@/core/service/event-watcher/index.ts"; async function bootstrap() { - try { - // Initialize mempool system before starting HTTP server - await initializeMempoolSystem(); + const rootLog = createLogger(); + const log = rootLog.scope("bootstrap"); + log.info("bootstrap"); + + const deps = { log: rootLog }; - // Start watching for Channel Auth contract events (loaded from DB) - await startEventWatcher(); + // Initialize lazy singletons that depend on the root logger. + getEventBus(deps); + getSessionManager(deps); + + try { + await initializeMempoolSystem(deps); + await startEventWatcher(deps); const app = new Application(); app.use(corsMiddleware); app.use(traceContextMiddleware); - app.use(appendRequestIdMiddleware); + app.use(appendRequestIdMiddleware(deps)); app.use(appendResponseHeadersMiddleware); - app.use(apiVi.routes()); + const apiV1 = buildApiRouter(deps); + app.use(apiV1.routes()); - LOG.info(`Server running on http://localhost:${PORT}`); + log.debug("port", PORT); + log.event(`server running on http://localhost:${PORT}`); - // Setup graceful shutdown const shutdown = () => { - LOG.info("Shutting down server..."); + log.event("shutting down server"); Promise.all([ stopEventWatcher(), - shutdownMempoolSystem(), + shutdownMempoolSystem(deps), ]).finally(() => Deno.exit(0)); }; @@ -48,12 +58,10 @@ async function bootstrap() { await app.listen({ port: Number(PORT) }); } catch (error) { - LOG.error("Failed to start server", { - error: error instanceof Error ? error.message : String(error), - }); + log.error(error, "failed to start server"); Promise.all([ stopEventWatcher(), - shutdownMempoolSystem(), + shutdownMempoolSystem(deps), ]).finally(() => Deno.exit(1)); } } diff --git a/src/utils/error/assert-or-throw.ts b/src/utils/error/assert-or-throw.ts index 363dbad..c605ea5 100644 --- a/src/utils/error/assert-or-throw.ts +++ b/src/utils/error/assert-or-throw.ts @@ -1,10 +1,8 @@ -import { logAndThrow } from "@/utils/error/log-and-throw.ts"; - export function assertOrThrow( condition: T | unknown, error: Error, ): asserts condition { if (!condition) { - logAndThrow(error); + throw error; } } diff --git a/src/utils/error/log-and-throw.ts b/src/utils/error/log-and-throw.ts index 6447194..01546ef 100644 --- a/src/utils/error/log-and-throw.ts +++ b/src/utils/error/log-and-throw.ts @@ -1,6 +1,3 @@ -import { LOG } from "@/config/logger.ts"; - -export function logAndThrow(error: Error): never { - LOG.error(error.message, { error }); - throw error; -} +// Helper retired in the logging convention migration. Throw directly at call +// sites — the catch in the calling handler/factory logs the error via +// log.error(err, msg). diff --git a/src/utils/logger/index.ts b/src/utils/logger/index.ts index a816b56..5623024 100644 --- a/src/utils/logger/index.ts +++ b/src/utils/logger/index.ts @@ -1,62 +1,231 @@ +// Draft Logger module — TypeScript port of github.com/AquiGorka/go-logger. +// Lives at src/utils/logger/index.ts in each backend repo. + import chalk from "chalk"; -export enum LogLevel { - FATAL = 0, - ERROR = 1, - WARN = 2, - INFO = 3, - DEBUG = 4, - TRACE = 5, +export enum Level { + Debug = 0, + Info = 1, + Event = 2, + Disabled = 3, +} + +export interface Logger { + info(msg: string): void; + event(msg: string): void; + debug(key: string, value: unknown): void; + error(err: unknown, msg: string): void; + scope(name: string): Logger; +} + +export interface Writer { + write(line: string): void; +} + +export interface LoggerOptions { + /** Custom stdout writer. Replaces console.log. Useful for tests. */ + writer?: Writer; + /** Opt-in file path for JSON-formatted records. Created if missing. */ + file?: string; +} + +interface Record { + ts: string; + level: "debug" | "info" | "event" | "error"; + scope: string; + msg?: string; + key?: string; + value?: unknown; + error?: string; +} + +type Format = (r: Record) => string; + +interface Sink { + writer: Writer; + format: Format; +} + +export function parseLevel(s: string | undefined): Level { + switch ((s ?? "").toLowerCase()) { + case "debug": + return Level.Debug; + case "info": + return Level.Info; + case "event": + return Level.Event; + default: + return Level.Disabled; + } +} + +const stdoutWriter: Writer = { + write: (line) => console.log(line), +}; + +class FileWriter implements Writer { + private file: Deno.FsFile; + constructor(path: string) { + const dir = path.substring(0, path.lastIndexOf("/")); + if (dir) Deno.mkdirSync(dir, { recursive: true }); + this.file = Deno.openSync(path, { + append: true, + create: true, + write: true, + }); + } + write(line: string): void { + this.file.writeSync(new TextEncoder().encode(line + "\n")); + } +} + +function stringify(v: unknown): string { + if (typeof v === "string") return v; + if (v instanceof Error) return v.message; + try { + return JSON.stringify(v); + } catch (err) { + return `[Unstringifiable: ${(err as Error).message}]`; + } } -export class Logger { - private logLevel: LogLevel; +function humanFormat(colored: boolean): Format { + const grayLb = colored ? chalk.gray : (s: string) => s; + const greenLb = colored ? chalk.green : (s: string) => s; + const whiteLb = colored ? chalk.white : (s: string) => s; + const cyanLb = colored ? chalk.cyan : (s: string) => s; + const redLb = colored ? chalk.red : (s: string) => s; + + return (r) => { + const ts = grayLb(`[${r.ts}]`); + switch (r.level) { + case "info": + return `${ts} ${greenLb("INF")} [${r.scope}] ${r.msg}`; + case "event": + return `${ts} ${whiteLb("EVT")} -${r.msg} (${r.scope})`; + case "debug": + return `${ts} ${cyanLb("DBG")} ${r.key}: ${ + stringify(r.value) + } (${r.scope})`; + case "error": + return `${ts} ${redLb("ERR")} [${r.scope}] ${r.msg} error="${r.error}"`; + } + }; +} - constructor(logLevel: LogLevel) { - this.logLevel = logLevel; +const jsonFormat: Format = (r) => { + // Stable schema. Only the fields relevant to each level are emitted. + const out: Record = { + ts: r.ts, + level: r.level, + scope: r.scope, + }; + if (r.msg !== undefined) out.msg = r.msg; + if (r.key !== undefined) out.key = r.key; + if (r.value !== undefined) out.value = safeJsonValue(r.value); + if (r.error !== undefined) out.error = r.error; + try { + return JSON.stringify(out); + } catch (err) { + return JSON.stringify({ + ts: r.ts, + level: r.level, + scope: r.scope, + msg: `[unserializable record: ${(err as Error).message}]`, + }); } +}; - private format(...args: unknown[]): string { - return args - .map((arg) => { - if (typeof arg === "string") return arg; - try { - return chalk.cyan(JSON.stringify(arg)); - } catch (error) { - return chalk.cyan(`[Unstringifiable: ${(error as Error).message}]`); - } - }) - .join(" "); +function safeJsonValue(v: unknown): unknown { + // BigInt + circular refs would break JSON.stringify. Coerce to string when + // we can't keep the structured value. + if (typeof v === "bigint") return v.toString(); + if (v instanceof Error) return { message: v.message, name: v.name }; + if (v === null || typeof v !== "object") return v; + try { + JSON.stringify(v); + return v; + } catch { + return stringify(v); } +} + +class LoggerImpl implements Logger { + constructor( + private readonly level: Level, + private readonly sinks: Sink[], + private readonly scopePath: string, + ) {} - private log(level: LogLevel, color: typeof chalk.blue, ...args: unknown[]) { - if (this.logLevel < level) return; - const timestamp = new Date().toISOString(); - const prefix = chalk.gray(`[${timestamp}::${LogLevel[level]}]`); - console.log(`${prefix} ${color(this.format(...args))}`); + info(msg: string): void { + if (this.level > Level.Info) return; + this.emit({ ts: now(), level: "info", scope: this.scopePath, msg }); } - trace(...args: unknown[]) { - this.log(LogLevel.TRACE, chalk.white, ...args); + event(msg: string): void { + if (this.level > Level.Event) return; + this.emit({ ts: now(), level: "event", scope: this.scopePath, msg }); } - debug(...args: unknown[]) { - this.log(LogLevel.DEBUG, chalk.green, ...args); + debug(key: string, value: unknown): void { + if (this.level > Level.Debug) return; + this.emit({ ts: now(), level: "debug", scope: this.scopePath, key, value }); } - info(...args: unknown[]) { - this.log(LogLevel.INFO, chalk.blue, ...args); + error(err: unknown, msg: string): void { + // ERR always emits regardless of level (matches go-logger / zerolog). + const detail = err instanceof Error ? err.message : String(err); + this.emit({ + ts: now(), + level: "error", + scope: this.scopePath, + msg, + error: detail, + }); } - warn(...args: unknown[]) { - this.log(LogLevel.WARN, chalk.yellow, ...args); + scope(name: string): Logger { + return new LoggerImpl(this.level, this.sinks, `${this.scopePath}.${name}`); } - error(...args: unknown[]) { - this.log(LogLevel.ERROR, chalk.red, ...args); + private emit(r: Record): void { + for (const sink of this.sinks) { + sink.writer.write(sink.format(r)); + } } +} - fatal(...args: unknown[]) { - this.log(LogLevel.FATAL, chalk.bgRed.white, ...args); +function now(): string { + return new Date().toISOString(); +} + +export function newLogger(level: Level, opts: LoggerOptions = {}): Logger { + const sinks: Sink[] = []; + + // stdout sink — always present. Human format. Colored when TTY (and only + // when caller did not pass a custom writer; tests get plain output). + const consoleWriter = opts.writer ?? stdoutWriter; + const colored = opts.writer === undefined && Deno.stdout.isTerminal(); + sinks.push({ writer: consoleWriter, format: humanFormat(colored) }); + + // file sink — opt-in. JSON format. + if (opts.file !== undefined) { + sinks.push({ writer: new FileWriter(opts.file), format: jsonFormat }); } + + return new LoggerImpl(level, sinks, "main"); +} + +class NoopLogger implements Logger { + info(): void {} + event(): void {} + debug(): void {} + error(): void {} + scope(): Logger { + return this; + } +} + +export function newNoop(): Logger { + return new NoopLogger(); }