diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6e2394..3e9fd55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,62 @@ A table of common prefixes (conventions) is below: | revert | Reverts a previous commit | +## Coding Guidelines + +These guidelines represent best practices. While exceptions may occasionally be necessary as "the lesser evil," strive to adhere to these principles whenever possible. + +1. Try to minimize state + + State is a complexity multiplier. Keep mutable class members to an absolute minimum, as each stateful property exponentially increases cognitive load and potential for bugs. When designing classes or components, first attempt a stateless approach, adding mutable properties only when demonstrably necessary. + +2. Try to avoid `any` types + + Use `unknown` instead, as it provides better type safety while maintaining flexibility when needed. + +3. Prefer immutable patterns + + Use immutable data structures where possible to improve code predictability and testability. + + For example: + + ```ts + // GOOD: + const mappedData = originalData.map((item) => item.field); + + // AVOID: + const mappedData = []; + + for(let i = 0; i < originalData.length; i++) { + mappedData.push(originalData[i].field); + } + ``` + + Do note that there are some cases where it may make more sense to mutate data inline (e.g. as an optimization for very large data structures). + + Use your best judgement, but treat immutability as the general rule. + +4. Avoid Typescript, Linter and Formatter Directives + + These exceptional cases should be minimized as they: + - Create inconsistency in code standards + - Make maintenance more difficult + - Often mask underlying issues that should be properly resolved + - Can lead to unintended side-effects throughout the codebase + +4. Avoid additional dependencies. + + Try to avoid pulling in additional packages. Not only can this bloat the project, but can also cause compatibility issues between environments (Web, Node, ESM, etc). We want `@xocash/stack` to "just work" without devs having to tailor build configurations. + + Obviously this is an ideal: Sometimes it is simply not feasible or sensible to rewrite complex functionality that can be better done by a well-built package. However, please try to adhere to the following: + + - Ensure the package is of high quality (well-written, well-tested). + - Does not drag in additional dependencies itself (note that `devDependencies` are fine. These will not be installed when leveraged from our packages). + - If the package being used **is heavy** and we have no other choice but to use it, consider whether we should package it separately to avoid problems. For example, if we build a `BlockchainBCHN` adapter, we will likely want to package this on its own under `@xocash/blockchain-bchn` as it only works in NodeJS environments. + +5. "When in Rome..." + + For conventions not explicitly mentioned, follow the "when in Rome" principle. Try to identify and use the patterns and conventions that are already established elsewhere in the codebase. + ## Patterns ### Dependency Injection @@ -77,7 +133,7 @@ class SomeClass { ### Factory Patterns -When creating classes, create static `from(...)` (or `fromSpecificThing(...)`) methods. +When creating classes, create static `from(...)` (or `fromSpecificThing(...)`) methods and place these at the top of the class (so that they appear first in the generated documentation). In Javascript, a `constructor` cannot be asynchronous and we want to ensure that we can `await` class instantiation to mitigate against stateful errors. @@ -223,7 +279,7 @@ Avoid revalidation at every code-path: This is not only inefficient, but a giant ```ts // You can accept other types (this is a nice convenience). -broadcastTransaction(transaction: Transaction | Uint8Array): Transaction { +broadcastTransaction(transaction: Transaction | Uint8Array | string): Transaction { // ... but always cast to the Primitive (to ensure correctness). if(!(transaction instanceof Transaction) { transaction = Transaction.from(transaction); diff --git a/packages/docs/src/.vitepress/config.ts b/packages/docs/src/.vitepress/config.ts index a6dcb68..09eb1d2 100644 --- a/packages/docs/src/.vitepress/config.ts +++ b/packages/docs/src/.vitepress/config.ts @@ -4,11 +4,13 @@ import typedocSidebar from '../api/typedoc-sidebar.json'; // Special cases map for acronyms and exceptions const SPECIAL_CASES = { + cryptocompare: 'CryptoCompare', hd: 'HD', http: 'HTTP', indexeddb: 'IndexedDB', inmemory: 'InMemory', json: 'JSON', + localstorage: 'LocalStorage', p2p: 'P2P', p2pkh: 'P2PKH', p2sh: 'P2SH', diff --git a/packages/stack/src/blockchain/blockchain-electrum.ts b/packages/stack/src/blockchain/blockchain-electrum.ts index c0bfa43..6bbe619 100644 --- a/packages/stack/src/blockchain/blockchain-electrum.ts +++ b/packages/stack/src/blockchain/blockchain-electrum.ts @@ -1,5 +1,7 @@ import { BaseBlockchain } from './base-blockchain.js'; import { + type AddressStatusPayload, + type BlockHeightPayload, type BlockchainCallback, type BlockchainTransaction, type BlockchainUTXO, @@ -54,16 +56,6 @@ export type BlockchainElectrumParams = { opts?: Partial; }; -export type AddressStatusPayload = { - type: 'address-status'; - status: string | null; -}; - -export type BlockHeightPayload = { - type: 'block-height'; - height: number | null; -}; - export class BlockchainElectrum extends BaseBlockchain< AddressStatusPayload, BlockHeightPayload diff --git a/packages/stack/src/blockchain/index.ts b/packages/stack/src/blockchain/index.ts index ce19c0d..64c8f8b 100644 --- a/packages/stack/src/blockchain/index.ts +++ b/packages/stack/src/blockchain/index.ts @@ -1,5 +1,8 @@ +// NOTE: Do not export specific providers in this file. +// Do this in the parent index.ts file instead (otherwise, we mingle interfaces and providers, which can create circular dependencies).. +// The exception to this rule is with the InMemory and Composite providers. +// These are considered built-ins and do not have any other interface dependencies. export * from './base-blockchain.js'; -export * from './blockchain-electrum.js'; export * from './blockchain-inmemory.js'; export * from './traits.js'; export * from './types.js'; diff --git a/packages/stack/src/blockchain/types.ts b/packages/stack/src/blockchain/types.ts index 4b07145..01de1c8 100644 --- a/packages/stack/src/blockchain/types.ts +++ b/packages/stack/src/blockchain/types.ts @@ -32,3 +32,14 @@ export type BlockchainUTXO = { // NOTE: To minimize refactors later, use this name elsewhere; not the `ExtMap` form. export type BlockchainUTXOs = ExtMap; + +// NOTE: These payloads are used by Electrum. +export type AddressStatusPayload = { + type: 'address-status'; + status: string | null; +}; + +export type BlockHeightPayload = { + type: 'block-height'; + height: number | null; +}; diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 2a45625..c15213b 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -7,3 +7,16 @@ export * from './rates/index.js'; export * from './storage/index.js'; export * from './utils/index.js'; export * from './wallet/index.js'; + +// Export providers that work across both Browser and NodeJS. +// NOTE: For browser-only providers, export these from index.browser.ts. +// For node-only providers, export these from index.node.ts. +export * from './blockchain/blockchain-electrum.js'; +export * from './storage/store-remote-http.js'; +export * from './rates/rates-bitpay.js'; +export * from './rates/rates-coinbase.js'; +export * from './rates/rates-cryptocompare.js'; +export * from './wallet/wallet-hd.js'; +export * from './wallet/wallet-p2pkh.js'; +export * from './wallet/wallet-p2sh20.js'; +export * from './wallet/wallet-p2sh32.js'; diff --git a/packages/stack/src/index.types.ts b/packages/stack/src/index.types.ts index 1ebd9a6..15c134f 100644 --- a/packages/stack/src/index.types.ts +++ b/packages/stack/src/index.types.ts @@ -1,3 +1,6 @@ // Export all of our stack-specific modules. +// NOTE: This file exists as an entry point for generating our documentation. +// In our documentation, we want to include both Browser and Node-specific providers. +// TODO: For clarity, consider whether we should rename this to index.docs.ts. export * from './index.browser.js'; export * from './index.node.js'; diff --git a/packages/stack/src/rates/index.ts b/packages/stack/src/rates/index.ts index d9dd100..5b866d0 100644 --- a/packages/stack/src/rates/index.ts +++ b/packages/stack/src/rates/index.ts @@ -1,6 +1,7 @@ +// NOTE: Do not export specific providers in this file. +// Do this in the parent index.ts file instead (otherwise, we mingle interfaces and providers, which can create circular dependencies).. +// The exception to this rule is with the InMemory and Composite providers. +// These are considered built-ins and do not have any other interface dependencies. export * from './base-rates.js'; -export * from './rates-bitpay.js'; -export * from './rates-coinbase.js'; -export * from './rates-cryptocompare.js'; export * from './rates-composite.js'; export * from './traits.js'; diff --git a/packages/stack/src/storage/activities.ts b/packages/stack/src/storage/activities.ts index fb0847c..4f6fb72 100644 --- a/packages/stack/src/storage/activities.ts +++ b/packages/stack/src/storage/activities.ts @@ -23,18 +23,22 @@ export interface Activity { } // Define the snapshot structure -export type SnapshotByType = { - [type: string]: { +export type SnapshotState = { + [key: string]: { [entityId: string]: Activity; }; }; -export type ActivitiesIndexedByKey = { - [entityId: string]: T; +export type SnapshotData = { + [keyOrEntityId: string]: T; }; -export type ActivitiesIndexedByEntityId = { - [key: string]: T; +export type SnapshotIndexedByKey = { + [key: string]: { [entityId: string]: T }; +}; + +export type SnapshotIndexedByEntityId = { + [entityId: string]: { [key: string]: T }; }; // Policy type definition @@ -45,6 +49,7 @@ export type PolicyFunction = ( export type ActivitiesEvents = { snapshotUpdated: undefined; + updated: { activity: Activity }; [key: `updated:${string}`]: { activity: Activity }; }; @@ -79,9 +84,15 @@ export class Activities extends EventEmitter { // TODO: If you can refactor this around your new Storage mechanism, things become a lot simpler: // 1. You don't need to define such a clunky snapshot format because you have indexes. // 2. Caching of snapshots likely becomes possible (because you can use a storage adapter). - public snapshot: SnapshotByType = {}; - private policies = new Map(); - private storeKeyMap = new Map(); + public snapshot: SnapshotState = {}; + + // Indexes. + // TODO: We want to be able to remove these in favor of the TODO note above. + // public snapshotIndexedByEntity: SnapshotIndexedByEntityId = {}; + // public snapshotIndexedByKey: SnapshotIndexedByKey = {}; + + protected policies = new Map(); + protected storeKeyMap = new Map(); /** * Create an Activities instance from the given composite store. @@ -93,7 +104,7 @@ export class Activities extends EventEmitter { return activities; } - private constructor(store: StoreComposite) { + protected constructor(store: StoreComposite) { super(); // Assign our dependencies. @@ -167,6 +178,10 @@ export class Activities extends EventEmitter { return removedActivity; } + public getSnapshot() { + return this.snapshot; + } + /** * Get a specific activity by type and id with type inference */ @@ -178,12 +193,23 @@ export class Activities extends EventEmitter { return activity as T | undefined; } + public getByEntity(entityId: string = '') { + return Object.keys(this.snapshot).reduce( + (result, key) => { + const activity = this.snapshot[key][entityId]; + if (activity) { + result[key] = activity; + } + return result; + }, + {} as Record, + ); + } + /** * Get all activities of a specific type */ - public getByKey( - key: T['key'], - ): ActivitiesIndexedByKey { + public getByKey(key: T['key']): SnapshotData { const activities = this.snapshot[key]; if (!activities) { return {}; @@ -191,9 +217,25 @@ export class Activities extends EventEmitter { // 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; + return { ...activities } as SnapshotData; } + /* + public getByEntityId( + entityId: string = '', + ): SnapshotData { + const activities = this.snapshotIndexedByEntity[entityId]; + + if (!activities) { + return {}; + } + + // 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 SnapshotData; + } + */ + /** * Register a policy for handling conflicts between activities of the same type * @param type Activity type to register policy for @@ -239,6 +281,9 @@ export class Activities extends EventEmitter { } this.storeKeyMap.set(snapshotKey, storeKey); + // Emit the updated activity. + this.emit('updated', { activity }); + // Emit the updated activity. this.emit(`updated:${activity.key}`, { activity }); @@ -254,7 +299,7 @@ export class Activities extends EventEmitter { * Compile a full snapshot from scratch * This happens automatically on initialization */ - private async compileSnapshot(): Promise { + private async compileSnapshot(): Promise { // Clear existing snapshot this.snapshot = {}; this.storeKeyMap.clear(); @@ -297,11 +342,33 @@ export class Activities extends EventEmitter { * Set an activity in the snapshot, creating necessary object structure */ private setActivityInSnapshot(activity: Activity): void { - if (!this.snapshot[activity.key]) { - this.snapshot[activity.key] = {}; + // Assign the key for convenience. + const key = activity.key; + + // Assign the entityId to the provided entityId - or to the root ("") entity if none provided. + const entityId = activity.entityId || ''; + + if (!this.snapshot[key]) { + this.snapshot[key] = {}; } - this.snapshot[activity.key][activity.entityId || ''] = activity; + this.snapshot[key][entityId] = activity; + + /* + // Index the activity by entityId:key. + if (!this.snapshotIndexedByEntity[entityId]) { + this.snapshot[entityId] = {}; + } + + this.snapshotIndexedByEntity[entityId][key] = activity; + + // Index the activity by key:entityId. + if (!this.snapshotIndexedByKey[key]) { + this.snapshot[key] = {}; + } + + this.snapshotIndexedByKey[key][entityId] = activity; + */ } /** diff --git a/packages/stack/src/storage/base-store.ts b/packages/stack/src/storage/base-store.ts index 447a220..bcb0882 100644 --- a/packages/stack/src/storage/base-store.ts +++ b/packages/stack/src/storage/base-store.ts @@ -5,6 +5,9 @@ export type StorageOpts = { encryption?: Encrypter; }; +// TODO: Do we extend from this at store construction or at `all()`? +export type StoreRecords = Record; + export type StoreEvents = { [key: `set:${string}`]: any; set: { key: string; value: any }; diff --git a/packages/stack/src/storage/index.ts b/packages/stack/src/storage/index.ts index a0ea86d..dc9ad39 100644 --- a/packages/stack/src/storage/index.ts +++ b/packages/stack/src/storage/index.ts @@ -1,6 +1,9 @@ +// NOTE: Do not export specific providers in this file. +// Do this in the parent index.ts file instead (otherwise, we mingle interfaces and providers, which can create circular dependencies).. +// The exception to this rule is with the InMemory and Composite providers. +// These are considered built-ins and do not have any other interface dependencies. export * from './activities.js'; export * from './base-store.js'; export * from './store-composite.js'; export * from './store-inmemory.js'; export * from './store-inmemory-synced.js'; -export * from './store-remote-http.js'; diff --git a/packages/stack/src/wallet/base-wallet.ts b/packages/stack/src/wallet/base-wallet.ts index 3fee0b7..3146dea 100644 --- a/packages/stack/src/wallet/base-wallet.ts +++ b/packages/stack/src/wallet/base-wallet.ts @@ -11,7 +11,15 @@ import { type BlockchainUTXOs } from '../blockchain/index.js'; import { type Activities, type BaseStore } from '../storage/index.js'; import { EventEmitter } from '../utils/index.js'; -import { type TokenBalances, type Address } from '@xocash/primitives'; +import { + type TokenBalances, + type Address, + Bytes, + Hasher, + encodeExtendedJson, +} from '@xocash/primitives'; + +export type WalletGenesisData = Record; export type WalletEvents = { shouldMonitorUpdated: boolean; @@ -33,32 +41,69 @@ export type WalletEvents = { export abstract class BaseWallet< WalletEventsType extends WalletEvents = WalletEvents, > extends EventEmitter { - static readonly deriveKey: (keyName: string) => Promise; + /** + * Derive a key to use based on the given Genesis Data. + * + * @remarks + * + * These keys can be used for Wallet Id's, Signing Keys, Encryption Keys and for Dapp Samdboxes. + */ + static deriveKey(genesisData: WalletGenesisData, keyName: string) { + // Hash the genesis data using Hash256 and the key ("WalletID") for this derivation using Hash256. + const genesisDataHashed = Hasher.fromUTF8( + encodeExtendedJson(genesisData), + ).toHash256Bytes(); + const keyHashed = Hasher.fromUTF8(keyName).toHash256Bytes(); + + // Combine the hashes and do one final hash for good measure. + return Hasher.fromBytes( + new Uint8Array([...genesisDataHashed, ...keyHashed]), + ).toHash256Bytes(); + } + + static deriveId(genesisData: WalletGenesisData) { + return BaseWallet.deriveKey(genesisData, 'walletId'); + } // Dependencies. abstract readonly activities: Activities; abstract readonly blockchain: WalletBlockchain; abstract readonly cache: BaseStore; + // Genesis Data. + abstract readonly genesisData: WalletGenesisData; + /** * Get the ID for this wallet. */ - abstract getId(): Promise; - - /** - * Derive a key using secret data from this wallet. - */ - abstract deriveKey(keyName: string): Promise; + async getId() { + // NOTE: Use the cache to optimize future calls. + return Bytes.from( + await this.cache.getOrSet('walletId', async () => + BaseWallet.deriveKey(this.genesisData, 'walletId'), + ), + ).toHex(); + } /** * Get the encryption key to use for this wallet. */ - abstract getEncryptionKey(): Promise; + async getEncryptionKey() { + // NOTE: Use the cache to optimize future calls. + return await this.cache.getOrSet('encryptionKey', async () => + BaseWallet.deriveKey(this.genesisData, 'encryptionKey'), + ); + } /** * Get the signing key to use for this wallet. */ - abstract getSigningKey(): Promise; + async getSigningKey() { + // NOTE: Use the cache to optimize future calls. + return this.cache.getOrSet('signingKey', async () => + BaseWallet.deriveKey(this.genesisData, 'signingKey'), + ); + } /** * Start monitoring this wallet for any transaction activity. diff --git a/packages/stack/src/wallet/index.ts b/packages/stack/src/wallet/index.ts index a8d351f..a72eeb8 100644 --- a/packages/stack/src/wallet/index.ts +++ b/packages/stack/src/wallet/index.ts @@ -1,8 +1,8 @@ +// NOTE: Do not export specific providers in this file. +// Do this in the parent index.ts file instead (otherwise, we mingle interfaces and providers, which can create circular dependencies).. +// The exception to this rule is with the InMemory and Composite providers. +// These are considered built-ins and do not have any other interface dependencies. export * from './activities.js'; export * from './base-wallet.js'; export * from './transaction-builder.js'; export * from './types.js'; -export * from './wallet-hd.js'; -export * from './wallet-p2pkh.js'; -export * from './wallet-p2sh20.js'; -export * from './wallet-p2sh32.js'; diff --git a/packages/stack/src/wallet/wallet-hd.ts b/packages/stack/src/wallet/wallet-hd.ts index 39da654..f8e6ed9 100644 --- a/packages/stack/src/wallet/wallet-hd.ts +++ b/packages/stack/src/wallet/wallet-hd.ts @@ -13,11 +13,8 @@ import { Mnemonic, calculateBalanceSats, calculateBalanceTokens, - encodeExtendedJson, } from '@xocash/primitives'; -import { hash256, binToHex, hexToBin, utf8ToBin } from '@bitauth/libauth'; - export const DERIVATION_PATH = "m/44'/145'/0'"; export const ADDRESS_GAP = 20; @@ -112,7 +109,7 @@ export class WalletHD extends BaseWallet { // Instantiate the HDPrivateNode from the type specified in our genesis data. if ('seed' in genesisData) { - this.hdPrivateNode = HDPrivateNode.fromSeed(hexToBin(genesisData.seed)); + this.hdPrivateNode = HDPrivateNode.fromSeed(genesisData.seed); } else if ('mnemonic' in genesisData) { this.hdPrivateNode = HDPrivateNode.fromMnemonic( Mnemonic.fromPhrase(genesisData.mnemonic), @@ -126,40 +123,6 @@ export class WalletHD extends BaseWallet { this.wallets[CHAIN_INTERNAL] = []; } - async getId() { - // NOTE: Use the cache to optimize future calls. - return binToHex( - await this.cache.getOrSet('walletId', async () => - this.deriveKey('walletId'), - ), - ); - } - - async deriveKey(keyName: string): Promise { - // Hash the genesis data using Hash256 and the key ("WalletID") for this derivation using Hash256. - const genesisDataHashed = hash256( - utf8ToBin(encodeExtendedJson(this.genesisData)), - ); - const keyHashed = hash256(utf8ToBin(keyName)); - - // Combine the hashes and do one final hash for good measure. - return hash256(new Uint8Array([...genesisDataHashed, ...keyHashed])); - } - - 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 address gap. // TODO: This should come from activities. diff --git a/packages/stack/src/wallet/wallet-p2pkh.ts b/packages/stack/src/wallet/wallet-p2pkh.ts index fce7d7b..4cf64c4 100644 --- a/packages/stack/src/wallet/wallet-p2pkh.ts +++ b/packages/stack/src/wallet/wallet-p2pkh.ts @@ -22,8 +22,6 @@ import { import { type Input, binToHex, - hash256, - utf8ToBin, walletTemplateP2pkhNonHd, walletTemplateToCompilerBCH, } from '@bitauth/libauth'; @@ -161,40 +159,6 @@ export class WalletP2PKH extends BaseWallet { this.privateKey = PrivateKey.fromHex(genesisData.privateKey); } - async getId() { - // NOTE: Use the cache to optimize future calls. - return binToHex( - await this.cache.getOrSet('walletId', async () => - this.deriveKey('walletId'), - ), - ); - } - - async deriveKey(keyName: string): Promise { - // Hash the genesis data using Hash256 and the key ("WalletID") for this derivation using Hash256. - const genesisDataHashed = hash256( - utf8ToBin(JSON.stringify(this.genesisData)), - ); - const keyHashed = hash256(utf8ToBin(keyName)); - - // Combine the hashes and do one final hash for good measure. - return hash256(new Uint8Array([...genesisDataHashed, ...keyHashed])); - } - - 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() { // Do nothing if already started. if (this.isStarted) {