Merge branch '103-support-wallet-offline-mode' into 'development'
Resolve "Support Wallet Offline Mode" Closes #103 See merge request GeneralProtocols/xo/stack!146
This commit is contained in:
@@ -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) {
|
||||
// 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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user