Skip to content

Commit 503ab08

Browse files
authored
Merge pull request #1258 from aetheron06/feat
feat(backend): refactor legacy codebase in Smart Contract Oracle Integrator
2 parents 82e28d3 + d259901 commit 503ab08

3 files changed

Lines changed: 173 additions & 20 deletions

File tree

backend/src/lib/horizon-poller.js

Lines changed: 108 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,66 @@ import {
5858
rateLimitExceededTotal,
5959
} from "./metrics.js";
6060

61+
// ── Merchant Config Cache ──────────────────────────────────────────────────────
62+
63+
/**
64+
* Robust in-memory LRU cache for merchant notification configs.
65+
* Reduces duplicate DB lookups across poll cycles and within a single cycle.
66+
*/
67+
class MerchantConfigCache {
68+
constructor(maxEntries = 1000, ttlMs = 5 * 60 * 1000) {
69+
this.maxEntries = maxEntries;
70+
this.ttlMs = ttlMs;
71+
this.cache = new Map();
72+
}
73+
74+
get(merchantId) {
75+
const entry = this.cache.get(merchantId);
76+
if (!entry) return null;
77+
if (Date.now() - entry.insertedAt > this.ttlMs) {
78+
this.cache.delete(merchantId);
79+
return null;
80+
}
81+
// LRU touch
82+
this.cache.delete(merchantId);
83+
this.cache.set(merchantId, entry);
84+
return entry.data;
85+
}
86+
87+
set(merchantId, data) {
88+
if (this.cache.has(merchantId)) {
89+
this.cache.delete(merchantId);
90+
}
91+
if (this.cache.size >= this.maxEntries) {
92+
const oldest = this.cache.keys().next().value;
93+
this.cache.delete(oldest);
94+
}
95+
this.cache.set(merchantId, { data, insertedAt: Date.now() });
96+
}
97+
98+
invalidate(merchantId) {
99+
if (merchantId) {
100+
this.cache.delete(merchantId);
101+
return;
102+
}
103+
this.cache.clear();
104+
}
105+
106+
getStats() {
107+
return {
108+
size: this.cache.size,
109+
maxEntries: this.maxEntries,
110+
ttlMs: this.ttlMs,
111+
};
112+
}
113+
}
114+
115+
const merchantConfigCache = new MerchantConfigCache();
116+
117+
export function getMerchantConfigCacheStats() {
118+
return merchantConfigCache.getStats();
119+
}
120+
61121
/** Prometheus label set identifying the Ledger Monitor's Horizon rate limiter. */
62122
const RATE_LIMIT_LABELS = { endpoint: "ledger_monitor", type: "horizon" };
63123

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

823-
const { data, error } = await supabase
824-
.from("merchants")
825-
.select(`id, ${MERCHANT_NOTIFICATION_FIELDS}`)
826-
.in("id", merchantIds);
827-
828-
if (error) {
829-
logger.warn(
830-
{ err: error, merchantCount: merchantIds.length },
831-
"Horizon poller: batch merchant preload failed — falling back to per-payment lookups",
832-
);
833-
return cache;
883+
const cached = [];
884+
const toFetch = [];
885+
for (const id of merchantIds) {
886+
const entry = merchantConfigCache.get(id);
887+
if (entry) {
888+
cached.push({ id, entry });
889+
} else {
890+
toFetch.push(id);
891+
}
834892
}
835893

836-
for (const merchant of data ?? []) {
837-
cache.set(merchant.id, merchant);
894+
for (const { id, entry } of cached) {
895+
cache.set(id, entry);
838896
}
839-
// Record cache misses as null so confirmation never re-queries them.
840-
for (const id of merchantIds) {
841-
if (!cache.has(id)) cache.set(id, null);
897+
898+
if (toFetch.length > 0) {
899+
const { data, error } = await supabase
900+
.from("merchants")
901+
.select(`id, ${MERCHANT_NOTIFICATION_FIELDS}`)
902+
.in("id", toFetch);
903+
904+
if (error) {
905+
logger.warn(
906+
{ err: error, merchantCount: toFetch.length },
907+
"Horizon poller: batch merchant preload failed — falling back to per-payment lookups",
908+
);
909+
return cache;
910+
}
911+
912+
for (const merchant of data ?? []) {
913+
merchantConfigCache.set(merchant.id, merchant);
914+
cache.set(merchant.id, merchant);
915+
}
916+
for (const id of toFetch) {
917+
if (!cache.has(id)) {
918+
merchantConfigCache.set(id, null);
919+
cache.set(id, null);
920+
}
921+
}
842922
}
843923
} catch (err) {
844924
logger.warn(
845925
{ err },
846926
"Horizon poller: batch merchant preload errored — falling back to per-payment lookups",
847927
);
928+
merchantConfigCache.invalidate(null);
848929
return new Map();
849930
}
850931
return cache;
@@ -859,6 +940,12 @@ async function loadMerchantNotificationConfig(merchantId, cache = new Map()) {
859940
return cache.get(merchantId);
860941
}
861942

943+
const merchant = merchantConfigCache.get(merchantId);
944+
if (merchant !== null && merchant !== undefined) {
945+
cache.set(merchantId, merchant);
946+
return merchant;
947+
}
948+
862949
const { data, error } = await supabase
863950
.from("merchants")
864951
.select(MERCHANT_NOTIFICATION_FIELDS)
@@ -871,10 +958,12 @@ async function loadMerchantNotificationConfig(merchantId, cache = new Map()) {
871958
"Horizon poller: failed to load merchant notification config",
872959
);
873960
cache.set(merchantId, null);
961+
merchantConfigCache.set(merchantId, null);
874962
return null;
875963
}
876964

877-
const merchant = data ?? null;
878-
cache.set(merchantId, merchant);
879-
return merchant;
965+
const result = data ?? null;
966+
cache.set(merchantId, result);
967+
merchantConfigCache.set(merchantId, result);
968+
return result;
880969
}

backend/src/lib/metrics.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,41 @@ export const signatureVerificationReplayAttempts = new client.Counter({
109109
help: "Total number of detected signature replay attempts",
110110
});
111111

112+
export const txSignatureVerificationTotal = new client.Counter({
113+
name: "tx_signature_verification_total",
114+
help: "Total number of transaction signature verifications",
115+
labelNames: ["outcome"], // valid, invalid
116+
});
117+
118+
export const txSignatureVerificationLatency = new client.Histogram({
119+
name: "tx_signature_verification_latency_seconds",
120+
help: "Latency of transaction signature verification",
121+
labelNames: ["label"],
122+
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
123+
});
124+
125+
export const txSignatureVerificationErrors = new client.Counter({
126+
name: "tx_signature_verification_errors_total",
127+
help: "Total number of transaction signature verification errors",
128+
labelNames: ["error_type"], // validation_failure, replay_attempt, verification_exception, invalid_signature
129+
});
130+
131+
export const txSignatureReplayAttempts = new client.Counter({
132+
name: "tx_signature_replay_attempts_total",
133+
help: "Total number of replay attempts detected by the transaction signer",
134+
});
135+
136+
export const txSignatureValidationFailures = new client.Counter({
137+
name: "tx_signature_validation_failures_total",
138+
help: "Total number of txHash validation failures",
139+
labelNames: ["reason"], // empty_or_non_string, invalid_format
140+
});
141+
142+
export const txSignatureCacheSize = new client.Gauge({
143+
name: "tx_signature_cache_size",
144+
help: "Current number of entries in the transaction signer replay cache",
145+
});
146+
112147
/**
113148
* Ledger Monitor Metrics
114149
*/
@@ -299,6 +334,12 @@ register.registerMetric(slowQueryCount);
299334
register.registerMetric(signatureVerificationTotal);
300335
register.registerMetric(signatureVerificationDuration);
301336
register.registerMetric(signatureVerificationReplayAttempts);
337+
register.registerMetric(txSignatureVerificationTotal);
338+
register.registerMetric(txSignatureVerificationLatency);
339+
register.registerMetric(txSignatureVerificationErrors);
340+
register.registerMetric(txSignatureReplayAttempts);
341+
register.registerMetric(txSignatureValidationFailures);
342+
register.registerMetric(txSignatureCacheSize);
302343
register.registerMetric(ledgerMonitorCycleDuration);
303344
register.registerMetric(ledgerMonitorPaymentsChecked);
304345
register.registerMetric(ledgerMonitorCircuitBreakerTrips);

backend/src/lib/transaction-signer.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ import {
1919
createTransactionSignerRedisStore,
2020
} from "./transaction-signer-rate-limit.js";
2121
import { logger } from "./logger.js";
22+
import {
23+
txSignatureVerificationTotal,
24+
txSignatureVerificationLatency,
25+
txSignatureVerificationErrors,
26+
txSignatureReplayAttempts,
27+
txSignatureCacheSize,
28+
txSignatureValidationFailures,
29+
} from "./metrics.js";
2230

2331
// ── Constants ─────────────────────────────────────────────────────────────────
2432

@@ -61,11 +69,13 @@ function recordVerifiedHash(txHash) {
6169
_replayCache.delete(oldest);
6270
}
6371
_replayCache.set(txHash, { verifiedAt: Date.now() });
72+
txSignatureCacheSize.set(_replayCache.size);
6473
}
6574

6675
/** Exposed for tests only. */
6776
export function clearReplayCache() {
6877
_replayCache.clear();
78+
txSignatureCacheSize.set(0);
6979
}
7080

7181
// ── Input Validation ──────────────────────────────────────────────────────────
@@ -78,9 +88,11 @@ export function clearReplayCache() {
7888
*/
7989
export function validateTxHash(txHash) {
8090
if (typeof txHash !== "string" || txHash.trim() === "") {
91+
txSignatureValidationFailures.inc({ reason: "empty_or_non_string" });
8192
return { valid: false, reason: "txHash must be a non-empty string" };
8293
}
8394
if (!TX_HASH_REGEX.test(txHash)) {
95+
txSignatureValidationFailures.inc({ reason: "invalid_format" });
8496
return { valid: false, reason: "txHash must be 64 lowercase hex characters" };
8597
}
8698
return { valid: true };
@@ -96,9 +108,13 @@ export function validateTxHash(txHash) {
96108
* @returns {Promise<{ valid: boolean, reason?: string, replay?: boolean, [key: string]: unknown }>}
97109
*/
98110
export async function verifyTransactionSignatureSecure(txHash, options = {}) {
111+
const timerLabel = "transaction_signer";
112+
const timerEnd = txSignatureVerificationLatency.startTimer({ label: timerLabel });
113+
99114
// 1. Format validation
100115
const formatCheck = validateTxHash(txHash);
101116
if (!formatCheck.valid) {
117+
txSignatureVerificationErrors.inc({ error_type: "validation_failure" });
102118
logger.warn({ txHash: String(txHash).slice(0, 10), reason: formatCheck.reason },
103119
"TransactionSigner: invalid txHash format rejected");
104120
return { valid: false, reason: formatCheck.reason };
@@ -109,6 +125,8 @@ export async function verifyTransactionSignatureSecure(txHash, options = {}) {
109125
// 2. Replay detection — prune stale entries first
110126
pruneReplayCache();
111127
if (_replayCache.has(normalizedHash)) {
128+
txSignatureReplayAttempts.inc();
129+
txSignatureVerificationErrors.inc({ error_type: "replay_attempt" });
112130
logger.warn({ txHash: normalizedHash },
113131
"TransactionSigner: replay attempt detected — txHash already verified");
114132
return { valid: false, reason: "replay: txHash was already verified", replay: true };
@@ -119,26 +137,31 @@ export async function verifyTransactionSignatureSecure(txHash, options = {}) {
119137
try {
120138
result = await verifyTransactionSignature(normalizedHash, options);
121139
} catch (err) {
140+
txSignatureVerificationErrors.inc({ error_type: "verification_exception" });
122141
logger.warn({ err, txHash: normalizedHash },
123142
"TransactionSigner: unexpected error during signature verification");
124143
return { valid: false, reason: "verification error: " + (err?.message ?? "unknown") };
125144
}
126145

127-
// 4. Record in replay cache on success
146+
// 4. Record metrics based on outcome
128147
if (result?.valid) {
129148
recordVerifiedHash(normalizedHash);
149+
txSignatureVerificationTotal.inc({ outcome: "valid" });
130150
logger.info({
131151
txHash: normalizedHash,
132152
isMultiSig: result.isMultiSig,
133153
signatureCount: result.signatureCount,
134154
}, "TransactionSigner: signature verified successfully");
135155
} else {
156+
txSignatureVerificationTotal.inc({ outcome: "invalid" });
157+
txSignatureVerificationErrors.inc({ error_type: "invalid_signature" });
136158
logger.warn({
137159
txHash: normalizedHash,
138160
reason: result?.reason ?? "unknown",
139161
}, "TransactionSigner: signature verification failed");
140162
}
141163

164+
timerEnd();
142165
return result ?? { valid: false, reason: "verifier returned no result" };
143166
}
144167

0 commit comments

Comments
 (0)