diff --git a/lib/escrow-settlement.ts b/lib/escrow-settlement.ts index 22e453f..2d157b9 100644 --- a/lib/escrow-settlement.ts +++ b/lib/escrow-settlement.ts @@ -11,10 +11,12 @@ import { z } from "zod" export const ESCROW_TIMEOUT_SECONDS = 86400 * 7 // 7 days export const DISPUTE_WINDOW_SECONDS = 86400 * 3 // 3 days export const MAX_ESCROW_AMOUNT = "100000000000" // 10000 PHASELQ in stroops +// Amounts stay as strings so large stroop values never lose integer precision. // ── Type definitions ─────────────────────────────────────────────────────── export const EscrowSchema = z.object({ + // Schema validation is the boundary between untrusted API input and settlement logic. escrowId: z.string().min(1).max(128), buyer: z.string().length(56).regex(/^G[A-Z2-7]{55}$/), seller: z.string().length(56).regex(/^G[A-Z2-7]{55}$/), @@ -109,6 +111,7 @@ export class EscrowSettlementError extends Error { export function validateEscrowCreation( escrow: CreateEscrow ): { valid: true } | { valid: false; error: string; code: string } { + // Validate the complete payload before applying cross-field business rules. const parsed = CreateEscrowSchema.safeParse(escrow) if (!parsed.success) { return { @@ -120,6 +123,7 @@ export function validateEscrowCreation( // Validate parties are unique const parties = [escrow.buyer, escrow.seller, escrow.arbiter] + // A distinct arbiter prevents one participant from controlling every signature. const uniqueParties = new Set(parties) if (uniqueParties.size !== 3) { return { @@ -132,6 +136,7 @@ export function validateEscrowCreation( // Validate amount try { const amountBI = BigInt(escrow.amount) + // BigInt comparisons keep the maximum check exact for on-chain-sized amounts. const maxBI = BigInt(MAX_ESCROW_AMOUNT) if (amountBI <= BigInt(0)) { @@ -167,6 +172,7 @@ export function validateEscrowSignature( signature: SignEscrow, escrow: Escrow ): { valid: true } | { valid: false; error: string; code: string } { + // Signature validation is intentionally separate from transaction submission. const parsed = SignEscrowSchema.safeParse(signature) if (!parsed.success) { return { @@ -187,6 +193,7 @@ export function validateEscrowSignature( // Check signer authorization const authorizedSigners = [escrow.buyer, escrow.seller, escrow.arbiter] + // Only the three parties recorded at creation may participate in consensus. if (!authorizedSigners.includes(signature.signer)) { return { valid: false, @@ -197,6 +204,7 @@ export function validateEscrowSignature( // Check if already signed const existingSignature = escrow.signatures.find((s) => s.signer === signature.signer) + // A signer cannot change their decision by submitting a second record. if (existingSignature) { return { valid: false, @@ -228,6 +236,7 @@ export function hasReachedConsensus(escrow: Escrow): { approvals: number rejections: number } { + // Approval and rejection thresholds are evaluated independently for 2-of-3 voting. const approvals = escrow.signatures.filter((s) => s.decision === "approve").length const rejections = escrow.signatures.filter((s) => s.decision === "reject").length @@ -308,6 +317,7 @@ export function determineSettlement(escrow: Escrow): { outcome: "complete" | "refund" | "expire" | "pending" reason: string } { + // Settlement checks expiration first so an unfunded consensus cannot revive a timeout. // Check expiration if (isEscrowExpired(escrow) && escrow.status === "funded") { return { diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index 6c13499..cf622d8 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -154,17 +154,22 @@ const FLAG_ENV_MAP: Record = { }; function isTruthy(v: string | undefined): boolean { + // Accept common deployment-system spellings while keeping unset values disabled. if (!v) return false; const s = v.trim().toLowerCase(); + // Lowercasing makes environment configuration case-insensitive. return s === "1" || s === "true" || s === "yes" || s === "on"; } export function isFeatureEnabled(flag: PhaseFeatureFlag): boolean { + // Public keys are checked first so the same helper works in browser and server code. const keys = FLAG_ENV_MAP[flag] ?? [ `NEXT_PUBLIC_FEATURE_${flag.replace(/-/g, "_").toUpperCase()}`, `FEATURE_${flag.replace(/-/g, "_").toUpperCase()}`, ]; + // Unknown flags still receive a predictable convention-based fallback. for (const k of keys) { + // Reading process.env defensively keeps this module safe during client bundling. const v = typeof process !== "undefined" ? (process.env as Record)[k] @@ -175,6 +180,7 @@ export function isFeatureEnabled(flag: PhaseFeatureFlag): boolean { } export function featureFlagEnvKeys(flag: PhaseFeatureFlag): string[] { + // Exposing the exact keys lets diagnostics explain how to enable a flag. return FLAG_ENV_MAP[flag] ? [...FLAG_ENV_MAP[flag]] : [ @@ -184,6 +190,7 @@ export function featureFlagEnvKeys(flag: PhaseFeatureFlag): string[] { } export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { + // Keep enumeration explicit so dashboards have stable, reviewable flag order. const all: PhaseFeatureFlag[] = [ "phase-66", "phase-77", @@ -234,10 +241,13 @@ export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { "phase-140", "phase-141", ]; + // Filtering is evaluated at call time, allowing runtime test overrides. return all.filter(isFeatureEnabled) } export function flagRollbackNote(flag: PhaseFeatureFlag): string { + // Rollback guidance is generated from the same key mapping used for activation. + // Returning a plain sentence keeps the note suitable for logs and admin screens. const keys = featureFlagEnvKeys(flag).join(" / "); return `Rollback ${flag}: unset ${keys} or set to 0/false and restart. No data migration to revert.`; } diff --git a/lib/gateway-health.ts b/lib/gateway-health.ts index ea4e6db..9f29f65 100644 --- a/lib/gateway-health.ts +++ b/lib/gateway-health.ts @@ -41,6 +41,7 @@ type InternalSample = { latencyMs: number; ok: boolean; at: number } const MAX_SAMPLES_PER_GATEWAY = 50 const SCORE_LATENCY_WEIGHT = 0.6 const SCORE_UPTIME_WEIGHT = 0.4 +// Keeping samples bounded prevents a long-lived server from growing without limit. // In-memory store (per-process). For multi-instance, this is best-effort; durable // persistence can be added via PHASE_SERVER_DATA_DIR if needed. @@ -48,12 +49,14 @@ const samples = new Map() const lastStatus = new Map() function percentile(sorted: number[], p: number): number { + // Callers sort first so percentile lookup remains a constant-time index operation. if (sorted.length === 0) return 0 const idx = Math.ceil((p / 100) * sorted.length) - 1 return sorted[Math.max(0, Math.min(idx, sorted.length - 1))] ?? 0 } function scoreFor(latencies: number[], uptime: number): number { + // A neutral score avoids prematurely ranking an untested gateway as best or worst. if (latencies.length === 0) return 50 const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length // Map avg latency to 0-100: 0ms=100, 500ms=80, 2000ms=40, 5000ms=10, 8000ms=0 @@ -64,20 +67,24 @@ function scoreFor(latencies: number[], uptime: number): number { else latencyScore = Math.max(0, 20 - ((avg - 4000) / 4000) * 20) // 0-20 const uptimeScore = uptime * 100 + // Latency and availability are blended so one fast failure cannot look healthy. const raw = latencyScore * SCORE_LATENCY_WEIGHT + uptimeScore * SCORE_UPTIME_WEIGHT return Math.max(0, Math.min(100, Math.round(raw))) } function normalizeGateway(gateway: string): string { + // Canonical URLs prevent duplicate entries caused only by trailing slashes. return gateway.trim().replace(/\/+$/, "") } export function recordGatewayLatency(gateway: string, latencyMs: number, ok: boolean): void { + // The feature gate makes instrumentation removable without changing callers. if (!isFeatureEnabled("phase-121") && process.env.NODE_ENV !== "test") { // Still record in test; otherwise no-op when flag off to avoid overhead return } const key = normalizeGateway(gateway) + // Clamp negative measurements from malformed timers before scoring them. const list = samples.get(key) ?? [] list.push({ latencyMs: Math.max(0, latencyMs), ok, at: Date.now() }) if (list.length > MAX_SAMPLES_PER_GATEWAY) list.shift() @@ -86,6 +93,7 @@ export function recordGatewayLatency(gateway: string, latencyMs: number, ok: boo } export function getGatewayHealthSnapshot(): GatewayHealthSnapshot { + // Build a fresh snapshot so consumers cannot mutate the internal maps. const enabled = isFeatureEnabled("phase-121") const entries: GatewayHealthEntry[] = [] @@ -143,6 +151,7 @@ export function getGatewayHealthSnapshot(): GatewayHealthSnapshot { } entries.sort((a, b) => b.score - a.score) + // Sorted entries make the first and last values the best and worst candidates. return { enabled, @@ -161,6 +170,7 @@ export function getGatewayRanking(): string[] { } export function resetGatewayHealth(): void { + // Tests and operational resets need to clear both samples and latest status. samples.clear() lastStatus.clear() } diff --git a/lib/json-store.ts b/lib/json-store.ts index 34d7f19..7695ee6 100644 --- a/lib/json-store.ts +++ b/lib/json-store.ts @@ -39,6 +39,7 @@ import { serverDataJsonPath, type ServerDataFile } from "@/lib/server-data-paths */ const fileQueues = new Map>(); +// The queue key is the normalized absolute path supplied by each store. /** * Runs `task` with exclusive access to `filePath` within this process. Pairing @@ -50,6 +51,7 @@ export function withFileLock( filePath: string, task: () => Promise, ): Promise { + // Reusing the previous promise preserves mutation order for this file. const previous = fileQueues.get(filePath) ?? Promise.resolve(); const next = previous.then(task, task); fileQueues.set( @@ -63,6 +65,7 @@ export function withFileLock( } function isErrnoCode(error: unknown, code: string): boolean { + // Error-shape checking stays local so callers can remain platform-neutral. return ( typeof error === "object" && error !== null && @@ -78,6 +81,7 @@ export async function readJsonFile( filePath: string, fallback: T, ): Promise { + // Reads remain unlocked because they never mutate shared state. let raw: string; try { raw = await readFile(filePath, "utf8"); @@ -101,10 +105,13 @@ export async function writeJsonFileAtomic( filePath: string, data: unknown, ): Promise { + // The temporary sibling keeps rename atomic on the same filesystem. await mkdir(path.dirname(filePath), { recursive: true }); const tmpPath = `${filePath}.${process.pid}.${Date.now().toString(36)}.tmp`; + // A process-and-time suffix avoids collisions between simultaneous writers. try { await writeFile(tmpPath, JSON.stringify(data, null, 2), "utf8"); + // Rename is the commit point: readers see either the old file or the new one. await rename(tmpPath, filePath); } catch (error) { await rm(tmpPath, { force: true }).catch(() => undefined); @@ -127,12 +134,14 @@ export async function updateJsonFile( mutate: (store: T) => R | Promise; }, ): Promise { + // Both read and write must stay inside one lock to prevent lost updates. return withFileLock(filePath, async () => { const raw = await readJsonFile(filePath, undefined); // A missing file starts from an empty store, matching the // `catch { return {} }` readers this replaces. const store = opts.read ? opts.read(raw) : ((raw ?? {}) as T); const result = await opts.mutate(store); + // Persist only after the callback succeeds, so failed mutations are not saved. await writeJsonFileAtomic(filePath, store); return result; }); @@ -163,5 +172,6 @@ export function readStore( key: ServerDataFile, fallback: () => T, ): Promise { + // Registered stores resolve their path through one shared server-data policy. return readJsonFile(serverDataJsonPath(key), fallback()); } diff --git a/lib/phase-protocol.ts b/lib/phase-protocol.ts index 99fe0c0..21bb857 100644 --- a/lib/phase-protocol.ts +++ b/lib/phase-protocol.ts @@ -34,11 +34,13 @@ export { recordGatewayLatency, getGatewayHealthSnapshot, getGatewayRanking, rese export type { GatewayHealthEntry, GatewayHealthSnapshot } from "@/lib/gateway-health" function isPhase121Enabled(): boolean { + // This local check keeps the dashboard gate usable before feature-flag initialization. const v = (typeof process !== "undefined" ? (process.env.NEXT_PUBLIC_FEATURE_PHASE_121 ?? process.env.FEATURE_PHASE_121 ?? "") : "")?.trim().toLowerCase() return v === "1" || v === "true" || v === "yes" || v === "on" } // Light wrapper for dashboard consumers (adds flag context, structured error) export function getGatewayHealthDashboardSafe(): { enabled: boolean; snapshot: import("@/lib/gateway-health").GatewayHealthSnapshot | null; error: string | null } { + // UI callers receive structured state instead of handling a thrown dashboard error. if (!isPhase121Enabled()) return { enabled: false, snapshot: null, error: "phase-121 flag disabled (set NEXT_PUBLIC_FEATURE_PHASE_121=1)" } try { return { enabled: true, snapshot: _getGatewayHealthSnapshot(), error: null } @@ -68,6 +70,7 @@ function sorobanContractIdFromEnv( fallback: string, settingName: string, ): string { + // Prefer the first configured key, then validate the resolved value uniformly. const raw = envKeys.map((k) => k?.trim()).find((v) => v && v.length > 0) const id = raw ?? fallback if (StrKey.isValidContract(id)) return id @@ -83,6 +86,7 @@ function sorobanContractIdFromEnv( } export const CONTRACT_ID = (() => { + // Client bundles must use public environment variables to avoid hydration drift. const e = (typeof process !== "undefined" ? process.env : {}) as NodeJS.ProcessEnv // Solo NEXT_PUBLIC_*: PHASE_PROTOCOL_ID (sin prefijo) no existe en el bundle del cliente → hydration mismatch. return sorobanContractIdFromEnv( @@ -97,6 +101,7 @@ export const CONTRACT_ID = (() => { * No uses esto en componentes cliente. */ export function phaseProtocolContractIdForServer(): string { + // Server routes may additionally read the non-public protocol ID override. const e = process.env return sorobanContractIdFromEnv( [e.NEXT_PUBLIC_PHASE_PROTOCOL_ID, e.PHASE_PROTOCOL_ID], @@ -107,6 +112,7 @@ export function phaseProtocolContractIdForServer(): string { /** Contrato PHASE (NFT de utilidad) en Stellar Expert — testnet */ export function stellarExpertTestnetContractUrl(contractId: string = CONTRACT_ID) { + // Centralizing explorer links keeps network selection consistent across the UI. return `https://stellar.expert/explorer/testnet/contract/${contractId}` } @@ -179,6 +185,7 @@ export const RPC_URL = "https://soroban-testnet.stellar.org" * o deja que se infiera desde `