@btc-stamps/tx-builder
Version:
Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection
575 lines (505 loc) • 13.8 kB
text/typescript
/**
* ElectrumX Response Cache
* Intelligent caching system with TTL, LRU eviction, and invalidation strategies
*/
import type { Balance, Transaction, UTXO } from '../interfaces/provider.interface.ts';
import { clearIntervalCompat, setIntervalCompat, type TimerId } from '../utils/timer-utils.ts';
export interface CacheEntry<T = any> {
data: T;
timestamp: number;
ttl: number;
accessCount: number;
lastAccessed: number;
size: number; // Estimated size in bytes
}
export interface CacheConfig {
maxMemoryMB: number;
defaultTTL: number; // seconds
utxoTTL: number;
balanceTTL: number;
transactionTTL: number;
feeEstimateTTL: number;
blockHeightTTL: number;
cleanupInterval: number; // seconds
compressionEnabled: boolean;
}
export interface CacheStats {
totalEntries: number;
memoryUsageMB: number;
hitRate: number;
missRate: number;
evictionCount: number;
lastCleanup: number;
entriesByType: Record<string, number>;
}
/**
* Advanced caching system with LRU eviction and intelligent TTL
*/
export class ElectrumXCache {
private cache = new Map<string, CacheEntry>();
private config: Required<CacheConfig>;
private accessOrder: string[] = []; // For LRU eviction
private stats = {
hits: 0,
misses: 0,
evictions: 0,
lastCleanup: Date.now(),
};
private cleanupTimer: TimerId | null = null;
private memoryUsage = 0; // Estimated memory usage in bytes
constructor(config?: Partial<CacheConfig>) {
this.config = {
maxMemoryMB: 100,
defaultTTL: 300, // 5 minutes
utxoTTL: 60, // 1 minute - UTXOs can change frequently
balanceTTL: 30, // 30 seconds - Balance changes often
transactionTTL: 3600, // 1 hour - Transactions are immutable once confirmed
feeEstimateTTL: 60, // 1 minute - Fee estimates change frequently
blockHeightTTL: 600, // 10 minutes - Block height changes roughly every 10 minutes
cleanupInterval: 300, // 5 minutes
compressionEnabled: false, // Could implement gzip compression
...config,
};
this.startCleanupTimer();
}
/**
* Get cached item
*/
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
this.stats.misses++;
return null;
}
// Check if expired
if (this.isExpired(entry)) {
this.cache.delete(key);
this.removeFromAccessOrder(key);
this.memoryUsage -= entry.size;
this.stats.misses++;
return null;
}
// Update access tracking
entry.lastAccessed = Date.now();
entry.accessCount++;
this.updateAccessOrder(key);
this.stats.hits++;
return entry.data as T;
}
/**
* Set cached item
*/
set<T>(
key: string,
data: T,
ttl?: number,
category?: 'utxo' | 'balance' | 'transaction' | 'fee' | 'block',
): void {
const now = Date.now();
const effectiveTTL = ttl || this.getTTLForCategory(category);
const size = this.estimateSize(data);
// Remove existing entry if present
if (this.cache.has(key)) {
const existing = this.cache.get(key)!;
this.memoryUsage -= existing.size;
}
const entry: CacheEntry<T> = {
data,
timestamp: now,
ttl: effectiveTTL,
accessCount: 1,
lastAccessed: now,
size,
};
this.cache.set(key, entry);
this.memoryUsage += size;
this.updateAccessOrder(key);
// Check memory usage and evict if necessary
this.enforceMemoryLimit();
}
/**
* Cache UTXO data with address-based invalidation
*/
cacheUTXOs(address: string, utxos: UTXO[]): void {
const key = `utxo:${address}`;
this.set(key, utxos, undefined, 'utxo');
// Also cache individual UTXOs for faster lookup
utxos.forEach((utxo) => {
const utxoKey = `utxo-item:${utxo.txid}:${utxo.vout}`;
this.set(utxoKey, utxo, this.config.utxoTTL * 2, 'utxo'); // Longer TTL for individual items
});
}
/**
* Get cached UTXOs for address
*/
getCachedUTXOs(address: string): UTXO[] | null {
return this.get<UTXO[]>(`utxo:${address}`);
}
/**
* Cache balance data
*/
cacheBalance(address: string, balance: Balance): void {
const key = `balance:${address}`;
this.set(key, balance, undefined, 'balance');
}
/**
* Get cached balance
*/
getCachedBalance(address: string): Balance | null {
return this.get<Balance>(`balance:${address}`);
}
/**
* Cache transaction data
*/
cacheTransaction(txid: string, transaction: Transaction): void {
const key = `tx:${txid}`;
this.set(key, transaction, undefined, 'transaction');
}
/**
* Get cached transaction
*/
getCachedTransaction(txid: string): Transaction | null {
return this.get<Transaction>(`tx:${txid}`);
}
/**
* Cache fee estimate
*/
cacheFeeEstimate(priority: string, feeRate: number): void {
const key = `fee:${priority}`;
this.set(key, feeRate, undefined, 'fee');
}
/**
* Get cached fee estimate
*/
getCachedFeeEstimate(priority: string): number | null {
return this.get<number>(`fee:${priority}`);
}
/**
* Cache block height
*/
cacheBlockHeight(height: number): void {
const key = 'blockheight';
this.set(key, height, undefined, 'block');
}
/**
* Get cached block height
*/
getCachedBlockHeight(): number | null {
return this.get<number>('blockheight');
}
/**
* Invalidate cache entries by pattern
*/
invalidatePattern(pattern: string): number {
let count = 0;
const regex = new RegExp(pattern);
for (const key of this.cache.keys()) {
if (regex.test(key)) {
const entry = this.cache.get(key)!;
this.cache.delete(key);
this.removeFromAccessOrder(key);
this.memoryUsage -= entry.size;
count++;
}
}
return count;
}
/**
* Invalidate all UTXO caches for an address
*/
invalidateAddress(address: string): void {
this.invalidatePattern(
`^(utxo|balance):${address.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
);
}
/**
* Invalidate all fee estimates
*/
invalidateFeeEstimates(): void {
this.invalidatePattern('^fee:');
}
/**
* Batch set multiple items
*/
setBatch<T>(
items: Array<{ key: string; data: T; ttl?: number; category?: string }>,
): void {
for (const item of items) {
this.set(item.key, item.data, item.ttl, item.category as any);
}
}
/**
* Batch get multiple items
*/
getBatch<T>(keys: string[]): Record<string, T | null> {
const results: Record<string, T | null> = {};
for (const key of keys) {
results[key] = this.get<T>(key);
}
return results;
}
/**
* Get cache statistics
*/
getStats(): CacheStats {
const totalRequests = this.stats.hits + this.stats.misses;
const hitRate = totalRequests > 0 ? this.stats.hits / totalRequests : 0;
const missRate = totalRequests > 0 ? this.stats.misses / totalRequests : 0;
// Count entries by type
const entriesByType: Record<string, number> = {};
for (const key of this.cache.keys()) {
const type = key.split(':')[0];
if (type) {
entriesByType[type] = (entriesByType[type] || 0) + 1;
}
}
return {
totalEntries: this.cache.size,
memoryUsageMB: this.memoryUsage / (1024 * 1024),
hitRate,
missRate,
evictionCount: this.stats.evictions,
lastCleanup: this.stats.lastCleanup,
entriesByType,
};
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
this.accessOrder = [];
this.memoryUsage = 0;
this.stats = {
hits: 0,
misses: 0,
evictions: 0,
lastCleanup: Date.now(),
};
}
/**
* Get entries sorted by access frequency
*/
getTopEntries(limit: number = 10): Array<{
key: string;
accessCount: number;
age: number;
size: number;
}> {
const entries = Array.from(this.cache.entries())
.map(([key, entry]) => ({
key,
accessCount: entry.accessCount,
age: (Date.now() - entry.timestamp) / 1000,
size: entry.size,
}))
.sort((a, b) => b.accessCount - a.accessCount)
.slice(0, limit);
return entries;
}
/**
* Check if entry is expired
*/
private isExpired(entry: CacheEntry): boolean {
return Date.now() - entry.timestamp > entry.ttl * 1000;
}
/**
* Get TTL for category
*/
private getTTLForCategory(category?: string): number {
switch (category) {
case 'utxo':
return this.config.utxoTTL;
case 'balance':
return this.config.balanceTTL;
case 'transaction':
return this.config.transactionTTL;
case 'fee':
return this.config.feeEstimateTTL;
case 'block':
return this.config.blockHeightTTL;
default:
return this.config.defaultTTL;
}
}
/**
* Estimate size of data in bytes
*/
private estimateSize(data: any): number {
if (data === null || data === undefined) return 8;
if (typeof data === 'string') return data.length * 2; // Unicode characters
if (typeof data === 'number') return 8;
if (typeof data === 'boolean') return 4;
if (Array.isArray(data)) {
return data.reduce((size, item) => size + this.estimateSize(item), 16); // Array overhead
}
if (typeof data === 'object') {
return Object.entries(data).reduce(
(size, [key, value]) => size + key.length * 2 + this.estimateSize(value),
16,
);
}
return 16; // Default size
}
/**
* Update access order for LRU
*/
private updateAccessOrder(key: string): void {
// Remove if already present
this.removeFromAccessOrder(key);
// Add to end (most recently accessed)
this.accessOrder.push(key);
}
/**
* Remove from access order
*/
private removeFromAccessOrder(key: string): void {
const index = this.accessOrder.indexOf(key);
if (index !== -1) {
this.accessOrder.splice(index, 1);
}
}
/**
* Enforce memory limit by evicting LRU entries
*/
private enforceMemoryLimit(): void {
const maxBytes = this.config.maxMemoryMB * 1024 * 1024;
while (this.memoryUsage > maxBytes && this.accessOrder.length > 0) {
const lruKey = this.accessOrder.shift()!;
const entry = this.cache.get(lruKey);
if (entry) {
this.cache.delete(lruKey);
this.memoryUsage -= entry.size;
this.stats.evictions++;
}
}
}
/**
* Start cleanup timer
*/
private startCleanupTimer(): void {
this.cleanupTimer = setIntervalCompat(() => {
this.cleanup();
}, this.config.cleanupInterval * 1000);
}
/**
* Clean up expired entries
*/
private cleanup(): void {
const now = Date.now();
let cleanedCount = 0;
for (const [key, entry] of this.cache.entries()) {
if (this.isExpired(entry)) {
this.cache.delete(key);
this.removeFromAccessOrder(key);
this.memoryUsage -= entry.size;
cleanedCount++;
}
}
this.stats.lastCleanup = now;
if (cleanedCount > 0) {
console.log(`ElectrumX cache cleaned up ${cleanedCount} expired entries`);
}
}
/**
* Update cache configuration
*/
updateConfig(newConfig: Partial<CacheConfig>): void {
this.config = { ...this.config, ...newConfig };
// Restart cleanup timer if interval changed
if (newConfig.cleanupInterval) {
if (this.cleanupTimer) {
clearIntervalCompat(this.cleanupTimer);
}
this.startCleanupTimer();
}
// Enforce new memory limit if changed
if (newConfig.maxMemoryMB) {
this.enforceMemoryLimit();
}
}
/**
* Export cache data for analysis
*/
export(): {
config: CacheConfig;
stats: CacheStats;
entries: Array<{
key: string;
size: number;
age: number;
accessCount: number;
ttl: number;
}>;
} {
const entries = Array.from(this.cache.entries()).map(([key, entry]) => ({
key,
size: entry.size,
age: (Date.now() - entry.timestamp) / 1000,
accessCount: entry.accessCount,
ttl: entry.ttl,
}));
return {
config: this.config,
stats: this.getStats(),
entries,
};
}
/**
* Import cache data (careful - this replaces current cache)
*/
import(
data: { entries: Array<{ key: string; data: any; ttl: number }> },
): void {
this.clear();
for (const entry of data.entries) {
this.set(entry.key, entry.data, entry.ttl);
}
}
/**
* Shutdown cache
*/
shutdown(): void {
if (this.cleanupTimer) {
clearIntervalCompat(this.cleanupTimer);
this.cleanupTimer = null;
}
this.clear();
}
}
/**
* Create ElectrumX cache with sensible defaults
*/
export function createElectrumXCache(
config?: Partial<CacheConfig>,
): ElectrumXCache {
return new ElectrumXCache(config);
}
/**
* Create memory-optimized cache (smaller memory footprint)
*/
export function createMemoryOptimizedCache(): ElectrumXCache {
return new ElectrumXCache({
maxMemoryMB: 25,
utxoTTL: 30,
balanceTTL: 15,
transactionTTL: 1800, // 30 minutes
feeEstimateTTL: 30,
blockHeightTTL: 300, // 5 minutes
cleanupInterval: 60, // 1 minute
});
}
/**
* Create performance-optimized cache (larger memory, longer TTL)
*/
export function createPerformanceOptimizedCache(): ElectrumXCache {
return new ElectrumXCache({
maxMemoryMB: 500,
utxoTTL: 120, // 2 minutes
balanceTTL: 60, // 1 minute
transactionTTL: 7200, // 2 hours
feeEstimateTTL: 120, // 2 minutes
blockHeightTTL: 1200, // 20 minutes
cleanupInterval: 600, // 10 minutes
});
}