XO Integration Part II

This commit is contained in:
Jim Hamill
2025-05-08 08:05:46 +00:00
parent 476a535768
commit 1870877df5
13 changed files with 164 additions and 21 deletions
+4 -4
View File
@@ -8185,9 +8185,9 @@
} }
}, },
"node_modules/idb": { "node_modules/idb": {
"version": "8.0.1", "version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.1.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-EkBCzUZSdhJV8PxMSbeEV//xguVKZu9hZZulM+2gHXI0t2hGVU3eYE6/XnH77DS6FM2FY8wl17aDcu9vXpvLWQ==", "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ieee754": { "node_modules/ieee754": {
@@ -14750,7 +14750,7 @@
"@electrum-cash/protocol": "2.1.0-development.8075178089", "@electrum-cash/protocol": "2.1.0-development.8075178089",
"@xocash/primitives": "*", "@xocash/primitives": "*",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"idb": "^8.0.0", "idb": "8.0.3",
"msgpackr": "^1.11.2", "msgpackr": "^1.11.2",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
+25
View File
@@ -88,6 +88,31 @@ export class BlockHeader {
return new this(bytes); return new this(bytes);
} }
static generateRandom(timestampUnix?: number): BlockHeader {
// Create buffer for the 80-byte header
const header = new Uint8Array(80);
// Fill with random bytes
crypto.getRandomValues(header);
// Optional: Set specific version if needed
// First 4 bytes is version (little-endian)
const version = 1;
header[0] = version & 0xff;
header[1] = (version >> 8) & 0xff;
header[2] = (version >> 16) & 0xff;
header[3] = (version >> 24) & 0xff;
// Optional: Set realistic timestamp (current time)
const timestamp = timestampUnix || Math.floor(Date.now() / 1000);
header[68] = timestamp & 0xff;
header[69] = (timestamp >> 8) & 0xff;
header[70] = (timestamp >> 16) & 0xff;
header[71] = (timestamp >> 24) & 0xff;
return this.fromBytes(header);
}
/** /**
* Checks whether the given BlochHeader is of a valid structure. * Checks whether the given BlochHeader is of a valid structure.
* *
+1 -1
View File
@@ -36,7 +36,7 @@
"@electrum-cash/protocol": "2.1.0-development.8075178089", "@electrum-cash/protocol": "2.1.0-development.8075178089",
"@xocash/primitives": "*", "@xocash/primitives": "*",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"idb": "^8.0.0", "idb": "8.0.3",
"msgpackr": "^1.11.2", "msgpackr": "^1.11.2",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
@@ -7,7 +7,7 @@ import {
type BlockchainUnsubscribeCallback, type BlockchainUnsubscribeCallback,
} from './types.js'; } from './types.js';
import { Transaction } from '@xocash/primitives'; import { BlockHeader, Transaction, TransactionHash } from '@xocash/primitives';
export type BlockchainEvents = { export type BlockchainEvents = {
isConnectedUpdated: boolean; isConnectedUpdated: boolean;
@@ -80,6 +80,15 @@ export abstract class BaseBlockchain<
address: string, address: string,
): Promise<BlockchainTransactions>; ): Promise<BlockchainTransactions>;
/**
* Retrieves the Block Header at the given height.
*
* @param {number} blockHeight - The block height of the header to retrieve
* @returns {Promise<BlockHeader>} A promise that resolves with the Block Header
* @throws {Error} If the block number is invalid or the query fails
*/
abstract fetchBlockHeader(blockHeight: number): Promise<BlockHeader>;
/** /**
* Retrieves detailed information about a specific transaction. * Retrieves detailed information about a specific transaction.
* *
@@ -196,15 +205,29 @@ export abstract class BaseBlockchain<
tx = Transaction.from(tx); tx = Transaction.from(tx);
} }
const txHash = tx.toHashHex(); // Then wait for either resolution or timeout
return this.waitForTransactionHash(tx.toHash(), timeoutMs);
}
async waitForTransactionHash(
txHash: TransactionHash | Uint8Array | string,
timeoutMs = 10_000,
) {
// Cast the transaction hash.
if (!(txHash instanceof TransactionHash)) {
txHash = TransactionHash.from(txHash);
}
// First, subscribe to the transaction // First, subscribe to the transaction
let unsubscribeCallback: () => void; let unsubscribeCallback: () => void;
const subscriptionPromise = this.subscribeTransaction(txHash, () => { const subscriptionPromise = this.subscribeTransaction(
txHash.toHex(),
() => {
if (unsubscribeCallback) { if (unsubscribeCallback) {
unsubscribeCallback(); unsubscribeCallback();
} }
}); },
);
// Then create the wait promise without an async executor // Then create the wait promise without an async executor
const waitForTxPromise = new Promise<void>((resolve, reject) => { const waitForTxPromise = new Promise<void>((resolve, reject) => {
@@ -218,7 +241,7 @@ export abstract class BaseBlockchain<
// Create a subscribe function so that we get notified when transaction is seen by network // Create a subscribe function so that we get notified when transaction is seen by network
unsubscribeCallback = () => { unsubscribeCallback = () => {
clearTimeout(timeout); clearTimeout(timeout);
this.unsubscribeTransaction(txHash, unsubscribeCallback); this.unsubscribeTransaction(txHash.toHex(), unsubscribeCallback);
resolve(); resolve();
}; };
}); });
@@ -9,6 +9,7 @@ import { type BaseStore, StoreInMemory } from '../storage/index.js';
import { import {
Address, Address,
BlockHeader,
ExtMap, ExtMap,
Outpoint, Outpoint,
Transaction, Transaction,
@@ -24,6 +25,7 @@ import {
type TransactionSubscribeNotification, type TransactionSubscribeNotification,
initializeElectrumClient, initializeElectrumClient,
broadcastTransaction, broadcastTransaction,
fetchBlockHeaderFromBlockHeight,
fetchUnspentTransactionOutputs, fetchUnspentTransactionOutputs,
fetchHistory, fetchHistory,
fetchTransaction, fetchTransaction,
@@ -278,7 +280,7 @@ export class BlockchainElectrum extends BaseBlockchain<
return Transaction.fromHex(txHex).toRaw(); return Transaction.fromHex(txHex).toRaw();
}); });
// Decode the hex into our transaction primitive. // Decode the raw data into our transaction primitive.
const transaction = Transaction.fromRaw(transactionRaw); const transaction = Transaction.fromRaw(transactionRaw);
// Return the transaction hex. // Return the transaction hex.
@@ -318,6 +320,32 @@ export class BlockchainElectrum extends BaseBlockchain<
return transactionsIndexed; return transactionsIndexed;
} }
async fetchBlockHeader(blockHeight: number) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Define the cache key.
const cacheKey = `blockHeader.${blockHeight}`;
// Attempt to get the transaction history from the cache or fetch them if not available.
const blockHeaderRaw = await this.store.getOrSet(cacheKey, async () => {
const headerHex = await fetchBlockHeaderFromBlockHeight(
this.electrumClient,
blockHeight,
);
return BlockHeader.fromHex(headerHex).toRaw();
});
// Decode the raw data into our Block Header primitive.
const blockHeader = BlockHeader.fromRaw(blockHeaderRaw);
// Return the block header.
return blockHeader;
}
async broadcastTransaction( async broadcastTransaction(
tx: Transaction | Uint8Array | string, tx: Transaction | Uint8Array | string,
opts: BroadcastOpts = { timeoutMs: 10_000, waitUntilSeen: true }, opts: BroadcastOpts = { timeoutMs: 10_000, waitUntilSeen: true },
@@ -9,6 +9,7 @@ import {
import { import {
Address, Address,
BlockHeader,
ExtMap, ExtMap,
Outpoint, Outpoint,
Transaction, Transaction,
@@ -30,6 +31,7 @@ export class BlockchainInMemory extends BaseBlockchain {
return blockchain as InstanceType<T>; return blockchain as InstanceType<T>;
} }
private blockHeaders: Array<BlockHeader>;
private transactions: BlockchainTransactions; private transactions: BlockchainTransactions;
private addressSubscriptions: Map<string, Set<BlockchainCallback>>; private addressSubscriptions: Map<string, Set<BlockchainCallback>>;
private transactionSubscriptions: Map<string, Set<BlockchainCallback>>; private transactionSubscriptions: Map<string, Set<BlockchainCallback>>;
@@ -40,6 +42,7 @@ export class BlockchainInMemory extends BaseBlockchain {
this.transactions = new ExtMap<BlockchainTransaction>(); this.transactions = new ExtMap<BlockchainTransaction>();
this.addressSubscriptions = new Map(); this.addressSubscriptions = new Map();
this.transactionSubscriptions = new Map(); this.transactionSubscriptions = new Map();
this.blockHeaders = [];
} }
async start() {} async start() {}
@@ -89,6 +92,17 @@ export class BlockchainInMemory extends BaseBlockchain {
}); });
} }
async fetchBlockHeader(blockHeight: number) {
const blockHeader = this.blockHeaders[blockHeight];
if (!blockHeader) {
throw new Error(`No block available for height ${blockHeight}`);
}
// Return the block header.
return blockHeader;
}
async broadcastTransaction( async broadcastTransaction(
tx: Transaction | Uint8Array | string, tx: Transaction | Uint8Array | string,
opts: { skipValidation: boolean } = { skipValidation: false }, opts: { skipValidation: boolean } = { skipValidation: false },
@@ -326,6 +340,14 @@ export class BlockchainInMemory extends BaseBlockchain {
} }
async mineBlock(outputs: Array<Output>): Promise<void> { async mineBlock(outputs: Array<Output>): Promise<void> {
// Create a block header from the current timestamp.
const newBlockHeader = BlockHeader.generateRandom(
Math.floor(Date.now() / 1000),
);
// Add it to our list of block headers.
this.blockHeaders.push(newBlockHeader);
// Create a transaction that pays to our subscribed address. // Create a transaction that pays to our subscribed address.
const transaction = Transaction.fromRaw({ const transaction = Transaction.fromRaw({
version: 2, version: 2,
+24 -5
View File
@@ -25,10 +25,18 @@ export interface Activity {
// Define the snapshot structure // Define the snapshot structure
export type SnapshotByType = { export type SnapshotByType = {
[type: string]: { [type: string]: {
[id: string]: Activity; [entityId: string]: Activity;
}; };
}; };
export type ActivitiesIndexedByKey<T extends Activity = Activity> = {
[entityId: string]: T;
};
export type ActivitiesIndexedByEntityId<T extends Activity = Activity> = {
[key: string]: T;
};
// Policy type definition // Policy type definition
export type PolicyFunction = ( export type PolicyFunction = (
existingActivity: Activity | undefined, existingActivity: Activity | undefined,
@@ -36,6 +44,7 @@ export type PolicyFunction = (
) => Activity; ) => Activity;
export type ActivitiesEvents = { export type ActivitiesEvents = {
snapshotUpdated: undefined;
[key: `updated:${string}`]: { activity: Activity }; [key: `updated:${string}`]: { activity: Activity };
}; };
@@ -172,12 +181,17 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
/** /**
* Get all activities of a specific type * Get all activities of a specific type
*/ */
public getByType<T extends Activity>(type: T['key']): T[] { public getByKey<T extends Activity>(
const activities = this.snapshot[type]; key: T['key'],
): ActivitiesIndexedByKey<T> {
const activities = this.snapshot[key];
if (!activities) { if (!activities) {
return []; return {};
} }
return Object.values(activities) as T[];
// NOTE: We want to return a new object, not a reference to our current object.
// But we only need to "clone" the first level as each activity itself is immutable.
return { ...activities } as ActivitiesIndexedByKey<T>;
} }
/** /**
@@ -227,6 +241,9 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
// Emit the updated activity. // Emit the updated activity.
this.emit(`updated:${activity.key}`, { activity }); this.emit(`updated:${activity.key}`, { activity });
// Emit an event indicating our snapshot has been updated.
this.emit('snapshotUpdated', undefined);
} else if (storeKey !== this.storeKeyMap.get(snapshotKey)) { } else if (storeKey !== this.storeKeyMap.get(snapshotKey)) {
// If we kept the existing activity, delete the new activity from the store // If we kept the existing activity, delete the new activity from the store
// await this.store.set(storeKey, null); // await this.store.set(storeKey, null);
@@ -261,6 +278,8 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
await this.processActivity(storeKey, activity); await this.processActivity(storeKey, activity);
} }
this.emit('snapshotUpdated', undefined);
return this.snapshot; return this.snapshot;
} }
+1
View File
@@ -6,6 +6,7 @@ export type StorageOpts = {
}; };
export type StoreEvents = { export type StoreEvents = {
[key: `set:${string}`]: any;
set: { key: string; value: any }; set: { key: string; value: any };
delete: { key: string }; delete: { key: string };
clear: undefined; clear: undefined;
@@ -26,6 +26,10 @@ export class StorageLocalStorage extends BaseStorage {
const stores = new Set<string>(); const stores = new Set<string>();
// Iterate through localStorage to find all stores with our dbName prefix // Iterate through localStorage to find all stores with our dbName prefix
// NOTE: I think this was me (Jim) just being stupid.
// We don't want to do this - we want to over-ride "on" to emit on first subscription.
// And then call super.on?
/*
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (key && key.startsWith(prefix)) { if (key && key.startsWith(prefix)) {
@@ -35,6 +39,7 @@ export class StorageLocalStorage extends BaseStorage {
} }
} }
} }
*/
return Array.from(stores); return Array.from(stores);
} }
@@ -171,7 +176,15 @@ export class StoreLocalStorage extends BaseStore {
encryption?: Encrypter, encryption?: Encrypter,
parentStorage?: StorageLocalStorage, parentStorage?: StorageLocalStorage,
) { ) {
return new this(dbName, storeName, encryption, parentStorage); const store = new this(dbName, storeName, encryption, parentStorage);
// Emit events for all entries.
const allEntries = await store.all();
Object.entries(allEntries).forEach(([key, value]) =>
store.emit(`set:${key}`, value),
);
return store;
} }
async all<T>() { async all<T>() {
@@ -235,6 +248,7 @@ export class StoreLocalStorage extends BaseStore {
// LocalStorage can only store strings, so we convert the binary data // LocalStorage can only store strings, so we convert the binary data
localStorage.setItem(fullKey, encryptedValue); localStorage.setItem(fullKey, encryptedValue);
this.emit('set', { key, value }); this.emit('set', { key, value });
this.emit(`set:${key}`, value);
} }
async delete(key: string) { async delete(key: string) {
+5
View File
@@ -24,6 +24,11 @@ export interface WalletDescription extends Activity {
value: string; value: string;
} }
export interface WalletCurrency extends Activity {
key: 'WalletCurrency';
value: string;
}
export interface WalletHDAddressGap extends Activity { export interface WalletHDAddressGap extends Activity {
key: 'WalletHDAddressGap'; key: 'WalletHDAddressGap';
value: number; value: number;
+1 -1
View File
@@ -173,5 +173,5 @@ export abstract class BaseWallet<
*/ */
abstract sendTransaction( abstract sendTransaction(
template: Partial<TransactionTemplate>, template: Partial<TransactionTemplate>,
): Promise<void>; ): Promise<Uint8Array>;
} }
+3
View File
@@ -372,6 +372,9 @@ export class WalletHD extends BaseWallet {
// Broadcast the transaction to the blockchain. // Broadcast the transaction to the blockchain.
await this.blockchain.broadcastTransaction(generatedTxBytes); await this.blockchain.broadcastTransaction(generatedTxBytes);
// Return the transaction.
return generatedTxBytes;
} }
async deriveWallets( async deriveWallets(
@@ -414,6 +414,9 @@ export class WalletP2PKH extends BaseWallet {
// Broadcast the transaction to the blockchain. // Broadcast the transaction to the blockchain.
await this.blockchain.broadcastTransaction(generatedTxBytes); await this.blockchain.broadcastTransaction(generatedTxBytes);
// Return the transaction.
return generatedTxBytes;
} }
private async onAddressNotification( private async onAddressNotification(