Merge branch 'xo_wallet_integration' into 'development'

XO wallet integration

See merge request GeneralProtocols/xo/stack!77
This commit is contained in:
Jim Hamill
2025-05-05 03:19:47 +00:00
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'; 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. * Sort transactions by the order in which they should be broadcast.
*/ */
export function sortTransactions( export function sortTransactionsByDependencies(
transactions: Array<Transaction>, transactions: Array<Transaction>,
): Array<Transaction> { ): Array<Transaction> {
const transactionMap = transactions.reduce( const transactionMap = transactions.reduce(
@@ -139,6 +140,24 @@ export class ExtMap<T> extends Map<PropertyKey, T> {
return sorted; 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 * 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 './blockchain-inmemory.js';
export * from './traits.js'; export * from './traits.js';
export * from './types.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. // These exports will only work in a browser environment.
export * from './storage/store-indexeddb.js'; 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 { BaseStore } from './base-store.js';
import { StoreComposite } from './store-composite.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. // 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. // 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 // Base Activity interface
export interface Activity { export interface Activity {
entityId: string; /** Key (Type) of this activity. */
name: string; key: string;
/** Value for this activity. */
value: unknown; value: unknown;
/** Timestamp for this activity. */
timestamp: bigint; timestamp: bigint;
/** Entity ID may refer to a transaction id or other specific identifier. */
entityId?: string;
} }
// Define the snapshot structure // Define the snapshot structure
@@ -28,6 +35,10 @@ export type PolicyFunction = (
newActivity: Activity, newActivity: Activity,
) => Activity; ) => Activity;
export type ActivitiesEvents = {
[key: `updated:${string}`]: { activity: Activity };
};
/** /**
* Implements an event sourcing pattern for managing application state * Implements an event sourcing pattern for managing application state
* with a simplified interface focused on the current snapshot. * with a simplified interface focused on the current snapshot.
@@ -51,7 +62,14 @@ export type PolicyFunction = (
* const savedActivity = await activities.add(walletName); * 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 = {}; public snapshot: SnapshotByType = {};
private policies = new Map<string, PolicyFunction>(); private policies = new Map<string, PolicyFunction>();
private storeKeyMap = new Map<string, string>(); private storeKeyMap = new Map<string, string>();
@@ -59,27 +77,20 @@ export class Activities {
/** /**
* Create an Activities instance from the given composite store. * 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); const activities = new Activities(compositeStore);
await activities.compileSnapshot(); await activities.compileSnapshot();
return activities; return activities;
} }
/** private constructor(store: StoreComposite) {
* Create an Activities instance from the given primary and secondary stores super();
*/
static async fromStores(
primaryStore: BaseStore,
secondaryStores?: Array<BaseStore>,
): Promise<Activities> {
const compositeStore = await StoreComposite.from(
primaryStore,
secondaryStores,
);
return Activities.from(compositeStore);
}
private constructor(public store: StoreComposite) { // Assign our dependencies.
this.store = store;
// Register callbacks.
this.store.on( this.store.on(
'set', 'set',
async (payload: { key: string; value: Activity | null }) => { 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 * Add a new activity to the store
* @param activity The activity to add (id and timestamp are optional) * @param activity The activity to add (id and timestamp are optional)
* @returns The stored activity with all fields populated * @returns The stored activity with all fields populated
*/ */
public async add<T extends Optional<Activity, 'timestamp'>>( public async add<T extends Activity>(
activity: T, activity: Optional<T, 'entityId' | 'timestamp'>,
): Promise<Activity> { ): Promise<Activity> {
// Generate timestamp if not provided // Generate timestamp if not provided
const timestamp = activity.timestamp ?? BigInt(Date.now()); const timestamp = activity.timestamp ?? BigInt(Date.now());
// Generate a UUID based on the timestamp // 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 // If this doesn't reference a specific entity id, put it at root level (empty string: "")
const id = activity.entityId ?? key; const id = activity.entityId ?? '';
const completeActivity: Activity = { const completeActivity: Activity = {
name: activity.name, key: activity.key,
value: activity.value, value: activity.value,
entityId: id, entityId: id,
timestamp, timestamp,
}; };
await this.store.set(key, completeActivity); await this.store.set(uuid, completeActivity);
return 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 * Get a specific activity by type and id with type inference
*/ */
public get<T extends Activity>(type: T['name'], id: string): T | undefined { public get<T extends Activity>(
const activity = this.snapshot[type]?.[id]; type: T['key'],
entityId: string = '',
): T | undefined {
const activity = this.snapshot[type]?.[entityId];
return activity as T | undefined; return activity as T | undefined;
} }
/** /**
* Get all activities of a specific type * 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]; const activities = this.snapshot[type];
if (!activities) { if (!activities) {
return []; return [];
@@ -157,20 +199,14 @@ export class Activities {
if (!activity) { if (!activity) {
return; return;
} }
// Ensure timestamp exists by extracting from UUID if needed // Ensure timestamp exists by extracting from UUID if needed
if (!activity.timestamp) { if (!activity.timestamp) {
activity.timestamp = UUIDV7.from(storeKey).getTimestamp(); activity.timestamp = UUIDV7.from(storeKey).getTimestamp();
} }
// Ensure ID exists
if (!activity.entityId) {
activity.entityId = storeKey;
}
const snapshotKey = this.getSnapshotKey(activity); const snapshotKey = this.getSnapshotKey(activity);
const existingActivity = this.getActivityFromSnapshot( const existingActivity = this.getActivityFromSnapshot(
activity.name, activity.key,
activity.entityId, activity.entityId,
); );
@@ -185,12 +221,15 @@ export class Activities {
const oldStoreKey = this.storeKeyMap.get(snapshotKey); const oldStoreKey = this.storeKeyMap.get(snapshotKey);
if (oldStoreKey && oldStoreKey !== storeKey) { if (oldStoreKey && oldStoreKey !== storeKey) {
// Delete the old activity from the store // Delete the old activity from the store
await this.store.set(oldStoreKey, null); // await this.store.set(oldStoreKey, null);
} }
this.storeKeyMap.set(snapshotKey, storeKey); this.storeKeyMap.set(snapshotKey, storeKey);
// Emit the updated activity.
this.emit(`updated:${activity.key}`, { activity });
} else if (storeKey !== this.storeKeyMap.get(snapshotKey)) { } else if (storeKey !== this.storeKeyMap.get(snapshotKey)) {
// If we kept the existing activity, delete the new activity from the store // 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( private getActivityFromSnapshot(
type: string, type: string,
id: string, entityId: string = '',
): Activity | undefined { ): Activity | undefined {
return this.snapshot[type]?.[id]; return this.snapshot[type]?.[entityId];
} }
/** /**
* Set an activity in the snapshot, creating necessary object structure * Set an activity in the snapshot, creating necessary object structure
*/ */
private setActivityInSnapshot(activity: Activity): void { private setActivityInSnapshot(activity: Activity): void {
if (!this.snapshot[activity.name]) { if (!this.snapshot[activity.key]) {
this.snapshot[activity.name] = {}; 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 * Get the snapshot key for an activity
*/ */
private getSnapshotKey(activity: Activity): string { 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, existingActivity: Activity | undefined,
newActivity: Activity, newActivity: Activity,
): Activity { ): Activity {
const policy = this.policies.get(newActivity.name); const policy = this.policies.get(newActivity.key);
if (!policy) { if (!policy) {
// Default policy: compare timestamps // Default policy: compare timestamps
+53 -51
View File
@@ -1,4 +1,5 @@
import { BaseStore } from './base-store.js'; import { BaseStore } from './base-store.js';
import { StoreInMemory } from './store-inmemory.js';
/** /**
* A store that accepts multiple stores and syncs them by key. * 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 { export class StoreComposite extends BaseStore {
static async from( static async from(
localStore: BaseStore, stores: Array<BaseStore> = [],
remoteStores: Array<BaseStore> = [], primaryStore: BaseStore = StoreInMemory.from(),
) { ) {
const compositeStore = new this(localStore, remoteStores); const compositeStore = new this(stores, primaryStore);
await compositeStore.sync(); await compositeStore.sync();
return compositeStore; return compositeStore;
} }
constructor( constructor(
private localStore: BaseStore, private stores: Array<BaseStore> = [],
private remoteStores: Array<BaseStore> = [], private primaryStore: BaseStore,
) { ) {
super(); super();
} }
async all<T>() { async all<T>() {
return await this.localStore.all<T>(); return await this.primaryStore.all<T>();
} }
async keys() { async keys() {
return await this.localStore.keys(); return await this.primaryStore.keys();
} }
async get<T>(key: string) { 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) { async set<T>(key: string, value: T) {
await this.localStore.set(key, value); await this.primaryStore.set(key, value);
this.emit('set', { key, value }); this.emit('set', { key, value });
} }
async delete(key: string) { async delete(key: string) {
await this.localStore.delete(key); await this.primaryStore.delete(key);
this.emit('delete', { key }); this.emit('delete', { key });
} }
async clear() { async clear() {
await this.localStore.clear(); await this.primaryStore.clear();
this.emit('clear', undefined); this.emit('clear', undefined);
} }
async has(key: string) { async has(key: string) {
return await this.localStore.has(key); return await this.primaryStore.has(key);
} }
async close() { async close() {
await Promise.all([ await Promise.all([
this.localStore.close(), this.primaryStore.close(),
...this.remoteStores.map((store) => store.close()), ...this.stores.map((store) => store.close()),
]); ]);
} }
/** /**
* Add a new remote store and set up synchronization * Add a new remote store and set up synchronization
*/ */
public async addRemoteStore(remoteStore: BaseStore): Promise<void> { async addStore(remoteStore: BaseStore): Promise<void> {
this.remoteStores.push(remoteStore); this.stores.push(remoteStore);
// Propagate existing local events to the new remote // 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); await remoteStore.set(payload.key, payload.value);
}); });
this.localStore.on('delete', async (payload) => { this.primaryStore.on('delete', async (payload) => {
if (await remoteStore.has(payload.key)) {
await remoteStore.delete(payload.key); await remoteStore.delete(payload.key);
}
}); });
this.localStore.on('clear', async () => { this.primaryStore.on('clear', async () => {
await remoteStore.clear(); await remoteStore.clear();
}); });
// Set up remote to local propagation // Set up remote to local propagation
remoteStore.on('set', async (payload) => { remoteStore.on('set', async (payload) => {
if (await this.localStore.has(payload.key)) return; if (await this.primaryStore.has(payload.key)) return;
await this.localStore.set(payload.key, payload.value); await this.primaryStore.set(payload.key, payload.value);
}); });
remoteStore.on('delete', async (payload) => { remoteStore.on('delete', async (payload) => {
if (await this.localStore.has(payload.key)) { if (await this.primaryStore.has(payload.key)) {
await this.localStore.delete(payload.key); await this.primaryStore.delete(payload.key);
} }
}); });
remoteStore.on('clear', async () => { remoteStore.on('clear', async () => {
await this.localStore.clear(); await this.primaryStore.clear();
}); });
// Sync with the new remote // Sync with the new remote
await this.syncWithRemote(remoteStore); await this.syncStore(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);
}),
]);
} }
async sync(): Promise<void> { async sync(): Promise<void> {
// Sync all remote stores in parallel // Sync all remote stores in parallel
await Promise.all( await Promise.all(
this.remoteStores.map(async (remoteStore) => this.stores.map(async (remoteStore) => this.syncStore(remoteStore)),
this.syncWithRemote(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);
}),
]);
}
} }
+143 -28
View File
@@ -3,25 +3,55 @@ import { StoreInMemorySynced } from './store-inmemory-synced.js';
import { Encrypter } from '@xocash/primitives'; import { Encrypter } from '@xocash/primitives';
// TODO: Only use pack/unpack on encrypted content. // Only use pack/unpack on encrypted content.
// Unsure why you were doing it on non-encrypted content (as it's not needed for IDB). // This ensures consistent handling of data whether encrypted or not.
// Did you bump into an object that couldn't be structuredClone'd?
import { pack, unpack } from 'msgpackr'; import { pack, unpack } from 'msgpackr';
import { openDB, deleteDB, IDBPDatabase } from 'idb'; import { openDB, deleteDB, IDBPDatabase } from 'idb';
export class StorageIndexedDB extends BaseStorage { export class StorageIndexedDB extends BaseStorage {
private constructor( private db: IDBPDatabase;
private db: IDBPDatabase, private dbName: string;
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(); 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) { public static async createOrOpen(dbName: string) {
try { try {
// Check if database exists by attempting to open it at current version // 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); return new StorageIndexedDB(existingDb, dbName);
} catch (error) { } catch (error) {
// If database doesn't exist, create it at version 1 // If database doesn't exist, create it at version 1
@@ -35,33 +65,67 @@ export class StorageIndexedDB extends BaseStorage {
} }
async listStores() { async listStores() {
await this.waitForVersionChange();
return Array.from(this.db.objectStoreNames); return Array.from(this.db.objectStoreNames);
} }
async createStore(storeName: string, opts: StorageOpts = {}) { async createStore(storeName: string, opts: StorageOpts = {}) {
await this.waitForVersionChange();
if (!this.db.objectStoreNames.contains(storeName)) { if (!this.db.objectStoreNames.contains(storeName)) {
const currentVersion = this.db.version; const currentVersion = this.db.version;
this.db.close();
const db = await openDB(this.dbName, currentVersion + 1, { // Set up the version change promise
this.versionChangePromise = (async () => {
try {
const newDb = await openDB(this.dbName, currentVersion + 1, {
upgrade(db) { upgrade(db) {
db.createObjectStore(storeName); 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`,
);
},
}); });
this.db = db;
// 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( const store = await StoreIndexedDB.create(
this.db, this.db,
storeName, storeName,
opts.encryption, 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; return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store;
} }
async getStore(storeName: string, opts: StorageOpts = {}) { async getStore(storeName: string, opts: StorageOpts = {}) {
try { try {
await this.waitForVersionChange();
if (!this.db.objectStoreNames.contains(storeName)) { if (!this.db.objectStoreNames.contains(storeName)) {
return null; return null;
} }
@@ -70,9 +134,13 @@ export class StorageIndexedDB extends BaseStorage {
this.db, this.db,
storeName, storeName,
opts.encryption, 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) { } catch (error) {
return null; return null;
} }
@@ -87,23 +155,60 @@ export class StorageIndexedDB extends BaseStorage {
} }
async deleteStore(storeName: string) { async deleteStore(storeName: string) {
await this.waitForVersionChange();
if (this.db.objectStoreNames.contains(storeName)) { if (this.db.objectStoreNames.contains(storeName)) {
const currentVersion = this.db.version; const currentVersion = this.db.version;
this.db.close();
const db = await openDB(this.dbName, currentVersion + 1, { this.versionChangePromise = (async () => {
try {
const newDb = await openDB(this.dbName, currentVersion + 1, {
upgrade(db) { upgrade(db) {
db.deleteObjectStore(storeName); 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 = db;
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() { async deleteDatabase() {
// Remove all active stores before closing
this.activeStores.clear();
// Close current connection
this.db.close(); this.db.close();
// Delete the database
await deleteDB(this.dbName); 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 db: IDBPDatabase,
public storeName: string, public storeName: string,
public encryption?: Encrypter, public encryption?: Encrypter,
private parentStorage?: StorageIndexedDB, // Reference to parent storage
) { ) {
super(); super();
}
/* // Method to update the database reference
this.broadcastChannel = new BroadcastChannel( updateDatabaseReference(newDb: IDBPDatabase) {
`StoreIndexedDB:${db.name}.${this.storeName}`, this.db = newDb;
);
this.broadcastChannel.onmessage = (message) => {
this.emit('set', message);
};
*/
} }
/** /**
* Factory method to create a new instance of StoreIndexedDB. * 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 db - An instance of an upgraded and opened IndexedDB database
* @param storeName - The name of the object store within the database to use * @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 * @example
* ```typescript * ```typescript
@@ -144,8 +247,9 @@ export class StoreIndexedDB extends BaseStore {
db: IDBPDatabase, db: IDBPDatabase,
storeName: string, storeName: string,
encryption?: Encrypter, encryption?: Encrypter,
parentStorage?: StorageIndexedDB,
) { ) {
return new this(db, storeName, encryption); return new this(db, storeName, encryption, parentStorage);
} }
async all<T>() { async all<T>() {
@@ -173,6 +277,10 @@ export class StoreIndexedDB extends BaseStore {
async get<T>(key: string) { async get<T>(key: string) {
const encodedValue = await this.db.get(this.storeName, key); const encodedValue = await this.db.get(this.storeName, key);
if (!encodedValue) {
return undefined as unknown as T;
}
const decryptedValue = this.encryption const decryptedValue = this.encryption
? this.encryption.decrypt(encodedValue) ? this.encryption.decrypt(encodedValue)
: encodedValue; : encodedValue;
@@ -221,7 +329,10 @@ export class StoreIndexedDB extends BaseStore {
await Promise.all( await Promise.all(
Array.from(items).map(([key, value]) => { Array.from(items).map(([key, value]) => {
const encodedValue = pack(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() { 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'; import { type Activity } from '../storage/index.js';
export type WalletActivities =
| WalletName
| WalletDescription
| WalletHDAddressGap
| TransactionFirstSeen
| TransactionMemo
| XOWalletColor
| XOTransactionCategory
| XOFrozenCoin;
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Standardized Activities // Standardized Activities
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
export interface WalletName extends Activity { export interface WalletName extends Activity {
name: 'WalletName'; key: 'WalletName';
value: string; value: string;
} }
export interface WalletDescription extends Activity { export interface WalletDescription extends Activity {
name: 'WalletDescription'; key: 'WalletDescription';
value: string; value: string;
} }
export interface WalletHDAddressGap extends Activity { export interface WalletHDAddressGap extends Activity {
name: 'WalletHDAddressGap'; key: 'WalletHDAddressGap';
value: number; value: number;
} }
export interface TransactionFirstSeen extends Activity { export interface TransactionFirstSeen extends Activity {
name: 'TransactionFirstSeen'; key: 'TransactionFirstSeen';
value: string; value: string;
} }
export interface TransactionMemo extends Activity { export interface TransactionMemo extends Activity {
name: 'TransactionMemo'; key: 'TransactionMemo';
value: string; value: string;
} }
@@ -34,16 +44,16 @@ export interface TransactionMemo extends Activity {
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
export interface XOWalletColor extends Activity { export interface XOWalletColor extends Activity {
name: 'XOWalletColor'; key: 'XOWalletColor';
value: string; value: string;
} }
export interface XOTransactionCategory extends Activity { export interface XOTransactionCategory extends Activity {
name: 'XOTransactionCategory'; key: 'XOTransactionCategory';
value: string; value: string;
} }
export interface XOFrozenCoin extends Activity { export interface XOFrozenCoin extends Activity {
name: 'XOFrozenCoin'; key: 'XOFrozenCoin';
value: boolean; value: boolean;
} }
+12 -7
View File
@@ -1,18 +1,21 @@
import { import {
WalletUTXOs,
type InputTemplateBCH, type InputTemplateBCH,
type TransactionTemplate, type TransactionTemplate,
type WalletAddresses, type WalletAddresses,
type WalletBlockchain,
type WalletTransactions, type WalletTransactions,
} from './types.js'; } from './types.js';
import { type BlockchainUTXOs } from '../blockchain/index.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 { EventEmitter } from '../utils/index.js';
import { type TokenBalances, type Address } from '@xocash/primitives'; import { type TokenBalances, type Address } from '@xocash/primitives';
export type WalletEvents = { export type WalletEvents = {
shouldMonitorUpdated: boolean; shouldMonitorUpdated: boolean;
addressesUpdated: WalletAddresses;
transactionsUpdated: WalletTransactions; transactionsUpdated: WalletTransactions;
unspentsUpdated: BlockchainUTXOs; unspentsUpdated: BlockchainUTXOs;
stateUpdated: string | null; stateUpdated: string | null;
@@ -30,6 +33,13 @@ export type WalletEvents = {
export abstract class BaseWallet< export abstract class BaseWallet<
WalletEventsType extends WalletEvents = WalletEvents, WalletEventsType extends WalletEvents = WalletEvents,
> extends EventEmitter<WalletEventsType> { > 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. * Get the ID for this wallet.
*/ */
@@ -50,11 +60,6 @@ export abstract class BaseWallet<
*/ */
abstract getSigningKey(): Promise<Uint8Array>; abstract getSigningKey(): Promise<Uint8Array>;
/**
* Get the activities implementation
*/
abstract getActivities(): Promise<Activities>;
/** /**
* Start monitoring this wallet for any transaction activity. * 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. * May as well just getUnspentDirectives() from the local state instead of fetch.
* Likewise for balances. * Likewise for balances.
*/ */
abstract getUnspentDirectives(): Promise<Array<InputTemplateBCH>>; abstract getUnspentDirectives(): Promise<WalletUTXOs>;
/** /**
* Get the satoshi balance of this wallet. * 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 TransactionTemplate = TransactionTemplateFixed<CompilerBCH>;
export type FilterAndSortCallback = () => Promise<Array<InputTemplateBCH>>; export type FilterAndSortCallback = () => Promise<Array<InputTemplateBCH>>;
+16 -17
View File
@@ -59,22 +59,24 @@ export type WalletHDGenesisData = {
export class WalletHD extends BaseWallet { export class WalletHD extends BaseWallet {
static async from<T extends typeof WalletHD>( static async from<T extends typeof WalletHD>(
this: T, this: T,
services: WalletDependencies, deps: WalletDependencies,
genesisData: WalletHDGenesisData, genesisData: WalletHDGenesisData,
) { ) {
// Assign in memory stores as defaults in case otherwise not provided. // Assign defaults if not otherwise provideed.
const servicesFinal = { const activities = deps.activities
...{ ? deps.activities
// Default to an in-memory-only Activity store. : await Activities.from();
activities: await Activities.fromStores(StoreInMemory.from()), const cache = deps.cache ? deps.cache : StoreInMemory.from();
store: new StoreInMemory(),
cache: new StoreInMemory(), // Assign our final dependencies.
}, const depsFinal = {
...services, activities,
cache,
blockchain: deps.blockchain,
}; };
// Return a new instance of WalletHD. // Return a new instance of WalletHD.
return new this(servicesFinal, genesisData) as InstanceType<T>; return new this(depsFinal, genesisData) as InstanceType<T>;
} }
// Dependencies. // Dependencies.
@@ -158,10 +160,6 @@ export class WalletHD extends BaseWallet {
); );
} }
async getActivities(): Promise<Activities> {
return this.activities;
}
async start() { async start() {
// Get the address gap. // Get the address gap.
// TODO: This should come from activities. // TODO: This should come from activities.
@@ -263,7 +261,7 @@ export class WalletHD extends BaseWallet {
const unspentDirectives = await Promise.all(fetchPromises); const unspentDirectives = await Promise.all(fetchPromises);
// Flatten them. // Flatten them.
const unspentsFlattened = unspentDirectives.flat(); const unspentsFlattened = ExtMap.flatten(unspentDirectives);
// Return the unspents. // Return the unspents.
return unspentsFlattened; return unspentsFlattened;
@@ -358,7 +356,7 @@ export class WalletHD extends BaseWallet {
// Build the transaction. // Build the transaction.
const tx = TransactionBuilder.build( const tx = TransactionBuilder.build(
txTemplateFinal, txTemplateFinal,
unspents, unspents.toArray(),
changeLockingBytecode, changeLockingBytecode,
); );
@@ -398,6 +396,7 @@ export class WalletHD extends BaseWallet {
// Create a child P2PKH wallet for this Private Key. // Create a child P2PKH wallet for this Private Key.
const wallet = await WalletP2PKH.from( const wallet = await WalletP2PKH.from(
{ {
activities: this.activities,
blockchain: this.blockchain, blockchain: this.blockchain,
cache: this.cache, cache: this.cache,
}, },
+19 -18
View File
@@ -46,20 +46,27 @@ export type WalletP2PKHGenesisData = {
export class WalletP2PKH extends BaseWallet { export class WalletP2PKH extends BaseWallet {
static async from<T extends typeof WalletP2PKH>( static async from<T extends typeof WalletP2PKH>(
this: T, this: T,
dependencies: WalletDependencies, deps: WalletDependencies,
genesisData: WalletP2PKHGenesisData, genesisData: WalletP2PKHGenesisData,
) { ) {
// Assign in memory stores as defaults in case otherwise not provided. // Assign defaults if not otherwise provideed.
const dependenciesFinal = { const activities = deps.activities
...{ ? deps.activities
activities: await Activities.fromStores(StoreInMemory.from()), : await Activities.from();
cache: new StoreInMemory(), const cache = deps.cache ? deps.cache : StoreInMemory.from();
},
...dependencies, // Assign our final dependencies.
const depsFinal = {
activities,
cache,
blockchain: deps.blockchain,
}; };
// Return a new instance of Wallet P2PKH. // 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>( static fromBytes<T extends typeof WalletP2PKH>(
@@ -188,10 +195,6 @@ export class WalletP2PKH extends BaseWallet {
); );
} }
async getActivities(): Promise<Activities> {
return this.activities;
}
async start() { async start() {
// Do nothing if already started. // Do nothing if already started.
if (this.isStarted) { if (this.isStarted) {
@@ -314,8 +317,7 @@ export class WalletP2PKH extends BaseWallet {
const compilerP2PKH = walletTemplateToCompilerBCH(walletTemplateP2pkhNonHd); const compilerP2PKH = walletTemplateToCompilerBCH(walletTemplateP2pkhNonHd);
// Transform our inputs into unlocking directives. // Transform our inputs into unlocking directives.
const inputDirectives = unspentOutputs const inputDirectives = unspentOutputs.map((blockchainUTXO) => ({
.map((blockchainUTXO) => ({
outpointIndex: blockchainUTXO.utxo.outpoint.outpointIndex, outpointIndex: blockchainUTXO.utxo.outpoint.outpointIndex,
outpointTransactionHash: outpointTransactionHash:
blockchainUTXO.utxo.outpoint.outpointTransactionHash, blockchainUTXO.utxo.outpoint.outpointTransactionHash,
@@ -329,8 +331,7 @@ export class WalletP2PKH extends BaseWallet {
token: blockchainUTXO.utxo.output.token, token: blockchainUTXO.utxo.output.token,
valueSatoshis: blockchainUTXO.utxo.output.valueSatoshis, valueSatoshis: blockchainUTXO.utxo.output.valueSatoshis,
}, },
})) }));
.toArray();
// Return the directives. // Return the directives.
return inputDirectives; return inputDirectives;
@@ -397,7 +398,7 @@ export class WalletP2PKH extends BaseWallet {
// Build the transaction. // Build the transaction.
const tx = TransactionBuilder.build( const tx = TransactionBuilder.build(
txTemplateFinal, txTemplateFinal,
unspents, unspents.toArray(),
changeLockingBytecode, changeLockingBytecode,
); );
+16 -17
View File
@@ -2,16 +2,15 @@ import { describe, test, expect, beforeEach } from 'vitest';
import { Activities, StoreInMemory } from '../../src/storage'; import { Activities, StoreInMemory } from '../../src/storage';
describe('Activities', () => { describe('Activities', () => {
let store: StoreInMemory;
let activities: Activities; let activities: Activities;
beforeEach(async () => { beforeEach(async () => {
activities = await Activities.fromStores(StoreInMemory.from()); activities = await Activities.from();
}); });
test('should add activity with auto-generated timestamp', async () => { test('should add activity with auto-generated timestamp', async () => {
const activity = { const activity = {
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'Test Wallet', value: 'Test Wallet',
}; };
@@ -28,7 +27,7 @@ describe('Activities', () => {
const timestamp = BigInt(1234567890000); const timestamp = BigInt(1234567890000);
const activity = { const activity = {
timestamp, timestamp,
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'Test Wallet', value: 'Test Wallet',
}; };
@@ -45,14 +44,14 @@ describe('Activities', () => {
await activities.add({ await activities.add({
timestamp: oldTimestamp, timestamp: oldTimestamp,
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'Old Name', value: 'Old Name',
}); });
await activities.add({ await activities.add({
timestamp: newTimestamp, timestamp: newTimestamp,
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'New Name', value: 'New Name',
}); });
@@ -73,14 +72,14 @@ describe('Activities', () => {
await activities.add({ await activities.add({
timestamp: oldTimestamp, timestamp: oldTimestamp,
name: 'TransactionFirstSeen', key: 'TransactionFirstSeen',
entityId: 'tx1', entityId: 'tx1',
value: 'First Value', value: 'First Value',
}); });
await activities.add({ await activities.add({
timestamp: newTimestamp, timestamp: newTimestamp,
name: 'TransactionFirstSeen', key: 'TransactionFirstSeen',
entityId: 'tx1', entityId: 'tx1',
value: 'Second Value', value: 'Second Value',
}); });
@@ -100,14 +99,14 @@ describe('Activities', () => {
await activities.add({ await activities.add({
timestamp: BigInt(1000), timestamp: BigInt(1000),
name: 'CustomType', key: 'CustomType',
entityId: 'custom1', entityId: 'custom1',
value: 50, value: 50,
}); });
await activities.add({ await activities.add({
timestamp: BigInt(2000), timestamp: BigInt(2000),
name: 'CustomType', key: 'CustomType',
entityId: 'custom1', entityId: 'custom1',
value: 30, value: 30,
}); });
@@ -121,19 +120,19 @@ describe('Activities', () => {
const testActivities = [ const testActivities = [
{ {
timestamp: BigInt(3000), timestamp: BigInt(3000),
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'Name 3', value: 'Name 3',
}, },
{ {
timestamp: BigInt(1000), timestamp: BigInt(1000),
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'Name 1', value: 'Name 1',
}, },
{ {
timestamp: BigInt(2000), timestamp: BigInt(2000),
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'Name 2', value: 'Name 2',
}, },
@@ -151,14 +150,14 @@ describe('Activities', () => {
test('should handle multiple activity types independently', async () => { test('should handle multiple activity types independently', async () => {
await activities.add({ await activities.add({
timestamp: BigInt(1000), timestamp: BigInt(1000),
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'My Wallet', value: 'My Wallet',
}); });
await activities.add({ await activities.add({
timestamp: BigInt(2000), timestamp: BigInt(2000),
name: 'WalletDescription', key: 'WalletDescription',
entityId: 'wallet1', entityId: 'wallet1',
value: 'My Description', value: 'My Description',
}); });
@@ -172,7 +171,7 @@ describe('Activities', () => {
test('should update snapshot immediately when activity is added', async () => { test('should update snapshot immediately when activity is added', async () => {
const activity = { const activity = {
timestamp: BigInt(1000), timestamp: BigInt(1000),
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'Test Wallet', value: 'Test Wallet',
}; };
@@ -186,7 +185,7 @@ describe('Activities', () => {
test('should handle missing timestamp gracefully', async () => { test('should handle missing timestamp gracefully', async () => {
const activity = { const activity = {
name: 'WalletName', key: 'WalletName',
entityId: 'wallet1', entityId: 'wallet1',
value: 'Test Wallet', value: 'Test Wallet',
}; };