Resolve "Support Wallet Offline Mode"
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user