Skip to content
Merged

fy #365

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/escrow-settlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/),
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions lib/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,17 +154,22 @@ const FLAG_ENV_MAP: Record<PhaseFeatureFlag, string[]> = {
};

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<string, string | undefined>)[k]
Expand All @@ -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]]
: [
Expand All @@ -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",
Expand Down Expand Up @@ -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.`;
}
10 changes: 10 additions & 0 deletions lib/gateway-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,22 @@ 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.
const samples = new Map<string, InternalSample[]>()
const lastStatus = new Map<string, { ok: boolean; latencyMs: number; at: number }>()

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
Expand All @@ -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()
Expand All @@ -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[] = []

Expand Down Expand Up @@ -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,
Expand All @@ -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()
}
Expand Down
10 changes: 10 additions & 0 deletions lib/json-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { serverDataJsonPath, type ServerDataFile } from "@/lib/server-data-paths
*/

const fileQueues = new Map<string, Promise<unknown>>();
// The queue key is the normalized absolute path supplied by each store.

/**
* Runs `task` with exclusive access to `filePath` within this process. Pairing
Expand All @@ -50,6 +51,7 @@ export function withFileLock<T>(
filePath: string,
task: () => Promise<T>,
): Promise<T> {
// 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(
Expand All @@ -63,6 +65,7 @@ export function withFileLock<T>(
}

function isErrnoCode(error: unknown, code: string): boolean {
// Error-shape checking stays local so callers can remain platform-neutral.
return (
typeof error === "object" &&
error !== null &&
Expand All @@ -78,6 +81,7 @@ export async function readJsonFile<T>(
filePath: string,
fallback: T,
): Promise<T> {
// Reads remain unlocked because they never mutate shared state.
let raw: string;
try {
raw = await readFile(filePath, "utf8");
Expand All @@ -101,10 +105,13 @@ export async function writeJsonFileAtomic(
filePath: string,
data: unknown,
): Promise<void> {
// 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);
Expand All @@ -127,12 +134,14 @@ export async function updateJsonFile<T, R>(
mutate: (store: T) => R | Promise<R>;
},
): Promise<R> {
// Both read and write must stay inside one lock to prevent lost updates.
return withFileLock(filePath, async () => {
const raw = await readJsonFile<unknown>(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;
});
Expand Down Expand Up @@ -163,5 +172,6 @@ export function readStore<T extends object>(
key: ServerDataFile,
fallback: () => T,
): Promise<T> {
// Registered stores resolve their path through one shared server-data policy.
return readJsonFile<T>(serverDataJsonPath(key), fallback());
}
10 changes: 10 additions & 0 deletions lib/phase-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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],
Expand All @@ -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}`
}

Expand Down Expand Up @@ -179,6 +185,7 @@ export const RPC_URL = "https://soroban-testnet.stellar.org"
* o deja que se infiera desde `<script src="…/_next/…">` (reverse proxy sin `basePath` en next.config).
*/
function inferClientBasePathFromNextScripts(): string {
// Script URLs reveal a reverse-proxy subpath when Next.js has no explicit basePath.
if (typeof document === "undefined") return ""
const scripts = document.getElementsByTagName("script")
for (let i = 0; i < scripts.length; i++) {
Expand All @@ -197,6 +204,7 @@ function inferClientBasePathFromNextScripts(): string {
}

function effectiveSorobanRpcUrl(): string {
// Browser requests prefer the same-origin proxy because public Soroban RPC lacks CORS.
if (typeof window !== "undefined") {
const pub = process.env.NEXT_PUBLIC_SOROBAN_RPC_PROXY_URL?.trim()
if (pub && /^https?:\/\//i.test(pub)) return pub
Expand Down Expand Up @@ -322,13 +330,15 @@ export const READONLY_SIM_SOURCE_G =

/** El SDK rechaza URLs `http://` (p. ej. `http://localhost:3000/api/soroban-rpc`) sin este flag. */
function newRpcServerForUrl(url: string): rpc.Server {
// The SDK requires an explicit opt-in for local HTTP endpoints.
const allowHttp = url.toLowerCase().startsWith("http://")
return new rpc.Server(url, allowHttp ? { allowHttp: true } : undefined)
}

let _rpcServer: rpc.Server | null = null
let _cachedRpcUrl: string | null = null
function getRpc(): rpc.Server {
// Reuse the RPC client until configuration changes, avoiding needless connections.
const url = effectiveSorobanRpcUrl()
if (_rpcServer && _cachedRpcUrl === url) return _rpcServer
_cachedRpcUrl = url
Expand Down
Loading