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(), description: z.string().optional(),
uris: z uris: z
.object({ .object({
// Since 'icon' and 'web' are strongly recommended uri identifiers(https://github.com/bitjson/chip-bcmr?tab=readme-ov-file#recommended-uri-identifiers) // '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: z.url(), icon: z.url(),
web: z.url(), web: z.url().optional(),
}) })
// Allows any other keys with any values. // Allows any other keys with any values.
.catchall(z.unknown()), .catchall(z.unknown()),
@@ -192,17 +192,17 @@ export class BlockchainElectrum extends BaseBlockchain<
} }
async fetchUnspents(address: string) { 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. // Define the cache key.
const cacheKey = `addressUnspents.${address}`; const cacheKey = `addressUnspents.${address}`;
// Attempt to get the address unspents from the cache or fetch them if not available. // Attempt to get the address unspents from the cache or fetch them if not available.
const addressUnspents = await this.store.getOrSet(cacheKey, async () => { 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( return await fetchUnspentTransactionOutputs(
this.electrumClient, this.electrumClient,
address, address,
@@ -249,17 +249,17 @@ export class BlockchainElectrum extends BaseBlockchain<
} }
async fetchTransaction(txHash: string) { 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. // Define the cache key.
const cacheKey = `transactions.${txHash}`; const cacheKey = `transactions.${txHash}`;
// Attempt to get the transaction hex from the cache or fetch it if not available. // Attempt to get the transaction hex from the cache or fetch it if not available.
const transactionRaw = await this.store.getOrSet(cacheKey, async () => { 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); const txHex = await fetchTransaction(this.electrumClient, txHash);
return Transaction.fromHex(txHex).toRaw(); return Transaction.fromHex(txHex).toRaw();
}); });
@@ -272,17 +272,17 @@ export class BlockchainElectrum extends BaseBlockchain<
} }
async fetchAddressHistory(address: string) { 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. // Define the cache key.
const cacheKey = `addressHistory.${address}`; const cacheKey = `addressHistory.${address}`;
// Attempt to get the transaction history from the cache or fetch them if not available. // Attempt to get the transaction history from the cache or fetch them if not available.
const addressHistory = await this.store.getOrSet(cacheKey, async () => { 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); return await fetchHistory(this.electrumClient, address);
}); });
@@ -305,17 +305,17 @@ export class BlockchainElectrum extends BaseBlockchain<
} }
async fetchBlockHeader(blockHeight: number) { 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. // Define the cache key.
const cacheKey = `blockHeader.${blockHeight}`; const cacheKey = `blockHeader.${blockHeight}`;
// Attempt to get the transaction history from the cache or fetch them if not available. // Attempt to get the transaction history from the cache or fetch them if not available.
const blockHeaderRaw = await this.store.getOrSet(cacheKey, async () => { 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( const headerHex = await fetchBlockHeaderFromBlockHeight(
this.electrumClient, this.electrumClient,
blockHeight, 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. // 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. // We may be able to leverage this side-effect to avoid fetching the chain tip.
async fetchChainTip() { 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. // 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. // 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) { 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); const chainTip = await fetchCurrentChainTip(this.electrumClient);
this.blockHeight = chainTip.height; 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. // For node-only providers, export these from index.node.ts.
export * from './bcmr/bcmr-composite.js'; export * from './bcmr/bcmr-composite.js';
export * from './bcmr/bcmr-http.js'; export * from './bcmr/bcmr-http.js';
export * from './bcmr/bcmr-helpers.js';
export * from './blockchain/blockchain-electrum.js'; export * from './blockchain/blockchain-electrum.js';
export * from './blockchain/blockchain-chaingraph.js'; export * from './blockchain/blockchain-chaingraph.js';
export * from './storage/store-remote-http.js'; export * from './storage/store-remote-http.js';
+26 -6
View File
@@ -1,6 +1,11 @@
import { BaseRates } from './base-rates.js'; import { BaseRates } from './base-rates.js';
import { UUIDV7 } from '../utils/uuid.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 * A composite rate adapter that combines rates from multiple rate sources
* using a configurable policy. * using a configurable policy.
@@ -13,7 +18,16 @@ export class RatesComposite extends BaseRates {
private availablePairs: Set<string> = new Set(); private availablePairs: Set<string> = new Set();
// Cache of the latest rates from each adapter // 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 // Off callbacks for unsubscribing from events
private unsubscribeFunctions: Map<string, () => void> = new Map(); private unsubscribeFunctions: Map<string, () => void> = new Map();
@@ -210,7 +224,7 @@ export class RatesComposite extends BaseRates {
// Store the rate in the cache // Store the rate in the cache
const rateMap = denomMap.get(numeratorUnitCode)!; const rateMap = denomMap.get(numeratorUnitCode)!;
rateMap.set(adapterId, price); rateMap.set(adapterId, { price, timestamp: Date.now() });
// Add pair to available pairs // Add pair to available pairs
this.availablePairs.add(`${numeratorUnitCode}/${denominatorUnitCode}`); this.availablePairs.add(`${numeratorUnitCode}/${denominatorUnitCode}`);
@@ -225,12 +239,19 @@ export class RatesComposite extends BaseRates {
// Process each numerator currency // Process each numerator currency
for (const [numerator, rateMap] of numeratorMap.entries()) { for (const [numerator, rateMap] of numeratorMap.entries()) {
// Go through our adapters in order, plucking the rate for each adapter // 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)) .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 // 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 // Only emit if we have a valid rate
if (combinedRate !== null) { if (combinedRate !== null) {
@@ -244,7 +265,6 @@ export class RatesComposite extends BaseRates {
} }
} }
} }
export type RatesCompositePolicy = (rates: number[]) => number | null; export type RatesCompositePolicy = (rates: number[]) => number | null;
/** /**
+20 -10
View File
@@ -15,20 +15,27 @@ export type StoreEvents = {
clear: undefined; 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 listStores(): Promise<Array<string>>;
abstract createStore( abstract createStore<T = unknown>(
storeName: string, storeName: string,
opts: StorageOpts, opts: StorageOpts,
): Promise<BaseStore>; ): Promise<BaseStore<T, E>>;
abstract getStore( abstract getStore<T = unknown>(
storeName: string,
opts?: StorageOpts,
): Promise<BaseStore<T, E> | null>;
abstract createOrGetStore<T = unknown>(
storeName: string, storeName: string,
opts: StorageOpts, opts: StorageOpts,
): Promise<BaseStore | null>; ): Promise<BaseStore<T, E>>;
abstract createOrGetStore(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore>;
abstract deleteStore(storeName: string): Promise<void>; abstract deleteStore(storeName: string): Promise<void>;
abstract deleteDatabase(): 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. * 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 { SSESession } from '../utils/index.js';
import { import {
Bytes, Bytes,
@@ -15,13 +20,18 @@ export type EncrptedStoreOpts = StorageOpts & {
encrypter: Encrypter; encrypter: Encrypter;
}; };
export type StoreRemoteHTTPEvents = StoreEvents & {
connected: undefined;
disconnected: undefined;
};
/** /**
* A storage adapter for a remote storage server. * 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. * 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. * 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. * Creates a new StorageRemoteHTTP instance.
* *
@@ -33,7 +43,7 @@ export class StorageRemoteHTTP extends BaseStorage {
return storage; return storage;
} }
private stores: Map<string, StoreRemoteHTTP> = new Map(); private stores: Map<string, StoreRemoteHTTP<any>> = new Map();
constructor(protected baseURL: string) { constructor(protected baseURL: string) {
super(); super();
@@ -45,7 +55,9 @@ export class StorageRemoteHTTP extends BaseStorage {
* @param storeName The name of the store to get. * @param storeName The name of the store to get.
* @returns The store or null if it does not exist. * @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; 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. * @param opts The options for the store. This includes the private key and encrypter.
* @returns The created store. * @returns The created store.
*/ */
async createStore(storeName: string, opts: EncrptedStoreOpts): Promise<StoreRemoteHTTP> { async createStore<T = unknown>(
const store = await StoreRemoteHTTP.from( storeName: string,
opts: EncrptedStoreOpts,
): Promise<StoreRemoteHTTP<T>> {
const store = await StoreRemoteHTTP.from<T>(
this.baseURL, this.baseURL,
opts.privateKey, opts.privateKey,
opts.encrypter, 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. * @param opts The options for the store. This includes the private key and encrypter.
* @returns The created or existing store. * @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); const store = await this.getStore(storeName);
if (store) { if (store) {
return store; return store;
@@ -110,12 +128,15 @@ export class StorageRemoteHTTP extends BaseStorage {
} }
} }
export class StoreRemoteHTTP extends BaseStore { export class StoreRemoteHTTP<T = unknown> extends BaseStore<
static async from( T,
StoreRemoteHTTPEvents
> {
static async from<T = unknown>(
baseURL: string, baseURL: string,
privateKey: PrivateKey, privateKey: PrivateKey,
encrypter: Encrypter, encrypter: Encrypter,
) { ): Promise<StoreRemoteHTTP<T>> {
const store = new this(baseURL, privateKey, encrypter); const store = new this(baseURL, privateKey, encrypter);
await store.subscribeEvents(); await store.subscribeEvents();
@@ -235,6 +256,12 @@ export class StoreRemoteHTTP extends BaseStore {
}); });
}, },
onReconnect: createSSESessionOptions, onReconnect: createSSESessionOptions,
onConnected: () => {
this.emit('connected', undefined);
},
onDisconnected: () => {
this.emit('disconnected', undefined);
},
...options, ...options,
}); });
} }