diff --git a/packages/stack/src/bcmr/bcmr-helpers.ts b/packages/stack/src/bcmr/bcmr-helpers.ts index 08fc6f1..43def65 100644 --- a/packages/stack/src/bcmr/bcmr-helpers.ts +++ b/packages/stack/src/bcmr/bcmr-helpers.ts @@ -6,10 +6,9 @@ export const MetadataRegistrySchema = z description: z.string().optional(), uris: z .object({ - // Since 'icon' and 'web' are strongly recommended uri identifiers(https://github.com/bitjson/chip-bcmr?tab=readme-ov-file#recommended-uri-identifiers) - // We require it as non optional. + // 'icon' and 'web' are strongly recommended uri identifiers(https://github.com/bitjson/chip-bcmr?tab=readme-ov-file#recommended-uri-identifiers) icon: z.url(), - web: z.url(), + web: z.url().optional(), }) // Allows any other keys with any values. .catchall(z.unknown()), diff --git a/packages/stack/src/blockchain/blockchain-electrum.ts b/packages/stack/src/blockchain/blockchain-electrum.ts index 196560c..74b48ae 100644 --- a/packages/stack/src/blockchain/blockchain-electrum.ts +++ b/packages/stack/src/blockchain/blockchain-electrum.ts @@ -192,17 +192,17 @@ export class BlockchainElectrum extends BaseBlockchain< } async fetchUnspents(address: string) { - // Wait for connection to be established before attempting to subscribe. - // TODO: Consider doing this at a higher level. - if (!this.isConnected) { - await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); - } - // Define the cache key. const cacheKey = `addressUnspents.${address}`; // Attempt to get the address unspents from the cache or fetch them if not available. const addressUnspents = await this.store.getOrSet(cacheKey, async () => { + // Wait for connection to be established before attempting to subscribe. + // TODO: Consider doing this at a higher level. + if (!this.isConnected) { + await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); + } + return await fetchUnspentTransactionOutputs( this.electrumClient, address, @@ -249,17 +249,17 @@ export class BlockchainElectrum extends BaseBlockchain< } async fetchTransaction(txHash: string) { - // Wait for connection to be established before attempting to subscribe. - // TODO: Consider doing this at a higher level. - if (!this.isConnected) { - await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); - } - // Define the cache key. const cacheKey = `transactions.${txHash}`; // Attempt to get the transaction hex from the cache or fetch it if not available. const transactionRaw = await this.store.getOrSet(cacheKey, async () => { + // Wait for connection to be established before attempting to subscribe. + // TODO: Consider doing this at a higher level. + if (!this.isConnected) { + await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); + } + const txHex = await fetchTransaction(this.electrumClient, txHash); return Transaction.fromHex(txHex).toRaw(); }); @@ -272,17 +272,17 @@ export class BlockchainElectrum extends BaseBlockchain< } async fetchAddressHistory(address: string) { - // Wait for connection to be established before attempting to subscribe. - // TODO: Consider doing this at a higher level. - if (!this.isConnected) { - await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); - } - // Define the cache key. const cacheKey = `addressHistory.${address}`; // Attempt to get the transaction history from the cache or fetch them if not available. const addressHistory = await this.store.getOrSet(cacheKey, async () => { + // Wait for connection to be established before attempting to subscribe. + // TODO: Consider doing this at a higher level. + if (!this.isConnected) { + await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); + } + return await fetchHistory(this.electrumClient, address); }); @@ -305,17 +305,17 @@ export class BlockchainElectrum extends BaseBlockchain< } async fetchBlockHeader(blockHeight: number) { - // Wait for connection to be established before attempting to subscribe. - // TODO: Consider doing this at a higher level. - if (!this.isConnected) { - await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); - } - // Define the cache key. const cacheKey = `blockHeader.${blockHeight}`; // Attempt to get the transaction history from the cache or fetch them if not available. const blockHeaderRaw = await this.store.getOrSet(cacheKey, async () => { + // Wait for connection to be established before attempting to subscribe. + // TODO: Consider doing this at a higher level. + if (!this.isConnected) { + await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); + } + const headerHex = await fetchBlockHeaderFromBlockHeight( this.electrumClient, blockHeight, @@ -333,15 +333,15 @@ export class BlockchainElectrum extends BaseBlockchain< // TODO: ElectrumClient provides a chainHeight property that is populated after a `blockchain.headers.subscribe` call. // We may be able to leverage this side-effect to avoid fetching the chain tip. async fetchChainTip() { - // Wait for connection to be established before attempting to subscribe. - // TODO: Consider doing this at a higher level. - if (!this.isConnected) { - await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); - } - // If we don't currently have a block height, fetch the current chain tip. // NOTE: We don't use our cache here because the key will be the same and so on-boot, we will have a stale value. if (!this.blockHeight) { + // Wait for connection to be established before attempting to subscribe. + // TODO: Consider doing this at a higher level. + if (!this.isConnected) { + await this.waitFor('isConnectedUpdated', (isConnected) => isConnected); + } + const chainTip = await fetchCurrentChainTip(this.electrumClient); this.blockHeight = chainTip.height; diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 058ffb6..054e8e4 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -15,6 +15,7 @@ export * from './wallet/index.js'; // For node-only providers, export these from index.node.ts. export * from './bcmr/bcmr-composite.js'; export * from './bcmr/bcmr-http.js'; +export * from './bcmr/bcmr-helpers.js'; export * from './blockchain/blockchain-electrum.js'; export * from './blockchain/blockchain-chaingraph.js'; export * from './storage/store-remote-http.js'; diff --git a/packages/stack/src/rates/rates-composite.ts b/packages/stack/src/rates/rates-composite.ts index e39f9c0..1af5836 100644 --- a/packages/stack/src/rates/rates-composite.ts +++ b/packages/stack/src/rates/rates-composite.ts @@ -1,6 +1,11 @@ import { BaseRates } from './base-rates.js'; import { UUIDV7 } from '../utils/uuid.js'; +export type CachedRate = { + price: number; + timestamp: number; +}; + /** * A composite rate adapter that combines rates from multiple rate sources * using a configurable policy. @@ -13,7 +18,16 @@ export class RatesComposite extends BaseRates { private availablePairs: Set = new Set(); // Cache of the latest rates from each adapter - private rateCache: Map>> = new Map(); + /** + * Cache of the latest rates from each adapter + * + * The cache is a nested map of: + * - denominatorCode -> numeratorCode -> adapterId -> CachedRate + * + * Eg: BCH -> USD -> 1 (Oracles) -> { price: 100, timestamp: 1714857600 } + */ + private rateCache: Map>> = + new Map(); // Off callbacks for unsubscribing from events private unsubscribeFunctions: Map void> = new Map(); @@ -210,7 +224,7 @@ export class RatesComposite extends BaseRates { // Store the rate in the cache const rateMap = denomMap.get(numeratorUnitCode)!; - rateMap.set(adapterId, price); + rateMap.set(adapterId, { price, timestamp: Date.now() }); // Add pair to available pairs this.availablePairs.add(`${numeratorUnitCode}/${denominatorUnitCode}`); @@ -225,12 +239,19 @@ export class RatesComposite extends BaseRates { // Process each numerator currency for (const [numerator, rateMap] of numeratorMap.entries()) { // Go through our adapters in order, plucking the rate for each adapter - const rates = this.adapters + const cachedRates = this.adapters + // First, get the Cached rate for each adapter .map(({ id }) => rateMap.get(id)) - .filter((rate) => rate !== undefined); + // Then, filter out any undefined rates (service is down, rate limit, etc.) + .filter((rate) => rate !== undefined) + // Then, filter out any rates that are older than the refresh interval so we don't emit stale rates + // it's up to the client to hold onto "stale" rates if they need them, rather than us mixing old and new rates without disclosing it. + .filter( + (rate) => rate.timestamp > Date.now() - this.refreshIntervalMs, + ); // Apply policy to get combined rate - const combinedRate = this.policy(rates); + const combinedRate = this.policy(cachedRates.map(({ price }) => price)); // Only emit if we have a valid rate if (combinedRate !== null) { @@ -244,7 +265,6 @@ export class RatesComposite extends BaseRates { } } } - export type RatesCompositePolicy = (rates: number[]) => number | null; /** diff --git a/packages/stack/src/storage/base-store.ts b/packages/stack/src/storage/base-store.ts index 152710b..024ea16 100644 --- a/packages/stack/src/storage/base-store.ts +++ b/packages/stack/src/storage/base-store.ts @@ -15,20 +15,27 @@ export type StoreEvents = { clear: undefined; }; -export abstract class BaseStorage { +/** + * Abstract base class for storage implementations. + * + * @template E The events type for stores created by this storage. + * Must extend StoreEvents (at least includes base events). + * Defaults to StoreEvents for base type hints. + */ +export abstract class BaseStorage { abstract listStores(): Promise>; - abstract createStore( + abstract createStore( storeName: string, opts: StorageOpts, - ): Promise; - abstract getStore( + ): Promise>; + abstract getStore( + storeName: string, + opts?: StorageOpts, + ): Promise | null>; + abstract createOrGetStore( storeName: string, opts: StorageOpts, - ): Promise; - abstract createOrGetStore( - storeName: string, - opts: StorageOpts, - ): Promise; + ): Promise>; abstract deleteStore(storeName: string): Promise; abstract deleteDatabase(): Promise; } @@ -79,7 +86,10 @@ export type StoreValueProxy = { * } * ``` */ -export abstract class BaseStore extends EventEmitter { +export abstract class BaseStore< + K = unknown, + E extends StoreEvents = StoreEvents, +> extends EventEmitter { /** * Returns an array of all keys in the store. */ diff --git a/packages/stack/src/storage/store-remote-http.ts b/packages/stack/src/storage/store-remote-http.ts index 3c6107c..ca29a83 100644 --- a/packages/stack/src/storage/store-remote-http.ts +++ b/packages/stack/src/storage/store-remote-http.ts @@ -1,4 +1,9 @@ -import { BaseStorage, BaseStore, StorageOpts } from './base-store.js'; +import { + BaseStorage, + BaseStore, + StorageOpts, + StoreEvents, +} from './base-store.js'; import { SSESession } from '../utils/index.js'; import { Bytes, @@ -15,13 +20,18 @@ export type EncrptedStoreOpts = StorageOpts & { encrypter: Encrypter; }; +export type StoreRemoteHTTPEvents = StoreEvents & { + connected: undefined; + disconnected: undefined; +}; + /** * A storage adapter for a remote storage server. * * All data sent to the server is first packed using MessagePack and then encrypted using the provided encrypter and then packed again using MessagePack. * The requests are signed using the provided private key and the timestamp of the request to mitigate replay attacks and to authenticate the request. */ -export class StorageRemoteHTTP extends BaseStorage { +export class StorageRemoteHTTP extends BaseStorage { /** * Creates a new StorageRemoteHTTP instance. * @@ -33,7 +43,7 @@ export class StorageRemoteHTTP extends BaseStorage { return storage; } - private stores: Map = new Map(); + private stores: Map> = new Map(); constructor(protected baseURL: string) { super(); @@ -45,7 +55,9 @@ export class StorageRemoteHTTP extends BaseStorage { * @param storeName The name of the store to get. * @returns The store or null if it does not exist. */ - async getStore(storeName: string): Promise { + async getStore( + storeName: string, + ): Promise | null> { return this.stores.get(storeName) || null; } @@ -56,8 +68,11 @@ export class StorageRemoteHTTP extends BaseStorage { * @param opts The options for the store. This includes the private key and encrypter. * @returns The created store. */ - async createStore(storeName: string, opts: EncrptedStoreOpts): Promise { - const store = await StoreRemoteHTTP.from( + async createStore( + storeName: string, + opts: EncrptedStoreOpts, + ): Promise> { + const store = await StoreRemoteHTTP.from( this.baseURL, opts.privateKey, opts.encrypter, @@ -73,7 +88,10 @@ export class StorageRemoteHTTP extends BaseStorage { * @param opts The options for the store. This includes the private key and encrypter. * @returns The created or existing store. */ - async createOrGetStore(storeName: string, opts: EncrptedStoreOpts): Promise { + async createOrGetStore( + storeName: string, + opts: EncrptedStoreOpts, + ): Promise> { const store = await this.getStore(storeName); if (store) { return store; @@ -110,12 +128,15 @@ export class StorageRemoteHTTP extends BaseStorage { } } -export class StoreRemoteHTTP extends BaseStore { - static async from( +export class StoreRemoteHTTP extends BaseStore< + T, + StoreRemoteHTTPEvents +> { + static async from( baseURL: string, privateKey: PrivateKey, encrypter: Encrypter, - ) { + ): Promise> { const store = new this(baseURL, privateKey, encrypter); await store.subscribeEvents(); @@ -235,6 +256,12 @@ export class StoreRemoteHTTP extends BaseStore { }); }, onReconnect: createSSESessionOptions, + onConnected: () => { + this.emit('connected', undefined); + }, + onDisconnected: () => { + this.emit('disconnected', undefined); + }, ...options, }); }