Merge branch 'xo_wallet_integration_part_4' into 'development'

XO Wallet Integration Part IV

See merge request GeneralProtocols/xo/stack!96
This commit is contained in:
Jim Hamill
2025-05-27 23:17:43 +00:00
15 changed files with 249 additions and 123 deletions
+58 -2
View File
@@ -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);
+2
View File
@@ -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',
@@ -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<BlockchainElectrumOpts>;
};
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
+4 -1
View File
@@ -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';
+11
View File
@@ -32,3 +32,14 @@ export type BlockchainUTXO = {
// NOTE: To minimize refactors later, use this name elsewhere; not the `ExtMap<Type>` form.
export type BlockchainUTXOs = ExtMap<BlockchainUTXO>;
// 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;
};
+13
View File
@@ -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';
+3
View File
@@ -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';
+4 -3
View File
@@ -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';
+85 -18
View File
@@ -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<T extends Activity = Activity> = {
[entityId: string]: T;
export type SnapshotData<T extends Activity = Activity> = {
[keyOrEntityId: string]: T;
};
export type ActivitiesIndexedByEntityId<T extends Activity = Activity> = {
[key: string]: T;
export type SnapshotIndexedByKey<T extends Activity = Activity> = {
[key: string]: { [entityId: string]: T };
};
export type SnapshotIndexedByEntityId<T extends Activity = Activity> = {
[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<ActivitiesEvents> {
// 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<string, PolicyFunction>();
private storeKeyMap = new Map<string, string>();
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<string, PolicyFunction>();
protected storeKeyMap = new Map<string, string>();
/**
* Create an Activities instance from the given composite store.
@@ -93,7 +104,7 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
return activities;
}
private constructor(store: StoreComposite) {
protected constructor(store: StoreComposite) {
super();
// Assign our dependencies.
@@ -167,6 +178,10 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
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<ActivitiesEvents> {
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<string, Activity>,
);
}
/**
* Get all activities of a specific type
*/
public getByKey<T extends Activity>(
key: T['key'],
): ActivitiesIndexedByKey<T> {
public getByKey<T extends Activity>(key: T['key']): SnapshotData<T> {
const activities = this.snapshot[key];
if (!activities) {
return {};
@@ -191,9 +217,25 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
// 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>;
return { ...activities } as SnapshotData<T>;
}
/*
public getByEntityId<T extends Activity>(
entityId: string = '',
): SnapshotData<T> {
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<T>;
}
*/
/**
* 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<ActivitiesEvents> {
}
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<ActivitiesEvents> {
* Compile a full snapshot from scratch
* This happens automatically on initialization
*/
private async compileSnapshot(): Promise<SnapshotByType> {
private async compileSnapshot(): Promise<SnapshotState> {
// Clear existing snapshot
this.snapshot = {};
this.storeKeyMap.clear();
@@ -297,11 +342,33 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
* 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;
*/
}
/**
+3
View File
@@ -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<string, unknown>;
export type StoreEvents = {
[key: `set:${string}`]: any;
set: { key: string; value: any };
+4 -1
View File
@@ -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';
+55 -10
View File
@@ -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<string, unknown>;
export type WalletEvents = {
shouldMonitorUpdated: boolean;
@@ -33,32 +41,69 @@ export type WalletEvents = {
export abstract class BaseWallet<
WalletEventsType extends WalletEvents = WalletEvents,
> extends EventEmitter<WalletEventsType> {
static readonly deriveKey: (keyName: string) => Promise<Uint8Array>;
/**
* 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<string>;
/**
* Derive a key using secret data from this wallet.
*/
abstract deriveKey(keyName: string): Promise<Uint8Array>;
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<Uint8Array>;
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<Uint8Array>;
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.
+4 -4
View File
@@ -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';
+1 -38
View File
@@ -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<Uint8Array> {
// 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.
-36
View File
@@ -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<Uint8Array> {
// 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) {