Resolve "Support Wallet Offline Mode"

This commit is contained in:
2026-01-05 12:26:10 +00:00
committed by Emergent Reasons
parent b47a7c43ad
commit 38dc5be0f4
6 changed files with 116 additions and 59 deletions
+2 -3
View File
@@ -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()),
@@ -192,17 +192,17 @@ export class BlockchainElectrum extends BaseBlockchain<
}
async fetchUnspents(address: string) {
// 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);
}
// 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 () => {
return await fetchUnspentTransactionOutputs(
this.electrumClient,
address,
@@ -249,17 +249,17 @@ export class BlockchainElectrum extends BaseBlockchain<
}
async fetchTransaction(txHash: string) {
// 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);
}
// 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 () => {
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) {
// 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);
}
// 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 () => {
return await fetchHistory(this.electrumClient, address);
});
@@ -305,17 +305,17 @@ export class BlockchainElectrum extends BaseBlockchain<
}
async fetchBlockHeader(blockHeight: number) {
// 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);
}
// 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 () => {
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() {
// 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);
}
// 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) {
const chainTip = await fetchCurrentChainTip(this.electrumClient);
this.blockHeight = chainTip.height;
+1
View File
@@ -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';
+26 -6
View File
@@ -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<string> = new Set();
// Cache of the latest rates from each adapter
private rateCache: Map<string, Map<string, Map<string, number>>> = 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<string, Map<string, Map<string, CachedRate>>> =
new Map();
// Off callbacks for unsubscribing from events
private unsubscribeFunctions: Map<string, () => 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;
/**
+20 -10
View File
@@ -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<E extends StoreEvents = StoreEvents> {
abstract listStores(): Promise<Array<string>>;
abstract createStore(
abstract createStore<T = unknown>(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore>;
abstract getStore(
): Promise<BaseStore<T, E>>;
abstract getStore<T = unknown>(
storeName: string,
opts?: StorageOpts,
): Promise<BaseStore<T, E> | null>;
abstract createOrGetStore<T = unknown>(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore | null>;
abstract createOrGetStore(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore>;
): Promise<BaseStore<T, E>>;
abstract deleteStore(storeName: string): Promise<void>;
abstract deleteDatabase(): Promise<void>;
}
@@ -79,7 +86,10 @@ export type StoreValueProxy<T> = {
* }
* ```
*/
export abstract class BaseStore<K = unknown> extends EventEmitter<StoreEvents> {
export abstract class BaseStore<
K = unknown,
E extends StoreEvents = StoreEvents,
> extends EventEmitter<E> {
/**
* Returns an array of all keys in the store.
*/
+37 -10
View File
@@ -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<StoreRemoteHTTPEvents> {
/**
* Creates a new StorageRemoteHTTP instance.
*
@@ -33,7 +43,7 @@ export class StorageRemoteHTTP extends BaseStorage {
return storage;
}
private stores: Map<string, StoreRemoteHTTP> = new Map();
private stores: Map<string, StoreRemoteHTTP<any>> = 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<StoreRemoteHTTP | null> {
async getStore<T = unknown>(
storeName: string,
): Promise<StoreRemoteHTTP<T> | 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<StoreRemoteHTTP> {
const store = await StoreRemoteHTTP.from(
async createStore<T = unknown>(
storeName: string,
opts: EncrptedStoreOpts,
): Promise<StoreRemoteHTTP<T>> {
const store = await StoreRemoteHTTP.from<T>(
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<StoreRemoteHTTP> {
async createOrGetStore<T = unknown>(
storeName: string,
opts: EncrptedStoreOpts,
): Promise<StoreRemoteHTTP<T>> {
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<T = unknown> extends BaseStore<
T,
StoreRemoteHTTPEvents
> {
static async from<T = unknown>(
baseURL: string,
privateKey: PrivateKey,
encrypter: Encrypter,
) {
): Promise<StoreRemoteHTTP<T>> {
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,
});
}