Skip to content
Merged
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
127 changes: 108 additions & 19 deletions backend/src/lib/horizon-poller.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,66 @@ import {
rateLimitExceededTotal,
} from "./metrics.js";

// ── Merchant Config Cache ──────────────────────────────────────────────────────

/**
* Robust in-memory LRU cache for merchant notification configs.
* Reduces duplicate DB lookups across poll cycles and within a single cycle.
*/
class MerchantConfigCache {
constructor(maxEntries = 1000, ttlMs = 5 * 60 * 1000) {
this.maxEntries = maxEntries;
this.ttlMs = ttlMs;
this.cache = new Map();
}

get(merchantId) {
const entry = this.cache.get(merchantId);
if (!entry) return null;
if (Date.now() - entry.insertedAt > this.ttlMs) {
this.cache.delete(merchantId);
return null;
}
// LRU touch
this.cache.delete(merchantId);
this.cache.set(merchantId, entry);
return entry.data;
}

set(merchantId, data) {
if (this.cache.has(merchantId)) {
this.cache.delete(merchantId);
}
if (this.cache.size >= this.maxEntries) {
const oldest = this.cache.keys().next().value;
this.cache.delete(oldest);
}
this.cache.set(merchantId, { data, insertedAt: Date.now() });
}

invalidate(merchantId) {
if (merchantId) {
this.cache.delete(merchantId);
return;
}
this.cache.clear();
}

getStats() {
return {
size: this.cache.size,
maxEntries: this.maxEntries,
ttlMs: this.ttlMs,
};
}
}

const merchantConfigCache = new MerchantConfigCache();

export function getMerchantConfigCacheStats() {
return merchantConfigCache.getStats();
}

/** Prometheus label set identifying the Ledger Monitor's Horizon rate limiter. */
const RATE_LIMIT_LABELS = { endpoint: "ledger_monitor", type: "horizon" };

Expand Down Expand Up @@ -820,31 +880,52 @@ async function preloadMerchantConfigs(payments) {
];
if (merchantIds.length === 0) return cache;

const { data, error } = await supabase
.from("merchants")
.select(`id, ${MERCHANT_NOTIFICATION_FIELDS}`)
.in("id", merchantIds);

if (error) {
logger.warn(
{ err: error, merchantCount: merchantIds.length },
"Horizon poller: batch merchant preload failed — falling back to per-payment lookups",
);
return cache;
const cached = [];
const toFetch = [];
for (const id of merchantIds) {
const entry = merchantConfigCache.get(id);
if (entry) {
cached.push({ id, entry });
} else {
toFetch.push(id);
}
}

for (const merchant of data ?? []) {
cache.set(merchant.id, merchant);
for (const { id, entry } of cached) {
cache.set(id, entry);
}
// Record cache misses as null so confirmation never re-queries them.
for (const id of merchantIds) {
if (!cache.has(id)) cache.set(id, null);

if (toFetch.length > 0) {
const { data, error } = await supabase
.from("merchants")
.select(`id, ${MERCHANT_NOTIFICATION_FIELDS}`)
.in("id", toFetch);

if (error) {
logger.warn(
{ err: error, merchantCount: toFetch.length },
"Horizon poller: batch merchant preload failed — falling back to per-payment lookups",
);
return cache;
}

for (const merchant of data ?? []) {
merchantConfigCache.set(merchant.id, merchant);
cache.set(merchant.id, merchant);
}
for (const id of toFetch) {
if (!cache.has(id)) {
merchantConfigCache.set(id, null);
cache.set(id, null);
}
}
}
} catch (err) {
logger.warn(
{ err },
"Horizon poller: batch merchant preload errored — falling back to per-payment lookups",
);
merchantConfigCache.invalidate(null);
return new Map();
}
return cache;
Expand All @@ -859,6 +940,12 @@ async function loadMerchantNotificationConfig(merchantId, cache = new Map()) {
return cache.get(merchantId);
}

const merchant = merchantConfigCache.get(merchantId);
if (merchant !== null && merchant !== undefined) {
cache.set(merchantId, merchant);
return merchant;
}

const { data, error } = await supabase
.from("merchants")
.select(MERCHANT_NOTIFICATION_FIELDS)
Expand All @@ -871,10 +958,12 @@ async function loadMerchantNotificationConfig(merchantId, cache = new Map()) {
"Horizon poller: failed to load merchant notification config",
);
cache.set(merchantId, null);
merchantConfigCache.set(merchantId, null);
return null;
}

const merchant = data ?? null;
cache.set(merchantId, merchant);
return merchant;
const result = data ?? null;
cache.set(merchantId, result);
merchantConfigCache.set(merchantId, result);
return result;
}
41 changes: 41 additions & 0 deletions backend/src/lib/metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,41 @@ export const signatureVerificationReplayAttempts = new client.Counter({
help: "Total number of detected signature replay attempts",
});

export const txSignatureVerificationTotal = new client.Counter({
name: "tx_signature_verification_total",
help: "Total number of transaction signature verifications",
labelNames: ["outcome"], // valid, invalid
});

export const txSignatureVerificationLatency = new client.Histogram({
name: "tx_signature_verification_latency_seconds",
help: "Latency of transaction signature verification",
labelNames: ["label"],
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});

export const txSignatureVerificationErrors = new client.Counter({
name: "tx_signature_verification_errors_total",
help: "Total number of transaction signature verification errors",
labelNames: ["error_type"], // validation_failure, replay_attempt, verification_exception, invalid_signature
});

export const txSignatureReplayAttempts = new client.Counter({
name: "tx_signature_replay_attempts_total",
help: "Total number of replay attempts detected by the transaction signer",
});

export const txSignatureValidationFailures = new client.Counter({
name: "tx_signature_validation_failures_total",
help: "Total number of txHash validation failures",
labelNames: ["reason"], // empty_or_non_string, invalid_format
});

export const txSignatureCacheSize = new client.Gauge({
name: "tx_signature_cache_size",
help: "Current number of entries in the transaction signer replay cache",
});

/**
* Ledger Monitor Metrics
*/
Expand Down Expand Up @@ -217,6 +252,12 @@ register.registerMetric(slowQueryCount);
register.registerMetric(signatureVerificationTotal);
register.registerMetric(signatureVerificationDuration);
register.registerMetric(signatureVerificationReplayAttempts);
register.registerMetric(txSignatureVerificationTotal);
register.registerMetric(txSignatureVerificationLatency);
register.registerMetric(txSignatureVerificationErrors);
register.registerMetric(txSignatureReplayAttempts);
register.registerMetric(txSignatureValidationFailures);
register.registerMetric(txSignatureCacheSize);
register.registerMetric(ledgerMonitorCycleDuration);
register.registerMetric(ledgerMonitorPaymentsChecked);
register.registerMetric(ledgerMonitorCircuitBreakerTrips);
Expand Down
25 changes: 24 additions & 1 deletion backend/src/lib/transaction-signer.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ import {
createTransactionSignerRedisStore,
} from "./transaction-signer-rate-limit.js";
import { logger } from "./logger.js";
import {
txSignatureVerificationTotal,
txSignatureVerificationLatency,
txSignatureVerificationErrors,
txSignatureReplayAttempts,
txSignatureCacheSize,
txSignatureValidationFailures,
} from "./metrics.js";

// ── Constants ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -61,11 +69,13 @@ function recordVerifiedHash(txHash) {
_replayCache.delete(oldest);
}
_replayCache.set(txHash, { verifiedAt: Date.now() });
txSignatureCacheSize.set(_replayCache.size);
}

/** Exposed for tests only. */
export function clearReplayCache() {
_replayCache.clear();
txSignatureCacheSize.set(0);
}

// ── Input Validation ──────────────────────────────────────────────────────────
Expand All @@ -78,9 +88,11 @@ export function clearReplayCache() {
*/
export function validateTxHash(txHash) {
if (typeof txHash !== "string" || txHash.trim() === "") {
txSignatureValidationFailures.inc({ reason: "empty_or_non_string" });
return { valid: false, reason: "txHash must be a non-empty string" };
}
if (!TX_HASH_REGEX.test(txHash)) {
txSignatureValidationFailures.inc({ reason: "invalid_format" });
return { valid: false, reason: "txHash must be 64 lowercase hex characters" };
}
return { valid: true };
Expand All @@ -96,9 +108,13 @@ export function validateTxHash(txHash) {
* @returns {Promise<{ valid: boolean, reason?: string, replay?: boolean, [key: string]: unknown }>}
*/
export async function verifyTransactionSignatureSecure(txHash, options = {}) {
const timerLabel = "transaction_signer";
const timerEnd = txSignatureVerificationLatency.startTimer({ label: timerLabel });

// 1. Format validation
const formatCheck = validateTxHash(txHash);
if (!formatCheck.valid) {
txSignatureVerificationErrors.inc({ error_type: "validation_failure" });
logger.warn({ txHash: String(txHash).slice(0, 10), reason: formatCheck.reason },
"TransactionSigner: invalid txHash format rejected");
return { valid: false, reason: formatCheck.reason };
Expand All @@ -109,6 +125,8 @@ export async function verifyTransactionSignatureSecure(txHash, options = {}) {
// 2. Replay detection — prune stale entries first
pruneReplayCache();
if (_replayCache.has(normalizedHash)) {
txSignatureReplayAttempts.inc();
txSignatureVerificationErrors.inc({ error_type: "replay_attempt" });
logger.warn({ txHash: normalizedHash },
"TransactionSigner: replay attempt detected — txHash already verified");
return { valid: false, reason: "replay: txHash was already verified", replay: true };
Expand All @@ -119,26 +137,31 @@ export async function verifyTransactionSignatureSecure(txHash, options = {}) {
try {
result = await verifyTransactionSignature(normalizedHash, options);
} catch (err) {
txSignatureVerificationErrors.inc({ error_type: "verification_exception" });
logger.warn({ err, txHash: normalizedHash },
"TransactionSigner: unexpected error during signature verification");
return { valid: false, reason: "verification error: " + (err?.message ?? "unknown") };
}

// 4. Record in replay cache on success
// 4. Record metrics based on outcome
if (result?.valid) {
recordVerifiedHash(normalizedHash);
txSignatureVerificationTotal.inc({ outcome: "valid" });
logger.info({
txHash: normalizedHash,
isMultiSig: result.isMultiSig,
signatureCount: result.signatureCount,
}, "TransactionSigner: signature verified successfully");
} else {
txSignatureVerificationTotal.inc({ outcome: "invalid" });
txSignatureVerificationErrors.inc({ error_type: "invalid_signature" });
logger.warn({
txHash: normalizedHash,
reason: result?.reason ?? "unknown",
}, "TransactionSigner: signature verification failed");
}

timerEnd();
return result ?? { valid: false, reason: "verifier returned no result" };
}

Expand Down