Activities/Wallet Refactor

This commit is contained in:
Jim Hamill
2025-04-18 05:17:20 +00:00
parent 408d3b8d8c
commit 7e4508cf5a
18 changed files with 706 additions and 1111 deletions
+8 -4
View File
@@ -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.
+275
View File
@@ -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<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// 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<string, PolicyFunction>();
private storeKeyMap = new Map<string, string>();
/**
* 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<BaseStore>,
): Promise<Activities> {
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<T extends Optional<Activity, 'timestamp'>>(
activity: T,
): Promise<Activity> {
// Generate timestamp if not provided
const timestamp = activity.timestamp ?? BigInt(Date.now());
// Generate a UUID based on the timestamp
const key = UUIDV7.fromTimestamp(timestamp).toString();
// 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<T extends Activity>(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<T extends Activity>(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<void> {
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<SnapshotByType> {
// Clear existing snapshot
this.snapshot = {};
this.storeKeyMap.clear();
// Get all activity data from our store
const activities = await this.store.all<Activity>();
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);
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './activities.js';
export * from './base-store.js';
export * from './store-composite.js';
export * from './store-inmemory.js';
+70 -82
View File
@@ -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<BaseStore>) {
static async from(
localStore: BaseStore,
remoteStores: Array<BaseStore> = [],
) {
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<BaseStore>,
private remoteStores: Array<BaseStore> = [],
) {
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<T>() {
@@ -108,43 +65,74 @@ export class StoreComposite extends BaseStore {
]);
}
/**
* Add a new remote store and set up synchronization
*/
public async addRemoteStore(remoteStore: BaseStore): Promise<void> {
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<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> {
// 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),
),
);
}
}
+6 -1
View File
@@ -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<string, any>;
// TODO: Eventually this amy accept indexes as an argument.
static from(): StoreInMemory {
return new StoreInMemory();
}
private store: Map<string, unknown>;
constructor() {
super();
+46 -289
View File
@@ -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<Type extends string, ValueType> = {
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<string, any>;
};
};
// Policy type definitions
export type PolicyFunction<T = any> = (
existingActivity: Activity<string, T> | undefined,
newActivity: Activity<string, T>,
) => Activity<string, T>;
/**
* 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<string, PolicyFunction>();
// Add a map to track store keys for each snapshot key
private storeKeyMap = new Map<string, string>();
constructor(public store: BaseStore) {
this.store.on(
'set',
async (payload: { key: string; value: Activity<string, any> }) => {
// 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<T>(type: string): Activity<string, T>[] {
return Object.values(this.snapshot[type] || {});
}
/**
* Get a specific activity by type and id
*/
public getActivity<T>(
type: string,
id: string,
): Activity<string, T> | undefined {
return this.snapshot[type]?.[id];
}
/**
* Process a single activity and update the snapshot
*/
private async processActivity(
storeKey: string,
activity: Activity<string, any>,
) {
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<string, any>) {
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<Activity<string, any>>();
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<string, any> | undefined {
return this.snapshot[type]?.[id];
}
/**
* Set an activity in the snapshot, creating necessary object structure
*/
private setActivityInSnapshot(activity: Activity<string, any>) {
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, any>): string {
return `${activity.type}:${activity.id}`;
}
/**
* Apply policy to determine which activity should be kept
*/
private applyPolicy(
existingActivity: Activity<string, any> | undefined,
newActivity: Activity<string, any>,
): Activity<string, any> {
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;
}
+40 -28
View File
@@ -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<WalletEvents> {
WalletEventsType extends WalletEvents = WalletEvents,
> extends EventEmitter<WalletEventsType> {
/**
* Get the ID for this wallet.
*/
abstract getId(): Promise<string>;
/**
* Derive a key using secret data from this wallet.
*/
abstract deriveKey(keyName: string): Promise<Uint8Array>;
/**
* Get the encryption key to use for this wallet.
*/
abstract getEncryptionKey(): Promise<Uint8Array>;
/**
* Get the signing key to use for this wallet.
*/
abstract getSigningKey(): Promise<Uint8Array>;
/**
* Get the activities implementation
*/
abstract getActivities(): Promise<Activities>;
/**
* Start monitoring this wallet for any transaction activity.
*/
@@ -136,28 +172,4 @@ export abstract class BaseWallet<
abstract sendTransaction(
template: Partial<TransactionTemplate>,
): Promise<void>;
// 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<T>(activity: T): Promise<void>;
abstract export(): Promise<string>;
/**
* Get the ID for this wallet.
*/
abstract getId(): Promise<string>;
/**
* Get the encryption key to use for this wallet.
*/
abstract getEncryptionKey(): Promise<Uint8Array>;
/**
* Get the signing key to use for this wallet.
*/
abstract getSigningKey(): Promise<Uint8Array>;
}
-1
View File
@@ -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';
@@ -26,6 +26,31 @@ import {
* @experimental
*/
export class TransactionBuilder {
static build(
transaction: TransactionTemplateFixed<CompilerBCH>,
unspents: Array<WalletUTXO> = [],
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<WalletUTXO>;
constructor(
+4 -4
View File
@@ -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;
};
+83 -148
View File
@@ -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<WalletType> = {
walletDataUpdated: WalletHDGenesisData;
shouldMonitorUpdated: boolean;
walletsUpdated: Array<WalletType>;
transactionsUpdated: BlockchainTransactions;
unspentsUpdated: BlockchainUTXOs;
stateUpdated: string | null;
};
/**
* A Hierarhical Determinstic (BIP44) Wallet.
*
@@ -78,92 +57,72 @@ export type WalletHDEvents<WalletType> = {
* await wallet = await walletHD.start();
* ```
*/
export class WalletHD<
WalletType extends WalletP2PKH = WalletP2PKH,
> extends BaseWallet<WalletHDEvents<WalletType>> {
// 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<WalletHDGenesisData>;
// Store child wallets, grouped by chain ("change") path in BIP44 spec.
public wallets: { [chainPath: number]: Array<WalletType> } = {};
export class WalletHD extends BaseWallet {
static async from<T extends typeof WalletHD>(
this: T,
services: WalletServices,
genesisData?: WalletHDGenesisData,
walletFactory?: WalletP2PKHFactory<any>,
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<WalletHDGenesisData>(
'genesis',
genesisData,
);
}
// Retrieve our genesis data and extract the private key.
const retrievedGenesisData =
await servicesFinal.store.getOrFail<WalletHDGenesisData>('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<T>;
return new this(servicesFinal, genesisData) as InstanceType<T>;
}
// 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<WalletP2PKH> } = {};
constructor(
public blockchain: BaseBlockchain,
public store: BaseStore,
public cache: BaseStore,
public hdPrivateNode: HDPrivateNode,
private walletFactory: WalletP2PKHFactory<WalletType> = useWalletP2PKH,
dependencies: Required<WalletDependencies>,
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<Uint8Array> {
// 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<Activities> {
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<T>(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<T>(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<WalletType> = [];
const newWallets: Array<WalletP2PKH> = [];
// 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<WalletType> = [];
const wallets: Array<WalletP2PKH> = [];
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<WalletType> = await this.deriveWallets(
const batch: Array<WalletP2PKH> = await this.deriveWallets(
addressGap,
chainPath,
currentIndex,
@@ -527,19 +477,6 @@ export class WalletHD<
this.emit('stateUpdated', null);
}
protected async deriveKey(keyName: string): Promise<Uint8Array> {
// 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<T extends WalletHD = WalletHD> = (
services: WalletServices,
genesisData?: WalletHDGenesisData,
walletFactory?: WalletP2PKHFactory<any>,
services: WalletDependencies,
genesisData: WalletHDGenesisData,
) => Promise<T>;
export async function useWalletHD<T extends WalletHD = WalletHD>(
services: WalletServices,
genesisData?: WalletHDGenesisData,
walletFactory?: WalletP2PKHFactory<any>,
services: WalletDependencies,
genesisData: WalletHDGenesisData,
): Promise<T> {
return (await WalletHD.from(services, genesisData, walletFactory)) as T;
return (await WalletHD.from(services, genesisData)) as T;
}
+70 -114
View File
@@ -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<WalletP2PKHEvents> {
// Internal State.
public isStarted = false;
public status: string | null = null;
export class WalletP2PKH extends BaseWallet {
static async from<T extends typeof WalletP2PKH>(
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<WalletP2PKHGenesisData>(
'genesis',
genesisData,
);
}
// Retrieve our genesis data and extract the private key.
const retrievedGenesisData =
await servicesFinal.store.getOrFail<WalletP2PKHGenesisData>('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<T>;
return new this(dependenciesFinal, genesisData) as InstanceType<T>;
}
static fromBytes<T extends typeof WalletP2PKH>(
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<WalletP2PKHEvents> {
static fromHex<T extends typeof WalletP2PKH>(
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<WalletP2PKHEvents> {
static fromWIF<T extends typeof WalletP2PKH>(
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<WalletP2PKHEvents> {
static generateRandom<T extends typeof WalletP2PKH>(
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<WalletP2PKHEvents> {
});
}
// 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<WalletDependencies>,
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<WalletP2PKHEvents> {
);
}
async deriveKey(keyName: string): Promise<Uint8Array> {
// 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<WalletP2PKHEvents> {
);
}
async getActivities(): Promise<Activities> {
return this.activities;
}
async start() {
// Do nothing if already started.
if (this.isStarted) {
@@ -396,22 +387,15 @@ export class WalletP2PKH extends BaseWallet<WalletP2PKHEvents> {
// 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<WalletP2PKHEvents> {
this.emit('stateUpdated', this.status);
}
}
async export() {
const allData = await this.store.all();
const asJson = encodeExtendedJson(allData);
return asJson;
}
async addActivity<T>(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<T>(activityId, activity);
}
private async deriveKey(keyName: string): Promise<Uint8Array> {
// 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<WalletP2PKHEvents> {
//-----------------------------------------------------------------------------
export type WalletP2PKHFactory<T extends WalletP2PKH = WalletP2PKH> = (
services: WalletServices,
genesisData?: WalletP2PKHGenesisData,
dependencies: WalletDependencies,
genesisData: WalletP2PKHGenesisData,
) => Promise<T>;
export async function useWalletP2PKH<T extends WalletP2PKH = WalletP2PKH>(
services: WalletServices,
genesisData?: WalletP2PKHGenesisData,
dependencies: WalletDependencies,
genesisData: WalletP2PKHGenesisData,
): Promise<T> {
return (await WalletP2PKH.from(services, genesisData)) as T;
return (await WalletP2PKH.from(dependencies, genesisData)) as T;
}
-317
View File
@@ -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<WalletSetEvents> {
// 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<WalletSetGenesisData>;
public wallets: { [id: string]: BaseWallet<any> } = {};
constructor(
protected adapters: WalletAdapters,
protected genesisData: WalletSetGenesisData,
) {
super();
// Initialize proxies to our stored data.
this.genesisData = this.store.proxy('genesis');
}
static async from<T extends typeof WalletSet>(
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<WalletSetGenesisData>(
'genesis',
genesisData,
);
}
// Retrieve our genesis data and extract the private key.
const retrievedGenesisData =
await servicesFinal.store.getOrFail<WalletSetGenesisData>('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<T>;
}
async start() {
// Get the genesis data.
const genesisData = await this.genesisData.getOrFail();
// Declare storage for our start promises.
const startPromises: Promise<void>[] = [];
// Wait for all of our start promises to complete.
await Promise.all(startPromises);
}
async stop() {
// Declare storage for our stop promises.
const stopPromises: Promise<void>[] = [];
// 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<TokenBalances> {
// 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<TransactionTemplate>,
): Promise<Uint8Array> {
// 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<TransactionTemplate>) {
// Sign the transaction template.
const generatedTxBytes = await this.signTransaction(txTemplate);
// Broadcast the transaction to the blockchain.
await this.blockchain.broadcastTransaction(generatedTxBytes);
}
}
*/
@@ -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 };
+1 -1
View File
@@ -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
+60 -110
View File
@@ -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<void>[] = []; // 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',
+11 -6
View File
@@ -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"
]
}
+5 -5
View File
@@ -10,10 +10,10 @@
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"*": ["src/*"]
}
"baseUrl": "."
},
"exclude": ["node_modules", "dist"]
"exclude": [
"node_modules",
"dist"
]
}