diff --git a/package-lock.json b/package-lock.json index d5655a8..ffc46a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8185,9 +8185,9 @@ } }, "node_modules/idb": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.1.tgz", - "integrity": "sha512-EkBCzUZSdhJV8PxMSbeEV//xguVKZu9hZZulM+2gHXI0t2hGVU3eYE6/XnH77DS6FM2FY8wl17aDcu9vXpvLWQ==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", "license": "ISC" }, "node_modules/ieee754": { @@ -14750,7 +14750,7 @@ "@electrum-cash/protocol": "2.1.0-development.8075178089", "@xocash/primitives": "*", "better-sqlite3": "^11.8.1", - "idb": "^8.0.0", + "idb": "8.0.3", "msgpackr": "^1.11.2", "zod": "^3.24.2" }, diff --git a/packages/primitives/src/block-header.ts b/packages/primitives/src/block-header.ts index 8bd30ab..609116a 100644 --- a/packages/primitives/src/block-header.ts +++ b/packages/primitives/src/block-header.ts @@ -88,6 +88,31 @@ export class BlockHeader { 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. * diff --git a/packages/stack/package.json b/packages/stack/package.json index 4337059..53a0394 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -36,7 +36,7 @@ "@electrum-cash/protocol": "2.1.0-development.8075178089", "@xocash/primitives": "*", "better-sqlite3": "^11.8.1", - "idb": "^8.0.0", + "idb": "8.0.3", "msgpackr": "^1.11.2", "zod": "^3.24.2" }, diff --git a/packages/stack/src/blockchain/base-blockchain.ts b/packages/stack/src/blockchain/base-blockchain.ts index 9a60d5f..5361102 100644 --- a/packages/stack/src/blockchain/base-blockchain.ts +++ b/packages/stack/src/blockchain/base-blockchain.ts @@ -7,7 +7,7 @@ import { type BlockchainUnsubscribeCallback, } from './types.js'; -import { Transaction } from '@xocash/primitives'; +import { BlockHeader, Transaction, TransactionHash } from '@xocash/primitives'; export type BlockchainEvents = { isConnectedUpdated: boolean; @@ -80,6 +80,15 @@ export abstract class BaseBlockchain< address: string, ): Promise; + /** + * Retrieves the Block Header at the given height. + * + * @param {number} blockHeight - The block height of the header to retrieve + * @returns {Promise} 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; + /** * Retrieves detailed information about a specific transaction. * @@ -196,15 +205,29 @@ export abstract class BaseBlockchain< 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 let unsubscribeCallback: () => void; - const subscriptionPromise = this.subscribeTransaction(txHash, () => { - if (unsubscribeCallback) { - unsubscribeCallback(); - } - }); + const subscriptionPromise = this.subscribeTransaction( + txHash.toHex(), + () => { + if (unsubscribeCallback) { + unsubscribeCallback(); + } + }, + ); // Then create the wait promise without an async executor const waitForTxPromise = new Promise((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 unsubscribeCallback = () => { clearTimeout(timeout); - this.unsubscribeTransaction(txHash, unsubscribeCallback); + this.unsubscribeTransaction(txHash.toHex(), unsubscribeCallback); resolve(); }; }); diff --git a/packages/stack/src/blockchain/blockchain-electrum.ts b/packages/stack/src/blockchain/blockchain-electrum.ts index 824f964..c0bfa43 100644 --- a/packages/stack/src/blockchain/blockchain-electrum.ts +++ b/packages/stack/src/blockchain/blockchain-electrum.ts @@ -9,6 +9,7 @@ import { type BaseStore, StoreInMemory } from '../storage/index.js'; import { Address, + BlockHeader, ExtMap, Outpoint, Transaction, @@ -24,6 +25,7 @@ import { type TransactionSubscribeNotification, initializeElectrumClient, broadcastTransaction, + fetchBlockHeaderFromBlockHeight, fetchUnspentTransactionOutputs, fetchHistory, fetchTransaction, @@ -278,7 +280,7 @@ export class BlockchainElectrum extends BaseBlockchain< 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); // Return the transaction hex. @@ -318,6 +320,32 @@ export class BlockchainElectrum extends BaseBlockchain< 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( tx: Transaction | Uint8Array | string, opts: BroadcastOpts = { timeoutMs: 10_000, waitUntilSeen: true }, diff --git a/packages/stack/src/blockchain/blockchain-inmemory.ts b/packages/stack/src/blockchain/blockchain-inmemory.ts index 2ba3665..4d3ae36 100644 --- a/packages/stack/src/blockchain/blockchain-inmemory.ts +++ b/packages/stack/src/blockchain/blockchain-inmemory.ts @@ -9,6 +9,7 @@ import { import { Address, + BlockHeader, ExtMap, Outpoint, Transaction, @@ -30,6 +31,7 @@ export class BlockchainInMemory extends BaseBlockchain { return blockchain as InstanceType; } + private blockHeaders: Array; private transactions: BlockchainTransactions; private addressSubscriptions: Map>; private transactionSubscriptions: Map>; @@ -40,6 +42,7 @@ export class BlockchainInMemory extends BaseBlockchain { this.transactions = new ExtMap(); this.addressSubscriptions = new Map(); this.transactionSubscriptions = new Map(); + this.blockHeaders = []; } 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( tx: Transaction | Uint8Array | string, opts: { skipValidation: boolean } = { skipValidation: false }, @@ -326,6 +340,14 @@ export class BlockchainInMemory extends BaseBlockchain { } async mineBlock(outputs: Array): Promise { + // 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. const transaction = Transaction.fromRaw({ version: 2, diff --git a/packages/stack/src/storage/activities.ts b/packages/stack/src/storage/activities.ts index b749a55..fb0847c 100644 --- a/packages/stack/src/storage/activities.ts +++ b/packages/stack/src/storage/activities.ts @@ -25,10 +25,18 @@ export interface Activity { // Define the snapshot structure export type SnapshotByType = { [type: string]: { - [id: string]: Activity; + [entityId: string]: Activity; }; }; +export type ActivitiesIndexedByKey = { + [entityId: string]: T; +}; + +export type ActivitiesIndexedByEntityId = { + [key: string]: T; +}; + // Policy type definition export type PolicyFunction = ( existingActivity: Activity | undefined, @@ -36,6 +44,7 @@ export type PolicyFunction = ( ) => Activity; export type ActivitiesEvents = { + snapshotUpdated: undefined; [key: `updated:${string}`]: { activity: Activity }; }; @@ -172,12 +181,17 @@ export class Activities extends EventEmitter { /** * Get all activities of a specific type */ - public getByType(type: T['key']): T[] { - const activities = this.snapshot[type]; + public getByKey( + key: T['key'], + ): ActivitiesIndexedByKey { + const activities = this.snapshot[key]; 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; } /** @@ -227,6 +241,9 @@ export class Activities extends EventEmitter { // Emit the updated 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)) { // If we kept the existing activity, delete the new activity from the store // await this.store.set(storeKey, null); @@ -261,6 +278,8 @@ export class Activities extends EventEmitter { await this.processActivity(storeKey, activity); } + this.emit('snapshotUpdated', undefined); + return this.snapshot; } diff --git a/packages/stack/src/storage/base-store.ts b/packages/stack/src/storage/base-store.ts index d938f81..447a220 100644 --- a/packages/stack/src/storage/base-store.ts +++ b/packages/stack/src/storage/base-store.ts @@ -6,6 +6,7 @@ export type StorageOpts = { }; export type StoreEvents = { + [key: `set:${string}`]: any; set: { key: string; value: any }; delete: { key: string }; clear: undefined; diff --git a/packages/stack/src/storage/store-localstorage.ts b/packages/stack/src/storage/store-localstorage.ts index 2f6e7ca..afc4da6 100644 --- a/packages/stack/src/storage/store-localstorage.ts +++ b/packages/stack/src/storage/store-localstorage.ts @@ -26,6 +26,10 @@ export class StorageLocalStorage extends BaseStorage { const stores = new Set(); // 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++) { const key = localStorage.key(i); if (key && key.startsWith(prefix)) { @@ -35,6 +39,7 @@ export class StorageLocalStorage extends BaseStorage { } } } + */ return Array.from(stores); } @@ -171,7 +176,15 @@ export class StoreLocalStorage extends BaseStore { encryption?: Encrypter, 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() { @@ -235,6 +248,7 @@ export class StoreLocalStorage extends BaseStore { // LocalStorage can only store strings, so we convert the binary data localStorage.setItem(fullKey, encryptedValue); this.emit('set', { key, value }); + this.emit(`set:${key}`, value); } async delete(key: string) { diff --git a/packages/stack/src/wallet/activities.ts b/packages/stack/src/wallet/activities.ts index 9f98d5c..0c63f91 100644 --- a/packages/stack/src/wallet/activities.ts +++ b/packages/stack/src/wallet/activities.ts @@ -24,6 +24,11 @@ export interface WalletDescription extends Activity { value: string; } +export interface WalletCurrency extends Activity { + key: 'WalletCurrency'; + value: string; +} + export interface WalletHDAddressGap extends Activity { key: 'WalletHDAddressGap'; value: number; diff --git a/packages/stack/src/wallet/base-wallet.ts b/packages/stack/src/wallet/base-wallet.ts index 89189af..3fee0b7 100644 --- a/packages/stack/src/wallet/base-wallet.ts +++ b/packages/stack/src/wallet/base-wallet.ts @@ -173,5 +173,5 @@ export abstract class BaseWallet< */ abstract sendTransaction( template: Partial, - ): Promise; + ): Promise; } diff --git a/packages/stack/src/wallet/wallet-hd.ts b/packages/stack/src/wallet/wallet-hd.ts index 12a043c..39da654 100644 --- a/packages/stack/src/wallet/wallet-hd.ts +++ b/packages/stack/src/wallet/wallet-hd.ts @@ -372,6 +372,9 @@ export class WalletHD extends BaseWallet { // Broadcast the transaction to the blockchain. await this.blockchain.broadcastTransaction(generatedTxBytes); + + // Return the transaction. + return generatedTxBytes; } async deriveWallets( diff --git a/packages/stack/src/wallet/wallet-p2pkh.ts b/packages/stack/src/wallet/wallet-p2pkh.ts index 48e5e83..fce7d7b 100644 --- a/packages/stack/src/wallet/wallet-p2pkh.ts +++ b/packages/stack/src/wallet/wallet-p2pkh.ts @@ -414,6 +414,9 @@ export class WalletP2PKH extends BaseWallet { // Broadcast the transaction to the blockchain. await this.blockchain.broadcastTransaction(generatedTxBytes); + + // Return the transaction. + return generatedTxBytes; } private async onAddressNotification(