Initial Commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
|
*.sqlite*
|
||||||
1356
package-lock.json
generated
Normal file
1356
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "xpub-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"author": "",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@xo/stack": "file:../stack",
|
||||||
|
"@xocash/stack": "file:../stack/packages/stack",
|
||||||
|
"dbug": "^0.4.2",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/debug": "^4.1.13",
|
||||||
|
"@types/node": "^25.8.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"tsx": "^4.22.1",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/index.ts
Normal file
102
src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { BlockchainElectrum, StorageSQLite } from '@xocash/stack';
|
||||||
|
|
||||||
|
import { WalletHDWatch, type WalletHDWatchGenesisData } from './xo-extensions/index.js';
|
||||||
|
import { WalletRoutes } from './routes/wallet.js';
|
||||||
|
import { HTTPService } from './services/router.js';
|
||||||
|
import Debug from 'debug';
|
||||||
|
|
||||||
|
export type AppDependencies = {
|
||||||
|
blockchain: BlockchainElectrum;
|
||||||
|
wallet: WalletHDWatch;
|
||||||
|
httpService: HTTPService;
|
||||||
|
debug: Debug.Debugger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eh, this is just a temp function. CBF making this nice right now, and doesnt hurt to leave this in.
|
||||||
|
const getCurrentRamUsage = () => {
|
||||||
|
const memoryUsage = process.memoryUsage();
|
||||||
|
return memoryUsage.heapUsed / 1024 / 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class App {
|
||||||
|
static async create(genesisData: WalletHDWatchGenesisData) {
|
||||||
|
const debug = Debug('xpub');
|
||||||
|
|
||||||
|
debug('Creating storage...');
|
||||||
|
const storage = await StorageSQLite.createOrOpen('xpub-backend.sqlite');
|
||||||
|
|
||||||
|
debug('Creating blockchain cache...');
|
||||||
|
const blockchainCache = await storage.createOrGetStore('blockchain', {
|
||||||
|
syncInMemory: false
|
||||||
|
});
|
||||||
|
|
||||||
|
debug('Creating wallet cache...');
|
||||||
|
const walletCache = await storage.createOrGetStore('wallet', {
|
||||||
|
syncInMemory: false
|
||||||
|
});
|
||||||
|
|
||||||
|
debug('Creating blockchain...');
|
||||||
|
const blockchain = await BlockchainElectrum.from({
|
||||||
|
store: blockchainCache,
|
||||||
|
servers: [ 'cashnode.bch.ninja' ]
|
||||||
|
});
|
||||||
|
|
||||||
|
debug('Creating wallet...');
|
||||||
|
const wallet = await WalletHDWatch.from({
|
||||||
|
blockchain,
|
||||||
|
cache: walletCache
|
||||||
|
}, genesisData);
|
||||||
|
|
||||||
|
debug('Creating Routes...');
|
||||||
|
const routes = new WalletRoutes(wallet);
|
||||||
|
|
||||||
|
debug('Creating Router...');
|
||||||
|
const router = new HTTPService({ routes: [routes], debug });
|
||||||
|
|
||||||
|
debug('Creating App...');
|
||||||
|
const app = new App({ blockchain, wallet, httpService: router, debug });
|
||||||
|
|
||||||
|
await app.start();
|
||||||
|
return app;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(private readonly deps: AppDependencies) {}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.deps.debug('Starting app...');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
// Start the blockchain
|
||||||
|
this.deps.blockchain.start()
|
||||||
|
.then(() => this.deps.debug('Blockchain started')),
|
||||||
|
|
||||||
|
// Start the wallet
|
||||||
|
this.deps.wallet.start()
|
||||||
|
.then(() => this.deps.debug('Wallet started')),
|
||||||
|
|
||||||
|
// Start the router
|
||||||
|
this.deps.httpService.start()
|
||||||
|
.then(() => this.deps.debug('Router started')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.deps.debug(`Current RAM usage: ${getCurrentRamUsage()} MB`);
|
||||||
|
this.deps.debug('App started');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The XPUB used by isMikeKomaranskyAlive.com.
|
||||||
|
const MIKE_KOMARANSKY_ALIVE_XPUB = 'xpub6D7LhEKSJwQV3eNZ6MN56wnazz3oEgoJtEDG2fpH6bpvBjUoSQFYNmigvD6Coj3HadQz4CqiNcCDDKKMmAjtcXf9zS37dFTxUxaDTzji8PQ';
|
||||||
|
|
||||||
|
// The derivation path used by isMikeKomaranskyAlive.com.
|
||||||
|
const MIKE_KOMARANSKY_ALIVE_DERIVATION_PATH = "m/44'/145'/0'";
|
||||||
|
|
||||||
|
// Create the genesis data for the app.
|
||||||
|
const genesisData: WalletHDWatchGenesisData = {
|
||||||
|
type: 'WalletHDWatch',
|
||||||
|
xpub: MIKE_KOMARANSKY_ALIVE_XPUB,
|
||||||
|
derivationPath: MIKE_KOMARANSKY_ALIVE_DERIVATION_PATH,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the app.
|
||||||
|
export const app = await App.create(genesisData);
|
||||||
0
src/routes/index.ts
Normal file
0
src/routes/index.ts
Normal file
158
src/routes/wallet.ts
Normal file
158
src/routes/wallet.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
import { Address, Bytes } from '@xocash/stack';
|
||||||
|
import { binToUtf8, disassembleBytecodeBCH, hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
import { toCsv } from '../utils/to-csv.js';
|
||||||
|
import type { WalletHDWatch } from '../xo-extensions/wallet-hd-watch.js';
|
||||||
|
|
||||||
|
export class WalletRoutes {
|
||||||
|
private wallet: WalletHDWatch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for the WalletRoutes class
|
||||||
|
* @param wallet - The wallet to get the transactions from
|
||||||
|
*/
|
||||||
|
constructor(wallet: WalletHDWatch) {
|
||||||
|
this.wallet = wallet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all of the routes for the wallet
|
||||||
|
* @returns The routes for the wallet
|
||||||
|
*/
|
||||||
|
async getRoutes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
url: '/transactions',
|
||||||
|
handler: this.getTransactions.bind(this),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
url: '/addresses',
|
||||||
|
handler: this.getAddresses.bind(this),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all of the transactions from the wallet
|
||||||
|
* @param wallet - The wallet to get the transactions from
|
||||||
|
* @returns The transactions from the wallet
|
||||||
|
*/
|
||||||
|
static async getWalletTransactions(wallet: WalletHDWatch) {
|
||||||
|
// Once the wallet has fully synced, we want to save all the transactions to a csv file
|
||||||
|
// start syncs the wallet, so we can just save it now
|
||||||
|
const transactions = await wallet.getTransactions();
|
||||||
|
|
||||||
|
const formattedTransactions = transactions.toArray().map(tx => {
|
||||||
|
// Get all of the outputs from the transaction
|
||||||
|
const outputs = tx.transaction.getOutputs();
|
||||||
|
|
||||||
|
// Get all of the locking bytecodes from the outputs
|
||||||
|
const lockingBytecodes = outputs.map(output => Bytes.from(output.lockingBytecode).toHex());
|
||||||
|
|
||||||
|
// Find the op_return locking bytecode
|
||||||
|
const op_return = lockingBytecodes.find(lockingBytecode => lockingBytecode.startsWith('6a'));
|
||||||
|
|
||||||
|
// If there is no op_return, return
|
||||||
|
if (!op_return) return;
|
||||||
|
|
||||||
|
// Disassemble the op_return locking bytecode, grabbing the timestamp and heart rate
|
||||||
|
const [_script, _op_return, timestamp, _op_push, heartRate] = disassembleBytecodeBCH(hexToBin(op_return)).split(' ');
|
||||||
|
|
||||||
|
// If there is no timestamp or heart rate, return
|
||||||
|
if (!timestamp || !heartRate) return;
|
||||||
|
|
||||||
|
// Strip the 0x prefix from the timestamp and heart rate
|
||||||
|
const strippedTimestamp = timestamp.replace('0x', '');
|
||||||
|
const strippedHeartRate = heartRate.replace('0x', '');
|
||||||
|
|
||||||
|
// Get the sats output (need to rethink this, should probably make sure its the one using p2pkh)
|
||||||
|
const addressOutput = outputs[1]
|
||||||
|
if (!addressOutput) return;
|
||||||
|
|
||||||
|
// Convert the address output to a cash address
|
||||||
|
const address = Address.fromLockscriptBytes(addressOutput.lockingBytecode).toCashAddr('bitcoincash');
|
||||||
|
|
||||||
|
// Return the transaction data
|
||||||
|
return {
|
||||||
|
address: address,
|
||||||
|
hash: tx.hash.toHex(),
|
||||||
|
timestamp: Number(binToUtf8(hexToBin(strippedTimestamp))),
|
||||||
|
heartRate: Number(binToUtf8(hexToBin(strippedHeartRate))),
|
||||||
|
op_return: op_return,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Filter out any transactions that don't have an address
|
||||||
|
.filter(tx => tx !== undefined);
|
||||||
|
|
||||||
|
// Sort the transactions by timestamp (Timestamp was pulled from the data inside the op_return)
|
||||||
|
const sortedTransactions = formattedTransactions.sort((a, b) => a?.timestamp - b?.timestamp);
|
||||||
|
|
||||||
|
// Return the sorted transactions
|
||||||
|
return sortedTransactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all of the transactions from the wallet and return them as a csv file
|
||||||
|
* @param req - The request object
|
||||||
|
* @param res - The response object
|
||||||
|
* @returns The transactions from the wallet as a csv file
|
||||||
|
*/
|
||||||
|
async getTransactions(_req: FastifyRequest, res: FastifyReply) {
|
||||||
|
// Get all of the transactions from the wallet
|
||||||
|
const sortedTransactions = await WalletRoutes.getWalletTransactions(this.wallet);
|
||||||
|
|
||||||
|
// Convert the transactions to a csv
|
||||||
|
const transactionsCsv = toCsv(sortedTransactions);
|
||||||
|
|
||||||
|
// Set the headers to download the file
|
||||||
|
res.header('Content-Disposition', `attachment; filename="isMikeKomaranskyAlive-transactions-${new Date().toDateString()}.csv"`);
|
||||||
|
res.header('Content-Type', 'text/csv');
|
||||||
|
res.header('Content-Length', transactionsCsv.length.toString());
|
||||||
|
|
||||||
|
// Disable caching
|
||||||
|
res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.header('Pragma', 'no-cache');
|
||||||
|
res.header('Expires', '0');
|
||||||
|
|
||||||
|
return res.send(transactionsCsv);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current wallet address, locking bytecode, and derivation index based on the current time.
|
||||||
|
* @returns The current wallet address, locking bytecode, and derivation index based on the current time.
|
||||||
|
*/
|
||||||
|
async getAddresses(_req: FastifyRequest, res: FastifyReply) {
|
||||||
|
// Get all of the transactions from the wallet
|
||||||
|
const sortedTransactions = await WalletRoutes.getWalletTransactions(this.wallet);
|
||||||
|
|
||||||
|
// Then create a set to get all of the unique addresses
|
||||||
|
const addresses = Array.from(new Set(sortedTransactions.map(tx => tx.address)));
|
||||||
|
|
||||||
|
// Set the headers to download the file
|
||||||
|
res.header('Content-Disposition', `attachment; filename="isMikeKomaranskyAlive-addresses-${new Date().toDateString()}.csv"`);
|
||||||
|
res.header('Content-Type', 'text/csv');
|
||||||
|
res.header('Content-Length', addresses.length.toString());
|
||||||
|
|
||||||
|
// Disable caching
|
||||||
|
res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.header('Pragma', 'no-cache');
|
||||||
|
res.header('Expires', '0');
|
||||||
|
|
||||||
|
// Format the addresses for the csv
|
||||||
|
const formattedAddresses = addresses.map(address => {
|
||||||
|
return {
|
||||||
|
address
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert the addresses to a csv
|
||||||
|
const addressesCsv = toCsv(formattedAddresses);
|
||||||
|
|
||||||
|
// Send the csv file
|
||||||
|
return res.send(addressesCsv);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/services/router.ts
Normal file
167
src/services/router.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import fastify, { type FastifyInstance, type RouteOptions } from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import Debug from "debug";
|
||||||
|
import {
|
||||||
|
decodeExtendedJsonObject,
|
||||||
|
encodeExtendedJsonObject,
|
||||||
|
} from "@xocash/stack";
|
||||||
|
|
||||||
|
// Interface to add to our route classes so that we can register them.
|
||||||
|
// NOTE: I hate this pattern. But ExpressJS is odd in that it is structured as a singleton that still needs registration.
|
||||||
|
export interface APIRoutes {
|
||||||
|
getRoutes(): Promise<Array<RouteOptions>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPServiceOptions = {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
routes: Array<APIRoutes>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPServiceDependencies = {
|
||||||
|
debug: debug.Debugger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HTTPService {
|
||||||
|
private readonly debug: debug.Debugger;
|
||||||
|
private readonly server: FastifyInstance;
|
||||||
|
|
||||||
|
private readonly port: number;
|
||||||
|
private readonly host: string;
|
||||||
|
private readonly routes: Array<APIRoutes>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
args: Partial<HTTPServiceOptions & HTTPServiceDependencies>
|
||||||
|
) {
|
||||||
|
// Default values
|
||||||
|
const mergedArgs: HTTPServiceOptions & HTTPServiceDependencies = {
|
||||||
|
debug: Debug(''),
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
routes: [],
|
||||||
|
...args,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { debug, port, host, routes } = mergedArgs;
|
||||||
|
|
||||||
|
// Extend the debug namespace with the http-router namespace
|
||||||
|
this.debug = debug.extend("http-router");
|
||||||
|
|
||||||
|
// Assign the port, host, and routes
|
||||||
|
this.port = port;
|
||||||
|
this.host = host;
|
||||||
|
this.routes = routes;
|
||||||
|
|
||||||
|
this.server = fastify({
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.debug(`Starting on http://${this.host}:${this.port}`);
|
||||||
|
|
||||||
|
// Setup ExtJSON handling.
|
||||||
|
this.handleExtJSON();
|
||||||
|
|
||||||
|
// Setup Error Handling (to give more verbose Zod errors)
|
||||||
|
this.handleErrors();
|
||||||
|
|
||||||
|
// Allow CORS requests. This allows requests from any origin/domain.
|
||||||
|
// Capacitor apps (like XO Wallet) use localhost:3000 as the origin, making it difficult to meaningfully restrict requests by origin for security.
|
||||||
|
await this.server.register(cors);
|
||||||
|
|
||||||
|
// Register your routes here before starting the server
|
||||||
|
this.server.get("/health", async () => {
|
||||||
|
return { status: "ok" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register each route.
|
||||||
|
for (const routes of this.routes) {
|
||||||
|
for (const routeOptions of await routes.getRoutes()) {
|
||||||
|
this.server.route(routeOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.server.ready();
|
||||||
|
|
||||||
|
await this.server.listen({
|
||||||
|
port: this.port,
|
||||||
|
host: this.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.debug(`Started on http://${this.host}:${this.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to access the server instance
|
||||||
|
getInstance(): FastifyInstance {
|
||||||
|
return this.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleErrors() {
|
||||||
|
// Customize our error handler to give better errors.
|
||||||
|
// NOTE: This will nicely format the Zod validation errors.
|
||||||
|
this.server.setErrorHandler((error, _request, reply) => {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const formattedErrors = error.issues.map((issue) => ({
|
||||||
|
path: issue.path.join("."),
|
||||||
|
message: issue.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.debug(`Error: ${error}`);
|
||||||
|
|
||||||
|
return reply.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
error: "Validation Error",
|
||||||
|
details: formattedErrors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debug(`Error: ${error}`);
|
||||||
|
|
||||||
|
// Handle other types of errors
|
||||||
|
reply.status(500).send({ error: "Internal Server Error" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleExtJSON() {
|
||||||
|
// Add onRequest hook to decode requests from ExtJSON
|
||||||
|
this.server.addHook("onRequest", async (request, _reply) => {
|
||||||
|
this.debug(`Request: ${JSON.stringify(request.body)}`);
|
||||||
|
this.debug(`Request URL: ${request.method} ${request.url}`);
|
||||||
|
// Only transform JSON requests
|
||||||
|
if (
|
||||||
|
request.headers["content-type"]?.includes("application/json") &&
|
||||||
|
request.body
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Decode ExtJSON body
|
||||||
|
request.body = decodeExtendedJsonObject(request.body);
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
body: request.body,
|
||||||
|
},
|
||||||
|
"Failed to decode ExtJSON request body",
|
||||||
|
);
|
||||||
|
throw new Error("Invalid JSON in request body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add onSend hook to encode responses to ExtJSON
|
||||||
|
this.server.addHook("onSend", async (_request, reply, payload) => {
|
||||||
|
// Only transform JSON responses
|
||||||
|
if (
|
||||||
|
reply.getHeader("content-type")?.toString().includes("application/json")
|
||||||
|
) {
|
||||||
|
// If payload is a string (already serialized), parse it first
|
||||||
|
const data =
|
||||||
|
typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||||
|
return JSON.stringify(encodeExtendedJsonObject(data));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/utils/to-csv.ts
Normal file
77
src/utils/to-csv.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Represents a flat object that can be serialized to CSV.
|
||||||
|
*/
|
||||||
|
export type CsvRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes a single CSV cell value.
|
||||||
|
*
|
||||||
|
* CSV requires wrapping in double quotes when a value contains:
|
||||||
|
* - comma
|
||||||
|
* - double quote
|
||||||
|
* - line break
|
||||||
|
*
|
||||||
|
* Any double quote inside the value is escaped by doubling it.
|
||||||
|
*
|
||||||
|
* @param value Raw cell value.
|
||||||
|
* @returns CSV-safe string representation of the value.
|
||||||
|
*/
|
||||||
|
function escapeCsvValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = String(value);
|
||||||
|
const escapedValue = normalizedValue.replaceAll('"', '""');
|
||||||
|
const mustQuote = /[",\n\r]/.test(escapedValue);
|
||||||
|
|
||||||
|
return mustQuote ? `"${escapedValue}"` : escapedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a stable ordered list of all keys used by rows.
|
||||||
|
*
|
||||||
|
* This makes sure we keep all fields, even if later objects contain keys
|
||||||
|
* that are not present in the first object.
|
||||||
|
*
|
||||||
|
* @param rows Flat object rows.
|
||||||
|
* @returns Ordered header keys.
|
||||||
|
*/
|
||||||
|
function collectHeaders<T extends CsvRecord>(rows: T[]): string[] {
|
||||||
|
const headers = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
headers.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an array of flat objects to CSV text.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* toCsv([
|
||||||
|
* { name: 'Alice', age: 30 },
|
||||||
|
* { name: 'Bob', age: 28 }
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* @param rows Array of non-nested objects.
|
||||||
|
* @returns CSV string including header row.
|
||||||
|
*/
|
||||||
|
export function toCsv<T extends CsvRecord>(rows: T[]): string {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = collectHeaders(rows);
|
||||||
|
const csvHeaderRow = headers.map((header) => escapeCsvValue(header)).join(',');
|
||||||
|
|
||||||
|
const csvDataRows = rows.map((row) =>
|
||||||
|
headers.map((header) => escapeCsvValue(row[header])).join(','),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [csvHeaderRow, ...csvDataRows].join('\n');
|
||||||
|
}
|
||||||
296
src/xo-extensions/hd-private-node.ts
Normal file
296
src/xo-extensions/hd-private-node.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { Bytes, Mnemonic, PrivateKey } from '@xocash/stack';
|
||||||
|
|
||||||
|
import { HDPublicNode } from './hd-public-node.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
encodeHdPrivateKey,
|
||||||
|
decodeHdPrivateKey,
|
||||||
|
deriveHdPath,
|
||||||
|
deriveHdPathRelative,
|
||||||
|
deriveHdPublicNode,
|
||||||
|
deriveHdPrivateNodeFromSeed,
|
||||||
|
generateRandomBytes,
|
||||||
|
} from '@bitauth/libauth';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DecodedHdKey,
|
||||||
|
HdKeyNetwork,
|
||||||
|
HdPrivateNodeValid,
|
||||||
|
} from '@bitauth/libauth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hierarchically Deterministic Private Node Entity.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* // Import our primitives.
|
||||||
|
* import { Mnemonic, HDPrivateNode } from '@xocash/primitives';
|
||||||
|
*
|
||||||
|
* // Generate a mnemonic.
|
||||||
|
* const mnemonic = Mnemonic.generateRandom();
|
||||||
|
*
|
||||||
|
* // Initialize a HD Private Node from Mnemonic.
|
||||||
|
* const hdPrivateNode = HDPrivateNode.fromMnemonic(mnemonic);
|
||||||
|
*
|
||||||
|
* // Derive the first Private Key.
|
||||||
|
* const privateKey = hdPrivateNode.derivePath("m/44'/145'/0'/0/0").toPrivateKey();
|
||||||
|
*
|
||||||
|
* // Output the BCH Address.
|
||||||
|
* console.log(privateKey.derivePublicKey().deriveAddress().toCashAddr());
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class HDPrivateNode {
|
||||||
|
// The HD Private Node.
|
||||||
|
protected readonly node: HdPrivateNodeValid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a new HD Private Node.
|
||||||
|
*
|
||||||
|
* @param node The HD Private Node.
|
||||||
|
*/
|
||||||
|
protected constructor(node: HdPrivateNodeValid) {
|
||||||
|
this.node = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a HD Private Node from a raw seed.
|
||||||
|
*
|
||||||
|
* @param seed The raw seed bytes.
|
||||||
|
*
|
||||||
|
* @throws {Error} If HD Private Node cannot be created.
|
||||||
|
*
|
||||||
|
* @returns The created HD Private Node.
|
||||||
|
*/
|
||||||
|
public static fromSeed(seed: Uint8Array | string): HDPrivateNode {
|
||||||
|
// Cast from hex if a string was provided.
|
||||||
|
if (typeof seed === 'string') {
|
||||||
|
seed = Bytes.fromHex(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the seed is not 64 bytes, throw an error.
|
||||||
|
if (seed.length !== 64) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to derive Private Node from seed: Seed must be 64 bytes (${seed.length} given)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to derive HD Private Node from the given seed.
|
||||||
|
const deriveResult = deriveHdPrivateNodeFromSeed(seed);
|
||||||
|
|
||||||
|
// If a string is returned, this indicates an error...
|
||||||
|
if (typeof deriveResult === 'string') {
|
||||||
|
throw new Error(deriveResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a new HD Private Node from the given seed.
|
||||||
|
return new this(deriveResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a HD Private Node from the given mnemonic.
|
||||||
|
*
|
||||||
|
* @param mnemonic The mnemonic to use.
|
||||||
|
*
|
||||||
|
* @throws {Error} If HD Private Node cannot be created.
|
||||||
|
*
|
||||||
|
* @returns The created HD Private Node.
|
||||||
|
*/
|
||||||
|
public static fromMnemonic(mnemonic: Mnemonic): HDPrivateNode {
|
||||||
|
// Return a new HDPrivateNode from the given seed.
|
||||||
|
return this.fromSeed(mnemonic.toSeed());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a HD Private Node from the given XPriv Key.
|
||||||
|
*
|
||||||
|
* @param xpriv The XPriv Key.
|
||||||
|
*
|
||||||
|
* @throws {Error} If HD Private Node cannot be created.
|
||||||
|
*
|
||||||
|
* @returns The created HD Private Node.
|
||||||
|
*/
|
||||||
|
public static fromXPriv(xpriv: string): HDPrivateNode {
|
||||||
|
// Attempt to decode the XPriv Key.
|
||||||
|
const decodeResult = decodeHdPrivateKey(xpriv);
|
||||||
|
|
||||||
|
// If a string is returned, this indicates an error...
|
||||||
|
if (typeof decodeResult === 'string') {
|
||||||
|
throw new Error(decodeResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a new HD Private Node from the given XPriv.
|
||||||
|
return new this(decodeResult.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a HD Private Node from raw node data.
|
||||||
|
*
|
||||||
|
* @param node The raw node data.
|
||||||
|
*
|
||||||
|
* @returns The created HD Private Node.
|
||||||
|
*/
|
||||||
|
public static fromRaw(node: HdPrivateNodeValid) {
|
||||||
|
return new this(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a random HD Private Node.
|
||||||
|
*
|
||||||
|
* @throws {Error} If HD Private Node cannot be created.
|
||||||
|
*
|
||||||
|
* @returns The created HD Private Node.
|
||||||
|
*/
|
||||||
|
public static generateRandom(): HDPrivateNode {
|
||||||
|
// Attempt to derive HD Private Node from randomly generated bytes.
|
||||||
|
const deriveResult = deriveHdPrivateNodeFromSeed(generateRandomBytes(32));
|
||||||
|
|
||||||
|
// If a string is returned, this indicates an error...
|
||||||
|
if (typeof deriveResult === 'string') {
|
||||||
|
throw new Error(deriveResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the new HD Private Node from the random bytes.
|
||||||
|
return new this(deriveResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given XPriv string is a valid HD Private Key.
|
||||||
|
*
|
||||||
|
* @param xpriv The XPriv string to check.
|
||||||
|
*
|
||||||
|
* @returns True if the XPriv string is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
public static isXPriv(xpriv: string): boolean {
|
||||||
|
try {
|
||||||
|
this.fromXPriv(xpriv);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Private Key of this node (as a Uint8Array).
|
||||||
|
*
|
||||||
|
* @returns The Node's Private Key (as a Uint8Array).
|
||||||
|
*/
|
||||||
|
public toBytes(): Bytes {
|
||||||
|
// Encode the XPriv from our node info.
|
||||||
|
return Bytes.from(this.node.privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the Private Key entity for this node.
|
||||||
|
*
|
||||||
|
* @returns The Private Key Entity for this node.
|
||||||
|
*/
|
||||||
|
public toPrivateKey(): PrivateKey {
|
||||||
|
// Return a Private Key Entity from the private key of our node.
|
||||||
|
return PrivateKey.fromRaw(this.node.privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts this node to an XPriv Key for export.
|
||||||
|
*
|
||||||
|
* @param network {HdKeyNetwork} The network to encode for.
|
||||||
|
*
|
||||||
|
* @returns The XPriv Key.
|
||||||
|
*/
|
||||||
|
public toXPriv(network: HdKeyNetwork = 'mainnet'): string {
|
||||||
|
// Create our node info structure.
|
||||||
|
const nodeInfo: DecodedHdKey<HdPrivateNodeValid> = {
|
||||||
|
network: network,
|
||||||
|
node: this.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encode the XPriv from our node info.
|
||||||
|
return encodeHdPrivateKey(nodeInfo).hdPrivateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the XPriv string for this node.
|
||||||
|
*
|
||||||
|
* @returns The XPriv string for this node.
|
||||||
|
*/
|
||||||
|
public toString(): string {
|
||||||
|
// Return the Mainnet XPriv.
|
||||||
|
return this.toXPriv();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw node data.
|
||||||
|
*
|
||||||
|
* @returns The raw node data.
|
||||||
|
*/
|
||||||
|
public toRaw(): HdPrivateNodeValid {
|
||||||
|
return this.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a HD Public Node from this Private Node.
|
||||||
|
*
|
||||||
|
* @returns The derived HD Public Node
|
||||||
|
*/
|
||||||
|
public deriveHDPublicNode(): HDPublicNode {
|
||||||
|
// Derive a HD Public Node from this HD Private Node.
|
||||||
|
const publicNode = deriveHdPublicNode(this.node);
|
||||||
|
|
||||||
|
// Return a new HD Public Node entity from our derived Public Node.
|
||||||
|
return HDPublicNode.fromRaw(publicNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a HD Private Node from the given BIP32 path.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
*
|
||||||
|
* This method automatically detects whether the path is absolute (starts with 'm/')
|
||||||
|
* or relative (e.g. '0/1') and uses the appropriate derivation method.
|
||||||
|
*
|
||||||
|
* @param path {string} The BIP32 Path (e.g. m/44'/145'/0'/0/1 or 0/1).
|
||||||
|
*
|
||||||
|
* @throws {Error} If HD Private Node cannot be derived.
|
||||||
|
*
|
||||||
|
* @returns The derived HD Private Node
|
||||||
|
*/
|
||||||
|
public derivePath(path: string): HDPrivateNode {
|
||||||
|
// Determine if this is an absolute path (starts with 'm' or 'M').
|
||||||
|
const isAbsolutePath = path.startsWith('m') || path.startsWith('M');
|
||||||
|
|
||||||
|
// Use the appropriate derivation function.
|
||||||
|
const derivePathResult = isAbsolutePath
|
||||||
|
? deriveHdPath(this.node, path)
|
||||||
|
: deriveHdPathRelative(this.node, path);
|
||||||
|
|
||||||
|
// If a string is returned, this indicates an error...
|
||||||
|
if (typeof derivePathResult === 'string') {
|
||||||
|
throw new Error(derivePathResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a new HD Private Node Entity derived from the given path.
|
||||||
|
return new HDPrivateNode(derivePathResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a HD Private Node from the given relative BIP32 path.
|
||||||
|
*
|
||||||
|
* @param path {string} The BIP32 Path (e.g. 0/0).
|
||||||
|
*
|
||||||
|
* @throws {Error} If HD Private Node cannot be derived.
|
||||||
|
*
|
||||||
|
* @returns The derived HD Private Node
|
||||||
|
*/
|
||||||
|
public deriveRelativePath(path: string): HDPrivateNode {
|
||||||
|
// Attempt to derive the given path.
|
||||||
|
const derivePathResult = deriveHdPathRelative(this.node, path);
|
||||||
|
|
||||||
|
// If a string is returned, this indicates an error...
|
||||||
|
if (typeof derivePathResult === 'string') {
|
||||||
|
throw new Error(derivePathResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a new HD Private Node Entity derived from the given path.
|
||||||
|
return new HDPrivateNode(derivePathResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/xo-extensions/hd-public-node.ts
Normal file
175
src/xo-extensions/hd-public-node.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { PublicKey } from '@xocash/stack';
|
||||||
|
|
||||||
|
import {
|
||||||
|
encodeHdPublicKey,
|
||||||
|
decodeHdPublicKey,
|
||||||
|
deriveHdPath,
|
||||||
|
deriveHdPathRelative,
|
||||||
|
} from '@bitauth/libauth';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DecodedHdKey,
|
||||||
|
HdKeyNetwork,
|
||||||
|
HdPublicNodeValid,
|
||||||
|
} from '@bitauth/libauth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hierarchically Deterministic Public Node Entity.
|
||||||
|
*/
|
||||||
|
export class HDPublicNode {
|
||||||
|
// The HD Public Node.
|
||||||
|
protected readonly node: HdPublicNodeValid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a new HD Public Node.
|
||||||
|
*
|
||||||
|
* @param node {HdPublicNode} The HD Public Node.
|
||||||
|
*/
|
||||||
|
protected constructor(node: HdPublicNodeValid) {
|
||||||
|
this.node = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a HD Public Node from the given XPriv Key.
|
||||||
|
*
|
||||||
|
* @param xpub {string} The XPriv Key.
|
||||||
|
*
|
||||||
|
* @throws {Error} If HD Public Node cannot be created.
|
||||||
|
*
|
||||||
|
* @returns {HDPrivateNode} The created HD Public Node.
|
||||||
|
*/
|
||||||
|
public static fromXPub(xpub: string): HDPublicNode {
|
||||||
|
// Attempt to decode the XPub Key.
|
||||||
|
const decodeResult = decodeHdPublicKey(xpub);
|
||||||
|
|
||||||
|
// If a string is returned, this indicates an error...
|
||||||
|
if (typeof decodeResult === 'string') {
|
||||||
|
throw new Error(decodeResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a new HD Public Node from the given XPub.
|
||||||
|
return new HDPublicNode(decodeResult.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a HDPublicNode instance from a raw object representation (LibAuth's HdPublicNodeValid).
|
||||||
|
*
|
||||||
|
* @remarks This method is UNSAFE and MUST only be used where input is already verified or trusted.
|
||||||
|
*
|
||||||
|
* @param obj - LibAuth Object of type HdPublicNodeValid
|
||||||
|
* @returns A new HDPublicNode instance
|
||||||
|
*/
|
||||||
|
public static fromRaw(node: HdPublicNodeValid) {
|
||||||
|
return new this(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static isXPub(xpub: string): boolean {
|
||||||
|
try {
|
||||||
|
this.fromXPub(xpub);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the Public Key Entity for this node.
|
||||||
|
*
|
||||||
|
* @returns The Public Key Entity for this node.
|
||||||
|
*/
|
||||||
|
public toPublicKey(): PublicKey {
|
||||||
|
// Return a Public Key Entity from the public key of our node.
|
||||||
|
return PublicKey.fromRaw(this.node.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts this node to an XPub Key for export.
|
||||||
|
*
|
||||||
|
* @param network The network to encode for.
|
||||||
|
*
|
||||||
|
* @returns The XPub Key.
|
||||||
|
*/
|
||||||
|
public toXPub(network: HdKeyNetwork = 'mainnet'): string {
|
||||||
|
// Create our node info structure.
|
||||||
|
const nodeInfo = {
|
||||||
|
network: network,
|
||||||
|
node: this.node,
|
||||||
|
} as DecodedHdKey<HdPublicNodeValid>;
|
||||||
|
|
||||||
|
// Encode the XPub from our node info.
|
||||||
|
return encodeHdPublicKey(nodeInfo).hdPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the XPub string for this node.
|
||||||
|
*
|
||||||
|
* @returns {string} The XPub string for this node.
|
||||||
|
*/
|
||||||
|
public toString(): string {
|
||||||
|
// Return the Mainnet XPub.
|
||||||
|
return this.toXPub();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the HDPublicKey to its raw Libauth representation (HDPublicNodeValid).
|
||||||
|
*
|
||||||
|
* @returns A LibAuth HDPublicNodeValid object
|
||||||
|
*/
|
||||||
|
public toRaw(): HdPublicNodeValid {
|
||||||
|
return { ...this.node };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a HD Public Node from the given BIP32 path.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
*
|
||||||
|
* This method automatically detects whether the path is absolute (starts with 'm/')
|
||||||
|
* or relative (e.g. '0/1') and uses the appropriate derivation method.
|
||||||
|
*
|
||||||
|
* @param path The BIP32 Path (e.g. m/44'/145'/0'/0/1 or 0/1).
|
||||||
|
*
|
||||||
|
* @throws {Error} If HD Public Node cannot be derived.
|
||||||
|
*
|
||||||
|
* @returns The derived HD Public Node
|
||||||
|
*/
|
||||||
|
public derivePath(path: string): HDPublicNode {
|
||||||
|
// Determine if this is an absolute path (starts with 'm' or 'M').
|
||||||
|
const isAbsolutePath = path.startsWith('m') || path.startsWith('M');
|
||||||
|
|
||||||
|
// Use the appropriate derivation function.
|
||||||
|
const derivePathResult = isAbsolutePath
|
||||||
|
? deriveHdPath(this.node, path)
|
||||||
|
: deriveHdPathRelative(this.node, path);
|
||||||
|
|
||||||
|
// If a string is returned, this indicates an error...
|
||||||
|
if (typeof derivePathResult === 'string') {
|
||||||
|
throw new Error(derivePathResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a new HD Public Node Entity derived from the given path.
|
||||||
|
return new HDPublicNode(derivePathResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a HD Public Node from the given relative BIP32 path.
|
||||||
|
*
|
||||||
|
* @param path The relative BIP32 Path (e.g. 0/0 or 0/1).
|
||||||
|
*
|
||||||
|
* @throws {Error} If HD Public Node cannot be derived.
|
||||||
|
*
|
||||||
|
* @returns The derived HD Public Node
|
||||||
|
*/
|
||||||
|
public deriveRelativePath(path: string): HDPublicNode {
|
||||||
|
// Attempt to derive the given relative path.
|
||||||
|
const derivePathResult = deriveHdPathRelative(this.node, path);
|
||||||
|
|
||||||
|
// If a string is returned, this indicates an error...
|
||||||
|
if (typeof derivePathResult === 'string') {
|
||||||
|
throw new Error(derivePathResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a new HD Public Node Entity derived from the given path.
|
||||||
|
return new HDPublicNode(derivePathResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/xo-extensions/index.ts
Normal file
4
src/xo-extensions/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './wallet-hd-watch.js';
|
||||||
|
export * from './wallet-p2pkh-watch.js';
|
||||||
|
export * from './hd-public-node.js';
|
||||||
|
export * from './hd-private-node.js';
|
||||||
525
src/xo-extensions/wallet-hd-watch.ts
Normal file
525
src/xo-extensions/wallet-hd-watch.ts
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
import {
|
||||||
|
BaseWallet,
|
||||||
|
type TransactionTemplate,
|
||||||
|
type WalletBlockchain,
|
||||||
|
type WalletDependencies,
|
||||||
|
TransactionBuilder,
|
||||||
|
ADDRESS_GAP,
|
||||||
|
CHAIN_EXTERNAL,
|
||||||
|
CHAIN_INTERNAL,
|
||||||
|
BaseStore,
|
||||||
|
Activities,
|
||||||
|
StoreInMemory,
|
||||||
|
type DistributiveOmit,
|
||||||
|
type TokenBalances,
|
||||||
|
ExtMap,
|
||||||
|
calculateBalanceSats,
|
||||||
|
calculateBalanceTokens,
|
||||||
|
} from '@xocash/stack';
|
||||||
|
|
||||||
|
import { HDPublicNode } from './hd-public-node.js';
|
||||||
|
import { WalletP2PKHWatch } from './wallet-p2pkh-watch.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for creating a watch-only HD wallet from an xpub.
|
||||||
|
*/
|
||||||
|
export type WalletHDWatchEntropy = { xpub: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derivation data for the HD wallet.
|
||||||
|
*/
|
||||||
|
export type WalletHDWatchDerivationData = {
|
||||||
|
/**
|
||||||
|
* The full derivation path for display purposes.
|
||||||
|
* Example: "m/44'/145'/0'" for an account-level xpub.
|
||||||
|
*/
|
||||||
|
derivationPath: string;
|
||||||
|
|
||||||
|
addressGap?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genesis data for the watch-only HD wallet.
|
||||||
|
*/
|
||||||
|
export type WalletHDWatchGenesisData = {
|
||||||
|
type: 'WalletHDWatch';
|
||||||
|
} & WalletHDWatchEntropy &
|
||||||
|
WalletHDWatchDerivationData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Watch-Only Hierarchical Deterministic (BIP44) Wallet.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
*
|
||||||
|
* This wallet uses an extended public key (xpub) to derive addresses and monitor
|
||||||
|
* transactions. It cannot sign transactions because it does not have access to
|
||||||
|
* private keys.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* // Define services to use.
|
||||||
|
* const services = {
|
||||||
|
* blockchain,
|
||||||
|
* cache,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Instantiate an instance of WalletHDWatch.
|
||||||
|
* const walletHDWatch = await WalletHDWatch.from({
|
||||||
|
* derivationPath: "m/44'/145'/0'",
|
||||||
|
* xpub: 'xpub...'
|
||||||
|
* }, services);
|
||||||
|
*
|
||||||
|
* // Scan for transactions and start monitoring.
|
||||||
|
* await walletHDWatch.start();
|
||||||
|
*
|
||||||
|
* // Get the balance (watch-only).
|
||||||
|
* const balance = await walletHDWatch.getBalanceSats();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class WalletHDWatch extends BaseWallet {
|
||||||
|
/**
|
||||||
|
* Factory method to create a new WalletHDWatch instance.
|
||||||
|
*
|
||||||
|
* @param deps - The wallet dependencies (blockchain, cache, activities).
|
||||||
|
* @param genesisData - The genesis data containing the xpub and derivation info.
|
||||||
|
* @returns A new WalletHDWatch instance.
|
||||||
|
*/
|
||||||
|
static async from<T extends typeof WalletHDWatch>(
|
||||||
|
this: T,
|
||||||
|
deps: WalletDependencies,
|
||||||
|
genesisData: DistributiveOmit<WalletHDWatchGenesisData, 'type'>,
|
||||||
|
) {
|
||||||
|
// Assign defaults if not otherwise provided.
|
||||||
|
const activities = deps.activities
|
||||||
|
? deps.activities
|
||||||
|
: await Activities.from();
|
||||||
|
const cache = deps.cache ? deps.cache : StoreInMemory.from();
|
||||||
|
|
||||||
|
// Assign our final dependencies.
|
||||||
|
const depsFinal = {
|
||||||
|
activities,
|
||||||
|
cache,
|
||||||
|
blockchain: deps.blockchain,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return a new instance of WalletHDWatch.
|
||||||
|
return new WalletHDWatch(depsFinal, genesisData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies.
|
||||||
|
public readonly activities: Activities;
|
||||||
|
public readonly blockchain: WalletBlockchain;
|
||||||
|
public readonly cache: BaseStore;
|
||||||
|
|
||||||
|
// Genesis Data.
|
||||||
|
public readonly genesisData: WalletHDWatchGenesisData;
|
||||||
|
|
||||||
|
// Optimizations/Simplifications.
|
||||||
|
public readonly hdPublicNode: HDPublicNode;
|
||||||
|
|
||||||
|
// Mutable State.
|
||||||
|
public shouldMonitor = false;
|
||||||
|
|
||||||
|
// Store child wallets, grouped by chain ("change") path in BIP44 spec.
|
||||||
|
public wallets: { [chainPath: number]: Array<WalletP2PKHWatch> } = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new WalletHDWatch instance.
|
||||||
|
*
|
||||||
|
* @param dependencies - The required wallet dependencies.
|
||||||
|
* @param genesisData - The genesis data containing the xpub and derivation info.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
dependencies: Required<WalletDependencies>,
|
||||||
|
genesisData: DistributiveOmit<WalletHDWatchGenesisData, 'type'>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// Assign our dependencies.
|
||||||
|
this.activities = dependencies.activities;
|
||||||
|
this.blockchain = dependencies.blockchain;
|
||||||
|
this.cache = dependencies.cache;
|
||||||
|
|
||||||
|
// Assign our state.
|
||||||
|
this.genesisData = {
|
||||||
|
type: 'WalletHDWatch',
|
||||||
|
...genesisData,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Instantiate the HDPublicNode from the xpub in our genesis data.
|
||||||
|
this.hdPublicNode = HDPublicNode.fromXPub(genesisData.xpub);
|
||||||
|
|
||||||
|
// Initialize empty arrays for our wallets.
|
||||||
|
this.wallets[CHAIN_EXTERNAL] = [];
|
||||||
|
this.wallets[CHAIN_INTERNAL] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts monitoring all derived addresses for transaction activity.
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
// Get the address gap.
|
||||||
|
// TODO: This should come from activities.
|
||||||
|
const addressGap = this.genesisData.addressGap || ADDRESS_GAP;
|
||||||
|
|
||||||
|
// Scan the HDWallet for addresses with activity.
|
||||||
|
await this.scan(0, addressGap);
|
||||||
|
|
||||||
|
// Declare storage for our start promises.
|
||||||
|
const startPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
// Iterate over each chain path and start each wallet.
|
||||||
|
for (const chainWallets of Object.values(this.wallets)) {
|
||||||
|
for (const wallet of chainWallets) {
|
||||||
|
startPromises.push(wallet.start());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all of our start promises to complete.
|
||||||
|
await Promise.all(startPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops monitoring all derived addresses.
|
||||||
|
*/
|
||||||
|
async stop() {
|
||||||
|
// Declare storage for our stop promises.
|
||||||
|
const stopPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
// Iterate over each chain path and stop each wallet.
|
||||||
|
for (const chainWallets of Object.values(this.wallets)) {
|
||||||
|
for (const wallet of chainWallets) {
|
||||||
|
stopPromises.push(wallet.stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all of our stop promises to complete.
|
||||||
|
await Promise.all(stopPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all event listeners from this wallet.
|
||||||
|
*/
|
||||||
|
async destroy() {
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all transactions involving any of the watched addresses.
|
||||||
|
*
|
||||||
|
* @returns A map of transactions indexed by their transaction hash.
|
||||||
|
*/
|
||||||
|
async getTransactions() {
|
||||||
|
// Declare storage for our fetch promises.
|
||||||
|
const fetchPromises = [];
|
||||||
|
|
||||||
|
// Iterate over each chain path and fetch transactions for each wallet.
|
||||||
|
for (const chainWallets of Object.values(this.wallets)) {
|
||||||
|
for (const wallet of chainWallets) {
|
||||||
|
fetchPromises.push(wallet.getTransactions());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all of our fetch promises to complete.
|
||||||
|
const txs = await Promise.all(fetchPromises);
|
||||||
|
|
||||||
|
// Flatten (and deduplicate) the transactions.
|
||||||
|
const txsFlattened = ExtMap.flatten(txs);
|
||||||
|
|
||||||
|
// Emit an event to notify that transactions have been updated.
|
||||||
|
this.emit('transactionsUpdated', txsFlattened);
|
||||||
|
|
||||||
|
// Return the deduplicated transactions.
|
||||||
|
return txsFlattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all unspent outputs belonging to any of the watched addresses.
|
||||||
|
*
|
||||||
|
* @returns A map of unspent outputs.
|
||||||
|
*/
|
||||||
|
async getUnspents() {
|
||||||
|
// Declare storage for our fetch promises.
|
||||||
|
const fetchPromises = [];
|
||||||
|
|
||||||
|
// Iterate over each chain path and fetch unspents for each wallet.
|
||||||
|
for (const chainWallets of Object.values(this.wallets)) {
|
||||||
|
for (const wallet of chainWallets) {
|
||||||
|
fetchPromises.push(wallet.getUnspents());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all of our fetch promises to complete.
|
||||||
|
const unspents = await Promise.all(fetchPromises);
|
||||||
|
|
||||||
|
// Flatten them.
|
||||||
|
const unspentsFlattened = ExtMap.flatten(unspents);
|
||||||
|
|
||||||
|
// Return the unspents.
|
||||||
|
return unspentsFlattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws an error because watch-only wallets cannot produce signing directives.
|
||||||
|
*
|
||||||
|
* @throws {Error} Always throws because this is a watch-only wallet.
|
||||||
|
*/
|
||||||
|
async getUnspentDirectives(): Promise<never> {
|
||||||
|
throw new Error(
|
||||||
|
'WalletHDWatch is a watch-only wallet and cannot produce signing directives. ' +
|
||||||
|
'Use WalletHD with a private key or seed phrase to sign transactions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the total satoshi balance across all watched addresses.
|
||||||
|
*
|
||||||
|
* @returns The total balance in satoshis.
|
||||||
|
*/
|
||||||
|
async getBalanceSats() {
|
||||||
|
// Fetch our unspents.
|
||||||
|
const unspents = await this.getUnspents();
|
||||||
|
|
||||||
|
// Get the individual UTXOs.
|
||||||
|
const utxos = unspents.map((blockchainUTXO) => blockchainUTXO.utxo).toArray();
|
||||||
|
|
||||||
|
// Calculate the balance.
|
||||||
|
return calculateBalanceSats(utxos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the token balances across all watched addresses.
|
||||||
|
*
|
||||||
|
* @returns A map of token balances by category.
|
||||||
|
*/
|
||||||
|
async getBalanceTokens(): Promise<TokenBalances> {
|
||||||
|
// Fetch our unspents.
|
||||||
|
const unspents = await this.getUnspents();
|
||||||
|
|
||||||
|
// Get the individual UTXOs.
|
||||||
|
const utxos = unspents.map((blockchainUTXO) => blockchainUTXO.utxo).toArray();
|
||||||
|
|
||||||
|
// Calculate the token balances.
|
||||||
|
return calculateBalanceTokens(utxos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all addresses currently being watched.
|
||||||
|
*
|
||||||
|
* @returns A map of addresses indexed by their lockscript hex.
|
||||||
|
*/
|
||||||
|
async getAddresses() {
|
||||||
|
const chainPaths = [CHAIN_EXTERNAL, CHAIN_INTERNAL];
|
||||||
|
|
||||||
|
const addressPromises = [];
|
||||||
|
|
||||||
|
for (const chainPath of chainPaths) {
|
||||||
|
addressPromises.push(
|
||||||
|
...this.wallets[chainPath].map((wallet) =>
|
||||||
|
wallet.getReceivingAddress(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addresses = await Promise.all(addressPromises);
|
||||||
|
|
||||||
|
return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the next receiving address for this wallet.
|
||||||
|
*
|
||||||
|
* @param chainPath - The chain path to use (default: CHAIN_EXTERNAL).
|
||||||
|
* @returns The receiving address.
|
||||||
|
*/
|
||||||
|
async getReceivingAddress(chainPath = CHAIN_EXTERNAL) {
|
||||||
|
// TODO: Derive a new wallet, not the first one you lazy fuckhead.
|
||||||
|
// You'll probably need to refactor your scan() approach.
|
||||||
|
let receivingWallet = this.wallets[chainPath][0];
|
||||||
|
|
||||||
|
// If there is no receiving wallet, create one.
|
||||||
|
// TODO: This is fucked. You need to rethink deriveWallets.
|
||||||
|
if (!receivingWallet) {
|
||||||
|
this.deriveWallets(1, chainPath);
|
||||||
|
|
||||||
|
receivingWallet = this.wallets[chainPath][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the receiving address of the derived wallet.
|
||||||
|
return receivingWallet.publicKey.deriveAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws an error because watch-only wallets cannot sign transactions.
|
||||||
|
*
|
||||||
|
* @throws {Error} Always throws because this is a watch-only wallet.
|
||||||
|
*/
|
||||||
|
async signTransaction(
|
||||||
|
_txTemplate: Partial<TransactionTemplate>,
|
||||||
|
): Promise<TransactionBuilder> {
|
||||||
|
throw new Error(
|
||||||
|
'WalletHDWatch is a watch-only wallet and cannot sign transactions. ' +
|
||||||
|
'Use WalletHD with a private key or seed phrase to sign transactions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws an error because watch-only wallets cannot send transactions.
|
||||||
|
*
|
||||||
|
* @throws {Error} Always throws because this is a watch-only wallet.
|
||||||
|
*/
|
||||||
|
async sendTransaction(
|
||||||
|
_txTemplate: Partial<TransactionTemplate>,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
throw new Error(
|
||||||
|
'WalletHDWatch is a watch-only wallet and cannot send transactions. ' +
|
||||||
|
'Use WalletHD with a private key or seed phrase to send transactions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a specified number of watch-only wallets from the HD public key.
|
||||||
|
*
|
||||||
|
* @param count - The number of wallets to derive.
|
||||||
|
* @param chainPath - The chain path (0 = external, 1 = internal).
|
||||||
|
* @param startIndex - The starting index for derivation.
|
||||||
|
* @returns An array of newly derived WalletP2PKHWatch instances.
|
||||||
|
*/
|
||||||
|
async deriveWallets(
|
||||||
|
count: number,
|
||||||
|
chainPath = CHAIN_EXTERNAL,
|
||||||
|
startIndex = 0,
|
||||||
|
) {
|
||||||
|
// Create an array to store our wallets.
|
||||||
|
const newWallets: Array<WalletP2PKHWatch> = [];
|
||||||
|
|
||||||
|
// Create the given count of wallets.
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Define the derivation path (relative from the account-level xpub).
|
||||||
|
// The xpub should already be at account level, so we only need chainPath/index.
|
||||||
|
const relativePath = `${chainPath}/${startIndex + i}`;
|
||||||
|
|
||||||
|
// Derive the public key at the given path.
|
||||||
|
const publicKeyHex = await this.cache.getOrSet(relativePath, () => {
|
||||||
|
const childNode = this.hdPublicNode.derivePath(relativePath);
|
||||||
|
return childNode.toPublicKey().toHex();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a child P2PKHWatch wallet for this Public Key.
|
||||||
|
const wallet = await WalletP2PKHWatch.from(
|
||||||
|
{
|
||||||
|
activities: this.activities,
|
||||||
|
blockchain: this.blockchain,
|
||||||
|
cache: this.cache,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
publicKey: publicKeyHex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to wallet-state updates.
|
||||||
|
wallet.on('stateUpdated', async () => {
|
||||||
|
this.emit('stateUpdated', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start monitoring the wallet.
|
||||||
|
wallet.start();
|
||||||
|
|
||||||
|
// Add the wallet to our list of wallets.
|
||||||
|
newWallets.push(wallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set our wallets.
|
||||||
|
this.wallets[chainPath] = [...this.wallets[chainPath], ...newWallets];
|
||||||
|
|
||||||
|
// Return the wallets.
|
||||||
|
return newWallets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the blockchain for addresses with transaction activity.
|
||||||
|
*
|
||||||
|
* @param startIndex - The starting index for scanning.
|
||||||
|
* @param addressGap - The number of consecutive empty addresses before stopping.
|
||||||
|
* @param chainPaths - The chain paths to scan.
|
||||||
|
*/
|
||||||
|
async scan(
|
||||||
|
startIndex = 0,
|
||||||
|
addressGap = 20,
|
||||||
|
chainPaths: Array<number> = [CHAIN_EXTERNAL, CHAIN_INTERNAL],
|
||||||
|
) {
|
||||||
|
// Iterate over each of the provided chain (change) paths.
|
||||||
|
for (const chainPath of chainPaths) {
|
||||||
|
// Array to store active wallet nodes.
|
||||||
|
const wallets: Array<WalletP2PKHWatch> = [];
|
||||||
|
|
||||||
|
let currentIndex = startIndex;
|
||||||
|
let emptyAddressCount = 0;
|
||||||
|
|
||||||
|
// Continue scanning until we find 'addressGap' consecutive empty addresses.
|
||||||
|
while (emptyAddressCount < addressGap) {
|
||||||
|
// Derive a number of wallets equivalent to our addressGap.
|
||||||
|
const batch: Array<WalletP2PKHWatch> = await this.deriveWallets(
|
||||||
|
addressGap,
|
||||||
|
chainPath,
|
||||||
|
currentIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the transaction history of each wallet in the batch.
|
||||||
|
const results = await Promise.all(
|
||||||
|
batch.map((wallet) => wallet.getTransactions()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process the results.
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
if (results[i].size > 0) {
|
||||||
|
// This address has transactions, add it to nodes and reset empty address count.
|
||||||
|
wallets.push(batch[i]);
|
||||||
|
emptyAddressCount = 0;
|
||||||
|
} else {
|
||||||
|
// This address is empty, increment the empty address count.
|
||||||
|
emptyAddressCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIndex++;
|
||||||
|
|
||||||
|
// If we've found 'addressGap' consecutive empty addresses, stop scanning.
|
||||||
|
if (emptyAddressCount >= addressGap) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('stateUpdated', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
// Factory Types/Functions
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory type for creating WalletHDWatch instances.
|
||||||
|
*/
|
||||||
|
export type WalletHDWatchFactory<T extends WalletHDWatch = WalletHDWatch> = (
|
||||||
|
services: WalletDependencies,
|
||||||
|
genesisData: WalletHDWatchGenesisData,
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a new WalletHDWatch instance.
|
||||||
|
*
|
||||||
|
* @param services - The wallet dependencies.
|
||||||
|
* @param genesisData - The genesis data containing the xpub and derivation info.
|
||||||
|
* @returns A new WalletHDWatch instance.
|
||||||
|
*/
|
||||||
|
export async function useWalletHDWatch<
|
||||||
|
T extends WalletHDWatch = WalletHDWatch,
|
||||||
|
>(
|
||||||
|
services: WalletDependencies,
|
||||||
|
genesisData: WalletHDWatchGenesisData,
|
||||||
|
): Promise<T> {
|
||||||
|
return (await WalletHDWatch.from(services, genesisData)) as T;
|
||||||
|
}
|
||||||
459
src/xo-extensions/wallet-p2pkh-watch.ts
Normal file
459
src/xo-extensions/wallet-p2pkh-watch.ts
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
import {
|
||||||
|
BaseWallet,
|
||||||
|
type TransactionTemplate,
|
||||||
|
type WalletBlockchain,
|
||||||
|
type WalletDependencies,
|
||||||
|
TransactionBuilder,
|
||||||
|
type AddressStatusPayload,
|
||||||
|
type BlockchainTransaction,
|
||||||
|
type BaseStore,
|
||||||
|
Activities,
|
||||||
|
StoreInMemory,
|
||||||
|
ExtMap,
|
||||||
|
PublicKey,
|
||||||
|
calculateBalanceSats,
|
||||||
|
calculateBalanceTokens,
|
||||||
|
} from '@xocash/stack';
|
||||||
|
|
||||||
|
import { type Input, binToHex, type Output } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genesis data for a watch-only P2PKH wallet.
|
||||||
|
*/
|
||||||
|
export type WalletP2PKHWatchGenesisData = {
|
||||||
|
type: 'WalletP2PKHWatch';
|
||||||
|
publicKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Watch-Only P2PKH (Pay-to-Public-Key-Hash) Wallet.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
*
|
||||||
|
* This wallet uses a public key to derive an address and monitor transactions.
|
||||||
|
* It cannot sign transactions because it does not have access to the private key.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Instantiate P2PKH Watch Wallet from a public key.
|
||||||
|
* const walletP2PKHWatch = await WalletP2PKHWatch.from(
|
||||||
|
* { blockchain },
|
||||||
|
* { publicKey: '02...' }
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // Start monitoring the balance.
|
||||||
|
* await walletP2PKHWatch.start();
|
||||||
|
*
|
||||||
|
* // Get the balance (watch-only).
|
||||||
|
* const balance = await walletP2PKHWatch.getBalanceSats();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class WalletP2PKHWatch extends BaseWallet {
|
||||||
|
/**
|
||||||
|
* Factory method to create a new WalletP2PKHWatch instance.
|
||||||
|
*
|
||||||
|
* @param deps - The wallet dependencies (blockchain, cache, activities).
|
||||||
|
* @param genesisData - The genesis data containing the public key.
|
||||||
|
* @returns A new WalletP2PKHWatch instance.
|
||||||
|
*/
|
||||||
|
static async from<T extends typeof WalletP2PKHWatch>(
|
||||||
|
this: T,
|
||||||
|
deps: WalletDependencies,
|
||||||
|
genesisData: Omit<WalletP2PKHWatchGenesisData, 'type'>,
|
||||||
|
) {
|
||||||
|
// Assign defaults if not otherwise provided.
|
||||||
|
const activities = deps.activities
|
||||||
|
? deps.activities
|
||||||
|
: await Activities.from();
|
||||||
|
const cache = deps.cache ? deps.cache : StoreInMemory.from();
|
||||||
|
|
||||||
|
// Assign our final dependencies.
|
||||||
|
const depsFinal = {
|
||||||
|
activities,
|
||||||
|
cache,
|
||||||
|
blockchain: deps.blockchain,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return a new instance of WalletP2PKHWatch.
|
||||||
|
const wallet = new this(depsFinal, genesisData) as InstanceType<T>;
|
||||||
|
|
||||||
|
// Return the wallet instance.
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a WalletP2PKHWatch from raw public key bytes.
|
||||||
|
*
|
||||||
|
* @param services - The wallet dependencies.
|
||||||
|
* @param publicKeyBytes - The public key as a Uint8Array.
|
||||||
|
* @returns A new WalletP2PKHWatch instance.
|
||||||
|
*/
|
||||||
|
static fromBytes<T extends typeof WalletP2PKHWatch>(
|
||||||
|
this: T,
|
||||||
|
services: WalletDependencies,
|
||||||
|
publicKeyBytes: Uint8Array,
|
||||||
|
) {
|
||||||
|
// Create a Public Key entity from the provided bytes.
|
||||||
|
const publicKey = PublicKey.fromBytes(publicKeyBytes);
|
||||||
|
|
||||||
|
// Return a new instance of WalletP2PKHWatch.
|
||||||
|
return this.from<T>(services, {
|
||||||
|
publicKey: publicKey.toHex(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a WalletP2PKHWatch from a hex-encoded public key.
|
||||||
|
*
|
||||||
|
* @param services - The wallet dependencies.
|
||||||
|
* @param publicKeyHex - The public key as a hex string.
|
||||||
|
* @returns A new WalletP2PKHWatch instance.
|
||||||
|
*/
|
||||||
|
static fromHex<T extends typeof WalletP2PKHWatch>(
|
||||||
|
this: T,
|
||||||
|
services: WalletDependencies,
|
||||||
|
publicKeyHex: string,
|
||||||
|
) {
|
||||||
|
// Create a Public Key entity from the provided hex.
|
||||||
|
const publicKey = PublicKey.fromHex(publicKeyHex);
|
||||||
|
|
||||||
|
// Return a new instance of WalletP2PKHWatch.
|
||||||
|
return this.from<T>(services, {
|
||||||
|
publicKey: publicKey.toHex(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies.
|
||||||
|
public readonly activities: Activities;
|
||||||
|
public readonly blockchain: WalletBlockchain;
|
||||||
|
public readonly cache: BaseStore;
|
||||||
|
|
||||||
|
// Genesis Data.
|
||||||
|
public readonly genesisData: WalletP2PKHWatchGenesisData;
|
||||||
|
|
||||||
|
// Optimizations/Simplifications.
|
||||||
|
public readonly publicKey: PublicKey;
|
||||||
|
|
||||||
|
// Mutable State.
|
||||||
|
public isStarted = false;
|
||||||
|
public status: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new WalletP2PKHWatch instance.
|
||||||
|
*
|
||||||
|
* @param dependencies - The required wallet dependencies.
|
||||||
|
* @param genesisData - The genesis data containing the public key.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
dependencies: Required<WalletDependencies>,
|
||||||
|
genesisData: Omit<WalletP2PKHWatchGenesisData, 'type'>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// Assign our dependencies.
|
||||||
|
this.activities = dependencies.activities;
|
||||||
|
this.blockchain = dependencies.blockchain;
|
||||||
|
this.cache = dependencies.cache;
|
||||||
|
|
||||||
|
// Assign our Genesis Data.
|
||||||
|
this.genesisData = {
|
||||||
|
type: 'WalletP2PKHWatch',
|
||||||
|
...genesisData,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimizations/Simplifications.
|
||||||
|
this.publicKey = PublicKey.fromHex(genesisData.publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts monitoring this wallet's address for transaction activity.
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
// Do nothing if already started.
|
||||||
|
if (this.isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the receiving address of this wallet so we can monitor it.
|
||||||
|
const receivingAddress = await this.getReceivingAddress();
|
||||||
|
|
||||||
|
// Start monitoring the receiving address.
|
||||||
|
await this.blockchain.subscribeAddress(
|
||||||
|
receivingAddress.toCashAddr(),
|
||||||
|
this.onAddressNotification.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark this wallet as started.
|
||||||
|
this.isStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops monitoring this wallet's address.
|
||||||
|
*/
|
||||||
|
async stop() {
|
||||||
|
// Do nothing if not started.
|
||||||
|
if (!this.isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the receiving address of this wallet so we can stop monitoring it.
|
||||||
|
const receivingAddress = await this.getReceivingAddress();
|
||||||
|
|
||||||
|
// Stop monitoring the receiving address.
|
||||||
|
await this.blockchain.unsubscribeAddress(
|
||||||
|
receivingAddress.toCashAddr(),
|
||||||
|
this.onAddressNotification.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark this wallet as stopped.
|
||||||
|
this.isStarted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all event listeners from this wallet.
|
||||||
|
*/
|
||||||
|
async destroy() {
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all transactions involving this wallet's address.
|
||||||
|
*
|
||||||
|
* @returns A map of transactions indexed by their transaction hash.
|
||||||
|
*/
|
||||||
|
async getTransactions() {
|
||||||
|
// Get this node's address.
|
||||||
|
const address = await this.getReceivingAddress();
|
||||||
|
|
||||||
|
// Get transactions involving this address.
|
||||||
|
const transactions = await this.blockchain.fetchAddressHistory(
|
||||||
|
address.toCashAddr(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Define a function to get a single source output for an input.
|
||||||
|
const getSourceOutput = async (input: Input) => {
|
||||||
|
const sourceTx = await this.blockchain.fetchTransaction(
|
||||||
|
binToHex(input.outpointTransactionHash),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceOutput = sourceTx.getOutputs().at(input.outpointIndex);
|
||||||
|
|
||||||
|
if (!sourceOutput) {
|
||||||
|
throw new Error(`Failed to find source output`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceOutput;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define a function to get all source outputs for a tx.
|
||||||
|
const getSourceOutputs = async (tx: BlockchainTransaction) => {
|
||||||
|
const sourceOutputPromises = tx.transaction
|
||||||
|
.getInputs()
|
||||||
|
// Coinbase transactions do not have source output, so we return null.
|
||||||
|
.map((input) =>
|
||||||
|
getSourceOutput(input).catch(() => {
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (await Promise.all(sourceOutputPromises)).filter(
|
||||||
|
(output): output is Output => output !== undefined,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the source outputs for each of the transactions.
|
||||||
|
const walletTxs = await Promise.all(
|
||||||
|
transactions.toArray().map(async (tx) => ({
|
||||||
|
...tx,
|
||||||
|
sourceOutputs: await getSourceOutputs(tx),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for source outputs to be populated.
|
||||||
|
const walletTxsIndexed = ExtMap.fromArray(walletTxs, (tx) =>
|
||||||
|
tx.hash.toHex(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Merge activity information into a WalletTransaction type.
|
||||||
|
// 2024-11-17: Actually, that might not belong here?
|
||||||
|
|
||||||
|
// Emit an event to notify that transactions have been updated.
|
||||||
|
this.emit('transactionsUpdated', walletTxsIndexed);
|
||||||
|
|
||||||
|
// Return the fetched transactions.
|
||||||
|
return walletTxsIndexed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all unspent outputs belonging to this wallet's address.
|
||||||
|
*
|
||||||
|
* @returns A map of unspent outputs.
|
||||||
|
*/
|
||||||
|
async getUnspents() {
|
||||||
|
// Get this wallet's address.
|
||||||
|
const address = await this.getReceivingAddress();
|
||||||
|
|
||||||
|
// Fetch unspents belonging to this address.
|
||||||
|
const unspents = await this.blockchain.fetchUnspents(address.toCashAddr());
|
||||||
|
|
||||||
|
// TODO: Merge activity information into a WalletUnspent type.
|
||||||
|
|
||||||
|
// Emit an event to notify that unspents have been updated.
|
||||||
|
this.emit('unspentsUpdated', unspents);
|
||||||
|
|
||||||
|
// Return the unspents.
|
||||||
|
return unspents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws an error because watch-only wallets cannot produce signing directives.
|
||||||
|
*
|
||||||
|
* @throws {Error} Always throws because this is a watch-only wallet.
|
||||||
|
*/
|
||||||
|
async getUnspentDirectives(): Promise<never> {
|
||||||
|
throw new Error(
|
||||||
|
'WalletP2PKHWatch is a watch-only wallet and cannot produce signing directives. ' +
|
||||||
|
'Use WalletP2PKH with a private key to sign transactions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the satoshi balance of this wallet.
|
||||||
|
*
|
||||||
|
* @returns The balance in satoshis.
|
||||||
|
*/
|
||||||
|
async getBalanceSats() {
|
||||||
|
// Fetch our unspents.
|
||||||
|
const unspents = await this.getUnspents();
|
||||||
|
|
||||||
|
// Get the individual UTXOs.
|
||||||
|
const utxos = unspents
|
||||||
|
.map((blockchainUTXO) => blockchainUTXO.utxo)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
// Calculate the balance.
|
||||||
|
return calculateBalanceSats(utxos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the token balances of this wallet.
|
||||||
|
*
|
||||||
|
* @returns A map of token balances by category.
|
||||||
|
*/
|
||||||
|
async getBalanceTokens() {
|
||||||
|
// Fetch our unspents.
|
||||||
|
const unspents = await this.getUnspents();
|
||||||
|
|
||||||
|
// Get the individual UTXOs.
|
||||||
|
const utxos = unspents
|
||||||
|
.map((blockchainUTXO) => blockchainUTXO.utxo)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
// Calculate the token balances.
|
||||||
|
return calculateBalanceTokens(utxos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all addresses belonging to this wallet.
|
||||||
|
*
|
||||||
|
* @returns A map of addresses indexed by their lockscript hex.
|
||||||
|
*/
|
||||||
|
async getAddresses() {
|
||||||
|
const addresses = await Promise.all([this.getReceivingAddress()]);
|
||||||
|
|
||||||
|
return ExtMap.fromArray(addresses, (address) => address.toLockscriptHex());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the receiving address for this wallet.
|
||||||
|
*
|
||||||
|
* @returns The address derived from the public key.
|
||||||
|
*/
|
||||||
|
async getReceivingAddress() {
|
||||||
|
return this.publicKey.deriveAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws an error because watch-only wallets cannot sign transactions.
|
||||||
|
*
|
||||||
|
* @throws {Error} Always throws because this is a watch-only wallet.
|
||||||
|
*/
|
||||||
|
async signTransaction(
|
||||||
|
_txTemplate: Partial<TransactionTemplate>,
|
||||||
|
): Promise<TransactionBuilder> {
|
||||||
|
throw new Error(
|
||||||
|
'WalletP2PKHWatch is a watch-only wallet and cannot sign transactions. ' +
|
||||||
|
'Use WalletP2PKH with a private key to sign transactions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws an error because watch-only wallets cannot send transactions.
|
||||||
|
*
|
||||||
|
* @throws {Error} Always throws because this is a watch-only wallet.
|
||||||
|
*/
|
||||||
|
async sendTransaction(
|
||||||
|
_txTemplate: Partial<TransactionTemplate>,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
throw new Error(
|
||||||
|
'WalletP2PKHWatch is a watch-only wallet and cannot send transactions. ' +
|
||||||
|
'Use WalletP2PKH with a private key to send transactions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles notifications when the address status changes.
|
||||||
|
*
|
||||||
|
* @param _address - The address string (unused).
|
||||||
|
* @param payload - The status payload containing the new status.
|
||||||
|
*/
|
||||||
|
private async onAddressNotification(
|
||||||
|
_address: string,
|
||||||
|
payload?: AddressStatusPayload,
|
||||||
|
) {
|
||||||
|
// Not all blockchain adapters will provide a status payload. But if they do, we check if it's the same as our current status.
|
||||||
|
// If it is, we don't want the wallet to try to update its state as it will be the same and is a waste of resources.
|
||||||
|
if (payload && payload.status === this.status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
// Set our new status.
|
||||||
|
this.status = payload.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit an event.
|
||||||
|
this.emit('stateUpdated', this.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
// Factory Types/Functions
|
||||||
|
//-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory type for creating WalletP2PKHWatch instances.
|
||||||
|
*/
|
||||||
|
export type WalletP2PKHWatchFactory<
|
||||||
|
T extends WalletP2PKHWatch = WalletP2PKHWatch,
|
||||||
|
> = (
|
||||||
|
dependencies: WalletDependencies,
|
||||||
|
genesisData: WalletP2PKHWatchGenesisData,
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a new WalletP2PKHWatch instance.
|
||||||
|
*
|
||||||
|
* @param dependencies - The wallet dependencies.
|
||||||
|
* @param genesisData - The genesis data containing the public key.
|
||||||
|
* @returns A new WalletP2PKHWatch instance.
|
||||||
|
*/
|
||||||
|
export async function useWalletP2PKHWatch<
|
||||||
|
T extends WalletP2PKHWatch = WalletP2PKHWatch,
|
||||||
|
>(
|
||||||
|
dependencies: WalletDependencies,
|
||||||
|
genesisData: WalletP2PKHWatchGenesisData,
|
||||||
|
): Promise<T> {
|
||||||
|
return (await WalletP2PKHWatch.from(dependencies, genesisData)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// File Layout
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
|
||||||
|
// Environment Settings
|
||||||
|
"module": "nodenext",
|
||||||
|
"target": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Other Outputs
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
|
||||||
|
// Stricter Typechecking Options
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
|
||||||
|
// Recommended Options
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user