Activities/Wallet Refactor
This commit is contained in:
+8
-4
@@ -58,11 +58,15 @@ class SomeClass {
|
|||||||
protected store: BaseStore;
|
protected store: BaseStore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
//
|
// Dependency Injection first
|
||||||
dependencies: {
|
dependencies: {
|
||||||
blockchain: BaseBlockchain,
|
blockchain: BaseBlockchain,
|
||||||
store: BaseStore,
|
store: BaseStore,
|
||||||
}
|
},
|
||||||
|
// Then main (required) initialization params
|
||||||
|
initParams,
|
||||||
|
// Then optional params (if applicable).
|
||||||
|
opts,
|
||||||
) {
|
) {
|
||||||
this.blockchain = dependencies.blockchain;
|
this.blockchain = dependencies.blockchain;
|
||||||
this.store = dependencies.store;
|
this.store = dependencies.store;
|
||||||
@@ -74,8 +78,6 @@ class SomeClass {
|
|||||||
|
|
||||||
When creating classes, create static `from(...)` (or `fromSpecificThing(...)`) methods.
|
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.
|
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:
|
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.
|
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
|
### 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.
|
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 './base-store.js';
|
||||||
export * from './store-composite.js';
|
export * from './store-composite.js';
|
||||||
export * from './store-inmemory.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.
|
* UUIDv7 keys are used to ensure uniqueness of keys.
|
||||||
*/
|
*/
|
||||||
export class StoreComposite extends BaseStore {
|
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);
|
const compositeStore = new this(localStore, remoteStores);
|
||||||
await compositeStore.sync();
|
await compositeStore.sync();
|
||||||
return compositeStore;
|
return compositeStore;
|
||||||
@@ -19,55 +22,9 @@ export class StoreComposite extends BaseStore {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private localStore: BaseStore,
|
private localStore: BaseStore,
|
||||||
private remoteStores: Array<BaseStore>,
|
private remoteStores: Array<BaseStore> = [],
|
||||||
) {
|
) {
|
||||||
super();
|
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>() {
|
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> {
|
async sync(): Promise<void> {
|
||||||
// Sync all remote stores in parallel
|
// Sync all remote stores in parallel
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.remoteStores.map(async (remoteStore) => {
|
this.remoteStores.map(async (remoteStore) =>
|
||||||
// TODO: We want to negotiate using an Inverse Bloom Filter first to optimize the sync.
|
this.syncWithRemote(remoteStore),
|
||||||
// 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);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { BaseStore } from './base-store.js';
|
|||||||
* This implementation can be used testing and caching of expensive operations.
|
* This implementation can be used testing and caching of expensive operations.
|
||||||
*/
|
*/
|
||||||
export class StoreInMemory extends BaseStore {
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@@ -1,292 +1,49 @@
|
|||||||
// An activity represents an event that should be saved to persistent storage(s).
|
import { type Activity } from '../storage';
|
||||||
// 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 { UUIDV7 } from '../utils';
|
//-----------------------------------------------------------------------------
|
||||||
import { BaseStore } from '../storage';
|
// Standardized Activities
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
// TODO: Think about different activities
|
export interface WalletName extends Activity {
|
||||||
// TemplateActivity
|
name: 'WalletName';
|
||||||
// CashConnectActivity
|
value: string;
|
||||||
// CoinLockActivity
|
}
|
||||||
|
|
||||||
// TODO: StorageMany adapter should be configurable to make it so set() cannot fail.
|
export interface WalletDescription extends Activity {
|
||||||
// However, for optimization, get() should probably only retrieve from a primary (usually local) adapter.
|
name: 'WalletDescription';
|
||||||
// Otherwise, shit will be too slow if we're doing a get() or all() from a remote source.
|
value: string;
|
||||||
// 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.
|
|
||||||
|
export interface WalletHDAddressGap extends Activity {
|
||||||
// TODO: I still haven't quite wrapped my head around LibAuth approach.
|
name: 'WalletHDAddressGap';
|
||||||
// But I imagine there'll be some kind of "Mutable" vs "Immutable" concept for Activities.
|
value: number;
|
||||||
// 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)
|
export interface TransactionFirstSeen extends Activity {
|
||||||
// 2. Syncing will take less time
|
name: 'TransactionFirstSeen';
|
||||||
// For the above, this probably means that we want to keep our Activities limited to one mutation.
|
value: string;
|
||||||
// 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).
|
export interface TransactionMemo extends Activity {
|
||||||
// Whereas, if we do:
|
name: 'TransactionMemo';
|
||||||
// { type: 'freezeCoin' }
|
value: string;
|
||||||
// ... 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"
|
// XO-specific Activities
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
// Type definitions
|
|
||||||
export type Activity<Type extends string, ValueType> = {
|
export interface XOWalletColor extends Activity {
|
||||||
timestamp?: bigint;
|
name: 'XOWalletColor';
|
||||||
type: Type;
|
value: string;
|
||||||
id: string;
|
}
|
||||||
value: ValueType;
|
|
||||||
};
|
export interface XOTransactionCategory extends Activity {
|
||||||
|
name: 'XOTransactionCategory';
|
||||||
// Standardized Activities.
|
value: string;
|
||||||
export type WalletName = Activity<'WalletName', string>;
|
}
|
||||||
export type WalletDescription = Activity<'WalletDescription', string>;
|
|
||||||
export type TransactionFirstSeen = Activity<'TransactionFirstSeen', string>;
|
export interface XOFrozenCoin extends Activity {
|
||||||
export type TransactionMemo = Activity<'TransactionMemo', string>;
|
name: 'XOFrozenCoin';
|
||||||
|
value: boolean;
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,22 @@ import {
|
|||||||
type WalletTransactions,
|
type WalletTransactions,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
import { type BlockchainUTXOs } from '../blockchain';
|
import {
|
||||||
import { type EventMap, EventEmitter } from '../utils';
|
type BlockchainTransactions,
|
||||||
|
type BlockchainUTXOs,
|
||||||
|
} from '../blockchain';
|
||||||
|
import { type Activities } from '../storage';
|
||||||
|
import { EventEmitter } from '../utils';
|
||||||
|
|
||||||
import { type TokenBalances, type Address } from '@xocash/primitives';
|
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.
|
* 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.
|
* 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<
|
export abstract class BaseWallet<
|
||||||
WalletEvents extends EventMap,
|
WalletEventsType extends WalletEvents = WalletEvents,
|
||||||
> extends EventEmitter<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.
|
* Start monitoring this wallet for any transaction activity.
|
||||||
*/
|
*/
|
||||||
@@ -136,28 +172,4 @@ export abstract class BaseWallet<
|
|||||||
abstract sendTransaction(
|
abstract sendTransaction(
|
||||||
template: Partial<TransactionTemplate>,
|
template: Partial<TransactionTemplate>,
|
||||||
): Promise<void>;
|
): 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-p2pkh.js';
|
||||||
export * from './wallet-p2sh20.js';
|
export * from './wallet-p2sh20.js';
|
||||||
export * from './wallet-p2sh32.js';
|
export * from './wallet-p2sh32.js';
|
||||||
export * from './wallet-set.js';
|
|
||||||
|
|||||||
@@ -26,6 +26,31 @@ import {
|
|||||||
* @experimental
|
* @experimental
|
||||||
*/
|
*/
|
||||||
export class TransactionBuilder {
|
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>;
|
public unspents: Array<WalletUTXO>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type BaseBlockchain, type BlockchainTransaction } from '../blockchain';
|
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 {
|
import {
|
||||||
type InputTemplate,
|
type InputTemplate,
|
||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
type Output,
|
type Output,
|
||||||
} from '@bitauth/libauth';
|
} from '@bitauth/libauth';
|
||||||
|
|
||||||
export type WalletServices = {
|
export type WalletDependencies = {
|
||||||
|
activities?: Activities;
|
||||||
blockchain: BaseBlockchain;
|
blockchain: BaseBlockchain;
|
||||||
store?: BaseStore;
|
|
||||||
cache?: BaseStore;
|
cache?: BaseStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import { BaseWallet } from './base-wallet.js';
|
import { BaseWallet } from './base-wallet.js';
|
||||||
import { type TransactionTemplate } from './types.js';
|
import { type TransactionTemplate } from './types.js';
|
||||||
import { TransactionBuilder } from './transaction-builder.js';
|
import { TransactionBuilder } from './transaction-builder.js';
|
||||||
import { type WalletServices } from './types.js';
|
import { type WalletDependencies } from './types.js';
|
||||||
import {
|
import { WalletP2PKH } from './wallet-p2pkh.js';
|
||||||
WalletP2PKH,
|
|
||||||
WalletP2PKHFactory,
|
|
||||||
useWalletP2PKH,
|
|
||||||
} from './wallet-p2pkh.js';
|
|
||||||
|
|
||||||
import {
|
import { type BaseBlockchain } from '../blockchain';
|
||||||
type BaseBlockchain,
|
import { type BaseStore, Activities, StoreInMemory } from '../storage';
|
||||||
type BlockchainTransactions,
|
|
||||||
type BlockchainUTXOs,
|
|
||||||
} from '../blockchain';
|
|
||||||
import {
|
|
||||||
type BaseStore,
|
|
||||||
type StoreValueProxy,
|
|
||||||
StoreInMemory,
|
|
||||||
} from '../storage';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type TokenBalances,
|
type TokenBalances,
|
||||||
@@ -45,15 +33,6 @@ export type WalletHDGenesisData = {
|
|||||||
addressGap?: number;
|
addressGap?: number;
|
||||||
} & ({ seed: string } | { mnemonic: string } | { xpriv: string });
|
} & ({ 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.
|
* A Hierarhical Determinstic (BIP44) Wallet.
|
||||||
*
|
*
|
||||||
@@ -78,92 +57,72 @@ export type WalletHDEvents<WalletType> = {
|
|||||||
* await wallet = await walletHD.start();
|
* await wallet = await walletHD.start();
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class WalletHD<
|
export class WalletHD extends BaseWallet {
|
||||||
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> } = {};
|
|
||||||
|
|
||||||
static async from<T extends typeof WalletHD>(
|
static async from<T extends typeof WalletHD>(
|
||||||
this: T,
|
this: T,
|
||||||
services: WalletServices,
|
services: WalletDependencies,
|
||||||
genesisData?: WalletHDGenesisData,
|
genesisData: WalletHDGenesisData,
|
||||||
walletFactory?: WalletP2PKHFactory<any>,
|
|
||||||
) {
|
) {
|
||||||
// Assign in memory stores as defaults in case otherwise not provided.
|
// Assign in memory stores as defaults in case otherwise not provided.
|
||||||
const servicesFinal = {
|
const servicesFinal = {
|
||||||
...{
|
...{
|
||||||
|
// Default to an in-memory-only Activity store.
|
||||||
|
activities: await Activities.fromStores(StoreInMemory.from()),
|
||||||
store: new StoreInMemory(),
|
store: new StoreInMemory(),
|
||||||
cache: new StoreInMemory(),
|
cache: new StoreInMemory(),
|
||||||
},
|
},
|
||||||
...services,
|
...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 a new instance of WalletHD.
|
||||||
return new this(
|
return new this(servicesFinal, genesisData) as InstanceType<T>;
|
||||||
servicesFinal.blockchain,
|
|
||||||
servicesFinal.store,
|
|
||||||
servicesFinal.cache,
|
|
||||||
hdPrivateNode,
|
|
||||||
walletFactory,
|
|
||||||
) 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(
|
constructor(
|
||||||
public blockchain: BaseBlockchain,
|
dependencies: Required<WalletDependencies>,
|
||||||
public store: BaseStore,
|
genesisData: WalletHDGenesisData,
|
||||||
public cache: BaseStore,
|
|
||||||
public hdPrivateNode: HDPrivateNode,
|
|
||||||
private walletFactory: WalletP2PKHFactory<WalletType> = useWalletP2PKH,
|
|
||||||
) {
|
) {
|
||||||
super();
|
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.
|
// Initialize empty arrays for our wallets.
|
||||||
this.wallets[CHAIN_EXTERNAL] = [];
|
this.wallets[CHAIN_EXTERNAL] = [];
|
||||||
this.wallets[CHAIN_INTERNAL] = [];
|
this.wallets[CHAIN_INTERNAL] = [];
|
||||||
|
|
||||||
// Initialize proxies to our stored data.
|
|
||||||
this.genesisData = this.store.proxy('genesis');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getId() {
|
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() {
|
async getEncryptionKey() {
|
||||||
// NOTE: Use the cache to optimize future calls.
|
// NOTE: Use the cache to optimize future calls.
|
||||||
return await this.cache.getOrSet('encryptionKey', async () =>
|
return await this.cache.getOrSet('encryptionKey', async () =>
|
||||||
@@ -189,12 +159,14 @@ export class WalletHD<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async getActivities(): Promise<Activities> {
|
||||||
// Get the genesis data.
|
return this.activities;
|
||||||
const genesisData = await this.genesisData.getOrFail();
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
// Get the address gap.
|
// 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.
|
// Scan the HDWallet for child wallets.
|
||||||
await this.scan(0, addressGap);
|
await this.scan(0, addressGap);
|
||||||
@@ -327,7 +299,7 @@ export class WalletHD<
|
|||||||
async getAddresses() {
|
async getAddresses() {
|
||||||
const chainPaths = [CHAIN_EXTERNAL, CHAIN_INTERNAL];
|
const chainPaths = [CHAIN_EXTERNAL, CHAIN_INTERNAL];
|
||||||
|
|
||||||
let addressPromises = [];
|
const addressPromises = [];
|
||||||
|
|
||||||
for (const chainPath of chainPaths) {
|
for (const chainPath of chainPaths) {
|
||||||
addressPromises.push(
|
addressPromises.push(
|
||||||
@@ -384,17 +356,15 @@ export class WalletHD<
|
|||||||
// Fetch unspents that are usable by this wallet.
|
// Fetch unspents that are usable by this wallet.
|
||||||
const unspents = await this.getUnspentDirectives();
|
const unspents = await this.getUnspentDirectives();
|
||||||
|
|
||||||
// Create an instance of our transaction builder and populate with our Wallet's UTXOs.
|
// Build the transaction.
|
||||||
// NOTE: We must deal with tokens first and satoshis last.
|
const tx = TransactionBuilder.build(
|
||||||
// The reason is that the change output will be determined based on transaction fee.
|
txTemplateFinal,
|
||||||
const txBuilder = new TransactionBuilder(txTemplateFinal, unspents)
|
unspents,
|
||||||
.addTokensFromUnspents()
|
changeLockingBytecode,
|
||||||
.addTokenChangeOutputs(changeLockingBytecode)
|
);
|
||||||
.addSatoshisFromUnspents()
|
|
||||||
.addSatoshiChangeOutput(changeLockingBytecode);
|
|
||||||
|
|
||||||
// Generate the transaction.
|
// Generate the transaction.
|
||||||
const generatedTxBytes = txBuilder.toBytes();
|
const generatedTxBytes = tx.toBytes();
|
||||||
|
|
||||||
return generatedTxBytes;
|
return generatedTxBytes;
|
||||||
}
|
}
|
||||||
@@ -407,38 +377,18 @@ export class WalletHD<
|
|||||||
await this.blockchain.broadcastTransaction(generatedTxBytes);
|
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(
|
async deriveWallets(
|
||||||
count: number,
|
count: number,
|
||||||
chainPath = CHAIN_EXTERNAL,
|
chainPath = CHAIN_EXTERNAL,
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
) {
|
) {
|
||||||
// Get the derivation path from the genesis data.
|
|
||||||
const genesisData = await this.genesisData.getOrFail();
|
|
||||||
|
|
||||||
// Create an array to store our wallets.
|
// Create an array to store our wallets.
|
||||||
const newWallets: Array<WalletType> = [];
|
const newWallets: Array<WalletP2PKH> = [];
|
||||||
|
|
||||||
// Create the given count of wallets.
|
// Create the given count of wallets.
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
// Define the derivation path.
|
// 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.
|
// Derive the private key at the given path.
|
||||||
const privateKeyHex = await this.cache.getOrSet(derivationPath, () => {
|
const privateKeyHex = await this.cache.getOrSet(derivationPath, () => {
|
||||||
@@ -447,7 +397,7 @@ export class WalletHD<
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create a child P2PKH wallet for this Private Key.
|
// Create a child P2PKH wallet for this Private Key.
|
||||||
const wallet = await this.walletFactory(
|
const wallet = await WalletP2PKH.from(
|
||||||
{
|
{
|
||||||
blockchain: this.blockchain,
|
blockchain: this.blockchain,
|
||||||
cache: this.cache,
|
cache: this.cache,
|
||||||
@@ -485,7 +435,7 @@ export class WalletHD<
|
|||||||
// Iterate over each of the provided chain (change) paths.
|
// Iterate over each of the provided chain (change) paths.
|
||||||
for (const chainPath of chainPaths) {
|
for (const chainPath of chainPaths) {
|
||||||
// Array to store active wallet nodes
|
// Array to store active wallet nodes
|
||||||
const wallets: Array<WalletType> = [];
|
const wallets: Array<WalletP2PKH> = [];
|
||||||
|
|
||||||
let currentIndex = startIndex;
|
let currentIndex = startIndex;
|
||||||
let emptyAddressCount = 0;
|
let emptyAddressCount = 0;
|
||||||
@@ -493,7 +443,7 @@ export class WalletHD<
|
|||||||
// Continue scanning until we find 'addressGap' consecutive empty addresses
|
// Continue scanning until we find 'addressGap' consecutive empty addresses
|
||||||
while (emptyAddressCount < addressGap) {
|
while (emptyAddressCount < addressGap) {
|
||||||
// Derive a number of wallets equivalent to our 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,
|
addressGap,
|
||||||
chainPath,
|
chainPath,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
@@ -527,19 +477,6 @@ export class WalletHD<
|
|||||||
|
|
||||||
this.emit('stateUpdated', null);
|
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> = (
|
export type WalletHDFactory<T extends WalletHD = WalletHD> = (
|
||||||
services: WalletServices,
|
services: WalletDependencies,
|
||||||
genesisData?: WalletHDGenesisData,
|
genesisData: WalletHDGenesisData,
|
||||||
walletFactory?: WalletP2PKHFactory<any>,
|
|
||||||
) => Promise<T>;
|
) => Promise<T>;
|
||||||
|
|
||||||
export async function useWalletHD<T extends WalletHD = WalletHD>(
|
export async function useWalletHD<T extends WalletHD = WalletHD>(
|
||||||
services: WalletServices,
|
services: WalletDependencies,
|
||||||
genesisData?: WalletHDGenesisData,
|
genesisData: WalletHDGenesisData,
|
||||||
walletFactory?: WalletP2PKHFactory<any>,
|
|
||||||
): Promise<T> {
|
): 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 { 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 { 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 {
|
import {
|
||||||
type BaseBlockchain,
|
|
||||||
type BlockchainTransaction,
|
|
||||||
type BlockchainUTXOs,
|
|
||||||
} from '../blockchain';
|
|
||||||
import { type BaseStore, StoreInMemory } from '../storage';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Address,
|
|
||||||
ExtMap,
|
ExtMap,
|
||||||
PrivateKey,
|
PrivateKey,
|
||||||
calculateBalanceSats,
|
calculateBalanceSats,
|
||||||
calculateBalanceTokens,
|
calculateBalanceTokens,
|
||||||
encodeExtendedJson,
|
|
||||||
} from '@xocash/primitives';
|
} from '@xocash/primitives';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Input,
|
type Input,
|
||||||
binToHex,
|
binToHex,
|
||||||
hash256,
|
hash256,
|
||||||
sha256,
|
|
||||||
utf8ToBin,
|
utf8ToBin,
|
||||||
walletTemplateP2pkhNonHd,
|
walletTemplateP2pkhNonHd,
|
||||||
walletTemplateToCompilerBCH,
|
walletTemplateToCompilerBCH,
|
||||||
@@ -34,12 +26,6 @@ export type WalletP2PKHGenesisData = {
|
|||||||
privateKey: string;
|
privateKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WalletP2PKHEvents = {
|
|
||||||
transactionsUpdated: WalletTransactions;
|
|
||||||
unspentsUpdated: BlockchainUTXOs;
|
|
||||||
stateUpdated: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -50,58 +36,28 @@ export type WalletP2PKHEvents = {
|
|||||||
* await walletP2PKH.startMonitoring();
|
* await walletP2PKH.startMonitoring();
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class WalletP2PKH extends BaseWallet<WalletP2PKHEvents> {
|
export class WalletP2PKH extends BaseWallet {
|
||||||
// Internal State.
|
|
||||||
public isStarted = false;
|
|
||||||
public status: string | null = null;
|
|
||||||
|
|
||||||
static async from<T extends typeof WalletP2PKH>(
|
static async from<T extends typeof WalletP2PKH>(
|
||||||
this: T,
|
this: T,
|
||||||
services: WalletServices,
|
dependencies: WalletDependencies,
|
||||||
genesisData?: WalletP2PKHGenesisData,
|
genesisData: WalletP2PKHGenesisData,
|
||||||
) {
|
) {
|
||||||
// Assign in memory stores as defaults in case otherwise not provided.
|
// 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(),
|
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 a new instance of Wallet P2PKH.
|
||||||
return new this(
|
return new this(dependenciesFinal, genesisData) as InstanceType<T>;
|
||||||
servicesFinal.blockchain,
|
|
||||||
servicesFinal.store,
|
|
||||||
servicesFinal.cache,
|
|
||||||
privateKey,
|
|
||||||
) as InstanceType<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromBytes<T extends typeof WalletP2PKH>(
|
static fromBytes<T extends typeof WalletP2PKH>(
|
||||||
this: T,
|
this: T,
|
||||||
services: WalletServices,
|
services: WalletDependencies,
|
||||||
privateKeyBytes: Uint8Array,
|
privateKeyBytes: Uint8Array,
|
||||||
) {
|
) {
|
||||||
// Create a Private Key entity from the provided bytes.
|
// 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>(
|
static fromHex<T extends typeof WalletP2PKH>(
|
||||||
this: T,
|
this: T,
|
||||||
services: WalletServices,
|
services: WalletDependencies,
|
||||||
privateKeyHex: string,
|
privateKeyHex: string,
|
||||||
) {
|
) {
|
||||||
// Create a Private Key entity from the provided bytes.
|
// 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>(
|
static fromWIF<T extends typeof WalletP2PKH>(
|
||||||
this: T,
|
this: T,
|
||||||
services: WalletServices,
|
services: WalletDependencies,
|
||||||
privateKeyWif: string,
|
privateKeyWif: string,
|
||||||
) {
|
) {
|
||||||
// Create a Private Key entity from the provided bytes.
|
// 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>(
|
static generateRandom<T extends typeof WalletP2PKH>(
|
||||||
this: T,
|
this: T,
|
||||||
services: WalletServices,
|
services: WalletDependencies,
|
||||||
) {
|
) {
|
||||||
// Create a Private Key entity generated from random bytes.
|
// Create a Private Key entity generated from random bytes.
|
||||||
const privateKey = PrivateKey.generateRandom();
|
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(
|
constructor(
|
||||||
// Dependencies.
|
dependencies: Required<WalletDependencies>,
|
||||||
public blockchain: BaseBlockchain,
|
genesisData: WalletP2PKHGenesisData,
|
||||||
// Persistent Storage.
|
|
||||||
public store: BaseStore,
|
|
||||||
// Cache.
|
|
||||||
public cache: BaseStore,
|
|
||||||
// Properties.
|
|
||||||
public privateKey: PrivateKey,
|
|
||||||
) {
|
) {
|
||||||
super();
|
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() {
|
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() {
|
async getEncryptionKey() {
|
||||||
// NOTE: Use the cache to optimize future calls.
|
// NOTE: Use the cache to optimize future calls.
|
||||||
return await this.cache.getOrSet('encryptionKey', async () =>
|
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() {
|
async start() {
|
||||||
// Do nothing if already started.
|
// Do nothing if already started.
|
||||||
if (this.isStarted) {
|
if (this.isStarted) {
|
||||||
@@ -396,22 +387,15 @@ export class WalletP2PKH extends BaseWallet<WalletP2PKHEvents> {
|
|||||||
// Fetch unspents that are usable by this wallet.
|
// Fetch unspents that are usable by this wallet.
|
||||||
const unspents = await this.getUnspentDirectives();
|
const unspents = await this.getUnspentDirectives();
|
||||||
|
|
||||||
// Create an instance of our transaction builder.
|
// Build the transaction.
|
||||||
const txBuilder = new TransactionBuilder(txTemplateFinal, unspents);
|
const tx = TransactionBuilder.build(
|
||||||
|
txTemplateFinal,
|
||||||
// Append necessary unspents to satisfy amount requirements.
|
unspents,
|
||||||
// NOTE: The order here is very important.
|
changeLockingBytecode,
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Generate the transaction.
|
// Generate the transaction.
|
||||||
const generatedTxBytes = txBuilder.toBytes();
|
const generatedTxBytes = tx.toBytes();
|
||||||
|
|
||||||
return generatedTxBytes;
|
return generatedTxBytes;
|
||||||
}
|
}
|
||||||
@@ -434,34 +418,6 @@ export class WalletP2PKH extends BaseWallet<WalletP2PKHEvents> {
|
|||||||
this.emit('stateUpdated', this.status);
|
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> = (
|
export type WalletP2PKHFactory<T extends WalletP2PKH = WalletP2PKH> = (
|
||||||
services: WalletServices,
|
dependencies: WalletDependencies,
|
||||||
genesisData?: WalletP2PKHGenesisData,
|
genesisData: WalletP2PKHGenesisData,
|
||||||
) => Promise<T>;
|
) => Promise<T>;
|
||||||
|
|
||||||
export async function useWalletP2PKH<T extends WalletP2PKH = WalletP2PKH>(
|
export async function useWalletP2PKH<T extends WalletP2PKH = WalletP2PKH>(
|
||||||
services: WalletServices,
|
dependencies: WalletDependencies,
|
||||||
genesisData?: WalletP2PKHGenesisData,
|
genesisData: WalletP2PKHGenesisData,
|
||||||
): Promise<T> {
|
): 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 { 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 = {
|
type TestEvents = {
|
||||||
test: { data: string };
|
test: { data: string };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
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', () => {
|
describe('UUIDV7', () => {
|
||||||
// Save and restore the Date.now implementation
|
// Save and restore the Date.now implementation
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
import { describe, test, expect, beforeEach } from 'vitest';
|
import { describe, test, expect, beforeEach } from 'vitest';
|
||||||
import { Activities } from '~/src/wallet';
|
import { Activities, StoreInMemory } from '../../src/storage';
|
||||||
import { StoreInMemory } from '~/src/storage';
|
|
||||||
|
|
||||||
describe('Activities', () => {
|
describe('Activities', () => {
|
||||||
let store: StoreInMemory;
|
let store: StoreInMemory;
|
||||||
let activities: Activities;
|
let activities: Activities;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
store = new StoreInMemory();
|
activities = await Activities.fromStores(StoreInMemory.from());
|
||||||
activities = new Activities(store);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add activity with auto-generated timestamp', async () => {
|
test('should add activity with auto-generated timestamp', async () => {
|
||||||
const activity = {
|
const activity = {
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Test Wallet',
|
value: 'Test Wallet',
|
||||||
};
|
};
|
||||||
|
|
||||||
await activities.addActivity(activity);
|
await activities.add(activity);
|
||||||
await activities.compileSnapshot();
|
|
||||||
|
|
||||||
const storedActivity = activities.getActivity('WalletName', 'wallet1');
|
const storedActivity = activities.get('WalletName', 'wallet1');
|
||||||
expect(storedActivity).toBeDefined();
|
expect(storedActivity).toBeDefined();
|
||||||
expect(storedActivity?.timestamp).toBeDefined();
|
expect(storedActivity?.timestamp).toBeDefined();
|
||||||
expect(storedActivity?.value).toBe('Test Wallet');
|
expect(storedActivity?.value).toBe('Test Wallet');
|
||||||
@@ -31,15 +28,14 @@ describe('Activities', () => {
|
|||||||
const timestamp = BigInt(1234567890000);
|
const timestamp = BigInt(1234567890000);
|
||||||
const activity = {
|
const activity = {
|
||||||
timestamp,
|
timestamp,
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Test Wallet',
|
value: 'Test Wallet',
|
||||||
};
|
};
|
||||||
|
|
||||||
await activities.addActivity(activity);
|
await activities.add(activity);
|
||||||
const snapshot = await activities.compileSnapshot();
|
|
||||||
|
|
||||||
const storedActivity = activities.getActivity('WalletName', 'wallet1');
|
const storedActivity = activities.get('WalletName', 'wallet1');
|
||||||
expect(storedActivity?.timestamp).toBe(timestamp);
|
expect(storedActivity?.timestamp).toBe(timestamp);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,22 +43,21 @@ describe('Activities', () => {
|
|||||||
const oldTimestamp = BigInt(1000);
|
const oldTimestamp = BigInt(1000);
|
||||||
const newTimestamp = BigInt(2000);
|
const newTimestamp = BigInt(2000);
|
||||||
|
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: oldTimestamp,
|
timestamp: oldTimestamp,
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Old Name',
|
value: 'Old Name',
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: newTimestamp,
|
timestamp: newTimestamp,
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'New Name',
|
value: 'New Name',
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.compileSnapshot();
|
const activity = activities.get('WalletName', 'wallet1');
|
||||||
const activity = activities.getActivity('WalletName', 'wallet1');
|
|
||||||
|
|
||||||
expect(activity?.value).toBe('New Name');
|
expect(activity?.value).toBe('New Name');
|
||||||
expect(activity?.timestamp).toBe(newTimestamp);
|
expect(activity?.timestamp).toBe(newTimestamp);
|
||||||
@@ -76,22 +71,21 @@ describe('Activities', () => {
|
|||||||
const oldTimestamp = BigInt(1000);
|
const oldTimestamp = BigInt(1000);
|
||||||
const newTimestamp = BigInt(2000);
|
const newTimestamp = BigInt(2000);
|
||||||
|
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: oldTimestamp,
|
timestamp: oldTimestamp,
|
||||||
type: 'TransactionFirstSeen',
|
name: 'TransactionFirstSeen',
|
||||||
id: 'tx1',
|
entityId: 'tx1',
|
||||||
value: 'First Value',
|
value: 'First Value',
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: newTimestamp,
|
timestamp: newTimestamp,
|
||||||
type: 'TransactionFirstSeen',
|
name: 'TransactionFirstSeen',
|
||||||
id: 'tx1',
|
entityId: 'tx1',
|
||||||
value: 'Second Value',
|
value: 'Second Value',
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await activities.compileSnapshot();
|
const activity = activities.get('TransactionFirstSeen', 'tx1');
|
||||||
const activity = activities.getActivity('TransactionFirstSeen', 'tx1');
|
|
||||||
|
|
||||||
expect(activity?.value).toBe('First Value');
|
expect(activity?.value).toBe('First Value');
|
||||||
expect(activity?.timestamp).toBe(oldTimestamp);
|
expect(activity?.timestamp).toBe(oldTimestamp);
|
||||||
@@ -101,25 +95,24 @@ describe('Activities', () => {
|
|||||||
// Policy that keeps activity with highest value
|
// Policy that keeps activity with highest value
|
||||||
activities.registerPolicy('CustomType', (existing, newer) => {
|
activities.registerPolicy('CustomType', (existing, newer) => {
|
||||||
if (!existing) return 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),
|
timestamp: BigInt(1000),
|
||||||
type: 'CustomType',
|
name: 'CustomType',
|
||||||
id: 'custom1',
|
entityId: 'custom1',
|
||||||
value: 50,
|
value: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: BigInt(2000),
|
timestamp: BigInt(2000),
|
||||||
type: 'CustomType',
|
name: 'CustomType',
|
||||||
id: 'custom1',
|
entityId: 'custom1',
|
||||||
value: 30,
|
value: 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await activities.compileSnapshot();
|
const activity = activities.get('CustomType', 'custom1');
|
||||||
const activity = activities.getActivity('CustomType', 'custom1');
|
|
||||||
|
|
||||||
expect(activity?.value).toBe(50);
|
expect(activity?.value).toBe(50);
|
||||||
});
|
});
|
||||||
@@ -128,55 +121,50 @@ describe('Activities', () => {
|
|||||||
const testActivities = [
|
const testActivities = [
|
||||||
{
|
{
|
||||||
timestamp: BigInt(3000),
|
timestamp: BigInt(3000),
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Name 3',
|
value: 'Name 3',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: BigInt(1000),
|
timestamp: BigInt(1000),
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Name 1',
|
value: 'Name 1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: BigInt(2000),
|
timestamp: BigInt(2000),
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Name 2',
|
value: 'Name 2',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const activity of testActivities) {
|
for (const activity of testActivities) {
|
||||||
await activities.addActivity(activity);
|
await activities.add(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
await activities.compileSnapshot();
|
const finalActivity = activities.get('WalletName', 'wallet1');
|
||||||
const finalActivity = activities.getActivity('WalletName', 'wallet1');
|
|
||||||
|
|
||||||
expect(finalActivity?.value).toBe('Name 3');
|
expect(finalActivity?.value).toBe('Name 3');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle multiple activity types independently', async () => {
|
test('should handle multiple activity types independently', async () => {
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: BigInt(1000),
|
timestamp: BigInt(1000),
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'My Wallet',
|
value: 'My Wallet',
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: BigInt(2000),
|
timestamp: BigInt(2000),
|
||||||
type: 'WalletDescription',
|
name: 'WalletDescription',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'My Description',
|
value: 'My Description',
|
||||||
});
|
});
|
||||||
|
|
||||||
await activities.compileSnapshot();
|
expect(activities.get('WalletName', 'wallet1')?.value).toBe('My Wallet');
|
||||||
|
expect(activities.get('WalletDescription', 'wallet1')?.value).toBe(
|
||||||
expect(activities.getActivity('WalletName', 'wallet1')?.value).toBe(
|
|
||||||
'My Wallet',
|
|
||||||
);
|
|
||||||
expect(activities.getActivity('WalletDescription', 'wallet1')?.value).toBe(
|
|
||||||
'My Description',
|
'My Description',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -184,75 +172,37 @@ describe('Activities', () => {
|
|||||||
test('should update snapshot immediately when activity is added', async () => {
|
test('should update snapshot immediately when activity is added', async () => {
|
||||||
const activity = {
|
const activity = {
|
||||||
timestamp: BigInt(1000),
|
timestamp: BigInt(1000),
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Test Wallet',
|
value: 'Test Wallet',
|
||||||
};
|
};
|
||||||
|
|
||||||
await activities.addActivity(activity);
|
await activities.add(activity);
|
||||||
|
|
||||||
// Check snapshot without calling compileSnapshot
|
// Check snapshot without calling compileSnapshot
|
||||||
const storedActivity = activities.getActivity('WalletName', 'wallet1');
|
const storedActivity = activities.get('WalletName', 'wallet1');
|
||||||
expect(storedActivity?.value).toBe('Test Wallet');
|
expect(storedActivity?.value).toBe('Test Wallet');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle missing timestamp gracefully', async () => {
|
test('should handle missing timestamp gracefully', async () => {
|
||||||
const activity = {
|
const activity = {
|
||||||
type: 'WalletName',
|
name: 'WalletName',
|
||||||
id: 'wallet1',
|
entityId: 'wallet1',
|
||||||
value: 'Test Wallet',
|
value: 'Test Wallet',
|
||||||
};
|
};
|
||||||
|
|
||||||
await activities.addActivity(activity);
|
await activities.add(activity);
|
||||||
await activities.compileSnapshot();
|
const storedActivity = activities.get('WalletName', 'wallet1');
|
||||||
const storedActivity = activities.getActivity('WalletName', 'wallet1');
|
|
||||||
|
|
||||||
expect(storedActivity).toBeDefined();
|
expect(storedActivity).toBeDefined();
|
||||||
expect(storedActivity?.timestamp).toBeDefined();
|
expect(storedActivity?.timestamp).toBeDefined();
|
||||||
expect(typeof storedActivity?.timestamp).toBe('bigint');
|
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 () => {
|
test('should cleanup old activities from store when newer versions are added', async () => {
|
||||||
// Add first activity
|
// Add first activity
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: BigInt(1000),
|
timestamp: BigInt(1000),
|
||||||
type: 'WalletName',
|
type: 'WalletName',
|
||||||
id: 'wallet1',
|
id: 'wallet1',
|
||||||
@@ -262,7 +212,7 @@ describe('Activities', () => {
|
|||||||
expect(Object.keys(await store.all()).length).toBe(1);
|
expect(Object.keys(await store.all()).length).toBe(1);
|
||||||
|
|
||||||
// Add newer version - should trigger cleanup of old version
|
// Add newer version - should trigger cleanup of old version
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: BigInt(2000),
|
timestamp: BigInt(2000),
|
||||||
type: 'WalletName',
|
type: 'WalletName',
|
||||||
id: 'wallet1',
|
id: 'wallet1',
|
||||||
@@ -273,7 +223,7 @@ describe('Activities', () => {
|
|||||||
expect(Object.keys(await store.all()).length).toBe(1);
|
expect(Object.keys(await store.all()).length).toBe(1);
|
||||||
|
|
||||||
// Add different type
|
// Add different type
|
||||||
await activities.addActivity({
|
await activities.add({
|
||||||
timestamp: BigInt(1500),
|
timestamp: BigInt(1500),
|
||||||
type: 'WalletDescription',
|
type: 'WalletDescription',
|
||||||
id: 'wallet1',
|
id: 'wallet1',
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"paths": {
|
"baseUrl": ".",
|
||||||
"~/*": ["./*"]
|
"lib": [
|
||||||
},
|
"dom"
|
||||||
"lib": ["dom"]
|
]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": [
|
||||||
"exclude": ["node_modules", "dist"]
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -10,10 +10,10 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": "."
|
||||||
"paths": {
|
|
||||||
"*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user