diff --git a/comebackhere-backend/.dockerignore b/comebackhere-backend/.dockerignore new file mode 100644 index 0000000..7034699 --- /dev/null +++ b/comebackhere-backend/.dockerignore @@ -0,0 +1,24 @@ +# Dependencies and build output are produced inside the image +node_modules +dist +coverage + +# Tests are not part of the runtime image +src/tests +**/*.test.ts +**/*.spec.ts + +# Local environment files — pass config at runtime (--env-file / -e) instead +.env +.env.* +!.env.example + +# Tooling / VCS noise +.git +.gitignore +.vscode +.idea +*.log +npm-debug.log* +Dockerfile +.dockerignore diff --git a/comebackhere-backend/Dockerfile b/comebackhere-backend/Dockerfile new file mode 100644 index 0000000..35f7d11 --- /dev/null +++ b/comebackhere-backend/Dockerfile @@ -0,0 +1,49 @@ +# syntax=docker/dockerfile:1 +# +# comebackhere-backend (Node) — multi-stage image +# +# docker build -t comebackhere-backend comebackhere-backend/ +# docker run --rm -p 3000:3000 --env-file comebackhere-backend/.env comebackhere-backend +# +# package-lock.json is gitignored in this repo, so installs use `npm ci` when a +# lockfile is present in the build context and fall back to `npm install`. + +ARG NODE_VERSION=20 + +# ── deps: production dependencies only ──────────────────────────────────────── +FROM node:${NODE_VERSION}-alpine AS deps +WORKDIR /app +COPY package*.json ./ +RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi \ + && npm cache clean --force + +# ── builder: full dependencies + TypeScript compile ─────────────────────────── +FROM node:${NODE_VERSION}-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src +RUN npm run build + +# ── runtime: dist/ + production node_modules, non-root ──────────────────────── +FROM node:${NODE_VERSION}-alpine AS runtime +ENV NODE_ENV=production \ + PORT=3000 +WORKDIR /app + +COPY --from=deps --chown=node:node /app/node_modules ./node_modules +COPY --from=builder --chown=node:node /app/dist ./dist +COPY --chown=node:node package.json ./ + +# The official image ships an unprivileged `node` user (uid 1000). +USER node + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -qO- "http://127.0.0.1:${PORT}/health" > /dev/null || exit 1 + +# Run node directly (not via npm) so SIGTERM reaches the app's graceful +# shutdown handler, which drains webhooks within SHUTDOWN_TIMEOUT_MS. +CMD ["node", "dist/index.js"] diff --git a/comebackhere-backend/package.json b/comebackhere-backend/package.json index c9692ca..c4564cf 100644 --- a/comebackhere-backend/package.json +++ b/comebackhere-backend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "tsx watch src/index.ts", - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest" diff --git a/comebackhere-backend/src/index.ts b/comebackhere-backend/src/index.ts index 3edf2dd..89b0ba0 100644 --- a/comebackhere-backend/src/index.ts +++ b/comebackhere-backend/src/index.ts @@ -1,11 +1,10 @@ /** * Backend entrypoint — Issue #219 * - * Handles SIGTERM and SIGINT for graceful shutdown: - * 1. Stops accepting new connections (server.close) - * 2. Stops the treasury indexer poll loop - * 3. Closes the MongoDB connection - * 4. Applies a hard-timeout safety net so the process always exits + * Handles SIGTERM and SIGINT for graceful shutdown (see ./shutdown.ts): + * stops accepting HTTP connections and webhook jobs, stops the indexers, + * drains in-flight webhook deliveries (persisting unfinished ones for retry), + * closes MongoDB, and applies a hard-timeout safety net. */ import { createApp } from "./app.js" @@ -13,15 +12,39 @@ import { startTreasuryIndexer, stopTreasuryIndexer } from "./services/treasury-i import { stopIndexer } from "./indexer.js" import { stopComplianceIndexer } from "./services/compliance-indexer.js" import { closeMongo } from "./db/mongo.js" +import { webhookDeliveryQueue } from "./services/webhook-delivery.js" +import { createShutdownHandler, resolveWebhookDrainTimeout } from "./shutdown.js" +import { validateEnv } from "./lib/env.js" import type { Server } from "http" +// Fail fast on missing variables or malformed Stellar ids, naming the variable. +try { + validateEnv(process.env) +} catch (err) { + console.error(`[startup] ${err instanceof Error ? err.message : err}`) + process.exit(1) +} + const PORT = process.env.PORT ?? "3000" /** Hard shutdown timeout in ms — forces exit if clean shutdown hangs. */ const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS ?? "10000") +/** Max time to wait for in-flight webhook deliveries; capped below SHUTDOWN_TIMEOUT_MS. */ +const WEBHOOK_DRAIN_TIMEOUT_MS = resolveWebhookDrainTimeout( + Number(process.env.WEBHOOK_DRAIN_TIMEOUT_MS ?? "5000"), + SHUTDOWN_TIMEOUT_MS, +) const app = createApp() startTreasuryIndexer() +// Retry deliveries that a previous process persisted during shutdown. +webhookDeliveryQueue.resumePending().catch((err: unknown) => { + console.error( + "[webhook] could not resume persisted deliveries:", + err instanceof Error ? err.message : err, + ) +}) + const server: Server = app.listen(Number(PORT), () => { console.log(`comebackhere-backend listening on port ${PORT}`) }) @@ -30,30 +53,10 @@ const server: Server = app.listen(Number(PORT), () => { // Graceful shutdown // --------------------------------------------------------------------------- -let shuttingDown = false - -async function shutdown(signal: string): Promise { - if (shuttingDown) return - shuttingDown = true - - console.log(`[shutdown] received ${signal} — starting graceful shutdown`) - - // Hard-timeout safety net: if clean shutdown takes too long, force exit. - const hardTimeout = setTimeout(() => { - console.error("[shutdown] hard timeout reached — forcing exit") - process.exit(1) - }, SHUTDOWN_TIMEOUT_MS) - // Allow the process to exit even if the timer is still pending. - hardTimeout.unref() - - try { - // 1. Stop accepting new HTTP connections; wait for in-flight requests. - await new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())) - }) - console.log("[shutdown] HTTP server closed") - - // 2. Stop indexer poll loops. +const shutdown = createShutdownHandler({ + server, + webhookQueue: webhookDeliveryQueue, + stopIndexers: () => { stopTreasuryIndexer() stopIndexer() stopComplianceIndexer() diff --git a/comebackhere-backend/src/lib/cache.ts b/comebackhere-backend/src/lib/cache.ts index 6a0b2d3..7cd04a5 100644 --- a/comebackhere-backend/src/lib/cache.ts +++ b/comebackhere-backend/src/lib/cache.ts @@ -35,3 +35,62 @@ export async function cacheSet(key: string, value: unknown, ttlSec = 30): Promis // silently fail – cache is optional } } + +// --------------------------------------------------------------------------- +// In-process TTL cache with explicit invalidation +// --------------------------------------------------------------------------- + +interface MemoryCacheEntry { + value: unknown + expiresAt: number +} + +const _memoryCache = new Map() + +/** Returns the cached value for `key` if present and not yet expired. */ +export function memoryCacheGet(key: string): T | null { + const entry = _memoryCache.get(key) + if (!entry) return null + if (Date.now() >= entry.expiresAt) { + _memoryCache.delete(key) + return null + } + return entry.value as T +} + +/** Stores `value` under `key`; the TTL is a safety net for changes nobody invalidates. */ +export function memoryCacheSet(key: string, value: unknown, ttlMs: number): void { + _memoryCache.set(key, { value, expiresAt: Date.now() + ttlMs }) +} + +/** + * Removes `key` from the in-process cache and logs the invalidation. + * Returns true when a live entry was evicted. + */ +export function invalidateCacheKey(key: string, reason = "manual"): boolean { + const existed = _memoryCache.delete(key) + console.log(`[cache] invalidated key=${key} reason=${reason} evicted=${existed}`) + return existed +} + +// --------------------------------------------------------------------------- +// Treasury balances cache (GET /api/treasury/balances) +// --------------------------------------------------------------------------- + +export type TreasuryBalances = Array<{ token: string; balance: string }> + +export const BALANCE_CACHE_KEY = "treasury:balances" +export const BALANCE_CACHE_TTL_MS = 5_000 + +export function getBalanceCache(): TreasuryBalances | null { + return memoryCacheGet(BALANCE_CACHE_KEY) +} + +export function setBalanceCache(data: TreasuryBalances): void { + memoryCacheSet(BALANCE_CACHE_KEY, data, BALANCE_CACHE_TTL_MS) +} + +/** Evicts cached balances so the next GET /balances re-reads them from RPC. */ +export function invalidateBalanceCache(reason?: string): boolean { + return invalidateCacheKey(BALANCE_CACHE_KEY, reason) +} diff --git a/comebackhere-backend/src/lib/soroban.ts b/comebackhere-backend/src/lib/soroban.ts index 6c34f69..09e248b 100644 --- a/comebackhere-backend/src/lib/soroban.ts +++ b/comebackhere-backend/src/lib/soroban.ts @@ -11,7 +11,7 @@ import { } from "stellar-sdk" export type SorobanClient = { - getAccount: (publicKey: string) => Promise[0]> + getAccount: (publicKey: string) => Promise[0]> simulateTransaction: ( tx: Parameters[0], ) => ReturnType diff --git a/comebackhere-backend/src/routes/compliance.ts b/comebackhere-backend/src/routes/compliance.ts index 67add3d..737d054 100644 --- a/comebackhere-backend/src/routes/compliance.ts +++ b/comebackhere-backend/src/routes/compliance.ts @@ -79,7 +79,7 @@ router.get("/audit", validateQuery(complianceAuditQuerySchema), async (req: Requ // --------------------------------------------------------------------------- export type SorobanClient = { - getAccount: (publicKey: string) => Promise[0]> + getAccount: (publicKey: string) => Promise[0]> simulateTransaction: (tx: Parameters[0]) => ReturnType sendTransaction: (tx: Parameters[0]) => ReturnType getTransaction: (hash: string) => ReturnType diff --git a/comebackhere-backend/src/routes/invoices.ts b/comebackhere-backend/src/routes/invoices.ts index 496b1f2..4113d10 100644 --- a/comebackhere-backend/src/routes/invoices.ts +++ b/comebackhere-backend/src/routes/invoices.ts @@ -19,7 +19,7 @@ export interface CreateInvoiceBody { // Soroban interaction extracted so it can be replaced in tests export type SorobanClient = { - getAccount: (publicKey: string) => Promise[0]> + getAccount: (publicKey: string) => Promise[0]> simulateTransaction: (tx: Parameters[0]) => ReturnType sendTransaction: (tx: Parameters[0]) => ReturnType getTransaction: (hash: string) => ReturnType diff --git a/comebackhere-backend/src/routes/treasury.ts b/comebackhere-backend/src/routes/treasury.ts index 1c67a4f..0768238 100644 --- a/comebackhere-backend/src/routes/treasury.ts +++ b/comebackhere-backend/src/routes/treasury.ts @@ -16,39 +16,13 @@ import { executeSettlementSchema, escalateHoldSchema, } from "../schemas/index.js" +import { getBalanceCache, setBalanceCache, invalidateBalanceCache } from "../lib/cache.js" const router = Router() -// --------------------------------------------------------------------------- -// #212 — In-memory balance cache with TTL -// --------------------------------------------------------------------------- - -const BALANCE_CACHE_TTL_MS = 5_000 // 5 second TTL - -interface BalanceCacheEntry { - data: Array<{ token: string; balance: string }> - expiresAt: number -} - -let _balanceCache: BalanceCacheEntry | null = null - -/** Returns cached balances if still fresh, otherwise null. */ -export function getBalanceCache(): Array<{ token: string; balance: string }> | null { - if (_balanceCache && Date.now() < _balanceCache.expiresAt) { - return _balanceCache.data - } - return null -} - -/** Stores balance data in the cache with a fresh TTL. */ -export function setBalanceCache(data: Array<{ token: string; balance: string }>): void { - _balanceCache = { data, expiresAt: Date.now() + BALANCE_CACHE_TTL_MS } -} - -/** Immediately invalidates the balance cache (call after execute-settlement / withdrawal). */ -export function invalidateBalanceCache(): void { - _balanceCache = null -} +// #212 — balance cache lives in lib/cache.ts so the treasury indexer can +// invalidate it too; re-exported here for existing callers. +export { getBalanceCache, setBalanceCache, invalidateBalanceCache } /** * @openapi @@ -366,7 +340,7 @@ router.post("/execute-settlement", validateBody(executeSettlementSchema), async env, ) // #212 — balance changed; evict the cache so the next GET /balances is fresh - invalidateBalanceCache() + invalidateBalanceCache(`execute-settlement id=${settlementId}`) res.json(result) } catch (err: unknown) { const status = (err as { status?: number })?.status ?? 500 diff --git a/comebackhere-backend/src/schemas/index.ts b/comebackhere-backend/src/schemas/index.ts index fa1bf5e..0de00c6 100644 --- a/comebackhere-backend/src/schemas/index.ts +++ b/comebackhere-backend/src/schemas/index.ts @@ -99,7 +99,7 @@ export const voteBodySchema = z.object({ .min(1, "signer_address is required") .refine(isValidStellarAddress, "signer_address must be a valid Stellar public key"), vote: z.enum(["ResolvedClaimant", "ResolvedCounterparty"], { - errorMap: () => ({ message: "vote must be 'ResolvedClaimant' or 'ResolvedCounterparty'" }), + message: "vote must be 'ResolvedClaimant' or 'ResolvedCounterparty'", }), weight: z .number({ message: "weight must be a positive integer" }) diff --git a/comebackhere-backend/src/services/treasury-indexer.ts b/comebackhere-backend/src/services/treasury-indexer.ts index 926605b..76125f2 100644 --- a/comebackhere-backend/src/services/treasury-indexer.ts +++ b/comebackhere-backend/src/services/treasury-indexer.ts @@ -10,6 +10,7 @@ import { type SettlementRecord, } from "../db/mongo.js" import { dispatchWebhook } from "./webhooks.js" +import { invalidateBalanceCache } from "../lib/cache.js" const CURSOR_ID = "treasury_settlement_events" const POLL_INTERVAL_MS = 5_000 @@ -279,6 +280,10 @@ export async function processIndexerBatch( } else if (eventType === "settlement_executed") { await processSettlementExecuted(settlements, settlementId, txHash) + // Treasury balances changed on-chain; drop the cached copy so the next + // GET /api/treasury/balances reads fresh data instead of waiting for TTL. + invalidateBalanceCache(`settlement_executed id=${settlementId} tx=${txHash}`) + // Dispatch signed webhook for settlement_executed const webhookUrl = process.env.WEBHOOK_URL if (webhookUrl) { diff --git a/comebackhere-backend/src/services/webhook-delivery.ts b/comebackhere-backend/src/services/webhook-delivery.ts index 3b278b2..4fd4ea3 100644 --- a/comebackhere-backend/src/services/webhook-delivery.ts +++ b/comebackhere-backend/src/services/webhook-delivery.ts @@ -14,6 +14,8 @@ * single event across both the backend and their own logs. */ +import { connectMongo } from "../db/mongo.js" + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -131,19 +133,24 @@ export async function deliverWebhook( delayFn: (ms: number) => Promise = defaultDelay, correlationId?: string, ): Promise { + const { signal, startAttempt = 0, onAttempt } = options const record: WebhookDeliveryRecord = { idempotency_key: payload.idempotency_key, endpoint, payload, status: "pending", - attempts: 0, + attempts: startAttempt, last_attempt_at: null, last_status_code: null, last_error: null, request_id: correlationId ?? null, } - for (let attempt = 0; attempt < maxAttempts; attempt++) { + for (let attempt = startAttempt; attempt < maxAttempts; attempt++) { + // Shutting down — leave the record "pending" so the caller can persist it. + if (signal?.aborted) return record + + onAttempt?.(attempt + 1) record.attempts = attempt + 1 record.last_attempt_at = new Date().toISOString() @@ -207,3 +214,233 @@ export function buildWebhookPayload( data, } } + +// --------------------------------------------------------------------------- +// Delivery queue with graceful drain +// --------------------------------------------------------------------------- + +/** A delivery that has not finished yet, in a form that can be persisted. */ +export interface WebhookDeliveryJob { + endpoint: string + payload: WebhookPayload + /** Attempts already made; a resumed job continues from here. */ + attempts: number +} + +/** Durable storage for deliveries that did not finish before shutdown. */ +export interface PendingDeliveryStore { + save(jobs: WebhookDeliveryJob[]): Promise + /** Returns every stored job and removes it from the store. */ + takeAll(): Promise +} + +const PENDING_DELIVERIES_COLLECTION = "webhook_pending_deliveries" + +/** MongoDB-backed store, keyed by idempotency_key so re-saving is harmless. */ +export function createMongoPendingDeliveryStore(): PendingDeliveryStore { + return { + async save(jobs) { + const database = await connectMongo() + const collection = database.collection( + PENDING_DELIVERIES_COLLECTION, + ) + await Promise.all( + jobs.map((job) => + collection.replaceOne( + { _id: job.payload.idempotency_key }, + job, + { upsert: true }, + ), + ), + ) + }, + async takeAll() { + const database = await connectMongo() + const collection = database.collection( + PENDING_DELIVERIES_COLLECTION, + ) + const docs = await collection.find().toArray() + if (docs.length > 0) { + await collection.deleteMany({ _id: { $in: docs.map((d) => d._id) } }) + } + return docs.map(({ endpoint, payload, attempts }) => ({ endpoint, payload, attempts })) + }, + } +} + +/** Sleeps for `ms`, resolving early if `signal` aborts. */ +function abortableDelay( + delayFn: (ms: number) => Promise, + ms: number, + signal: AbortSignal, +): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const onAbort = () => resolve() + signal.addEventListener("abort", onAbort, { once: true }) + delayFn(ms).then(() => { + signal.removeEventListener("abort", onAbort) + resolve() + }) + }) +} + +export interface WebhookDeliveryQueueOptions { + store?: PendingDeliveryStore + maxAttempts?: number + fetchFn?: typeof fetch + delayFn?: (ms: number) => Promise +} + +export interface DrainResult { + /** In-flight deliveries that finished (delivered or failed) within the timeout. */ + completed: number + /** Deliveries persisted for retry after restart. */ + persisted: number + timedOut: boolean +} + +export class WebhookDeliveryQueue { + private accepting = true + private readonly abort = new AbortController() + private readonly active = new Set<{ job: WebhookDeliveryJob; done: Promise }>() + /** Jobs submitted after shutdown began; persisted rather than started. */ + private deferred: WebhookDeliveryJob[] = [] + private readonly store: PendingDeliveryStore + private readonly maxAttempts: number + private readonly fetchFn: typeof fetch + private readonly delayFn: (ms: number) => Promise + + constructor(options: WebhookDeliveryQueueOptions = {}) { + this.store = options.store ?? createMongoPendingDeliveryStore() + this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS + this.fetchFn = options.fetchFn ?? ((...args) => fetch(...args)) + this.delayFn = options.delayFn ?? defaultDelay + } + + get isAccepting(): boolean { + return this.accepting + } + + get inFlightCount(): number { + return this.active.size + } + + /** + * Starts delivering `payload` to `endpoint`. Returns the delivery promise, + * or null when the queue is shutting down — the job is then kept and + * persisted for retry after restart instead of being started. + */ + enqueue( + endpoint: string, + payload: WebhookPayload, + attempts = 0, + ): Promise | null { + const job: WebhookDeliveryJob = { endpoint, payload, attempts } + + if (!this.accepting) { + console.warn( + `[webhook] queue closed — deferring key=${payload.idempotency_key} for retry after restart`, + ) + if (this.abort.signal.aborted) { + // Drain already finished; persist straight away. + this.persist([job]) + } else { + this.deferred.push(job) + } + return null + } + + const entry = { job, done: Promise.resolve() as Promise } + const done = deliverWebhook( + endpoint, + payload, + this.maxAttempts, + this.fetchFn, + (ms) => abortableDelay(this.delayFn, ms, this.abort.signal), + { + signal: this.abort.signal, + startAttempt: attempts, + onAttempt: (n) => { + job.attempts = n + }, + }, + ).then((record) => { + // A "pending" record was interrupted by drain; keep it tracked so it is + // persisted. Finished deliveries (delivered/failed) are dropped. + if (record.status !== "pending") this.active.delete(entry) + return record + }) + entry.done = done + this.active.add(entry) + return done + } + + /** Stops starting new deliveries; later enqueues are deferred for restart. */ + stopAccepting(): void { + if (this.accepting) { + this.accepting = false + console.log("[webhook] queue stopped accepting new deliveries") + } + } + + /** + * Stops accepting jobs, waits up to `timeoutMs` for in-flight deliveries, + * then halts retries and persists every unfinished job for retry. + */ + async drain(timeoutMs: number): Promise { + this.stopAccepting() + const startedWith = this.active.size + console.log( + `[webhook] draining ${startedWith} in-flight deliver${startedWith === 1 ? "y" : "ies"} (timeout ${timeoutMs}ms)`, + ) + + let timer: ReturnType | undefined + const timedOut = await Promise.race([ + Promise.allSettled([...this.active].map((e) => e.done)).then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), timeoutMs) + }), + ]) + clearTimeout(timer) + + // Stop further attempts/backoff. Anything still tracked is unfinished. + this.abort.abort() + const unfinished = [...[...this.active].map((e) => ({ ...e.job })), ...this.deferred] + const completed = startedWith - this.active.size + this.active.clear() + this.deferred = [] + + if (unfinished.length > 0) await this.persist(unfinished) + + console.log( + `[webhook] drain ${timedOut ? "timed out" : "complete"}: ` + + `${completed} finished, ${unfinished.length} persisted for retry`, + ) + return { completed, persisted: unfinished.length, timedOut } + } + + /** Re-enqueues deliveries persisted by a previous process. */ + async resumePending(): Promise { + const jobs = await this.store.takeAll() + for (const job of jobs) this.enqueue(job.endpoint, job.payload, job.attempts) + if (jobs.length > 0) { + console.log(`[webhook] resumed ${jobs.length} persisted deliver${jobs.length === 1 ? "y" : "ies"}`) + } + return jobs.length + } + + private async persist(jobs: WebhookDeliveryJob[]): Promise { + try { + await this.store.save(jobs) + } catch (err) { + console.error( + `[webhook] failed to persist ${jobs.length} unfinished deliver${jobs.length === 1 ? "y" : "ies"}:`, + err instanceof Error ? err.message : err, + ) + } + } +} + +/** Process-wide queue used by the backend entrypoint. */ +export const webhookDeliveryQueue = new WebhookDeliveryQueue() diff --git a/comebackhere-backend/src/shutdown.ts b/comebackhere-backend/src/shutdown.ts new file mode 100644 index 0000000..b11ef48 --- /dev/null +++ b/comebackhere-backend/src/shutdown.ts @@ -0,0 +1,102 @@ +/** + * Graceful shutdown sequence — Issue #219 + * + * Kept separate from the entrypoint so it can be exercised in tests without + * starting a real server. Order matters: + * 1. Stop accepting new webhook jobs (late ones are persisted, not dropped) + * 2. Stop accepting new HTTP connections; wait for in-flight requests + * 3. Stop indexer poll loops (no new events → no new webhooks) + * 4. Drain in-flight webhook deliveries up to the drain timeout and persist + * anything unfinished for retry after restart + * 5. Close MongoDB (after the drain, which persists into it) + * A hard timeout forces exit if any step hangs. + */ + +import type { Server } from "http" +import type { WebhookDeliveryQueue } from "./services/webhook-delivery.js" + +export interface ShutdownDeps { + server: Pick + webhookQueue: Pick + stopIndexers: () => void + closeMongo: () => Promise + /** Hard timeout — forces exit if clean shutdown hangs. */ + shutdownTimeoutMs: number + /** + * How long to wait for in-flight webhook deliveries. Must be shorter than + * `shutdownTimeoutMs` (itself shorter than the orchestrator's kill grace + * period, e.g. Kubernetes' 30s default) so the process exits cleanly. + */ + webhookDrainTimeoutMs: number + exit?: (code: number) => void +} + +/** + * Caps the webhook drain timeout below the hard shutdown timeout, leaving + * `marginMs` for persisting unfinished jobs and closing MongoDB. + */ +export function resolveWebhookDrainTimeout( + configuredMs: number, + shutdownTimeoutMs: number, + marginMs = 2_000, +): number { + const ceiling = Math.max(0, shutdownTimeoutMs - marginMs) + if (!Number.isFinite(configuredMs) || configuredMs < 0) return ceiling + if (configuredMs > ceiling) { + console.warn( + `[shutdown] WEBHOOK_DRAIN_TIMEOUT_MS=${configuredMs} exceeds shutdown budget; using ${ceiling}ms`, + ) + return ceiling + } + return configuredMs +} + +export function createShutdownHandler(deps: ShutdownDeps): (signal: string) => Promise { + const exit = deps.exit ?? ((code: number) => process.exit(code)) + let shuttingDown = false + + return async function shutdown(signal: string): Promise { + if (shuttingDown) return + shuttingDown = true + + console.log(`[shutdown] received ${signal} — starting graceful shutdown`) + + // Hard-timeout safety net: if clean shutdown takes too long, force exit. + const hardTimeout = setTimeout(() => { + console.error("[shutdown] hard timeout reached — forcing exit") + exit(1) + }, deps.shutdownTimeoutMs) + // Allow the process to exit even if the timer is still pending. + hardTimeout.unref() + + try { + // 1. No new webhook jobs from here on. + deps.webhookQueue.stopAccepting() + + // 2. Stop accepting new HTTP connections; wait for in-flight requests. + await new Promise((resolve, reject) => { + deps.server.close((err) => (err ? reject(err) : resolve())) + }) + console.log("[shutdown] HTTP server closed") + + // 3. Stop indexer poll loops. + deps.stopIndexers() + console.log("[shutdown] indexers stopped") + + // 4. Drain webhook deliveries; unfinished ones are persisted for retry. + await deps.webhookQueue.drain(deps.webhookDrainTimeoutMs) + + // 5. Close MongoDB connection. + await deps.closeMongo() + console.log("[shutdown] MongoDB connection closed") + + clearTimeout(hardTimeout) + console.log("[shutdown] clean exit") + exit(0) + } catch (err) { + clearTimeout(hardTimeout) + console.error("[shutdown] error during shutdown:", err) + exit(1) + } + } +} diff --git a/comebackhere-backend/src/tests/env-validation.test.ts b/comebackhere-backend/src/tests/env-validation.test.ts index 1d51064..6dba567 100644 --- a/comebackhere-backend/src/tests/env-validation.test.ts +++ b/comebackhere-backend/src/tests/env-validation.test.ts @@ -1,12 +1,21 @@ import { describe, it, expect } from "vitest" -import { validateEnv } from "../index.js" +import { validateEnv, findInvalidStellarVars } from "../lib/env.js" + +// Valid strkeys (deterministic, not real deployments) +const CONTRACT_A = "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526" +const CONTRACT_B = "CABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAFNSZ" +const ACCOUNT = "GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG" +const SECRET = "SABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGC45" + +/** Flips the last character, which breaks the strkey checksum. */ +const typo = (key: string) => key.slice(0, -1) + (key.endsWith("A") ? "B" : "A") const FULL_ENV: Record = { MONGODB_URI: "mongodb://localhost:27017", REDIS_URL: "redis://localhost:6379", SOROBAN_RPC_URL: "http://localhost:8000", - TREASURY_CONTRACT_ID: "CTREASURY", - INVOICE_CONTRACT_ID: "CINVOICE", + TREASURY_CONTRACT_ID: CONTRACT_A, + INVOICE_CONTRACT_ID: CONTRACT_B, ADMIN_KEY: "secret-admin", WEBHOOK_SECRET: "secret-webhook", } @@ -63,3 +72,79 @@ describe("validateEnv", () => { expect(() => validateEnv({})).toThrow(/Set the above variables/) }) }) + +describe("validateEnv — Stellar identifiers", () => { + const errorFor = (env: Record): string => { + try { + validateEnv(env) + } catch (e) { + return (e as Error).message + } + return "" + } + + describe.each(["TREASURY_CONTRACT_ID", "INVOICE_CONTRACT_ID", "USDC_CONTRACT_ID", "COMPLIANCE_CONTRACT_ID", "SETTLEMENT_CONTRACT_ID"])( + "contract id %s", + (name) => { + it("accepts a valid C... contract id", () => { + expect(() => validateEnv({ ...FULL_ENV, [name]: CONTRACT_A })).not.toThrow() + }) + + it.each([ + ["a checksum typo", typo(CONTRACT_A)], + ["a stray character", CONTRACT_A + "X"], + ["a truncated value", CONTRACT_A.slice(0, 20)], + ["a lowercase value", CONTRACT_A.toLowerCase()], + ["an account key instead of a contract", ACCOUNT], + ["a placeholder", "CTREASURY"], + ])("rejects %s and names the variable", (_label, value) => { + const message = errorFor({ ...FULL_ENV, [name]: value }) + expect(message).toContain(name) + expect(message).toMatch(/not a valid Stellar contract id \(expected C\.\.\./) + }) + }, + ) + + describe("account key ADMIN_PUBLIC_KEY", () => { + it("accepts a valid G... key", () => { + expect(() => validateEnv({ ...FULL_ENV, ADMIN_PUBLIC_KEY: ACCOUNT })).not.toThrow() + }) + + it.each([ + ["a checksum typo", typo(ACCOUNT)], + ["a contract id instead of an account", CONTRACT_A], + ["a secret seed instead of an account", SECRET], + ["a leading space", ` ${ACCOUNT}`], + ])("rejects %s and names the variable", (_label, value) => { + const message = errorFor({ ...FULL_ENV, ADMIN_PUBLIC_KEY: value }) + expect(message).toContain("ADMIN_PUBLIC_KEY") + expect(message).toMatch(/not a valid Stellar account key \(expected G\.\.\./) + }) + }) + + describe("secret seed SIGNER_SECRET_KEY", () => { + it("accepts a valid S... seed", () => { + expect(() => validateEnv({ ...FULL_ENV, SIGNER_SECRET_KEY: SECRET })).not.toThrow() + }) + + it("rejects an invalid seed without echoing its value", () => { + const bad = typo(SECRET) + const message = errorFor({ ...FULL_ENV, SIGNER_SECRET_KEY: bad }) + expect(message).toContain("SIGNER_SECRET_KEY") + expect(message).not.toContain(bad) + }) + }) + + it("skips optional identifiers that are not set", () => { + expect(findInvalidStellarVars(FULL_ENV)).toEqual([]) + }) + + it("reports missing and malformed variables together in one error", () => { + const env = { ...FULL_ENV, INVOICE_CONTRACT_ID: "CINVOICE" } + delete (env as Record).REDIS_URL + + const message = errorFor(env) + expect(message).toMatch(/REDIS_URL is missing/) + expect(message).toMatch(/INVOICE_CONTRACT_ID is not a valid Stellar contract id/) + }) +}) diff --git a/comebackhere-backend/src/tests/graceful-shutdown.test.ts b/comebackhere-backend/src/tests/graceful-shutdown.test.ts index 212689e..3e9804f 100644 --- a/comebackhere-backend/src/tests/graceful-shutdown.test.ts +++ b/comebackhere-backend/src/tests/graceful-shutdown.test.ts @@ -2,6 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { stopTreasuryIndexer } from "../services/treasury-indexer.js" import { stopIndexer } from "../indexer.js" import { closeMongo } from "../db/mongo.js" +import { + WebhookDeliveryQueue, + buildWebhookPayload, + type PendingDeliveryStore, + type WebhookDeliveryJob, +} from "../services/webhook-delivery.js" +import { createShutdownHandler, resolveWebhookDrainTimeout } from "../shutdown.js" // We test the shutdown logic in isolation without spawning a real process. // The strategy: extract and call the shutdown function's individual steps, @@ -22,13 +29,13 @@ vi.mock("../db/mongo.js", () => ({ })) // Re-import after mocks are set up -const { stopTreasuryIndexer: mockStopTreasuryIndexer } = await import("../services/treasury-indexer.js") as { +const { stopTreasuryIndexer: mockStopTreasuryIndexer } = await import("../services/treasury-indexer.js") as unknown as { stopTreasuryIndexer: ReturnType } -const { stopIndexer: mockStopIndexer } = await import("../indexer.js") as { +const { stopIndexer: mockStopIndexer } = await import("../indexer.js") as unknown as { stopIndexer: ReturnType } -const { closeMongo: mockCloseMongo } = await import("../db/mongo.js") as { +const { closeMongo: mockCloseMongo } = await import("../db/mongo.js") as unknown as { closeMongo: ReturnType } @@ -98,3 +105,163 @@ describe("stopTreasuryIndexer", () => { }).not.toThrow() }) }) + +// --------------------------------------------------------------------------- +// Webhook drain on shutdown — a delivery is in progress when SIGTERM arrives +// --------------------------------------------------------------------------- + +function memoryStore(): PendingDeliveryStore & { saved: WebhookDeliveryJob[] } { + const saved: WebhookDeliveryJob[] = [] + return { + saved, + save: vi.fn(async (jobs: WebhookDeliveryJob[]) => { + saved.push(...jobs) + }), + takeAll: vi.fn(async () => saved.splice(0)), + } +} + +/** fetch whose response is released manually, to hold a delivery in flight. */ +function controllableFetch() { + const releases: Array<(status: number) => void> = [] + const fetchFn = vi.fn( + () => + new Promise((resolve) => { + releases.push((status) => resolve({ status } as Response)) + }), + ) as unknown as typeof fetch & ReturnType + return { fetchFn, release: (status = 200) => releases.shift()?.(status) } +} + +function makeShutdown(queue: WebhookDeliveryQueue, webhookDrainTimeoutMs: number) { + const order: string[] = [] + const exit = vi.fn((code: number) => { + order.push(`exit:${code}`) + }) + const server = { close: vi.fn((cb: (err?: Error) => void) => cb()) } + const shutdown = createShutdownHandler({ + server: server as never, + webhookQueue: queue, + stopIndexers: () => order.push("stopIndexers"), + closeMongo: async () => { + order.push("closeMongo") + }, + shutdownTimeoutMs: 10_000, + webhookDrainTimeoutMs, + exit, + }) + return { shutdown, exit, order, server } +} + +const payload = (id: string) => + buildWebhookPayload("settlement.executed", { amount: "1" }, { settlementId: id }) + +describe("Graceful shutdown — webhook drain", () => { + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("waits for a delivery in progress at SIGTERM before closing Mongo and exiting", async () => { + const store = memoryStore() + const { fetchFn, release } = controllableFetch() + const queue = new WebhookDeliveryQueue({ store, fetchFn }) + const { shutdown, exit, order } = makeShutdown(queue, 1_000) + + const delivery = queue.enqueue("https://merchant.test/hook", payload("1")) + expect(queue.inFlightCount).toBe(1) + + const done = shutdown("SIGTERM") + // Let the shutdown reach the drain step, then finish the delivery. + await new Promise((r) => setTimeout(r, 20)) + expect(exit).not.toHaveBeenCalled() + release(200) + await done + + await expect(delivery).resolves.toMatchObject({ status: "delivered" }) + expect(store.saved).toEqual([]) + expect(order).toEqual(["stopIndexers", "closeMongo", "exit:0"]) + }) + + it("persists a delivery still in progress when the drain timeout expires", async () => { + const store = memoryStore() + const { fetchFn } = controllableFetch() // never released + const queue = new WebhookDeliveryQueue({ store, fetchFn }) + const { shutdown, exit } = makeShutdown(queue, 50) + + const p = payload("2") + queue.enqueue("https://merchant.test/hook", p) + await shutdown("SIGTERM") + + expect(store.saved).toEqual([ + { endpoint: "https://merchant.test/hook", payload: p, attempts: 1 }, + ]) + expect(exit).toHaveBeenCalledWith(0) + }) + + it("stops retry backoff at the timeout and persists the attempt count", async () => { + const store = memoryStore() + const fetchFn = vi.fn(async () => ({ status: 500 }) as Response) as unknown as typeof fetch + const neverEndingDelay = () => new Promise(() => {}) + const queue = new WebhookDeliveryQueue({ store, fetchFn, delayFn: neverEndingDelay }) + const { shutdown } = makeShutdown(queue, 50) + + const delivery = queue.enqueue("https://merchant.test/hook", payload("3")) + await shutdown("SIGTERM") + + await expect(delivery).resolves.toMatchObject({ status: "pending", attempts: 1 }) + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(store.saved).toHaveLength(1) + expect(store.saved[0].attempts).toBe(1) + }) + + it("does not start new jobs after the signal and persists them instead", async () => { + const store = memoryStore() + const { fetchFn } = controllableFetch() + const queue = new WebhookDeliveryQueue({ store, fetchFn }) + const { shutdown } = makeShutdown(queue, 50) + + const done = shutdown("SIGTERM") + expect(queue.isAccepting).toBe(false) + expect(queue.enqueue("https://merchant.test/hook", payload("4"))).toBeNull() + await done + + expect(fetchFn).not.toHaveBeenCalled() + expect(store.saved.map((j) => j.payload.idempotency_key)).toEqual(["settlement.executed:4"]) + }) + + it("persisted jobs are retried by the next process and resume their attempt count", async () => { + const store = memoryStore() + store.saved.push({ endpoint: "https://merchant.test/hook", payload: payload("5"), attempts: 2 }) + const fetchFn = vi.fn(async () => ({ status: 200 }) as Response) as unknown as typeof fetch + const queue = new WebhookDeliveryQueue({ store, fetchFn }) + + expect(await queue.resumePending()).toBe(1) + await queue.drain(1_000) + + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(store.saved).toEqual([]) + }) +}) + +describe("resolveWebhookDrainTimeout", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + }) + + it("keeps a configured timeout that fits within the shutdown budget", () => { + expect(resolveWebhookDrainTimeout(5_000, 10_000)).toBe(5_000) + }) + + it("caps the drain timeout below the hard shutdown timeout", () => { + expect(resolveWebhookDrainTimeout(30_000, 10_000)).toBe(8_000) + }) + + it("falls back to the cap for invalid values", () => { + expect(resolveWebhookDrainTimeout(Number.NaN, 10_000)).toBe(8_000) + }) +}) diff --git a/comebackhere-backend/src/tests/treasury-balances-cache.test.ts b/comebackhere-backend/src/tests/treasury-balances-cache.test.ts index dd84bf7..1debe83 100644 --- a/comebackhere-backend/src/tests/treasury-balances-cache.test.ts +++ b/comebackhere-backend/src/tests/treasury-balances-cache.test.ts @@ -6,6 +6,10 @@ * (getTokenBalance mock is only called once). * 2. The cache expires after the TTL, causing a fresh RPC call. * 3. The cache is invalidated immediately after a successful execute-settlement. + * 4. The treasury indexer invalidates the cache when it indexes a + * settlement_executed event, and logs the invalidation. + * 5. Events other than settlement_executed leave the cache alone, so TTL + * expiry remains the fallback for them. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" @@ -16,6 +20,9 @@ import { setBalanceCache, invalidateBalanceCache, } from "../routes/treasury.js" +import { processIndexerBatch } from "../services/treasury-indexer.js" +import type { SorobanClient } from "../lib/soroban.js" +import type { Db } from "mongodb" // --------------------------------------------------------------------------- // Constants — valid Stellar credentials for env setup @@ -159,21 +166,133 @@ describe("GET /api/treasury/balances — caching behaviour", () => { it("cache expires after TTL and next call fetches fresh data", async () => { const { getTokenBalance } = await import("../lib/soroban.js") - // Seed the cache with a known value - setBalanceCache([{ token: USDC_CONTRACT, balance: "10000000" }]) + // Only fake Date so supertest's real I/O timers keep working, and keep the + // fake clock active for the HTTP call so the entry stays expired. + vi.useFakeTimers({ toFake: ["Date"] }) + try { + // Seed the cache with a known value + setBalanceCache([{ token: USDC_CONTRACT, balance: "10000000" }]) + + // Advance past the 5-second TTL — the cache should now be stale + vi.advanceTimersByTime(6_000) + expect(getBalanceCache()).toBeNull() + + // Confirm a fresh HTTP call hits the mock (cache expired) + await request(app).get("/api/treasury/balances") + expect(vi.mocked(getTokenBalance)).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) - vi.useFakeTimers() +// --------------------------------------------------------------------------- +// Indexer-driven invalidation: settlement_executed evicts the balance cache +// --------------------------------------------------------------------------- - // Advance past the 5-second TTL — the cache should now be stale - vi.advanceTimersByTime(6_000) +function makeEvent(eventType: string, settlementId = 7, pagingToken = `tok-${eventType}`) { + // Each slot answers both u64() and address() since event types read the + // same positions differently (e.g. slot 3 is merchant vs. approval weight). + const slot = (n: number, a: string) => ({ + u64: () => ({ toString: () => String(n) }), + address: () => ({ toString: () => a }), + }) + return { + pagingToken, + txHash: `tx-${eventType}`, + topic: [{ sym: () => ({ toString: () => eventType }) }], + value: { + vec: () => [slot(settlementId, ""), slot(0, "addr-token"), slot(1000, ""), slot(1, "addr-merchant")], + }, + } +} + +function makeClient(events: ReturnType[]): SorobanClient { + return { + getEvents: vi.fn().mockResolvedValue({ events, latestLedger: 200 }), + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 200 }), + } as unknown as SorobanClient +} + +function makeDatabase(): Db { + const collection = { + findOne: vi.fn().mockResolvedValue(null), + updateOne: vi.fn().mockResolvedValue({}), + } + return { collection: vi.fn(() => collection) } as unknown as Db +} + +describe("treasury indexer — balance cache invalidation", () => { + const CACHED = [{ token: USDC_CONTRACT, balance: "1" }] + + beforeEach(() => { + invalidateBalanceCache() + delete process.env.WEBHOOK_URL + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("invalidates the cache when settlement_executed is indexed", async () => { + setBalanceCache(CACHED) + expect(getBalanceCache()).toEqual(CACHED) + + await processIndexerBatch(makeClient([makeEvent("settlement_executed")]), TREASURY_CONTRACT, makeDatabase()) - // Cache should be empty, so next real call hits getTokenBalance expect(getBalanceCache()).toBeNull() + }) - vi.useRealTimers() + it("logs the invalidation with the settlement id", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}) + setBalanceCache(CACHED) - // Confirm a fresh HTTP call hits the mock (cache was cleared) - await request(app).get("/api/treasury/balances") - expect(vi.mocked(getTokenBalance)).toHaveBeenCalledTimes(1) + await processIndexerBatch(makeClient([makeEvent("settlement_executed", 42)]), TREASURY_CONTRACT, makeDatabase()) + + const lines = log.mock.calls.map((c) => String(c[0])) + expect(lines.some((l) => l.includes("[cache] invalidated key=treasury:balances") && l.includes("settlement_executed id=42"))).toBe(true) + }) + + it("does not invalidate the cache for proposed or approved events", async () => { + setBalanceCache(CACHED) + + await processIndexerBatch( + makeClient([makeEvent("settlement_proposed"), makeEvent("settlement_approved")]), + TREASURY_CONTRACT, + makeDatabase(), + ) + + expect(getBalanceCache()).toEqual(CACHED) + }) + + it("keeps TTL as the fallback when no settlement_executed event is seen", async () => { + vi.useFakeTimers({ toFake: ["Date"] }) + try { + setBalanceCache(CACHED) + await processIndexerBatch(makeClient([]), TREASURY_CONTRACT, makeDatabase()) + expect(getBalanceCache()).toEqual(CACHED) + + vi.advanceTimersByTime(6_000) + expect(getBalanceCache()).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it("next GET /balances after an indexed execution fetches fresh data", async () => { + for (const [key, val] of Object.entries(ENV)) process.env[key] = val + const { getTokenBalance } = await import("../lib/soroban.js") + vi.mocked(getTokenBalance).mockClear() + vi.mocked(getTokenBalance).mockResolvedValueOnce(BigInt(100)).mockResolvedValueOnce(BigInt(40)) + const app = createApp() + + const before = await request(app).get("/api/treasury/balances") + expect(before.body).toEqual([{ token: USDC_CONTRACT, balance: "100" }]) + + await processIndexerBatch(makeClient([makeEvent("settlement_executed")]), TREASURY_CONTRACT, makeDatabase()) + + const after = await request(app).get("/api/treasury/balances") + expect(after.body).toEqual([{ token: USDC_CONTRACT, balance: "40" }]) + expect(vi.mocked(getTokenBalance)).toHaveBeenCalledTimes(2) }) }) diff --git a/comebackhere-backend/tsconfig.build.json b/comebackhere-backend/tsconfig.build.json new file mode 100644 index 0000000..26ba4d6 --- /dev/null +++ b/comebackhere-backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/tests", "src/**/*.test.ts"] +} diff --git a/scripts/validate_backend_env.sh b/scripts/validate_backend_env.sh index 11a9173..1b02e2c 100755 --- a/scripts/validate_backend_env.sh +++ b/scripts/validate_backend_env.sh @@ -19,6 +19,15 @@ # INVOICE_CONTRACT_ID # TREASURY_CONTRACT_ID # COMPLIANCE_CONTRACT_ID +# +# Stellar identifier format (always enforced when the variable is set; mirrors +# comebackhere-backend/src/lib/env.ts): +# C... contract ids : INVOICE_CONTRACT_ID TREASURY_CONTRACT_ID +# COMPLIANCE_CONTRACT_ID USDC_CONTRACT_ID SETTLEMENT_CONTRACT_ID +# G... account keys : ADMIN_PUBLIC_KEY +# S... secret seeds : SIGNER_SECRET_KEY (value is never printed) +# Full strkey validation (base32 + version byte + CRC16 checksum) uses python3; +# without python3 only the shape (prefix, length, alphabet) is checked. set -euo pipefail @@ -75,6 +84,68 @@ check_optional() { fi } +# is_valid_strkey KIND VALUE — KIND is contract | account | seed. +# Same rules as stellar-sdk StrKey.isValidContract / isValidEd25519PublicKey / +# isValidEd25519SecretSeed. +is_valid_strkey() { + local kind="$1" value="$2" prefix + case "$kind" in + contract) prefix="C" ;; + account) prefix="G" ;; + seed) prefix="S" ;; + *) return 1 ;; + esac + + if command -v python3 >/dev/null 2>&1; then + python3 - "$kind" "$value" <<'PY' +import base64, sys + +kind, value = sys.argv[1], sys.argv[2] +version = {"contract": 2 << 3, "account": 6 << 3, "seed": 18 << 3}[kind] + +def crc16_xmodem(data: bytes) -> int: + crc = 0 + for byte in data: + crc ^= byte << 8 + for _ in range(8): + crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1) + crc &= 0xFFFF + return crc + +try: + raw = base64.b32decode(value) +except Exception: + sys.exit(1) + +ok = ( + len(value) == 56 + and len(raw) == 35 + and raw[0] == version + and int.from_bytes(raw[-2:], "little") == crc16_xmodem(raw[:-2]) + and base64.b32encode(raw).decode() == value +) +sys.exit(0 if ok else 1) +PY + else + [[ "$value" =~ ^${prefix}[A-Z2-7]{55}$ ]] + fi +} + +check_strkey() { + local var="$1" kind="$2" + local val="${!var:-}" + [ -z "$val" ] && return 0 + if is_valid_strkey "$kind" "$val"; then + ok "$var is a valid $kind strkey" + return 0 + fi + case "$kind" in + contract) err "$var is not a valid Stellar contract id (expected C... address, got \"$val\")." ;; + account) err "$var is not a valid Stellar account key (expected G... address, got \"$val\")." ;; + seed) err "$var is not a valid Stellar secret seed (expected S... key)." ;; + esac +} + # ── source env file if provided ─────────────────────────────────────────────── if [ -n "$ENV_FILE" ]; then @@ -119,6 +190,23 @@ check_optional INVOICE_CONTRACT_ID check_optional TREASURY_CONTRACT_ID check_optional COMPLIANCE_CONTRACT_ID +# ── Stellar identifier format ───────────────────────────────────────────────── + +echo "" +echo "=== Stellar identifier format ===" + +for _var in INVOICE_CONTRACT_ID TREASURY_CONTRACT_ID COMPLIANCE_CONTRACT_ID \ + USDC_CONTRACT_ID SETTLEMENT_CONTRACT_ID; do + check_strkey "$_var" contract +done +check_strkey ADMIN_PUBLIC_KEY account +check_strkey SIGNER_SECRET_KEY seed +unset _var + +if ! command -v python3 >/dev/null 2>&1; then + warn "python3 not found — Stellar ids were only checked for shape, not checksum." +fi + # ── summary ─────────────────────────────────────────────────────────────────── echo ""