Resolve "WIP: UTXO Selection and Storage"

This commit is contained in:
Jim Hamill
2025-01-11 08:39:24 +00:00
parent f934216d68
commit 33fd9bac4d
156 changed files with 13304 additions and 4595 deletions
+240
View File
@@ -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);
}
}