Resolve "WIP: UTXO Selection and Storage"
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// 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';
|
||||
|
||||
// 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.
|
||||
// TODO: Allow the salt to be customized in an options argument to the constructor.
|
||||
export const DEFAULT_XO_SALT =
|
||||
'cash.xo.default_salt_for_encryption_aabbccddeeff00112233445566778899';
|
||||
|
||||
export type EncryptionOpts = {
|
||||
algorithm: string;
|
||||
keyLength: number;
|
||||
salt: Uint8Array;
|
||||
ivLength: number;
|
||||
iterations: number;
|
||||
};
|
||||
|
||||
export class Encryption {
|
||||
constructor(
|
||||
private key: CryptoKey,
|
||||
public opts: EncryptionOpts,
|
||||
) {}
|
||||
|
||||
static async fromBytes(key: Uint8Array, opts: Partial<EncryptionOpts> = {}) {
|
||||
// Merge the provided options into our default options.
|
||||
const encryptionOpts = {
|
||||
algorithm: 'AES-GCM',
|
||||
keyLength: 256,
|
||||
salt: utf8ToBin(DEFAULT_XO_SALT),
|
||||
ivLength: 16,
|
||||
iterations: 100_000,
|
||||
...opts,
|
||||
};
|
||||
|
||||
// Create a key derivation key
|
||||
const keyMaterialBase = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
key,
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
);
|
||||
|
||||
// Derive the actual encryption key
|
||||
const derivedKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: encryptionOpts.salt,
|
||||
iterations: encryptionOpts.iterations,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterialBase,
|
||||
{
|
||||
name: encryptionOpts.algorithm,
|
||||
length: encryptionOpts.keyLength,
|
||||
},
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
|
||||
return new Encryption(derivedKey, encryptionOpts);
|
||||
}
|
||||
|
||||
static async fromPassphrase(
|
||||
passphrase: string,
|
||||
opts: Partial<EncryptionOpts> = {},
|
||||
) {
|
||||
const key = utf8ToBin(passphrase);
|
||||
|
||||
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;
|
||||
|
||||
// Generate random IV
|
||||
const iv = crypto.getRandomValues(new Uint8Array(this.opts.ivLength));
|
||||
|
||||
// Encrypt the data
|
||||
const encryptedData = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: this.opts.algorithm,
|
||||
iv,
|
||||
},
|
||||
this.key,
|
||||
data,
|
||||
);
|
||||
|
||||
// Combine IV and encrypted data
|
||||
const result = new Uint8Array(iv.length + encryptedData.byteLength);
|
||||
result.set(iv);
|
||||
result.set(new Uint8Array(encryptedData), iv.length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async decrypt(payload: Uint8Array | string): Promise<Uint8Array> {
|
||||
// Ensure payload is Uint8Array
|
||||
if (typeof payload === 'string') {
|
||||
throw new Error('Encrypted payload must be Uint8Array');
|
||||
}
|
||||
|
||||
// Extract IV and encrypted data
|
||||
const iv = payload.slice(0, this.opts.ivLength);
|
||||
const encryptedData = payload.slice(this.opts.ivLength);
|
||||
|
||||
// Decrypt the data
|
||||
const decryptedData = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: this.opts.algorithm,
|
||||
iv,
|
||||
},
|
||||
this.key,
|
||||
encryptedData,
|
||||
);
|
||||
|
||||
return new Uint8Array(decryptedData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// TODO: You'll probably want to use WeakRef's here.
|
||||
|
||||
export type EventMap = Record<string, unknown>;
|
||||
|
||||
type Listener<T> = (detail: T) => void;
|
||||
|
||||
interface ListenerEntry<T> {
|
||||
listener: Listener<T>;
|
||||
wrappedListener: Listener<T>;
|
||||
debounceTime?: number;
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
export class EventEmitter<T extends EventMap> {
|
||||
private listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
|
||||
|
||||
on<K extends keyof T>(
|
||||
type: K,
|
||||
listener: Listener<T[K]>,
|
||||
debounceMilliseconds?: number,
|
||||
): Listener<T[K]> {
|
||||
const wrappedListener =
|
||||
debounceMilliseconds && debounceMilliseconds > 0
|
||||
? this.debounce(listener, debounceMilliseconds)
|
||||
: listener;
|
||||
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Set());
|
||||
}
|
||||
|
||||
const listenerEntry: ListenerEntry<T[K]> = {
|
||||
listener,
|
||||
wrappedListener,
|
||||
debounceTime: debounceMilliseconds,
|
||||
};
|
||||
|
||||
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
|
||||
return listener;
|
||||
}
|
||||
|
||||
once<K extends keyof T>(
|
||||
type: K,
|
||||
listener: Listener<T[K]>,
|
||||
debounceMilliseconds?: number,
|
||||
): Listener<T[K]> {
|
||||
const wrappedListener: Listener<T[K]> = (detail: T[K]) => {
|
||||
this.off(type, listener);
|
||||
listener(detail);
|
||||
};
|
||||
|
||||
const debouncedListener =
|
||||
debounceMilliseconds && debounceMilliseconds > 0
|
||||
? this.debounce(wrappedListener, debounceMilliseconds)
|
||||
: wrappedListener;
|
||||
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Set());
|
||||
}
|
||||
|
||||
const listenerEntry: ListenerEntry<T[K]> = {
|
||||
listener,
|
||||
wrappedListener: debouncedListener,
|
||||
debounceTime: debounceMilliseconds,
|
||||
once: true,
|
||||
};
|
||||
|
||||
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
|
||||
return listener;
|
||||
}
|
||||
|
||||
off<K extends keyof T>(type: K, listener: Listener<T[K]>): void {
|
||||
const listeners = this.listeners.get(type);
|
||||
if (!listeners) return;
|
||||
|
||||
const listenerEntry = Array.from(listeners).find(
|
||||
(entry) =>
|
||||
entry.listener === listener || entry.wrappedListener === listener,
|
||||
);
|
||||
|
||||
if (listenerEntry) {
|
||||
listeners.delete(listenerEntry);
|
||||
}
|
||||
}
|
||||
|
||||
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
|
||||
const listeners = this.listeners.get(type);
|
||||
if (!listeners) return false;
|
||||
|
||||
listeners.forEach((entry) => {
|
||||
entry.wrappedListener(payload);
|
||||
});
|
||||
|
||||
return listeners.size > 0;
|
||||
}
|
||||
|
||||
removeAllListeners(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
async waitFor<K extends keyof T>(
|
||||
type: K,
|
||||
predicate: (payload: T[K]) => boolean,
|
||||
timeoutMs?: number,
|
||||
): Promise<T[K]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const listener = (payload: T[K]) => {
|
||||
if (predicate(payload)) {
|
||||
// Clean up
|
||||
this.off(type, listener);
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
resolve(payload);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up timeout if specified
|
||||
if (timeoutMs !== undefined) {
|
||||
timeoutId = setTimeout(() => {
|
||||
this.off(type, listener);
|
||||
reject(new Error(`Timeout waiting for event "${String(type)}"`));
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
this.on(type, listener);
|
||||
});
|
||||
}
|
||||
|
||||
private debounce<K extends keyof T>(
|
||||
func: Listener<T[K]>,
|
||||
wait: number,
|
||||
): Listener<T[K]> {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (detail: T[K]) => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
func(detail);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export class ExtError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly details?: string,
|
||||
) {
|
||||
super(message);
|
||||
|
||||
// Capture the stack trace (V8 engines only)
|
||||
// This step ensures the stack trace is captured correctly
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, ExtError);
|
||||
} else {
|
||||
this.stack = new Error().stack;
|
||||
console.error(this.stack);
|
||||
}
|
||||
|
||||
// 👇️ because we are extending a built-in class
|
||||
Object.setPrototypeOf(this, ExtError.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './encryption.js';
|
||||
export * from './event-emitter.js';
|
||||
export * from './ext-error.js';
|
||||
export * from './memoizer.js';
|
||||
export * from './misc.js';
|
||||
export * from './uuid.js';
|
||||
@@ -0,0 +1,55 @@
|
||||
// TODO: We may want to move this to @xocash/primitives.
|
||||
|
||||
export class Memoizer {
|
||||
private cache = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* Gets a cached value for the given key, computing it if it doesn't exist.
|
||||
*
|
||||
* @param key - The key to look up or store the value under
|
||||
* @param compute - Function to compute the value if it's not cached
|
||||
* @returns The cached or computed value
|
||||
*/
|
||||
get<K extends string, V>(key: K, compute: () => V): V {
|
||||
if (!this.cache.has(key)) {
|
||||
this.cache.set(key, compute());
|
||||
}
|
||||
return this.cache.get(key) as V;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value exists for the given key.
|
||||
*
|
||||
* @param key - The key to check
|
||||
* @returns Whether the key exists in the cache
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a cached value.
|
||||
*
|
||||
* @param key - The key to delete
|
||||
* @returns Whether a value was deleted
|
||||
*/
|
||||
delete(key: string): boolean {
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all cached values.
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all cached keys.
|
||||
*
|
||||
* @returns Array of cached keys
|
||||
*/
|
||||
keys(): string[] {
|
||||
return Array.from(this.cache.keys());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { binToHex } from '@bitauth/libauth';
|
||||
|
||||
// TODO: Put unsorted stuff in here and then try to sort them somewhere more sensible later.
|
||||
|
||||
export function getOutpointId(outpoint: {
|
||||
outpointTransactionHash: Uint8Array;
|
||||
outpointIndex: number;
|
||||
}): string {
|
||||
const outpointHashHex = binToHex(outpoint.outpointTransactionHash);
|
||||
|
||||
return `${outpointHashHex}:${outpoint.outpointIndex}`;
|
||||
}
|
||||
|
||||
// TODO: Jason uses form timestampMs_entityId
|
||||
export function generateActivityId() {
|
||||
return Date.now().toString();
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
constructor(url: string, options: SSEOptions = {}) {
|
||||
this.url = url;
|
||||
this.options = {
|
||||
fetch: globalThis.fetch,
|
||||
headers: {},
|
||||
onMessage: () => {},
|
||||
onError: () => {},
|
||||
onOpen: () => {},
|
||||
onClose: () => {},
|
||||
maxRetries: 5,
|
||||
retryDelay: 3000,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.abortController = null;
|
||||
this.retryCount = 0;
|
||||
this.lastEventId = '';
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.abortController = new AbortController();
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length - 1; 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,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const [fieldName, ...fieldValue] = line.split(':');
|
||||
const value = fieldValue.join(':').trim();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.handleDisconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export function generateUUIDV7(customTimestamp?: bigint) {
|
||||
// Step 1: Get the current timestamp in milliseconds
|
||||
const timestamp = customTimestamp ?? BigInt(Date.now()); // Use BigInt to handle 48-bit values
|
||||
|
||||
// Step 2: Convert the timestamp to a 48-bit hexadecimal string
|
||||
const timestampHex = timestamp.toString(16).padStart(12, '0'); // Ensure 12 hex characters
|
||||
|
||||
// Step 3: Generate 74 bits (19 hex characters) of random data
|
||||
const randomHex = crypto
|
||||
.getRandomValues(new Uint8Array(10))
|
||||
.map((b) => Number(b.toString(16).padStart(2, '0')))
|
||||
.join(''); // 10 bytes = 20 hex characters
|
||||
|
||||
// Step 4: Combine fields to form the UUID
|
||||
const version = '7'; // Version 7 (0111)
|
||||
const variant = '8'; // Variant 1 (10xx) is "8", "9", "a", or "b"
|
||||
|
||||
return (
|
||||
`${timestampHex.substring(0, 8)}-${timestampHex.substring(8, 12)}` + // Timestamp (48 bits)
|
||||
`-${version}${randomHex.substring(0, 3)}` + // Version (4 bits) + 12 random bits
|
||||
`-${variant}${randomHex.substring(3, 6)}` + // Variant (2 bits) + 14 random bits
|
||||
`-${randomHex.substring(6)}` // Remaining 48 random bits
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the timestamp from a UUID v7
|
||||
*/
|
||||
export function getTimestampFromUUID(uuid: string): bigint {
|
||||
return BigInt(parseInt(uuid.slice(0, 8), 16));
|
||||
}
|
||||
Reference in New Issue
Block a user