@cygnus-wealth/asset-valuator 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
- import { CoinGeckoProvider } from './providers/coingecko.js';
1
+ import { DecentralizedAggregator } from './providers/decentralized-aggregator.js';
2
2
  export class AssetValuator {
3
3
  constructor(provider) {
4
4
  this.cache = new Map();
5
5
  this.cacheTimeout = 60000; // 1 minute
6
- this.provider = provider || new CoinGeckoProvider();
6
+ this.provider = provider || new DecentralizedAggregator();
7
7
  }
8
8
  getCacheKey(base, quote) {
9
9
  return `${base.toUpperCase()}_${quote.toUpperCase()}`;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { AssetValuator } from './asset-valuator.js';
2
2
  export { CoinGeckoProvider } from './providers/coingecko.js';
3
+ export { CoinPaprikaProvider } from './providers/coinpaprika.js';
4
+ export { DecentralizedAggregator } from './providers/decentralized-aggregator.js';
5
+ export { RateLimiter } from './utils/rate-limiter.js';
6
+ export { EdgeCache } from './utils/edge-cache.js';
3
7
  export { DataModelConverter } from './converters/data-model-converter.js';
4
8
  export type { PriceData, AssetPrice, PriceProvider, SupportedCurrency, ConversionOptions } from './types.js';
package/dist/index.js CHANGED
@@ -1,3 +1,7 @@
1
1
  export { AssetValuator } from './asset-valuator.js';
2
2
  export { CoinGeckoProvider } from './providers/coingecko.js';
3
+ export { CoinPaprikaProvider } from './providers/coinpaprika.js';
4
+ export { DecentralizedAggregator } from './providers/decentralized-aggregator.js';
5
+ export { RateLimiter } from './utils/rate-limiter.js';
6
+ export { EdgeCache } from './utils/edge-cache.js';
3
7
  export { DataModelConverter } from './converters/data-model-converter.js';
@@ -1,6 +1,7 @@
1
1
  import { PriceData, PriceProvider } from '../types.js';
2
2
  export declare class CoinGeckoProvider implements PriceProvider {
3
3
  private baseUrl;
4
+ private stablecoins;
4
5
  private symbolToIdMap;
5
6
  private getCoingeckoId;
6
7
  fetchPrice(symbol: string, currency?: string): Promise<PriceData>;
@@ -2,10 +2,12 @@ import axios from 'axios';
2
2
  export class CoinGeckoProvider {
3
3
  constructor() {
4
4
  this.baseUrl = 'https://api.coingecko.com/api/v3';
5
+ this.stablecoins = new Set(['USDC', 'USDT', 'DAI']);
5
6
  this.symbolToIdMap = new Map([
6
7
  ['BTC', 'bitcoin'],
7
8
  ['ETH', 'ethereum'],
8
9
  ['USDT', 'tether'],
10
+ ['USDC', 'usd-coin'],
9
11
  ['BNB', 'binancecoin'],
10
12
  ['SOL', 'solana'],
11
13
  ['XRP', 'ripple'],
@@ -18,6 +20,15 @@ export class CoinGeckoProvider {
18
20
  ['UNI', 'uniswap'],
19
21
  ['ATOM', 'cosmos'],
20
22
  ['LTC', 'litecoin'],
23
+ ['DAI', 'dai'],
24
+ ['WBTC', 'wrapped-bitcoin'],
25
+ ['AAVE', 'aave'],
26
+ ['COMP', 'compound-governance-token'],
27
+ ['CRV', 'curve-dao-token'],
28
+ ['MKR', 'maker'],
29
+ ['SNX', 'synthetix-network-token'],
30
+ ['SUSHI', 'sushi'],
31
+ ['YFI', 'yearn-finance'],
21
32
  ]);
22
33
  }
23
34
  getCoingeckoId(symbol) {
@@ -44,6 +55,14 @@ export class CoinGeckoProvider {
44
55
  };
45
56
  }
46
57
  catch (error) {
58
+ // For stablecoins, return 1 USD if we can't fetch the price
59
+ if (this.stablecoins.has(symbol.toUpperCase()) && currency.toLowerCase() === 'usd') {
60
+ return {
61
+ symbol: symbol.toUpperCase(),
62
+ price: 1,
63
+ timestamp: new Date()
64
+ };
65
+ }
47
66
  if (axios.isAxiosError(error)) {
48
67
  throw new Error(`Failed to fetch price: ${error.message}`);
49
68
  }
@@ -72,10 +91,38 @@ export class CoinGeckoProvider {
72
91
  timestamp
73
92
  });
74
93
  }
94
+ else if (this.stablecoins.has(symbol.toUpperCase()) && currency.toLowerCase() === 'usd') {
95
+ // For stablecoins, return 1 USD if price is not found
96
+ results.push({
97
+ symbol: symbol.toUpperCase(),
98
+ price: 1,
99
+ timestamp
100
+ });
101
+ }
75
102
  }
76
103
  return results;
77
104
  }
78
105
  catch (error) {
106
+ // If the entire request fails, check if any symbols are stablecoins
107
+ const results = [];
108
+ const timestamp = new Date();
109
+ for (const symbol of symbols) {
110
+ if (this.stablecoins.has(symbol.toUpperCase()) && currency.toLowerCase() === 'usd') {
111
+ results.push({
112
+ symbol: symbol.toUpperCase(),
113
+ price: 1,
114
+ timestamp
115
+ });
116
+ }
117
+ }
118
+ // If we have stablecoin results, return them; otherwise throw the error
119
+ if (results.length > 0) {
120
+ // For non-stablecoins, we still need to throw an error
121
+ if (results.length < symbols.length) {
122
+ console.warn(`Failed to fetch prices for some assets: ${error instanceof Error ? error.message : 'Unknown error'}`);
123
+ }
124
+ return results;
125
+ }
79
126
  if (axios.isAxiosError(error)) {
80
127
  throw new Error(`Failed to fetch prices: ${error.message}`);
81
128
  }
@@ -0,0 +1,8 @@
1
+ import { PriceData, PriceProvider } from '../types.js';
2
+ export declare class CoinPaprikaProvider implements PriceProvider {
3
+ private baseUrl;
4
+ private symbolToIdMap;
5
+ private getCoinPaprikaId;
6
+ fetchPrice(symbol: string, currency?: string): Promise<PriceData>;
7
+ fetchMultiplePrices(symbols: string[], currency?: string): Promise<PriceData[]>;
8
+ }
@@ -0,0 +1,64 @@
1
+ import axios from 'axios';
2
+ export class CoinPaprikaProvider {
3
+ constructor() {
4
+ this.baseUrl = 'https://api.coinpaprika.com/v1';
5
+ this.symbolToIdMap = new Map([
6
+ ['BTC', 'btc-bitcoin'],
7
+ ['ETH', 'eth-ethereum'],
8
+ ['USDT', 'usdt-tether'],
9
+ ['USDC', 'usdc-usd-coin'],
10
+ ['BNB', 'bnb-binance-coin'],
11
+ ['SOL', 'sol-solana'],
12
+ ['XRP', 'xrp-xrp'],
13
+ ['ADA', 'ada-cardano'],
14
+ ['DOGE', 'doge-dogecoin'],
15
+ ['AVAX', 'avax-avalanche'],
16
+ ['DOT', 'dot-polkadot'],
17
+ ['MATIC', 'matic-polygon'],
18
+ ['LINK', 'link-chainlink'],
19
+ ['UNI', 'uni-uniswap'],
20
+ ['ATOM', 'atom-cosmos'],
21
+ ['LTC', 'ltc-litecoin'],
22
+ ['DAI', 'dai-dai'],
23
+ ]);
24
+ }
25
+ getCoinPaprikaId(symbol) {
26
+ return this.symbolToIdMap.get(symbol.toUpperCase()) || `${symbol.toLowerCase()}-${symbol.toLowerCase()}`;
27
+ }
28
+ async fetchPrice(symbol, currency = 'usd') {
29
+ const id = this.getCoinPaprikaId(symbol);
30
+ const url = `${this.baseUrl}/tickers/${id}`;
31
+ try {
32
+ const response = await axios.get(url);
33
+ const data = response.data;
34
+ if (!data || !data.quotes || !data.quotes.USD) {
35
+ throw new Error(`Price not found for ${symbol}`);
36
+ }
37
+ // CoinPaprika only provides USD prices in free tier
38
+ const priceInUSD = data.quotes.USD.price;
39
+ let price = priceInUSD;
40
+ if (currency.toLowerCase() !== 'usd') {
41
+ // For other currencies, we'd need to convert
42
+ // This is a limitation of the free API
43
+ throw new Error(`CoinPaprika free tier only supports USD prices`);
44
+ }
45
+ return {
46
+ symbol: symbol.toUpperCase(),
47
+ price,
48
+ timestamp: new Date()
49
+ };
50
+ }
51
+ catch (error) {
52
+ if (axios.isAxiosError(error)) {
53
+ throw new Error(`Failed to fetch price from CoinPaprika: ${error.message}`);
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+ async fetchMultiplePrices(symbols, currency = 'usd') {
59
+ // CoinPaprika requires individual requests for each coin
60
+ const promises = symbols.map(symbol => this.fetchPrice(symbol, currency).catch(() => null));
61
+ const results = await Promise.all(promises);
62
+ return results.filter((result) => result !== null);
63
+ }
64
+ }
@@ -0,0 +1,30 @@
1
+ import { PriceData, PriceProvider } from '../types.js';
2
+ export interface AggregatorOptions {
3
+ providers?: PriceProvider[];
4
+ cacheOptions?: {
5
+ storage?: 'memory' | 'localStorage' | 'indexedDB';
6
+ ttl?: number;
7
+ };
8
+ rateLimitOptions?: {
9
+ maxRequests?: number;
10
+ windowMs?: number;
11
+ };
12
+ consensusThreshold?: number;
13
+ }
14
+ export declare class DecentralizedAggregator implements PriceProvider {
15
+ private providers;
16
+ private rateLimiter;
17
+ private cache;
18
+ private consensusThreshold;
19
+ constructor(options?: AggregatorOptions);
20
+ private selectBestStorage;
21
+ fetchPrice(symbol: string, currency?: string): Promise<PriceData>;
22
+ fetchMultiplePrices(symbols: string[], currency?: string): Promise<PriceData[]>;
23
+ private fetchFromProviders;
24
+ private calculateConsensusPrice;
25
+ private groupBySymbol;
26
+ addProvider(provider: PriceProvider): void;
27
+ removeProvider(index: number): void;
28
+ clearCache(): Promise<void>;
29
+ resetRateLimiter(): void;
30
+ }
@@ -0,0 +1,174 @@
1
+ import { CoinGeckoProvider } from './coingecko.js';
2
+ import { CoinPaprikaProvider } from './coinpaprika.js';
3
+ import { RateLimiter } from '../utils/rate-limiter.js';
4
+ import { EdgeCache } from '../utils/edge-cache.js';
5
+ import { hasIndexedDB, hasLocalStorage } from '../utils/browser-detect.js';
6
+ export class DecentralizedAggregator {
7
+ constructor(options = {}) {
8
+ // Default providers
9
+ this.providers = options.providers || [
10
+ new CoinGeckoProvider(),
11
+ new CoinPaprikaProvider(),
12
+ ];
13
+ // Rate limiter with conservative defaults for edge devices
14
+ this.rateLimiter = new RateLimiter({
15
+ maxRequests: options.rateLimitOptions?.maxRequests || 5,
16
+ windowMs: options.rateLimitOptions?.windowMs || 60000, // 1 minute
17
+ retryAfterMs: 2000,
18
+ maxRetries: 3
19
+ });
20
+ // Edge cache with automatic storage selection
21
+ this.cache = new EdgeCache({
22
+ storage: options.cacheOptions?.storage || this.selectBestStorage(),
23
+ defaultTTL: options.cacheOptions?.ttl || 60000, // 60 seconds
24
+ maxSize: 500
25
+ });
26
+ this.consensusThreshold = options.consensusThreshold || 0.5;
27
+ }
28
+ selectBestStorage() {
29
+ if (hasIndexedDB()) {
30
+ return 'indexedDB';
31
+ }
32
+ else if (hasLocalStorage()) {
33
+ return 'localStorage';
34
+ }
35
+ return 'memory';
36
+ }
37
+ async fetchPrice(symbol, currency = 'usd') {
38
+ const cacheKey = `${symbol}-${currency}`;
39
+ // Check cache first
40
+ const cached = await this.cache.get(cacheKey);
41
+ if (cached) {
42
+ return cached;
43
+ }
44
+ // Use rate limiter to fetch from providers
45
+ return this.rateLimiter.execute(cacheKey, async () => {
46
+ const results = await this.fetchFromProviders(symbol, currency);
47
+ if (results.length === 0) {
48
+ throw new Error(`No price data available for ${symbol}`);
49
+ }
50
+ // Calculate consensus price
51
+ const consensusPrice = this.calculateConsensusPrice(results);
52
+ const priceData = {
53
+ symbol: symbol.toUpperCase(),
54
+ price: consensusPrice,
55
+ timestamp: new Date()
56
+ };
57
+ // Cache the result
58
+ await this.cache.set(cacheKey, priceData);
59
+ return priceData;
60
+ });
61
+ }
62
+ async fetchMultiplePrices(symbols, currency = 'usd') {
63
+ // Check cache for all symbols
64
+ const results = [];
65
+ const uncachedSymbols = [];
66
+ for (const symbol of symbols) {
67
+ const cached = await this.cache.get(`${symbol}-${currency}`);
68
+ if (cached) {
69
+ results.push(cached);
70
+ }
71
+ else {
72
+ uncachedSymbols.push(symbol);
73
+ }
74
+ }
75
+ // Fetch uncached symbols
76
+ if (uncachedSymbols.length > 0) {
77
+ const batchKey = `batch-${uncachedSymbols.join(',')}-${currency}`;
78
+ const fetchedPrices = await this.rateLimiter.execute(batchKey, async () => {
79
+ const allResults = [];
80
+ // Try each provider
81
+ for (const provider of this.providers) {
82
+ try {
83
+ const providerResults = await provider.fetchMultiplePrices(uncachedSymbols, currency);
84
+ allResults.push(...providerResults);
85
+ }
86
+ catch (error) {
87
+ console.warn(`Provider failed:`, error);
88
+ }
89
+ }
90
+ // Group by symbol and calculate consensus
91
+ const grouped = this.groupBySymbol(allResults);
92
+ const consensusPrices = [];
93
+ for (const [symbol, prices] of grouped.entries()) {
94
+ if (prices.length > 0) {
95
+ const consensusPrice = this.calculateConsensusPrice(prices);
96
+ const priceData = {
97
+ symbol,
98
+ price: consensusPrice,
99
+ timestamp: new Date()
100
+ };
101
+ consensusPrices.push(priceData);
102
+ // Cache individual results
103
+ await this.cache.set(`${symbol}-${currency}`, priceData);
104
+ }
105
+ }
106
+ return consensusPrices;
107
+ });
108
+ results.push(...fetchedPrices);
109
+ }
110
+ return results;
111
+ }
112
+ async fetchFromProviders(symbol, currency) {
113
+ const results = [];
114
+ // Try each provider sequentially with fallback
115
+ for (const provider of this.providers) {
116
+ try {
117
+ const price = await provider.fetchPrice(symbol, currency);
118
+ results.push(price);
119
+ // If we have enough providers for consensus, we can stop
120
+ if (results.length >= Math.ceil(this.providers.length * this.consensusThreshold)) {
121
+ break;
122
+ }
123
+ }
124
+ catch (error) {
125
+ console.warn(`Provider failed for ${symbol}:`, error);
126
+ // Continue to next provider
127
+ }
128
+ }
129
+ return results;
130
+ }
131
+ calculateConsensusPrice(prices) {
132
+ if (prices.length === 0) {
133
+ throw new Error('No prices available for consensus');
134
+ }
135
+ if (prices.length === 1) {
136
+ return prices[0].price;
137
+ }
138
+ // Sort prices
139
+ const sortedPrices = prices.map(p => p.price).sort((a, b) => a - b);
140
+ // Remove outliers (prices that deviate more than 10% from median)
141
+ const median = sortedPrices[Math.floor(sortedPrices.length / 2)];
142
+ const filtered = sortedPrices.filter(price => Math.abs(price - median) / median <= 0.1);
143
+ // If too many outliers, use median
144
+ if (filtered.length < prices.length * this.consensusThreshold) {
145
+ return median;
146
+ }
147
+ // Calculate average of filtered prices
148
+ return filtered.reduce((sum, price) => sum + price, 0) / filtered.length;
149
+ }
150
+ groupBySymbol(prices) {
151
+ const grouped = new Map();
152
+ for (const price of prices) {
153
+ const symbol = price.symbol.toUpperCase();
154
+ if (!grouped.has(symbol)) {
155
+ grouped.set(symbol, []);
156
+ }
157
+ grouped.get(symbol).push(price);
158
+ }
159
+ return grouped;
160
+ }
161
+ // Utility methods for managing the aggregator
162
+ addProvider(provider) {
163
+ this.providers.push(provider);
164
+ }
165
+ removeProvider(index) {
166
+ this.providers.splice(index, 1);
167
+ }
168
+ async clearCache() {
169
+ await this.cache.clear();
170
+ }
171
+ resetRateLimiter() {
172
+ this.rateLimiter.reset();
173
+ }
174
+ }
@@ -0,0 +1,9 @@
1
+ declare global {
2
+ interface Window {
3
+ indexedDB: any;
4
+ localStorage: Storage;
5
+ }
6
+ }
7
+ export declare function isBrowser(): boolean;
8
+ export declare function hasIndexedDB(): boolean;
9
+ export declare function hasLocalStorage(): boolean;
@@ -0,0 +1,18 @@
1
+ export function isBrowser() {
2
+ return typeof globalThis !== 'undefined' &&
3
+ typeof globalThis.window !== 'undefined' &&
4
+ typeof globalThis.window.document !== 'undefined';
5
+ }
6
+ export function hasIndexedDB() {
7
+ return isBrowser() && 'indexedDB' in globalThis.window;
8
+ }
9
+ export function hasLocalStorage() {
10
+ try {
11
+ return isBrowser() &&
12
+ 'localStorage' in globalThis.window &&
13
+ globalThis.window.localStorage !== null;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
@@ -0,0 +1,24 @@
1
+ export interface CacheEntry<T> {
2
+ data: T;
3
+ timestamp: number;
4
+ ttl: number;
5
+ }
6
+ export interface EdgeCacheOptions {
7
+ defaultTTL?: number;
8
+ maxSize?: number;
9
+ storage?: 'memory' | 'localStorage' | 'indexedDB';
10
+ }
11
+ export declare class EdgeCache {
12
+ private memoryCache;
13
+ private options;
14
+ private dbName;
15
+ private storeName;
16
+ private db;
17
+ constructor(options?: EdgeCacheOptions);
18
+ private initIndexedDB;
19
+ get<T>(key: string): Promise<T | null>;
20
+ set<T>(key: string, data: T, ttl?: number): Promise<void>;
21
+ delete(key: string): Promise<void>;
22
+ clear(): Promise<void>;
23
+ private evictOldest;
24
+ }
@@ -0,0 +1,212 @@
1
+ import { hasIndexedDB, hasLocalStorage } from './browser-detect.js';
2
+ export class EdgeCache {
3
+ constructor(options = {}) {
4
+ this.memoryCache = new Map();
5
+ this.dbName = 'asset-valuator-cache';
6
+ this.storeName = 'prices';
7
+ this.db = null;
8
+ this.options = {
9
+ defaultTTL: options.defaultTTL || 60000, // 60 seconds
10
+ maxSize: options.maxSize || 1000,
11
+ storage: options.storage || 'memory'
12
+ };
13
+ if (this.options.storage === 'indexedDB' && hasIndexedDB()) {
14
+ this.initIndexedDB();
15
+ }
16
+ }
17
+ async initIndexedDB() {
18
+ try {
19
+ const request = globalThis.window.indexedDB.open(this.dbName, 1);
20
+ request.onupgradeneeded = (event) => {
21
+ const db = event.target.result;
22
+ if (!db.objectStoreNames.contains(this.storeName)) {
23
+ db.createObjectStore(this.storeName, { keyPath: 'key' });
24
+ }
25
+ };
26
+ this.db = await new Promise((resolve, reject) => {
27
+ request.onsuccess = () => resolve(request.result);
28
+ request.onerror = () => reject(request.error);
29
+ });
30
+ }
31
+ catch (error) {
32
+ console.warn('IndexedDB initialization failed, falling back to memory cache', error);
33
+ this.options.storage = 'memory';
34
+ }
35
+ }
36
+ async get(key) {
37
+ const now = Date.now();
38
+ switch (this.options.storage) {
39
+ case 'localStorage':
40
+ if (hasLocalStorage()) {
41
+ try {
42
+ const stored = localStorage.getItem(`cache_${key}`);
43
+ if (stored) {
44
+ const entry = JSON.parse(stored);
45
+ if (now - entry.timestamp < entry.ttl) {
46
+ return entry.data;
47
+ }
48
+ localStorage.removeItem(`cache_${key}`);
49
+ }
50
+ }
51
+ catch (error) {
52
+ console.warn('localStorage read failed', error);
53
+ }
54
+ }
55
+ break;
56
+ case 'indexedDB':
57
+ if (this.db) {
58
+ try {
59
+ const transaction = this.db.transaction([this.storeName], 'readonly');
60
+ const store = transaction.objectStore(this.storeName);
61
+ const request = store.get(key);
62
+ const result = await new Promise((resolve, reject) => {
63
+ request.onsuccess = () => resolve(request.result);
64
+ request.onerror = () => reject(request.error);
65
+ });
66
+ if (result && now - result.timestamp < result.ttl) {
67
+ return result.data;
68
+ }
69
+ // Clean up expired entry
70
+ if (result) {
71
+ this.delete(key);
72
+ }
73
+ }
74
+ catch (error) {
75
+ console.warn('IndexedDB read failed', error);
76
+ }
77
+ }
78
+ break;
79
+ default: // memory
80
+ const entry = this.memoryCache.get(key);
81
+ if (entry && now - entry.timestamp < entry.ttl) {
82
+ return entry.data;
83
+ }
84
+ if (entry) {
85
+ this.memoryCache.delete(key);
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ async set(key, data, ttl) {
91
+ const entry = {
92
+ data,
93
+ timestamp: Date.now(),
94
+ ttl: ttl || this.options.defaultTTL
95
+ };
96
+ switch (this.options.storage) {
97
+ case 'localStorage':
98
+ if (hasLocalStorage()) {
99
+ try {
100
+ localStorage.setItem(`cache_${key}`, JSON.stringify(entry));
101
+ // Enforce max size for localStorage
102
+ if (localStorage.length > this.options.maxSize) {
103
+ this.evictOldest();
104
+ }
105
+ }
106
+ catch (error) {
107
+ console.warn('localStorage write failed', error);
108
+ // Fall back to memory cache
109
+ this.memoryCache.set(key, entry);
110
+ }
111
+ }
112
+ break;
113
+ case 'indexedDB':
114
+ if (this.db) {
115
+ try {
116
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
117
+ const store = transaction.objectStore(this.storeName);
118
+ await new Promise((resolve, reject) => {
119
+ const request = store.put({ key, ...entry });
120
+ request.onsuccess = () => resolve();
121
+ request.onerror = () => reject(request.error);
122
+ });
123
+ }
124
+ catch (error) {
125
+ console.warn('IndexedDB write failed', error);
126
+ // Fall back to memory cache
127
+ this.memoryCache.set(key, entry);
128
+ }
129
+ }
130
+ break;
131
+ default: // memory
132
+ this.memoryCache.set(key, entry);
133
+ // Enforce max size for memory cache
134
+ if (this.memoryCache.size > this.options.maxSize) {
135
+ const firstKey = this.memoryCache.keys().next().value;
136
+ this.memoryCache.delete(firstKey);
137
+ }
138
+ }
139
+ }
140
+ async delete(key) {
141
+ switch (this.options.storage) {
142
+ case 'localStorage':
143
+ if (hasLocalStorage()) {
144
+ localStorage.removeItem(`cache_${key}`);
145
+ }
146
+ break;
147
+ case 'indexedDB':
148
+ if (this.db) {
149
+ try {
150
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
151
+ const store = transaction.objectStore(this.storeName);
152
+ store.delete(key);
153
+ }
154
+ catch (error) {
155
+ console.warn('IndexedDB delete failed', error);
156
+ }
157
+ }
158
+ break;
159
+ default:
160
+ this.memoryCache.delete(key);
161
+ }
162
+ }
163
+ async clear() {
164
+ switch (this.options.storage) {
165
+ case 'localStorage':
166
+ if (hasLocalStorage()) {
167
+ const keys = Object.keys(localStorage);
168
+ keys.forEach(key => {
169
+ if (key.startsWith('cache_')) {
170
+ localStorage.removeItem(key);
171
+ }
172
+ });
173
+ }
174
+ break;
175
+ case 'indexedDB':
176
+ if (this.db) {
177
+ try {
178
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
179
+ const store = transaction.objectStore(this.storeName);
180
+ store.clear();
181
+ }
182
+ catch (error) {
183
+ console.warn('IndexedDB clear failed', error);
184
+ }
185
+ }
186
+ break;
187
+ default:
188
+ this.memoryCache.clear();
189
+ }
190
+ }
191
+ evictOldest() {
192
+ if (hasLocalStorage()) {
193
+ const cacheKeys = Object.keys(localStorage)
194
+ .filter(key => key.startsWith('cache_'))
195
+ .map(key => {
196
+ try {
197
+ const entry = JSON.parse(localStorage.getItem(key) || '{}');
198
+ return { key, timestamp: entry.timestamp || 0 };
199
+ }
200
+ catch {
201
+ return { key, timestamp: 0 };
202
+ }
203
+ })
204
+ .sort((a, b) => a.timestamp - b.timestamp);
205
+ // Remove oldest 10% of entries
206
+ const toRemove = Math.ceil(cacheKeys.length * 0.1);
207
+ cacheKeys.slice(0, toRemove).forEach(({ key }) => {
208
+ localStorage.removeItem(key);
209
+ });
210
+ }
211
+ }
212
+ }
@@ -0,0 +1,16 @@
1
+ export interface RateLimiterOptions {
2
+ maxRequests: number;
3
+ windowMs: number;
4
+ retryAfterMs?: number;
5
+ maxRetries?: number;
6
+ }
7
+ export declare class RateLimiter {
8
+ private options;
9
+ private requests;
10
+ private retryCount;
11
+ constructor(options: RateLimiterOptions);
12
+ execute<T>(key: string, fn: () => Promise<T>): Promise<T>;
13
+ private sleep;
14
+ private is429Error;
15
+ reset(): void;
16
+ }
@@ -0,0 +1,60 @@
1
+ export class RateLimiter {
2
+ constructor(options) {
3
+ this.options = options;
4
+ this.requests = [];
5
+ this.retryCount = new Map();
6
+ this.options.retryAfterMs = options.retryAfterMs || 1000;
7
+ this.options.maxRetries = options.maxRetries || 3;
8
+ }
9
+ async execute(key, fn) {
10
+ const now = Date.now();
11
+ // Clean up old requests
12
+ this.requests = this.requests.filter(time => now - time < this.options.windowMs);
13
+ // Check if we're at the limit
14
+ if (this.requests.length >= this.options.maxRequests) {
15
+ const retries = this.retryCount.get(key) || 0;
16
+ if (retries >= this.options.maxRetries) {
17
+ this.retryCount.delete(key);
18
+ throw new Error('Rate limit exceeded. Please try again later.');
19
+ }
20
+ // Exponential backoff
21
+ const backoffMs = this.options.retryAfterMs * Math.pow(2, retries);
22
+ await this.sleep(backoffMs);
23
+ this.retryCount.set(key, retries + 1);
24
+ return this.execute(key, fn);
25
+ }
26
+ // Execute the function
27
+ this.requests.push(now);
28
+ this.retryCount.delete(key);
29
+ try {
30
+ return await fn();
31
+ }
32
+ catch (error) {
33
+ // If it's a 429 error, apply backoff
34
+ if (this.is429Error(error)) {
35
+ const retries = this.retryCount.get(key) || 0;
36
+ if (retries >= this.options.maxRetries) {
37
+ this.retryCount.delete(key);
38
+ throw error;
39
+ }
40
+ const backoffMs = this.options.retryAfterMs * Math.pow(2, retries);
41
+ await this.sleep(backoffMs);
42
+ this.retryCount.set(key, retries + 1);
43
+ return this.execute(key, fn);
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+ sleep(ms) {
49
+ return new Promise(resolve => setTimeout(resolve, ms));
50
+ }
51
+ is429Error(error) {
52
+ return error?.response?.status === 429 ||
53
+ error?.code === 'ERR_RATE_LIMITED' ||
54
+ (error?.message && error.message.includes('429'));
55
+ }
56
+ reset() {
57
+ this.requests = [];
58
+ this.retryCount.clear();
59
+ }
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cygnus-wealth/asset-valuator",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Asset valuation library for retrieving and converting cryptocurrency prices",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -40,6 +40,7 @@
40
40
  "eslint": "^9.32.0",
41
41
  "jest": "^30.0.5",
42
42
  "ts-jest": "^29.4.0",
43
+ "tsx": "^4.7.0",
43
44
  "typescript": "^5.8.3"
44
45
  }
45
46
  }