Activities/Wallet Refactor
This commit is contained in:
+8
-4
@@ -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.
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './activities.js';
|
||||
export * from './base-store.js';
|
||||
export * from './store-composite.js';
|
||||
export * from './store-inmemory.js';
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -10,10 +10,10 @@
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["src/*"]
|
||||
}
|
||||
"baseUrl": "."
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user