Doc commit tag conventions

This commit is contained in:
Jim Hamill
2025-03-10 05:36:16 +00:00
parent 307650e9fc
commit 0346f3f79e
23 changed files with 1290 additions and 286 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
export * from './index.js';
// These exports will only work in a NodeJS (backend) environment.
// NOTE: We import and export each module with a common alias so that any leveraging libraries can use the common name and maintain cross-platform compatibility.
// export * from './storage/storage-local.node.js';
// TODO: FIx me!
// export * from './storage/store-sqlite.js';
+11
View File
@@ -0,0 +1,11 @@
// A P2P Adapter does not HAVE to be LibP2P.
// We could create an adapter to use a centralized server.
export abstract class P2P {
// from(bootstrapNodes)
abstract createChannel(): Promise<void>;
abstract findPeer(peerId: string): Promise<void>;
}
export abstract class P2PChannel {}
+12 -1
View File
@@ -1,4 +1,4 @@
import { Encrypter, EventEmitter } from '../utils';
import { Encrypter, EventEmitter, InverseBloomFilter } from '../utils';
export type StorageOpts = {
syncInMemory?: boolean;
@@ -268,4 +268,15 @@ export abstract class BaseStore extends EventEmitter<StoreEvents> {
},
};
}
async computeInverseBloomFilter(expectedDiffSize?: number) {
// Get all keys.
const keys = await this.keys();
// Construct an Inverse Bloom Filter using the keys.
const ibf = InverseBloomFilter.from(keys, expectedDiffSize);
// Return the bloom filter.
return ibf;
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './base-store.js';
export * from './store-inmemory.js';
export * from './store-inmemory-synced.js';
export * from './store-remote-http.js';
@@ -0,0 +1,146 @@
import { BaseStore } from './base-store.js';
/**
* A store that accepts multiple stores and syncs them by key.
*
* @remarks
*
* Syncing is done by key - if values differ, then this will remain undetected.
* This is by design - local stores will sometimes prune irrelevant values as an optimization (while retaining the key).
* However, this cannot be done on the encrypted stores as the encrypted stores cannot inspect the data to know whether relevant/irrelevant.
* UUIDv7 keys are used to ensure uniqueness of keys.
*/
export class StoreComposite extends BaseStore {
static async from(localStore: BaseStore, remoteStores: Array<BaseStore>) {
const compositeStore = new this(localStore, remoteStores);
await compositeStore.sync();
return compositeStore;
}
constructor(
private localStore: BaseStore,
private remoteStores: Array<BaseStore>,
) {
super();
// Local store events propagate to remote stores
this.localStore.on('set', async (payload) => {
// Propagate to all remote stores in parallel
await Promise.all(
this.remoteStores.map((remoteStore) =>
remoteStore.set(payload.key, payload.value),
),
);
});
// Local store events propagate to remote stores
this.localStore.on('delete', async (payload) => {
// Propagate to all remote stores in parallel
await Promise.all(
this.remoteStores.map((remoteStore) => remoteStore.delete(payload.key)),
);
});
// Local store events propagate to remote stores
this.localStore.on('clear', async () => {
// Propagate to all remote stores in parallel
await Promise.all(
this.remoteStores.map((remoteStore) => remoteStore.clear()),
);
});
// Remote store events propagate to local store
for (const remoteStore of this.remoteStores) {
remoteStore.on('set', async (payload) => {
// Skip if the local store already has the key
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);
}
});
}
}
async all<T>() {
return await this.localStore.all<T>();
}
async keys() {
return await this.localStore.keys();
}
async get<T>(key: string) {
return await this.localStore.get<T>(key);
}
async set<T>(key: string, value: T) {
await this.localStore.set(key, value);
this.emit('set', { key, value });
}
async delete(key: string) {
await this.localStore.delete(key);
this.emit('delete', { key });
}
async clear() {
await this.localStore.clear();
this.emit('clear', undefined);
}
async has(key: string) {
return await this.localStore.has(key);
}
async close() {
await Promise.all([
this.localStore.close(),
...this.remoteStores.map((store) => store.close()),
]);
}
async sync(): Promise<void> {
// Sync all remote stores in parallel
await Promise.all(
this.remoteStores.map(async (remoteStore) => {
// TODO: We want to negotiate using an Inverse Bloom Filter first to optimize the sync.
// Currently, this is doing a naive set comparison.
// Get all keys from the remote store
const remoteKeys = await remoteStore.keys();
const localKeys = await this.localStore.keys();
// Find keys missing in local store
const missingLocalKeys = remoteKeys.filter(
(key) => !localKeys.includes(key),
);
// Find keys missing in remote store
const missingRemoteKeys = localKeys.filter(
(key) => !remoteKeys.includes(key),
);
// Sync missing keys from remote to local (in parallel)
await Promise.all(
missingLocalKeys.map(async (key) => {
const value = await remoteStore.get(key);
await this.localStore.set(key, value);
}),
);
// Sync missing keys from local to remote (in parallel)
await Promise.all(
missingRemoteKeys.map(async (key) => {
const value = await this.localStore.get(key);
await remoteStore.set(key, value);
}),
);
}),
);
}
}
@@ -0,0 +1,189 @@
import { BaseStore } from './base-store.js';
import { Encrypter, SSEClient } from '../utils';
import { Bytes, PrivateKey, PublicKey } from '@xocash/primitives';
import { pack, unpack } from 'msgpackr';
import { z } from 'zod';
export class StoreRemoteHTTP extends BaseStore {
static async from(
baseURL: string,
privateKey: PrivateKey,
encrypter: Encrypter,
) {
const store = new this(baseURL, privateKey, encrypter);
await store.subscribeEvents();
return store;
}
// Optimizations/Caches.
protected publicKey: PublicKey;
constructor(
protected baseURL: string,
protected privateKey: PrivateKey,
protected encrypter: Encrypter,
) {
super();
// Derive the public key and cache it to optimize performance.
this.publicKey = privateKey.derivePublicKey();
}
async keys() {
const response = await this.makeRequest('keys');
return response;
}
async get<T>(key: string) {
const response = await this.makeRequest('get', { key });
if (!response.value) {
return undefined;
}
// Decrypt the value using the encrypter
const decryptedValue = await this.encrypter.decrypt(
new Uint8Array(response.value),
);
// Parse the decrypted MessagePack value
return unpack(decryptedValue) as T;
}
async set<T>(key: string, value: T): Promise<void> {
// Pack the encoded value with MessagePack and encrypt it
const packedValue = pack(value);
const encryptedValue = await this.encrypter.encrypt(packedValue);
await this.makeRequest('set', {
key,
value: encryptedValue, // Convert Uint8Array to regular array for MessagePack
});
}
async delete(key: string): Promise<void> {
await this.makeRequest('delete', { key });
}
async subscribeEvents() {
const timestamp = Math.floor(Date.now() / 1000);
const signature = await StoreRemoteHTTP.signRequest(
this.privateKey.toHex(),
timestamp,
'events',
{},
);
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Public-Key': this.publicKey.toHex(),
'X-Signature': Bytes.fromBytes(signature).toHex(),
'X-Timestamp': timestamp.toString(),
};
const sseClient = new SSEClient(`${this.baseURL}events`, {
method: 'POST',
headers,
onMessage: (e) => {
// Validate the payload using Zod.
// TODO: Use msgpackr
const payload = StoreRemoteHTTPEventSchema.parse(JSON.parse(e.data));
// Emit the event (update) to any listeners.
this.emit(payload.action, {
key: payload.key,
value: payload.value,
});
},
body: JSON.stringify({}),
});
await sseClient.connect();
}
private async makeRequest(endpoint: string, payload: object = {}) {
const timestamp = Math.floor(Date.now() / 1000);
const signature = await StoreRemoteHTTP.signRequest(
this.privateKey.toHex(),
timestamp,
endpoint,
payload,
);
const headers = {
'Content-Type': 'application/msgpackr',
Accept: 'application/msgpackr',
'X-Public-Key': this.publicKey.toHex(),
'X-Signature': Bytes.fromBytes(signature).toHex(),
'X-Timestamp': timestamp.toString(),
};
const requestOptions = {
method: 'POST',
headers,
body: payload ? pack(payload) : pack({}),
};
// Use the URL class to compile the full path.
// NOTE: We do this to cater for those situations where the base URL might contain a trailing slash.
// E.g. https://wallet-sync.xo.cash/store/ vs https://wallet-sync.xo.cash/store
// TODO: THis is broken and currently requires a trailing slash in the BaseURL!
const requestURL = new URL(endpoint, this.baseURL).toString();
const response = await fetch(requestURL, requestOptions);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseBuffer = await response.arrayBuffer();
return unpack(new Uint8Array(responseBuffer));
}
static async signRequest(
privateKeyHex: string,
timestamp: number,
endpoint: string,
payload?: object,
) {
const messageBin = pack([endpoint, timestamp, payload]);
return PrivateKey.fromHex(privateKeyHex).signMessageSchnorr(messageBin);
}
static async verifySignature(
publicKeyHex: string,
signatureHex: string,
timestamp: number,
endpoint: string,
payload?: object,
) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 60) {
throw new Error(`Request has expired`);
}
const messageBin = pack([endpoint, timestamp, payload]);
const isValidSignature = PublicKey.fromHex(
publicKeyHex,
).verifyMessageSchnorr(messageBin, signatureHex);
if (!isValidSignature) {
throw new Error(`The provided signature is invalid`);
}
}
}
export const StoreRemoteHTTPEventSchema = z.object({
publicKey: z.string(),
action: z.enum(['set', 'delete', 'clear']),
key: z.string(),
value: z.string(),
timestamp: z.number(),
});
export type StoreRemoteHTTPEvent = z.infer<typeof StoreRemoteHTTPEventSchema>;
+7 -1
View File
@@ -1,7 +1,7 @@
// TODO: Consider whether we should shuffle this to Stack Util (as opposed to Primitive).
// It is async which breaks from our Primitive patterns.
import { utf8ToBin } from '@bitauth/libauth';
import { generateRandomBytes, utf8ToBin } from '@bitauth/libauth';
// NOTE: We need to provide a default salt otherwise we would be deriving a key per each operation.
// This would be far too computationally expensive.
@@ -74,6 +74,12 @@ export class Encrypter {
return this.fromBytes(key, opts);
}
static async generateRandom(opts: Partial<EncrypterOpts> = {}) {
const key = generateRandomBytes(32);
return this.fromBytes(key, opts);
}
async encrypt(payload: Uint8Array | string): Promise<Uint8Array> {
// Convert string payload to Uint8Array if needed
const data = typeof payload === 'string' ? utf8ToBin(payload) : payload;
+2
View File
@@ -2,6 +2,8 @@ export * from './encrypter.js';
export * from './event-emitter.js';
export * from './ext-error.js';
export * from './hasher.js';
export * from './inverse-bloom-filter.js';
export * from './memoizer.js';
export * from './misc.js';
export * from './sse-client.js';
export * from './uuid.js';
@@ -15,7 +15,11 @@ interface Cell {
hashSum: bigint;
}
class InverseBloomFilter {
export class InverseBloomFilter {
static from(items: string[], expectedDiffSize?: number) {
return new this(items, expectedDiffSize);
}
private size: number;
private cells: Cell[];
private items: Set<string>;
@@ -36,6 +40,41 @@ class InverseBloomFilter {
}
}
findMaybeMissing(otherFilterBytes: Uint8Array): string[] {
const diffCells = this.subtractFilter(otherFilterBytes);
const missing: string[] = [];
for (const item of this.items) {
const idx = this.hashToIndex(item);
const itemId = this.hashToId(item);
const cell = diffCells[idx];
if (cell.count === 1 && cell.idSum === itemId) {
missing.push(item);
}
}
return missing;
}
toBytes(): Uint8Array {
const cellSize = 4 + 8 + 8;
const bytes = new Uint8Array(8 + this.size * cellSize);
const view = new DataView(bytes.buffer);
view.setBigInt64(0, BigInt(this.size), true);
let offset = 8;
for (const cell of this.cells) {
view.setInt32(offset, cell.count, true);
view.setBigInt64(offset + 4, cell.idSum, true);
view.setBigInt64(offset + 12, cell.hashSum, true);
offset += cellSize;
}
return bytes;
}
private hashToIndex(value: string): number {
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
@@ -68,24 +107,6 @@ class InverseBloomFilter {
cell.hashSum ^= itemId;
}
toBytes(): Uint8Array {
const cellSize = 4 + 8 + 8;
const bytes = new Uint8Array(8 + this.size * cellSize);
const view = new DataView(bytes.buffer);
view.setBigInt64(0, BigInt(this.size), true);
let offset = 8;
for (const cell of this.cells) {
view.setInt32(offset, cell.count, true);
view.setBigInt64(offset + 4, cell.idSum, true);
view.setBigInt64(offset + 12, cell.hashSum, true);
offset += cellSize;
}
return bytes;
}
private subtractFilter(otherBytes: Uint8Array): Cell[] {
const view = new DataView(otherBytes.buffer);
const otherSize = Number(view.getBigInt64(0, true));
@@ -114,21 +135,4 @@ class InverseBloomFilter {
return result;
}
findMaybeMissing(otherFilterBytes: Uint8Array): string[] {
const diffCells = this.subtractFilter(otherFilterBytes);
const missing: string[] = [];
for (const item of this.items) {
const idx = this.hashToIndex(item);
const itemId = this.hashToId(item);
const cell = diffCells[idx];
if (cell.count === 1 && cell.idSum === itemId) {
missing.push(item);
}
}
return missing;
}
}
+254 -173
View File
@@ -1,205 +1,286 @@
// TODO: This probably needs some work.
// The reason this exists is that Browser's native EventSource is rubbish:
// 1. It does not allow passing of headers.
// 2. It is browser-only (this implementation aims to work for Node also)
// 3. It does not handle disconnects/reconnects gracefully
// 4. It does not handle last-event-id (important for history).
// 5. It does not allow a custom fetch function (probably not necessary, but there's a reason I'd rather support this which I'll explain some other time).
// This might eventually be shifted out of XO/stack package and into its own.
// Q: "Why not just leverage an existing package?"
// A: They're all bad - the "go to" is a Microsoft (@azure/fetch-event-soruce) that is no longer maintained.
// It does not support NodeJS (or Workers) and we cannot depend upon forks of it.
export interface SSEOptions {
fetch?: typeof fetch;
headers?: Record<string, string>;
onMessage?: (event: SSEMessageEvent) => void;
onError?: (error: Error) => void;
onOpen?: () => void;
onClose?: () => void;
maxRetries?: number;
retryDelay?: number;
}
export interface SSEMessageEvent {
data: string;
type: string;
id: string | null;
lastEventId: string;
origin: string;
}
export interface SSEEvent {
data: string;
event: string;
id: string | null;
}
// TODO: 1. Handle reconnection logic.
// 2. Allow specifying custom
// 3. Try to make it so that connect DOES NOT resolve until connection is actually establish.
// There's a very slim chance of a race-condition otherwise where we might miss an event.
// Note that this IS NOT trivial. Claude will probably NOT be able to do this for you!
/**
* A Server-Sent Events client implementation using fetch API.
* Supports custom headers, POST requests, and is non-blocking.
*/
export class SSEClient {
private url: string;
private options: Required<Omit<SSEOptions, 'headers'>> & {
headers: Record<string, string>;
};
private abortController: AbortController | null;
private retryCount: number;
private lastEventId: string;
private connected: boolean;
/**
* Creates and connects a new SSEClient instance.
* @param url The URL to connect to
* @param options Configuration options
* @returns A new connected SSEClient instance
*/
public static async from(
url: string,
options: SSEClientOptions,
): Promise<SSEClient> {
const client = new SSEClient(url, options);
await client.connect();
return client;
}
constructor(url: string, options: SSEOptions = {}) {
// State.
private url: string;
private options: SSEClientOptions;
private controller: AbortController;
private connected: boolean = false;
/**
* Creates a new SSEClient instance.
* @param url The URL to connect to
* @param options Configuration options
*/
constructor(url: string, options: SSEClientOptions) {
this.url = url;
this.options = {
fetch: globalThis.fetch,
headers: {},
onMessage: () => {},
onError: () => {},
method: 'GET',
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
onOpen: () => {},
onMessage: () => {},
onError: (error) => console.error('SSEClient error:', error),
onClose: () => {},
maxRetries: 5,
retryDelay: 3000,
...options,
};
this.abortController = null;
this.retryCount = 0;
this.lastEventId = '';
this.connected = false;
this.controller = new AbortController();
}
/**
* Connects to the SSE endpoint.
*
* @remarks
*
* This currently does not await properly and has a chance of introducing a race-condition.
* We want to wait for the connection to ESTABLISH before resolving.
* Otherwise, there is a slight possibility of missing events during the connection phase.
* If you try to fix this, be cautious about using a while(true) loop: JS is single threaded and you may block.
* Try to use the Streams event features instead.
*/
public async connect(): Promise<void> {
if (this.connected) {
return;
}
if (this.connected) return;
this.abortController = new AbortController();
this.connected = true;
const { method, headers, body } = this.options;
try {
const response = await this.options.fetch(this.url, {
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
'Last-Event-ID': this.lastEventId,
...this.options.headers,
},
signal: this.abortController.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error('ReadableStream not supported');
}
this.connected = true;
this.options.onOpen();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
this.handleDisconnect();
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r\n|\r|\n/);
buffer = this.processEventStream(lines);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
this.options.onError(
error instanceof Error ? error : new Error(String(error)),
);
this.handleDisconnect();
if (this.retryCount < this.options.maxRetries) {
this.retryCount++;
setTimeout(() => this.connect(), this.options.retryDelay);
}
}
}
private processEventStream(lines: string[]): string {
let event: SSEEvent = {
data: '',
event: 'message',
id: null,
const fetchOptions: RequestInit = {
method,
headers,
body,
signal: this.controller.signal,
cache: 'no-store',
};
for (let i = 0; i < lines.length - 1; i++) {
// Using an async IIFE to handle the connection without blocking
(async () => {
try {
// TODO: We want to resolve only AFTER we handle connection.
// But this is not trivial!
const response = await fetch(this.url, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
// Notify that the connection has been established
if (this.options.onOpen) {
this.options.onOpen();
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
this.connected = false;
if (this.options.onClose) {
this.options.onClose();
}
break;
}
// Decode the received chunk and add it to the buffer
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// Process any complete events in the buffer
const events = this.parseEvents(buffer);
buffer = events.remainder;
// Handle each complete event
for (const event of events.parsed) {
if (this.options.onMessage) {
this.options.onMessage(event);
}
}
}
} catch (error) {
this.connected = false;
if (this.options.onError) {
this.options.onError(error);
}
if (this.options.onClose) {
this.options.onClose();
}
}
})();
}
/**
* Parses SSE events from a text buffer.
* @param buffer The buffer to parse
* @returns Parsed events and the remaining buffer
*/
private parseEvents(buffer: string): {
parsed: SSEvent[];
remainder: string;
} {
const events: SSEvent[] = [];
const lines = buffer.split(/\r\n|\r|\n/);
let currentEvent: Partial<SSEvent> = {};
let completeEventCount = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length === 0) {
// Empty line means dispatch the event
if (event.data.length > 0) {
this.dispatchEvent(event);
event = {
data: '',
event: 'message',
id: null,
};
// Empty line signals the end of an event
if (line === '') {
if (currentEvent.data) {
// Remove trailing newline if present
currentEvent.data = currentEvent.data.replace(/\n$/, '');
events.push(currentEvent as SSEvent);
currentEvent = {};
completeEventCount = i + 1;
}
continue;
}
const [fieldName, ...fieldValue] = line.split(':');
const value = fieldValue.join(':').trim();
// Parse field: value format
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
switch (fieldName) {
case 'event':
event.event = value;
break;
case 'data':
event.data += value + '\n';
break;
case 'id':
event.id = value;
this.lastEventId = value;
break;
case 'retry': {
const retryInt = parseInt(value, 10);
if (!isNaN(retryInt)) {
this.options.retryDelay = retryInt;
}
break;
const field = line.slice(0, colonIndex);
// Skip initial space after colon if present
const valueStartIndex =
colonIndex + 1 + (line[colonIndex + 1] === ' ' ? 1 : 0);
const value = line.slice(valueStartIndex);
if (field === 'data') {
currentEvent.data = currentEvent.data
? currentEvent.data + '\n' + value
: value;
} else if (field === 'event') {
currentEvent.event = value;
} else if (field === 'id') {
currentEvent.id = value;
} else if (field === 'retry') {
const retryMs = parseInt(value, 10);
if (!isNaN(retryMs)) {
currentEvent.retry = retryMs;
}
}
}
// Return the last incomplete line as the new buffer
return lines[lines.length - 1];
}
private dispatchEvent(event: SSEEvent): void {
const messageEvent: SSEMessageEvent = {
data: event.data.slice(0, -1), // Remove trailing newline
type: event.event,
id: event.id,
lastEventId: this.lastEventId,
origin: this.url,
};
this.options.onMessage(messageEvent);
}
private handleDisconnect(): void {
this.connected = false;
this.options.onClose();
const remainder = lines.slice(completeEventCount).join('\n');
return { parsed: events, remainder };
}
/**
* Closes the SSE connection.
*/
public close(): void {
if (this.abortController) {
this.abortController.abort();
this.handleDisconnect();
}
if (!this.connected) return;
this.controller.abort();
this.connected = false;
}
/**
* Checks if the client is currently connected.
* @returns Whether the client is connected
*/
public isConnected(): boolean {
return this.connected;
}
}
/**
* Configuration options for the SSEClient.
*/
export interface SSEClientOptions {
/**
* HTTP method to use (GET or POST).
*/
method?: 'GET' | 'POST';
/**
* HTTP headers to send with the request.
*/
headers?: Record<string, string>;
/**
* Body to send with POST requests.
*/
body?: string | FormData | URLSearchParams;
/**
* Called when the connection is established.
*/
onOpen?: () => void;
/**
* Called when a message is received.
*/
onMessage?: (event: SSEvent) => void;
/**
* Called when an error occurs.
*/
onError?: (error: unknown) => void;
/**
* Called when the connection is closed.
*/
onClose?: () => void;
}
/**
* Represents a Server-Sent Event.
*/
export interface SSEvent {
/**
* Event data.
*/
data: string;
/**
* Event type.
*/
event?: string;
/**
* Event ID.
*/
id?: string;
/**
* Reconnection time in milliseconds.
*/
retry?: number;
}
+55 -3
View File
@@ -66,6 +66,51 @@ export type PolicyFunction<T = any> = (
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.
@@ -110,6 +155,10 @@ export class Activities {
storeKey: string,
activity: Activity<string, any>,
) {
if (!activity) {
return;
}
if (!activity.timestamp) {
console.warn('Activity without timestamp encountered:', storeKey);
return;
@@ -132,12 +181,12 @@ export class Activities {
const oldStoreKey = this.storeKeyMap.get(snapshotKey);
if (oldStoreKey) {
// Delete the old activity from the store
await this.store.delete(oldStoreKey);
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.delete(storeKey);
await this.store.set(storeKey, null);
}
}
@@ -167,8 +216,11 @@ export class Activities {
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 = entries.sort(([keyA], [keyB]) => {
const activitiesSorted = filteredEntries.sort(([keyA], [keyB]) => {
const timestampA = getTimestampFromUUID(keyA);
const timestampB = getTimestampFromUUID(keyB);
return Number(timestampA - timestampB);
+15
View File
@@ -145,4 +145,19 @@ export abstract class BaseWallet<
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>;
}
+54 -18
View File
@@ -29,7 +29,7 @@ import {
encodeExtendedJson,
} from '@xocash/primitives';
import { hexToBin } from '@bitauth/libauth';
import { hash256, binToHex, hexToBin, utf8ToBin } from '@bitauth/libauth';
export const DERIVATION_PATH = "m/44'/145'/0'";
export const ADDRESS_GAP = 20;
@@ -91,23 +91,6 @@ export class WalletHD<
// Store child wallets, grouped by chain ("change") path in BIP44 spec.
public wallets: { [chainPath: number]: Array<WalletType> } = {};
constructor(
public blockchain: BaseBlockchain,
public store: BaseStore,
public cache: BaseStore,
public hdPrivateNode: HDPrivateNode,
private walletFactory: WalletP2PKHFactory<WalletType> = useWalletP2PKH,
) {
super();
// Initialize empty arrays for our wallets.
this.wallets[CHAIN_EXTERNAL] = [];
this.wallets[CHAIN_INTERNAL] = [];
// Initialize proxies to our stored data.
this.genesisData = this.store.proxy('genesis');
}
static async from<T extends typeof WalletHD>(
this: T,
services: WalletServices,
@@ -166,6 +149,46 @@ export class WalletHD<
) as InstanceType<T>;
}
constructor(
public blockchain: BaseBlockchain,
public store: BaseStore,
public cache: BaseStore,
public hdPrivateNode: HDPrivateNode,
private walletFactory: WalletP2PKHFactory<WalletType> = useWalletP2PKH,
) {
super();
// Initialize empty arrays for our wallets.
this.wallets[CHAIN_EXTERNAL] = [];
this.wallets[CHAIN_INTERNAL] = [];
// Initialize proxies to our stored data.
this.genesisData = this.store.proxy('genesis');
}
async getId() {
// NOTE: Use the cache to optimize future calls.
return binToHex(
await this.cache.getOrSet('walletId', async () =>
this.deriveKey('walletId'),
),
);
}
async getEncryptionKey() {
// NOTE: Use the cache to optimize future calls.
return await this.cache.getOrSet('encryptionKey', async () =>
this.deriveKey('encryptionKey'),
);
}
async getSigningKey() {
// NOTE: Use the cache to optimize future calls.
return this.cache.getOrSet('signingKey', async () =>
this.deriveKey('signingKey'),
);
}
async start() {
// Get the genesis data.
const genesisData = await this.genesisData.getOrFail();
@@ -504,6 +527,19 @@ export class WalletHD<
this.emit('stateUpdated', null);
}
protected async deriveKey(keyName: string): Promise<Uint8Array> {
// Get the genesis data for this wallet.
// TODO: This needs to change!
const genesisData = await this.store.getOrFail('genesis');
// Hash the genesis data using Hash256 and the key ("WalletID") for this derivation using Hash256.
const genesisDataHashed = hash256(utf8ToBin(JSON.stringify(genesisData)));
const keyHashed = hash256(utf8ToBin(keyName));
// Combine the hashes and do one final hash for good measure.
return hash256(new Uint8Array([...genesisDataHashed, ...keyHashed]));
}
}
//-----------------------------------------------------------------------------
+47 -16
View File
@@ -22,6 +22,7 @@ import {
import {
type Input,
binToHex,
hash256,
sha256,
utf8ToBin,
walletTemplateP2pkhNonHd,
@@ -54,19 +55,6 @@ export class WalletP2PKH extends BaseWallet<WalletP2PKHEvents> {
public isStarted = false;
public status: string | null = null;
constructor(
// Dependencies.
public blockchain: BaseBlockchain,
// Persistent Storage.
public store: BaseStore,
// Cache.
public cache: BaseStore,
// Properties.
public privateKey: PrivateKey,
) {
super();
}
static async from<T extends typeof WalletP2PKH>(
this: T,
services: WalletServices,
@@ -170,10 +158,40 @@ export class WalletP2PKH extends BaseWallet<WalletP2PKHEvents> {
});
}
async getId() {
const genesisData = await this.store.getOrFail('genesis');
constructor(
// Dependencies.
public blockchain: BaseBlockchain,
// Persistent Storage.
public store: BaseStore,
// Cache.
public cache: BaseStore,
// Properties.
public privateKey: PrivateKey,
) {
super();
}
return sha256.hash(utf8ToBin(JSON.stringify(genesisData)));
async getId() {
// NOTE: Use the cache to optimize future calls.
return binToHex(
await this.cache.getOrSet('walletId', async () =>
this.deriveKey('walletId'),
),
);
}
async getEncryptionKey() {
// NOTE: Use the cache to optimize future calls.
return await this.cache.getOrSet('encryptionKey', async () =>
this.deriveKey('encryptionKey'),
);
}
async getSigningKey() {
// NOTE: Use the cache to optimize future calls.
return this.cache.getOrSet('signingKey', async () =>
this.deriveKey('signingKey'),
);
}
async start() {
@@ -431,6 +449,19 @@ export class WalletP2PKH extends BaseWallet<WalletP2PKHEvents> {
// 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]));
}
}
//-----------------------------------------------------------------------------