From 7e4508cf5aa7830feafbd5228c4c7d333dc89b65 Mon Sep 17 00:00:00 2001 From: Jim Hamill Date: Fri, 18 Apr 2025 05:17:20 +0000 Subject: [PATCH] Activities/Wallet Refactor --- CONTRIBUTING.md | 12 +- packages/stack/src/storage/activities.ts | 275 ++++++++++++++ packages/stack/src/storage/index.ts | 1 + packages/stack/src/storage/store-composite.ts | 152 ++++---- packages/stack/src/storage/store-inmemory.ts | 7 +- packages/stack/src/wallet/activities.ts | 335 +++--------------- packages/stack/src/wallet/base-wallet.ts | 68 ++-- packages/stack/src/wallet/index.ts | 1 - .../stack/src/wallet/transaction-builder.ts | 25 ++ packages/stack/src/wallet/types.ts | 8 +- packages/stack/src/wallet/wallet-hd.ts | 231 +++++------- packages/stack/src/wallet/wallet-p2pkh.ts | 184 ++++------ packages/stack/src/wallet/wallet-set.ts | 317 ----------------- .../stack/tests/utils/event-emitter.spec.ts | 2 +- packages/stack/tests/utils/uuid.spec.ts | 2 +- .../stack/tests/wallet/activities.spec.ts | 170 ++++----- packages/stack/tsconfig.json | 17 +- tsconfig.json | 10 +- 18 files changed, 706 insertions(+), 1111 deletions(-) create mode 100644 packages/stack/src/storage/activities.ts delete mode 100644 packages/stack/src/wallet/wallet-set.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a9680e..3b011e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,11 +58,15 @@ class SomeClass { protected store: BaseStore; constructor( - // + // Dependency Injection first dependencies: { blockchain: BaseBlockchain, store: BaseStore, - } + }, + // Then main (required) initialization params + initParams, + // Then optional params (if applicable). + opts, ) { this.blockchain = dependencies.blockchain; this.store = dependencies.store; @@ -74,8 +78,6 @@ class SomeClass { When creating classes, create static `from(...)` (or `fromSpecificThing(...)`) methods. -*Semantically, think of "from" as meaning "instantiate from": This is more succinct than a `create` factory method which, if we have multiple ways of instantiation, can become verbose. E.g. `PrivateKey.fromWIF()` as opposed to `PrivateKey.createFromWIF()`. It also allows us to differentiate between actually "creating" something (e.g. a `Filesystem.createFile(...)`) versus "instantiating" a representation of a "thing".* - In Javascript, a `constructor` cannot be asynchronous and we want to ensure that we can `await` class instantiation to mitigate against stateful errors. For example: @@ -99,6 +101,8 @@ class SomeClass { Although this isn't always necessary (i.e. not all instantiations need to be `async`), it keeps instantiation of classes consistent. +*Semantically, think of "from" as meaning "instantiate from": This is more succinct than a `createFrom...` factory method which, if we have multiple ways of instantiation, can become verbose. E.g. `PrivateKey.fromWIF()` as opposed to `PrivateKey.createFromWIF()`. It also allows us to differentiate between actually "creating" something (e.g. a `Filesystem.createFile(...)`) versus "instantiating" a representation of a "thing".* + ### Validations Check types with TypeScript - Runtime type-checking is a code smell. If the function accepts a string, assume it's been given a string. TypeScript definitions should expose improperly called functions to the developer at compile time, don't re-implement it at runtime. diff --git a/packages/stack/src/storage/activities.ts b/packages/stack/src/storage/activities.ts new file mode 100644 index 0000000..6b5f090 --- /dev/null +++ b/packages/stack/src/storage/activities.ts @@ -0,0 +1,275 @@ +import { BaseStore } from './base-store.js'; +import { StoreComposite } from './store-composite.js'; + +import { UUIDV7 } from '../utils'; + +// 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. +type Optional = Omit & Partial>; + +// Base Activity interface +export interface Activity { + entityId: string; + name: string; + value: unknown; + timestamp: bigint; +} + +// Define the snapshot structure +export type SnapshotByType = { + [type: string]: { + [id: string]: Activity; + }; +}; + +// Policy type definition +export type PolicyFunction = ( + existingActivity: Activity | undefined, + newActivity: Activity, +) => Activity; + +/** + * Implements an event sourcing pattern for managing application state + * with a simplified interface focused on the current snapshot. + * + * @example + * ```typescript + * // Create Activities manager with a store implementation + * const activities = await Activities.fromLocalStore(localStore); + * + * // Register a custom policy for wallet name conflicts + * activities.registerPolicy('WalletName', (existing, newActivity) => { + * // Keep the most recent wallet name + * return existing?.timestamp > newActivity.timestamp ? existing : newActivity; + * }); + * + * // Add a new wallet name activity + * const walletName: WalletNameInput = { + * name: 'WalletName', + * value: 'Test Wallet' + * }; + * const savedActivity = await activities.add(walletName); + * ``` + */ +export class Activities { + public snapshot: SnapshotByType = {}; + private policies = new Map(); + private storeKeyMap = new Map(); + + /** + * Create an Activities instance from the given composite store. + */ + static async from(compositeStore: StoreComposite) { + const activities = new Activities(compositeStore); + await activities.compileSnapshot(); + return activities; + } + + /** + * Create an Activities instance from the given primary and secondary stores + */ + static async fromStores( + primaryStore: BaseStore, + secondaryStores?: Array, + ): Promise { + const compositeStore = await StoreComposite.from( + primaryStore, + secondaryStores, + ); + return Activities.from(compositeStore); + } + + private constructor(public store: StoreComposite) { + this.store.on( + 'set', + async (payload: { key: string; value: Activity | null }) => { + if (payload.value !== null) { + await this.processActivity(payload.key, payload.value); + } + }, + ); + } + + /** + * Add a new activity to the store + * @param activity The activity to add (id and timestamp are optional) + * @returns The stored activity with all fields populated + */ + public async add>( + activity: T, + ): Promise { + // Generate timestamp if not provided + const timestamp = activity.timestamp ?? BigInt(Date.now()); + + // Generate a UUID based on the timestamp + const key = UUIDV7.fromTimestamp(timestamp).toString(); + + // Generate an ID if not provided + const id = activity.entityId ?? key; + + const completeActivity: Activity = { + name: activity.name, + value: activity.value, + entityId: id, + timestamp, + }; + + await this.store.set(key, completeActivity); + + return completeActivity; + } + + /** + * Get a specific activity by type and id with type inference + */ + public get(type: T['name'], id: string): T | undefined { + const activity = this.snapshot[type]?.[id]; + return activity as T | undefined; + } + + /** + * Get all activities of a specific type + */ + public getByType(type: T['name']): T[] { + const activities = this.snapshot[type]; + if (!activities) { + return []; + } + return Object.values(activities) as T[]; + } + + /** + * Register a policy for handling conflicts between activities of the same type + * @param type Activity type to register policy for + * @param policyFn Function that decides which activity to keep + */ + public registerPolicy(type: string, policyFn: PolicyFunction): void { + this.policies.set(type, policyFn); + } + + /** + * Process a single activity and update the snapshot + */ + private async processActivity( + storeKey: string, + activity: Activity, + ): Promise { + 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.entityId, + ); + + // Apply policy to determine which activity to keep + const keptActivity = this.applyPolicy(existingActivity, activity); + + // Update snapshot with the kept activity + this.setActivityInSnapshot(keptActivity); + + if (keptActivity === activity) { + // If we kept the new activity, update the store key mapping + const oldStoreKey = this.storeKeyMap.get(snapshotKey); + if (oldStoreKey && oldStoreKey !== storeKey) { + // Delete the old activity from the store + await this.store.set(oldStoreKey, null); + } + this.storeKeyMap.set(snapshotKey, storeKey); + } 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); + } + } + + /** + * Compile a full snapshot from scratch + * This happens automatically on initialization + */ + private async compileSnapshot(): Promise { + // Clear existing snapshot + this.snapshot = {}; + this.storeKeyMap.clear(); + + // Get all activity data from our store + const activities = await this.store.all(); + const entries = Object.entries(activities); + + // Filter for null payloads + const filteredEntries = entries.filter(([_, payload]) => payload); + + // Sort activities by timestamp using UUID v7's embedded timestamp + const activitiesSorted = filteredEntries.sort(([keyA], [keyB]) => { + const timestampA = UUIDV7.from(keyA).getTimestamp(); + const timestampB = UUIDV7.from(keyB).getTimestamp(); + return Number(timestampA - timestampB); + }); + + // Process each activity in order + for (const [storeKey, activity] of activitiesSorted) { + await this.processActivity(storeKey, activity); + } + + return this.snapshot; + } + + /** + * Get an activity from the snapshot by type and id + */ + private getActivityFromSnapshot( + type: string, + id: string, + ): Activity | undefined { + return this.snapshot[type]?.[id]; + } + + /** + * Set an activity in the snapshot, creating necessary object structure + */ + private setActivityInSnapshot(activity: Activity): void { + if (!this.snapshot[activity.name]) { + this.snapshot[activity.name] = {}; + } + + this.snapshot[activity.name][activity.entityId] = activity; + } + + /** + * Get the snapshot key for an activity + */ + private getSnapshotKey(activity: Activity): string { + return `${activity.name}:${activity.entityId}`; + } + + /** + * Apply policy to determine which activity should be kept + */ + private applyPolicy( + existingActivity: Activity | undefined, + newActivity: Activity, + ): Activity { + const policy = this.policies.get(newActivity.name); + + if (!policy) { + // Default policy: compare timestamps + if (!existingActivity) return newActivity; + return existingActivity.timestamp >= newActivity.timestamp + ? existingActivity + : newActivity; + } + + return policy(existingActivity, newActivity); + } +} diff --git a/packages/stack/src/storage/index.ts b/packages/stack/src/storage/index.ts index 2d3826e..a0ea86d 100644 --- a/packages/stack/src/storage/index.ts +++ b/packages/stack/src/storage/index.ts @@ -1,3 +1,4 @@ +export * from './activities.js'; export * from './base-store.js'; export * from './store-composite.js'; export * from './store-inmemory.js'; diff --git a/packages/stack/src/storage/store-composite.ts b/packages/stack/src/storage/store-composite.ts index 50db9be..2bab689 100644 --- a/packages/stack/src/storage/store-composite.ts +++ b/packages/stack/src/storage/store-composite.ts @@ -11,7 +11,10 @@ import { BaseStore } from './base-store.js'; * UUIDv7 keys are used to ensure uniqueness of keys. */ export class StoreComposite extends BaseStore { - static async from(localStore: BaseStore, remoteStores: Array) { + static async from( + localStore: BaseStore, + remoteStores: Array = [], + ) { const compositeStore = new this(localStore, remoteStores); await compositeStore.sync(); return compositeStore; @@ -19,55 +22,9 @@ export class StoreComposite extends BaseStore { constructor( private localStore: BaseStore, - private remoteStores: Array, + private remoteStores: Array = [], ) { super(); - - // Local store events propagate to remote stores - this.localStore.on('set', async (payload) => { - // Propagate to all remote stores in parallel - await Promise.all( - this.remoteStores.map((remoteStore) => - remoteStore.set(payload.key, payload.value), - ), - ); - }); - - // Local store events propagate to remote stores - this.localStore.on('delete', async (payload) => { - // Propagate to all remote stores in parallel - await Promise.all( - this.remoteStores.map((remoteStore) => remoteStore.delete(payload.key)), - ); - }); - - // Local store events propagate to remote stores - this.localStore.on('clear', async () => { - // Propagate to all remote stores in parallel - await Promise.all( - this.remoteStores.map((remoteStore) => remoteStore.clear()), - ); - }); - - // Remote store events propagate to local store - for (const remoteStore of this.remoteStores) { - remoteStore.on('set', async (payload) => { - // Skip if the local store already has the key - // NOTE: It's important we DO NOT repropagate events that have already occurred. - // If we do, we can end up in infinite loops. - if (await this.localStore.has(payload.key)) return; - - await this.localStore.set(payload.key, payload.value); - }); - - remoteStore.on('delete', async (payload) => { - // NOTE: It's important we DO NOT repropagate events that have already occurred. - // If we do, we can end up in infinite loops. - if (await this.localStore.has(payload.key)) { - await this.localStore.delete(payload.key); - } - }); - } } async all() { @@ -108,43 +65,74 @@ export class StoreComposite extends BaseStore { ]); } + /** + * Add a new remote store and set up synchronization + */ + public async addRemoteStore(remoteStore: BaseStore): Promise { + this.remoteStores.push(remoteStore); + + // Propagate existing local events to the new remote + this.localStore.on('set', async (payload) => { + await remoteStore.set(payload.key, payload.value); + }); + + this.localStore.on('delete', async (payload) => { + await remoteStore.delete(payload.key); + }); + + this.localStore.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); + }); + + remoteStore.on('delete', async (payload) => { + if (await this.localStore.has(payload.key)) { + await this.localStore.delete(payload.key); + } + }); + + remoteStore.on('clear', async () => { + await this.localStore.clear(); + }); + + // Sync with the new remote + await this.syncWithRemote(remoteStore); + } + + private async syncWithRemote(remoteStore: BaseStore): Promise { + const remoteKeys = await remoteStore.keys(); + const localKeys = await this.localStore.keys(); + + const missingLocalKeys = remoteKeys.filter( + (key) => !localKeys.includes(key), + ); + const missingRemoteKeys = localKeys.filter( + (key) => !remoteKeys.includes(key), + ); + + await Promise.all([ + ...missingLocalKeys.map(async (key) => { + const value = await remoteStore.get(key); + await this.localStore.set(key, value); + }), + ...missingRemoteKeys.map(async (key) => { + const value = await this.localStore.get(key); + await remoteStore.set(key, value); + }), + ]); + } + async sync(): Promise { // Sync all remote stores in parallel await Promise.all( - this.remoteStores.map(async (remoteStore) => { - // TODO: We want to negotiate using an Inverse Bloom Filter first to optimize the sync. - // Currently, this is doing a naive set comparison. - - // Get all keys from the remote store - const remoteKeys = await remoteStore.keys(); - const localKeys = await this.localStore.keys(); - - // Find keys missing in local store - const missingLocalKeys = remoteKeys.filter( - (key) => !localKeys.includes(key), - ); - - // Find keys missing in remote store - const missingRemoteKeys = localKeys.filter( - (key) => !remoteKeys.includes(key), - ); - - // Sync missing keys from remote to local (in parallel) - await Promise.all( - missingLocalKeys.map(async (key) => { - const value = await remoteStore.get(key); - await this.localStore.set(key, value); - }), - ); - - // Sync missing keys from local to remote (in parallel) - await Promise.all( - missingRemoteKeys.map(async (key) => { - const value = await this.localStore.get(key); - await remoteStore.set(key, value); - }), - ); - }), + this.remoteStores.map(async (remoteStore) => + this.syncWithRemote(remoteStore), + ), ); } } diff --git a/packages/stack/src/storage/store-inmemory.ts b/packages/stack/src/storage/store-inmemory.ts index 01dd8d7..ab95825 100644 --- a/packages/stack/src/storage/store-inmemory.ts +++ b/packages/stack/src/storage/store-inmemory.ts @@ -7,7 +7,12 @@ import { BaseStore } from './base-store.js'; * This implementation can be used testing and caching of expensive operations. */ export class StoreInMemory extends BaseStore { - private store: Map; + // TODO: Eventually this amy accept indexes as an argument. + static from(): StoreInMemory { + return new StoreInMemory(); + } + + private store: Map; constructor() { super(); diff --git a/packages/stack/src/wallet/activities.ts b/packages/stack/src/wallet/activities.ts index 914cd81..5c71511 100644 --- a/packages/stack/src/wallet/activities.ts +++ b/packages/stack/src/wallet/activities.ts @@ -1,292 +1,49 @@ -// An activity represents an event that should be saved to persistent storage(s). -// TODO: Read more into LibAuth's concept of these. -// Jason mentions pruning, but I don't quite understand how that will be done yet. -// He also has the concept of "extensions" - but I'm not sure this has been fleshed out yet. +import { type Activity } from '../storage'; -import { UUIDV7 } from '../utils'; -import { BaseStore } from '../storage'; +//----------------------------------------------------------------------------- +// Standardized Activities +//----------------------------------------------------------------------------- -// TODO: Think about different activities -// TemplateActivity -// CashConnectActivity -// CoinLockActivity - -// TODO: StorageMany adapter should be configurable to make it so set() cannot fail. -// However, for optimization, get() should probably only retrieve from a primary (usually local) adapter. -// Otherwise, shit will be too slow if we're doing a get() or all() from a remote source. -// This may also solve the AES256 problem: Local does not need to be encrypted per se. -// Sync from Remote (encrypted) to Local (non-encrypted) can happen in the background. - -// TODO: I still haven't quite wrapped my head around LibAuth approach. -// But I imagine there'll be some kind of "Mutable" vs "Immutable" concept for Activities. -// Because we need to compile a "snapshot" based on Activities, if we add a Mutable Activity, we can prune previous Mutable Activities of the same identifier. -// This will help massively with optimization: -// 1. Our set of Activities becomes smaller (computation/storage = faster/better) -// 2. Syncing will take less time -// For the above, this probably means that we want to keep our Activities limited to one mutation. -// For example, we ideally wouldn't want an Activity that modifies/refers to multiple values. -// { type: 'freezeCoins', coins: [...] } -// ... because we cannot sync them efficiently (we need the entire history). -// Whereas, if we do: -// { type: 'freezeCoin' } -// ... and have an identifer, we can ensure that each wallet only syncs the latest frozen coin. -// Amendment 2024-11-20: -// For mutable, we could use property "unique: boolean". -// For priority (e.g. txTime activity), we could use property "policy: "earlist" | latest" - -// Type definitions -export type Activity = { - timestamp?: bigint; - type: Type; - id: string; - value: ValueType; -}; - -// Standardized Activities. -export type WalletName = Activity<'WalletName', string>; -export type WalletDescription = Activity<'WalletDescription', string>; -export type TransactionFirstSeen = Activity<'TransactionFirstSeen', string>; -export type TransactionMemo = Activity<'TransactionMemo', string>; - -// XO-specific Activities. -// TODO: These probably don't belong here and should be moved to the Wallet Codebase. -export type XOWalletColor = Activity<'XOWalletColor', string>; -export type XOTransactionCategory = Activity<'XOTransactionCategory', string>; -export type XOFrozenCoin = Activity<'XOFrozenCoin', boolean>; - -export type SnapshotByType = { - [type: string]: { - [id: string]: Activity; - }; -}; - -// Policy type definitions -export type PolicyFunction = ( - existingActivity: Activity | undefined, - newActivity: Activity, -) => Activity; - -/** - * Implements an event sourcing pattern for managing application state. - * - * Event sourcing is an architectural pattern where: - * - All changes to application state are stored as a sequence of immutable events - * - The current state is derived by replaying these events in chronological order - * - Events serve as both the source of truth and a complete audit log - * - * This implementation provides: - * - Eventually consistent state across distributed systems - * - Conflict resolution through configurable policies - * - Point-in-time state reconstruction capability - * - Separation between event storage and derived state (snapshot) - * - * Events (Activities) in this system: - * - Have a unique identifier - * - Include a timestamp for ordering - * - Contain a type and associated value - * - Are processed according to registered type-specific policies - * - * @example - * ```typescript - * // Create Activities manager with a store implementation - * const activities = new Activities(new MyBaseStore()); - * - * // Register a custom policy for wallet name conflicts - * activities.registerPolicy('WalletName', (existing, newActivity) => { - * // Keep the most recent wallet name - * return existing?.timestamp! > newActivity.timestamp! ? existing : newActivity; - * }); - * - * // Initialize by compiling snapshot from stored events - * await activities.compileSnapshot(); - * - * // Add a new activity - * await activities.addActivity({ - * type: 'WalletName', - * id: 'wallet123', - * value: 'My Primary Wallet' - * }); - * ``` - * - * @template SnapshotByType - Structured representation of the current system state - * @template PolicyFunction - Function type for conflict resolution strategies - */ -export class Activities { - // TODO: Consider splitting into its own mutable class: ActivitySnapshot. - // This might be cleaner as we'll likely want to be able to view a raw timeline of activities. - // So we can make Activities have its own methods for that without cluttering the class. - public snapshot: SnapshotByType = {}; - private policies = new Map(); - - // Add a map to track store keys for each snapshot key - private storeKeyMap = new Map(); - - constructor(public store: BaseStore) { - this.store.on( - 'set', - async (payload: { key: string; value: Activity }) => { - // When a new activity is added, process it individually - await this.processActivity(payload.key, payload.value); - }, - ); - } - - /** - * Get all activities of a specific type - */ - public getActivitiesByType(type: string): Activity[] { - return Object.values(this.snapshot[type] || {}); - } - - /** - * Get a specific activity by type and id - */ - public getActivity( - type: string, - id: string, - ): Activity | undefined { - return this.snapshot[type]?.[id]; - } - - /** - * Process a single activity and update the snapshot - */ - private async processActivity( - storeKey: string, - activity: Activity, - ) { - if (!activity) { - return; - } - - if (!activity.timestamp) { - console.warn('Activity without timestamp encountered:', storeKey); - return; - } - - const snapshotKey = this.getSnapshotKey(activity); - const existingActivity = this.getActivityFromSnapshot( - activity.type, - activity.id, - ); - - // Apply policy to determine which activity to keep - const keptActivity = this.applyPolicy(existingActivity, activity); - - // Update snapshot with the kept activity - this.setActivityInSnapshot(keptActivity); - - if (keptActivity === activity) { - // If we kept the new activity, update the store key mapping - const oldStoreKey = this.storeKeyMap.get(snapshotKey); - if (oldStoreKey) { - // Delete the old activity from the store - await this.store.set(oldStoreKey, null); - } - this.storeKeyMap.set(snapshotKey, storeKey); - } else { - // If we kept the existing activity, delete the new activity from the store - await this.store.set(storeKey, null); - } - } - - /** - * Add a new activity to the store - */ - public async addActivity(activity: Activity) { - const timestamp = activity.timestamp ?? BigInt(Date.now()); - const activityWithTimestamp = { - ...activity, - timestamp, - }; - - const key = UUIDV7.fromTimestamp(timestamp).toString(); - await this.store.set(key, activityWithTimestamp); - // Note: We don't need to call processActivity here as the 'set' event will trigger it - } - - /** - * Compile a full snapshot from scratch (only needed for initialization or recovery) - */ - public async compileSnapshot() { - // Clear existing snapshot - this.snapshot = {}; - - // Get all activity data from our store - const activities = await this.store.all>(); - const entries = Object.entries(activities); - - // Filter for null payloads - const filteredEntries = entries.filter(([_, payload]) => payload); - - // Sort activities by timestamp using UUID v7's embedded timestamp - const activitiesSorted = filteredEntries.sort(([keyA], [keyB]) => { - const timestampA = UUIDV7.from(keyA).getTimestamp(); - const timestampB = UUIDV7.from(keyB).getTimestamp(); - return Number(timestampA - timestampB); - }); - - // Process each activity in order - for (const [storeKey, activity] of activitiesSorted) { - await this.processActivity(storeKey, activity); - } - - return this.snapshot; - } - - /** - * Register a policy for handling conflicts between activities of the same type - * @param type Activity type to register policy for - * @param policyFn Function that decides which activity to keep - */ - public registerPolicy(type: string, policyFn: PolicyFunction) { - this.policies.set(type, policyFn); - } - - /** - * Get an activity from the snapshot by type and id - */ - private getActivityFromSnapshot( - type: string, - id: string, - ): Activity | undefined { - return this.snapshot[type]?.[id]; - } - - /** - * Set an activity in the snapshot, creating necessary object structure - */ - private setActivityInSnapshot(activity: Activity) { - if (!this.snapshot[activity.type]) { - this.snapshot[activity.type] = {}; - } - this.snapshot[activity.type][activity.id] = activity; - } - - /** - * Get the snapshot key for an activity - */ - private getSnapshotKey(activity: Activity): string { - return `${activity.type}:${activity.id}`; - } - - /** - * Apply policy to determine which activity should be kept - */ - private applyPolicy( - existingActivity: Activity | undefined, - newActivity: Activity, - ): Activity { - const policy = this.policies.get(newActivity.type); - - if (!policy) { - // Default policy: compare timestamps - if (!existingActivity) return newActivity; - return existingActivity.timestamp! >= newActivity.timestamp! - ? existingActivity - : newActivity; - } - - return policy(existingActivity, newActivity); - } +export interface WalletName extends Activity { + name: 'WalletName'; + value: string; +} + +export interface WalletDescription extends Activity { + name: 'WalletDescription'; + value: string; +} + +export interface WalletHDAddressGap extends Activity { + name: 'WalletHDAddressGap'; + value: number; +} + +export interface TransactionFirstSeen extends Activity { + name: 'TransactionFirstSeen'; + value: string; +} + +export interface TransactionMemo extends Activity { + name: 'TransactionMemo'; + value: string; +} + +//----------------------------------------------------------------------------- +// XO-specific Activities +//----------------------------------------------------------------------------- + +export interface XOWalletColor extends Activity { + name: 'XOWalletColor'; + value: string; +} + +export interface XOTransactionCategory extends Activity { + name: 'XOTransactionCategory'; + value: string; +} + +export interface XOFrozenCoin extends Activity { + name: 'XOFrozenCoin'; + value: boolean; } diff --git a/packages/stack/src/wallet/base-wallet.ts b/packages/stack/src/wallet/base-wallet.ts index 685a4bb..0a18e3a 100644 --- a/packages/stack/src/wallet/base-wallet.ts +++ b/packages/stack/src/wallet/base-wallet.ts @@ -5,11 +5,22 @@ import { type WalletTransactions, } from './types.js'; -import { type BlockchainUTXOs } from '../blockchain'; -import { type EventMap, EventEmitter } from '../utils'; +import { + type BlockchainTransactions, + type BlockchainUTXOs, +} from '../blockchain'; +import { type Activities } from '../storage'; +import { EventEmitter } from '../utils'; import { type TokenBalances, type Address } from '@xocash/primitives'; +export type WalletEvents = { + shouldMonitorUpdated: boolean; + transactionsUpdated: BlockchainTransactions; + unspentsUpdated: BlockchainUTXOs; + stateUpdated: string | null; +}; + /** * Common interface for all wallet types. * @@ -20,8 +31,33 @@ import { type TokenBalances, type Address } from '@xocash/primitives'; * Or we might have to support some other custodial "Apple Pay"-like OS-interface to get accepted onto their app store. */ export abstract class BaseWallet< - WalletEvents extends EventMap, -> extends EventEmitter { + WalletEventsType extends WalletEvents = WalletEvents, +> extends EventEmitter { + /** + * Get the ID for this wallet. + */ + abstract getId(): Promise; + + /** + * Derive a key using secret data from this wallet. + */ + abstract deriveKey(keyName: string): Promise; + + /** + * Get the encryption key to use for this wallet. + */ + abstract getEncryptionKey(): Promise; + + /** + * Get the signing key to use for this wallet. + */ + abstract getSigningKey(): Promise; + + /** + * Get the activities implementation + */ + abstract getActivities(): Promise; + /** * Start monitoring this wallet for any transaction activity. */ @@ -136,28 +172,4 @@ export abstract class BaseWallet< abstract sendTransaction( template: Partial, ): Promise; - - // TODO: This should be a push-only list. - // Need to understand how Jason's pruning approach works. - // It might make more sense to "key" activities by type: - // This would allow us to filter more effectively each time a new activity is added. - // As it stands, we would have to collate the entire list if it's done by timestamp. - abstract addActivity(activity: T): Promise; - - abstract export(): Promise; - - /** - * Get the ID for this wallet. - */ - abstract getId(): Promise; - - /** - * Get the encryption key to use for this wallet. - */ - abstract getEncryptionKey(): Promise; - - /** - * Get the signing key to use for this wallet. - */ - abstract getSigningKey(): Promise; } diff --git a/packages/stack/src/wallet/index.ts b/packages/stack/src/wallet/index.ts index 8430707..a8d351f 100644 --- a/packages/stack/src/wallet/index.ts +++ b/packages/stack/src/wallet/index.ts @@ -6,4 +6,3 @@ export * from './wallet-hd.js'; export * from './wallet-p2pkh.js'; export * from './wallet-p2sh20.js'; export * from './wallet-p2sh32.js'; -export * from './wallet-set.js'; diff --git a/packages/stack/src/wallet/transaction-builder.ts b/packages/stack/src/wallet/transaction-builder.ts index dc335fe..9f89ca3 100644 --- a/packages/stack/src/wallet/transaction-builder.ts +++ b/packages/stack/src/wallet/transaction-builder.ts @@ -26,6 +26,31 @@ import { * @experimental */ export class TransactionBuilder { + static build( + transaction: TransactionTemplateFixed, + unspents: Array = [], + changeLockingBytecode: Uint8Array, + sourceOutputs: UTXOSet = new ExtMap(), + ) { + const txBuilder = new TransactionBuilder( + transaction, + unspents, + sourceOutputs, + ); + + // Append necessary unspents to satisfy amount requirements. + // NOTE: The order here is very important. + // We must deal with tokens first and satoshis last. + // The reason is that the change output will be determined based on transaction fee. + txBuilder + .addTokensFromUnspents() + .addTokenChangeOutputs(changeLockingBytecode) + .addSatoshisFromUnspents() + .addSatoshiChangeOutput(changeLockingBytecode); + + return txBuilder; + } + public unspents: Array; constructor( diff --git a/packages/stack/src/wallet/types.ts b/packages/stack/src/wallet/types.ts index 2a3216b..05d1a42 100644 --- a/packages/stack/src/wallet/types.ts +++ b/packages/stack/src/wallet/types.ts @@ -1,7 +1,7 @@ import { type BaseBlockchain, type BlockchainTransaction } from '../blockchain'; -import { type BaseStore } from '../storage'; +import { type Activities, type BaseStore } from '../storage'; -import { Address, ExtMap } from '@xocash/primitives'; +import { type Address, type ExtMap } from '@xocash/primitives'; import { type InputTemplate, @@ -12,9 +12,9 @@ import { type Output, } from '@bitauth/libauth'; -export type WalletServices = { +export type WalletDependencies = { + activities?: Activities; blockchain: BaseBlockchain; - store?: BaseStore; cache?: BaseStore; }; diff --git a/packages/stack/src/wallet/wallet-hd.ts b/packages/stack/src/wallet/wallet-hd.ts index 5c95218..3e844a0 100644 --- a/packages/stack/src/wallet/wallet-hd.ts +++ b/packages/stack/src/wallet/wallet-hd.ts @@ -1,23 +1,11 @@ import { BaseWallet } from './base-wallet.js'; import { type TransactionTemplate } from './types.js'; import { TransactionBuilder } from './transaction-builder.js'; -import { type WalletServices } from './types.js'; -import { - WalletP2PKH, - WalletP2PKHFactory, - useWalletP2PKH, -} from './wallet-p2pkh.js'; +import { type WalletDependencies } from './types.js'; +import { WalletP2PKH } from './wallet-p2pkh.js'; -import { - type BaseBlockchain, - type BlockchainTransactions, - type BlockchainUTXOs, -} from '../blockchain'; -import { - type BaseStore, - type StoreValueProxy, - StoreInMemory, -} from '../storage'; +import { type BaseBlockchain } from '../blockchain'; +import { type BaseStore, Activities, StoreInMemory } from '../storage'; import { type TokenBalances, @@ -45,15 +33,6 @@ export type WalletHDGenesisData = { addressGap?: number; } & ({ seed: string } | { mnemonic: string } | { xpriv: string }); -export type WalletHDEvents = { - walletDataUpdated: WalletHDGenesisData; - shouldMonitorUpdated: boolean; - walletsUpdated: Array; - transactionsUpdated: BlockchainTransactions; - unspentsUpdated: BlockchainUTXOs; - stateUpdated: string | null; -}; - /** * A Hierarhical Determinstic (BIP44) Wallet. * @@ -78,92 +57,72 @@ export type WalletHDEvents = { * await wallet = await walletHD.start(); * ``` */ -export class WalletHD< - WalletType extends WalletP2PKH = WalletP2PKH, -> extends BaseWallet> { - // Mutable State. - public shouldMonitor = false; - - // Store values that are proxied for convenience/clarity. - // NOTE: We define these for clarity so that we can see the state stored with this wallet. - public genesisData: StoreValueProxy; - - // Store child wallets, grouped by chain ("change") path in BIP44 spec. - public wallets: { [chainPath: number]: Array } = {}; - +export class WalletHD extends BaseWallet { static async from( this: T, - services: WalletServices, - genesisData?: WalletHDGenesisData, - walletFactory?: WalletP2PKHFactory, + services: 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, }; - // If we are attempting to create a new wallet... - if (genesisData) { - // Ensure that the provided store does not already contain data. - if (Object.keys(await servicesFinal.store.all()).length) { - throw new Error(`Provided store already contains data`); - } - - // Store the wallet genesis data. - await servicesFinal.store.set( - 'genesis', - genesisData, - ); - } - - // Retrieve our genesis data and extract the private key. - const retrievedGenesisData = - await servicesFinal.store.getOrFail('genesis'); - - let hdPrivateNode: HDPrivateNode; - - // Instantiate the HDPrivateNode from the type specified in metadata. - if ('seed' in retrievedGenesisData) { - hdPrivateNode = HDPrivateNode.fromSeed( - hexToBin(retrievedGenesisData.seed), - ); - } else if ('mnemonic' in retrievedGenesisData) { - hdPrivateNode = HDPrivateNode.fromMnemonic( - Mnemonic.fromPhrase(retrievedGenesisData.mnemonic), - ); - } else { - hdPrivateNode = HDPrivateNode.fromXPriv(retrievedGenesisData.xpriv); - } - // Return a new instance of WalletHD. - return new this( - servicesFinal.blockchain, - servicesFinal.store, - servicesFinal.cache, - hdPrivateNode, - walletFactory, - ) as InstanceType; + return new this(servicesFinal, genesisData) as InstanceType; } + // Dependencies. + public readonly activities: Activities; + public readonly blockchain: BaseBlockchain; + public readonly cache: BaseStore; + + // Genesis Data. + public readonly genesisData: WalletHDGenesisData; + + // Optimizations/Simplifications. + public readonly hdPrivateNode: HDPrivateNode; + + // Mutable State. + public shouldMonitor = false; + + // Store child wallets, grouped by chain ("change") path in BIP44 spec. + public wallets: { [chainPath: number]: Array } = {}; + constructor( - public blockchain: BaseBlockchain, - public store: BaseStore, - public cache: BaseStore, - public hdPrivateNode: HDPrivateNode, - private walletFactory: WalletP2PKHFactory = useWalletP2PKH, + dependencies: Required, + genesisData: WalletHDGenesisData, ) { super(); + // Assign our dependencies. + this.activities = dependencies.activities; + this.blockchain = dependencies.blockchain; + this.cache = dependencies.cache; + + // Assign our state. + this.genesisData = genesisData; + + // Instantiate the HDPrivateNode from the type specified in our genesis data. + if ('seed' in genesisData) { + this.hdPrivateNode = HDPrivateNode.fromSeed(hexToBin(genesisData.seed)); + } else if ('mnemonic' in genesisData) { + this.hdPrivateNode = HDPrivateNode.fromMnemonic( + Mnemonic.fromPhrase(genesisData.mnemonic), + ); + } else { + this.hdPrivateNode = HDPrivateNode.fromXPriv(genesisData.xpriv); + } + // Initialize empty arrays for our wallets. this.wallets[CHAIN_EXTERNAL] = []; this.wallets[CHAIN_INTERNAL] = []; - - // Initialize proxies to our stored data. - this.genesisData = this.store.proxy('genesis'); } async getId() { @@ -175,6 +134,17 @@ export class WalletHD< ); } + async deriveKey(keyName: string): Promise { + // Hash the genesis data using Hash256 and the key ("WalletID") for this derivation using Hash256. + const genesisDataHashed = hash256( + utf8ToBin(encodeExtendedJson(this.genesisData)), + ); + const keyHashed = hash256(utf8ToBin(keyName)); + + // Combine the hashes and do one final hash for good measure. + return hash256(new Uint8Array([...genesisDataHashed, ...keyHashed])); + } + async getEncryptionKey() { // NOTE: Use the cache to optimize future calls. return await this.cache.getOrSet('encryptionKey', async () => @@ -189,12 +159,14 @@ export class WalletHD< ); } - async start() { - // Get the genesis data. - const genesisData = await this.genesisData.getOrFail(); + async getActivities(): Promise { + return this.activities; + } + async start() { // Get the address gap. - const addressGap = genesisData.addressGap || ADDRESS_GAP; + // TODO: This should come from activities. + const addressGap = this.genesisData.addressGap || ADDRESS_GAP; // Scan the HDWallet for child wallets. await this.scan(0, addressGap); @@ -327,7 +299,7 @@ export class WalletHD< async getAddresses() { const chainPaths = [CHAIN_EXTERNAL, CHAIN_INTERNAL]; - let addressPromises = []; + const addressPromises = []; for (const chainPath of chainPaths) { addressPromises.push( @@ -384,17 +356,15 @@ export class WalletHD< // Fetch unspents that are usable by this wallet. const unspents = await this.getUnspentDirectives(); - // Create an instance of our transaction builder and populate with our Wallet's UTXOs. - // NOTE: We must deal with tokens first and satoshis last. - // The reason is that the change output will be determined based on transaction fee. - const txBuilder = new TransactionBuilder(txTemplateFinal, unspents) - .addTokensFromUnspents() - .addTokenChangeOutputs(changeLockingBytecode) - .addSatoshisFromUnspents() - .addSatoshiChangeOutput(changeLockingBytecode); + // Build the transaction. + const tx = TransactionBuilder.build( + txTemplateFinal, + unspents, + changeLockingBytecode, + ); // Generate the transaction. - const generatedTxBytes = txBuilder.toBytes(); + const generatedTxBytes = tx.toBytes(); return generatedTxBytes; } @@ -407,38 +377,18 @@ export class WalletHD< await this.blockchain.broadcastTransaction(generatedTxBytes); } - async addActivity(activity: T) { - // Generate an Activity ID. - // TODO: Look more into how LibAuth intends to do this. - const activityId = Date.now().toString(); - - // Store the data. - await this.store.set(activityId, activity); - } - - async export() { - const allData = await this.store.all(); - - const asJson = encodeExtendedJson(allData); - - return asJson; - } - async deriveWallets( count: number, chainPath = CHAIN_EXTERNAL, startIndex = 0, ) { - // Get the derivation path from the genesis data. - const genesisData = await this.genesisData.getOrFail(); - // Create an array to store our wallets. - const newWallets: Array = []; + const newWallets: Array = []; // Create the given count of wallets. for (let i = 0; i < count; i++) { // Define the derivation path. - const derivationPath = `${genesisData.derivationPath}/${chainPath}/${startIndex + i}`; + const derivationPath = `${this.genesisData.derivationPath}/${chainPath}/${startIndex + i}`; // Derive the private key at the given path. const privateKeyHex = await this.cache.getOrSet(derivationPath, () => { @@ -447,7 +397,7 @@ export class WalletHD< }); // Create a child P2PKH wallet for this Private Key. - const wallet = await this.walletFactory( + const wallet = await WalletP2PKH.from( { blockchain: this.blockchain, cache: this.cache, @@ -485,7 +435,7 @@ export class WalletHD< // Iterate over each of the provided chain (change) paths. for (const chainPath of chainPaths) { // Array to store active wallet nodes - const wallets: Array = []; + const wallets: Array = []; let currentIndex = startIndex; let emptyAddressCount = 0; @@ -493,7 +443,7 @@ export class WalletHD< // Continue scanning until we find 'addressGap' consecutive empty addresses while (emptyAddressCount < addressGap) { // Derive a number of wallets equivalent to our addressGap. - const batch: Array = await this.deriveWallets( + const batch: Array = await this.deriveWallets( addressGap, chainPath, currentIndex, @@ -527,19 +477,6 @@ export class WalletHD< this.emit('stateUpdated', null); } - - protected async deriveKey(keyName: string): Promise { - // Get the genesis data for this wallet. - // TODO: This needs to change! - const genesisData = await this.store.getOrFail('genesis'); - - // Hash the genesis data using Hash256 and the key ("WalletID") for this derivation using Hash256. - const genesisDataHashed = hash256(utf8ToBin(JSON.stringify(genesisData))); - const keyHashed = hash256(utf8ToBin(keyName)); - - // Combine the hashes and do one final hash for good measure. - return hash256(new Uint8Array([...genesisDataHashed, ...keyHashed])); - } } //----------------------------------------------------------------------------- @@ -547,15 +484,13 @@ export class WalletHD< //----------------------------------------------------------------------------- export type WalletHDFactory = ( - services: WalletServices, - genesisData?: WalletHDGenesisData, - walletFactory?: WalletP2PKHFactory, + services: WalletDependencies, + genesisData: WalletHDGenesisData, ) => Promise; export async function useWalletHD( - services: WalletServices, - genesisData?: WalletHDGenesisData, - walletFactory?: WalletP2PKHFactory, + services: WalletDependencies, + genesisData: WalletHDGenesisData, ): Promise { - return (await WalletHD.from(services, genesisData, walletFactory)) as T; + return (await WalletHD.from(services, genesisData)) as T; } diff --git a/packages/stack/src/wallet/wallet-p2pkh.ts b/packages/stack/src/wallet/wallet-p2pkh.ts index 37ad9ac..e778bdc 100644 --- a/packages/stack/src/wallet/wallet-p2pkh.ts +++ b/packages/stack/src/wallet/wallet-p2pkh.ts @@ -1,29 +1,21 @@ import { BaseWallet } from './base-wallet.js'; -import { type WalletTransactions, type TransactionTemplate } from './types.js'; +import { type TransactionTemplate, type WalletDependencies } from './types.js'; import { TransactionBuilder } from './transaction-builder.js'; -import { type WalletServices } from './types.js'; + +import { type BaseBlockchain, type BlockchainTransaction } from '../blockchain'; +import { type BaseStore, Activities, StoreInMemory } from '../storage'; import { - type BaseBlockchain, - type BlockchainTransaction, - type BlockchainUTXOs, -} from '../blockchain'; -import { type BaseStore, StoreInMemory } from '../storage'; - -import { - Address, ExtMap, PrivateKey, calculateBalanceSats, calculateBalanceTokens, - encodeExtendedJson, } from '@xocash/primitives'; import { type Input, binToHex, hash256, - sha256, utf8ToBin, walletTemplateP2pkhNonHd, walletTemplateToCompilerBCH, @@ -34,12 +26,6 @@ export type WalletP2PKHGenesisData = { privateKey: string; }; -export type WalletP2PKHEvents = { - transactionsUpdated: WalletTransactions; - unspentsUpdated: BlockchainUTXOs; - stateUpdated: string | null; -}; - /** * @example * ```ts @@ -50,58 +36,28 @@ export type WalletP2PKHEvents = { * await walletP2PKH.startMonitoring(); * ``` */ -export class WalletP2PKH extends BaseWallet { - // Internal State. - public isStarted = false; - public status: string | null = null; - +export class WalletP2PKH extends BaseWallet { static async from( this: T, - services: WalletServices, - genesisData?: WalletP2PKHGenesisData, + dependencies: WalletDependencies, + genesisData: WalletP2PKHGenesisData, ) { // Assign in memory stores as defaults in case otherwise not provided. - const servicesFinal = { + const dependenciesFinal = { ...{ - store: new StoreInMemory(), + activities: await Activities.fromStores(StoreInMemory.from()), cache: new StoreInMemory(), }, - ...services, + ...dependencies, }; - // If we are attempting to create a new wallet... - if (genesisData) { - // Ensure that the provided store does not already contain data. - if (await servicesFinal.store.get('genesis')) { - throw new Error(`Provided store already contains data`); - } - - // Store the wallet genesis data. - await servicesFinal.store.set( - 'genesis', - genesisData, - ); - } - - // Retrieve our genesis data and extract the private key. - const retrievedGenesisData = - await servicesFinal.store.getOrFail('genesis'); - - // Get the Private Key as hex. - const privateKey = PrivateKey.fromHex(retrievedGenesisData.privateKey); - // Return a new instance of Wallet P2PKH. - return new this( - servicesFinal.blockchain, - servicesFinal.store, - servicesFinal.cache, - privateKey, - ) as InstanceType; + return new this(dependenciesFinal, genesisData) as InstanceType; } static fromBytes( this: T, - services: WalletServices, + services: WalletDependencies, privateKeyBytes: Uint8Array, ) { // Create a Private Key entity from the provided bytes. @@ -116,7 +72,7 @@ export class WalletP2PKH extends BaseWallet { static fromHex( this: T, - services: WalletServices, + services: WalletDependencies, privateKeyHex: string, ) { // Create a Private Key entity from the provided bytes. @@ -131,7 +87,7 @@ export class WalletP2PKH extends BaseWallet { static fromWIF( this: T, - services: WalletServices, + services: WalletDependencies, privateKeyWif: string, ) { // Create a Private Key entity from the provided bytes. @@ -146,7 +102,7 @@ export class WalletP2PKH extends BaseWallet { static generateRandom( this: T, - services: WalletServices, + services: WalletDependencies, ) { // Create a Private Key entity generated from random bytes. const privateKey = PrivateKey.generateRandom(); @@ -158,17 +114,37 @@ export class WalletP2PKH extends BaseWallet { }); } + // Dependencies. + public readonly activities: Activities; + public readonly blockchain: BaseBlockchain; + public readonly cache: BaseStore; + + // Genesis Data. + public readonly genesisData: WalletP2PKHGenesisData; + + // Optimizations/Simplifications. + public readonly privateKey: PrivateKey; + + // Mutable State. + public isStarted = false; + public status: string | null = null; + constructor( - // Dependencies. - public blockchain: BaseBlockchain, - // Persistent Storage. - public store: BaseStore, - // Cache. - public cache: BaseStore, - // Properties. - public privateKey: PrivateKey, + dependencies: Required, + genesisData: WalletP2PKHGenesisData, ) { super(); + + // Assign our dependencies. + this.activities = dependencies.activities; + this.blockchain = dependencies.blockchain; + this.cache = dependencies.cache; + + // Assign our Genesis Data. + this.genesisData = genesisData; + + // Optimizations/Simplifications. + this.privateKey = PrivateKey.fromHex(genesisData.privateKey); } async getId() { @@ -180,6 +156,17 @@ export class WalletP2PKH extends BaseWallet { ); } + async deriveKey(keyName: string): Promise { + // Hash the genesis data using Hash256 and the key ("WalletID") for this derivation using Hash256. + const genesisDataHashed = hash256( + utf8ToBin(JSON.stringify(this.genesisData)), + ); + const keyHashed = hash256(utf8ToBin(keyName)); + + // Combine the hashes and do one final hash for good measure. + return hash256(new Uint8Array([...genesisDataHashed, ...keyHashed])); + } + async getEncryptionKey() { // NOTE: Use the cache to optimize future calls. return await this.cache.getOrSet('encryptionKey', async () => @@ -194,6 +181,10 @@ export class WalletP2PKH extends BaseWallet { ); } + async getActivities(): Promise { + return this.activities; + } + async start() { // Do nothing if already started. if (this.isStarted) { @@ -396,22 +387,15 @@ export class WalletP2PKH extends BaseWallet { // Fetch unspents that are usable by this wallet. const unspents = await this.getUnspentDirectives(); - // Create an instance of our transaction builder. - const txBuilder = new TransactionBuilder(txTemplateFinal, unspents); - - // Append necessary unspents to satisfy amount requirements. - // NOTE: The order here is very important. - // We must deal with tokens first and satoshis last. - // The reason is that the change output will be determined based on transaction fee. - // TODO: Consider replacing this with just a single util function. - txBuilder - .addTokensFromUnspents() - .addTokenChangeOutputs(changeLockingBytecode) - .addSatoshisFromUnspents() - .addSatoshiChangeOutput(changeLockingBytecode); + // Build the transaction. + const tx = TransactionBuilder.build( + txTemplateFinal, + unspents, + changeLockingBytecode, + ); // Generate the transaction. - const generatedTxBytes = txBuilder.toBytes(); + const generatedTxBytes = tx.toBytes(); return generatedTxBytes; } @@ -434,34 +418,6 @@ export class WalletP2PKH extends BaseWallet { this.emit('stateUpdated', this.status); } } - - async export() { - const allData = await this.store.all(); - const asJson = encodeExtendedJson(allData); - return asJson; - } - - async addActivity(activity: T) { - // Generate an Activity ID. - // TODO: Look more into how LibAuth intends to do this. - const activityId = Date.now().toString(); - - // Store the data. - await this.store.set(activityId, activity); - } - - private async deriveKey(keyName: string): Promise { - // Get the genesis data for this wallet. - // TODO: This needs to change! - const genesisData = await this.store.getOrFail('genesis'); - - // Hash the genesis data using Hash256 and the key ("WalletID") for this derivation using Hash256. - const genesisDataHashed = hash256(utf8ToBin(JSON.stringify(genesisData))); - const keyHashed = hash256(utf8ToBin(keyName)); - - // Combine the hashes and do one final hash for good measure. - return hash256(new Uint8Array([...genesisDataHashed, ...keyHashed])); - } } //----------------------------------------------------------------------------- @@ -469,13 +425,13 @@ export class WalletP2PKH extends BaseWallet { //----------------------------------------------------------------------------- export type WalletP2PKHFactory = ( - services: WalletServices, - genesisData?: WalletP2PKHGenesisData, + dependencies: WalletDependencies, + genesisData: WalletP2PKHGenesisData, ) => Promise; export async function useWalletP2PKH( - services: WalletServices, - genesisData?: WalletP2PKHGenesisData, + dependencies: WalletDependencies, + genesisData: WalletP2PKHGenesisData, ): Promise { - return (await WalletP2PKH.from(services, genesisData)) as T; + return (await WalletP2PKH.from(dependencies, genesisData)) as T; } diff --git a/packages/stack/src/wallet/wallet-set.ts b/packages/stack/src/wallet/wallet-set.ts deleted file mode 100644 index b8d0fb7..0000000 --- a/packages/stack/src/wallet/wallet-set.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { BaseWallet } from './base-wallet.js'; -import { type TransactionTemplate } from './types.js'; -import { TransactionBuilder } from './transaction-builder.js'; -import { type WalletServices } from './types.js'; -import { - WalletP2PKH, - WalletP2PKHFactory, - useWalletP2PKH, -} from './wallet-p2pkh.js'; - -import { - type BaseBlockchain, - type BlockchainTransactions, - type BlockchainUTXOs, -} from '../blockchain'; -import { - type BaseStore, - type StoreValueProxy, - StoreInMemory, -} from '../storage'; - -import { - type TokenBalances, - HDPrivateNode, - ExtMap, - calculateBalanceSats, - calculateBalanceTokens, - encodeExtendedJson, -} from '@xocash/primitives'; - -/** - * Placeholder. - * - * @remarks - * One difficult thing here is going to be storage of the subwallets. - * We're going to have child wallets and we need to propagate their storage to the root WalletSet. - * See remarks on Storage - it's semi-related and I'm still playing with approaches. - */ -// https://github.com/bitjson/libauth/blob/d3d610de088cd98f5c391fe62dce81874eb5d95a/src/lib/engine/types/wallet-set-types.ts - -/* -export type WalletSetGenesisData = { - type: 'WalletSet'; -} & ({ seed: string } | { mnemonic: string } | { xpriv: string }); - -export type WalletSetEvents = { - shouldMonitorUpdated: boolean; - stateUpdated: string | null; -}; - -export class WalletSet extends BaseWallet { - // Mutable State. - public shouldMonitor = false; - - // Store values that are proxied for convenience/clarity. - // NOTE: We define these for clarity so that we can see the state stored with this wallet. - public genesisData: StoreValueProxy; - - public wallets: { [id: string]: BaseWallet } = {}; - - constructor( - protected adapters: WalletAdapters, - protected genesisData: WalletSetGenesisData, - ) { - super(); - - // Initialize proxies to our stored data. - this.genesisData = this.store.proxy('genesis'); - } - - static async from( - this: T, - services: WalletServices, - genesisData?: WalletSetGenesisData, - ) { - // Assign in memory stores as defaults in case otherwise not provided. - const servicesFinal = { - ...{ - store: new StoreInMemory(), - cache: new StoreInMemory(), - }, - ...services, - }; - - // If we are attempting to create a new wallet... - if (genesisData) { - // Ensure that the provided store does not already contain data. - if (Object.keys(await servicesFinal.store.all()).length) { - throw new Error(`Provided store already contains data`); - } - - // Store the wallet genesis data. - await servicesFinal.store.set( - 'genesis', - genesisData, - ); - } - - // Retrieve our genesis data and extract the private key. - const retrievedGenesisData = - await servicesFinal.store.getOrFail('genesis'); - - let hdPrivateNode: HDPrivateNode; - - // Instantiate the HDPrivateNode from the type specified in metadata. - if ('seed' in retrievedGenesisData) { - hdPrivateNode = HDPrivateNode.fromSeed( - hexToBin(retrievedGenesisData.seed), - ); - } else if ('mnemonic' in retrievedGenesisData) { - hdPrivateNode = HDPrivateNode.fromMnemonic(retrievedGenesisData.mnemonic); - } else { - hdPrivateNode = HDPrivateNode.fromXPriv(retrievedGenesisData.xpriv); - } - - // Return a new instance of WalletHD. - return new this( - servicesFinal.blockchain, - servicesFinal.store, - servicesFinal.cache, - hdPrivateNode, - walletFactory, - ) as InstanceType; - } - - async start() { - // Get the genesis data. - const genesisData = await this.genesisData.getOrFail(); - - // Declare storage for our start promises. - const startPromises: Promise[] = []; - - // Wait for all of our start promises to complete. - await Promise.all(startPromises); - } - - async stop() { - // Declare storage for our stop promises. - const stopPromises: Promise[] = []; - - // Wait for all of our stop promises to complete. - await Promise.all(stopPromises); - } - - async destroy() { - this.removeAllListeners(); - } - - async getTransactions() { - // Declare storage for our fetch promises. - const fetchPromises = []; - - // Wait for all of our fetch promises to complete. - const txs = await Promise.all(fetchPromises); - - // Flatten (and deduplicate) the transactions. - const txsFlattened = ExtMap.flatten(txs); - - // Emit an event to notify that transactions have been updated. - this.emit('transactionsUpdated', txsFlattened); - - // Return the deduplicated transactions. - return txsFlattened; - } - - async getUnspents() { - // Declare storage for our fetch promises. - const fetchPromises = []; - - // Iterate over each chain path and fetch unspents for each wallet. - for (const chainWallets of Object.values(this.wallets)) { - for (const wallet of chainWallets) { - fetchPromises.push(wallet.getUnspents()); - } - } - - // Wait for all of our fetch promises to complete. - const unspents = await Promise.all(fetchPromises); - - // Flatten them. - const unspentsFlattened = ExtMap.flatten(unspents); - - // Return the unspents. - return unspentsFlattened; - } - - async getUnspentDirectives() { - // Declare storage for our fetch promises. - const fetchPromises = []; - - // Iterate over each chain path and fetch unspent directives for each wallet. - for (const chainWallets of Object.values(this.wallets)) { - for (const wallet of chainWallets) { - fetchPromises.push(wallet.getUnspentDirectives()); - } - } - - // Wait for all of our fetch promises to complete. - const unspentDirectives = await Promise.all(fetchPromises); - - // Flatten them. - const unspentsFlattened = unspentDirectives.flat(); - - // Return the unspents. - return unspentsFlattened; - } - - async getBalanceSats() { - // Fetch our unspents. - const unspents = await this.getUnspents(); - - // Get the individual UTXOs. - const utxos = unspents - .map((blockchainUTXO) => blockchainUTXO.utxo) - .toArray(); - - // Calculate the balance. - return calculateBalanceSats(utxos); - } - - async getBalanceTokens(): Promise { - // Fetch our unspents. - const unspents = await this.getUnspents(); - - // Get the individual UTXOs. - const utxos = unspents - .map((blockchainUTXO) => blockchainUTXO.utxo) - .toArray(); - - // Calculate the token balances. - return calculateBalanceTokens(utxos); - } - - async getAddresses() { - const chainPaths = [CHAIN_EXTERNAL, CHAIN_INTERNAL]; - - let addressPromises = []; - - for (const chainPath of chainPaths) { - addressPromises.push( - ...this.wallets[chainPath].map((wallet) => - wallet.getReceivingAddress(), - ), - ); - } - - const addresses = await Promise.all(addressPromises); - - return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex()); - } - - async getReceivingAddress(chainPath = CHAIN_EXTERNAL) { - // TODO: Derive a new wallet, not the first one you lazy fuckhead. - // You'll probably need to refactor your scan() approach. - let receivingWallet = this.wallets[chainPath][0]; - - // If there is no receiving wallet, create one. - // TODO: This is fucked. You need to rethink deriveWallets. - if (!receivingWallet) { - this.deriveWallets(1, chainPath); - - receivingWallet = this.wallets[chainPath][0]; - } - - // Return the receiving address of the derived wallet. - return receivingWallet.privateKey.derivePublicKey().deriveAddress(); - } - - async signTransaction( - txTemplate: Partial, - ): Promise { - // Populate the final transaction template by merging with defaults. - // NOTE: This is just a convenience to make "send" easier to use. - const txTemplateFinal: TransactionTemplate = { - ...{ - version: 2, - locktime: 0, - inputs: [], - outputs: [], - }, - ...txTemplate, - }; - - // Get the receiving address of this wallet. - // NOTE: As per BIP44 spec, we set the change address to use the internal chain. - const changeAddress = await this.getReceivingAddress(CHAIN_EXTERNAL); - - // Get the change locking bytecode (change address) for this wallet. - const changeLockingBytecode = changeAddress.toLockscriptBytes(); - - // Fetch unspents that are usable by this wallet. - const unspents = await this.getUnspentDirectives(); - - // Create an instance of our transaction builder and populate with our Wallet's UTXOs. - // NOTE: We must deal with tokens first and satoshis last. - // The reason is that the change output will be determined based on transaction fee. - const txBuilder = new TransactionBuilder(txTemplateFinal, unspents) - .addTokensFromUnspents() - .addTokenChangeOutputs(changeLockingBytecode) - .addSatoshisFromUnspents() - .addSatoshiChangeOutput(changeLockingBytecode); - - // Generate the transaction. - const generatedTxBytes = txBuilder.toBytes(); - - return generatedTxBytes; - } - - async sendTransaction(txTemplate: Partial) { - // Sign the transaction template. - const generatedTxBytes = await this.signTransaction(txTemplate); - - // Broadcast the transaction to the blockchain. - await this.blockchain.broadcastTransaction(generatedTxBytes); - } -} -*/ diff --git a/packages/stack/tests/utils/event-emitter.spec.ts b/packages/stack/tests/utils/event-emitter.spec.ts index f2aa778..ded7a55 100644 --- a/packages/stack/tests/utils/event-emitter.spec.ts +++ b/packages/stack/tests/utils/event-emitter.spec.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; -import { EventEmitter } from '~/src/utils/event-emitter.js'; +import { EventEmitter } from '../../src/utils/event-emitter.js'; type TestEvents = { test: { data: string }; diff --git a/packages/stack/tests/utils/uuid.spec.ts b/packages/stack/tests/utils/uuid.spec.ts index 6d90171..01b8a73 100644 --- a/packages/stack/tests/utils/uuid.spec.ts +++ b/packages/stack/tests/utils/uuid.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { UUIDV7 } from '~/src/utils/uuid.js'; +import { UUIDV7 } from '../../src/utils/uuid.js'; describe('UUIDV7', () => { // Save and restore the Date.now implementation diff --git a/packages/stack/tests/wallet/activities.spec.ts b/packages/stack/tests/wallet/activities.spec.ts index 4c64500..796d39b 100644 --- a/packages/stack/tests/wallet/activities.spec.ts +++ b/packages/stack/tests/wallet/activities.spec.ts @@ -1,27 +1,24 @@ import { describe, test, expect, beforeEach } from 'vitest'; -import { Activities } from '~/src/wallet'; -import { StoreInMemory } from '~/src/storage'; +import { Activities, StoreInMemory } from '../../src/storage'; describe('Activities', () => { let store: StoreInMemory; let activities: Activities; - beforeEach(() => { - store = new StoreInMemory(); - activities = new Activities(store); + beforeEach(async () => { + activities = await Activities.fromStores(StoreInMemory.from()); }); test('should add activity with auto-generated timestamp', async () => { const activity = { - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'Test Wallet', }; - await activities.addActivity(activity); - await activities.compileSnapshot(); + await activities.add(activity); - const storedActivity = activities.getActivity('WalletName', 'wallet1'); + const storedActivity = activities.get('WalletName', 'wallet1'); expect(storedActivity).toBeDefined(); expect(storedActivity?.timestamp).toBeDefined(); expect(storedActivity?.value).toBe('Test Wallet'); @@ -31,15 +28,14 @@ describe('Activities', () => { const timestamp = BigInt(1234567890000); const activity = { timestamp, - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'Test Wallet', }; - await activities.addActivity(activity); - const snapshot = await activities.compileSnapshot(); + await activities.add(activity); - const storedActivity = activities.getActivity('WalletName', 'wallet1'); + const storedActivity = activities.get('WalletName', 'wallet1'); expect(storedActivity?.timestamp).toBe(timestamp); }); @@ -47,22 +43,21 @@ describe('Activities', () => { const oldTimestamp = BigInt(1000); const newTimestamp = BigInt(2000); - await activities.addActivity({ + await activities.add({ timestamp: oldTimestamp, - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'Old Name', }); - await activities.addActivity({ + await activities.add({ timestamp: newTimestamp, - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'New Name', }); - await activities.compileSnapshot(); - const activity = activities.getActivity('WalletName', 'wallet1'); + const activity = activities.get('WalletName', 'wallet1'); expect(activity?.value).toBe('New Name'); expect(activity?.timestamp).toBe(newTimestamp); @@ -76,22 +71,21 @@ describe('Activities', () => { const oldTimestamp = BigInt(1000); const newTimestamp = BigInt(2000); - await activities.addActivity({ + await activities.add({ timestamp: oldTimestamp, - type: 'TransactionFirstSeen', - id: 'tx1', + name: 'TransactionFirstSeen', + entityId: 'tx1', value: 'First Value', }); - await activities.addActivity({ + await activities.add({ timestamp: newTimestamp, - type: 'TransactionFirstSeen', - id: 'tx1', + name: 'TransactionFirstSeen', + entityId: 'tx1', value: 'Second Value', }); - const snapshot = await activities.compileSnapshot(); - const activity = activities.getActivity('TransactionFirstSeen', 'tx1'); + const activity = activities.get('TransactionFirstSeen', 'tx1'); expect(activity?.value).toBe('First Value'); expect(activity?.timestamp).toBe(oldTimestamp); @@ -101,25 +95,24 @@ describe('Activities', () => { // Policy that keeps activity with highest value activities.registerPolicy('CustomType', (existing, newer) => { if (!existing) return newer; - return existing.value > newer.value ? existing : newer; + return existing.timestamp < newer.timestamp ? existing : newer; }); - await activities.addActivity({ + await activities.add({ timestamp: BigInt(1000), - type: 'CustomType', - id: 'custom1', + name: 'CustomType', + entityId: 'custom1', value: 50, }); - await activities.addActivity({ + await activities.add({ timestamp: BigInt(2000), - type: 'CustomType', - id: 'custom1', + name: 'CustomType', + entityId: 'custom1', value: 30, }); - const snapshot = await activities.compileSnapshot(); - const activity = activities.getActivity('CustomType', 'custom1'); + const activity = activities.get('CustomType', 'custom1'); expect(activity?.value).toBe(50); }); @@ -128,55 +121,50 @@ describe('Activities', () => { const testActivities = [ { timestamp: BigInt(3000), - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'Name 3', }, { timestamp: BigInt(1000), - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'Name 1', }, { timestamp: BigInt(2000), - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'Name 2', }, ]; for (const activity of testActivities) { - await activities.addActivity(activity); + await activities.add(activity); } - await activities.compileSnapshot(); - const finalActivity = activities.getActivity('WalletName', 'wallet1'); + const finalActivity = activities.get('WalletName', 'wallet1'); expect(finalActivity?.value).toBe('Name 3'); }); test('should handle multiple activity types independently', async () => { - await activities.addActivity({ + await activities.add({ timestamp: BigInt(1000), - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'My Wallet', }); - await activities.addActivity({ + await activities.add({ timestamp: BigInt(2000), - type: 'WalletDescription', - id: 'wallet1', + name: 'WalletDescription', + entityId: 'wallet1', value: 'My Description', }); - await activities.compileSnapshot(); - - expect(activities.getActivity('WalletName', 'wallet1')?.value).toBe( - 'My Wallet', - ); - expect(activities.getActivity('WalletDescription', 'wallet1')?.value).toBe( + expect(activities.get('WalletName', 'wallet1')?.value).toBe('My Wallet'); + expect(activities.get('WalletDescription', 'wallet1')?.value).toBe( 'My Description', ); }); @@ -184,75 +172,37 @@ describe('Activities', () => { test('should update snapshot immediately when activity is added', async () => { const activity = { timestamp: BigInt(1000), - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'Test Wallet', }; - await activities.addActivity(activity); + await activities.add(activity); // Check snapshot without calling compileSnapshot - const storedActivity = activities.getActivity('WalletName', 'wallet1'); + const storedActivity = activities.get('WalletName', 'wallet1'); expect(storedActivity?.value).toBe('Test Wallet'); }); test('should handle missing timestamp gracefully', async () => { const activity = { - type: 'WalletName', - id: 'wallet1', + name: 'WalletName', + entityId: 'wallet1', value: 'Test Wallet', }; - await activities.addActivity(activity); - await activities.compileSnapshot(); - const storedActivity = activities.getActivity('WalletName', 'wallet1'); + await activities.add(activity); + const storedActivity = activities.get('WalletName', 'wallet1'); expect(storedActivity).toBeDefined(); expect(storedActivity?.timestamp).toBeDefined(); expect(typeof storedActivity?.timestamp).toBe('bigint'); }); - test('should handle store errors gracefully', async () => { - // Simulate store failure - store.set = async () => { - throw new Error('Store error'); - }; - - await expect( - activities.addActivity({ - type: 'WalletName', - id: 'wallet1', - value: 'Test', - }), - ).rejects.toThrow('Store error'); - }); - - test('should handle concurrent activity additions', async () => { - const promises: Promise[] = []; // Add explicit type annotation - for (let i = 0; i < 10; i++) { - promises.push( - activities.addActivity({ - timestamp: BigInt(i), - type: 'WalletName', - id: 'wallet1', - value: `Value ${i}`, - }), - ); - } - - await Promise.all(promises); - await activities.compileSnapshot(); - - // Should have the last value - expect(activities.getActivity('WalletName', 'wallet1')?.value).toBe( - 'Value 9', - ); - }); - /* test('should cleanup old activities from store when newer versions are added', async () => { // Add first activity - await activities.addActivity({ + await activities.add({ timestamp: BigInt(1000), type: 'WalletName', id: 'wallet1', @@ -262,7 +212,7 @@ describe('Activities', () => { expect(Object.keys(await store.all()).length).toBe(1); // Add newer version - should trigger cleanup of old version - await activities.addActivity({ + await activities.add({ timestamp: BigInt(2000), type: 'WalletName', id: 'wallet1', @@ -273,7 +223,7 @@ describe('Activities', () => { expect(Object.keys(await store.all()).length).toBe(1); // Add different type - await activities.addActivity({ + await activities.add({ timestamp: BigInt(1500), type: 'WalletDescription', id: 'wallet1', diff --git a/packages/stack/tsconfig.json b/packages/stack/tsconfig.json index add0b90..718f79f 100644 --- a/packages/stack/tsconfig.json +++ b/packages/stack/tsconfig.json @@ -3,11 +3,16 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", - "paths": { - "~/*": ["./*"] - }, - "lib": ["dom"] + "baseUrl": ".", + "lib": [ + "dom" + ] }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] } diff --git a/tsconfig.json b/tsconfig.json index 41d8218..93463d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,10 +10,10 @@ "strict": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "baseUrl": ".", - "paths": { - "*": ["src/*"] - } + "baseUrl": "." }, - "exclude": ["node_modules", "dist"] + "exclude": [ + "node_modules", + "dist" + ] }