diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b011e2..c6e2394 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,7 @@ For example: ```ts class SomeClass { + // NOTE: We do not use concrete implementations (e.g. "BlockchainELectrum" or "StoreIndexedDB"), but the abstractions ("BaseBlockchain" and "BaseStore") instead. protected blockchain: BaseBlockchain; protected store: BaseStore; @@ -86,6 +87,8 @@ For example: class SomeClass { // Static "from" factory method. // NOTE: The below args are shorthand for using the same arguments as the constructor. + // Do not feel forced to use this - there are definitely some situations where you will have to define the args manually. + // But do try to follow the argument patterns in the above "Dependency Injection" section if possible. static async from(...args: ConstructorParameters) { // Create the instance. const someClass = new SomeClass(...args); @@ -103,6 +106,85 @@ Although this isn't always necessary (i.e. not all instantiations need to be `as *Semantically, think of "from" as meaning "instantiate from": This is more succinct than a `createFrom...` factory method which, if we have multiple ways of instantiation, can become verbose. E.g. `PrivateKey.fromWIF()` as opposed to `PrivateKey.createFromWIF()`. It also allows us to differentiate between actually "creating" something (e.g. a `Filesystem.createFile(...)`) versus "instantiating" a representation of a "thing".* +#### Extending from Static Factory Methods + +If you anticipate that someone will `extend` a class and want to re-use your static factory method, this can be achieved as follows: + +```ts +class YourClass { + // NOTE: The generic T extends typeof YourClass (so that we can return the correct type).: + static async from( + // NOTE: "this" as first argument is what technically, allows the extended class to instantiate correctly. + this: T, + params: SomeParams = {}, + ) { + // NOTE: So that Typescript can type correctly, we perform a cast. + return new this(params) as InstanceType; + } + + // ... +} +``` + +Extended classes can then be instantiated seamlessly as follows: + +```ts +class YourExtendedClass extends YourClass { + // ... +} + +// This will now be an instance of YourExtendedClass without having to redefine the `from(...)` method. +const yourExtClass = YourClass.from(...); +``` + +This adds quite a bit of confusing syntactic overhead, so only do this if you anticipate use-cases where extension is likely. + +### EventEmitter Patterns for BaseAdapters + +All BaseAdapters should extend EventEmitter. This design choice enables Stack components to work seamlessly with different frontend frameworks (vanilla JS, Vue, React, etc.) through a consistent event-based interface. + +1. State Management + Adapters in Stack should maintain minimal state. While backend flows typically operate procedurally (fetching data as needed), frontends often require reactive state updates for UI rendering. + +2. Framework Agnostic + Since reactive state handling varies across frameworks, we use event hooks rather than imposing specific state management patterns, allowing applications to handle higher-level state according to their framework (and product's) conventions. + +To illustrate how this might be used with VueJS: + +```ts +// Create a reactive variable (Vue example) +const isConnected = ref(); + +// Initialize adapter +const someAdapter = new SomeAdapter(); + +// Bind adapter state to reactive variable +someAdapter.on('isConnected', (isConnected) => { + isConnected.value = isConnected; +}); +``` + +```ts +// Declare a reactive variable for unspents (Vue example) +const unspents = ref([]); + +// Initialize the wallet +const someWallet = await WalletHD.from(genesisData); + +// Hook into state updated event +someWallet.on( + 'stateUpdated', + async () => { + // Sync unspents to the reactive variable + unspents.value = await someWallet.updateUnspents(), + }, + // (Optional) Debounce for 500ms to prevent excessive redraws + 500 +); +``` + +**Note: While XO Wallet will provide convenience wrappers around these patterns, this represents the general approach to use in @xocash/stack.** + ### Validations Check types with TypeScript - Runtime type-checking is a code smell. If the function accepts a string, assume it's been given a string. TypeScript definitions should expose improperly called functions to the developer at compile time, don't re-implement it at runtime. @@ -112,6 +194,8 @@ There are a few exceptions to this: - If TypeScript's lack of dependant types makes validating certain properties of a type (e.g. the length of a Uint8Array), then runtime type-checking should be used to detect and return errors. - If we are working with a network (remote/untrusted) payload, Type Validation should be done with [Zod](https://www.npmjs.com/package/zod). +#### Validating External Payloads + An example of working with Zod: ```ts @@ -132,4 +216,19 @@ const parsedPayload = SomeRemotePayload.parse(rawPayload); export type StoreRemoteHTTPEvent = z.infer; ``` +#### Leverage Primitives Over Repeated Validation +Avoid revalidation at every code-path: This is not only inefficient, but a giant code-smell. Instead, make use of Primitives as these provide assurances of correctness (Primitives are not just intended as a convenience - they are also intended to provide safety). For example, given a method: + + +```ts +// You can accept other types (this is a nice convenience). +broadcastTransaction(transaction: Transaction | Uint8Array): Transaction { + // ... but always cast to the Primitive (to ensure correctness). + if(!(transaction instanceof Transaction) { + transaction = Transaction.from(transaction); + } + + // ... use the Transaction primitive in follow up code. +} +``` diff --git a/package.json b/package.json index 120e70f..3099d2d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "docs:preview": "lerna run preview --scope=@xocash/docs --include-dependencies", "format": "lerna run format", "lint": "eslint .", - "madge": "npx madge --circular ./packages", + "madge": "lerna run madge", "test": "lerna run test", "test:coverage": "lerna run test:coverage", "version:patch": "lerna version patch", diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 44a5ec8..fb56144 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -13,6 +13,7 @@ "build": "tsc --build --clean && tsc --build", "prepublishOnly": "npm run build", "format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore", + "madge": "npx madge --circular ./src/index.ts", "test": "vitest --run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", diff --git a/packages/primitives/src/satoshis.ts b/packages/primitives/src/satoshis.ts index 198edd0..f7c91cf 100644 --- a/packages/primitives/src/satoshis.ts +++ b/packages/primitives/src/satoshis.ts @@ -1,4 +1,4 @@ -export type BCHUnits = ['sats', 'bits', 'mBCH', 'BCH']; +export type BCHUnits = 'sats' | 'bits' | 'mBCH' | 'BCH'; /** * Satoshis Primitive @@ -206,4 +206,70 @@ export class Satoshis { toRaw() { return this.satoshis; } + + /** + * Formats the amount in the specified unit with formatting options. + * + * @param units - The unit to format as ('sats', 'bits', 'mBCH', 'BCH') + * @param opts - Formatting options + * @param opts.roundToDigits - Number of decimals to round to + * @param opts.appendSuffix - Whether to append the unit suffix + * @returns Formatted amount as string + * + * @example + * // Format as BCH with 2 decimal places and suffix + * satoshis.format('BCH', { roundToDigits: 2, appendSuffix: true }) // "0.12 BCH" + * + * @example + * // Format as satoshis without suffix + * satoshis.format('sats', { roundToDigits: 0, appendSuffix: false }) // "12345678" + */ + public format( + units: BCHUnits, + opts: { + roundToDigits?: number; + appendSuffix?: boolean; + showApproximationSymbol?: boolean; + } = {}, + ): string { + // Set default values if not provided + const roundToDigits = opts.roundToDigits ?? 4; + const appendSuffix = opts.appendSuffix ?? true; + const showApproximationSymbol = opts.showApproximationSymbol ?? true; + + // Get the exact amount in the specified unit before rounding + let exactAmount: number; + + // Use type assertion to help TypeScript understand this is a valid BCHUnits value + switch (units) { + case 'sats': + exactAmount = this.toSats(); + break; + case 'bits': + exactAmount = Number(this.satoshis) / 100; + break; + case 'mBCH': + exactAmount = Number(this.satoshis) / 100_000; + break; + case 'BCH': + exactAmount = Number(this.satoshis) / 100_000_000; + break; + default: + throw new Error(`Unsupported unit: ${units}`); + } + + // Format the amount with the specified number of decimal places + const roundedAmount = Satoshis.roundToDigits(exactAmount, roundToDigits); + + // Check if rounding occurred + const wasRounded = exactAmount !== roundedAmount; + + // Add approximation symbol if needed and enabled + const prefix = wasRounded && showApproximationSymbol ? '~' : ''; + + // Return the formatted amount with prefix and/or suffix + return appendSuffix + ? `${prefix}${roundedAmount} ${units}` + : `${prefix}${roundedAmount}`; + } } diff --git a/packages/stack/package.json b/packages/stack/package.json index 53a0394..d8d3867 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -23,6 +23,7 @@ "bench": "vitest bench", "build": "tsc --build --clean && tsc --build", "format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore", + "madge": "npx madge --circular ./src/index.ts", "prepublishOnly": "npm run build", "test": "vitest --run", "test:coverage": "vitest run --coverage",