XO wallet integration

This commit is contained in:
Jim Hamill
2025-05-05 03:19:47 +00:00
parent f9fe792542
commit e0a554e897
14 changed files with 745 additions and 213 deletions
+20 -1
View File
@@ -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<Transaction>,
): Array<Transaction> {
const transactionMap = transactions.reduce(
@@ -139,6 +140,24 @@ export class ExtMap<T> extends Map<PropertyKey, T> {
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<T> {
const keys = Array.from(this.keys());
const slicedKeys = keys.slice(start, end);
const result = new ExtMap<T>();
for (const key of slicedKeys) {
result.set(key, this.get(key) as T);
}
return result;
}
/**
* Converts the collection to a plain object
*/
+1
View File
@@ -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';
+54
View File
@@ -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;
});
}
+1
View File
@@ -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';
+84 -45
View File
@@ -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<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// 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<ActivitiesEvents> {
// 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<string, PolicyFunction>();
private storeKeyMap = new Map<string, string>();
@@ -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<BaseStore>) {
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<BaseStore>,
): Promise<Activities> {
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<T extends Optional<Activity, 'timestamp'>>(
activity: T,
public async add<T extends Activity>(
activity: Optional<T, 'entityId' | 'timestamp'>,
): Promise<Activity> {
// 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<T extends Activity>(
activity: Pick<T, 'key' | 'entityId'>,
) {
// 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<T extends Activity>(type: T['name'], id: string): T | undefined {
const activity = this.snapshot[type]?.[id];
public get<T extends Activity>(
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<T extends Activity>(type: T['name']): T[] {
public getByType<T extends Activity>(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
+54 -52
View File
@@ -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<BaseStore> = [],
stores: Array<BaseStore> = [],
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<BaseStore> = [],
private stores: Array<BaseStore> = [],
private primaryStore: BaseStore,
) {
super();
}
async all<T>() {
return await this.localStore.all<T>();
return await this.primaryStore.all<T>();
}
async keys() {
return await this.localStore.keys();
return await this.primaryStore.keys();
}
async get<T>(key: string) {
return await this.localStore.get<T>(key);
return await this.primaryStore.get<T>(key);
}
async set<T>(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<void> {
this.remoteStores.push(remoteStore);
async addStore(remoteStore: BaseStore): Promise<void> {
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<void> {
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<void> {
// 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<void> {
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);
}),
]);
}
}
+151 -36
View File
@@ -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<void> | null = null;
private activeStores: Set<StoreIndexedDB> = 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<void> {
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<T>() {
@@ -173,6 +277,10 @@ export class StoreIndexedDB extends BaseStore {
async get<T>(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
}
}
@@ -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<StoreLocalStorage> = 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<string>();
// 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<T>() {
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<T>(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<T>(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<T>(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<T>(keys: string[]): Promise<Map<string, T>> {
const results = new Map<string, T>();
for (const key of keys) {
const value = await this.get<T>(key);
if (value !== undefined) {
results.set(key, value);
}
}
return results;
}
async batchSet<T>(items: Map<string, T>) {
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
}
}
+18 -8
View File
@@ -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;
}
+12 -7
View File
@@ -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<WalletEventsType> {
static readonly deriveKey: (keyName: string) => Promise<Uint8Array>;
// 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<Uint8Array>;
/**
* Get the activities implementation
*/
abstract getActivities(): Promise<Activities>;
/**
* 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<Array<InputTemplateBCH>>;
abstract getUnspentDirectives(): Promise<WalletUTXOs>;
/**
* Get the satoshi balance of this wallet.
+2
View File
@@ -45,6 +45,8 @@ export type WalletUTXO = {
>;
};
export type WalletUTXOs = ExtMap<WalletUTXO>;
export type TransactionTemplate = TransactionTemplateFixed<CompilerBCH>;
export type FilterAndSortCallback = () => Promise<Array<InputTemplateBCH>>;
+16 -17
View File
@@ -59,22 +59,24 @@ export type WalletHDGenesisData = {
export class WalletHD extends BaseWallet {
static async from<T extends typeof WalletHD>(
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<T>;
return new this(depsFinal, genesisData) as InstanceType<T>;
}
// Dependencies.
@@ -158,10 +160,6 @@ export class WalletHD extends BaseWallet {
);
}
async getActivities(): Promise<Activities> {
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,
},
+31 -30
View File
@@ -46,20 +46,27 @@ export type WalletP2PKHGenesisData = {
export class WalletP2PKH extends BaseWallet {
static async from<T extends typeof WalletP2PKH>(
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<T>;
const wallet = new this(depsFinal, genesisData) as InstanceType<T>;
// Return the wallet instance.
return wallet;
}
static fromBytes<T extends typeof WalletP2PKH>(
@@ -188,10 +195,6 @@ export class WalletP2PKH extends BaseWallet {
);
}
async getActivities(): Promise<Activities> {
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,
);
+16 -17
View File
@@ -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',
};