Resolve "WIP: UTXO Selection and Storage"
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
// An activity represents an event that should be saved to persistent storage(s).
|
||||
// TODO: Read more into LibAuth's concept of these.
|
||||
// Jason mentions pruning, but I don't quite understand how that will be done yet.
|
||||
// He also has the concept of "extensions" - but I'm not sure this has been fleshed out yet.
|
||||
|
||||
import { generateUUIDV7, getTimestampFromUUID } from '../utils';
|
||||
import { BaseStore } from '../storage';
|
||||
|
||||
// TODO: Think about different activities
|
||||
// TemplateActivity
|
||||
// CashConnectActivity
|
||||
// CoinLockActivity
|
||||
|
||||
// TODO: StorageMany adapter should be configurable to make it so set() cannot fail.
|
||||
// However, for optimization, get() should probably only retrieve from a primary (usually local) adapter.
|
||||
// Otherwise, shit will be too slow if we're doing a get() or all() from a remote source.
|
||||
// This may also solve the AES256 problem: Local does not need to be encrypted per se.
|
||||
// Sync from Remote (encrypted) to Local (non-encrypted) can happen in the background.
|
||||
|
||||
// TODO: I still haven't quite wrapped my head around LibAuth approach.
|
||||
// But I imagine there'll be some kind of "Mutable" vs "Immutable" concept for Activities.
|
||||
// Because we need to compile a "snapshot" based on Activities, if we add a Mutable Activity, we can prune previous Mutable Activities of the same identifier.
|
||||
// This will help massively with optimization:
|
||||
// 1. Our set of Activities becomes smaller (computation/storage = faster/better)
|
||||
// 2. Syncing will take less time
|
||||
// For the above, this probably means that we want to keep our Activities limited to one mutation.
|
||||
// For example, we ideally wouldn't want an Activity that modifies/refers to multiple values.
|
||||
// { type: 'freezeCoins', coins: [...] }
|
||||
// ... because we cannot sync them efficiently (we need the entire history).
|
||||
// Whereas, if we do:
|
||||
// { type: 'freezeCoin' }
|
||||
// ... and have an identifer, we can ensure that each wallet only syncs the latest frozen coin.
|
||||
// Amendment 2024-11-20:
|
||||
// For mutable, we could use property "unique: boolean".
|
||||
// For priority (e.g. txTime activity), we could use property "policy: "earlist" | latest"
|
||||
|
||||
// Type definitions
|
||||
export type Activity<Type extends string, ValueType> = {
|
||||
timestamp?: bigint;
|
||||
type: Type;
|
||||
id: string;
|
||||
value: ValueType;
|
||||
};
|
||||
|
||||
// Standardized Activities.
|
||||
export type WalletName = Activity<'WalletName', string>;
|
||||
export type WalletDescription = Activity<'WalletDescription', string>;
|
||||
export type TransactionFirstSeen = Activity<'TransactionFirstSeen', string>;
|
||||
export type TransactionMemo = Activity<'TransactionMemo', string>;
|
||||
|
||||
// XO-specific Activities.
|
||||
// TODO: These probably don't belong here and should be moved to the Wallet Codebase.
|
||||
export type XOWalletColor = Activity<'XOWalletColor', string>;
|
||||
export type XOTransactionCategory = Activity<'XOTransactionCategory', string>;
|
||||
export type XOFrozenCoin = Activity<'XOFrozenCoin', boolean>;
|
||||
|
||||
export type SnapshotByType = {
|
||||
[type: string]: {
|
||||
[id: string]: Activity<string, any>;
|
||||
};
|
||||
};
|
||||
|
||||
// Policy type definitions
|
||||
export type PolicyFunction<T = any> = (
|
||||
existingActivity: Activity<string, T> | undefined,
|
||||
newActivity: Activity<string, T>,
|
||||
) => Activity<string, T>;
|
||||
|
||||
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.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.delete(oldStoreKey);
|
||||
}
|
||||
this.storeKeyMap.set(snapshotKey, storeKey);
|
||||
} else {
|
||||
// If we kept the existing activity, delete the new activity from the store
|
||||
await this.store.delete(storeKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = generateUUIDV7(timestamp);
|
||||
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);
|
||||
|
||||
// Sort activities by timestamp using UUID v7's embedded timestamp
|
||||
const activitiesSorted = entries.sort(([keyA], [keyB]) => {
|
||||
const timestampA = getTimestampFromUUID(keyA);
|
||||
const timestampB = getTimestampFromUUID(keyB);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user