Compare commits
5
Commits
development
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dbf98c2bc | ||
|
|
949c5df2d8 | ||
|
|
c87ad46a48 | ||
|
|
8875e3f5ff | ||
|
|
cbb223cbda |
@@ -0,0 +1,295 @@
|
||||
---
|
||||
title: 'Vision to Reality 4: Revving the Engine'
|
||||
date: 2026-08-05
|
||||
description: Realization of the XO vision, part 4 covering recent improvements to the XO stack and a short roadmap.
|
||||
author: Jonathan Silverblood
|
||||
tags: [templates, engine, technical audience]
|
||||
---
|
||||
|
||||
# Vision to Reality 4: Revving the Engine
|
||||
|
||||
This article draws up a short roadmap to set expectations for the upcoming BCH Blaze hackathon in November 2026 and covers recent improvements to the XO stack.
|
||||
|
||||
----
|
||||
|
||||
```mermaid
|
||||
mindmap
|
||||
root )Responsibilities(
|
||||
|
||||
[INVITATIONS]
|
||||
(Connects)
|
||||
📱 Apps
|
||||
👛 Wallets
|
||||
👥 Users
|
||||
(Coordinates)
|
||||
⚡ Actions
|
||||
🧩 Variables
|
||||
📥 Inputs
|
||||
📤 Outputs
|
||||
|
||||
[TEMPLATES]
|
||||
(Describes)
|
||||
🧩 Variables
|
||||
💸 Transactions
|
||||
📥 Inputs
|
||||
📤 Outputs
|
||||
🏷️ Lockscripts
|
||||
(Defines)
|
||||
⚡ Actions
|
||||
💸 Transactions
|
||||
💾 Data
|
||||
|
||||
[ENGINE]
|
||||
(Generates)
|
||||
🔑 Keys
|
||||
🪪 Identities
|
||||
✉️ Invitations
|
||||
💸 Transactions
|
||||
(Manages)
|
||||
🤫 Secrets
|
||||
🧩 Variables
|
||||
📤 Outputs
|
||||
🏷️ Lockscripts
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Tools and tutorials
|
||||
|
||||
In preparation for the Blaze hackathon we are planning to release additional articles and tooling to help participants build on XO.
|
||||
|
||||
- Publish article on high-level XO concepts
|
||||
- Publish article on best practices when making templates
|
||||
- Publish article on current tools and limitations
|
||||
- Release alpha version of a command-line or text interface wallet
|
||||
- Release alpha version of graphical XO interactive debug tool
|
||||
|
||||
### Token Support
|
||||
|
||||
Currently, the XO engine is only capable of working with tokens in a very limited capacity. For example, while it is possible to define token balance estimations in templates, they are currently not being used.
|
||||
|
||||
Full token support is a target goal and is one of the key area for improvement leading up to the hackathon, with explicit support for token genesis, token minting, coin selection, balance estimation and much more.
|
||||
|
||||
|
||||
### Improved Naming
|
||||
|
||||
In templates today, the words `locking script` and `locking bytecode` are mixed and the result is vagueness and inconsistency.
|
||||
|
||||
All uses that directly refer to actual underlying bytecode will be renamed to either `lockingBytecode` or `unlockingBytecode`. Similarly, all references to `lockingScript` or `unlockingScript` will refer to named and documented entities that uses `lockingBytecode` or `unlockingBytecode` respectively in their definitions.
|
||||
|
||||
As a result, we will also introduce an `unlockingScripts` section similar to the `lockingScripts`. Here unlocking scripts can be described properly with names, descriptions and other metadata.
|
||||
|
||||
### Default Outputs Instead of Inheritance
|
||||
|
||||
One of the many goals of the XO stack is to ensure that no funds are lost. To this end, we previously added data to locking scripts that allowed the engine to classify and handle unknown transactions, when those transactions have outputs to monitored locking scripts in the engine.
|
||||
|
||||
Previously, any output to a known locking script that lacked classification by the engine, would inherit properties of the locking script and become an implicit default output.
|
||||
|
||||
Going forward, we are making this explicit by adding a reference to an explicit default output in locking scripts and moving all related functionality to this definition.
|
||||
|
||||
This has two big impacts. First, it makes it more explicit that a template is responsible for handling default outputs on locking scripts. Second, it encourages template authors to provide explicit names and descriptions for the default locking script and for any uncoordinated outputs that spend to it.
|
||||
|
||||
### Events
|
||||
|
||||
Currently, users of the XO stack have to poll for data when they need it. Going forward, we will implement proper event emission so that applications can know when new data is available. This will include all blockchain triggered changes, but also information on when the engine starts and completes state synchronization, when wallet balance estimations are updated and much more.
|
||||
|
||||
### Improved Documentation
|
||||
|
||||
While we are still doing early alpha releases and breaking changes are still to be expected, we will make a concentrated effort to improve the technical package documentation ahead of the BCH Blaze hackathon.
|
||||
|
||||
This includes tutorials on how to get started, better structure on the available documentation and more practical examples.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
A lot has happened since our last article and we have been hard at work making template, engine and tooling updates, as well as presenting at the [BCH Bliss](https://www.bliss.cash) conference.
|
||||
|
||||
Here is a short summary of what has improved:
|
||||
|
||||
- new BIP43 compliant derivation scheme
|
||||
- improved reservation handling
|
||||
- added expiration date on invitations and resources
|
||||
- introduce intents as a concept
|
||||
- cleared up and isolated state better
|
||||
- dynamic template roles instances
|
||||
- added simple automated coin selection
|
||||
- improved change handling with policies
|
||||
- implemented a large number of new primitive data types
|
||||
- various quality-of-life and developer experience improvements
|
||||
|
||||
### Derivation
|
||||
|
||||
Under the hood, a lot has changed with regards to how XO derives cryptographic keys. Previously, we used a scheme that was a variation of [BIP 44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) but that was **not** BIP44 compliant.
|
||||
|
||||
The new derivation paths are shorter and simpler and are instead based on [BIP 43](https://github.com/bitcoin/bips/blob/master/bip-0043.mediawiki):
|
||||
|
||||
> `m/XO'/ACCOUNT_OR_TEMPLATE'/USE_CASE'/INDEX`
|
||||
|
||||
Where `XO = 2415` and each template has its own hardened keyspace. Keys are further separated within each keyspace by a hardened `USE_CASE`.
|
||||
|
||||
This setup isolates keys that a template author might be able to influence from keys associated with other templates, or used by the engine in various ways.
|
||||
|
||||
For account-wide usage, the `ACCOUNT_OR_TEMPLATE` is set to 0, allowing for a non-templated keyspace, which also has hardened `USE_CASE` isolation.
|
||||
|
||||
### Reservations
|
||||
|
||||
When preparing to take action, the engine reserves resources that will be used by the action. This part of the code has been improved with better error reporting and more clear naming, and it is now possible for applications to reserve resources manually.
|
||||
|
||||
Technically, the reservation has changed from being a boolean, to being `reservedBy` with a reference. Requesting a reservation for an already reserved resource returns an error.
|
||||
|
||||
### Expirations
|
||||
|
||||
Participation in invitations now come with expiration dates, so that reserved resources can be restored when actions are not completed within expected timeframes.
|
||||
|
||||
Note that the expiration dates are advisory rather than enforced, and serve only to prevent permanent reservation of resources. If an action is attempted after an expiration has happened and the resources are still available, the action will still succeed.
|
||||
|
||||
### Intents
|
||||
|
||||
We have added a concept of reuseable intents which define the preconditions to creating resources in the engine.
|
||||
|
||||
This allowed some work to be specified upfront with the new `generate:` keyword, rather than being inferred by the engine after a requirement has been encountered.
|
||||
|
||||
For example, here is an intent to create a P2PKH change output:
|
||||
```ts
|
||||
{
|
||||
output: 'changeOutput',
|
||||
role: 'receiver',
|
||||
generate: [ 'ownerKey' ],
|
||||
}
|
||||
```
|
||||
|
||||
Further, this reconciles all differences between starting actions and follow-up actions, and serves as the framework for creating various structures such as locking scripts, outputs and other data.
|
||||
|
||||
### State
|
||||
|
||||
We have moved all state representations to only exist in outputs or locking scripts, whereas some were defined in requirements previously.
|
||||
|
||||
Note that we are further planning to move the inherited output handling for uncoordinated outputs from the locking scripts into a referenced `defaultOutput`, which will fully move **all** templated state representation to exist only in outputs as originally intended.
|
||||
|
||||
### Slots
|
||||
|
||||
Templates can now specify a minimum and maximum number of instances each role requires via the `slots:` keyword.
|
||||
|
||||
This removes the need for fixed roles and allows for arbitrary and fungible role takers, such as pledgers in fundraising campaigns or signers in multisig wallets.
|
||||
|
||||
### Coin Selection
|
||||
|
||||
Before automated coin selection, applications had to provide their inputs manually and the engine was only able to unlock inputs from the same template.
|
||||
|
||||
With the initial implementation of naive coin selection, the engine can now pick coins that are selectable from any template to satisfy requirements for satoshis when performing an action.
|
||||
|
||||
### Change
|
||||
|
||||
In previous versions of the engine, change was not handled automatically and instead implementations had to provide the change output by hand.
|
||||
|
||||
Note that change is a difficult concept to get right, as there are usecases that have conflicting requirements. It is easy to break user expectation when doing naive coin selection:
|
||||
|
||||
- Spending from a multisig template and generating a singlesig change.
|
||||
- Spending from a privacy focused template and generating a non-private change.
|
||||
|
||||
The long-term solution for this is introduction of a change policy which controls how the engine automates creation of change outputs.
|
||||
|
||||
As a result, templates can now define a change policy if they have specific needs:
|
||||
|
||||
```ts
|
||||
// Sets optional default values to be used with this template.
|
||||
defaults:
|
||||
{
|
||||
change:
|
||||
{
|
||||
output: 'changeOutput',
|
||||
role: 'receiver',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The engine follows a strict order when choosing what change policy to apply, allowing for fine-grained control by the application:
|
||||
|
||||
- `override` policy if manually provided by the application
|
||||
- `default` policy in the template linked to the spent input
|
||||
- `fallback` policy previously configured by the application
|
||||
|
||||
*If no change policy is available, the engine will refuse to take action when change is required.*
|
||||
|
||||
With this policy framework in place, a privacy focused wallet can guarantee that change will never go to a non-private output, either by setting an `override` policy, or by only allowing templates with acceptable privacy.
|
||||
|
||||
At the same time, if a wallet does not have strict change requirements they can set a `fallback` policy instead, and will then use `default` policies from the templates they are spending from when available.
|
||||
|
||||
Currently, the engine only supports the template `default` and engine `fallback` policies. The manual `override` policy will be supported soon.
|
||||
|
||||
### Primitives
|
||||
|
||||
A number of new primitives have been added, and all primitives are validated on construction, including new run-time validation.
|
||||
|
||||
**New primitive: Satoshis**
|
||||
|
||||
In various places in XO code, we used to have **bigints** for satoshi values, but this has been changed in favor of a dedicated `satoshis` primitive which additionally validates that the amount falls in reasonable ranges representable by the **CashVM**.
|
||||
|
||||
**New primitive: Transaction Hash**
|
||||
|
||||
We used to represent transaction hashes as strings, but this has been changed in favor of a dedicated `transaction hash` primitive.
|
||||
|
||||
This change makes it less likely that another string is used in a place where we should be using a transaction hash, and the primitive further validates the length and format of transaction hashes.
|
||||
|
||||
**New primitive: Extended Public Keys**
|
||||
|
||||
Previously we used to have a single public key for each participant in an invitation, which resulted in key re-use and potential for adversaries to control what data to sign with a key, risking cryptographic information leak.
|
||||
|
||||
By adding an `extended public key` primitive and using that in invitations, each update to an invitation now signs with a unique key. This reduces possible attack surface by removing key re-use in invitations.
|
||||
|
||||
**New primitive: Timestamps**
|
||||
|
||||
Representing time in script has long been a solved problem and well defined by the **CashVM**, but representing time to end-users in templates required better tooling on the engine side.
|
||||
|
||||
By adding an `timestamp` primitive we can extend support for end-user presentation of time, starting with the `iso8601` standard formatting.
|
||||
|
||||
```ts
|
||||
// Example CashASM demonstrating timestamp usage.
|
||||
description: 'Last seen: <myTimestamp.iso8601>',
|
||||
```
|
||||
|
||||
## Other improvements
|
||||
|
||||
### Passing Identifiers
|
||||
|
||||
Previously it was required that developers pass full objects for things like `invitations` every time they needed to interact with them. This has been changed so that only identifiers are needed for items that are cached by default.
|
||||
|
||||
```ts
|
||||
const { invitationIdentifier } = await engine.createInvitation();
|
||||
|
||||
// Later, when signing the invitation
|
||||
await engine.signInvitiation(invitationIdentifier);
|
||||
```
|
||||
|
||||
### Isolated Database Per-Seed.
|
||||
|
||||
To avoid conflicts when using the same application loaded with multiple seeds, we have added database isolation on a per-seed basis.
|
||||
|
||||
This also makes it possible to run multiple instances of the engine with different seeds, allowing multiple accounts within the same application.
|
||||
|
||||
### Improved Invitation Handling
|
||||
|
||||
Invitations can now be easily shared with import and export functionality, creating a flexible starting point for building transport protocols and standards for sending invitations between different wallets.
|
||||
|
||||
To prevent possible leakage of cryptographic entropy, each step of an invitation is now signed by a unique key, rather than a shared key per-invitation.
|
||||
|
||||
After executing an action from an invitation, the resulting `data` or `transaction` data structures are returned to the caller.
|
||||
|
||||
### Template Validation
|
||||
|
||||
We have improved validation prior to importing templates with both syntax and range checks, and ensured that all errors are properly reported with actionable error messages.
|
||||
|
||||
### Applicaton State
|
||||
|
||||
In additional to templated state, we have added support for engine and application state.
|
||||
|
||||
This is used by the engine to remember things like how to create default receiving addresses, and can be used by applications to store things like wallet settings.
|
||||
|
||||
### Rigorous Testing
|
||||
|
||||
Many parts of the XO stack have significantly improved their automated testing for additional quality assurance.
|
||||
|
||||
### P2PKH
|
||||
|
||||
P2PKH is the current industry standard for wallets. We have continued to improve the P2PKH template with better documentation, more human language and more explicit handling.
|
||||
@@ -0,0 +1,699 @@
|
||||
import { BaseWallet } from './base-wallet.js';
|
||||
import {
|
||||
type TransactionTemplate,
|
||||
type WalletBlockchain,
|
||||
type WalletDependencies,
|
||||
} from './types.js';
|
||||
import { TransactionBuilder } from './transaction-builder.js';
|
||||
import { WalletP2PKH } from './wallet-p2pkh.js';
|
||||
|
||||
import { type BaseStore, Activities, StoreInMemory } from '../storage/index.js';
|
||||
import { type DistributiveOmit } from '../utils/index.js';
|
||||
|
||||
import {
|
||||
type TokenBalances,
|
||||
HDPrivateNode,
|
||||
ExtMap,
|
||||
Mnemonic,
|
||||
calculateBalanceSats,
|
||||
calculateBalanceTokens,
|
||||
} from '@xocash/primitives';
|
||||
|
||||
import { binToNumberUint32LE, hexToBin } from '@bitauth/libauth';
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Constants
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* XO derivation purpose path. All XO key derivation starts from this BIP43 purpose node.
|
||||
* This matches engine/crypto's `XO_BASE_PATH = m/2415'`.
|
||||
*/
|
||||
export const XO_PURPOSE_PATH = "m/2415'";
|
||||
|
||||
/**
|
||||
* Default number of consecutive empty addresses before stopping the address gap scan.
|
||||
* If this many sequential child indexes produce addresses with no on-chain activity,
|
||||
* we assume there are no more used keys beyond this point.
|
||||
*/
|
||||
export const XO_ADDRESS_GAP = 100;
|
||||
|
||||
/**
|
||||
* Template key use case index for signing and spending (e.g. Schnorr signatures, P2PKH).
|
||||
* Matches engine/crypto's `TemplateKeyUseCase.SigningAndSpending = 0`.
|
||||
*/
|
||||
export const XO_USE_CASE_SIGNING_AND_SPENDING = 0;
|
||||
|
||||
/**
|
||||
* Template key use case index for encryption.
|
||||
* Not yet implemented in engine/crypto, but we scan it for forward-compatibility.
|
||||
* Matches engine/crypto's `AccountKeyUseCase.Encryption = 1`.
|
||||
*/
|
||||
export const XO_USE_CASE_ENCRYPTION = 1;
|
||||
|
||||
/**
|
||||
* All use case indexes to scan for each template.
|
||||
* We check both so that keys derived under either use case are discovered.
|
||||
*/
|
||||
export const XO_USE_CASES = [
|
||||
XO_USE_CASE_SIGNING_AND_SPENDING,
|
||||
XO_USE_CASE_ENCRYPTION,
|
||||
];
|
||||
|
||||
/**
|
||||
* Maximum valid non-hardened BIP32 child index (2^31 - 1).
|
||||
* Matches engine/crypto's `NON_HARDENED_INDEX_MAX`.
|
||||
*/
|
||||
const NON_HARDENED_INDEX_MAX = 0x7fffffff;
|
||||
|
||||
/**
|
||||
* Expected length of a template identifier hex string (32 bytes = 64 hex chars).
|
||||
* Template identifiers are SHA-256 hashes represented as hex.
|
||||
*/
|
||||
const TEMPLATE_IDENTIFIER_HEX_LENGTH = 64;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Types
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Entropy sources for WalletXO. Accepts the same formats as WalletHD:
|
||||
* a hex seed string, an extended private key, or a Mnemonic instance.
|
||||
*/
|
||||
export type WalletXOEntropy =
|
||||
| { seed: string }
|
||||
| { xpriv: string }
|
||||
| { mnemonic: Mnemonic };
|
||||
|
||||
/**
|
||||
* Genesis data for constructing a WalletXO instance.
|
||||
* Contains the entropy source, the list of template identifiers to scan,
|
||||
* and an optional address gap override.
|
||||
*/
|
||||
export type WalletXOGenesisData = {
|
||||
type: 'WalletXO';
|
||||
templateIdentifiers: string[];
|
||||
addressGap?: number;
|
||||
} & WalletXOEntropy;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Utilities
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a 31-bit BIP32 non-hardened child index from a template identifier hex string.
|
||||
* This mirrors the engine/crypto `createPathIndexFromHash` function but operates on
|
||||
* the raw template identifier bytes directly (template identifiers are already 32-byte hashes).
|
||||
*
|
||||
* The algorithm reads the first 4 bytes as a little-endian uint32, then masks to 31 bits
|
||||
* to guarantee a valid non-hardened index (0 to 2,147,483,647).
|
||||
*
|
||||
* @see engine/crypto/source/utils.ts - createPathIndexFromHash
|
||||
* @param templateIdentifier - A 64-character hex string representing a 32-byte template identifier
|
||||
* @returns Path index (31-bit unsigned integer, 0 to 2,147,483,647)
|
||||
*/
|
||||
const createPathIndexFromTemplateIdentifier = (
|
||||
templateIdentifier: string,
|
||||
): number => {
|
||||
// Convert the hex template identifier to raw bytes.
|
||||
const hashBytes = hexToBin(templateIdentifier);
|
||||
|
||||
// Read the first 4 bytes as a little-endian uint32.
|
||||
const uint32FirstFourBytes = binToNumberUint32LE(hashBytes.slice(0, 4));
|
||||
|
||||
// Mask to 31 bits to stay within the valid non-hardened index range.
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return uint32FirstFourBytes & NON_HARDENED_INDEX_MAX;
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// WalletXO
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A wallet that implements the same HD key derivation as the XO Crypto package.
|
||||
* This allows a user to import the mnemonic used with an XO Templates wallet
|
||||
* and then derive keys based on a template identifier.
|
||||
*
|
||||
* Crypto Package Reference: engine/crypto/source/derivation.ts
|
||||
*
|
||||
* Derivation Path: m/2415'/{templateIndex}'/{useCaseIndex}'/{childIndex}
|
||||
*
|
||||
* - templateIndex: derived from the first 4 bytes of the template identifier
|
||||
* (little-endian uint32, masked to 31 bits). This is computed via the same
|
||||
* algorithm as engine/crypto's `createPathIndexFromHash`.
|
||||
* - useCaseIndex: 0 (SigningAndSpending) or 1 (Encryption). Both are scanned.
|
||||
* - childIndex: 0..N, scanned using address gap logic to discover used keys.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* // Create a WalletXO from a mnemonic and template identifiers.
|
||||
* const walletXO = await WalletXO.from(
|
||||
* { blockchain },
|
||||
* {
|
||||
* mnemonic: Mnemonic.from('abandon abandon abandon ... about'),
|
||||
* templateIdentifiers: ['aabb...64hexchars...'],
|
||||
* },
|
||||
* );
|
||||
*
|
||||
* // Scan for transactions and start monitoring.
|
||||
* await walletXO.start();
|
||||
*
|
||||
* // Dynamically add a new template later.
|
||||
* await walletXO.addTemplate('ccdd...64hexchars...');
|
||||
* ```
|
||||
*/
|
||||
export class WalletXO extends BaseWallet {
|
||||
/**
|
||||
* Factory method to create a new WalletXO instance with default dependencies.
|
||||
* Fills in optional dependencies (activities, cache) with defaults if not provided.
|
||||
*
|
||||
* @param deps - Wallet dependencies (blockchain required, activities/cache optional)
|
||||
* @param genesisData - Entropy source + template identifiers + optional address gap
|
||||
* @returns A new WalletXO instance
|
||||
*/
|
||||
static async from<T extends typeof WalletXO>(
|
||||
this: T,
|
||||
deps: WalletDependencies,
|
||||
genesisData: DistributiveOmit<WalletXOGenesisData, 'type'>,
|
||||
) {
|
||||
// Assign defaults if not otherwise provided.
|
||||
const activities = deps.activities
|
||||
? deps.activities
|
||||
: await Activities.from();
|
||||
const cache = deps.cache ? deps.cache : StoreInMemory.from();
|
||||
|
||||
// Assemble the final dependencies with all required fields.
|
||||
const depsFinal = {
|
||||
activities,
|
||||
cache,
|
||||
blockchain: deps.blockchain,
|
||||
};
|
||||
|
||||
// Return a new instance of WalletXO.
|
||||
return new this(depsFinal, genesisData) as InstanceType<T>;
|
||||
}
|
||||
|
||||
// Dependencies.
|
||||
public readonly activities: Activities;
|
||||
public readonly blockchain: WalletBlockchain;
|
||||
public readonly cache: BaseStore;
|
||||
|
||||
// Genesis Data.
|
||||
public readonly genesisData: WalletXOGenesisData;
|
||||
|
||||
/** The root HD node derived from the provided entropy source. */
|
||||
public readonly hdPrivateNode: HDPrivateNode;
|
||||
|
||||
/**
|
||||
* The XO purpose HD node at m/2415'.
|
||||
* All template key derivations are relative to this node, matching
|
||||
* the engine/crypto package's `derivePurposeHDNodeFromSeed` output.
|
||||
*/
|
||||
public readonly purposeNode: HDPrivateNode;
|
||||
|
||||
/**
|
||||
* Child wallets grouped by template identifier and use case index.
|
||||
* Structure: Map<templateIdentifier, { [useCaseIndex]: WalletP2PKH[] }>
|
||||
*/
|
||||
public wallets: Map<string, { [useCaseIndex: number]: WalletP2PKH[] }> =
|
||||
new Map();
|
||||
|
||||
constructor(
|
||||
dependencies: Required<WalletDependencies>,
|
||||
genesisData: DistributiveOmit<WalletXOGenesisData, 'type'>,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Assign our dependencies.
|
||||
this.activities = dependencies.activities;
|
||||
this.blockchain = dependencies.blockchain;
|
||||
this.cache = dependencies.cache;
|
||||
|
||||
// Assign our genesis data with the type discriminator.
|
||||
this.genesisData = {
|
||||
type: 'WalletXO',
|
||||
...genesisData,
|
||||
};
|
||||
|
||||
// Instantiate the HDPrivateNode from the entropy source provided in genesis data.
|
||||
if ('seed' in genesisData) {
|
||||
this.hdPrivateNode = HDPrivateNode.fromSeed(genesisData.seed);
|
||||
} else if ('mnemonic' in genesisData) {
|
||||
this.hdPrivateNode = HDPrivateNode.fromMnemonic(genesisData.mnemonic);
|
||||
} else {
|
||||
this.hdPrivateNode = HDPrivateNode.fromXPriv(genesisData.xpriv);
|
||||
}
|
||||
|
||||
// Derive the XO purpose node at m/2415'.
|
||||
// This is the root for all template key derivations and matches the
|
||||
// engine/crypto purposeHDNode exactly.
|
||||
this.purposeNode = this.hdPrivateNode.derivePath(XO_PURPOSE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the wallet by scanning all template identifiers from genesis data
|
||||
* for used keys and then starting all derived child wallets for monitoring.
|
||||
*/
|
||||
async start() {
|
||||
// Get the address gap from genesis data or use the default.
|
||||
const addressGap = this.genesisData.addressGap || XO_ADDRESS_GAP;
|
||||
|
||||
// Scan all templates provided in genesis data for used keys.
|
||||
for (const templateIdentifier of this.genesisData.templateIdentifiers) {
|
||||
await this.scanTemplate(templateIdentifier, 0, addressGap);
|
||||
}
|
||||
|
||||
// Start all derived child wallets.
|
||||
// Child wallets may already be started from deriveWalletsForUseCase,
|
||||
// but WalletP2PKH.start() is idempotent (checks isStarted flag).
|
||||
const startPromises: Promise<void>[] = [];
|
||||
for (const wallet of this.getAllChildWallets()) {
|
||||
startPromises.push(wallet.start());
|
||||
}
|
||||
await Promise.all(startPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring all child wallets for transaction activity.
|
||||
*/
|
||||
async stop() {
|
||||
const stopPromises: Promise<void>[] = [];
|
||||
for (const wallet of this.getAllChildWallets()) {
|
||||
stopPromises.push(wallet.stop());
|
||||
}
|
||||
await Promise.all(stopPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all child wallets and remove all event listeners from this wallet.
|
||||
*/
|
||||
async destroy() {
|
||||
const destroyPromises: Promise<void>[] = [];
|
||||
for (const wallet of this.getAllChildWallets()) {
|
||||
destroyPromises.push(wallet.destroy());
|
||||
}
|
||||
await Promise.all(destroyPromises);
|
||||
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically add a new template identifier and scan for derived keys.
|
||||
* If the template has already been added, this is a no-op.
|
||||
*
|
||||
* @param templateIdentifier - A 64-character hex string template identifier
|
||||
* @throws {Error} If the template identifier is not 64 hex characters
|
||||
*/
|
||||
async addTemplate(templateIdentifier: string) {
|
||||
// Validate the template identifier length.
|
||||
if (templateIdentifier.length !== TEMPLATE_IDENTIFIER_HEX_LENGTH) {
|
||||
console.error('Template identifier must be ${TEMPLATE_IDENTIFIER_HEX_LENGTH} hex characters, got ${templateIdentifier.length}');
|
||||
throw new Error(
|
||||
`Template identifier must be ${TEMPLATE_IDENTIFIER_HEX_LENGTH} hex characters, got ${templateIdentifier.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Skip if this template has already been added.
|
||||
if (this.wallets.has(templateIdentifier)) {
|
||||
console.error('Template ${templateIdentifier} has already been added');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the address gap to use for scanning.
|
||||
const addressGap = this.genesisData.addressGap || XO_ADDRESS_GAP;
|
||||
console.log('addressGap', addressGap);
|
||||
|
||||
// Scan the template for derived keys across all use cases.
|
||||
console.log('scanning template');
|
||||
await this.scanTemplate(templateIdentifier, 0, addressGap);
|
||||
console.log('scanTemplate complete');
|
||||
|
||||
// Start all newly derived child wallets for this template.
|
||||
const templateWallets = this.wallets.get(templateIdentifier);
|
||||
if (templateWallets) {
|
||||
const startPromises: Promise<void>[] = [];
|
||||
for (const useCaseWallets of Object.values(templateWallets)) {
|
||||
for (const wallet of useCaseWallets) {
|
||||
startPromises.push(wallet.start());
|
||||
}
|
||||
}
|
||||
await Promise.all(startPromises);
|
||||
}
|
||||
|
||||
this.emit('stateUpdated', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all transactions from the blockchain involving any child wallet.
|
||||
* Results are flattened and deduplicated across all templates and use cases.
|
||||
*/
|
||||
async getTransactions() {
|
||||
const fetchPromises = [];
|
||||
|
||||
for (const wallet of this.getAllChildWallets()) {
|
||||
fetchPromises.push(wallet.getTransactions());
|
||||
}
|
||||
|
||||
const txs = await Promise.all(fetchPromises);
|
||||
|
||||
// Flatten and deduplicate transactions across all child wallets.
|
||||
const txsFlattened = ExtMap.flatten(txs);
|
||||
|
||||
// Emit an event to notify that transactions have been updated.
|
||||
this.emit('transactionsUpdated', txsFlattened);
|
||||
|
||||
return txsFlattened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all unspent outputs from the blockchain across all child wallets.
|
||||
*/
|
||||
async getUnspents() {
|
||||
const fetchPromises = [];
|
||||
|
||||
for (const wallet of this.getAllChildWallets()) {
|
||||
fetchPromises.push(wallet.getUnspents());
|
||||
}
|
||||
|
||||
const unspents = await Promise.all(fetchPromises);
|
||||
|
||||
return ExtMap.flatten(unspents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all unspent output directives (with unlocking bytecode) across all child wallets.
|
||||
*/
|
||||
async getUnspentDirectives() {
|
||||
const fetchPromises = [];
|
||||
|
||||
for (const wallet of this.getAllChildWallets()) {
|
||||
fetchPromises.push(wallet.getUnspentDirectives());
|
||||
}
|
||||
|
||||
const unspentDirectives = await Promise.all(fetchPromises);
|
||||
|
||||
return ExtMap.flatten(unspentDirectives);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total satoshi balance across all child wallets.
|
||||
*/
|
||||
async getBalanceSats() {
|
||||
const unspents = await this.getUnspents();
|
||||
|
||||
const utxos = unspents
|
||||
.map((blockchainUTXO) => blockchainUTXO.utxo)
|
||||
.toArray();
|
||||
|
||||
return calculateBalanceSats(utxos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total token balances across all child wallets.
|
||||
*/
|
||||
async getBalanceTokens(): Promise<TokenBalances> {
|
||||
const unspents = await this.getUnspents();
|
||||
|
||||
const utxos = unspents
|
||||
.map((blockchainUTXO) => blockchainUTXO.utxo)
|
||||
.toArray();
|
||||
|
||||
return calculateBalanceTokens(utxos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all addresses belonging to this wallet across all templates and use cases.
|
||||
*/
|
||||
async getAddresses() {
|
||||
const addressPromises = [];
|
||||
|
||||
for (const wallet of this.getAllChildWallets()) {
|
||||
addressPromises.push(wallet.getReceivingAddress());
|
||||
}
|
||||
|
||||
const addresses = await Promise.all(addressPromises);
|
||||
|
||||
return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default receiving address for this wallet.
|
||||
* Returns the address of the first derived child wallet.
|
||||
*
|
||||
* @throws {Error} If no child wallets have been derived yet
|
||||
*/
|
||||
async getReceivingAddress() {
|
||||
const allWallets = this.getAllChildWallets();
|
||||
|
||||
if (allWallets.length === 0) {
|
||||
throw new Error(
|
||||
'No child wallets available. Add a template and start the wallet first.',
|
||||
);
|
||||
}
|
||||
|
||||
// Return the first child wallet's receiving address.
|
||||
return allWallets[0].privateKey.derivePublicKey().deriveAddress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a signed transaction using UTXOs from all child wallets.
|
||||
* The change output is sent to the first child wallet's address.
|
||||
*
|
||||
* @param txTemplate - Partial transaction template with desired outputs
|
||||
* @returns A TransactionBuilder with the compiled transaction
|
||||
*/
|
||||
async signTransaction(
|
||||
txTemplate: Partial<TransactionTemplate>,
|
||||
): Promise<TransactionBuilder> {
|
||||
// Use the first child wallet's address as the change address.
|
||||
const changeAddress = await this.getReceivingAddress();
|
||||
const changeLockingBytecode = changeAddress.toLockscriptBytes();
|
||||
|
||||
// Gather unspent directives from all child wallets.
|
||||
const unspents = await this.getUnspentDirectives();
|
||||
|
||||
// Build the transaction.
|
||||
return TransactionBuilder.build(
|
||||
txTemplate,
|
||||
unspents.toArray(),
|
||||
changeLockingBytecode,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build, sign, and broadcast a transaction using UTXOs from all child wallets.
|
||||
*
|
||||
* @param txTemplate - Partial transaction template with desired outputs
|
||||
* @returns The raw transaction bytes that were broadcast
|
||||
*/
|
||||
async sendTransaction(txTemplate: Partial<TransactionTemplate>) {
|
||||
// Generate the transaction.
|
||||
const generatedTx = await this.signTransaction(txTemplate);
|
||||
|
||||
// Convert the transaction to bytes.
|
||||
const generatedTxBytes = generatedTx.toBytes();
|
||||
|
||||
// Broadcast the transaction to the blockchain.
|
||||
await this.blockchain.broadcastTransaction(generatedTxBytes);
|
||||
|
||||
return generatedTxBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a batch of WalletP2PKH child wallets for a specific template + use case combination.
|
||||
* Each child wallet monitors its own address on the blockchain.
|
||||
*
|
||||
* The derivation path relative to the purpose node is:
|
||||
* {templateIndex}'/{useCaseIndex}'/{childIndex}
|
||||
*
|
||||
* This produces identical keys to engine/crypto's `deriveUseCaseChildNode` when given
|
||||
* the same purpose node, template index, use case index, and child index.
|
||||
*
|
||||
* @param templateIdentifier - The 64-char hex template identifier (used as storage key)
|
||||
* @param templateIndex - The BIP32-compatible template index derived from the identifier
|
||||
* @param useCaseIndex - The use case index (0 = SigningAndSpending, 1 = Encryption)
|
||||
* @param count - Number of child wallets to derive
|
||||
* @param startIndex - Starting child index (default 0)
|
||||
* @returns Array of newly derived WalletP2PKH instances
|
||||
*/
|
||||
async deriveWalletsForUseCase(
|
||||
templateIdentifier: string,
|
||||
templateIndex: number,
|
||||
useCaseIndex: number,
|
||||
count: number,
|
||||
startIndex = 0,
|
||||
): Promise<WalletP2PKH[]> {
|
||||
const newWallets: WalletP2PKH[] = [];
|
||||
|
||||
// Ensure the wallet storage structure exists for this template and all use cases.
|
||||
if (!this.wallets.has(templateIdentifier)) {
|
||||
const useCases: { [useCaseIndex: number]: WalletP2PKH[] } = {};
|
||||
for (const uc of XO_USE_CASES) {
|
||||
useCases[uc] = [];
|
||||
}
|
||||
this.wallets.set(templateIdentifier, useCases);
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const childIndex = startIndex + i;
|
||||
|
||||
// Build the relative derivation path from the purpose node.
|
||||
// This matches engine/crypto's deriveUseCaseChildNode relative path construction:
|
||||
// `${accountOrTemplateIndex}'/${accountOrTemplateUseCaseIndex}'/${useCaseChildIndex}`
|
||||
const relativePath = `${templateIndex}'/${useCaseIndex}'/${childIndex}`;
|
||||
|
||||
// Use the full absolute path as the cache key to avoid collisions.
|
||||
const fullPath = `${XO_PURPOSE_PATH}/${relativePath}`;
|
||||
|
||||
// Derive the private key at this path. The cache avoids re-deriving the same key.
|
||||
const privateKeyHex = await this.cache.getOrSet(fullPath, () => {
|
||||
const childNode = this.purposeNode.deriveRelativePath(relativePath);
|
||||
return childNode.toPrivateKey().toHex();
|
||||
});
|
||||
|
||||
// Create a child P2PKH wallet for this derived private key.
|
||||
const wallet = await WalletP2PKH.from(
|
||||
{
|
||||
activities: this.activities,
|
||||
blockchain: this.blockchain,
|
||||
cache: this.cache,
|
||||
},
|
||||
{ privateKey: privateKeyHex },
|
||||
);
|
||||
|
||||
// Bubble up state-update events from child wallets to this parent wallet.
|
||||
wallet.on('stateUpdated', async () => {
|
||||
this.emit('stateUpdated', null);
|
||||
});
|
||||
|
||||
// Start monitoring the child wallet's address on the blockchain.
|
||||
await wallet.start();
|
||||
|
||||
newWallets.push(wallet);
|
||||
}
|
||||
|
||||
// Append the newly derived wallets to the storage for this template + use case.
|
||||
const templateWallets = this.wallets.get(templateIdentifier)!;
|
||||
templateWallets[useCaseIndex] = [
|
||||
...templateWallets[useCaseIndex],
|
||||
...newWallets,
|
||||
];
|
||||
|
||||
return newWallets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single template across all use cases using address gap logic.
|
||||
* For each use case, derives batches of child wallets and checks their transaction
|
||||
* history. Scanning stops for a use case when `addressGap` consecutive child indexes
|
||||
* produce addresses with no on-chain activity.
|
||||
*
|
||||
* @param templateIdentifier - The 64-char hex template identifier
|
||||
* @param startIndex - Starting child index for scanning (default 0)
|
||||
* @param addressGap - Number of consecutive empty addresses before stopping (default XO_ADDRESS_GAP)
|
||||
*/
|
||||
async scanTemplate(
|
||||
templateIdentifier: string,
|
||||
startIndex = 0,
|
||||
addressGap = XO_ADDRESS_GAP,
|
||||
) {
|
||||
// Validate the template identifier length (must be a 32-byte hex string).
|
||||
if (templateIdentifier.length !== TEMPLATE_IDENTIFIER_HEX_LENGTH) {
|
||||
throw new Error(
|
||||
`Template identifier must be ${TEMPLATE_IDENTIFIER_HEX_LENGTH} hex characters, got ${templateIdentifier.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Compute the BIP32-compatible template index from the identifier.
|
||||
// This uses the same algorithm as engine/crypto's createPathIndexFromHash.
|
||||
console.log('creating path index from template identifier');
|
||||
const templateIndex =
|
||||
createPathIndexFromTemplateIdentifier(templateIdentifier);
|
||||
console.log('path index created');
|
||||
|
||||
// Scan each use case for this template (SigningAndSpending + Encryption).
|
||||
for (const useCaseIndex of XO_USE_CASES) {
|
||||
console.log('scanning use case', useCaseIndex);
|
||||
let currentIndex = startIndex;
|
||||
let emptyAddressCount = 0;
|
||||
|
||||
// Continue scanning until we find 'addressGap' consecutive empty addresses.
|
||||
while (emptyAddressCount < addressGap) {
|
||||
console.log('deriving wallets for use case', useCaseIndex);
|
||||
|
||||
// Derive a batch of wallets for this use case.
|
||||
const batch = await this.deriveWalletsForUseCase(
|
||||
templateIdentifier,
|
||||
templateIndex,
|
||||
useCaseIndex,
|
||||
addressGap,
|
||||
currentIndex,
|
||||
);
|
||||
console.log('batch derived', typeof batch, batch.length);
|
||||
|
||||
// Check the transaction history of each wallet in the batch.
|
||||
const results = await Promise.all(
|
||||
batch.map((wallet) => wallet.getTransactions()),
|
||||
);
|
||||
console.log('results', typeof results, results.length);
|
||||
|
||||
// Process results: reset empty count on activity, increment on empty.
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i].size > 0) {
|
||||
// This address has transactions; reset the empty address counter.
|
||||
emptyAddressCount = 0;
|
||||
} else {
|
||||
// This address is empty; increment the consecutive empty counter.
|
||||
emptyAddressCount++;
|
||||
}
|
||||
|
||||
currentIndex++;
|
||||
|
||||
// If we've hit the address gap threshold, stop scanning this use case.
|
||||
if (emptyAddressCount >= addressGap) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('stateUpdated', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all child WalletP2PKH instances across all templates and use cases
|
||||
* into a single flat array. Used by aggregate methods to operate over every
|
||||
* derived wallet uniformly.
|
||||
*
|
||||
* @returns Flat array of all child WalletP2PKH instances
|
||||
*/
|
||||
private getAllChildWallets(): WalletP2PKH[] {
|
||||
const allWallets: WalletP2PKH[] = [];
|
||||
|
||||
for (const templateWallets of this.wallets.values()) {
|
||||
for (const useCaseWallets of Object.values(templateWallets)) {
|
||||
allWallets.push(...useCaseWallets);
|
||||
}
|
||||
}
|
||||
|
||||
return allWallets;
|
||||
}
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Factory Types/Functions
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export type WalletXOFactory<T extends WalletXO = WalletXO> = (
|
||||
services: WalletDependencies,
|
||||
genesisData: WalletXOGenesisData,
|
||||
) => Promise<T>;
|
||||
|
||||
export async function useWalletXO<T extends WalletXO = WalletXO>(
|
||||
services: WalletDependencies,
|
||||
genesisData: WalletXOGenesisData,
|
||||
): Promise<T> {
|
||||
return (await WalletXO.from(services, genesisData)) as T;
|
||||
}
|
||||
Reference in New Issue
Block a user