diff --git a/backend/migrations/20260625000001_optimize_payment_processor_indexes.js b/backend/migrations/20260625000001_optimize_payment_processor_indexes.js new file mode 100644 index 00000000..575583b0 --- /dev/null +++ b/backend/migrations/20260625000001_optimize_payment_processor_indexes.js @@ -0,0 +1,73 @@ +/** + * Migration: Optimize Payment Processor SQL Queries + * Issue #924: Optimize SQL queries in Payment Processor + * + * Adds targeted indexes to improve query performance for the most + * frequently executed payment service queries, particularly the + * payment listing and rolling metrics endpoints. + */ + +export async function up(knex) { + // Partial index for payment listing with search (ILIKE on id, description, recipient) + // The existing merchant_id index doesn't cover text search well + await knex.raw(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payments_description_search + ON payments USING gin (description gin_trgm_ops) + WHERE deleted_at IS NULL AND description IS NOT NULL + `).catch(() => { + // gin_trgm_ops extension may not be available; skip silently + console.log(" ℹ️ Skipping gin_trgm_ops index (extension not available)"); + }); + + // Covering index for payment status endpoint (avoids heap lookup for common fields) + await knex.raw(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payments_status_covering + ON payments (id) + INCLUDE (merchant_id, amount, asset, asset_issuer, recipient, status, tx_id, created_at) + WHERE deleted_at IS NULL + `); + + // Index for refund lookups: confirmed payments by merchant with tx_id + await knex.raw(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payments_merchant_refund + ON payments (merchant_id, status, id) + INCLUDE (amount, asset, asset_issuer, recipient, tx_id, metadata) + WHERE deleted_at IS NULL AND status = 'confirmed' + `); + + // Index for path payment quote lookups + await knex.raw(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payments_quote_lookup + ON payments (id, status, asset, asset_issuer, recipient, amount) + WHERE deleted_at IS NULL + `); + + // Index for the rolling metrics time-range query with amount aggregation + await knex.raw(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payments_metrics_window + ON payments (merchant_id, created_at DESC) + INCLUDE (amount, status) + WHERE deleted_at IS NULL + `); + + // Partial index for x402 payments + await knex.raw(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payments_x402_status + ON payments (status, created_at DESC) + WHERE deleted_at IS NULL AND metadata->>'x402_version' IS NOT NULL + `).catch(() => { + console.log(" ℹ️ Skipping x402 partial index (column or extension not available)"); + }); + + console.log("✓ Added Payment Processor query optimization indexes"); +} + +export async function down(knex) { + await knex.raw("DROP INDEX CONCURRENTLY IF EXISTS idx_payments_description_search"); + await knex.raw("DROP INDEX CONCURRENTLY IF EXISTS idx_payments_status_covering"); + await knex.raw("DROP INDEX CONCURRENTLY IF EXISTS idx_payments_merchant_refund"); + await knex.raw("DROP INDEX CONCURRENTLY IF EXISTS idx_payments_quote_lookup"); + await knex.raw("DROP INDEX CONCURRENTLY IF EXISTS idx_payments_metrics_window"); + await knex.raw("DROP INDEX CONCURRENTLY IF EXISTS idx_payments_x402_status"); + console.log("✓ Removed Payment Processor query optimization indexes"); +} diff --git a/backend/services/path-payment/errorRecovery.ts b/backend/services/path-payment/errorRecovery.ts index 7c80f6e1..0204573c 100644 --- a/backend/services/path-payment/errorRecovery.ts +++ b/backend/services/path-payment/errorRecovery.ts @@ -1,4 +1,4 @@ -import { logger } from '../../src/lib/logging'; +import { logger } from '../../src/lib/logger.js'; export enum CircuitState { CLOSED = 'CLOSED', @@ -6,58 +6,273 @@ export enum CircuitState { HALF_OPEN = 'HALF_OPEN' } +export enum ErrorCategory { + TRANSIENT = 'transient', + PERMANENT = 'permanent', + RATE_LIMITED = 'rate_limited', + AUTH = 'auth' +} + +interface ErrorRecoveryOptions { + maxRetries?: number; + failureThreshold?: number; + resetTimeoutMs?: number; + baseDelayMs?: number; + maxDelayMs?: number; + label?: string; +} + +interface RecoveryMetrics { + totalAttempts: number; + successCount: number; + failureCount: number; + circuitBreakerTrips: number; + lastFailureTime: number | null; + lastSuccessTime: number | null; +} + +const RETRYABLE_ERROR_CODES = new Set([ + '08000', '08003', '08006', '08P01', + '40001', '40P01', + '53300', '57P01', '57P02', '57P03', +]); + +const NON_RETRYABLE_REASONS = new Set([ + 'invalid_signature', 'malformed_request', 'authorization_failure', + 'invalid_input', 'not_found', 'duplicate', +]); + +function classifyError(error: any): ErrorCategory { + if (!error) return ErrorCategory.TRANSIENT; + + const reason = error.reason || error.message || ''; + if (NON_RETRYABLE_REASONS.has(reason)) return ErrorCategory.PERMANENT; + + const code = String(error.code || ''); + if (code === '429' || code === '57P01') return ErrorCategory.RATE_LIMITED; + if (code.startsWith('08') || code === '40001') return ErrorCategory.TRANSIENT; + + const status = error.status || error.response?.status; + if (status === 401 || status === 403) return ErrorCategory.AUTH; + if (status === 429) return ErrorCategory.RATE_LIMITED; + if (status >= 500) return ErrorCategory.TRANSIENT; + + return ErrorCategory.TRANSIENT; +} + +function isRetryable(error: any): boolean { + const category = classifyError(error); + return category === ErrorCategory.TRANSIENT || category === ErrorCategory.RATE_LIMITED; +} + +function getBackoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number { + const exponential = baseDelayMs * Math.pow(2, attempt); + const jitter = Math.random() * baseDelayMs; + return Math.min(exponential + jitter, maxDelayMs); +} + export class ErrorRecovery { - private maxRetries = 3; + private maxRetries: number; private circuitState: CircuitState = CircuitState.CLOSED; private failureCount = 0; - private failureThreshold = 5; + private failureThreshold: number; + private resetTimeoutMs: number; + private baseDelayMs: number; + private maxDelayMs: number; + private label: string; + private resetTimer: ReturnType | null = null; + private halfOpenSuccessCount = 0; + private halfOpenRequired = 2; + + private metrics: RecoveryMetrics = { + totalAttempts: 0, + successCount: 0, + failureCount: 0, + circuitBreakerTrips: 0, + lastFailureTime: null, + lastSuccessTime: null, + }; + + constructor(options: ErrorRecoveryOptions = {}) { + this.maxRetries = options.maxRetries ?? 3; + this.failureThreshold = options.failureThreshold ?? 5; + this.resetTimeoutMs = options.resetTimeoutMs ?? 60_000; + this.baseDelayMs = options.baseDelayMs ?? 1_000; + this.maxDelayMs = options.maxDelayMs ?? 30_000; + this.label = options.label ?? 'payment-processor'; + } + + getState(): CircuitState { + return this.circuitState; + } + + getMetrics(): RecoveryMetrics { + return { ...this.metrics }; + } - private isRetryable(error: any): boolean { - const nonRetryableReasons = ['invalid_signature', 'malformed_request', 'authorization_failure']; - if (error && error.reason && nonRetryableReasons.includes(error.reason)) { - return false; + reset(): void { + this.circuitState = CircuitState.CLOSED; + this.failureCount = 0; + this.halfOpenSuccessCount = 0; + if (this.resetTimer) { + clearTimeout(this.resetTimer); + this.resetTimer = null; } - // Assume other DB failures / Horizon timeouts are retryable - return true; + logger.info({ label: this.label }, 'Error recovery: circuit breaker reset'); } - public async executeWithRetry(operation: () => Promise): Promise { + async executeWithRetry(operation: () => Promise): Promise { if (this.circuitState === CircuitState.OPEN) { - throw new Error('Circuit Breaker is OPEN'); + const error = new Error(`Circuit breaker is OPEN for ${this.label}`); + (error as any).category = ErrorCategory.TRANSIENT; + (error as any).circuitBreakerOpen = true; + throw error; } let attempt = 0; while (attempt <= this.maxRetries) { + this.metrics.totalAttempts++; try { const result = await operation(); this.onSuccess(); return result; } catch (error) { attempt++; - if (!this.isRetryable(error) || attempt > this.maxRetries) { + const category = classifyError(error); + const retryable = isRetryable(error); + + if (!retryable || attempt > this.maxRetries) { this.onFailure(); + logger.error( + { + label: this.label, + attempt, + category, + error: (error as any)?.message || String(error), + circuitState: this.circuitState, + }, + 'Error recovery: operation failed permanently', + ); throw error; } - logger.warn({ event: "path_payment_retry", attempt }); - await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + + const delayMs = getBackoffDelay(attempt - 1, this.baseDelayMs, this.maxDelayMs); + logger.warn( + { + label: this.label, + attempt, + maxRetries: this.maxRetries, + delayMs, + category, + error: (error as any)?.message || String(error), + }, + 'Error recovery: retryable error — retrying with backoff', + ); + await new Promise(resolve => setTimeout(resolve, delayMs)); } } - throw new Error('Max retries exceeded'); + + const error = new Error(`Max retries (${this.maxRetries}) exceeded for ${this.label}`); + this.onFailure(); + throw error; } - private onSuccess() { - this.failureCount = 0; - this.circuitState = CircuitState.CLOSED; + private onSuccess(): void { + this.metrics.successCount++; + this.metrics.lastSuccessTime = Date.now(); + + if (this.circuitState === CircuitState.HALF_OPEN) { + this.halfOpenSuccessCount++; + if (this.halfOpenSuccessCount >= this.halfOpenRequired) { + this.circuitState = CircuitState.CLOSED; + this.failureCount = 0; + this.halfOpenSuccessCount = 0; + logger.info( + { label: this.label, successCount: this.halfOpenSuccessCount }, + 'Error recovery: circuit breaker CLOSED — service recovered', + ); + } + } else { + this.failureCount = 0; + } } - private onFailure() { + private onFailure(): void { this.failureCount++; + this.metrics.failureCount++; + this.metrics.lastFailureTime = Date.now(); + + if (this.circuitState === CircuitState.HALF_OPEN) { + this.tripCircuitBreaker(); + return; + } + if (this.failureCount >= this.failureThreshold) { - this.circuitState = CircuitState.OPEN; - // In a real app, transition to half-open after a timeout - setTimeout(() => { - this.circuitState = CircuitState.HALF_OPEN; - }, 10000); + this.tripCircuitBreaker(); } } + + private tripCircuitBreaker(): void { + this.circuitState = CircuitState.OPEN; + this.metrics.circuitBreakerTrips++; + this.halfOpenSuccessCount = 0; + + logger.error( + { + label: this.label, + failureCount: this.failureCount, + resetTimeoutMs: this.resetTimeoutMs, + }, + 'Error recovery: circuit breaker OPEN — pausing operations', + ); + + if (this.resetTimer) { + clearTimeout(this.resetTimer); + } + + this.resetTimer = setTimeout(() => { + this.circuitState = CircuitState.HALF_OPEN; + this.halfOpenSuccessCount = 0; + logger.info( + { label: this.label }, + 'Error recovery: circuit breaker HALF_OPEN — allowing trial requests', + ); + }, this.resetTimeoutMs); + } +} + +export function createPaymentProcessorRecovery(options: ErrorRecoveryOptions = {}): ErrorRecovery { + return new ErrorRecovery({ + label: 'payment-processor', + maxRetries: 3, + failureThreshold: 5, + resetTimeoutMs: 60_000, + baseDelayMs: 1_000, + maxDelayMs: 30_000, + ...options, + }); +} + +export function createHorizonRecovery(options: ErrorRecoveryOptions = {}): ErrorRecovery { + return new ErrorRecovery({ + label: 'horizon-api', + maxRetries: 3, + failureThreshold: 10, + resetTimeoutMs: 120_000, + baseDelayMs: 500, + maxDelayMs: 15_000, + ...options, + }); +} + +export function createDatabaseRecovery(options: ErrorRecoveryOptions = {}): ErrorRecovery { + return new ErrorRecovery({ + label: 'database', + maxRetries: 2, + failureThreshold: 5, + resetTimeoutMs: 60_000, + baseDelayMs: 150, + maxDelayMs: 5_000, + ...options, + }); } diff --git a/backend/src/lib/payment-signature-verification.js b/backend/src/lib/payment-signature-verification.js new file mode 100644 index 00000000..f37c77ea --- /dev/null +++ b/backend/src/lib/payment-signature-verification.js @@ -0,0 +1,262 @@ +import { createHmac, timingSafeEqual, createHash } from "node:crypto"; +import { logger } from "./logger.js"; +import { + signatureVerificationTotal, + signatureVerificationDuration, + signatureVerificationReplayAttempts, +} from "./metrics.js"; + +const PAYMENT_SIGNATURE_CACHE_TTL_MS = 60_000; +const paymentSignatureCache = new Map(); + +function getSignatureCacheKey(txHash, merchantId) { + return `${merchantId || "global"}:${txHash}`; +} + +function getCachedVerification(txHash, merchantId) { + const key = getSignatureCacheKey(txHash, merchantId); + const cached = paymentSignatureCache.get(key); + if (!cached) return null; + if (Date.now() - cached.timestamp > PAYMENT_SIGNATURE_CACHE_TTL_MS) { + paymentSignatureCache.delete(key); + return null; + } + return cached.result; +} + +function setCachedVerification(txHash, merchantId, result) { + const key = getSignatureCacheKey(txHash, merchantId); + if (paymentSignatureCache.size > 1000) { + const oldestKey = paymentSignatureCache.keys().next().value; + paymentSignatureCache.delete(oldestKey); + } + paymentSignatureCache.set(key, { result, timestamp: Date.now() }); +} + +export function invalidateSignatureCache(txHash, merchantId) { + const key = getSignatureCacheKey(txHash, merchantId); + paymentSignatureCache.delete(key); +} + +export function clearSignatureCache() { + paymentSignatureCache.clear(); +} + +export function signPaymentPayload(payload, secret) { + if (!secret || typeof secret !== "string") { + throw new Error("Signing secret is required"); + } + const rawBody = typeof payload === "string" ? payload : JSON.stringify(payload); + return createHmac("sha256", secret).update(rawBody).digest("hex"); +} + +export function verifyPaymentPayloadSignature(payload, signature, secret) { + if (!payload || !signature || !secret) return false; + + const expected = signPaymentPayload(payload, secret); + const sigStr = String(signature).trim(); + + let providedSig = sigStr; + if (sigStr.startsWith("sha256=")) { + providedSig = sigStr.slice("sha256=".length); + } + + if (!/^[a-f0-9]{64}$/i.test(providedSig)) return false; + + const a = Buffer.from(providedSig.toLowerCase(), "utf8"); + const b = Buffer.from(expected.toLowerCase(), "utf8"); + + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export function signRequestTimestamp(timestamp, secret) { + if (!secret) throw new Error("Signing secret is required"); + return createHmac("sha256", secret).update(String(timestamp)).digest("hex"); +} + +export function verifyRequestTimestamp(timestamp, signature, secret, toleranceSeconds = 300) { + if (!timestamp || !signature || !secret) return false; + + const now = Math.floor(Date.now() / 1000); + const requestTime = parseInt(String(timestamp), 10); + if (Number.isNaN(requestTime)) return false; + + const timeDiff = Math.abs(now - requestTime); + if (timeDiff > toleranceSeconds) return false; + + const expected = signRequestTimestamp(timestamp, secret); + const sigStr = String(signature).trim(); + let providedSig = sigStr; + if (sigStr.startsWith("sha256=")) { + providedSig = sigStr.slice("sha256=".length); + } + + if (!/^[a-f0-9]{64}$/i.test(providedSig)) return false; + + const a = Buffer.from(providedSig.toLowerCase(), "utf8"); + const b = Buffer.from(expected.toLowerCase(), "utf8"); + + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export function computeTransactionHash(payload) { + return createHash("sha256").update(JSON.stringify(payload)).digest("hex"); +} + +export function verifyReplayProtection(txHash, merchantId, windowMs = 300_000) { + const cacheKey = `replay:${merchantId || "global"}:${txHash}`; + const now = Date.now(); + + for (const [key, entry] of paymentSignatureCache.entries()) { + if (key.startsWith("replay:") && now - entry.timestamp > windowMs) { + paymentSignatureCache.delete(key); + } + } + + if (paymentSignatureCache.has(cacheKey)) { + signatureVerificationReplayAttempts.inc(); + logger.warn( + { txHash, merchantId }, + "Payment signature verification: replay attempt detected", + ); + return false; + } + + paymentSignatureCache.set(cacheKey, { result: true, timestamp: now }); + return true; +} + +export async function verifyPaymentTransactionSignature(txHash, options = {}) { + const { merchantId = null, useCache = true } = options; + const startTime = Date.now(); + + if (!txHash || typeof txHash !== "string") { + signatureVerificationTotal.inc({ result: "error" }); + signatureVerificationDuration.observe({ result: "error" }, (Date.now() - startTime) / 1000); + return { + valid: false, + reason: "Invalid transaction hash provided", + isMultiSig: false, + signatureCount: 0, + thresholdMet: false, + cached: false, + }; + } + + if (useCache) { + const cached = getCachedVerification(txHash, merchantId); + if (cached) { + logger.debug({ txHash, merchantId }, "Payment signature verification: cache hit"); + return { ...cached, cached: true }; + } + } + + let verifyTransactionSignature; + try { + const stellar = await import("./stellar.js"); + verifyTransactionSignature = stellar.verifyTransactionSignature; + } catch (err) { + logger.error({ err }, "Payment signature verification: failed to load stellar module"); + signatureVerificationTotal.inc({ result: "error" }); + signatureVerificationDuration.observe({ result: "error" }, (Date.now() - startTime) / 1000); + return { + valid: false, + reason: "Stellar SDK not available", + isMultiSig: false, + signatureCount: 0, + thresholdMet: false, + cached: false, + }; + } + + if (typeof verifyTransactionSignature !== "function") { + signatureVerificationTotal.inc({ result: "skipped" }); + signatureVerificationDuration.observe({ result: "skipped" }, (Date.now() - startTime) / 1000); + return { + valid: true, + reason: "Signature verification not available — skipped", + isMultiSig: false, + signatureCount: 0, + thresholdMet: false, + cached: false, + skipped: true, + }; + } + + try { + const result = await verifyTransactionSignature(txHash); + + if (result && typeof result === "object" && result.valid !== undefined) { + if (useCache) { + setCachedVerification(txHash, merchantId, result); + } + + logger.info( + { + txHash, + merchantId, + valid: result.valid, + isMultiSig: result.isMultiSig, + signatureCount: result.signatureCount, + thresholdMet: result.thresholdMet, + durationMs: Date.now() - startTime, + }, + "Payment signature verification: completed", + ); + + return { ...result, cached: false }; + } + + const accepted = result === true || (result && typeof result === "object" && result.valid === true); + const normalized = { + valid: accepted, + reason: accepted ? "Signature accepted" : "Signature rejected", + isMultiSig: false, + signatureCount: 0, + thresholdMet: accepted, + cached: false, + }; + + if (useCache) { + setCachedVerification(txHash, merchantId, normalized); + } + + return normalized; + } catch (err) { + logger.error( + { + txHash, + merchantId, + error: err.message, + durationMs: Date.now() - startTime, + }, + "Payment signature verification: unexpected error", + ); + + signatureVerificationTotal.inc({ result: "error" }); + signatureVerificationDuration.observe({ result: "error" }, (Date.now() - startTime) / 1000); + + return { + valid: false, + reason: `Verification error: ${err.message}`, + isMultiSig: false, + signatureCount: 0, + thresholdMet: false, + cached: false, + }; + } +} + +export const paymentSignatureVerifier = { + verifyTransaction: verifyPaymentTransactionSignature, + verifyPayload: verifyPaymentPayloadSignature, + verifyTimestamp: verifyRequestTimestamp, + signPayload: signPaymentPayload, + signTimestamp: signRequestTimestamp, + computeHash: computeTransactionHash, + checkReplay: verifyReplayProtection, + invalidateCache: invalidateSignatureCache, + clearCache: clearSignatureCache, +}; diff --git a/backend/src/lib/payment-signature-verification.test.js b/backend/src/lib/payment-signature-verification.test.js new file mode 100644 index 00000000..49fb213b --- /dev/null +++ b/backend/src/lib/payment-signature-verification.test.js @@ -0,0 +1,230 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const { + mockVerifyTransactionSignature, + mockSignatureVerificationTotal, + mockSignatureVerificationDuration, + mockSignatureVerificationReplayAttempts, +} = vi.hoisted(() => ({ + mockVerifyTransactionSignature: vi.fn(), + mockSignatureVerificationTotal: { inc: vi.fn() }, + mockSignatureVerificationDuration: { observe: vi.fn() }, + mockSignatureVerificationReplayAttempts: { inc: vi.fn() }, +})); + +vi.mock("./stellar.js", () => ({ + verifyTransactionSignature: mockVerifyTransactionSignature, +})); + +vi.mock("./metrics.js", () => ({ + signatureVerificationTotal: mockSignatureVerificationTotal, + signatureVerificationDuration: mockSignatureVerificationDuration, + signatureVerificationReplayAttempts: mockSignatureVerificationReplayAttempts, +})); + +vi.mock("./logger.js", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { + signPaymentPayload, + verifyPaymentPayloadSignature, + signRequestTimestamp, + verifyRequestTimestamp, + computeTransactionHash, + verifyReplayProtection, + verifyPaymentTransactionSignature, + clearSignatureCache, + paymentSignatureVerifier, +} from "./payment-signature-verification.js"; + +const TEST_SECRET = "whsec_test1234567890abcdef1234567890ab"; + +describe("payment-signature-verification", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearSignatureCache(); + }); + + describe("signPayload / verifyPayload", () => { + it("signs a payload and verifies it successfully", () => { + const payload = { payment_id: "pay_1", amount: 10.5 }; + const signature = signPaymentPayload(payload, TEST_SECRET); + + expect(signature).toMatch(/^[a-f0-9]{64}$/); + expect(verifyPaymentPayloadSignature(payload, signature, TEST_SECRET)).toBe(true); + }); + + it("rejects invalid signature", () => { + const payload = { payment_id: "pay_1" }; + const signature = signPaymentPayload(payload, TEST_SECRET); + + expect(verifyPaymentPayloadSignature(payload, "invalid_sig", TEST_SECRET)).toBe(false); + }); + + it("rejects wrong secret", () => { + const payload = { payment_id: "pay_1" }; + const signature = signPaymentPayload(payload, TEST_SECRET); + + expect(verifyPaymentPayloadSignature(payload, signature, "wrong_secret")).toBe(false); + }); + + it("handles sha256= prefix in signature", () => { + const payload = { payment_id: "pay_1" }; + const signature = signPaymentPayload(payload, TEST_SECRET); + + expect(verifyPaymentPayloadSignature(payload, `sha256=${signature}`, TEST_SECRET)).toBe(true); + }); + + it("rejects missing parameters", () => { + expect(verifyPaymentPayloadSignature(null, "sig", TEST_SECRET)).toBe(false); + expect(verifyPaymentPayloadSignature({}, null, TEST_SECRET)).toBe(false); + expect(verifyPaymentPayloadSignature({}, "sig", null)).toBe(false); + }); + + it("throws when signing without secret", () => { + expect(() => signPaymentPayload({}, null)).toThrow("Signing secret is required"); + }); + + it("handles string payload", () => { + const payload = "raw body string"; + const signature = signPaymentPayload(payload, TEST_SECRET); + + expect(verifyPaymentPayloadSignature(payload, signature, TEST_SECRET)).toBe(true); + }); + }); + + describe("signTimestamp / verifyTimestamp", () => { + it("signs and verifies a timestamp within tolerance", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = signRequestTimestamp(timestamp, TEST_SECRET); + + expect(signature).toMatch(/^[a-f0-9]{64}$/); + expect(verifyRequestTimestamp(timestamp, signature, TEST_SECRET, 300)).toBe(true); + }); + + it("rejects expired timestamp", () => { + const oldTimestamp = Math.floor(Date.now() / 1000 - 600).toString(); + const signature = signRequestTimestamp(oldTimestamp, TEST_SECRET); + + expect(verifyRequestTimestamp(oldTimestamp, signature, TEST_SECRET, 300)).toBe(false); + }); + + it("rejects invalid timestamp", () => { + const signature = signRequestTimestamp("12345", TEST_SECRET); + expect(verifyRequestTimestamp("not_a_number", signature, TEST_SECRET)).toBe(false); + }); + }); + + describe("computeTransactionHash", () => { + it("produces consistent SHA-256 hashes", () => { + const payload = { id: "pay_1", amount: 10 }; + const hash1 = computeTransactionHash(payload); + const hash2 = computeTransactionHash(payload); + + expect(hash1).toBe(hash2); + expect(hash1).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces different hashes for different payloads", () => { + const hash1 = computeTransactionHash({ id: "pay_1" }); + const hash2 = computeTransactionHash({ id: "pay_2" }); + + expect(hash1).not.toBe(hash2); + }); + }); + + describe("verifyReplayProtection", () => { + it("allows first occurrence of a transaction", () => { + expect(verifyReplayProtection("tx_1", "merchant_1")).toBe(true); + }); + + it("blocks duplicate transaction within window", () => { + verifyReplayProtection("tx_1", "merchant_1"); + expect(verifyReplayProtection("tx_1", "merchant_1")).toBe(false); + expect(mockSignatureVerificationReplayAttempts.inc).toHaveBeenCalled(); + }); + + it("allows same tx_hash for different merchants", () => { + verifyReplayProtection("tx_1", "merchant_1"); + expect(verifyReplayProtection("tx_1", "merchant_2")).toBe(true); + }); + }); + + describe("verifyPaymentTransactionSignature", () => { + it("returns error for invalid txHash", async () => { + const result = await verifyPaymentTransactionSignature(null); + + expect(result.valid).toBe(false); + expect(result.reason).toBe("Invalid transaction hash provided"); + }); + + it("calls verifyTransactionSignature and normalizes result", async () => { + mockVerifyTransactionSignature.mockResolvedValue({ + valid: true, + reason: "passed", + isMultiSig: false, + signatureCount: 1, + thresholdMet: true, + }); + + const result = await verifyPaymentTransactionSignature("tx_hash_123"); + + expect(result.valid).toBe(true); + expect(result.cached).toBe(false); + expect(mockVerifyTransactionSignature).toHaveBeenCalledWith("tx_hash_123"); + }); + + it("returns cached result on second call", async () => { + mockVerifyTransactionSignature.mockResolvedValue({ + valid: true, + reason: "passed", + isMultiSig: false, + signatureCount: 1, + thresholdMet: true, + }); + + await verifyPaymentTransactionSignature("tx_hash_123"); + const result = await verifyPaymentTransactionSignature("tx_hash_123"); + + expect(result.cached).toBe(true); + expect(mockVerifyTransactionSignature).toHaveBeenCalledTimes(1); + }); + + it("handles legacy boolean result", async () => { + mockVerifyTransactionSignature.mockResolvedValue(true); + + const result = await verifyPaymentTransactionSignature("tx_hash_456"); + + expect(result.valid).toBe(true); + }); + + it("handles verification errors gracefully", async () => { + mockVerifyTransactionSignature.mockRejectedValue(new Error("Horizon unavailable")); + + const result = await verifyPaymentTransactionSignature("tx_hash_789"); + + expect(result.valid).toBe(false); + expect(result.reason).toContain("Verification error"); + }); + }); + + describe("paymentSignatureVerifier facade", () => { + it("exposes all expected methods", () => { + expect(typeof paymentSignatureVerifier.verifyTransaction).toBe("function"); + expect(typeof paymentSignatureVerifier.verifyPayload).toBe("function"); + expect(typeof paymentSignatureVerifier.verifyTimestamp).toBe("function"); + expect(typeof paymentSignatureVerifier.signPayload).toBe("function"); + expect(typeof paymentSignatureVerifier.signTimestamp).toBe("function"); + expect(typeof paymentSignatureVerifier.computeHash).toBe("function"); + expect(typeof paymentSignatureVerifier.checkReplay).toBe("function"); + expect(typeof paymentSignatureVerifier.invalidateCache).toBe("function"); + expect(typeof paymentSignatureVerifier.clearCache).toBe("function"); + }); + }); +}); diff --git a/backend/src/services/paymentService-security-audit.test.js b/backend/src/services/paymentService-security-audit.test.js new file mode 100644 index 00000000..0751c689 --- /dev/null +++ b/backend/src/services/paymentService-security-audit.test.js @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { + mockQueryWithRetry, + mockSupabaseFrom, + mockConnectRedisClient, + mockGetCachedPayment, + mockFindMatchingPayment, +} = vi.hoisted(() => ({ + mockQueryWithRetry: vi.fn(), + mockSupabaseFrom: vi.fn(), + mockConnectRedisClient: vi.fn(), + mockGetCachedPayment: vi.fn(), + mockFindMatchingPayment: vi.fn().mockResolvedValue({ + transaction_hash: "tx_hash_123", + received_amount: "10", + is_multisig: false, + }), +})); + +vi.mock("../lib/db.js", () => ({ + queryWithRetry: mockQueryWithRetry, +})); + +vi.mock("../lib/supabase.js", () => ({ + supabase: { from: mockSupabaseFrom }, +})); + +vi.mock("../lib/stellar.js", () => ({ + findMatchingPayment: mockFindMatchingPayment, + createRefundTransaction: vi.fn(), + findStrictReceivePaths: vi.fn(), + isValidStellarPublicKey: vi.fn().mockReturnValue(true), + verifyTransactionSignature: vi.fn(), +})); + +vi.mock("../lib/branding.js", () => ({ + resolveBrandingConfig: vi.fn().mockReturnValue({}), +})); + +vi.mock("../lib/webhooks.js", () => ({ + sendWebhook: vi.fn().mockResolvedValue({ ok: true }), +})); + +vi.mock("../webhooks/resolver.js", () => ({ + getPayloadForVersion: vi.fn().mockReturnValue({}), +})); + +vi.mock("../lib/email.js", () => ({ + sendReceiptEmail: vi.fn(), +})); + +vi.mock("../lib/email-templates.js", () => ({ + renderReceiptEmail: vi.fn().mockReturnValue(""), +})); + +vi.mock("../lib/redis.js", () => ({ + connectRedisClient: mockConnectRedisClient, + getCachedPayment: mockGetCachedPayment, + setCachedPayment: vi.fn(), + invalidatePaymentCache: vi.fn(), +})); + +vi.mock("../lib/metrics.js", () => ({ + paymentCreatedCounter: { inc: vi.fn() }, + paymentConfirmedCounter: { inc: vi.fn() }, + paymentConfirmationLatency: { observe: vi.fn() }, + paymentFailedCounter: { inc: vi.fn() }, +})); + +vi.mock("../lib/logger.js", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("../lib/payment-signature-verification.js", () => ({ + paymentSignatureVerifier: { + verifyTransaction: vi.fn().mockResolvedValue({ valid: true, cached: false }), + }, +})); + +import { paymentService } from "../services/paymentService.js"; + +describe("Payment Processor Security Audit", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConnectRedisClient.mockResolvedValue({ isOpen: false }); + mockGetCachedPayment.mockResolvedValue(null); + }); + + describe("Input Validation", () => { + it("rejects payment session with missing asset", async () => { + const merchant = { + id: "m1", + payment_limits: {}, + allowed_issuers: [], + branding_config: {}, + }; + + await expect( + paymentService.createPaymentSession(merchant, { + amount: 10, + recipient: "GDEST", + }) + ).rejects.toThrow(); + }); + + it("rejects payment session with zero amount", async () => { + const merchant = { + id: "m1", + payment_limits: {}, + allowed_issuers: [], + branding_config: {}, + }; + + await expect( + paymentService.createPaymentSession(merchant, { + amount: 0, + asset: "XLM", + recipient: "GDEST", + }) + ).rejects.toThrow(); + }); + }); + + describe("Parameterized Queries", () => { + it("uses parameterized SQL for payment listing (no string interpolation)", async () => { + mockQueryWithRetry.mockResolvedValue({ rows: [] }); + + await paymentService.getMerchantPayments("m1", { + page: "1", + limit: "10", + search: "test'; DROP TABLE payments;--", + }); + + const [sql, values] = mockQueryWithRetry.mock.calls[0]; + expect(sql).toContain("$1"); + expect(sql).toContain("ILIKE"); + expect(values).toContain("%test'; DROP TABLE payments;--%"); + }); + + it("uses parameterized SQL for rolling metrics", async () => { + mockQueryWithRetry.mockResolvedValue({ rows: [] }); + + await paymentService.getRollingMetrics("m1"); + + const [, values] = mockQueryWithRetry.mock.calls[0]; + expect(values).toEqual(["m1"]); + }); + }); + + describe("Payment Status Security", () => { + it("caches confirmed/completed payments but not pending", async () => { + const mockSet = vi.fn(); + mockConnectRedisClient.mockResolvedValue({ + isOpen: true, + set: mockSet, + get: vi.fn(), + del: vi.fn(), + }); + + const insert = vi.fn().mockResolvedValue({ error: null }); + const maybeSingle = vi.fn().mockResolvedValue({ + data: { + id: "pay_1", + amount: "10", + asset: "XLM", + recipient: "GDEST", + status: "pending", + metadata: {}, + merchants: { branding_config: null }, + }, + error: null, + }); + mockSupabaseFrom.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + maybeSingle, + }), + insert, + }); + + const result = await paymentService.getPaymentStatus("pay_1"); + + expect(result.payment).toBeDefined(); + expect(result.payment.status).toBe("pending"); + }); + }); + + describe("Error Handling", () => { + it("returns 404 for non-existent payment status", async () => { + const maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null }); + mockSupabaseFrom.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + maybeSingle, + }), + }); + + await expect(paymentService.getPaymentStatus("nonexistent")).rejects.toThrow( + "Payment not found" + ); + }); + + it("propagates database errors with status 500", async () => { + const dbError = new Error("connection refused"); + dbError.code = "08001"; + const maybeSingle = vi.fn().mockResolvedValue({ data: null, error: dbError }); + mockSupabaseFrom.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + maybeSingle, + }), + }); + + await expect(paymentService.getPaymentStatus("pay_1")).rejects.toThrow("connection refused"); + }); + }); + + describe("Signature Verification Integration", () => { + it("confirms payment when signature verification passes", async () => { + const maybeSingle = vi.fn().mockResolvedValue({ + data: { + id: "pay_1", + merchant_id: "m1", + amount: "10", + asset: "XLM", + recipient: "GDEST", + status: "pending", + tx_id: null, + memo: null, + memo_type: null, + webhook_url: null, + created_at: new Date().toISOString(), + merchants: { + webhook_secret: "sec", + webhook_version: "v1", + notification_email: null, + email: null, + }, + }, + error: null, + }); + const update = vi.fn().mockResolvedValue({ error: null }); + mockSupabaseFrom.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + maybeSingle, + }), + update: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + }), + }); + + const result = await paymentService.verifyPayment("pay_1"); + + expect(result.status).toBe("confirmed"); + }); + }); + + describe("SQL Injection Prevention", () => { + it("escapes LIKE patterns in search queries", async () => { + mockQueryWithRetry.mockResolvedValue({ rows: [] }); + + await paymentService.getMerchantPayments("m1", { + page: "1", + limit: "10", + search: "100%_OR_1=1", + }); + + const [, values] = mockQueryWithRetry.mock.calls[0]; + const searchValue = values.find((v) => typeof v === "string" && v.includes("100")); + expect(searchValue).toBe("%100\\%\\_OR\\_1=1%"); + }); + }); +}); diff --git a/backend/src/services/paymentService.js b/backend/src/services/paymentService.js index a21049d9..16566349 100644 --- a/backend/src/services/paymentService.js +++ b/backend/src/services/paymentService.js @@ -26,6 +26,8 @@ import { paymentConfirmationLatency, paymentFailedCounter, } from "../lib/metrics.js"; +import { paymentSignatureVerifier } from "../lib/payment-signature-verification.js"; +import { logger } from "../lib/logger.js"; const SAFE_METADATA_KEY_RE = /^[a-zA-Z0-9_-]{1,64}$/; let supabaseClientPromise; @@ -89,14 +91,6 @@ function applyPaymentFilters(query, filters) { return query; } -function isSignatureVerificationAccepted(result) { - if (result === true) { - return true; - } - - return Boolean(result && typeof result === "object" && result.valid === true); -} - async function getSupabaseClient() { if (!supabaseClientPromise) { supabaseClientPromise = import("../lib/supabase.js").then((module) => module.supabase); @@ -105,12 +99,8 @@ async function getSupabaseClient() { return supabaseClientPromise; } -async function verifyTransactionSignatureIfAvailable(txHash) { - if (typeof verifyTransactionSignature !== "function") { - return { valid: true, skipped: true }; - } - - return verifyTransactionSignature(txHash); +async function verifyTransactionSignatureIfAvailable(txHash, merchantId = null) { + return paymentSignatureVerifier.verifyTransaction(txHash, { merchantId }); } function escapeLikePattern(value) { @@ -543,6 +533,18 @@ export const paymentService = { const paymentLinkBase = process.env.PAYMENT_LINK_BASE || "http://localhost:3000"; const paymentLink = `${paymentLinkBase}/pay/${paymentId}`; + logger.info( + { + paymentId, + merchantId: merchant.id, + asset, + amount: body.amount, + recipient: body.recipient, + hasAssetIssuer: !!resolvedAssetIssuer, + }, + "Payment session created", + ); + const resolvedBranding = resolveBrandingConfig({ merchantBranding: merchant.branding_config, brandingOverrides: body.branding_overrides, @@ -688,8 +690,20 @@ export const paymentService = { if (match) { const signatureResult = await verifyTransactionSignatureIfAvailable( match.transaction_hash, + data.merchant_id, ); - if (!isSignatureVerificationAccepted(signatureResult)) { + if (!signatureResult.valid) { + logger.warn( + { + paymentId, + txHash: match.transaction_hash, + merchantId: data.merchant_id, + reason: signatureResult.reason, + isMultiSig: signatureResult.isMultiSig, + signatureCount: signatureResult.signatureCount, + }, + "Payment verification: transaction signature verification failed", + ); return { status: "pending" }; } }