diff --git a/packages/primitives/src/utils.ts b/packages/primitives/src/utils.ts index 372481b..ec35829 100644 --- a/packages/primitives/src/utils.ts +++ b/packages/primitives/src/utils.ts @@ -2,10 +2,11 @@ import { Transaction } from './transaction.js'; import { binToHex } from '@bitauth/libauth'; +// TODO: The below should probably be moved and renamed. /** * Sort transactions by the order in which they should be broadcast. */ -export function sortTransactions( +export function sortTransactionsByDependencies( transactions: Array, ): Array { const transactionMap = transactions.reduce( @@ -139,6 +140,24 @@ export class ExtMap extends Map { return sorted; } + /** + * Returns a new ExtMap containing a subset of entries from the original Map + * @param start - The starting index (inclusive) + * @param end - The ending index (exclusive) + * @returns A new ExtMap with the sliced entries + */ + slice(start: number, end?: number): ExtMap { + const keys = Array.from(this.keys()); + const slicedKeys = keys.slice(start, end); + + const result = new ExtMap(); + for (const key of slicedKeys) { + result.set(key, this.get(key) as T); + } + + return result; + } + /** * Converts the collection to a plain object */ diff --git a/packages/stack/src/blockchain/index.ts b/packages/stack/src/blockchain/index.ts index 86c29e1..ce19c0d 100644 --- a/packages/stack/src/blockchain/index.ts +++ b/packages/stack/src/blockchain/index.ts @@ -3,3 +3,4 @@ export * from './blockchain-electrum.js'; export * from './blockchain-inmemory.js'; export * from './traits.js'; export * from './types.js'; +export * from './utils.js'; diff --git a/packages/stack/src/blockchain/utils.ts b/packages/stack/src/blockchain/utils.ts new file mode 100644 index 0000000..747d957 --- /dev/null +++ b/packages/stack/src/blockchain/utils.ts @@ -0,0 +1,54 @@ +// TODO: If we end up with several utilities, we might want to consider a refactor: +// 1. Convert BlockchainTransactions into a class with a sort function. +// 2. Have WalletTransactions extends from BlockchainTransactions. + +import { type BlockchainTransactions } from './types.js'; + +export function sortTransactions( + transactions: BlockchainTransactions, + order: 'ASC' | 'DESC', +): BlockchainTransactions { + return transactions.sort((a, b) => { + // Name the outcomes, or I'll go insane. + const aFirst = order === 'DESC' ? -1 : 1; + const bFirst = order === 'DESC' ? 1 : -1; + + // Sort by mempool, then blockheight, then dependency. + // height = 0 -- mempool + // height > 0 -- confirmed + // height < 0 -- mempool with dependencies + + // If a is dependant on a tx in the mempool, while b is in the mempool or confirmed, sort a before b. + if (a.height <= -1 && b.height >= 0) { + return aFirst; + } + + // If b is dependant on a tx in the mempool, while a is in the mempool or confirmed, sort b before a. + if (b.height <= -1 && a.height >= 0) { + return bFirst; + } + + // If a is in the mempool, while b is confirmed, sort a before b. + if (a.height <= 0 && b.height > 0) { + return aFirst; + } + + // If b is in the mempool, while a is confirmed, sort b before a. + if (b.height <= 0 && a.height > 0) { + return bFirst; + } + + // If a has a higher blockheight than b, sort a before b. + if (a.height > b.height) { + return aFirst; + } + + // If b has a higher blockheight than a, sort b before a. + if (b.height > a.height) { + return bFirst; + } + + // There was no criteria to sort by, so return 0. + return 0; + }); +} diff --git a/packages/stack/src/index.browser.ts b/packages/stack/src/index.browser.ts index c5fe44f..695cce3 100644 --- a/packages/stack/src/index.browser.ts +++ b/packages/stack/src/index.browser.ts @@ -3,3 +3,4 @@ export * from './index.js'; // These exports will only work in a browser environment. export * from './storage/store-indexeddb.js'; +export * from './storage/store-localstorage.js'; diff --git a/packages/stack/src/storage/activities.ts b/packages/stack/src/storage/activities.ts index 3d54476..b749a55 100644 --- a/packages/stack/src/storage/activities.ts +++ b/packages/stack/src/storage/activities.ts @@ -1,7 +1,7 @@ import { BaseStore } from './base-store.js'; import { StoreComposite } from './store-composite.js'; -import { UUIDV7 } from '../utils/index.js'; +import { EventEmitter, UUIDV7 } from '../utils/index.js'; // Make the given property in a type optional. // TODO: This is an extremely useful utility. If we use it elsewhere, move to own "type-utils.ts" file. @@ -9,10 +9,17 @@ type Optional = Omit & Partial>; // Base Activity interface export interface Activity { - entityId: string; - name: string; + /** Key (Type) of this activity. */ + key: string; + + /** Value for this activity. */ value: unknown; + + /** Timestamp for this activity. */ timestamp: bigint; + + /** Entity ID may refer to a transaction id or other specific identifier. */ + entityId?: string; } // Define the snapshot structure @@ -28,6 +35,10 @@ export type PolicyFunction = ( newActivity: Activity, ) => Activity; +export type ActivitiesEvents = { + [key: `updated:${string}`]: { activity: Activity }; +}; + /** * Implements an event sourcing pattern for managing application state * with a simplified interface focused on the current snapshot. @@ -51,7 +62,14 @@ export type PolicyFunction = ( * const savedActivity = await activities.add(walletName); * ``` */ -export class Activities { +export class Activities extends EventEmitter { + // Dependencies. + public readonly store: StoreComposite; + + // State. + // TODO: If you can refactor this around your new Storage mechanism, things become a lot simpler: + // 1. You don't need to define such a clunky snapshot format because you have indexes. + // 2. Caching of snapshots likely becomes possible (because you can use a storage adapter). public snapshot: SnapshotByType = {}; private policies = new Map(); private storeKeyMap = new Map(); @@ -59,27 +77,20 @@ export class Activities { /** * Create an Activities instance from the given composite store. */ - static async from(compositeStore: StoreComposite) { + static async from(stores?: Array) { + const compositeStore = await StoreComposite.from(stores); const activities = new Activities(compositeStore); await activities.compileSnapshot(); return activities; } - /** - * Create an Activities instance from the given primary and secondary stores - */ - static async fromStores( - primaryStore: BaseStore, - secondaryStores?: Array, - ): Promise { - const compositeStore = await StoreComposite.from( - primaryStore, - secondaryStores, - ); - return Activities.from(compositeStore); - } + private constructor(store: StoreComposite) { + super(); - private constructor(public store: StoreComposite) { + // Assign our dependencies. + this.store = store; + + // Register callbacks. this.store.on( 'set', async (payload: { key: string; value: Activity | null }) => { @@ -90,47 +101,78 @@ export class Activities { ); } + // TODO: Additional argument to "add": Something like a quarum. + // But, instead of a quorum, it's a function that takes the resolve of all "set" promises. + // That way, we can implement custom storage policies? + /** * Add a new activity to the store * @param activity The activity to add (id and timestamp are optional) * @returns The stored activity with all fields populated */ - public async add>( - activity: T, + public async add( + activity: Optional, ): Promise { // Generate timestamp if not provided const timestamp = activity.timestamp ?? BigInt(Date.now()); // Generate a UUID based on the timestamp - const key = UUIDV7.fromTimestamp(timestamp).toString(); + const uuid = UUIDV7.fromTimestamp(timestamp).toString(); - // Generate an ID if not provided - const id = activity.entityId ?? key; + // If this doesn't reference a specific entity id, put it at root level (empty string: "") + const id = activity.entityId ?? ''; const completeActivity: Activity = { - name: activity.name, + key: activity.key, value: activity.value, entityId: id, timestamp, }; - await this.store.set(key, completeActivity); + await this.store.set(uuid, completeActivity); return completeActivity; } + public async remove( + activity: Pick, + ) { + // Generate timestamp. + const timestamp = BigInt(Date.now()); + + // Generate a UUID based on the timestamp + const uuid = UUIDV7.fromTimestamp(timestamp).toString(); + + // If this doesn't reference a specific entity id, put it at root level (empty string: "") + const id = activity.entityId ?? ''; + + const removedActivity: Activity = { + key: activity.key, + value: null, + entityId: id, + timestamp, + }; + + await this.store.set(uuid, removedActivity); + + return removedActivity; + } + /** * Get a specific activity by type and id with type inference */ - public get(type: T['name'], id: string): T | undefined { - const activity = this.snapshot[type]?.[id]; + public get( + type: T['key'], + entityId: string = '', + ): T | undefined { + const activity = this.snapshot[type]?.[entityId]; return activity as T | undefined; } /** * Get all activities of a specific type */ - public getByType(type: T['name']): T[] { + public getByType(type: T['key']): T[] { const activities = this.snapshot[type]; if (!activities) { return []; @@ -157,20 +199,14 @@ export class Activities { if (!activity) { return; } - // Ensure timestamp exists by extracting from UUID if needed if (!activity.timestamp) { activity.timestamp = UUIDV7.from(storeKey).getTimestamp(); } - // Ensure ID exists - if (!activity.entityId) { - activity.entityId = storeKey; - } - const snapshotKey = this.getSnapshotKey(activity); const existingActivity = this.getActivityFromSnapshot( - activity.name, + activity.key, activity.entityId, ); @@ -185,12 +221,15 @@ export class Activities { const oldStoreKey = this.storeKeyMap.get(snapshotKey); if (oldStoreKey && oldStoreKey !== storeKey) { // Delete the old activity from the store - await this.store.set(oldStoreKey, null); + // await this.store.set(oldStoreKey, null); } this.storeKeyMap.set(snapshotKey, storeKey); + + // Emit the updated activity. + this.emit(`updated:${activity.key}`, { activity }); } else if (storeKey !== this.storeKeyMap.get(snapshotKey)) { // If we kept the existing activity, delete the new activity from the store - await this.store.set(storeKey, null); + // await this.store.set(storeKey, null); } } @@ -230,27 +269,27 @@ export class Activities { */ private getActivityFromSnapshot( type: string, - id: string, + entityId: string = '', ): Activity | undefined { - return this.snapshot[type]?.[id]; + return this.snapshot[type]?.[entityId]; } /** * Set an activity in the snapshot, creating necessary object structure */ private setActivityInSnapshot(activity: Activity): void { - if (!this.snapshot[activity.name]) { - this.snapshot[activity.name] = {}; + if (!this.snapshot[activity.key]) { + this.snapshot[activity.key] = {}; } - this.snapshot[activity.name][activity.entityId] = activity; + this.snapshot[activity.key][activity.entityId || ''] = activity; } /** * Get the snapshot key for an activity */ private getSnapshotKey(activity: Activity): string { - return `${activity.name}:${activity.entityId}`; + return `${activity.key}:${activity.entityId}`; } /** @@ -260,7 +299,7 @@ export class Activities { existingActivity: Activity | undefined, newActivity: Activity, ): Activity { - const policy = this.policies.get(newActivity.name); + const policy = this.policies.get(newActivity.key); if (!policy) { // Default policy: compare timestamps diff --git a/packages/stack/src/storage/store-composite.ts b/packages/stack/src/storage/store-composite.ts index 2bab689..3b39109 100644 --- a/packages/stack/src/storage/store-composite.ts +++ b/packages/stack/src/storage/store-composite.ts @@ -1,4 +1,5 @@ import { BaseStore } from './base-store.js'; +import { StoreInMemory } from './store-inmemory.js'; /** * A store that accepts multiple stores and syncs them by key. @@ -12,127 +13,128 @@ import { BaseStore } from './base-store.js'; */ export class StoreComposite extends BaseStore { static async from( - localStore: BaseStore, - remoteStores: Array = [], + stores: Array = [], + primaryStore: BaseStore = StoreInMemory.from(), ) { - const compositeStore = new this(localStore, remoteStores); + const compositeStore = new this(stores, primaryStore); await compositeStore.sync(); return compositeStore; } constructor( - private localStore: BaseStore, - private remoteStores: Array = [], + private stores: Array = [], + private primaryStore: BaseStore, ) { super(); } async all() { - return await this.localStore.all(); + return await this.primaryStore.all(); } async keys() { - return await this.localStore.keys(); + return await this.primaryStore.keys(); } async get(key: string) { - return await this.localStore.get(key); + return await this.primaryStore.get(key); } async set(key: string, value: T) { - await this.localStore.set(key, value); + await this.primaryStore.set(key, value); this.emit('set', { key, value }); } async delete(key: string) { - await this.localStore.delete(key); + await this.primaryStore.delete(key); this.emit('delete', { key }); } async clear() { - await this.localStore.clear(); + await this.primaryStore.clear(); this.emit('clear', undefined); } async has(key: string) { - return await this.localStore.has(key); + return await this.primaryStore.has(key); } async close() { await Promise.all([ - this.localStore.close(), - ...this.remoteStores.map((store) => store.close()), + this.primaryStore.close(), + ...this.stores.map((store) => store.close()), ]); } /** * Add a new remote store and set up synchronization */ - public async addRemoteStore(remoteStore: BaseStore): Promise { - this.remoteStores.push(remoteStore); + async addStore(remoteStore: BaseStore): Promise { + this.stores.push(remoteStore); // Propagate existing local events to the new remote - this.localStore.on('set', async (payload) => { + this.primaryStore.on('set', async (payload) => { + if (await remoteStore.has(payload.key)) return; await remoteStore.set(payload.key, payload.value); }); - this.localStore.on('delete', async (payload) => { - await remoteStore.delete(payload.key); + this.primaryStore.on('delete', async (payload) => { + if (await remoteStore.has(payload.key)) { + await remoteStore.delete(payload.key); + } }); - this.localStore.on('clear', async () => { + this.primaryStore.on('clear', async () => { await remoteStore.clear(); }); // Set up remote to local propagation remoteStore.on('set', async (payload) => { - if (await this.localStore.has(payload.key)) return; - await this.localStore.set(payload.key, payload.value); + if (await this.primaryStore.has(payload.key)) return; + await this.primaryStore.set(payload.key, payload.value); }); remoteStore.on('delete', async (payload) => { - if (await this.localStore.has(payload.key)) { - await this.localStore.delete(payload.key); + if (await this.primaryStore.has(payload.key)) { + await this.primaryStore.delete(payload.key); } }); remoteStore.on('clear', async () => { - await this.localStore.clear(); + await this.primaryStore.clear(); }); // Sync with the new remote - await this.syncWithRemote(remoteStore); - } - - private async syncWithRemote(remoteStore: BaseStore): Promise { - const remoteKeys = await remoteStore.keys(); - const localKeys = await this.localStore.keys(); - - const missingLocalKeys = remoteKeys.filter( - (key) => !localKeys.includes(key), - ); - const missingRemoteKeys = localKeys.filter( - (key) => !remoteKeys.includes(key), - ); - - await Promise.all([ - ...missingLocalKeys.map(async (key) => { - const value = await remoteStore.get(key); - await this.localStore.set(key, value); - }), - ...missingRemoteKeys.map(async (key) => { - const value = await this.localStore.get(key); - await remoteStore.set(key, value); - }), - ]); + await this.syncStore(remoteStore); } async sync(): Promise { // Sync all remote stores in parallel await Promise.all( - this.remoteStores.map(async (remoteStore) => - this.syncWithRemote(remoteStore), - ), + this.stores.map(async (remoteStore) => this.syncStore(remoteStore)), ); } + + private async syncStore(store: BaseStore): Promise { + const storeKeys = await store.keys(); + const primaryStoreKeys = await this.primaryStore.keys(); + + const missingLocalKeys = storeKeys.filter( + (key) => !primaryStoreKeys.includes(key), + ); + const missingRemoteKeys = primaryStoreKeys.filter( + (key) => !storeKeys.includes(key), + ); + + await Promise.all([ + ...missingLocalKeys.map(async (key) => { + const value = await store.get(key); + await this.set(key, value); + }), + ...missingRemoteKeys.map(async (key) => { + const value = await this.primaryStore.get(key); + await store.set(key, value); + }), + ]); + } } diff --git a/packages/stack/src/storage/store-indexeddb.ts b/packages/stack/src/storage/store-indexeddb.ts index f4444f6..9191ea1 100644 --- a/packages/stack/src/storage/store-indexeddb.ts +++ b/packages/stack/src/storage/store-indexeddb.ts @@ -3,25 +3,55 @@ import { StoreInMemorySynced } from './store-inmemory-synced.js'; import { Encrypter } from '@xocash/primitives'; -// TODO: Only use pack/unpack on encrypted content. -// Unsure why you were doing it on non-encrypted content (as it's not needed for IDB). -// Did you bump into an object that couldn't be structuredClone'd? +// Only use pack/unpack on encrypted content. +// This ensures consistent handling of data whether encrypted or not. import { pack, unpack } from 'msgpackr'; import { openDB, deleteDB, IDBPDatabase } from 'idb'; export class StorageIndexedDB extends BaseStorage { - private constructor( - private db: IDBPDatabase, - private dbName: string, - ) { + private db: IDBPDatabase; + private dbName: string; + private versionChangePromise: Promise | null = null; + private activeStores: Set = new Set(); // Track all active stores + + private constructor(db: IDBPDatabase, dbName: string) { super(); + this.db = db; + this.dbName = dbName; + + // Listen for version change events + this.db.addEventListener('versionchange', () => { + this.db.close(); + }); + } + + // Wait for any ongoing version changes to complete + private async waitForVersionChange(): Promise { + if (this.versionChangePromise) { + await this.versionChangePromise; + } } public static async createOrOpen(dbName: string) { try { // Check if database exists by attempting to open it at current version - const existingDb = await openDB(dbName, undefined); + const existingDb = await openDB(dbName, undefined, { + blocked() { + // Handle case where the database is blocked + console.warn(`Database ${dbName} is blocked, waiting...`); + }, + blocking() { + // When this connection is blocking others + console.warn(`This connection is blocking others for ${dbName}`); + }, + terminated() { + // When the database connection is terminated unexpectedly + console.error( + `Database connection to ${dbName} was terminated unexpectedly`, + ); + }, + }); return new StorageIndexedDB(existingDb, dbName); } catch (error) { // If database doesn't exist, create it at version 1 @@ -35,33 +65,67 @@ export class StorageIndexedDB extends BaseStorage { } async listStores() { + await this.waitForVersionChange(); return Array.from(this.db.objectStoreNames); } async createStore(storeName: string, opts: StorageOpts = {}) { + await this.waitForVersionChange(); + if (!this.db.objectStoreNames.contains(storeName)) { const currentVersion = this.db.version; - this.db.close(); - const db = await openDB(this.dbName, currentVersion + 1, { - upgrade(db) { - db.createObjectStore(storeName); - }, - }); - this.db = db; + // Set up the version change promise + this.versionChangePromise = (async () => { + try { + const newDb = await openDB(this.dbName, currentVersion + 1, { + upgrade(db) { + db.createObjectStore(storeName); + }, + blocked() { + console.warn(`Database upgrade is blocked, waiting...`); + }, + blocking() { + console.warn(`This connection is blocking upgrades`); + }, + terminated() { + console.error( + `Database connection was terminated unexpectedly during upgrade`, + ); + }, + }); + + // Update the database reference + this.db = newDb; + + // Update db references in all active stores + for (const store of this.activeStores) { + store.updateDatabaseReference(newDb); + } + } finally { + this.versionChangePromise = null; + } + })(); + + // Wait for the version change to complete + await this.versionChangePromise; } const store = await StoreIndexedDB.create( this.db, storeName, opts.encryption, + this, // Pass a reference to the parent storage ); + this.activeStores.add(store); // Track this store return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store; } async getStore(storeName: string, opts: StorageOpts = {}) { try { + await this.waitForVersionChange(); + if (!this.db.objectStoreNames.contains(storeName)) { return null; } @@ -70,9 +134,13 @@ export class StorageIndexedDB extends BaseStorage { this.db, storeName, opts.encryption, + this, // Pass reference to parent ); - return opts.syncInMemory ? StoreInMemorySynced.create(store) : store; + this.activeStores.add(store); // Track this store + return opts.syncInMemory + ? await StoreInMemorySynced.create(store) + : store; } catch (error) { return null; } @@ -87,23 +155,60 @@ export class StorageIndexedDB extends BaseStorage { } async deleteStore(storeName: string) { + await this.waitForVersionChange(); + if (this.db.objectStoreNames.contains(storeName)) { const currentVersion = this.db.version; - this.db.close(); - const db = await openDB(this.dbName, currentVersion + 1, { - upgrade(db) { - db.deleteObjectStore(storeName); - }, - }); - this.db = db; + this.versionChangePromise = (async () => { + try { + const newDb = await openDB(this.dbName, currentVersion + 1, { + upgrade(db) { + db.deleteObjectStore(storeName); + }, + blocked() { + console.warn(`Database upgrade is blocked, waiting...`); + }, + blocking() { + console.warn(`This connection is blocking upgrades`); + }, + terminated() { + console.error( + `Database connection was terminated unexpectedly during upgrade`, + ); + }, + }); + + this.db = newDb; + + // Update db references in all active stores + for (const store of this.activeStores) { + store.updateDatabaseReference(newDb); + } + } finally { + this.versionChangePromise = null; + } + })(); + + await this.versionChangePromise; } } async deleteDatabase() { + // Remove all active stores before closing + this.activeStores.clear(); + + // Close current connection this.db.close(); + + // Delete the database await deleteDB(this.dbName); } + + // Method to remove store from tracking when it's closed + removeActiveStore(store: StoreIndexedDB) { + this.activeStores.delete(store); + } } /** @@ -114,26 +219,24 @@ export class StoreIndexedDB extends BaseStore { public db: IDBPDatabase, public storeName: string, public encryption?: Encrypter, + private parentStorage?: StorageIndexedDB, // Reference to parent storage ) { super(); + } - /* - this.broadcastChannel = new BroadcastChannel( - `StoreIndexedDB:${db.name}.${this.storeName}`, - ); - - this.broadcastChannel.onmessage = (message) => { - this.emit('set', message); - }; - */ + // Method to update the database reference + updateDatabaseReference(newDb: IDBPDatabase) { + this.db = newDb; } /** * Factory method to create a new instance of StoreIndexedDB. - * Provides a more convenient way to instantiate the store when working with promises. + * Provides a convenient way to instantiate the store when working with promises. * * @param db - An instance of an upgraded and opened IndexedDB database * @param storeName - The name of the object store within the database to use + * @param encryption - Optional encrypter for encrypting/decrypting data + * @param parentStorage - Reference to the parent StorageIndexedDB instance * * @example * ```typescript @@ -144,8 +247,9 @@ export class StoreIndexedDB extends BaseStore { db: IDBPDatabase, storeName: string, encryption?: Encrypter, + parentStorage?: StorageIndexedDB, ) { - return new this(db, storeName, encryption); + return new this(db, storeName, encryption, parentStorage); } async all() { @@ -173,6 +277,10 @@ export class StoreIndexedDB extends BaseStore { async get(key: string) { const encodedValue = await this.db.get(this.storeName, key); + if (!encodedValue) { + return undefined as unknown as T; + } + const decryptedValue = this.encryption ? this.encryption.decrypt(encodedValue) : encodedValue; @@ -221,7 +329,10 @@ export class StoreIndexedDB extends BaseStore { await Promise.all( Array.from(items).map(([key, value]) => { const encodedValue = pack(value); - return store.put(encodedValue, key); + const encryptedValue = this.encryption + ? this.encryption.encrypt(encodedValue) + : encodedValue; + return store.put(encryptedValue, key); }), ); @@ -245,6 +356,10 @@ export class StoreIndexedDB extends BaseStore { } async close() { - this.db.close(); + // Remove this store from parent's tracking + if (this.parentStorage) { + this.parentStorage.removeActiveStore(this); + } + // Note: We don't close the DB here as it's managed by StorageIndexedDB } } diff --git a/packages/stack/src/storage/store-localstorage.ts b/packages/stack/src/storage/store-localstorage.ts new file mode 100644 index 0000000..2f6e7ca --- /dev/null +++ b/packages/stack/src/storage/store-localstorage.ts @@ -0,0 +1,285 @@ +import { BaseStorage, BaseStore, StorageOpts } from './base-store.js'; +import { StoreInMemorySynced } from './store-inmemory-synced.js'; + +import { + Bytes, + Encrypter, + encodeExtendedJson, + decodeExtendedJson, +} from '@xocash/primitives'; + +export class StorageLocalStorage extends BaseStorage { + private dbName: string; + private activeStores: Set = new Set(); // Track all active stores + + private constructor(dbName: string) { + super(); + this.dbName = dbName; + } + + public static async createOrOpen(dbName: string) { + return new StorageLocalStorage(dbName); + } + + async listStores() { + const prefix = `${this.dbName}.`; + const stores = new Set(); + + // Iterate through localStorage to find all stores with our dbName prefix + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + const parts = key.split('.'); + if (parts.length >= 2) { + stores.add(parts[1]); // Extract store name + } + } + } + + return Array.from(stores); + } + + async createStore(storeName: string, opts: StorageOpts = {}) { + // No need to explicitly create a store in localStorage, just return an instance + const store = await StoreLocalStorage.create( + this.dbName, + storeName, + opts.encryption, + this, // Pass a reference to the parent storage + ); + + this.activeStores.add(store); // Track this store + return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store; + } + + async getStore(storeName: string, opts: StorageOpts = {}) { + try { + // Check if store exists by looking for any keys with this store's prefix + const storePrefix = `${this.dbName}.${storeName}.`; + let storeExists = false; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(storePrefix)) { + storeExists = true; + break; + } + } + + if (!storeExists) { + return null; + } + + const store = await StoreLocalStorage.create( + this.dbName, + storeName, + opts.encryption, + this, // Pass reference to parent + ); + + this.activeStores.add(store); // Track this store + return opts.syncInMemory + ? await StoreInMemorySynced.create(store) + : store; + } catch (_error) { + return null; + } + } + + async createOrGetStore(storeName: string, opts: StorageOpts = {}) { + const existing = await this.getStore(storeName, opts); + if (existing) { + return existing; + } + return this.createStore(storeName, opts); + } + + async deleteStore(storeName: string) { + const storePrefix = `${this.dbName}.${storeName}.`; + + // Collect all keys to remove + const keysToRemove: string[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(storePrefix)) { + keysToRemove.push(key); + } + } + + // Remove all keys for this store + keysToRemove.forEach((key) => localStorage.removeItem(key)); + } + + async deleteDatabase() { + // Remove all active stores before closing + this.activeStores.clear(); + + // Remove all keys with this dbName prefix + const prefix = `${this.dbName}.`; + const keysToRemove: string[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + keysToRemove.push(key); + } + } + + keysToRemove.forEach((key) => localStorage.removeItem(key)); + } + + // Method to remove store from tracking when it's closed + removeActiveStore(store: StoreLocalStorage) { + this.activeStores.delete(store); + } +} + +/** + * Implementation of BaseStore using LocalStorage as the storage backend. + */ +export class StoreLocalStorage extends BaseStore { + private keyPrefix: string; + + constructor( + public dbName: string, + public storeName: string, + public encryption?: Encrypter, + private parentStorage?: StorageLocalStorage, // Reference to parent storage + ) { + super(); + this.keyPrefix = `${dbName}.${storeName}.`; + } + + /** + * Factory method to create a new instance of StoreLocalStorage. + * Provides a convenient way to instantiate the store when working with promises. + * + * @param dbName - The name of the database + * @param storeName - The name of the store within the database + * @param encryption - Optional encrypter for encrypting/decrypting data + * @param parentStorage - Reference to the parent StorageLocalStorage instance + * + * @example + * ```typescript + * const store = await StoreLocalStorage.create('myDB', 'myStore'); + * ``` + */ + static async create( + dbName: string, + storeName: string, + encryption?: Encrypter, + parentStorage?: StorageLocalStorage, + ) { + return new this(dbName, storeName, encryption, parentStorage); + } + + async all() { + const result: { [key: string]: T } = {}; + + for (let i = 0; i < localStorage.length; i++) { + const fullKey = localStorage.key(i); + if (fullKey && fullKey.startsWith(this.keyPrefix)) { + const key = fullKey.substring(this.keyPrefix.length); + const value = await this.get(key); + if (value !== undefined) { + result[key] = value; + } + } + } + + return result; + } + + async keys() { + const keys: string[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const fullKey = localStorage.key(i); + if (fullKey && fullKey.startsWith(this.keyPrefix)) { + keys.push(fullKey.substring(this.keyPrefix.length)); + } + } + + return keys; + } + + async get(key: string) { + const fullKey = this.keyPrefix + key; + const encodedValue = localStorage.getItem(fullKey); + + if (!encodedValue) { + return undefined as unknown as T; + } + + try { + const decryptedValue = this.encryption + ? Bytes.from(await this.encryption.decrypt(encodedValue)).toUtf8() + : encodedValue; + + return decodeExtendedJson(decryptedValue) as T; + } catch (error) { + console.error(`Error retrieving value for key ${key}:`, error); + return undefined as unknown as T; + } + } + + async set(key: string, value: T) { + const fullKey = this.keyPrefix + key; + const encodedValue = encodeExtendedJson(value); + + const encryptedValue = this.encryption + ? Bytes.from(await this.encryption.encrypt(encodedValue)).toHex() + : encodedValue; + + // LocalStorage can only store strings, so we convert the binary data + localStorage.setItem(fullKey, encryptedValue); + this.emit('set', { key, value }); + } + + async delete(key: string) { + const fullKey = this.keyPrefix + key; + localStorage.removeItem(fullKey); + this.emit('delete', { key }); + } + + async batchGet(keys: string[]): Promise> { + const results = new Map(); + + for (const key of keys) { + const value = await this.get(key); + if (value !== undefined) { + results.set(key, value); + } + } + + return results; + } + + async batchSet(items: Map) { + for (const [key, value] of items) { + await this.set(key, value); + } + } + + async clear() { + const keys = await this.keys(); + for (const key of keys) { + await this.delete(key); + } + this.emit('clear', undefined); + } + + async has(key: string) { + const fullKey = this.keyPrefix + key; + return localStorage.getItem(fullKey) !== null; + } + + async close() { + // Remove this store from parent's tracking + if (this.parentStorage) { + this.parentStorage.removeActiveStore(this); + } + // No need to close anything with localStorage + } +} diff --git a/packages/stack/src/wallet/activities.ts b/packages/stack/src/wallet/activities.ts index 630f557..9f98d5c 100644 --- a/packages/stack/src/wallet/activities.ts +++ b/packages/stack/src/wallet/activities.ts @@ -1,31 +1,41 @@ import { type Activity } from '../storage/index.js'; +export type WalletActivities = + | WalletName + | WalletDescription + | WalletHDAddressGap + | TransactionFirstSeen + | TransactionMemo + | XOWalletColor + | XOTransactionCategory + | XOFrozenCoin; + //----------------------------------------------------------------------------- // Standardized Activities //----------------------------------------------------------------------------- export interface WalletName extends Activity { - name: 'WalletName'; + key: 'WalletName'; value: string; } export interface WalletDescription extends Activity { - name: 'WalletDescription'; + key: 'WalletDescription'; value: string; } export interface WalletHDAddressGap extends Activity { - name: 'WalletHDAddressGap'; + key: 'WalletHDAddressGap'; value: number; } export interface TransactionFirstSeen extends Activity { - name: 'TransactionFirstSeen'; + key: 'TransactionFirstSeen'; value: string; } export interface TransactionMemo extends Activity { - name: 'TransactionMemo'; + key: 'TransactionMemo'; value: string; } @@ -34,16 +44,16 @@ export interface TransactionMemo extends Activity { //----------------------------------------------------------------------------- export interface XOWalletColor extends Activity { - name: 'XOWalletColor'; + key: 'XOWalletColor'; value: string; } export interface XOTransactionCategory extends Activity { - name: 'XOTransactionCategory'; + key: 'XOTransactionCategory'; value: string; } export interface XOFrozenCoin extends Activity { - name: 'XOFrozenCoin'; + key: 'XOFrozenCoin'; value: boolean; } diff --git a/packages/stack/src/wallet/base-wallet.ts b/packages/stack/src/wallet/base-wallet.ts index f96b2aa..89189af 100644 --- a/packages/stack/src/wallet/base-wallet.ts +++ b/packages/stack/src/wallet/base-wallet.ts @@ -1,18 +1,21 @@ import { + WalletUTXOs, type InputTemplateBCH, type TransactionTemplate, type WalletAddresses, + type WalletBlockchain, type WalletTransactions, } from './types.js'; import { type BlockchainUTXOs } from '../blockchain/index.js'; -import { type Activities } from '../storage/index.js'; +import { type Activities, type BaseStore } from '../storage/index.js'; import { EventEmitter } from '../utils/index.js'; import { type TokenBalances, type Address } from '@xocash/primitives'; export type WalletEvents = { shouldMonitorUpdated: boolean; + addressesUpdated: WalletAddresses; transactionsUpdated: WalletTransactions; unspentsUpdated: BlockchainUTXOs; stateUpdated: string | null; @@ -30,6 +33,13 @@ export type WalletEvents = { export abstract class BaseWallet< WalletEventsType extends WalletEvents = WalletEvents, > extends EventEmitter { + static readonly deriveKey: (keyName: string) => Promise; + + // Dependencies. + abstract readonly activities: Activities; + abstract readonly blockchain: WalletBlockchain; + abstract readonly cache: BaseStore; + /** * Get the ID for this wallet. */ @@ -50,11 +60,6 @@ export abstract class BaseWallet< */ abstract getSigningKey(): Promise; - /** - * Get the activities implementation - */ - abstract getActivities(): Promise; - /** * Start monitoring this wallet for any transaction activity. */ @@ -109,7 +114,7 @@ export abstract class BaseWallet< * May as well just getUnspentDirectives() from the local state instead of fetch. * Likewise for balances. */ - abstract getUnspentDirectives(): Promise>; + abstract getUnspentDirectives(): Promise; /** * Get the satoshi balance of this wallet. diff --git a/packages/stack/src/wallet/types.ts b/packages/stack/src/wallet/types.ts index bfbc74c..d656b36 100644 --- a/packages/stack/src/wallet/types.ts +++ b/packages/stack/src/wallet/types.ts @@ -45,6 +45,8 @@ export type WalletUTXO = { >; }; +export type WalletUTXOs = ExtMap; + export type TransactionTemplate = TransactionTemplateFixed; export type FilterAndSortCallback = () => Promise>; diff --git a/packages/stack/src/wallet/wallet-hd.ts b/packages/stack/src/wallet/wallet-hd.ts index 7c647f4..12a043c 100644 --- a/packages/stack/src/wallet/wallet-hd.ts +++ b/packages/stack/src/wallet/wallet-hd.ts @@ -59,22 +59,24 @@ export type WalletHDGenesisData = { export class WalletHD extends BaseWallet { static async from( this: T, - services: WalletDependencies, + deps: WalletDependencies, genesisData: WalletHDGenesisData, ) { - // Assign in memory stores as defaults in case otherwise not provided. - const servicesFinal = { - ...{ - // Default to an in-memory-only Activity store. - activities: await Activities.fromStores(StoreInMemory.from()), - store: new StoreInMemory(), - cache: new StoreInMemory(), - }, - ...services, + // Assign defaults if not otherwise provideed. + const activities = deps.activities + ? deps.activities + : await Activities.from(); + const cache = deps.cache ? deps.cache : StoreInMemory.from(); + + // Assign our final dependencies. + const depsFinal = { + activities, + cache, + blockchain: deps.blockchain, }; // Return a new instance of WalletHD. - return new this(servicesFinal, genesisData) as InstanceType; + return new this(depsFinal, genesisData) as InstanceType; } // Dependencies. @@ -158,10 +160,6 @@ export class WalletHD extends BaseWallet { ); } - async getActivities(): Promise { - return this.activities; - } - async start() { // Get the address gap. // TODO: This should come from activities. @@ -263,7 +261,7 @@ export class WalletHD extends BaseWallet { const unspentDirectives = await Promise.all(fetchPromises); // Flatten them. - const unspentsFlattened = unspentDirectives.flat(); + const unspentsFlattened = ExtMap.flatten(unspentDirectives); // Return the unspents. return unspentsFlattened; @@ -358,7 +356,7 @@ export class WalletHD extends BaseWallet { // Build the transaction. const tx = TransactionBuilder.build( txTemplateFinal, - unspents, + unspents.toArray(), changeLockingBytecode, ); @@ -398,6 +396,7 @@ export class WalletHD extends BaseWallet { // Create a child P2PKH wallet for this Private Key. const wallet = await WalletP2PKH.from( { + activities: this.activities, blockchain: this.blockchain, cache: this.cache, }, diff --git a/packages/stack/src/wallet/wallet-p2pkh.ts b/packages/stack/src/wallet/wallet-p2pkh.ts index fdf34c1..48e5e83 100644 --- a/packages/stack/src/wallet/wallet-p2pkh.ts +++ b/packages/stack/src/wallet/wallet-p2pkh.ts @@ -46,20 +46,27 @@ export type WalletP2PKHGenesisData = { export class WalletP2PKH extends BaseWallet { static async from( this: T, - dependencies: WalletDependencies, + deps: WalletDependencies, genesisData: WalletP2PKHGenesisData, ) { - // Assign in memory stores as defaults in case otherwise not provided. - const dependenciesFinal = { - ...{ - activities: await Activities.fromStores(StoreInMemory.from()), - cache: new StoreInMemory(), - }, - ...dependencies, + // Assign defaults if not otherwise provideed. + const activities = deps.activities + ? deps.activities + : await Activities.from(); + const cache = deps.cache ? deps.cache : StoreInMemory.from(); + + // Assign our final dependencies. + const depsFinal = { + activities, + cache, + blockchain: deps.blockchain, }; // Return a new instance of Wallet P2PKH. - return new this(dependenciesFinal, genesisData) as InstanceType; + const wallet = new this(depsFinal, genesisData) as InstanceType; + + // Return the wallet instance. + return wallet; } static fromBytes( @@ -188,10 +195,6 @@ export class WalletP2PKH extends BaseWallet { ); } - async getActivities(): Promise { - return this.activities; - } - async start() { // Do nothing if already started. if (this.isStarted) { @@ -314,23 +317,21 @@ export class WalletP2PKH extends BaseWallet { const compilerP2PKH = walletTemplateToCompilerBCH(walletTemplateP2pkhNonHd); // Transform our inputs into unlocking directives. - const inputDirectives = unspentOutputs - .map((blockchainUTXO) => ({ - outpointIndex: blockchainUTXO.utxo.outpoint.outpointIndex, - outpointTransactionHash: - blockchainUTXO.utxo.outpoint.outpointTransactionHash, - sequenceNumber: 0, - unlockingBytecode: { - compiler: compilerP2PKH, - data: { - keys: { privateKeys: { key: this.privateKey.toBytes() } }, - }, - script: 'unlock', - token: blockchainUTXO.utxo.output.token, - valueSatoshis: blockchainUTXO.utxo.output.valueSatoshis, + const inputDirectives = unspentOutputs.map((blockchainUTXO) => ({ + outpointIndex: blockchainUTXO.utxo.outpoint.outpointIndex, + outpointTransactionHash: + blockchainUTXO.utxo.outpoint.outpointTransactionHash, + sequenceNumber: 0, + unlockingBytecode: { + compiler: compilerP2PKH, + data: { + keys: { privateKeys: { key: this.privateKey.toBytes() } }, }, - })) - .toArray(); + script: 'unlock', + token: blockchainUTXO.utxo.output.token, + valueSatoshis: blockchainUTXO.utxo.output.valueSatoshis, + }, + })); // Return the directives. return inputDirectives; @@ -397,7 +398,7 @@ export class WalletP2PKH extends BaseWallet { // Build the transaction. const tx = TransactionBuilder.build( txTemplateFinal, - unspents, + unspents.toArray(), changeLockingBytecode, ); diff --git a/packages/stack/tests/wallet/activities.spec.ts b/packages/stack/tests/wallet/activities.spec.ts index 796d39b..3008668 100644 --- a/packages/stack/tests/wallet/activities.spec.ts +++ b/packages/stack/tests/wallet/activities.spec.ts @@ -2,16 +2,15 @@ import { describe, test, expect, beforeEach } from 'vitest'; import { Activities, StoreInMemory } from '../../src/storage'; describe('Activities', () => { - let store: StoreInMemory; let activities: Activities; beforeEach(async () => { - activities = await Activities.fromStores(StoreInMemory.from()); + activities = await Activities.from(); }); test('should add activity with auto-generated timestamp', async () => { const activity = { - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'Test Wallet', }; @@ -28,7 +27,7 @@ describe('Activities', () => { const timestamp = BigInt(1234567890000); const activity = { timestamp, - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'Test Wallet', }; @@ -45,14 +44,14 @@ describe('Activities', () => { await activities.add({ timestamp: oldTimestamp, - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'Old Name', }); await activities.add({ timestamp: newTimestamp, - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'New Name', }); @@ -73,14 +72,14 @@ describe('Activities', () => { await activities.add({ timestamp: oldTimestamp, - name: 'TransactionFirstSeen', + key: 'TransactionFirstSeen', entityId: 'tx1', value: 'First Value', }); await activities.add({ timestamp: newTimestamp, - name: 'TransactionFirstSeen', + key: 'TransactionFirstSeen', entityId: 'tx1', value: 'Second Value', }); @@ -100,14 +99,14 @@ describe('Activities', () => { await activities.add({ timestamp: BigInt(1000), - name: 'CustomType', + key: 'CustomType', entityId: 'custom1', value: 50, }); await activities.add({ timestamp: BigInt(2000), - name: 'CustomType', + key: 'CustomType', entityId: 'custom1', value: 30, }); @@ -121,19 +120,19 @@ describe('Activities', () => { const testActivities = [ { timestamp: BigInt(3000), - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'Name 3', }, { timestamp: BigInt(1000), - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'Name 1', }, { timestamp: BigInt(2000), - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'Name 2', }, @@ -151,14 +150,14 @@ describe('Activities', () => { test('should handle multiple activity types independently', async () => { await activities.add({ timestamp: BigInt(1000), - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'My Wallet', }); await activities.add({ timestamp: BigInt(2000), - name: 'WalletDescription', + key: 'WalletDescription', entityId: 'wallet1', value: 'My Description', }); @@ -172,7 +171,7 @@ describe('Activities', () => { test('should update snapshot immediately when activity is added', async () => { const activity = { timestamp: BigInt(1000), - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'Test Wallet', }; @@ -186,7 +185,7 @@ describe('Activities', () => { test('should handle missing timestamp gracefully', async () => { const activity = { - name: 'WalletName', + key: 'WalletName', entityId: 'wallet1', value: 'Test Wallet', };